Skip to content

Commit 217467a

Browse files
gregfeliceclaude
andauthored
Fix MATCH after CREATE returning 0 rows (issue #2308) (#2340)
When a MATCH clause follows CREATE + WITH and re-uses bound variables (e.g. CREATE (a)-[e]->(b) WITH a,e,b MATCH p=(a)-[e]->(b)), the MATCH generates filter quals (age_start_id(e) = age_id(a), etc.) that reference only columns from the predecessor subquery. PostgreSQL's optimizer pushes these quals through the transparent subquery layers into the CREATE's child plan, where they evaluate on NULL values before CREATE has executed — always yielding 0 rows. Fix: mark the predecessor subquery RTE as security_barrier when the clause chain contains a data-modifying operation (CREATE, SET, DELETE, or MERGE). This prevents PostgreSQL from pushing filter quals into the subquery, ensuring they evaluate after the DML produces output values. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 20ada84 commit 217467a

3 files changed

Lines changed: 193 additions & 0 deletions

File tree

regress/expected/cypher_match.out

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3533,6 +3533,106 @@ SELECT * FROM cypher('test_enable_containment', $$ EXPLAIN (costs off) MATCH (x:
35333533
Filter: ((agtype_access_operator(VARIADIC ARRAY[properties, '"school"'::agtype]) = '{"name": "XYZ College", "program": {"major": "Psyc", "degree": "BSc"}}'::agtype) AND (agtype_access_operator(VARIADIC ARRAY[properties, '"phone"'::agtype]) = '[123456789, 987654321, 456987123]'::agtype))
35343534
(2 rows)
35353535

3536+
--
3537+
-- issue 2308: MATCH after CREATE returns 0 rows
3538+
--
3539+
-- When all MATCH variables are already bound from a preceding CREATE + WITH,
3540+
-- the MATCH filter quals must evaluate after CREATE, not before.
3541+
--
3542+
SELECT create_graph('issue_2308');
3543+
NOTICE: graph "issue_2308" has been created
3544+
create_graph
3545+
--------------
3546+
3547+
(1 row)
3548+
3549+
-- Reporter's exact case: CREATE + WITH + MATCH + SET + RETURN
3550+
SELECT * FROM cypher('issue_2308', $$
3551+
CREATE (a:TestB3)-[e:B3REL]->(b:TestB3)
3552+
WITH a, e, b
3553+
MATCH p = (a)-[e]->(b)
3554+
SET a.something = 'something'
3555+
RETURN a
3556+
$$) AS (a agtype);
3557+
a
3558+
----------------------------------------------------------------------------------------------
3559+
{"id": 844424930131969, "label": "TestB3", "properties": {"something": "something"}}::vertex
3560+
(1 row)
3561+
3562+
-- Bound variables, no SET
3563+
SELECT * FROM cypher('issue_2308', $$
3564+
CREATE (a:T2)-[e:R2]->(b:T2)
3565+
WITH a, e, b
3566+
MATCH (a)-[e]->(b)
3567+
RETURN a, e, b
3568+
$$) AS (a agtype, e agtype, b agtype);
3569+
a | e | b
3570+
-------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------+-------------------------------------------------------------------
3571+
{"id": 1407374883553281, "label": "T2", "properties": {}}::vertex | {"id": 1688849860263937, "label": "R2", "end_id": 1407374883553282, "start_id": 1407374883553281, "properties": {}}::edge | {"id": 1407374883553282, "label": "T2", "properties": {}}::vertex
3572+
(1 row)
3573+
3574+
-- Reversed direction: filter should reject (0 rows expected)
3575+
SELECT * FROM cypher('issue_2308', $$
3576+
CREATE (a:T3)-[e:R3]->(b:T3)
3577+
WITH a, e, b
3578+
MATCH (b)-[e]->(a)
3579+
RETURN a
3580+
$$) AS (a agtype);
3581+
a
3582+
---
3583+
(0 rows)
3584+
3585+
-- Node-only MATCH with bound variable
3586+
SELECT * FROM cypher('issue_2308', $$
3587+
CREATE (a:T4 {name: 'test'})
3588+
WITH a
3589+
MATCH (a)
3590+
RETURN a
3591+
$$) AS (a agtype);
3592+
a
3593+
---------------------------------------------------------------------------------
3594+
{"id": 2533274790395905, "label": "T4", "properties": {"name": "test"}}::vertex
3595+
(1 row)
3596+
3597+
-- MATCH after SET (SET is also DML, chain must be protected)
3598+
SELECT * FROM cypher('issue_2308', $$
3599+
CREATE (a:T5 {val: 1})-[e:R5]->(b:T5 {val: 2})
3600+
$$) AS (r agtype);
3601+
r
3602+
---
3603+
(0 rows)
3604+
3605+
SELECT * FROM cypher('issue_2308', $$
3606+
MATCH (a:T5)-[e:R5]->(b:T5)
3607+
SET a.val = 10
3608+
WITH a, e, b
3609+
MATCH (a)-[e]->(b)
3610+
RETURN a.val
3611+
$$) AS (val agtype);
3612+
val
3613+
-----
3614+
10
3615+
(1 row)
3616+
3617+
SELECT drop_graph('issue_2308', true);
3618+
NOTICE: drop cascades to 11 other objects
3619+
DETAIL: drop cascades to table issue_2308._ag_label_vertex
3620+
drop cascades to table issue_2308._ag_label_edge
3621+
drop cascades to table issue_2308."TestB3"
3622+
drop cascades to table issue_2308."B3REL"
3623+
drop cascades to table issue_2308."T2"
3624+
drop cascades to table issue_2308."R2"
3625+
drop cascades to table issue_2308."T3"
3626+
drop cascades to table issue_2308."R3"
3627+
drop cascades to table issue_2308."T4"
3628+
drop cascades to table issue_2308."T5"
3629+
drop cascades to table issue_2308."R5"
3630+
NOTICE: graph "issue_2308" has been dropped
3631+
drop_graph
3632+
------------
3633+
3634+
(1 row)
3635+
35363636
--
35373637
-- Clean up
35383638
--

regress/sql/cypher_match.sql

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1437,6 +1437,61 @@ SELECT count(*) FROM cypher('test_enable_containment', $$ MATCH p=(x:Customer)-[
14371437
SELECT * FROM cypher('test_enable_containment', $$ EXPLAIN (costs off) MATCH (x:Customer)-[:bought ={store: 'Amazon', addr:{city: 'Vancouver', street: 30}}]->(y:Product) RETURN 0 $$) as (a agtype);
14381438
SELECT * FROM cypher('test_enable_containment', $$ EXPLAIN (costs off) MATCH (x:Customer ={school: { name: 'XYZ College',program: { major: 'Psyc', degree: 'BSc'} },phone: [ 123456789, 987654321, 456987123 ]}) RETURN 0 $$) as (a agtype);
14391439

1440+
--
1441+
-- issue 2308: MATCH after CREATE returns 0 rows
1442+
--
1443+
-- When all MATCH variables are already bound from a preceding CREATE + WITH,
1444+
-- the MATCH filter quals must evaluate after CREATE, not before.
1445+
--
1446+
SELECT create_graph('issue_2308');
1447+
1448+
-- Reporter's exact case: CREATE + WITH + MATCH + SET + RETURN
1449+
SELECT * FROM cypher('issue_2308', $$
1450+
CREATE (a:TestB3)-[e:B3REL]->(b:TestB3)
1451+
WITH a, e, b
1452+
MATCH p = (a)-[e]->(b)
1453+
SET a.something = 'something'
1454+
RETURN a
1455+
$$) AS (a agtype);
1456+
1457+
-- Bound variables, no SET
1458+
SELECT * FROM cypher('issue_2308', $$
1459+
CREATE (a:T2)-[e:R2]->(b:T2)
1460+
WITH a, e, b
1461+
MATCH (a)-[e]->(b)
1462+
RETURN a, e, b
1463+
$$) AS (a agtype, e agtype, b agtype);
1464+
1465+
-- Reversed direction: filter should reject (0 rows expected)
1466+
SELECT * FROM cypher('issue_2308', $$
1467+
CREATE (a:T3)-[e:R3]->(b:T3)
1468+
WITH a, e, b
1469+
MATCH (b)-[e]->(a)
1470+
RETURN a
1471+
$$) AS (a agtype);
1472+
1473+
-- Node-only MATCH with bound variable
1474+
SELECT * FROM cypher('issue_2308', $$
1475+
CREATE (a:T4 {name: 'test'})
1476+
WITH a
1477+
MATCH (a)
1478+
RETURN a
1479+
$$) AS (a agtype);
1480+
1481+
-- MATCH after SET (SET is also DML, chain must be protected)
1482+
SELECT * FROM cypher('issue_2308', $$
1483+
CREATE (a:T5 {val: 1})-[e:R5]->(b:T5 {val: 2})
1484+
$$) AS (r agtype);
1485+
SELECT * FROM cypher('issue_2308', $$
1486+
MATCH (a:T5)-[e:R5]->(b:T5)
1487+
SET a.val = 10
1488+
WITH a, e, b
1489+
MATCH (a)-[e]->(b)
1490+
RETURN a.val
1491+
$$) AS (val agtype);
1492+
1493+
SELECT drop_graph('issue_2308', true);
1494+
14401495
--
14411496
-- Clean up
14421497
--

src/backend/parser/cypher_clause.c

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ static bool isa_special_VLE_case(cypher_path *path);
345345

346346
static ParseNamespaceItem *find_pnsi(cypher_parsestate *cpstate, char *varname);
347347
static bool has_list_comp_or_subquery(Node *expr, void *context);
348+
static bool clause_chain_has_dml(cypher_clause *clause);
348349

349350
/*
350351
* Add required permissions to the RTEPermissionInfo for a relation.
@@ -2917,6 +2918,21 @@ static Query *transform_cypher_match_pattern(cypher_parsestate *cpstate,
29172918

29182919
pnsi = transform_prev_cypher_clause(cpstate, clause->prev, true);
29192920
rte = pnsi->p_rte;
2921+
2922+
/*
2923+
* If the predecessor clause chain contains a data-modifying
2924+
* operation (CREATE, SET, DELETE, MERGE), mark the subquery
2925+
* RTE as a security barrier. This prevents PostgreSQL's
2926+
* optimizer from pushing MATCH filter quals down into the
2927+
* subquery, which would cause them to evaluate before the
2928+
* DML executes -- resulting in quals checking NULL values
2929+
* and filtering out all rows.
2930+
*/
2931+
if (clause_chain_has_dml(clause->prev))
2932+
{
2933+
rte->security_barrier = true;
2934+
}
2935+
29202936
rtindex = list_length(pstate->p_rtable);
29212937
/* rte is the first RangeTblEntry in pstate */
29222938
if (rtindex != 1)
@@ -6545,6 +6561,28 @@ static void advance_transform_entities_to_next_clause(List *entities)
65456561
}
65466562
}
65476563

6564+
/*
6565+
* Walk the clause chain and return true if any clause is a
6566+
* data-modifying operation (CREATE, SET, DELETE, or MERGE).
6567+
*/
6568+
static bool clause_chain_has_dml(cypher_clause *clause)
6569+
{
6570+
while (clause != NULL)
6571+
{
6572+
if (is_ag_node(clause->self, cypher_create) ||
6573+
is_ag_node(clause->self, cypher_set) ||
6574+
is_ag_node(clause->self, cypher_delete) ||
6575+
is_ag_node(clause->self, cypher_merge))
6576+
{
6577+
return true;
6578+
}
6579+
6580+
clause = clause->prev;
6581+
}
6582+
6583+
return false;
6584+
}
6585+
65486586
static Query *analyze_cypher_clause(transform_method transform,
65496587
cypher_clause *clause,
65506588
cypher_parsestate *parent_cpstate)

0 commit comments

Comments
 (0)