Repository navigation
query_graph: WHERE NOT <predicate> on the target node or relationship of a MATCH pattern silently returns 0 rows — same predicate with <>, inside OR, or after WITH returns the right rows #2250
Description
Summary
In query_graph, a WHERE NOT <predicate> that references the target node or the relationship of a MATCH pattern returns 0 rows with no error — even when the predicate is a tautology (NOT b.name = 'zzz' against a graph where no b is named zzz), and even when the query is an aggregate (RETURN count(*) returns zero rows, not one row with 0). The same predicate spelled with <> returns the correct rows, NOT on the source node works, and moving the NOT behind a WITH also works. So this is not "negation unsupported": it is a silent wrong answer for one shape of query.
I checked #1293 first — that one is about inbound anti-joins (NOT EXISTS { (f)<-[:CALLS]-() }); this is plain property negation and I could not find it reported.
Reproduction (public, 3 files, 25 nodes)
pkg/__init__.py (empty)
pkg/core.py
main.py
pkg/core.py:
def alpha():
return 1
def beta():
return alpha() + 1
def gamma():
return alpha() + beta()
def delta_helper():
return gamma()main.py:
from pkg.core import alpha, beta, gamma, delta_helper
def run():
return alpha() + beta() + gamma() + delta_helper()codebase-memory-mcp cli index_repository --repo-path <dir> --name cbmnot --mode fast
codebase-memory-mcp cli query_graph --project cbmnot --format json --query "<query>"
Baseline, so the expected numbers are on the record:
MATCH (a)-[:CALLS]->(b) RETURN a.name, b.name
-> 8 rows: run→alpha, run→beta, run→gamma, run→delta_helper, beta→alpha, gamma→alpha, gamma→beta, delta_helper→gamma
Results
NOT on a single-node pattern is correct in every form:
| query | rows |
|---|---|
MATCH (n:Function) WHERE n.name <> 'alpha' RETURN count(*) |
6 ✅ |
MATCH (n:Function) WHERE NOT n.name = 'alpha' RETURN count(*) |
6 ✅ |
MATCH (n:Function) WHERE NOT (n.name = 'alpha') RETURN count(*) |
6 ✅ |
MATCH (n:Function) WHERE NOT n.name ENDS WITH 'helper' RETURN n.name |
6 ✅ |
MATCH (n:Function) WHERE NOT n.name IN ['alpha','beta'] RETURN n.name |
5 ✅ |
MATCH (n:Function) WHERE NOT n.name STARTS WITH 'a' RETURN n.name |
6 ✅ |
MATCH (n:Function) WHERE NOT n.name CONTAINS 'et' RETURN n.name |
6 ✅ |
NOT on the source node of a relationship pattern is correct:
| query | rows |
|---|---|
MATCH (a)-[:CALLS]->(b) WHERE a.name <> 'run' RETURN a.name, b.name |
4 ✅ |
MATCH (a)-[:CALLS]->(b) WHERE NOT a.name = 'run' RETURN a.name, b.name |
4 ✅ |
MATCH (a:Function)-[:CALLS]->(b:Function) WHERE NOT a.name = 'run' RETURN a.name, b.name |
4 ✅ |
NOT on the target node or the relationship returns nothing, silently:
| query | expected | got |
|---|---|---|
MATCH (a)-[:CALLS]->(b) WHERE b.name <> 'alpha' RETURN a.name, b.name |
5 | 5 ✅ (control) |
MATCH (a)-[:CALLS]->(b) WHERE NOT b.name = 'alpha' RETURN a.name, b.name |
5 | 0 |
MATCH (a:Function)-[:CALLS]->(b:Function) WHERE NOT b.name = 'alpha' RETURN a.name, b.name |
5 | 0 |
MATCH (a)-[:CALLS]->(b) WHERE NOT (b.name = 'alpha') RETURN count(*) |
1 row: 5 |
0 rows |
MATCH (a)-[:CALLS]->(b) WHERE NOT b.name IN ['alpha'] RETURN a.name, b.name |
5 | 0 |
MATCH (a)-[:CALLS]->(b) WHERE NOT b.name ENDS WITH 'helper' RETURN a.name, b.name |
7 | 0 |
MATCH (a)-[:CALLS]->(b) WHERE a.name = 'run' AND NOT b.name = 'alpha' RETURN a.name, b.name |
3 | 0 |
MATCH (a)-[:CALLS]->(b) WHERE NOT b.name = 'zzz' RETURN count(*) |
1 row: 8 (tautology) |
0 rows |
MATCH (a)-[r:CALLS]->(b) WHERE r.line <> 0 RETURN count(*) |
8 |
8 ✅ (control) |
MATCH (a)-[r:CALLS]->(b) WHERE NOT r.line = 0 RETURN count(*) |
1 row: 8 |
0 rows |
MATCH (a)-[]->(b) WHERE b.name <> 'alpha' RETURN count(*) |
43 |
43 ✅ (control) |
MATCH (a)-[]->(b) WHERE NOT b.name = 'alpha' RETURN count(*) |
1 row: 43 |
0 rows |
Two results that narrow where it lives:
| query | got |
|---|---|
MATCH (a)-[:CALLS]->(b) WHERE b.name = 'alpha' OR NOT b.name = 'alpha' RETURN count(*) |
8 ✅ — the same NOT term is fine inside an OR |
MATCH (a)-[:CALLS]->(b) WITH a, b WHERE NOT b.name = 'alpha' RETURN a.name, b.name |
5 ✅ — the same NOT term is fine after a WITH |
The --json envelope carries no warning or diagnostic field for the failing cases; the only thing that comes back is the generic "hint": "Query returned no results. Use get_graph_schema() to see available labels and edge types.", which points the user at the schema rather than at the query.
Also on a large graph, and on the previous release
Same shape on an indexed project of 108,825 nodes / 558,430 edges (v0.11.0, freshly built):
MATCH (a:Function)-[:CALLS]->(b) WHERE b.name <> 'str' RETURN count(*) -> 116905
MATCH (a:Function)-[:CALLS]->(b) WHERE NOT b.name = 'str' RETURN count(*) -> 0 rows
MATCH (a:Function)-[:CALLS]->(b) WHERE NOT b.name = 'zzz_no_such' RETURN count(*) -> 0 rows
I had the same result on v0.10.8 against the same repositories before upgrading (that is how I noticed it), so it is not a v0.11.0 regression — it predates the Cypher fail-closed work (#1875, #1918, #1922, #1998) and is not covered by it.
Reading of the mechanism (a guess, offered to be shot down)
The pattern fits a predicate pushdown: a top-level NOT … that references only the target node (or the relationship) is pushed to the expansion stage, where b/r is not bound yet, and NOT <null> evaluates to false for every candidate — so a tautology filters everything out. The evidence for that reading is that (a) the identical term is evaluated correctly once it sits inside an OR, or after a WITH, i.e. once it can no longer be pushed as a standalone conjunct; (b) NOT on the source node, which is bound at expansion time, is correct; (c) <> is never affected, so it presumably is not pushed the same way. I have not read the planner, so treat the paragraph as a hypothesis, not a finding.
Expected
Either of these would be fine; the current behaviour is the one thing that should not happen:
NOT <predicate>on the target node / relationship evaluates the same as<>/ afterWITH(the correct rows).- If the shape is genuinely unsupported, the query is refused with an error, the way the v0.11.0 Cypher work refuses a
WITHwider than its binding — not answered with an empty result set that is indistinguishable from "no such edges". An empty result forNOT b.name = 'zzz'on a graph with 8 edges is a wrong answer, not a limitation.
Environment
- codebase-memory-mcp 0.11.0, release binary
codebase-memory-mcp-windows-amd64.zip(SHA-256 verified againstchecksums.txt), run throughcli; also reproduced on 0.10.8. - Windows 11 Pro 10.0.26200, x64, 32 GB RAM.
- Store built fresh by 0.11.0 (
--mode fastfor the 3-file repo,--mode moderatefor the large one).
Happy to re-run the table against a candidate build; the small fixture makes it a 10-second check.
Activity
DeusData commentedon Oct 1, 2026
Thank you for the minimal fixture and the careful comparison of equivalent query forms. I checked main at c61b380: in src/cypher/cypher.c, execute_single evaluates WHERE on the initial binding before expanding relationships (around lines 5139-5161); an unbound property returns NULL, eval_condition treats that as true, and EXPR_NOT negates it (lines 2566-2600 and 2644-2737). That supports the early-filtering diagnosis for this shape. This was source inspection, not a new runtime reproduction. PR #2253 is an open fix candidate for that boundary. Keeping this open and high priority until the fix is reviewed and lands; no additional information is needed from you for that next step. Thank you for making the silent wrong answer so precise.
DeusData commentedon Oct 19, 2026
Thank you again for the equivalent-query controls. I have no new verified status for #2253 beyond the earlier acknowledgment. The next review remains on our side; no additional reproduction is needed for this triage.
revert(cypher): drop the early-WHERE deferral that DeusData#2253 supe…
Merge pull request #2502 from lorenzozanee/fix/query-graph-where-not-…
DeusData commentedon Oct 21, 2026
Thank you for the detailed query comparisons and for helping track the early-WHERE filtering issue. PR #2502 is now merged in 3457b21 after the required CI checks passed. It preserves the regression cases alongside the earlier #2253 fix and closes the remaining function-argument alias-binding gap. The merge closed this issue as completed.
The separate empty-input count(*) behavior is tracked in #2742. Thanks again for the useful report and follow-up!