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 5005c21e Fix VLE NULL handling for chained OPTIONAL MATCH (#2337)
5005c21e is described below

commit 5005c21e5c2aa5daaca909fee7c4f9ed8ccdf984
Author: Greg Felice <[email protected]>
AuthorDate: Thu Feb 26 18:30:56 2026 -0500

    Fix VLE NULL handling for chained OPTIONAL MATCH (#2337)
    
    * Fix VLE NULL handling for chained OPTIONAL MATCH (#2092)
    
    VLE functions (age_match_vle_terminal_edge, age_match_two_vle_edges,
    age_match_vle_edge_to_id_qual) threw errors when receiving NULL
    arguments from OPTIONAL MATCH (LEFT JOIN) contexts. Additionally,
    build_local_vle_context crashed with a segfault when dereferencing
    a NULL next_vertex pointer in the cached VLE context path.
    
    These functions are used as join quals. In a LEFT JOIN, NULL arguments
    mean the inner side produced no match. The correct response is FALSE
    (no match), which lets PostgreSQL emit NULL-extended rows — the
    expected OPTIONAL MATCH behavior. Errors or crashes are incorrect.
    
    Changes:
    - build_local_vle_context: guard against NULL next_vertex in cached
      path; return NULL when vertex list is exhausted
    - age_vle: handle NULL return from build_local_vle_context with
      SRF_RETURN_DONE
    - age_match_vle_terminal_edge: return FALSE on NULL arguments instead
      of ereport(ERROR)
    - age_match_two_vle_edges: return FALSE on NULL arguments
    - age_match_vle_edge_to_id_qual: return FALSE on NULL arguments
    
    All 32 regression tests pass including new tests for this fix.
    
    * Address review feedback: fix error message and add ORDER BY to tests
    
    - Fix errmsg in age_match_vle_terminal_edge() to use the correct
      function name (was age_match_terminal_edge)
    - Add ORDER BY p.name to regression test queries to avoid
      nondeterministic row ordering in expected output
    
    AI-assisted: Claude (Anthropic) was used in developing this fix.
    
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 <[email protected]>
---
 regress/expected/cypher_vle.out | 89 +++++++++++++++++++++++++++++++++++++++++
 regress/sql/cypher_vle.sql      | 53 ++++++++++++++++++++++++
 src/backend/utils/adt/age_vle.c | 62 +++++++++++++++++++---------
 3 files changed, 185 insertions(+), 19 deletions(-)

diff --git a/regress/expected/cypher_vle.out b/regress/expected/cypher_vle.out
index 57f930d9..6574e060 100644
--- a/regress/expected/cypher_vle.out
+++ b/regress/expected/cypher_vle.out
@@ -1109,6 +1109,95 @@ NOTICE:  graph "issue_1910" has been dropped
  
 (1 row)
 
+-- issue 2092: VLE with chained OPTIONAL MATCH and NULL handling
+-- Previously, chained OPTIONAL MATCH with VLE would either segfault
+-- (with WHERE IS NOT NULL) or error with "arguments cannot be NULL"
+-- (without WHERE) instead of producing correct NULL-extended rows.
+SELECT create_graph('issue_2092');
+NOTICE:  graph "issue_2092" has been created
+ create_graph 
+--------------
+ 
+(1 row)
+
+-- Set up a small graph where some OPTIONAL MATCH paths exist and some don't
+SELECT * FROM cypher('issue_2092', $$
+    CREATE (a:Person {name: 'Alice'})
+    CREATE (b:Person {name: 'Bob'})
+    CREATE (c:City {name: 'NYC'})
+    CREATE (d:City {name: 'LA'})
+    CREATE (e:Place {name: 'Central Park'})
+    CREATE (a)-[:LIVES_IN]->(c)
+    CREATE (c)-[:HAS_PLACE]->(e)
+    CREATE (b)-[:LIVES_IN]->(d)
+$$) AS (result agtype);
+ result 
+--------
+(0 rows)
+
+-- Alice lives in NYC which has Central Park.
+-- Bob lives in LA which has no places.
+-- VLE + chained OPTIONAL MATCH + WHERE IS NOT NULL: should return rows
+-- without crashing (was: segfault)
+SELECT * FROM cypher('issue_2092', $$
+    MATCH (p:Person)-[:LIVES_IN*]->(c:City)
+    OPTIONAL MATCH (c)-[:HAS_PLACE*]->(place)
+    OPTIONAL MATCH (place)-[:NEARBY*]->(other)
+    WHERE place IS NOT NULL
+    RETURN p.name, place.name, other
+    ORDER BY p.name
+$$) AS (person agtype, place agtype, other agtype);
+ person  |     place      | other 
+---------+----------------+-------
+ "Alice" | "Central Park" | 
+ "Bob"   |                | 
+(2 rows)
+
+-- VLE + chained OPTIONAL MATCH without WHERE: should return NULL-extended
+-- rows without error (was: "match_vle_terminal_edge() arguments cannot be
+-- NULL")
+SELECT * FROM cypher('issue_2092', $$
+    MATCH (p:Person)-[:LIVES_IN*]->(c:City)
+    OPTIONAL MATCH (c)-[:HAS_PLACE*]->(place)
+    OPTIONAL MATCH (place)-[:NEARBY*]->(other)
+    RETURN p.name, place.name, other
+    ORDER BY p.name
+$$) AS (person agtype, place agtype, other agtype);
+ person  |     place      | other 
+---------+----------------+-------
+ "Alice" | "Central Park" | 
+ "Bob"   |                | 
+(2 rows)
+
+-- Verify the happy path still works: Alice's full chain resolves
+SELECT * FROM cypher('issue_2092', $$
+    MATCH (p:Person)-[:LIVES_IN*]->(c:City)
+    OPTIONAL MATCH (c)-[:HAS_PLACE*]->(place)
+    WHERE place IS NOT NULL
+    RETURN p.name, c.name, place.name
+    ORDER BY p.name
+$$) AS (person agtype, city agtype, place agtype);
+ person  | city  |     place      
+---------+-------+----------------
+ "Alice" | "NYC" | "Central Park"
+ "Bob"   | "LA"  | 
+(2 rows)
+
+SELECT drop_graph('issue_2092', true);
+NOTICE:  drop cascades to 7 other objects
+DETAIL:  drop cascades to table issue_2092._ag_label_vertex
+drop cascades to table issue_2092._ag_label_edge
+drop cascades to table issue_2092."Person"
+drop cascades to table issue_2092."City"
+drop cascades to table issue_2092."Place"
+drop cascades to table issue_2092."LIVES_IN"
+drop cascades to table issue_2092."HAS_PLACE"
+NOTICE:  graph "issue_2092" has been dropped
+ drop_graph 
+------------
+ 
+(1 row)
+
 --
 -- Clean up
 --
diff --git a/regress/sql/cypher_vle.sql b/regress/sql/cypher_vle.sql
index 5835627c..c960aa7a 100644
--- a/regress/sql/cypher_vle.sql
+++ b/regress/sql/cypher_vle.sql
@@ -357,6 +357,59 @@ SELECT * FROM cypher('issue_1910', $$ MATCH (n) WHERE 
EXISTS((n)-[*2..2]-({name:
 
 SELECT drop_graph('issue_1910', true);
 
+-- issue 2092: VLE with chained OPTIONAL MATCH and NULL handling
+-- Previously, chained OPTIONAL MATCH with VLE would either segfault
+-- (with WHERE IS NOT NULL) or error with "arguments cannot be NULL"
+-- (without WHERE) instead of producing correct NULL-extended rows.
+SELECT create_graph('issue_2092');
+
+-- Set up a small graph where some OPTIONAL MATCH paths exist and some don't
+SELECT * FROM cypher('issue_2092', $$
+    CREATE (a:Person {name: 'Alice'})
+    CREATE (b:Person {name: 'Bob'})
+    CREATE (c:City {name: 'NYC'})
+    CREATE (d:City {name: 'LA'})
+    CREATE (e:Place {name: 'Central Park'})
+    CREATE (a)-[:LIVES_IN]->(c)
+    CREATE (c)-[:HAS_PLACE]->(e)
+    CREATE (b)-[:LIVES_IN]->(d)
+$$) AS (result agtype);
+
+-- Alice lives in NYC which has Central Park.
+-- Bob lives in LA which has no places.
+-- VLE + chained OPTIONAL MATCH + WHERE IS NOT NULL: should return rows
+-- without crashing (was: segfault)
+SELECT * FROM cypher('issue_2092', $$
+    MATCH (p:Person)-[:LIVES_IN*]->(c:City)
+    OPTIONAL MATCH (c)-[:HAS_PLACE*]->(place)
+    OPTIONAL MATCH (place)-[:NEARBY*]->(other)
+    WHERE place IS NOT NULL
+    RETURN p.name, place.name, other
+    ORDER BY p.name
+$$) AS (person agtype, place agtype, other agtype);
+
+-- VLE + chained OPTIONAL MATCH without WHERE: should return NULL-extended
+-- rows without error (was: "match_vle_terminal_edge() arguments cannot be
+-- NULL")
+SELECT * FROM cypher('issue_2092', $$
+    MATCH (p:Person)-[:LIVES_IN*]->(c:City)
+    OPTIONAL MATCH (c)-[:HAS_PLACE*]->(place)
+    OPTIONAL MATCH (place)-[:NEARBY*]->(other)
+    RETURN p.name, place.name, other
+    ORDER BY p.name
+$$) AS (person agtype, place agtype, other agtype);
+
+-- Verify the happy path still works: Alice's full chain resolves
+SELECT * FROM cypher('issue_2092', $$
+    MATCH (p:Person)-[:LIVES_IN*]->(c:City)
+    OPTIONAL MATCH (c)-[:HAS_PLACE*]->(place)
+    WHERE place IS NOT NULL
+    RETURN p.name, c.name, place.name
+    ORDER BY p.name
+$$) AS (person agtype, city agtype, place agtype);
+
+SELECT drop_graph('issue_2092', true);
+
 --
 -- Clean up
 --
diff --git a/src/backend/utils/adt/age_vle.c b/src/backend/utils/adt/age_vle.c
index f9e4c70b..9224ed61 100644
--- a/src/backend/utils/adt/age_vle.c
+++ b/src/backend/utils/adt/age_vle.c
@@ -561,6 +561,11 @@ static VLE_local_context 
*build_local_vle_context(FunctionCallInfo fcinfo,
         /* get and update the start vertex id */
         if (PG_ARGISNULL(1) || is_agtype_null(AG_GET_ARG_AGTYPE_P(1)))
         {
+            /* if there are no more vertices to process, return NULL */
+            if (vlelctx->next_vertex == NULL)
+            {
+                return NULL;
+            }
             vlelctx->vsid = get_graphid(vlelctx->next_vertex);
             /* increment to the next vertex */
             vlelctx->next_vertex = next_GraphIdNode(vlelctx->next_vertex);
@@ -1733,6 +1738,16 @@ Datum age_vle(PG_FUNCTION_ARGS)
         /* build the local vle context */
         vlelctx = build_local_vle_context(fcinfo, funcctx);
 
+        /*
+         * If the context is NULL, there are no paths to find.
+         * This can happen when a cached VLE context has exhausted
+         * its vertex list (e.g., from a NULL OPTIONAL MATCH variable).
+         */
+        if (vlelctx == NULL)
+        {
+            SRF_RETURN_DONE(funcctx);
+        }
+
         /*
          * Point the function call context's user pointer to the local VLE
          * context just created
@@ -1934,6 +1949,16 @@ Datum age_match_two_vle_edges(PG_FUNCTION_ARGS)
     graphid *left_array, *right_array;
     int left_array_size;
 
+    /*
+     * If either argument is NULL, return FALSE. This can occur in
+     * OPTIONAL MATCH (LEFT JOIN) contexts where a preceding clause
+     * produced no results.
+     */
+    if (PG_ARGISNULL(0) || PG_ARGISNULL(1))
+    {
+        PG_RETURN_BOOL(false);
+    }
+
     /* get the VLE_path_container argument */
     agt_arg_vpc = AG_GET_ARG_AGTYPE_P(0);
 
@@ -2008,12 +2033,14 @@ Datum age_match_vle_edge_to_id_qual(PG_FUNCTION_ARGS)
                         errmsg("age_match_vle_edge_to_id_qual() invalid number 
of arguments")));
     }
 
-    /* the arguments cannot be NULL */
+    /*
+     * If any argument is NULL, return FALSE. This can occur in
+     * OPTIONAL MATCH (LEFT JOIN) contexts where a preceding clause
+     * produced no results.
+     */
     if (nulls[0] || nulls[1] || nulls[2])
     {
-        ereport(ERROR,
-                (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
-                 errmsg("age_match_vle_edge_to_id_qual() arguments must be non 
NULL")));
+        PG_RETURN_BOOL(false);
     }
 
     /* get the VLE_path_container argument */
@@ -2233,26 +2260,27 @@ Datum age_match_vle_terminal_edge(PG_FUNCTION_ARGS)
     if (nargs != 3)
     {
         ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
-                        errmsg("age_match_terminal_edge() invalid number of 
arguments")));
+                        errmsg("age_match_vle_terminal_edge() invalid number 
of arguments")));
     }
 
-    /* the arguments cannot be NULL */
+    /*
+     * If any argument is NULL, return FALSE. This can occur when this
+     * function is used as a join qual in an OPTIONAL MATCH (LEFT JOIN)
+     * where a preceding OPTIONAL MATCH produced no results. Returning
+     * FALSE allows PostgreSQL to produce the correct NULL-extended rows.
+     */
     if (nulls[0] || nulls[1] || nulls[2])
     {
-        ereport(ERROR,
-                (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
-                 errmsg("match_vle_terminal_edge() arguments cannot be 
NULL")));
+        PG_RETURN_BOOL(false);
     }
 
     /* get the vpc */
     agt_arg_path = DATUM_GET_AGTYPE_P(args[2]);
 
-    /* it cannot be NULL */
+    /* if the vpc is an agtype NULL, return FALSE */
     if (is_agtype_null(agt_arg_path))
     {
-        ereport(ERROR,
-                (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
-                 errmsg("match_vle_terminal_edge() argument 3 cannot be 
NULL")));
+        PG_RETURN_BOOL(false);
     }
 
     /*
@@ -2290,9 +2318,7 @@ Datum age_match_vle_terminal_edge(PG_FUNCTION_ARGS)
         }
         else
         {
-            ereport(ERROR,
-                (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
-                 errmsg("match_vle_terminal_edge() argument 1 must be non 
NULL")));
+            PG_RETURN_BOOL(false);
         }
     }
     else if (types[0] == GRAPHIDOID)
@@ -2320,9 +2346,7 @@ Datum age_match_vle_terminal_edge(PG_FUNCTION_ARGS)
         }
         else
         {
-            ereport(ERROR,
-                (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
-                 errmsg("match_vle_terminal_edge() argument 2 must be non 
NULL")));
+            PG_RETURN_BOOL(false);
         }
     }
     else if (types[1] == GRAPHIDOID)

Reply via email to