Optimize getting all incoming and outgoing edges for each node and computing the difference in amounts

Hello, I am trying to solve the following problem:

I have transaction data between Accounts (nodes) with each :SENT transaction as a directional relationship between nodes. I would like to compute the balance of each Account by taking the difference of the sums of all incoming and outgoing transactions, but I am running into issues with how long it is taking to run my query. Please bear in mind that I am a Cypher newbie so don't really know what I'm doing :)

The Account node is the only type of node in the database and the SENT relationship is the only type of relationship. The database is not very large, about 50,000 nodes and 70,000 relationships.

My current query is as follows:

MATCH (n)
WITH n
MATCH ()-[incoming]->(l) WHERE l.id = n.id
WITH n, incoming
WITH n, sum(incoming.amount) as sumIn
MATCH (m) -[outgoing]-> () WHERE m.id = n.id
WITH n, outgoing, sumIn
WITH n, sumIn, sum(outgoing.amount) as sumOut
SET n.balance = sumIn - sumOut

But the runtime of this query was more than 80 minutes, which seems quite strange for a relatively small dataset.

First I tried a query like this:

MATCH () -[incoming]-> (n) -[outgoing]-> () 
WITH n, incoming, outgoing
return n.id, count(distinct incoming), count(distinct outgoing), sum(distinct incoming.amount) - sum(distinct outgoing.amount) as balance

But while count() in this case returns the correct number of incoming and outgoing transactions, I have not been able to sum the amounts.
When I run the MATCH query separately as
MATCH () -[incoming]-> (n) and
MATCH (n) -[outgoing-> () I get the correct counts and sums, but as soon as I try to run it together like
MATCH () -[incoming]-> (n) -[outgoing]-> () it returns a much larger number for both incoming and outgoing and it's the same for both.

That prompted me to try the following approach:

MATCH (n)
WITH n, (n)-[:SENT]->() as outgoing, (n)<-[:SENT]-() as incoming
return n.id,count(s),count(r)

Which gets the correct counts, but since outgoing and incoming here are paths, not relationships, I don't know how to access the amount property in this case to sum and calculate the balance.

This led me to the current approach which I listed first, to match all nodes, then match and sum all incoming edges for each node, then match and sum all outgoing edges for each node and set the balance. But the runtime of this is incredibly long.

What am I missing or overthinking? I feel like there must be a better way to accomplish what seems like a relatively common and simple task. Thanks for any input!

Hello @tomas.vrba :slight_smile:

This is a solution:

MATCH (n)
CALL {
    WITH n MATCH (n)-[r]->() RETURN count(r) AS occurrences
    UNION
    WITH n MATCH (n)<-[r]-() RETURN count(r) AS occurrences
}
WITH n, collect(occurrences) AS values
SET n.balance = values[0] - values[1]

To speed-up this query, you can do several things if you are not working on all nodes of the database:

  • specify a label or a relation type
  • specify a UNIQUE CONSTRAINT and use the index created in a WHERE clause

Regards,
Cobra

Fantastic, thanks so much!
This set me on the right path, what I needed were the sums, and I also had to do UNION ALL since some accounts have exactly the same amounts for transactions going in and out, so that wasn't showing up.
But the following query worked with much improved runtime (622ms as opposed to 80 minutes :D )

MATCH (n)
CALL {
    WITH n MATCH (n)-[r]->() RETURN sum(r.amount) AS total
    UNION ALL
    WITH n MATCH (n)<-[r]-() RETURN sum(r.amount) AS total
}
WITH n, collect(total) AS values
WITH n,
CASE WHEN values[0] IS NOT NULL THEN values[0] ELSE 0 END AS totalOut,
CASE WHEN values[1] IS NOT NULL THEN values[1] ELSE 0 END AS totalIn
SET n.balance = totalIn-totalOut
1 Like

Hello, I am trying to reproduce this query for a very similar problem.
The nodes are accounts, that have addresses, and the edges are transactions with some metadata.
The result is always NULL for the balance for each address.

Here is the following query:

MATCH (w:Wallet) - [tx:SENT_TO {at: "at"}] - (Wallet)
CALL {
    WITH w MATCH (w)-[tx:SENT_TO {at: "at"}]->(Wallet)
    RETURN SUM(tx.value) AS total
    UNION ALL
    WITH w MATCH (w)<-[tx:SENT_TO {at: "at"}]-(Wallet) 
    RETURN SUM(tx.value) AS total
}
WITH w, collect(total) AS values
WITH w,
CASE WHEN values[0] IS NOT NULL THEN values[0] ELSE 0 END AS totalOut,
CASE WHEN values[1] IS NOT NULL THEN values[1] ELSE 0 END AS totalIn
RETURN w.address, totalIn, totalOut

Hello @milan.keca :slight_smile:

I created a little dataset and tested your query and everything worked.
Can you share your dataset?
Which version of Neo4j are you using?

Regards,
Cobra

Hello, thanks for testing it out.

Version 4.

Here is the dataset:

[
  {"from": 0, "to": 1, "value": 2000, "at": "at"},
  {"from": 0, "to": 2, "value": 5000, "at": "at"},
  {"from": 1, "to": 5, "value": 1000, "at": "at"},
  {"from": 2, "to": 4, "value": 1000, "at": "at"},
  {"from": 4, "to": 7, "value": 1000, "at": "at"},
  {"from": 5, "to": 12, "value": 200, "at": "at"},
  {"from": 2, "to": 1, "value": 500, "at": "at"}
]

The expected balances are:
0 - -7000
1 - 1500
2 - 3500
4 - 0
5 - 800
7 - 1000
12 - 200

I am getting 0s for all (totalIn - totalOut), meaning totalIn and totalOut are always the same.

I have managed to do it with the following query, but it's slower and returns duplicates:

MATCH (w:Wallet) - [tx:SENT_TO {at: "at"}] - (Wallet)
CALL {
    WITH w MATCH (w) <- [tx:SENT_TO {at: "at"}] - (Wallet)
    RETURN SUM(tx.value) AS value_in
}
CALL {
    WITH w MATCH (w) - [tx:SENT_TO {at: "at"}] -> (Wallet)
    RETURN SUM(tx.value) AS value_out
}
RETURN w.address, value_in, value_out

This query works on my side (Neo4j Enterprise 4.4.5):

CALL {
    MATCH (w:Wallet)-[tx:SENT_TO {at: "at"}]-()
    RETURN w, sum(tx.value) AS total
    UNION
    MATCH (w:Wallet)-[tx:SENT_TO {at: "at"}]->()
    RETURN w, sum(tx.value) AS total
    UNION
    MATCH (w:Wallet)<-[tx:SENT_TO {at: "at"}]-() 
    RETURN w, sum(tx.value) AS total
}
WITH w.address AS address, collect(total) AS values
RETURN 
    address,
    CASE WHEN values[0] IS NOT NULL THEN values[0] ELSE 0 END AS total,
    CASE WHEN values[1] IS NOT NULL THEN values[1] ELSE 0 END AS totalOut,
    CASE WHEN values[2] IS NOT NULL THEN values[2] ELSE 0 END AS totalIn

Hmm, still getting wrong numbers. My version is 4.4-aura, enterprise.

This is the data:

╒═════════════╀══════════════════════════════════════════════════════════════════════╀══════════════╕
β”‚"w"          β”‚"tx"                                                                  β”‚"w2"          β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ══════════════════════════════════════════════════════════════════════β•ͺ══════════════║
β”‚{"address":0}β”‚{"blockchain":"Ethereum","block_number":1,"tx_hash":"1","value":2000,"β”‚{"address":1} β”‚
β”‚             β”‚coin":"test"}                                                         β”‚              β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚{"address":0}β”‚{"blockchain":"Ethereum","block_number":1,"tx_hash":"2","value":5000,"β”‚{"address":2} β”‚
β”‚             β”‚coin":"test"}                                                         β”‚              β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚{"address":1}β”‚{"blockchain":"Ethereum","block_number":1,"tx_hash":"3","value":1000,"β”‚{"address":5} β”‚
β”‚             β”‚coin":"test"}                                                         β”‚              β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚{"address":2}β”‚{"blockchain":"Ethereum","block_number":1,"tx_hash":"4","value":1000,"β”‚{"address":4} β”‚
β”‚             β”‚coin":"test"}                                                         β”‚              β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚{"address":4}β”‚{"blockchain":"Ethereum","block_number":1,"tx_hash":"5","value":1000,"β”‚{"address":7} β”‚
β”‚             β”‚coin":"test"}                                                         β”‚              β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚{"address":5}β”‚{"blockchain":"Ethereum","block_number":1,"tx_hash":"6","value":200,"cβ”‚{"address":12}β”‚
β”‚             β”‚oin":"test"}                                                          β”‚              β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚{"address":2}β”‚{"blockchain":"Ethereum","block_number":1,"tx_hash":"7","value":500,"cβ”‚{"address":1} β”‚
β”‚             β”‚oin":"test"}                                                          β”‚              β”‚

This is the query (your last query)

CALL {
    MATCH (w:Wallet)-[tx:SENT_TO {coin: "test"}]-()
    RETURN w, sum(tx.value) AS total
    UNION
    MATCH (w:Wallet)-[tx:SENT_TO {coin: "test"}]->()
    RETURN w, sum(tx.value) AS total
    UNION
    MATCH (w:Wallet)<-[tx:SENT_TO {coin: "test"}]-() 
    RETURN w, sum(tx.value) AS total
}
WITH w.address AS address, collect(total) AS values
RETURN 
    address,
    CASE WHEN values[0] IS NOT NULL THEN values[0] ELSE 0 END AS total,
    CASE WHEN values[1] IS NOT NULL THEN values[1] ELSE 0 END AS totalOut,
    CASE WHEN values[2] IS NOT NULL THEN values[2] ELSE 0 END AS totalIn

This is the result:

╒═════════╀═══════╀══════════╀═════════╕
β”‚"address"β”‚"total"β”‚"totalOut"β”‚"totalIn"β”‚
β•žβ•β•β•β•β•β•β•β•β•β•ͺ═══════β•ͺ══════════β•ͺ═════════║
β”‚0        β”‚7000   β”‚0         β”‚0        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚1        β”‚3500   β”‚1000      β”‚2500     β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚2        β”‚6500   β”‚1500      β”‚5000     β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚5        β”‚1200   β”‚200       β”‚1000     β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚4        β”‚2000   β”‚1000      β”‚0        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚7        β”‚1000   β”‚0         β”‚0        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚12       β”‚200    β”‚0         β”‚0        

Is there anything I am missing?

I have this result and it's good for me so I don't understand what is your issue :confused:

I have the same results, but those results are not accurate. If you follow through the transactions, it should be:

address     totalIn     totalOut
0                0               7000   
1                2500        1000
2                5000        1500
4                1000        1000
5                1000        200
7                1000.       0
12              200          0  

My bad, it should be UNION ALL instead of UNION in the query:

CALL {
    MATCH (w:Wallet)-[tx:SENT_TO {at: "at"}]-()
    RETURN w, sum(tx.value) AS total
    UNION ALL
    MATCH (w:Wallet)-[tx:SENT_TO {at: "at"}]->()
    RETURN w, sum(tx.value) AS total
    UNION ALL
    MATCH (w:Wallet)<-[tx:SENT_TO {at: "at"}]-() 
    RETURN w, sum(tx.value) AS total
}
WITH w.address AS address, collect(total) AS values
RETURN 
    address,
    CASE WHEN values[0] IS NOT NULL THEN values[0] ELSE 0 END AS total,
    CASE WHEN values[1] IS NOT NULL THEN values[1] ELSE 0 END AS totalOut,
    CASE WHEN values[2] IS NOT NULL THEN values[2] ELSE 0 END AS totalIn

Be aware that UNION ALL should be used instead of UNION because UNION will remove duplicates.

Awesome, thank you!
This seems to work and is very fast.

1 Like

Actually I just noticed an issue.
If you look at the result for addresses 7 and 12.
They have totalOut 1000, and 200 respectively and 0 totalIn, it should be the other way around.

Can't get my head around that one.

We are never sure to have 3 values in the values list so we have to change a bit the query to always have 3 values:

CALL {
    MATCH (w:Wallet)
    OPTIONAL MATCH (w)-[tx:SENT_TO {at: "at"}]-()
    RETURN w, sum(tx.value) AS total
    UNION ALL
    MATCH (w:Wallet)
    OPTIONAL MATCH (w)-[tx:SENT_TO {at: "at"}]->()
    RETURN w, sum(tx.value) AS total
    UNION ALL
    MATCH (w:Wallet)
    OPTIONAL MATCH (w)<-[tx:SENT_TO {at: "at"}]-() 
    RETURN w, sum(tx.value) AS total
}
WITH w.address AS address, collect(total) AS values
RETURN 
    address,
    CASE WHEN values[0] IS NOT NULL THEN values[0] ELSE 0 END AS total,
    CASE WHEN values[1] IS NOT NULL THEN values[1] ELSE 0 END AS totalOut,
    CASE WHEN values[2] IS NOT NULL THEN values[2] ELSE 0 END AS totalIn

Ah yeah, I was just debugging and figured out it has to do something with not having out txs, probably can achieve something similar with reordering the MATCHES in CALL, because of the domain conditions, but thanks.

You helped us a lot.

The only issue with this approach is that it matches all txs, not just txs {at: "at"}, so a lot of 0s are there.
Is there a way to keep the optional match, but filter out wallets with no txs?

1 Like

The logic in the original post is a little off in the case of the first matches not returning a result. In this case, values is going to be single element list with a value representing the incoming count. The logic as is, will map the single value to the totalOut count and totalIn will be zero. this could be fixed with a combination of optional match and coalesce.

In your case, you are returning the sum resulting from the match. From my testing, the sum on a null result will produce a value of zero. As such, you will always get a values list with two values; therefore, I believe you can remove the CASE statements and eliminate the incorrect logic, as it will never be executed.

I will let @Cobra address your other issue.

I think I am going to append my previous statement. Since you are returning 'w' in your return statement, you will not always get a result.

I see you changed the code to use an optional match, so you have it corrected. You should be able to remove the case statements though, as you should always get a list of three elements. For the case of no match, the 'w' value will be null and the 'sum' will be zero.

Some good catches by @glilienfield :slight_smile:

This query should be the right one:

CALL {
    MATCH (w:Wallet)
    OPTIONAL MATCH (w)-[tx:SENT_TO {at: "at"}]-()
    RETURN w, sum(tx.value) AS total
    UNION ALL
    MATCH (w:Wallet)
    OPTIONAL MATCH (w)-[tx:SENT_TO {at: "at"}]->()
    RETURN w, sum(tx.value) AS total
    UNION ALL
    MATCH (w:Wallet)
    OPTIONAL MATCH (w)<-[tx:SENT_TO {at: "at"}]-() 
    RETURN w, sum(tx.value) AS total
}
WITH w.address AS address, collect(total) AS values
WHERE values[0] > 0
RETURN address, values[0] AS total, values[1] AS totalOut, values[2] AS totalIn