From 30c0d8e8021a26930a80d328abe161cec747e6c7 Mon Sep 17 00:00:00 2001 From: Yyunozor Date: Wed, 29 Jul 2026 21:51:45 +0000 Subject: [PATCH] Fix leading OPTIONAL MATCH dropping the row when the pattern has no match A query that begins with OPTIONAL MATCH returned no rows when its pattern matched nothing: OPTIONAL MATCH (n:NoSuchLabel) RETURN n -- returned 0 rows Cypher gives every OPTIONAL MATCH left join semantics. With no clause to its left it joins against the implicit single row that begins a query, so the pattern above must still emit one row with n = NULL. That is what distinguishes OPTIONAL MATCH from MATCH. transform_cypher_match_pattern builds the null-preserving LATERAL LEFT JOIN out of the previous clause, and falls through to the plain MATCH path when there is none. A leading OPTIONAL MATCH has no previous clause, so it took the plain path. Any preceding clause, even a trivial one, already produced the correct result: WITH 1 AS x OPTIONAL MATCH (n:NoSuchLabel) RETURN n -- 1 row, n = NULL So the join itself was already right; only its left side was missing. This gives a leading OPTIONAL MATCH a previous clause that produces exactly one row, built the way transform_cypher_with already builds a synthesized cypher_return. Its lone column is named with the hidden variable prefix, so expand_pnsi_attrs keeps it out of RETURN * expansion. The clause is attached in analyze_cypher rather than in the transform because the transform temporarily clears prev to recurse into the join right side, and relies on prev being NULL there to take the plain MATCH path; synthesizing a prev inside the transform re-arms that recursion indefinitely. Tests cover the no-match case for node and relationship patterns, a WHERE that no binding survives, that a matching pattern gains no extra null row, that RETURN * does not expose the internal column, that a leading MATCH still returns no rows, and that MATCH following OPTIONAL MATCH still errors. Closes #2473 --- regress/expected/cypher_match.out | 110 ++++++++++++++++++++++++++++ regress/sql/cypher_match.sql | 66 +++++++++++++++++ src/backend/parser/cypher_analyze.c | 20 +++++ src/backend/parser/cypher_clause.c | 37 ++++++++++ src/include/parser/cypher_clause.h | 1 + 5 files changed, 234 insertions(+) diff --git a/regress/expected/cypher_match.out b/regress/expected/cypher_match.out index ab51486b3..f82acf05b 100644 --- a/regress/expected/cypher_match.out +++ b/regress/expected/cypher_match.out @@ -3956,6 +3956,116 @@ NOTICE: graph "issue_2378" has been dropped (1 row) +-- +-- Issue 2473: a query that begins with OPTIONAL MATCH drops the row when the +-- pattern has no match. +-- +-- Cypher gives every OPTIONAL MATCH left join semantics. With no clause to +-- its left it joins against the implicit single row that begins a query, so a +-- leading OPTIONAL MATCH whose pattern matches nothing must still emit one +-- row, with NULLs in the optional columns. Before the fix the transform had +-- no previous clause to join against and fell through to the plain MATCH +-- path, which returns no rows at all. +-- +SELECT create_graph('issue_2473'); +NOTICE: graph "issue_2473" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('issue_2473', $$ + CREATE (:Person {name: 'Alice'}), + (:Person {name: 'Bob'}) +$$) AS (v agtype); + v +--- +(0 rows) + +-- No vertex carries this label; expect one row with n = NULL. +SELECT * FROM cypher('issue_2473', $$ + OPTIONAL MATCH (n:NoSuchLabel) + RETURN n +$$) AS (n agtype); + n +--- + +(1 row) + +-- The pattern does match; expect exactly the matching rows, and no extra +-- null-filled row from the synthesized left side. +SELECT * FROM cypher('issue_2473', $$ + OPTIONAL MATCH (n:Person) + RETURN n.name AS name + ORDER BY name +$$) AS (name agtype); + name +--------- + "Alice" + "Bob" +(2 rows) + +-- Relationship pattern with no match; every optional column is NULL. +SELECT * FROM cypher('issue_2473', $$ + OPTIONAL MATCH (a:Person)-[r:NoSuchEdge]->(b) + RETURN a.name AS name, r, b +$$) AS (name agtype, r agtype, b agtype); + name | r | b +------+---+--- + | | +(1 row) + +-- A WHERE that no binding survives still preserves the row. +SELECT * FROM cypher('issue_2473', $$ + OPTIONAL MATCH (n:Person) + WHERE n.name = 'Nobody' + RETURN n +$$) AS (n agtype); + n +--- + +(1 row) + +-- The synthesized left side is internal; RETURN * must not expose it. +SELECT * FROM cypher('issue_2473', $$ + OPTIONAL MATCH (n:NoSuchLabel) + RETURN * +$$) AS (n agtype); + n +--- + +(1 row) + +-- A leading MATCH is unaffected and still returns no rows. +SELECT * FROM cypher('issue_2473', $$ + MATCH (n:NoSuchLabel) + RETURN n +$$) AS (n agtype); + n +--- +(0 rows) + +-- The guard against MATCH following OPTIONAL MATCH still applies when the +-- OPTIONAL MATCH leads the query. +SELECT * FROM cypher('issue_2473', $$ + OPTIONAL MATCH (n:Person) + MATCH (m:Person) + RETURN n, m +$$) AS (n agtype, m agtype); +ERROR: MATCH cannot follow OPTIONAL MATCH +LINE 1: SELECT * FROM cypher('issue_2473', $$ + ^ +SELECT drop_graph('issue_2473', true); +NOTICE: drop cascades to 3 other objects +DETAIL: drop cascades to table issue_2473._ag_label_vertex +drop cascades to table issue_2473._ag_label_edge +drop cascades to table issue_2473."Person" +NOTICE: graph "issue_2473" has been dropped + drop_graph +------------ + +(1 row) + -- -- Clean up -- diff --git a/regress/sql/cypher_match.sql b/regress/sql/cypher_match.sql index 8da012ff8..8c9ebd6e3 100644 --- a/regress/sql/cypher_match.sql +++ b/regress/sql/cypher_match.sql @@ -1689,6 +1689,72 @@ $$) AS (name agtype, friend agtype); SELECT drop_graph('issue_2378', true); +-- +-- Issue 2473: a query that begins with OPTIONAL MATCH drops the row when the +-- pattern has no match. +-- +-- Cypher gives every OPTIONAL MATCH left join semantics. With no clause to +-- its left it joins against the implicit single row that begins a query, so a +-- leading OPTIONAL MATCH whose pattern matches nothing must still emit one +-- row, with NULLs in the optional columns. Before the fix the transform had +-- no previous clause to join against and fell through to the plain MATCH +-- path, which returns no rows at all. +-- +SELECT create_graph('issue_2473'); +SELECT * FROM cypher('issue_2473', $$ + CREATE (:Person {name: 'Alice'}), + (:Person {name: 'Bob'}) +$$) AS (v agtype); + +-- No vertex carries this label; expect one row with n = NULL. +SELECT * FROM cypher('issue_2473', $$ + OPTIONAL MATCH (n:NoSuchLabel) + RETURN n +$$) AS (n agtype); + +-- The pattern does match; expect exactly the matching rows, and no extra +-- null-filled row from the synthesized left side. +SELECT * FROM cypher('issue_2473', $$ + OPTIONAL MATCH (n:Person) + RETURN n.name AS name + ORDER BY name +$$) AS (name agtype); + +-- Relationship pattern with no match; every optional column is NULL. +SELECT * FROM cypher('issue_2473', $$ + OPTIONAL MATCH (a:Person)-[r:NoSuchEdge]->(b) + RETURN a.name AS name, r, b +$$) AS (name agtype, r agtype, b agtype); + +-- A WHERE that no binding survives still preserves the row. +SELECT * FROM cypher('issue_2473', $$ + OPTIONAL MATCH (n:Person) + WHERE n.name = 'Nobody' + RETURN n +$$) AS (n agtype); + +-- The synthesized left side is internal; RETURN * must not expose it. +SELECT * FROM cypher('issue_2473', $$ + OPTIONAL MATCH (n:NoSuchLabel) + RETURN * +$$) AS (n agtype); + +-- A leading MATCH is unaffected and still returns no rows. +SELECT * FROM cypher('issue_2473', $$ + MATCH (n:NoSuchLabel) + RETURN n +$$) AS (n agtype); + +-- The guard against MATCH following OPTIONAL MATCH still applies when the +-- OPTIONAL MATCH leads the query. +SELECT * FROM cypher('issue_2473', $$ + OPTIONAL MATCH (n:Person) + MATCH (m:Person) + RETURN n, m +$$) AS (n agtype, m agtype); + +SELECT drop_graph('issue_2473', true); + -- -- Clean up -- diff --git a/src/backend/parser/cypher_analyze.c b/src/backend/parser/cypher_analyze.c index 5dd53dcd0..39422883e 100644 --- a/src/backend/parser/cypher_analyze.c +++ b/src/backend/parser/cypher_analyze.c @@ -966,6 +966,26 @@ static Query *analyze_cypher(List *stmt, ParseState *parent_pstate, * the clauses is inverted. */ clause = NULL; + + /* + * A query that begins with OPTIONAL MATCH still has to emit a row. + * openCypher gives every OPTIONAL MATCH left join semantics; with no + * clause to its left it joins against the implicit single row that + * begins a query, so a pattern matching nothing yields one row of nulls + * rather than no rows at all. transform_cypher_match_pattern builds that + * join out of the previous clause, so give the leading OPTIONAL MATCH a + * previous clause that produces exactly one row. + * + * This has to happen here rather than in the transform: the transform + * temporarily clears prev to recurse into the join's right side, and + * relies on prev being NULL there to take the plain MATCH path. + */ + if (stmt != NIL && is_ag_node(linitial(stmt), cypher_match) && + ((cypher_match *)linitial(stmt))->optional) + { + clause = make_optional_match_unit_clause(); + } + foreach (lc, stmt) { cypher_clause *next; diff --git a/src/backend/parser/cypher_clause.c b/src/backend/parser/cypher_clause.c index 147e3e74e..9560a04c1 100644 --- a/src/backend/parser/cypher_clause.c +++ b/src/backend/parser/cypher_clause.c @@ -78,6 +78,7 @@ #define AGE_VARNAME_DELETE_CLAUSE AGE_DEFAULT_VARNAME_PREFIX"delete_clause" #define AGE_VARNAME_MERGE_CLAUSE AGE_DEFAULT_VARNAME_PREFIX"merge_clause" #define AGE_VARNAME_ID AGE_DEFAULT_VARNAME_PREFIX"id" +#define AGE_VARNAME_OPTIONAL_MATCH_UNIT AGE_DEFAULT_VARNAME_PREFIX"optional_match_unit" #define AGE_VARNAME_SET_CLAUSE AGE_DEFAULT_VARNAME_PREFIX"set_clause" #define AGE_VARNAME_SET_VALUE AGE_DEFAULT_VARNAME_PREFIX"set_value" @@ -3991,6 +3992,42 @@ static void get_res_cols(ParseState *pstate, ParseNamespaceItem *l_pnsi, *res_colvars = list_concat(*res_colvars, colvars); } +/* + * make_optional_match_unit_clause + * Build a clause that yields exactly one row, with no user-visible + * columns, to stand in as the left side of a leading OPTIONAL MATCH. + * + * This mirrors transform_cypher_with, which likewise wraps a synthesized + * cypher_return in a cypher_clause. The single projected item is a null + * constant named with the hidden variable prefix, so expand_pnsi_attrs keeps + * it out of RETURN * expansion. + */ +cypher_clause *make_optional_match_unit_clause(void) +{ + A_Const *unit_value; + ResTarget *unit_item; + cypher_return *return_clause; + cypher_clause *wrapper; + + unit_value = makeNode(A_Const); + unit_value->isnull = true; + unit_value->location = -1; + + unit_item = makeNode(ResTarget); + unit_item->name = pstrdup(AGE_VARNAME_OPTIONAL_MATCH_UNIT); + unit_item->indirection = NIL; + unit_item->val = (Node *)unit_value; + unit_item->location = -1; + + return_clause = make_ag_node(cypher_return); + return_clause->items = list_make1(unit_item); + + wrapper = palloc0(sizeof(*wrapper)); + wrapper->self = (Node *)return_clause; + + return wrapper; +} + /* * transform_cypher_optional_match_clause * Transform the previous clauses and OPTIONAL MATCH clauses to be LATERAL LEFT JOIN diff --git a/src/include/parser/cypher_clause.h b/src/include/parser/cypher_clause.h index d0fc3ca22..90d5ae461 100644 --- a/src/include/parser/cypher_clause.h +++ b/src/include/parser/cypher_clause.h @@ -32,6 +32,7 @@ struct cypher_clause cypher_clause *prev; /* previous clause */ }; +cypher_clause *make_optional_match_unit_clause(void); Query *transform_cypher_clause(cypher_parsestate *cpstate, cypher_clause *clause);