Skip to content

Commit cefdd1a

Browse files
committed
Fix VLE [*0..N] zero-hop self-binding when edge label is missing (#2382)
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 54e19fa commit cefdd1a

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

29012955
if (lcd == NULL || lcd->kind != LABEL_KIND_EDGE)
29022956
{
2903-
return false;
2957+
/*
2958+
* Issue #2382: a missing edge label is fatal only if
2959+
* the pattern actually requires an edge of that label.
2960+
* For VLE with lower bound 0, the zero-hop self-bind
2961+
* case must still produce rows.
2962+
*/
2963+
if (!is_zero_lower_bound_vle(rel->varlen))
2964+
return false;
29042965
}
29052966
}
29062967
}
@@ -4967,7 +5028,15 @@ static bool path_check_valid_label(cypher_path *path,
49675028

49685029
if (lcd == NULL || lcd->kind != LABEL_KIND_EDGE)
49695030
{
4970-
return false;
5031+
/*
5032+
* Issue #2382: Don't invalidate the whole path just
5033+
* because a VLE edge with lower bound 0 references a
5034+
* missing label. The zero-hop self-binding semantics
5035+
* still allow the surrounding nodes to bind, so the
5036+
* other vertex labels in this path must be honoured.
5037+
*/
5038+
if (!is_zero_lower_bound_vle(rel->varlen))
5039+
return false;
49715040
}
49725041
}
49735042
}

src/backend/utils/adt/age_vle.c

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

387+
/*
388+
* Issue #2382: If the user asked for a specific edge label but that label
389+
* does not exist in the graph (edge_label_name_oid == InvalidOid while
390+
* edge_label_name is non-NULL), no real edge can match. Returning false
391+
* here ensures that for VLE patterns like [:NOEXIST*0..N] we do not
392+
* traverse arbitrary other-label edges. Zero-hop self-binding is handled
393+
* separately via build_VLE_zero_container() so this does not break it.
394+
*/
395+
if (vlelctx->edge_label_name != NULL &&
396+
vlelctx->edge_label_name_oid == InvalidOid)
397+
{
398+
return false;
399+
}
400+
387401
/*
388402
* We only care about verifying that we have all of the property conditions.
389403
* We don't care about extra unmatched properties. If there aren't any edge

0 commit comments

Comments
 (0)