This is an automated email from the ASF dual-hosted git repository.
jrgemignani 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 12e2a31c Fix VLE [*0..N] zero-hop self-binding when edge label is
missing (#2382) (#2419)
12e2a31c is described below
commit 12e2a31c6716073fa046efe6fd3b1f930efabca6
Author: Prashant Chinnam <[email protected]>
AuthorDate: Thu Jun 11 06:58:30 2026 -0700
Fix VLE [*0..N] zero-hop self-binding when edge label is missing (#2382)
(#2419)
A variable-length relationship pattern with a zero lower bound, e.g.
`(p)-[:LABEL*0..N]-(f)`, must produce the zero-hop self-binding row
(`f` = `p`) regardless of whether any edge of `LABEL` exists in the
graph. This matches Neo4j/openCypher semantics. Previously, when the
edge label did not exist in the label cache, AGE short-circuited the
entire MATCH to zero rows (or NULL-extended rows for OPTIONAL MATCH).
The fix has three parts:
1. parser/cypher_clause.c: A new helper `is_zero_lower_bound_vle()`
inspects the FuncCall produced by `build_VLE_relation()` and reports
whether the relationship is a zero-bound VLE. It is intentionally
defensive about the FuncCall shape so that any future parser changes
fall back to the existing short-circuit safely.
`match_check_valid_label()` and `path_check_valid_label()` now treat
a missing edge label as fatal only when the relationship requires at
least one edge of that label. Patterns mixing a zero-bound segment
with another impossible segment (e.g.
`(a)-[:NOEXIST*0..1]-(b)-[:STILL_MISSING]-(c)`)
still correctly resolve to zero rows because the second segment
independently fails the label check.
2. utils/adt/age_vle.c: `is_an_edge_match()` now returns false early
when the user requested a specific label that does not exist
(`edge_label_name != NULL && edge_label_name_oid == InvalidOid`).
This prevents a zero-bound traversal of `[:NOEXIST*0..N]` from
incorrectly walking arbitrary other-label edges via the existing
"no constraints -> match all" fast path. The zero-hop case itself
is unaffected because it is generated by `build_VLE_zero_container()`
without ever consulting `is_an_edge_match()`.
3. regress/sql/cypher_vle.sql: Adds seven regression cases that lock
in the new behaviour, including the rubber-duck scenarios where
another label exists in the graph (must NOT be matched by the
missing-label VLE), where another segment is unsatisfiable
(must still produce zero rows), and where the label exists
(sanity check, unchanged behaviour).
---
regress/expected/cypher_vle.out | 115 +++++++++++++++++++++++++++++++++++++
regress/sql/cypher_vle.sql | 69 ++++++++++++++++++++++
src/backend/parser/cypher_clause.c | 73 ++++++++++++++++++++++-
src/backend/utils/adt/age_vle.c | 14 +++++
4 files changed, 269 insertions(+), 2 deletions(-)
diff --git a/regress/expected/cypher_vle.out b/regress/expected/cypher_vle.out
index a09cd4aa..0f564015 100644
--- a/regress/expected/cypher_vle.out
+++ b/regress/expected/cypher_vle.out
@@ -1219,6 +1219,121 @@ NOTICE: graph "cypher_vle" has been dropped
(1 row)
+--
+-- Issue #2382: variable-length relationships with a zero lower bound must
+-- still produce the zero-hop self-binding even when the edge label does not
+-- exist in the graph (Neo4j/openCypher semantics).
+--
+SELECT create_graph('issue_2382');
+NOTICE: graph "issue_2382" has been created
+ create_graph
+--------------
+
+(1 row)
+
+SELECT * FROM cypher('issue_2382', $$
+ CREATE (:Person {name: 'Alice'})-[:KNOWS]->(:Person {name: 'Bob'})
+$$) AS (v agtype);
+ v
+---
+(0 rows)
+
+-- Plain MATCH on a non-existent edge label with [*0..N] must return the
+-- zero-hop self-binding row (Alice -> Alice). It must NOT match arbitrary
+-- edges of other labels (e.g. KNOWS).
+SELECT * FROM cypher('issue_2382', $$
+ MATCH (p:Person {name: 'Alice'})
+ MATCH (p)-[:NOEXIST*0..1]-(f:Person)
+ RETURN p.name AS person, f.name AS friend
+$$) AS (person agtype, friend agtype);
+ person | friend
+---------+---------
+ "Alice" | "Alice"
+(1 row)
+
+-- OPTIONAL MATCH form (the exact shape from the issue report).
+SELECT * FROM cypher('issue_2382', $$
+ MATCH (p:Person {name: 'Alice'})
+ OPTIONAL MATCH (p)-[:NOEXIST*0..1]-(f:Person)
+ RETURN p.name AS person, f.name AS friend
+$$) AS (person agtype, friend agtype);
+ person | friend
+---------+---------
+ "Alice" | "Alice"
+(1 row)
+
+-- [*0..0] still emits exactly the zero-hop self-binding.
+SELECT * FROM cypher('issue_2382', $$
+ MATCH (p:Person {name: 'Alice'})
+ MATCH (p)-[:NOEXIST*0..0]-(f:Person)
+ RETURN p.name AS person, f.name AS friend
+$$) AS (person agtype, friend agtype);
+ person | friend
+---------+---------
+ "Alice" | "Alice"
+(1 row)
+
+-- Fixed-length (lower bound > 0) on a missing label must still return zero
+-- rows: there is no edge of that label, so the pattern is unsatisfiable.
+SELECT * FROM cypher('issue_2382', $$
+ MATCH (p:Person {name: 'Alice'})
+ MATCH (p)-[:NOEXIST*1..1]-(f:Person)
+ RETURN p.name AS person, f.name AS friend
+$$) AS (person agtype, friend agtype);
+ person | friend
+--------+--------
+(0 rows)
+
+-- OPTIONAL MATCH on the unsatisfiable fixed-length pattern still preserves
+-- the outer row with NULL bindings.
+SELECT * FROM cypher('issue_2382', $$
+ MATCH (p:Person {name: 'Alice'})
+ OPTIONAL MATCH (p)-[:NOEXIST*1..1]-(f:Person)
+ RETURN p.name AS person, f.name AS friend
+$$) AS (person agtype, friend agtype);
+ person | friend
+---------+--------
+ "Alice" |
+(1 row)
+
+-- Mixed pattern: a zero-bound VLE on a missing label combined with another
+-- fixed-length missing label segment must still yield zero rows. The other
+-- segment is impossible regardless of the zero-hop case.
+SELECT * FROM cypher('issue_2382', $$
+ MATCH (a:Person {name: 'Alice'})
+ MATCH (a)-[:NOEXIST*0..1]-(b:Person)-[:STILL_MISSING]-(c:Person)
+ RETURN a.name, b.name, c.name
+$$) AS (a agtype, b agtype, c agtype);
+ a | b | c
+---+---+---
+(0 rows)
+
+-- Sanity: zero-bound VLE on an EXISTING label still works the way it did
+-- before (Alice via zero-hop, Bob via 1-hop KNOWS).
+SELECT * FROM cypher('issue_2382', $$
+ MATCH (p:Person {name: 'Alice'})
+ MATCH (p)-[:KNOWS*0..1]-(f:Person)
+ RETURN p.name AS person, f.name AS friend
+ ORDER BY f.name
+$$) AS (person agtype, friend agtype);
+ person | friend
+---------+---------
+ "Alice" | "Alice"
+ "Alice" | "Bob"
+(2 rows)
+
+SELECT drop_graph('issue_2382', true);
+NOTICE: drop cascades to 4 other objects
+DETAIL: drop cascades to table issue_2382._ag_label_vertex
+drop cascades to table issue_2382._ag_label_edge
+drop cascades to table issue_2382."Person"
+drop cascades to table issue_2382."KNOWS"
+NOTICE: graph "issue_2382" has been dropped
+ drop_graph
+------------
+
+(1 row)
+
--
-- End
--
diff --git a/regress/sql/cypher_vle.sql b/regress/sql/cypher_vle.sql
index b121234e..4592a7fb 100644
--- a/regress/sql/cypher_vle.sql
+++ b/regress/sql/cypher_vle.sql
@@ -417,6 +417,75 @@ SELECT drop_graph('issue_2092', true);
DROP TABLE start_and_end_points;
SELECT drop_graph('cypher_vle', true);
+--
+-- Issue #2382: variable-length relationships with a zero lower bound must
+-- still produce the zero-hop self-binding even when the edge label does not
+-- exist in the graph (Neo4j/openCypher semantics).
+--
+SELECT create_graph('issue_2382');
+
+SELECT * FROM cypher('issue_2382', $$
+ CREATE (:Person {name: 'Alice'})-[:KNOWS]->(:Person {name: 'Bob'})
+$$) AS (v agtype);
+
+-- Plain MATCH on a non-existent edge label with [*0..N] must return the
+-- zero-hop self-binding row (Alice -> Alice). It must NOT match arbitrary
+-- edges of other labels (e.g. KNOWS).
+SELECT * FROM cypher('issue_2382', $$
+ MATCH (p:Person {name: 'Alice'})
+ MATCH (p)-[:NOEXIST*0..1]-(f:Person)
+ RETURN p.name AS person, f.name AS friend
+$$) AS (person agtype, friend agtype);
+
+-- OPTIONAL MATCH form (the exact shape from the issue report).
+SELECT * FROM cypher('issue_2382', $$
+ MATCH (p:Person {name: 'Alice'})
+ OPTIONAL MATCH (p)-[:NOEXIST*0..1]-(f:Person)
+ RETURN p.name AS person, f.name AS friend
+$$) AS (person agtype, friend agtype);
+
+-- [*0..0] still emits exactly the zero-hop self-binding.
+SELECT * FROM cypher('issue_2382', $$
+ MATCH (p:Person {name: 'Alice'})
+ MATCH (p)-[:NOEXIST*0..0]-(f:Person)
+ RETURN p.name AS person, f.name AS friend
+$$) AS (person agtype, friend agtype);
+
+-- Fixed-length (lower bound > 0) on a missing label must still return zero
+-- rows: there is no edge of that label, so the pattern is unsatisfiable.
+SELECT * FROM cypher('issue_2382', $$
+ MATCH (p:Person {name: 'Alice'})
+ MATCH (p)-[:NOEXIST*1..1]-(f:Person)
+ RETURN p.name AS person, f.name AS friend
+$$) AS (person agtype, friend agtype);
+
+-- OPTIONAL MATCH on the unsatisfiable fixed-length pattern still preserves
+-- the outer row with NULL bindings.
+SELECT * FROM cypher('issue_2382', $$
+ MATCH (p:Person {name: 'Alice'})
+ OPTIONAL MATCH (p)-[:NOEXIST*1..1]-(f:Person)
+ RETURN p.name AS person, f.name AS friend
+$$) AS (person agtype, friend agtype);
+
+-- Mixed pattern: a zero-bound VLE on a missing label combined with another
+-- fixed-length missing label segment must still yield zero rows. The other
+-- segment is impossible regardless of the zero-hop case.
+SELECT * FROM cypher('issue_2382', $$
+ MATCH (a:Person {name: 'Alice'})
+ MATCH (a)-[:NOEXIST*0..1]-(b:Person)-[:STILL_MISSING]-(c:Person)
+ RETURN a.name, b.name, c.name
+$$) AS (a agtype, b agtype, c agtype);
+
+-- Sanity: zero-bound VLE on an EXISTING label still works the way it did
+-- before (Alice via zero-hop, Bob via 1-hop KNOWS).
+SELECT * FROM cypher('issue_2382', $$
+ MATCH (p:Person {name: 'Alice'})
+ MATCH (p)-[:KNOWS*0..1]-(f:Person)
+ RETURN p.name AS person, f.name AS friend
+ ORDER BY f.name
+$$) AS (person agtype, friend agtype);
+
+SELECT drop_graph('issue_2382', true);
--
-- End
diff --git a/src/backend/parser/cypher_clause.c
b/src/backend/parser/cypher_clause.c
index 3f169718..dcd31b9d 100644
--- a/src/backend/parser/cypher_clause.c
+++ b/src/backend/parser/cypher_clause.c
@@ -132,6 +132,60 @@ static Expr *transform_cypher_edge(cypher_parsestate
*cpstate,
static Expr *transform_cypher_node(cypher_parsestate *cpstate,
cypher_node *node, List **target_list,
bool output_node, bool valid_label);
+/*
+ * Issue #2382: For variable-length relationships with a lower bound of 0
+ * (e.g., [:LABEL*0..N]), the zero-hop self-binding case must succeed even
+ * when LABEL is missing from the cache, because Neo4j/openCypher semantics
+ * say a zero-hop pattern matches the same node regardless of any edges.
+ *
+ * By the time match_check_valid_label() runs, build_VLE_relation() (in
+ * cypher_gram.y) has rewritten cypher_relationship.varlen from A_Indices
+ * into a FuncCall named "vle" whose argument list is:
+ * (start_id, end_id, edge_match_proto, lidx, uidx, dir, unique_id)
+ * so the lower-bound is the 4th argument (1-based).
+ *
+ * This helper is intentionally defensive: every assumption about the shape
+ * of the FuncCall is guarded so any parser refactor that changes it will
+ * fall back to "not zero-bound", which is the safe behaviour (the existing
+ * false-where short-circuit will still kick in for impossible patterns).
+ */
+static bool is_zero_lower_bound_vle(Node *varlen)
+{
+ FuncCall *fc;
+ String *fname;
+ Node *lidx_node;
+ A_Const *lidx;
+
+ if (varlen == NULL || !IsA(varlen, FuncCall))
+ return false;
+
+ fc = (FuncCall *) varlen;
+
+ if (list_length(fc->funcname) != 1)
+ return false;
+ fname = (String *) linitial(fc->funcname);
+ if (fname == NULL || !IsA(fname, String))
+ return false;
+ if (strcmp(strVal(fname), "vle") != 0)
+ return false;
+
+ /* args = {start, end, edge_match, lidx, uidx, dir, uniq} */
+ if (list_length(fc->args) < 5)
+ return false;
+
+ lidx_node = (Node *) list_nth(fc->args, 3);
+ if (lidx_node == NULL || !IsA(lidx_node, A_Const))
+ return false;
+
+ lidx = (A_Const *) lidx_node;
+ if (lidx->isnull)
+ return false;
+ if (lidx->val.ival.type != T_Integer)
+ return false;
+
+ return lidx->val.ival.ival == 0;
+}
+
static bool match_check_valid_label(cypher_match *match,
cypher_parsestate *cpstate);
static Node *make_vertex_expr(cypher_parsestate *cpstate,
@@ -2927,7 +2981,14 @@ static bool match_check_valid_label(cypher_match *match,
if (lcd == NULL || lcd->kind != LABEL_KIND_EDGE)
{
- return false;
+ /*
+ * Issue #2382: a missing edge label is fatal only if
+ * the pattern actually requires an edge of that label.
+ * For VLE with lower bound 0, the zero-hop self-bind
+ * case must still produce rows.
+ */
+ if (!is_zero_lower_bound_vle(rel->varlen))
+ return false;
}
}
}
@@ -5047,7 +5108,15 @@ static bool path_check_valid_label(cypher_path *path,
if (lcd == NULL || lcd->kind != LABEL_KIND_EDGE)
{
- return false;
+ /*
+ * Issue #2382: Don't invalidate the whole path just
+ * because a VLE edge with lower bound 0 references a
+ * missing label. The zero-hop self-binding semantics
+ * still allow the surrounding nodes to bind, so the
+ * other vertex labels in this path must be honoured.
+ */
+ if (!is_zero_lower_bound_vle(rel->varlen))
+ return false;
}
}
}
diff --git a/src/backend/utils/adt/age_vle.c b/src/backend/utils/adt/age_vle.c
index 3bc276cf..9e433b9e 100644
--- a/src/backend/utils/adt/age_vle.c
+++ b/src/backend/utils/adt/age_vle.c
@@ -417,6 +417,20 @@ static bool is_an_edge_match(VLE_local_context *vlelctx,
edge_entry *ee)
/* get the number of conditions from the prototype edge */
num_edge_property_constraints =
AGT_ROOT_COUNT(vlelctx->edge_property_constraint);
+ /*
+ * Issue #2382: If the user asked for a specific edge label but that label
+ * does not exist in the graph (edge_label_name_oid == InvalidOid while
+ * edge_label_name is non-NULL), no real edge can match. Returning false
+ * here ensures that for VLE patterns like [:NOEXIST*0..N] we do not
+ * traverse arbitrary other-label edges. Zero-hop self-binding is handled
+ * separately via build_VLE_zero_container() so this does not break it.
+ */
+ if (vlelctx->edge_label_name != NULL &&
+ vlelctx->edge_label_name_oid == InvalidOid)
+ {
+ return false;
+ }
+
/*
* We only care about verifying that we have all of the property
conditions.
* We don't care about extra unmatched properties. If there aren't any edge