This is an automated email from the ASF dual-hosted git repository.
jgemignani pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/age.git
The following commit(s) were added to refs/heads/master by this push:
new d6f1b7f6 Fix MATCH on brand-new label after CREATE returning 0 rows
(#2341)
d6f1b7f6 is described below
commit d6f1b7f67087bd86f2e0fe6e1b6c897b7e569926
Author: Greg Felice <[email protected]>
AuthorDate: Mon Apr 6 11:34:47 2026 -0400
Fix MATCH on brand-new label after CREATE returning 0 rows (#2341)
* 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 <[email protected]>
* 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 <[email protected]>
* Address Copilot review: DRY false-where helper, cache has_dml, ORDER BY
in tests
- Factor duplicated WHERE true=false construction into
make_false_where_clause() helper (used in both early and deferred
label validation paths)
- Compute clause_chain_has_dml() once and reuse, avoiding repeated
clause chain traversal
- Add ORDER BY to the single-CREATE City regression test for
deterministic result ordering
* Address Copilot review: volatile false predicate, DML side-effect test
1. Prevent plan elimination of DML predecessor: replace constant
(true = false) with volatile (random() IS NULL) in the deferred
label check path. PG's planner can constant-fold the former into
a One-Time Filter: false, skipping the DML scan entirely.
2. Unify make_false_where_clause(bool volatile_needed): merge the
constant and volatile variants into a single parameterized
function. Call sites are now self-documenting:
- make_false_where_clause(false) for non-DML path
- make_false_where_clause(true) for DML predecessor path
3. Document why add_volatile_wrapper() cannot be reused here (it
operates post-transform at the Expr level and returns agtype,
while the WHERE clause is built at the parse-tree level).
4. Add regression test verifying CREATE side effects persist when
MATCH references a non-existent label after a DML predecessor.
All regression tests pass (cypher_match: ok).
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Replace non-ASCII em dashes with -- in C comments
ASCII-only codebase convention; avoids encoding/tooling issues.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
regress/expected/cypher_match.out | 97 ++++++++++++++++++++++++++++++++++
regress/sql/cypher_match.sql | 58 +++++++++++++++++++++
src/backend/parser/cypher_clause.c | 103 ++++++++++++++++++++++++++++++++-----
3 files changed, 244 insertions(+), 14 deletions(-)
diff --git a/regress/expected/cypher_match.out
b/regress/expected/cypher_match.out
index 94315f13..2f01d516 100644
--- a/regress/expected/cypher_match.out
+++ b/regress/expected/cypher_match.out
@@ -3737,6 +3737,103 @@ EXECUTE issue_1964_vertex_eq_on('{"props": {"name":
"Alice", "age": 25}}');
(0 rows)
DEALLOCATE issue_1964_vertex_eq_on;
+--
+-- 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 ORDER BY 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 ORDER BY 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 ORDER BY 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)
+
+-- 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)
+
+-- Verify that the CREATE side effect was preserved even though MATCH
+-- returned 0 rows (guards against plan-elimination regressions where
+-- a constant-false predicate causes PG to skip the DML predecessor)
+SELECT * FROM cypher('issue_2193', $$
+ MATCH (a:Person {name: 'Alice'})
+ RETURN a.name
+$$) AS (result agtype);
+ result
+---------
+ "Alice"
+(1 row)
+
+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 d14f45f1..410d097b 100644
--- a/regress/sql/cypher_match.sql
+++ b/regress/sql/cypher_match.sql
@@ -1558,6 +1558,64 @@ PREPARE issue_1964_vertex_eq_on(agtype) AS
EXECUTE issue_1964_vertex_eq_on('{"props": {"name": "Alice", "age": 25}}');
DEALLOCATE issue_1964_vertex_eq_on;
+--
+-- 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 ORDER BY 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 ORDER BY 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 ORDER BY 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);
+
+-- 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);
+
+-- Verify that the CREATE side effect was preserved even though MATCH
+-- returned 0 rows (guards against plan-elimination regressions where
+-- a constant-false predicate causes PG to skip the DML predecessor)
+SELECT * FROM cypher('issue_2193', $$
+ MATCH (a:Person {name: 'Alice'})
+ RETURN a.name
+$$) 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 6f06bbb8..e5540aa3 100644
--- a/src/backend/parser/cypher_clause.c
+++ b/src/backend/parser/cypher_clause.c
@@ -346,6 +346,7 @@ static bool isa_special_VLE_case(cypher_path *path);
static ParseNamespaceItem *find_pnsi(cypher_parsestate *cpstate, char
*varname);
static bool has_list_comp_or_subquery(Node *expr, void *context);
static bool clause_chain_has_dml(cypher_clause *clause);
+static Node *make_false_where_clause(bool volatile_needed);
/*
* Add required permissions to the RTEPermissionInfo for a relation.
@@ -2639,20 +2640,19 @@ 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);
-
- l->boolean = true;
- l->location = -1;
- r->boolean = false;
- r->location = -1;
-
- /*if the label is invalid, create a paradoxical where to get null*/
- match_self->where = (Node *)makeSimpleA_Expr(AEXPR_OP, "=",
- (Node *)l,
- (Node *)r, -1);
+ /* Label is invalid -- inject a false WHERE so the MATCH returns
+ * zero rows. No DML predecessor here, so constant-foldable is fine. */
+ match_self->where = make_false_where_clause(false);
}
if (has_list_comp_or_subquery((Node *)match_self->where, NULL))
@@ -2915,6 +2915,7 @@ static Query
*transform_cypher_match_pattern(cypher_parsestate *cpstate,
RangeTblEntry *rte;
int rtindex;
ParseNamespaceItem *pnsi;
+ bool has_dml;
pnsi = transform_prev_cypher_clause(cpstate, clause->prev, true);
rte = pnsi->p_rte;
@@ -2928,7 +2929,9 @@ static Query
*transform_cypher_match_pattern(cypher_parsestate *cpstate,
* DML executes -- resulting in quals checking NULL values
* and filtering out all rows.
*/
- if (clause_chain_has_dml(clause->prev))
+ has_dml = clause_chain_has_dml(clause->prev);
+
+ if (has_dml)
{
rte->security_barrier = true;
}
@@ -2949,6 +2952,25 @@ 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().
+ *
+ * We use a volatile false predicate (random() IS NULL)
+ * instead of a constant one (true = false) because PG's
+ * planner can constant-fold the latter into a One-Time
+ * Filter: false, eliminating the entire plan subtree --
+ * including the DML predecessor scan -- without executing it.
+ */
+ if (has_dml && !match_check_valid_label(self, cpstate))
+ {
+ where = make_false_where_clause(true);
+ }
}
transform_match_pattern(cpstate, query, self->pattern, where);
@@ -6621,6 +6643,59 @@ static bool clause_chain_has_dml(cypher_clause *clause)
return false;
}
+/*
+ * Build a false WHERE clause that forces a MATCH to return zero rows.
+ * Used when the MATCH pattern references a label that does not exist.
+ *
+ * When volatile_needed is false, returns a constant (true = false) that
+ * PG's planner may constant-fold -- this is fine when there is no DML
+ * predecessor whose execution must be preserved.
+ *
+ * When volatile_needed is true, returns (random() IS NULL) instead.
+ * random() is VOLATILE, so eval_const_expressions() cannot fold this,
+ * preventing PG from creating a One-Time Filter: false that would
+ * eliminate the DML predecessor scan without executing it.
+ *
+ * Note: AGE's add_volatile_wrapper() serves a similar anti-fold purpose
+ * but operates at the Expr level (post-transform) and returns agtype,
+ * so it cannot be used here in the parse-tree WHERE clause context.
+ */
+static Node *make_false_where_clause(bool volatile_needed)
+{
+ if (volatile_needed)
+ {
+ FuncCall *random_fn;
+ NullTest *nt;
+
+ random_fn = makeFuncCall(
+ list_make2(makeString("pg_catalog"), makeString("random")),
+ NIL,
+ COERCE_EXPLICIT_CALL,
+ -1);
+
+ nt = makeNode(NullTest);
+ nt->arg = (Expr *)random_fn;
+ nt->nulltesttype = IS_NULL;
+ nt->argisrow = false;
+ nt->location = -1;
+
+ return (Node *)nt;
+ }
+ else
+ {
+ 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;
+
+ return (Node *)makeSimpleA_Expr(AEXPR_OP, "=",
+ (Node *)l, (Node *)r, -1);
+ }
+}
+
static Query *analyze_cypher_clause(transform_method transform,
cypher_clause *clause,
cypher_parsestate *parent_cpstate)