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);