Skip to content

Commit 12e2a31

Browse files
authored
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).
1 parent 23cbe57 commit 12e2a31

4 files changed

Lines changed: 269 additions & 2 deletions

File tree

regress/expected/cypher_vle.out

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,6 +1219,121 @@ NOTICE: graph "cypher_vle" has been dropped
12191219

12201220
(1 row)
12211221

1222+
--
1223+
-- Issue #2382: variable-length relationships with a zero lower bound must
1224+
-- still produce the zero-hop self-binding even when the edge label does not
1225+
-- exist in the graph (Neo4j/openCypher semantics).
1226+
--
1227+
SELECT create_graph('issue_2382');
1228+
NOTICE: graph "issue_2382" has been created
1229+
create_graph
1230+
--------------
1231+
1232+
(1 row)
1233+
1234+
SELECT * FROM cypher('issue_2382', $$
1235+
CREATE (:Person {name: 'Alice'})-[:KNOWS]->(:Person {name: 'Bob'})
1236+
$$) AS (v agtype);
1237+
v
1238+
---
1239+
(0 rows)
1240+
1241+
-- Plain MATCH on a non-existent edge label with [*0..N] must return the
1242+
-- zero-hop self-binding row (Alice -> Alice). It must NOT match arbitrary
1243+
-- edges of other labels (e.g. KNOWS).
1244+
SELECT * FROM cypher('issue_2382', $$
1245+
MATCH (p:Person {name: 'Alice'})
1246+
MATCH (p)-[:NOEXIST*0..1]-(f:Person)
1247+
RETURN p.name AS person, f.name AS friend
1248+
$$) AS (person agtype, friend agtype);
1249+
person | friend
1250+
---------+---------
1251+
"Alice" | "Alice"
1252+
(1 row)
1253+
1254+
-- OPTIONAL MATCH form (the exact shape from the issue report).
1255+
SELECT * FROM cypher('issue_2382', $$
1256+
MATCH (p:Person {name: 'Alice'})
1257+
OPTIONAL MATCH (p)-[:NOEXIST*0..1]-(f:Person)
1258+
RETURN p.name AS person, f.name AS friend
1259+
$$) AS (person agtype, friend agtype);
1260+
person | friend
1261+
---------+---------
1262+
"Alice" | "Alice"
1263+
(1 row)
1264+
1265+
-- [*0..0] still emits exactly the zero-hop self-binding.
1266+
SELECT * FROM cypher('issue_2382', $$
1267+
MATCH (p:Person {name: 'Alice'})
1268+
MATCH (p)-[:NOEXIST*0..0]-(f:Person)
1269+
RETURN p.name AS person, f.name AS friend
1270+
$$) AS (person agtype, friend agtype);
1271+
person | friend
1272+
---------+---------
1273+
"Alice" | "Alice"
1274+
(1 row)
1275+
1276+
-- Fixed-length (lower bound > 0) on a missing label must still return zero
1277+
-- rows: there is no edge of that label, so the pattern is unsatisfiable.
1278+
SELECT * FROM cypher('issue_2382', $$
1279+
MATCH (p:Person {name: 'Alice'})
1280+
MATCH (p)-[:NOEXIST*1..1]-(f:Person)
1281+
RETURN p.name AS person, f.name AS friend
1282+
$$) AS (person agtype, friend agtype);
1283+
person | friend
1284+
--------+--------
1285+
(0 rows)
1286+
1287+
-- OPTIONAL MATCH on the unsatisfiable fixed-length pattern still preserves
1288+
-- the outer row with NULL bindings.
1289+
SELECT * FROM cypher('issue_2382', $$
1290+
MATCH (p:Person {name: 'Alice'})
1291+
OPTIONAL MATCH (p)-[:NOEXIST*1..1]-(f:Person)
1292+
RETURN p.name AS person, f.name AS friend
1293+
$$) AS (person agtype, friend agtype);
1294+
person | friend
1295+
---------+--------
1296+
"Alice" |
1297+
(1 row)
1298+
1299+
-- Mixed pattern: a zero-bound VLE on a missing label combined with another
1300+
-- fixed-length missing label segment must still yield zero rows. The other
1301+
-- segment is impossible regardless of the zero-hop case.
1302+
SELECT * FROM cypher('issue_2382', $$
1303+
MATCH (a:Person {name: 'Alice'})
1304+
MATCH (a)-[:NOEXIST*0..1]-(b:Person)-[:STILL_MISSING]-(c:Person)
1305+
RETURN a.name, b.name, c.name
1306+
$$) AS (a agtype, b agtype, c agtype);
1307+
a | b | c
1308+
---+---+---
1309+
(0 rows)
1310+
1311+
-- Sanity: zero-bound VLE on an EXISTING label still works the way it did
1312+
-- before (Alice via zero-hop, Bob via 1-hop KNOWS).
1313+
SELECT * FROM cypher('issue_2382', $$
1314+
MATCH (p:Person {name: 'Alice'})
1315+
MATCH (p)-[:KNOWS*0..1]-(f:Person)
1316+
RETURN p.name AS person, f.name AS friend
1317+
ORDER BY f.name
1318+
$$) AS (person agtype, friend agtype);
1319+
person | friend
1320+
---------+---------
1321+
"Alice" | "Alice"
1322+
"Alice" | "Bob"
1323+
(2 rows)
1324+
1325+
SELECT drop_graph('issue_2382', true);
1326+
NOTICE: drop cascades to 4 other objects
1327+
DETAIL: drop cascades to table issue_2382._ag_label_vertex
1328+
drop cascades to table issue_2382._ag_label_edge
1329+
drop cascades to table issue_2382."Person"
1330+
drop cascades to table issue_2382."KNOWS"
1331+
NOTICE: graph "issue_2382" has been dropped
1332+
drop_graph
1333+
------------
1334+
1335+
(1 row)
1336+
12221337
--
12231338
-- End
12241339
--

regress/sql/cypher_vle.sql

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,75 @@ SELECT drop_graph('issue_2092', true);
417417
DROP TABLE start_and_end_points;
418418

419419
SELECT drop_graph('cypher_vle', true);
420+
--
421+
-- Issue #2382: variable-length relationships with a zero lower bound must
422+
-- still produce the zero-hop self-binding even when the edge label does not
423+
-- exist in the graph (Neo4j/openCypher semantics).
424+
--
425+
SELECT create_graph('issue_2382');
426+
427+
SELECT * FROM cypher('issue_2382', $$
428+
CREATE (:Person {name: 'Alice'})-[:KNOWS]->(:Person {name: 'Bob'})
429+
$$) AS (v agtype);
430+
431+
-- Plain MATCH on a non-existent edge label with [*0..N] must return the
432+
-- zero-hop self-binding row (Alice -> Alice). It must NOT match arbitrary
433+
-- edges of other labels (e.g. KNOWS).
434+
SELECT * FROM cypher('issue_2382', $$
435+
MATCH (p:Person {name: 'Alice'})
436+
MATCH (p)-[:NOEXIST*0..1]-(f:Person)
437+
RETURN p.name AS person, f.name AS friend
438+
$$) AS (person agtype, friend agtype);
439+
440+
-- OPTIONAL MATCH form (the exact shape from the issue report).
441+
SELECT * FROM cypher('issue_2382', $$
442+
MATCH (p:Person {name: 'Alice'})
443+
OPTIONAL MATCH (p)-[:NOEXIST*0..1]-(f:Person)
444+
RETURN p.name AS person, f.name AS friend
445+
$$) AS (person agtype, friend agtype);
446+
447+
-- [*0..0] still emits exactly the zero-hop self-binding.
448+
SELECT * FROM cypher('issue_2382', $$
449+
MATCH (p:Person {name: 'Alice'})
450+
MATCH (p)-[:NOEXIST*0..0]-(f:Person)
451+
RETURN p.name AS person, f.name AS friend
452+
$$) AS (person agtype, friend agtype);
453+
454+
-- Fixed-length (lower bound > 0) on a missing label must still return zero
455+
-- rows: there is no edge of that label, so the pattern is unsatisfiable.
456+
SELECT * FROM cypher('issue_2382', $$
457+
MATCH (p:Person {name: 'Alice'})
458+
MATCH (p)-[:NOEXIST*1..1]-(f:Person)
459+
RETURN p.name AS person, f.name AS friend
460+
$$) AS (person agtype, friend agtype);
461+
462+
-- OPTIONAL MATCH on the unsatisfiable fixed-length pattern still preserves
463+
-- the outer row with NULL bindings.
464+
SELECT * FROM cypher('issue_2382', $$
465+
MATCH (p:Person {name: 'Alice'})
466+
OPTIONAL MATCH (p)-[:NOEXIST*1..1]-(f:Person)
467+
RETURN p.name AS person, f.name AS friend
468+
$$) AS (person agtype, friend agtype);
469+
470+
-- Mixed pattern: a zero-bound VLE on a missing label combined with another
471+
-- fixed-length missing label segment must still yield zero rows. The other
472+
-- segment is impossible regardless of the zero-hop case.
473+
SELECT * FROM cypher('issue_2382', $$
474+
MATCH (a:Person {name: 'Alice'})
475+
MATCH (a)-[:NOEXIST*0..1]-(b:Person)-[:STILL_MISSING]-(c:Person)
476+
RETURN a.name, b.name, c.name
477+
$$) AS (a agtype, b agtype, c agtype);
478+
479+
-- Sanity: zero-bound VLE on an EXISTING label still works the way it did
480+
-- before (Alice via zero-hop, Bob via 1-hop KNOWS).
481+
SELECT * FROM cypher('issue_2382', $$
482+
MATCH (p:Person {name: 'Alice'})
483+
MATCH (p)-[:KNOWS*0..1]-(f:Person)
484+
RETURN p.name AS person, f.name AS friend
485+
ORDER BY f.name
486+
$$) AS (person agtype, friend agtype);
487+
488+
SELECT drop_graph('issue_2382', true);
420489

421490
--
422491
-- End

src/backend/parser/cypher_clause.c

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,60 @@ static Expr *transform_cypher_edge(cypher_parsestate *cpstate,
132132
static Expr *transform_cypher_node(cypher_parsestate *cpstate,
133133
cypher_node *node, List **target_list,
134134
bool output_node, bool valid_label);
135+
/*
136+
* Issue #2382: For variable-length relationships with a lower bound of 0
137+
* (e.g., [:LABEL*0..N]), the zero-hop self-binding case must succeed even
138+
* when LABEL is missing from the cache, because Neo4j/openCypher semantics
139+
* say a zero-hop pattern matches the same node regardless of any edges.
140+
*
141+
* By the time match_check_valid_label() runs, build_VLE_relation() (in
142+
* cypher_gram.y) has rewritten cypher_relationship.varlen from A_Indices
143+
* into a FuncCall named "vle" whose argument list is:
144+
* (start_id, end_id, edge_match_proto, lidx, uidx, dir, unique_id)
145+
* so the lower-bound is the 4th argument (1-based).
146+
*
147+
* This helper is intentionally defensive: every assumption about the shape
148+
* of the FuncCall is guarded so any parser refactor that changes it will
149+
* fall back to "not zero-bound", which is the safe behaviour (the existing
150+
* false-where short-circuit will still kick in for impossible patterns).
151+
*/
152+
static bool is_zero_lower_bound_vle(Node *varlen)
153+
{
154+
FuncCall *fc;
155+
String *fname;
156+
Node *lidx_node;
157+
A_Const *lidx;
158+
159+
if (varlen == NULL || !IsA(varlen, FuncCall))
160+
return false;
161+
162+
fc = (FuncCall *) varlen;
163+
164+
if (list_length(fc->funcname) != 1)
165+
return false;
166+
fname = (String *) linitial(fc->funcname);
167+
if (fname == NULL || !IsA(fname, String))
168+
return false;
169+
if (strcmp(strVal(fname), "vle") != 0)
170+
return false;
171+
172+
/* args = {start, end, edge_match, lidx, uidx, dir, uniq} */
173+
if (list_length(fc->args) < 5)
174+
return false;
175+
176+
lidx_node = (Node *) list_nth(fc->args, 3);
177+
if (lidx_node == NULL || !IsA(lidx_node, A_Const))
178+
return false;
179+
180+
lidx = (A_Const *) lidx_node;
181+
if (lidx->isnull)
182+
return false;
183+
if (lidx->val.ival.type != T_Integer)
184+
return false;
185+
186+
return lidx->val.ival.ival == 0;
187+
}
188+
135189
static bool match_check_valid_label(cypher_match *match,
136190
cypher_parsestate *cpstate);
137191
static Node *make_vertex_expr(cypher_parsestate *cpstate,
@@ -2927,7 +2981,14 @@ static bool match_check_valid_label(cypher_match *match,
29272981

29282982
if (lcd == NULL || lcd->kind != LABEL_KIND_EDGE)
29292983
{
2930-
return false;
2984+
/*
2985+
* Issue #2382: a missing edge label is fatal only if
2986+
* the pattern actually requires an edge of that label.
2987+
* For VLE with lower bound 0, the zero-hop self-bind
2988+
* case must still produce rows.
2989+
*/
2990+
if (!is_zero_lower_bound_vle(rel->varlen))
2991+
return false;
29312992
}
29322993
}
29332994
}
@@ -5047,7 +5108,15 @@ static bool path_check_valid_label(cypher_path *path,
50475108

50485109
if (lcd == NULL || lcd->kind != LABEL_KIND_EDGE)
50495110
{
5050-
return false;
5111+
/*
5112+
* Issue #2382: Don't invalidate the whole path just
5113+
* because a VLE edge with lower bound 0 references a
5114+
* missing label. The zero-hop self-binding semantics
5115+
* still allow the surrounding nodes to bind, so the
5116+
* other vertex labels in this path must be honoured.
5117+
*/
5118+
if (!is_zero_lower_bound_vle(rel->varlen))
5119+
return false;
50515120
}
50525121
}
50535122
}

src/backend/utils/adt/age_vle.c

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,20 @@ static bool is_an_edge_match(VLE_local_context *vlelctx, edge_entry *ee)
417417
/* get the number of conditions from the prototype edge */
418418
num_edge_property_constraints = AGT_ROOT_COUNT(vlelctx->edge_property_constraint);
419419

420+
/*
421+
* Issue #2382: If the user asked for a specific edge label but that label
422+
* does not exist in the graph (edge_label_name_oid == InvalidOid while
423+
* edge_label_name is non-NULL), no real edge can match. Returning false
424+
* here ensures that for VLE patterns like [:NOEXIST*0..N] we do not
425+
* traverse arbitrary other-label edges. Zero-hop self-binding is handled
426+
* separately via build_VLE_zero_container() so this does not break it.
427+
*/
428+
if (vlelctx->edge_label_name != NULL &&
429+
vlelctx->edge_label_name_oid == InvalidOid)
430+
{
431+
return false;
432+
}
433+
420434
/*
421435
* We only care about verifying that we have all of the property conditions.
422436
* We don't care about extra unmatched properties. If there aren't any edge

0 commit comments

Comments
 (0)