Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
129 changes: 129 additions & 0 deletions regress/expected/cypher_merge.out
Original file line number Diff line number Diff line change
Expand Up @@ -1888,9 +1888,138 @@ SELECT * FROM cypher('issue_1446', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype
---
(0 rows)

--
-- ON CREATE SET / ON MATCH SET tests (issue #1619)
--
SELECT create_graph('merge_actions');
NOTICE: graph "merge_actions" has been created
create_graph
--------------

(1 row)

-- Basic ON CREATE SET: first run creates the node
SELECT * FROM cypher('merge_actions', $$
MERGE (n:Person {name: 'Alice'})
ON CREATE SET n.created = true
RETURN n.name, n.created
$$) AS (name agtype, created agtype);
name | created
---------+---------
"Alice" | true
(1 row)

-- ON MATCH SET: second run matches the existing node
SELECT * FROM cypher('merge_actions', $$
MERGE (n:Person {name: 'Alice'})
ON MATCH SET n.found = true
RETURN n.name, n.created, n.found
$$) AS (name agtype, created agtype, found agtype);
name | created | found
---------+---------+-------
"Alice" | true | true
(1 row)

-- Both ON CREATE SET and ON MATCH SET (first run = create)
SELECT * FROM cypher('merge_actions', $$
MERGE (n:Person {name: 'Bob'})
ON CREATE SET n.created = true
ON MATCH SET n.matched = true
RETURN n.name, n.created, n.matched
$$) AS (name agtype, created agtype, matched agtype);
name | created | matched
-------+---------+---------
"Bob" | true |
(1 row)

-- Both ON CREATE SET and ON MATCH SET (second run = match)
SELECT * FROM cypher('merge_actions', $$
MERGE (n:Person {name: 'Bob'})
ON CREATE SET n.created = true
ON MATCH SET n.matched = true
RETURN n.name, n.created, n.matched
$$) AS (name agtype, created agtype, matched agtype);
name | created | matched
-------+---------+---------
"Bob" | true | true
(1 row)

-- ON MATCH SET with MERGE after MATCH (Case 1: has predecessor)
SELECT * FROM cypher('merge_actions', $$
MATCH (a:Person {name: 'Alice'})
MERGE (a)-[:KNOWS]->(b:Person {name: 'Charlie'})
ON CREATE SET b.source = 'merge_create'
RETURN a.name, b.name, b.source
$$) AS (a agtype, b agtype, source agtype);
a | b | source
---------+-----------+----------------
"Alice" | "Charlie" | "merge_create"
(1 row)

-- Multiple SET items in a single ON CREATE SET
SELECT * FROM cypher('merge_actions', $$
MERGE (n:Person {name: 'Dave'})
ON CREATE SET n.a = 1, n.b = 2
RETURN n.name, n.a, n.b
$$) AS (name agtype, a agtype, b agtype);
name | a | b
--------+---+---
"Dave" | 1 | 2
(1 row)

-- Reverse order: ON MATCH before ON CREATE should work
SELECT * FROM cypher('merge_actions', $$
MERGE (n:Person {name: 'Eve'})
ON MATCH SET n.seen = true
ON CREATE SET n.new = true
RETURN n.name, n.new
$$) AS (name agtype, new agtype);
name | new
-------+------
"Eve" | true
(1 row)

-- Error: ON CREATE SET specified more than once
SELECT * FROM cypher('merge_actions', $$
MERGE (n:Person {name: 'Bad'})
ON CREATE SET n.a = 1
ON CREATE SET n.b = 2
RETURN n
$$) AS (n agtype);
ERROR: ON CREATE SET specified more than once
LINE 1: SELECT * FROM cypher('merge_actions', $$
^
-- Error: ON MATCH SET specified more than once
SELECT * FROM cypher('merge_actions', $$
MERGE (n:Person {name: 'Bad'})
ON MATCH SET n.a = 1
ON MATCH SET n.b = 2
RETURN n
$$) AS (n agtype);
ERROR: ON MATCH SET specified more than once
LINE 1: SELECT * FROM cypher('merge_actions', $$
^
-- cleanup
SELECT * FROM cypher('merge_actions', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype);
a
---
(0 rows)

--
-- delete graphs
--
SELECT drop_graph('merge_actions', true);
NOTICE: drop cascades to 4 other objects
DETAIL: drop cascades to table merge_actions._ag_label_vertex
drop cascades to table merge_actions._ag_label_edge
drop cascades to table merge_actions."Person"
drop cascades to table merge_actions."KNOWS"
NOTICE: graph "merge_actions" has been dropped
drop_graph
------------

(1 row)

SELECT drop_graph('issue_1907', true);
NOTICE: drop cascades to 4 other objects
DETAIL: drop cascades to table issue_1907._ag_label_vertex
Expand Down
78 changes: 78 additions & 0 deletions regress/sql/cypher_merge.sql
Original file line number Diff line number Diff line change
Expand Up @@ -868,9 +868,87 @@ SELECT * FROM cypher('issue_1630', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype
SELECT * FROM cypher('issue_1709', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype);
SELECT * FROM cypher('issue_1446', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype);

--
-- ON CREATE SET / ON MATCH SET tests (issue #1619)
--
SELECT create_graph('merge_actions');

-- Basic ON CREATE SET: first run creates the node
SELECT * FROM cypher('merge_actions', $$
MERGE (n:Person {name: 'Alice'})
ON CREATE SET n.created = true
RETURN n.name, n.created
$$) AS (name agtype, created agtype);

-- ON MATCH SET: second run matches the existing node
SELECT * FROM cypher('merge_actions', $$
MERGE (n:Person {name: 'Alice'})
ON MATCH SET n.found = true
RETURN n.name, n.created, n.found
$$) AS (name agtype, created agtype, found agtype);

-- Both ON CREATE SET and ON MATCH SET (first run = create)
SELECT * FROM cypher('merge_actions', $$
MERGE (n:Person {name: 'Bob'})
ON CREATE SET n.created = true
ON MATCH SET n.matched = true
RETURN n.name, n.created, n.matched
$$) AS (name agtype, created agtype, matched agtype);

-- Both ON CREATE SET and ON MATCH SET (second run = match)
SELECT * FROM cypher('merge_actions', $$
MERGE (n:Person {name: 'Bob'})
ON CREATE SET n.created = true
ON MATCH SET n.matched = true
RETURN n.name, n.created, n.matched
$$) AS (name agtype, created agtype, matched agtype);

-- ON MATCH SET with MERGE after MATCH (Case 1: has predecessor)
SELECT * FROM cypher('merge_actions', $$
MATCH (a:Person {name: 'Alice'})
MERGE (a)-[:KNOWS]->(b:Person {name: 'Charlie'})
ON CREATE SET b.source = 'merge_create'
RETURN a.name, b.name, b.source
$$) AS (a agtype, b agtype, source agtype);

-- Multiple SET items in a single ON CREATE SET
SELECT * FROM cypher('merge_actions', $$
MERGE (n:Person {name: 'Dave'})
ON CREATE SET n.a = 1, n.b = 2
RETURN n.name, n.a, n.b
$$) AS (name agtype, a agtype, b agtype);

-- Reverse order: ON MATCH before ON CREATE should work
SELECT * FROM cypher('merge_actions', $$
MERGE (n:Person {name: 'Eve'})
ON MATCH SET n.seen = true
ON CREATE SET n.new = true
RETURN n.name, n.new
$$) AS (name agtype, new agtype);

-- Error: ON CREATE SET specified more than once
SELECT * FROM cypher('merge_actions', $$
MERGE (n:Person {name: 'Bad'})
ON CREATE SET n.a = 1
ON CREATE SET n.b = 2
RETURN n
$$) AS (n agtype);

-- Error: ON MATCH SET specified more than once
SELECT * FROM cypher('merge_actions', $$
MERGE (n:Person {name: 'Bad'})
ON MATCH SET n.a = 1
ON MATCH SET n.b = 2
RETURN n
$$) AS (n agtype);

Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no test covering the interaction of ON CREATE SET / ON MATCH SET with chained (non-terminal) MERGE statements, which exercises the eager-buffering code path added in PR #2344. The non-terminal MERGE path (lines 664-750 in cypher_merge.c) has ON CREATE SET and ON MATCH SET logic, but the tests only cover Case 1 with a MATCH predecessor (not a MERGE-then-MERGE chain). A test like:

MERGE (a:A {name: 'X'}) ON CREATE SET a.new = true
MERGE (b:B {name: 'Y'}) ON CREATE SET b.new = true
RETURN a, b

or with a chained terminal MERGE following a non-terminal MERGE with ON SET would validate that the eager buffering path handles ON SET correctly.

Suggested change
-- Chained MERGE with ON CREATE SET in non-terminal and terminal clauses
SELECT * FROM cypher('merge_actions', $$
MERGE (a:Person {name: 'ChainCreateA'})
ON CREATE SET a.new = true
MERGE (b:Person {name: 'ChainCreateB'})
ON CREATE SET b.new = true
RETURN a.name, a.new, b.name, b.new
$$) AS (a_name agtype, a_new agtype, b_name agtype, b_new agtype);
-- Chained MERGE with non-terminal ON CREATE SET and terminal ON MATCH SET
-- Setup an existing node to be matched by the second MERGE
SELECT * FROM cypher('merge_actions', $$
CREATE (p:Person {name: 'ChainMatch', seen: false})
$$) AS (n agtype);
SELECT * FROM cypher('merge_actions', $$
MERGE (a:Person {name: 'ChainCreateOnce'})
ON CREATE SET a.created = true
MERGE (b:Person {name: 'ChainMatch'})
ON MATCH SET b.seen = true
RETURN a.name, a.created, b.name, b.seen
$$) AS (a_name agtype, a_created agtype, b_name agtype, b_seen agtype);

Copilot uses AI. Check for mistakes.
-- cleanup
SELECT * FROM cypher('merge_actions', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype);

--
-- delete graphs
--
SELECT drop_graph('merge_actions', true);
SELECT drop_graph('issue_1907', true);
SELECT drop_graph('cypher_merge', true);
SELECT drop_graph('issue_1630', true);
Expand Down
84 changes: 75 additions & 9 deletions src/backend/executor/cypher_merge.c
Original file line number Diff line number Diff line change
Expand Up @@ -321,8 +321,29 @@ static void process_simple_merge(CustomScanState *node)

/* setup the scantuple that the process_path needs */
econtext->ecxt_scantuple = sss->ss.ss_ScanTupleSlot;
mark_tts_isnull(econtext->ecxt_scantuple);

process_path(css, NULL, true);

/* ON CREATE SET: path was just created */
if (css->on_create_set_info)
{
ExecStoreVirtualTuple(econtext->ecxt_scantuple);
apply_update_list(&css->css, css->on_create_set_info);
}
}
else
{
/* ON MATCH SET: path already exists */
if (css->on_match_set_info)
{
ExprContext *econtext = node->ss.ps.ps_ExprContext;

econtext->ecxt_scantuple =
node->ss.ps.lefttree->ps_ProjInfo->pi_exprContext->ecxt_scantuple;

apply_update_list(&css->css, css->on_match_set_info);
}
}
}

Expand Down Expand Up @@ -657,6 +678,11 @@ static TupleTableSlot *exec_cypher_merge(CustomScanState *node)
free_path_entry_array(prebuilt_path_array,
path_length);
process_path(css, found_path_array, false);

/* ON MATCH SET: path was found as duplicate */
if (css->on_match_set_info)
apply_update_list(&css->css,
css->on_match_set_info);
}
else
{
Expand All @@ -668,8 +694,19 @@ static TupleTableSlot *exec_cypher_merge(CustomScanState *node)
css->created_paths_list = new_path;

process_path(css, prebuilt_path_array, true);

/* ON CREATE SET: path was just created */
if (css->on_create_set_info)
apply_update_list(&css->css,
css->on_create_set_info);
Comment on lines +729 to +731
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the Case 1 (has predecessor, non-terminal) ON CREATE SET path (line 728-730), apply_update_list is called after process_path(css, prebuilt_path_array, true) with ecxt_scantuple still pointing to the lateral join's scantuple (set at the top of the loop). Unlike Case 2 (process_simple_merge, line 360) and Case 3 (line 988), ExecStoreVirtualTuple is not called before apply_update_list to ensure tts_nvalid is set correctly.

While this may work if the lateral join's projected slot already has tts_nvalid = natts after ExecProject, the entity values for the newly-created MERGE path entities (e.g., a fresh node b in MERGE (a)-[:R]->(b)) would be null in the lateral join's slot. However, apply_update_list reads the entity from scanTupleSlot->tts_values[entity_position - 1] to get its graphid for the UPDATE. If the entity slot is null (from the lateral join), this would fail at the tts_isnull check (line 453, if (scanTupleSlot->tts_isnull[update_item->entity_position - 1]) continue), silently skipping the ON CREATE SET update without applying it.

The fix would be to use a fresh scan tuple slot (like the sss->ss.ss_ScanTupleSlot approach used in Case 3), calling ExecStoreVirtualTuple before apply_update_list.

Suggested change
if (css->on_create_set_info)
apply_update_list(&css->css,
css->on_create_set_info);
if (css->on_create_set_info)
{
/* Use the scan tuple slot populated by process_path */
econtext->ecxt_scantuple =
css->css.ss.ss_ScanTupleSlot;
ExecStoreVirtualTuple(econtext->ecxt_scantuple);
apply_update_list(&css->css,
css->on_create_set_info);
}

Copilot uses AI. Check for mistakes.
}
}
else
{
/* ON MATCH SET: path already existed from lateral join */
if (css->on_match_set_info)
apply_update_list(&css->css, css->on_match_set_info);
}

/* Project the result and save a copy */
econtext->ecxt_scantuple =
Expand Down Expand Up @@ -742,6 +779,10 @@ static TupleTableSlot *exec_cypher_merge(CustomScanState *node)
{
free_path_entry_array(prebuilt_path_array, path_length);
process_path(css, found_path_array, false);

/* ON MATCH SET: path was found as duplicate */
if (css->on_match_set_info)
apply_update_list(&css->css, css->on_match_set_info);
}
else
{
Expand All @@ -752,8 +793,18 @@ static TupleTableSlot *exec_cypher_merge(CustomScanState *node)
css->created_paths_list = new_path;

process_path(css, prebuilt_path_array, true);

/* ON CREATE SET: path was just created */
if (css->on_create_set_info)
apply_update_list(&css->css, css->on_create_set_info);
Comment on lines +829 to +830
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same issue as in the non-terminal Case 1: in the terminal Case 1 ON CREATE SET path (line 824-828), apply_update_list is called with ecxt_scantuple pointing to the lateral join's projection (set at line 789-790). The newly-created entity's values would be null in this slot. Depending on whether process_path writes entity values into this lateral join slot (which it does via merge_vertex), this may or may not work, but it is inconsistent with the Case 2 and Case 3 approaches that call ExecStoreVirtualTuple first.

Suggested change
if (css->on_create_set_info)
apply_update_list(&css->css, css->on_create_set_info);
if (css->on_create_set_info)
{
/*
* Materialize the current virtual tuple into the scan
* slot so that apply_update_list sees the values
* corresponding to the newly-created path.
*
* This mirrors the behavior in the other MERGE cases,
* which call ExecStoreVirtualTuple before
* apply_update_list.
*/
ExecStoreVirtualTuple(econtext->ecxt_scantuple);
apply_update_list(&css->css, css->on_create_set_info);
}

Copilot uses AI. Check for mistakes.
}
}
else
{
/* ON MATCH SET: path already existed from lateral join */
if (css->on_match_set_info)
apply_update_list(&css->css, css->on_match_set_info);
}

} while (true);

Expand Down Expand Up @@ -826,6 +877,14 @@ static TupleTableSlot *exec_cypher_merge(CustomScanState *node)
*/
css->found_a_path = true;

/* ON MATCH SET: path already exists */
if (css->on_match_set_info)
{
econtext->ecxt_scantuple =
node->ss.ps.lefttree->ps_ProjInfo->pi_exprContext->ecxt_scantuple;
apply_update_list(&css->css, css->on_match_set_info);
}

econtext->ecxt_scantuple = ExecProject(node->ss.ps.lefttree->ps_ProjInfo);
return ExecProject(node->ss.ps.ps_ProjInfo);
}
Expand Down Expand Up @@ -886,21 +945,26 @@ static TupleTableSlot *exec_cypher_merge(CustomScanState *node)
/* setup the scantuple that the process_path needs */
econtext->ecxt_scantuple = sss->ss.ss_ScanTupleSlot;

/* create the path */
process_path(css, NULL, true);

/* mark the create_new_path flag to true. */
css->created_new_path = true;

/*
* find the tts_values that process_path did not populate and
* mark as null.
* Initialize the scan tuple slot as all-null before process_path
* populates it with the created entities. This ensures the slot
* is properly set up for apply_update_list.
*/
mark_tts_isnull(econtext->ecxt_scantuple);

/* store the heap tuble */
/* create the path */
process_path(css, NULL, true);

/* mark the slot as valid so tts_nvalid reflects natts */
ExecStoreVirtualTuple(econtext->ecxt_scantuple);

/* ON CREATE SET: path was just created */
if (css->on_create_set_info)
apply_update_list(&css->css, css->on_create_set_info);

/* mark the create_new_path flag to true. */
css->created_new_path = true;

/*
* make the subquery's projection scan slot be the tuple table we
* created and run the projection logic.
Expand Down Expand Up @@ -1029,6 +1093,8 @@ Node *create_cypher_merge_plan_state(CustomScan *cscan)
cypher_css->created_new_path = false;
cypher_css->found_a_path = false;
cypher_css->graph_oid = merge_information->graph_oid;
cypher_css->on_match_set_info = merge_information->on_match_set_info;
cypher_css->on_create_set_info = merge_information->on_create_set_info;

cypher_css->css.ss.ps.type = T_CustomScanState;
cypher_css->css.methods = &cypher_merge_exec_methods;
Expand Down
Loading