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)