From d2d72450ba7c34a0d953e31af0855e61ff9951bd Mon Sep 17 00:00:00 2001 From: Greg Felice Date: Wed, 25 Feb 2026 23:05:13 -0500 Subject: [PATCH 1/2] Fix MATCH on brand-new label after CREATE returning 0 rows (issue #2193) When CREATE introduces a new label and a subsequent MATCH references it (e.g., CREATE (:Person) WITH ... MATCH (p:Person)), the query returns 0 rows on first execution but works on the second. Root cause: match_check_valid_label() in transform_cypher_match() runs before transform_prev_cypher_clause() processes the predecessor chain. Since CREATE has not yet executed its transform (which creates the label table as a side effect), the label is not in the cache and the check generates a One-Time Filter: false plan that returns no rows. Fix: Skip the early label validity check when the predecessor clause chain contains a data-modifying operation (CREATE, SET, DELETE, MERGE). After transform_prev_cypher_clause() completes and any new labels exist in the cache, run a deferred label check. If the labels are still invalid at that point, generate an empty result via makeBoolConst(false). This preserves the existing behavior for MATCH without DML predecessors (e.g., MATCH-MATCH chains still get the early check and proper error messages for invalid labels). Depends on: PR #2340 (clause_chain_has_dml helper) Co-Authored-By: Claude Opus 4.6 --- regress/expected/cypher_match.out | 73 ++++++++++++++++++++++++++++++ regress/sql/cypher_match.sql | 41 +++++++++++++++++ src/backend/parser/cypher_clause.c | 38 +++++++++++++++- 3 files changed, 150 insertions(+), 2 deletions(-) diff --git a/regress/expected/cypher_match.out b/regress/expected/cypher_match.out index ff2825ae0..f69576bed 100644 --- a/regress/expected/cypher_match.out +++ b/regress/expected/cypher_match.out @@ -3633,6 +3633,79 @@ NOTICE: graph "issue_2308" has been dropped (1 row) +-- +-- Issue 2193: CREATE ... WITH ... MATCH on brand-new label returns 0 rows +-- on first execution because match_check_valid_label() runs before +-- transform_prev_cypher_clause() creates the label table. +-- +SELECT create_graph('issue_2193'); +NOTICE: graph "issue_2193" has been created + create_graph +-------------- + +(1 row) + +-- Reporter's exact case: CREATE two Person nodes, then MATCH on Person +-- Should return 2 rows on the very first execution +SELECT * FROM cypher('issue_2193', $$ + CREATE (a:Person {name: 'Jane', livesIn: 'London'}), + (b:Person {name: 'Tom', livesIn: 'Copenhagen'}) + WITH a, b + MATCH (p:Person) + RETURN p.name +$$) AS (result agtype); + result +-------- + "Jane" + "Tom" +(2 rows) + +-- Single CREATE + MATCH on brand-new label +SELECT * FROM cypher('issue_2193', $$ + CREATE (a:City {name: 'Berlin'}) + WITH a + MATCH (c:City) + RETURN c.name +$$) AS (result agtype); + result +---------- + "Berlin" +(1 row) + +-- MATCH on a label that now exists (second execution) still works +SELECT * FROM cypher('issue_2193', $$ + CREATE (a:City {name: 'Paris'}) + WITH a + MATCH (c:City) + RETURN c.name +$$) AS (result agtype); + result +---------- + "Berlin" + "Paris" +(2 rows) + +-- MATCH on non-existent label without DML predecessor still returns 0 rows +SELECT * FROM cypher('issue_2193', $$ + MATCH (x:NonExistentLabel) + RETURN x +$$) AS (result agtype); + result +-------- +(0 rows) + +SELECT drop_graph('issue_2193', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table issue_2193._ag_label_vertex +drop cascades to table issue_2193._ag_label_edge +drop cascades to table issue_2193."Person" +drop cascades to table issue_2193."City" +NOTICE: graph "issue_2193" has been dropped + drop_graph +------------ + +(1 row) + -- -- Clean up -- diff --git a/regress/sql/cypher_match.sql b/regress/sql/cypher_match.sql index ebcd67b84..6bac96a43 100644 --- a/regress/sql/cypher_match.sql +++ b/regress/sql/cypher_match.sql @@ -1492,6 +1492,47 @@ $$) AS (val agtype); SELECT drop_graph('issue_2308', true); +-- +-- Issue 2193: CREATE ... WITH ... MATCH on brand-new label returns 0 rows +-- on first execution because match_check_valid_label() runs before +-- transform_prev_cypher_clause() creates the label table. +-- +SELECT create_graph('issue_2193'); + +-- Reporter's exact case: CREATE two Person nodes, then MATCH on Person +-- Should return 2 rows on the very first execution +SELECT * FROM cypher('issue_2193', $$ + CREATE (a:Person {name: 'Jane', livesIn: 'London'}), + (b:Person {name: 'Tom', livesIn: 'Copenhagen'}) + WITH a, b + MATCH (p:Person) + RETURN p.name +$$) AS (result agtype); + +-- Single CREATE + MATCH on brand-new label +SELECT * FROM cypher('issue_2193', $$ + CREATE (a:City {name: 'Berlin'}) + WITH a + MATCH (c:City) + RETURN c.name +$$) AS (result agtype); + +-- MATCH on a label that now exists (second execution) still works +SELECT * FROM cypher('issue_2193', $$ + CREATE (a:City {name: 'Paris'}) + WITH a + MATCH (c:City) + RETURN c.name +$$) AS (result agtype); + +-- MATCH on non-existent label without DML predecessor still returns 0 rows +SELECT * FROM cypher('issue_2193', $$ + MATCH (x:NonExistentLabel) + RETURN x +$$) AS (result agtype); + +SELECT drop_graph('issue_2193', true); + -- -- Clean up -- diff --git a/src/backend/parser/cypher_clause.c b/src/backend/parser/cypher_clause.c index 446e97b3f..293c7be40 100644 --- a/src/backend/parser/cypher_clause.c +++ b/src/backend/parser/cypher_clause.c @@ -2639,7 +2639,15 @@ static Query *transform_cypher_match(cypher_parsestate *cpstate, cypher_match *match_self = (cypher_match*) clause->self; Node *where = match_self->where; - if(!match_check_valid_label(match_self, cpstate)) + /* + * Check label validity early unless the predecessor clause chain + * contains a data-modifying operation (CREATE, SET, DELETE, MERGE). + * DML predecessors may create new labels that are not yet in the + * cache, so the check is deferred to after transform_prev_cypher_clause() + * for those cases. + */ + if (!clause_chain_has_dml(clause->prev) && + !match_check_valid_label(match_self, cpstate)) { cypher_bool_const *l = make_ag_node(cypher_bool_const); cypher_bool_const *r = make_ag_node(cypher_bool_const); @@ -2910,6 +2918,8 @@ static Query *transform_cypher_match_pattern(cypher_parsestate *cpstate, } else { + bool valid_labels = true; + if (clause->prev) { RangeTblEntry *rte; @@ -2949,9 +2959,33 @@ static Query *transform_cypher_match_pattern(cypher_parsestate *cpstate, */ pnsi = get_namespace_item(pstate, rte); query->targetList = expandNSItemAttrs(pstate, pnsi, 0, true, -1); + + /* + * Now that the predecessor chain is fully transformed and + * any CREATE-generated labels exist in the cache, check + * whether the MATCH pattern references valid labels. This + * deferred check is only needed when the chain has DML, + * since labels created by CREATE are not in the cache at + * the time of the early check in transform_cypher_match(). + */ + if (clause_chain_has_dml(clause->prev) && + !match_check_valid_label(self, cpstate)) + { + valid_labels = false; + } } - transform_match_pattern(cpstate, query, self->pattern, where); + if (valid_labels) + { + transform_match_pattern(cpstate, query, self->pattern, where); + } + else + { + query->rtable = pstate->p_rtable; + query->rteperminfos = pstate->p_rteperminfos; + query->jointree = makeFromExpr(pstate->p_joinlist, + makeBoolConst(false, false)); + } } markTargetListOrigins(pstate, query->targetList); From bcc3e741b4ef9432e8dc11fb78e5944d240f91a5 Mon Sep 17 00:00:00 2001 From: Greg Felice Date: Fri, 27 Feb 2026 15:33:21 -0500 Subject: [PATCH 2/2] Address review feedback: fix variable registration for deferred label check When the deferred label validity check (DML predecessor + non-existent label) found an invalid label, the code skipped transform_match_pattern() entirely, which meant MATCH-introduced variables were never registered in the namespace. This would cause errors if a later clause referenced those variables (e.g., RETURN p). Fix: mirror the early-check strategy by injecting a paradoxical WHERE (true = false) and always calling transform_match_pattern(). Variables get registered normally; zero rows are returned via the impossible qual. Also add ORDER BY to multi-row regression tests for deterministic output, and add a test case for DML predecessor + non-existent label + returning a MATCH-introduced variable. Co-Authored-By: Claude Opus 4.6 --- regress/expected/cypher_match.out | 16 ++++++++++++++-- regress/sql/cypher_match.sql | 13 +++++++++++-- src/backend/parser/cypher_clause.c | 26 ++++++++++++-------------- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/regress/expected/cypher_match.out b/regress/expected/cypher_match.out index f69576bed..157754114 100644 --- a/regress/expected/cypher_match.out +++ b/regress/expected/cypher_match.out @@ -3652,7 +3652,7 @@ SELECT * FROM cypher('issue_2193', $$ (b:Person {name: 'Tom', livesIn: 'Copenhagen'}) WITH a, b MATCH (p:Person) - RETURN p.name + RETURN p.name ORDER BY p.name $$) AS (result agtype); result -------- @@ -3677,7 +3677,7 @@ SELECT * FROM cypher('issue_2193', $$ CREATE (a:City {name: 'Paris'}) WITH a MATCH (c:City) - RETURN c.name + RETURN c.name ORDER BY c.name $$) AS (result agtype); result ---------- @@ -3694,6 +3694,18 @@ $$) AS (result agtype); -------- (0 rows) +-- MATCH on non-existent label after DML predecessor still returns 0 rows +-- and MATCH-introduced variable (p) is properly registered +SELECT * FROM cypher('issue_2193', $$ + CREATE (a:Person {name: 'Alice'}) + WITH a + MATCH (p:NonExistentLabel) + RETURN p +$$) AS (result agtype); + result +-------- +(0 rows) + SELECT drop_graph('issue_2193', true); NOTICE: drop cascades to 4 other objects DETAIL: drop cascades to table issue_2193._ag_label_vertex diff --git a/regress/sql/cypher_match.sql b/regress/sql/cypher_match.sql index 6bac96a43..78117b73d 100644 --- a/regress/sql/cypher_match.sql +++ b/regress/sql/cypher_match.sql @@ -1506,7 +1506,7 @@ SELECT * FROM cypher('issue_2193', $$ (b:Person {name: 'Tom', livesIn: 'Copenhagen'}) WITH a, b MATCH (p:Person) - RETURN p.name + RETURN p.name ORDER BY p.name $$) AS (result agtype); -- Single CREATE + MATCH on brand-new label @@ -1522,7 +1522,7 @@ SELECT * FROM cypher('issue_2193', $$ CREATE (a:City {name: 'Paris'}) WITH a MATCH (c:City) - RETURN c.name + RETURN c.name ORDER BY c.name $$) AS (result agtype); -- MATCH on non-existent label without DML predecessor still returns 0 rows @@ -1531,6 +1531,15 @@ SELECT * FROM cypher('issue_2193', $$ RETURN x $$) AS (result agtype); +-- MATCH on non-existent label after DML predecessor still returns 0 rows +-- and MATCH-introduced variable (p) is properly registered +SELECT * FROM cypher('issue_2193', $$ + CREATE (a:Person {name: 'Alice'}) + WITH a + MATCH (p:NonExistentLabel) + RETURN p +$$) AS (result agtype); + SELECT drop_graph('issue_2193', true); -- diff --git a/src/backend/parser/cypher_clause.c b/src/backend/parser/cypher_clause.c index 293c7be40..f00b2bc53 100644 --- a/src/backend/parser/cypher_clause.c +++ b/src/backend/parser/cypher_clause.c @@ -2918,8 +2918,6 @@ static Query *transform_cypher_match_pattern(cypher_parsestate *cpstate, } else { - bool valid_labels = true; - if (clause->prev) { RangeTblEntry *rte; @@ -2971,21 +2969,21 @@ static Query *transform_cypher_match_pattern(cypher_parsestate *cpstate, if (clause_chain_has_dml(clause->prev) && !match_check_valid_label(self, cpstate)) { - valid_labels = false; + cypher_bool_const *l = make_ag_node(cypher_bool_const); + cypher_bool_const *r = make_ag_node(cypher_bool_const); + + l->boolean = true; + l->location = -1; + r->boolean = false; + r->location = -1; + + where = (Node *)makeSimpleA_Expr(AEXPR_OP, "=", + (Node *)l, + (Node *)r, -1); } } - if (valid_labels) - { - transform_match_pattern(cpstate, query, self->pattern, where); - } - else - { - query->rtable = pstate->p_rtable; - query->rteperminfos = pstate->p_rteperminfos; - query->jointree = makeFromExpr(pstate->p_joinlist, - makeBoolConst(false, false)); - } + transform_match_pattern(cpstate, query, self->pattern, where); } markTargetListOrigins(pstate, query->targetList);