Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions regress/expected/cypher_match.out
Original file line number Diff line number Diff line change
Expand Up @@ -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
--
Expand Down
66 changes: 66 additions & 0 deletions regress/sql/cypher_match.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
--
Expand Down
20 changes: 20 additions & 0 deletions src/backend/parser/cypher_analyze.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
37 changes: 37 additions & 0 deletions src/backend/parser/cypher_clause.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/include/parser/cypher_clause.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down