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)

Reply via email to