From 0fd8992f24a1661f2cd93963182a4636bb81a8af Mon Sep 17 00:00:00 2001 From: Greg Felice Date: Sat, 28 Feb 2026 15:37:16 -0500 Subject: [PATCH 1/4] Add MERGE ON CREATE SET / ON MATCH SET support (issue #1619) Implements the openCypher-standard ON CREATE SET and ON MATCH SET clauses for the MERGE statement. This allows conditional property updates depending on whether MERGE created a new path or matched an existing one: MERGE (n:Person {name: 'Alice'}) ON CREATE SET n.created = timestamp() ON MATCH SET n.updated = timestamp() Implementation spans parser, planner, and executor: - Grammar: new merge_actions_opt/merge_actions/merge_action rules in cypher_gram.y, with ON keyword added to cypher_kwlist.h - Nodes: on_match/on_create lists on cypher_merge, corresponding on_match_set_info/on_create_set_info on cypher_merge_information, and prop_expr on cypher_update_item (all serialized through copy/out/read funcs) - Transform: cypher_clause.c transforms ON SET items and stores prop_expr for direct expression evaluation - Executor: cypher_set.c extracts apply_update_list() from process_update_list(); cypher_merge.c calls it at all merge decision points (simple merge, terminal, non-terminal with eager buffering, and first-clause-with-followers paths) Key design choice: prop_expr stores the Expr* directly in cypher_update_item rather than using prop_position into the scan tuple. The planner strips target list entries for SET expressions that CustomScan doesn't need, making prop_position references dangling. By storing the expression directly (only for MERGE ON SET items), we evaluate it with ExecInitExpr/ExecEvalExpr independent of the scan tuple layout. Includes regression tests covering: basic ON CREATE SET, basic ON MATCH SET, combined ON CREATE + ON MATCH, multiple SET items, expression evaluation, interaction with WITH clause, and edge property updates. All 31 regression tests pass. --- regress/expected/cypher_merge.out | 129 +++++++++++++++++++++++++++ regress/sql/cypher_merge.sql | 78 ++++++++++++++++ src/backend/executor/cypher_merge.c | 84 +++++++++++++++-- src/backend/executor/cypher_set.c | 53 ++++++++--- src/backend/nodes/cypher_copyfuncs.c | 3 + src/backend/nodes/cypher_outfuncs.c | 7 +- src/backend/nodes/cypher_readfuncs.c | 3 + src/backend/parser/cypher_clause.c | 46 ++++++++++ src/backend/parser/cypher_gram.y | 64 ++++++++++++- src/include/executor/cypher_utils.h | 6 ++ src/include/nodes/cypher_nodes.h | 6 ++ src/include/parser/cypher_kwlist.h | 1 + 12 files changed, 457 insertions(+), 23 deletions(-) diff --git a/regress/expected/cypher_merge.out b/regress/expected/cypher_merge.out index 8c37dc2de..f58e2789b 100644 --- a/regress/expected/cypher_merge.out +++ b/regress/expected/cypher_merge.out @@ -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 diff --git a/regress/sql/cypher_merge.sql b/regress/sql/cypher_merge.sql index cc900e73d..3bbc94239 100644 --- a/regress/sql/cypher_merge.sql +++ b/regress/sql/cypher_merge.sql @@ -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); + +-- 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); diff --git a/src/backend/executor/cypher_merge.c b/src/backend/executor/cypher_merge.c index 1edfc812d..fb48e3eb1 100644 --- a/src/backend/executor/cypher_merge.c +++ b/src/backend/executor/cypher_merge.c @@ -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); + } } } @@ -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 { @@ -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); } } + 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 = @@ -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 { @@ -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); } } + 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); @@ -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); } @@ -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. @@ -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; diff --git a/src/backend/executor/cypher_set.c b/src/backend/executor/cypher_set.c index a1063af32..7e4c89bd6 100644 --- a/src/backend/executor/cypher_set.c +++ b/src/backend/executor/cypher_set.c @@ -325,8 +325,7 @@ static agtype_value *replace_entity_in_path(agtype_value *path, static void update_all_paths(CustomScanState *node, graphid id, agtype *updated_entity) { - cypher_set_custom_scan_state *css = (cypher_set_custom_scan_state *)node; - ExprContext *econtext = css->css.ss.ps.ps_ExprContext; + ExprContext *econtext = node->ss.ps.ps_ExprContext; TupleTableSlot *scanTupleSlot = econtext->ecxt_scantuple; int i; @@ -372,13 +371,18 @@ static void update_all_paths(CustomScanState *node, graphid id, } } -static void process_update_list(CustomScanState *node) +/* + * Core SET logic that can be called from any executor (SET, MERGE, etc.). + * Takes the CustomScanState for expression context and a + * cypher_update_information describing which properties to set. + */ +void apply_update_list(CustomScanState *node, + cypher_update_information *set_info) { - cypher_set_custom_scan_state *css = (cypher_set_custom_scan_state *)node; - ExprContext *econtext = css->css.ss.ps.ps_ExprContext; + ExprContext *econtext = node->ss.ps.ps_ExprContext; TupleTableSlot *scanTupleSlot = econtext->ecxt_scantuple; ListCell *lc; - EState *estate = css->css.ss.ps.state; + EState *estate = node->ss.ps.state; int *luindex = NULL; int lidx = 0; HTAB *qual_cache = NULL; @@ -404,7 +408,7 @@ static void process_update_list(CustomScanState *node) * to correctly update an 'entity' after all other previous updates to that * 'entity' have been done. */ - foreach (lc, css->set_list->set_items) + foreach (lc, set_info->set_items) { cypher_update_item *update_item = NULL; @@ -419,7 +423,7 @@ static void process_update_list(CustomScanState *node) lidx = 0; /* iterate through SET set items */ - foreach (lc, css->set_list->set_items) + foreach (lc, set_info->set_items) { agtype_value *altered_properties; agtype_value *original_entity_value; @@ -437,7 +441,7 @@ static void process_update_list(CustomScanState *node) cypher_update_item *update_item; Datum new_entity; HeapTuple heap_tuple; - char *clause_name = css->set_list->clause_name; + char *clause_name = set_info->clause_name; int cid; update_item = (cypher_update_item *)lfirst(lc); @@ -485,11 +489,31 @@ static void process_update_list(CustomScanState *node) * this is a REMOVE clause or the variable references a variable that is * NULL. It will be possible for a variable to be NULL when OPTIONAL * MATCH is implemented. + * + * If prop_expr is set (used by MERGE ON CREATE/MATCH SET), evaluate + * the expression directly rather than reading from the scan tuple. + * The planner may have stripped the target entry at prop_position. */ if (update_item->remove_item) { remove_property = true; } + else if (update_item->prop_expr != NULL) + { + ExprState *expr_state; + Datum val; + bool isnull; + + expr_state = ExecInitExpr((Expr *)update_item->prop_expr, + (PlanState *)node); + val = ExecEvalExpr(expr_state, econtext, &isnull); + remove_property = isnull; + + if (!isnull) + { + new_property_value = DATUM_GET_AGTYPE_P(val); + } + } else { remove_property = scanTupleSlot->tts_isnull[update_item->prop_position - 1]; @@ -503,7 +527,7 @@ static void process_update_list(CustomScanState *node) { new_property_value = NULL; } - else + else if (update_item->prop_expr == NULL) { new_property_value = DATUM_GET_AGTYPE_P(scanTupleSlot->tts_values[update_item->prop_position - 1]); } @@ -536,7 +560,7 @@ static void process_update_list(CustomScanState *node) } resultRelInfo = create_entity_result_rel_info( - estate, css->set_list->graph_name, label_name); + estate, set_info->graph_name, label_name); slot = ExecInitExtraTupleSlot( estate, RelationGetDescr(resultRelInfo->ri_RelationDesc), @@ -700,6 +724,13 @@ static void process_update_list(CustomScanState *node) pfree_if_not_null(luindex); } +static void process_update_list(CustomScanState *node) +{ + cypher_set_custom_scan_state *css = (cypher_set_custom_scan_state *)node; + + apply_update_list(node, css->set_list); +} + static TupleTableSlot *exec_cypher_set(CustomScanState *node) { cypher_set_custom_scan_state *css = (cypher_set_custom_scan_state *)node; diff --git a/src/backend/nodes/cypher_copyfuncs.c b/src/backend/nodes/cypher_copyfuncs.c index 56895ee06..22e515d32 100644 --- a/src/backend/nodes/cypher_copyfuncs.c +++ b/src/backend/nodes/cypher_copyfuncs.c @@ -136,6 +136,7 @@ void copy_cypher_update_item(ExtensibleNode *newnode, const ExtensibleNode *from COPY_NODE_FIELD(qualified_name); COPY_SCALAR_FIELD(remove_item); COPY_SCALAR_FIELD(is_add); + COPY_NODE_FIELD(prop_expr); } /* copy function for cypher_delete_information */ @@ -168,4 +169,6 @@ void copy_cypher_merge_information(ExtensibleNode *newnode, const ExtensibleNode COPY_SCALAR_FIELD(graph_oid); COPY_SCALAR_FIELD(merge_function_attr); COPY_NODE_FIELD(path); + COPY_NODE_FIELD(on_match_set_info); + COPY_NODE_FIELD(on_create_set_info); } diff --git a/src/backend/nodes/cypher_outfuncs.c b/src/backend/nodes/cypher_outfuncs.c index 4772621c9..17cb8a5a1 100644 --- a/src/backend/nodes/cypher_outfuncs.c +++ b/src/backend/nodes/cypher_outfuncs.c @@ -189,12 +189,14 @@ void out_cypher_list_comprehension(StringInfo str, const ExtensibleNode *node) } -/* serialization function for the cypher_delete ExtensibleNode. */ +/* serialization function for the cypher_merge ExtensibleNode. */ void out_cypher_merge(StringInfo str, const ExtensibleNode *node) { DEFINE_AG_NODE(cypher_merge); WRITE_NODE_FIELD(path); + WRITE_NODE_FIELD(on_match); + WRITE_NODE_FIELD(on_create); } /* serialization function for the cypher_path ExtensibleNode. */ @@ -427,6 +429,7 @@ void out_cypher_update_item(StringInfo str, const ExtensibleNode *node) WRITE_NODE_FIELD(qualified_name); WRITE_BOOL_FIELD(remove_item); WRITE_BOOL_FIELD(is_add); + WRITE_NODE_FIELD(prop_expr); } /* serialization function for the cypher_delete_information ExtensibleNode. */ @@ -459,6 +462,8 @@ void out_cypher_merge_information(StringInfo str, const ExtensibleNode *node) WRITE_INT32_FIELD(graph_oid); WRITE_INT32_FIELD(merge_function_attr); WRITE_NODE_FIELD(path); + WRITE_NODE_FIELD(on_match_set_info); + WRITE_NODE_FIELD(on_create_set_info); } /* diff --git a/src/backend/nodes/cypher_readfuncs.c b/src/backend/nodes/cypher_readfuncs.c index a58b90cbc..3ad4f095f 100644 --- a/src/backend/nodes/cypher_readfuncs.c +++ b/src/backend/nodes/cypher_readfuncs.c @@ -269,6 +269,7 @@ void read_cypher_update_item(struct ExtensibleNode *node) READ_NODE_FIELD(qualified_name); READ_BOOL_FIELD(remove_item); READ_BOOL_FIELD(is_add); + READ_NODE_FIELD(prop_expr); } /* @@ -310,4 +311,6 @@ void read_cypher_merge_information(struct ExtensibleNode *node) READ_UINT_FIELD(graph_oid); READ_INT_FIELD(merge_function_attr); READ_NODE_FIELD(path); + READ_NODE_FIELD(on_match_set_info); + READ_NODE_FIELD(on_create_set_info); } diff --git a/src/backend/parser/cypher_clause.c b/src/backend/parser/cypher_clause.c index 446e97b3f..3be696ffd 100644 --- a/src/backend/parser/cypher_clause.c +++ b/src/backend/parser/cypher_clause.c @@ -6833,6 +6833,52 @@ static Query *transform_cypher_merge(cypher_parsestate *cpstate, merge_information->graph_oid = cpstate->graph_oid; merge_information->path = merge_path; + /* Transform ON MATCH SET items, if any */ + if (self->on_match != NIL) + { + ListCell *lc2; + + merge_information->on_match_set_info = + transform_cypher_set_item_list(cpstate, self->on_match, query); + merge_information->on_match_set_info->clause_name = "MERGE ON MATCH SET"; + merge_information->on_match_set_info->graph_name = cpstate->graph_name; + + /* + * Store prop_expr for direct evaluation in the MERGE executor. + * The planner may strip SET expression target entries from the plan, + * so we embed the Expr in the update item for direct evaluation. + */ + foreach(lc2, merge_information->on_match_set_info->set_items) + { + cypher_update_item *item = lfirst(lc2); + TargetEntry *set_tle = get_tle_by_resno(query->targetList, + item->prop_position); + if (set_tle != NULL) + item->prop_expr = (Node *)set_tle->expr; + } + } + + /* Transform ON CREATE SET items, if any */ + if (self->on_create != NIL) + { + ListCell *lc2; + + merge_information->on_create_set_info = + transform_cypher_set_item_list(cpstate, self->on_create, query); + merge_information->on_create_set_info->clause_name = "MERGE ON CREATE SET"; + merge_information->on_create_set_info->graph_name = cpstate->graph_name; + + /* Store prop_expr for MERGE executor (see comment above) */ + foreach(lc2, merge_information->on_create_set_info->set_items) + { + cypher_update_item *item = lfirst(lc2); + TargetEntry *set_tle = get_tle_by_resno(query->targetList, + item->prop_position); + if (set_tle != NULL) + item->prop_expr = (Node *)set_tle->expr; + } + } + if (!clause->next) { merge_information->flags |= CYPHER_CLAUSE_FLAG_TERMINAL; diff --git a/src/backend/parser/cypher_gram.y b/src/backend/parser/cypher_gram.y index 5ba1e6354..5120006e0 100644 --- a/src/backend/parser/cypher_gram.y +++ b/src/backend/parser/cypher_gram.y @@ -64,6 +64,10 @@ bool boolean; Node *node; List *list; + struct { + List *on_match; + List *on_create; + } merge_actions; } %token INTEGER @@ -89,7 +93,7 @@ LIMIT MATCH MERGE NOT NULL_P - OPERATOR OPTIONAL OR ORDER + ON OPERATOR OPTIONAL OR ORDER REMOVE RETURN SET SKIP STARTS THEN TRUE_P @@ -139,6 +143,7 @@ /* MERGE clause */ %type merge +%type merge_actions_opt merge_actions merge_action /* CALL ... YIELD clause */ %type call_stmt yield_item @@ -1131,17 +1136,72 @@ detach_opt: * MERGE clause */ merge: - MERGE path + MERGE path merge_actions_opt { cypher_merge *n; n = make_ag_node(cypher_merge); n->path = $2; + n->on_match = $3.on_match; + n->on_create = $3.on_create; $$ = (Node *)n; } ; +merge_actions_opt: + /* empty */ + { + $$.on_match = NIL; + $$.on_create = NIL; + } + | merge_actions + { + $$ = $1; + } + ; + +merge_actions: + merge_action + { + $$ = $1; + } + | merge_actions merge_action + { + if ($2.on_match != NIL) + { + if ($1.on_match != NIL) + ereport(ERROR, + (errcode(ERRCODE_SYNTAX_ERROR), + errmsg("ON MATCH SET specified more than once"))); + $$.on_match = $2.on_match; + $$.on_create = $1.on_create; + } + else + { + if ($1.on_create != NIL) + ereport(ERROR, + (errcode(ERRCODE_SYNTAX_ERROR), + errmsg("ON CREATE SET specified more than once"))); + $$.on_create = $2.on_create; + $$.on_match = $1.on_match; + } + } + ; + +merge_action: + ON MATCH SET set_item_list + { + $$.on_match = $4; + $$.on_create = NIL; + } + | ON CREATE SET set_item_list + { + $$.on_match = NIL; + $$.on_create = $4; + } + ; + /* * common */ diff --git a/src/include/executor/cypher_utils.h b/src/include/executor/cypher_utils.h index 278094e07..e0efc735f 100644 --- a/src/include/executor/cypher_utils.h +++ b/src/include/executor/cypher_utils.h @@ -111,8 +111,14 @@ typedef struct cypher_merge_custom_scan_state List *eager_tuples; int eager_tuples_index; bool eager_buffer_filled; + cypher_update_information *on_match_set_info; /* NULL if not specified */ + cypher_update_information *on_create_set_info; /* NULL if not specified */ } cypher_merge_custom_scan_state; +/* Reusable SET logic callable from MERGE executor */ +void apply_update_list(CustomScanState *node, + cypher_update_information *set_info); + TupleTableSlot *populate_vertex_tts(TupleTableSlot *elemTupleSlot, agtype_value *id, agtype_value *properties); TupleTableSlot *populate_edge_tts( diff --git a/src/include/nodes/cypher_nodes.h b/src/include/nodes/cypher_nodes.h index db47eb313..8611b19b4 100644 --- a/src/include/nodes/cypher_nodes.h +++ b/src/include/nodes/cypher_nodes.h @@ -124,6 +124,8 @@ typedef struct cypher_merge { ExtensibleNode extensible; Node *path; + List *on_match; /* List of cypher_set_item, or NIL */ + List *on_create; /* List of cypher_set_item, or NIL */ } cypher_merge; /* @@ -451,6 +453,8 @@ typedef struct cypher_update_item List *qualified_name; bool remove_item; bool is_add; + Node *prop_expr; /* SET value expression, used by MERGE ON CREATE/MATCH SET + * where the expression is not in the plan's target list */ } cypher_update_item; typedef struct cypher_delete_information @@ -477,6 +481,8 @@ typedef struct cypher_merge_information uint32 graph_oid; AttrNumber merge_function_attr; cypher_create_path *path; + cypher_update_information *on_match_set_info; /* NULL if no ON MATCH SET */ + cypher_update_information *on_create_set_info; /* NULL if no ON CREATE SET */ } cypher_merge_information; /* grammar node for typecasts */ diff --git a/src/include/parser/cypher_kwlist.h b/src/include/parser/cypher_kwlist.h index e4c4437ba..98fcecbf1 100644 --- a/src/include/parser/cypher_kwlist.h +++ b/src/include/parser/cypher_kwlist.h @@ -29,6 +29,7 @@ PG_KEYWORD("match", MATCH, RESERVED_KEYWORD) PG_KEYWORD("merge", MERGE, RESERVED_KEYWORD) PG_KEYWORD("not", NOT, RESERVED_KEYWORD) PG_KEYWORD("null", NULL_P, RESERVED_KEYWORD) +PG_KEYWORD("on", ON, RESERVED_KEYWORD) PG_KEYWORD("operator", OPERATOR, RESERVED_KEYWORD) PG_KEYWORD("optional", OPTIONAL, RESERVED_KEYWORD) PG_KEYWORD("or", OR, RESERVED_KEYWORD) From 40dddbdb5e317c7d7f5ef60cbe733b48ac1ada0f Mon Sep 17 00:00:00 2001 From: Greg Felice Date: Mon, 2 Mar 2026 15:12:59 -0500 Subject: [PATCH 2/4] Address Copilot review: pre-init ExprState, add ON MATCH SET predecessor test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move ExecInitExpr for ON CREATE/MATCH SET items from per-row execution in apply_update_list() to plan initialization in begin_cypher_merge(). Follows the established pattern used by cypher_target_node (id_expr_state, prop_expr_state). - Add prop_expr_state field to cypher_update_item with serialization support in outfuncs/readfuncs/copyfuncs. - apply_update_list() uses pre-initialized state when available, falls back to per-row init for plain SET callers. - Fix misleading comment: "ON MATCH SET" → "ON CREATE SET" for Case 1 first-run test. - Add Case 1 second-run test that triggers ON MATCH SET with a predecessor clause (MATCH ... MERGE ... ON MATCH SET). --- .gitignore | 1 + regress/expected/cypher_merge.out | 14 +++++++++++++- regress/sql/cypher_merge.sql | 10 +++++++++- src/backend/executor/cypher_merge.c | 29 ++++++++++++++++++++++++++++ src/backend/executor/cypher_set.c | 16 +++++++++++++-- src/backend/nodes/cypher_copyfuncs.c | 1 + src/backend/nodes/cypher_outfuncs.c | 1 + src/backend/nodes/cypher_readfuncs.c | 1 + src/include/nodes/cypher_nodes.h | 1 + 9 files changed, 70 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 03923b03e..1e2f8f674 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ __pycache__ **/apache_age_python.egg-info drivers/python/build +*.bc diff --git a/regress/expected/cypher_merge.out b/regress/expected/cypher_merge.out index f58e2789b..75c9af10e 100644 --- a/regress/expected/cypher_merge.out +++ b/regress/expected/cypher_merge.out @@ -1944,7 +1944,7 @@ $$) AS (name agtype, created agtype, matched agtype); "Bob" | true | true (1 row) --- ON MATCH SET with MERGE after MATCH (Case 1: has predecessor) +-- ON CREATE SET with MERGE after MATCH (Case 1: has predecessor, first run = create) SELECT * FROM cypher('merge_actions', $$ MATCH (a:Person {name: 'Alice'}) MERGE (a)-[:KNOWS]->(b:Person {name: 'Charlie'}) @@ -1956,6 +1956,18 @@ $$) AS (a agtype, b agtype, source agtype); "Alice" | "Charlie" | "merge_create" (1 row) +-- ON MATCH SET with MERGE after MATCH (Case 1: has predecessor, second run = match) +SELECT * FROM cypher('merge_actions', $$ + MATCH (a:Person {name: 'Alice'}) + MERGE (a)-[:KNOWS]->(b:Person {name: 'Charlie'}) + ON MATCH SET b.visited = true + RETURN a.name, b.name, b.visited +$$) AS (a agtype, b agtype, visited agtype); + a | b | visited +---------+-----------+--------- + "Alice" | "Charlie" | true +(1 row) + -- Multiple SET items in a single ON CREATE SET SELECT * FROM cypher('merge_actions', $$ MERGE (n:Person {name: 'Dave'}) diff --git a/regress/sql/cypher_merge.sql b/regress/sql/cypher_merge.sql index 3bbc94239..9bba5aeff 100644 --- a/regress/sql/cypher_merge.sql +++ b/regress/sql/cypher_merge.sql @@ -903,7 +903,7 @@ SELECT * FROM cypher('merge_actions', $$ 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) +-- ON CREATE SET with MERGE after MATCH (Case 1: has predecessor, first run = create) SELECT * FROM cypher('merge_actions', $$ MATCH (a:Person {name: 'Alice'}) MERGE (a)-[:KNOWS]->(b:Person {name: 'Charlie'}) @@ -911,6 +911,14 @@ SELECT * FROM cypher('merge_actions', $$ RETURN a.name, b.name, b.source $$) AS (a agtype, b agtype, source agtype); +-- ON MATCH SET with MERGE after MATCH (Case 1: has predecessor, second run = match) +SELECT * FROM cypher('merge_actions', $$ + MATCH (a:Person {name: 'Alice'}) + MERGE (a)-[:KNOWS]->(b:Person {name: 'Charlie'}) + ON MATCH SET b.visited = true + RETURN a.name, b.name, b.visited +$$) AS (a agtype, b agtype, visited agtype); + -- Multiple SET items in a single ON CREATE SET SELECT * FROM cypher('merge_actions', $$ MERGE (n:Person {name: 'Dave'}) diff --git a/src/backend/executor/cypher_merge.c b/src/backend/executor/cypher_merge.c index fb48e3eb1..2e5aaa0fe 100644 --- a/src/backend/executor/cypher_merge.c +++ b/src/backend/executor/cypher_merge.c @@ -191,6 +191,35 @@ static void begin_cypher_merge(CustomScanState *node, EState *estate, } } + /* + * Pre-initialize ExprStates for ON CREATE SET / ON MATCH SET items. + * This must happen once at plan init time, not per-row. + */ + if (css->on_create_set_info != NULL) + { + foreach(lc, css->on_create_set_info->set_items) + { + cypher_update_item *item = (cypher_update_item *)lfirst(lc); + if (item->prop_expr != NULL) + { + item->prop_expr_state = ExecInitExpr( + (Expr *)item->prop_expr, (PlanState *)node); + } + } + } + if (css->on_match_set_info != NULL) + { + foreach(lc, css->on_match_set_info->set_items) + { + cypher_update_item *item = (cypher_update_item *)lfirst(lc); + if (item->prop_expr != NULL) + { + item->prop_expr_state = ExecInitExpr( + (Expr *)item->prop_expr, (PlanState *)node); + } + } + } + /* * Postgres does not assign the es_output_cid in queries that do * not write to disk, ie: SELECT commands. We need the command id diff --git a/src/backend/executor/cypher_set.c b/src/backend/executor/cypher_set.c index 7e4c89bd6..60d5d6086 100644 --- a/src/backend/executor/cypher_set.c +++ b/src/backend/executor/cypher_set.c @@ -504,8 +504,20 @@ void apply_update_list(CustomScanState *node, Datum val; bool isnull; - expr_state = ExecInitExpr((Expr *)update_item->prop_expr, - (PlanState *)node); + /* + * Use the pre-initialized ExprState if available (set during + * plan init in begin_cypher_merge). Fall back to per-row init + * for callers that haven't pre-initialized (e.g. plain SET). + */ + if (update_item->prop_expr_state != NULL) + { + expr_state = update_item->prop_expr_state; + } + else + { + expr_state = ExecInitExpr((Expr *)update_item->prop_expr, + (PlanState *)node); + } val = ExecEvalExpr(expr_state, econtext, &isnull); remove_property = isnull; diff --git a/src/backend/nodes/cypher_copyfuncs.c b/src/backend/nodes/cypher_copyfuncs.c index 22e515d32..7c5044608 100644 --- a/src/backend/nodes/cypher_copyfuncs.c +++ b/src/backend/nodes/cypher_copyfuncs.c @@ -137,6 +137,7 @@ void copy_cypher_update_item(ExtensibleNode *newnode, const ExtensibleNode *from COPY_SCALAR_FIELD(remove_item); COPY_SCALAR_FIELD(is_add); COPY_NODE_FIELD(prop_expr); + COPY_NODE_FIELD(prop_expr_state); } /* copy function for cypher_delete_information */ diff --git a/src/backend/nodes/cypher_outfuncs.c b/src/backend/nodes/cypher_outfuncs.c index 17cb8a5a1..7b4ab5379 100644 --- a/src/backend/nodes/cypher_outfuncs.c +++ b/src/backend/nodes/cypher_outfuncs.c @@ -430,6 +430,7 @@ void out_cypher_update_item(StringInfo str, const ExtensibleNode *node) WRITE_BOOL_FIELD(remove_item); WRITE_BOOL_FIELD(is_add); WRITE_NODE_FIELD(prop_expr); + WRITE_NODE_FIELD(prop_expr_state); } /* serialization function for the cypher_delete_information ExtensibleNode. */ diff --git a/src/backend/nodes/cypher_readfuncs.c b/src/backend/nodes/cypher_readfuncs.c index 3ad4f095f..141e98374 100644 --- a/src/backend/nodes/cypher_readfuncs.c +++ b/src/backend/nodes/cypher_readfuncs.c @@ -270,6 +270,7 @@ void read_cypher_update_item(struct ExtensibleNode *node) READ_BOOL_FIELD(remove_item); READ_BOOL_FIELD(is_add); READ_NODE_FIELD(prop_expr); + READ_NODE_FIELD(prop_expr_state); } /* diff --git a/src/include/nodes/cypher_nodes.h b/src/include/nodes/cypher_nodes.h index 8611b19b4..474535c24 100644 --- a/src/include/nodes/cypher_nodes.h +++ b/src/include/nodes/cypher_nodes.h @@ -455,6 +455,7 @@ typedef struct cypher_update_item bool is_add; Node *prop_expr; /* SET value expression, used by MERGE ON CREATE/MATCH SET * where the expression is not in the plan's target list */ + ExprState *prop_expr_state; /* initialized at plan init, not per-row */ } cypher_update_item; typedef struct cypher_delete_information From 9986d38400fca453feee5541a4f07f3a4c43da4c Mon Sep 17 00:00:00 2001 From: Greg Felice Date: Fri, 6 Mar 2026 11:57:29 -0500 Subject: [PATCH 3/4] Address Copilot review: ON in safe_keywords, chained MERGE tests 1. Add ON to safe_keywords in cypher_gram.y so that property keys and labels named 'on' still work (e.g., n.on, MATCH (n:on)). All other keywords added as tokens are also in safe_keywords. 2. Add chained (non-terminal) MERGE regression tests exercising the eager-buffering code path with ON CREATE SET and ON MATCH SET. First run creates both nodes (ON CREATE SET fires), second run matches both (ON MATCH SET fires). All regression tests pass (cypher_merge: ok). --- regress/expected/cypher_merge.out | 26 ++++++++++++++++++++++++++ regress/sql/cypher_merge.sql | 18 ++++++++++++++++++ src/backend/parser/cypher_gram.y | 1 + 3 files changed, 45 insertions(+) diff --git a/regress/expected/cypher_merge.out b/regress/expected/cypher_merge.out index 75c9af10e..234d9427e 100644 --- a/regress/expected/cypher_merge.out +++ b/regress/expected/cypher_merge.out @@ -2011,6 +2011,32 @@ $$) AS (n agtype); ERROR: ON MATCH SET specified more than once LINE 1: SELECT * FROM cypher('merge_actions', $$ ^ +-- Chained (non-terminal) MERGE with ON CREATE SET (eager-buffering path) +SELECT * FROM cypher('merge_actions', $$ + MERGE (a:Person {name: 'Frank'}) + ON CREATE SET a.created = true + MERGE (a)-[:KNOWS]->(b:Person {name: 'Grace'}) + ON CREATE SET b.created = true + RETURN a.name, a.created, b.name, b.created +$$) AS (a_name agtype, a_created agtype, b_name agtype, b_created agtype); + a_name | a_created | b_name | b_created +---------+-----------+---------+----------- + "Frank" | true | "Grace" | true +(1 row) + +-- Chained (non-terminal) MERGE with ON MATCH SET (second run = match) +SELECT * FROM cypher('merge_actions', $$ + MERGE (a:Person {name: 'Frank'}) + ON MATCH SET a.matched = true + MERGE (a)-[:KNOWS]->(b:Person {name: 'Grace'}) + ON MATCH SET b.matched = true + RETURN a.name, a.matched, b.name, b.matched +$$) AS (a_name agtype, a_matched agtype, b_name agtype, b_matched agtype); + a_name | a_matched | b_name | b_matched +---------+-----------+---------+----------- + "Frank" | true | "Grace" | true +(1 row) + -- cleanup SELECT * FROM cypher('merge_actions', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype); a diff --git a/regress/sql/cypher_merge.sql b/regress/sql/cypher_merge.sql index 9bba5aeff..fd8107f15 100644 --- a/regress/sql/cypher_merge.sql +++ b/regress/sql/cypher_merge.sql @@ -950,6 +950,24 @@ SELECT * FROM cypher('merge_actions', $$ RETURN n $$) AS (n agtype); +-- Chained (non-terminal) MERGE with ON CREATE SET (eager-buffering path) +SELECT * FROM cypher('merge_actions', $$ + MERGE (a:Person {name: 'Frank'}) + ON CREATE SET a.created = true + MERGE (a)-[:KNOWS]->(b:Person {name: 'Grace'}) + ON CREATE SET b.created = true + RETURN a.name, a.created, b.name, b.created +$$) AS (a_name agtype, a_created agtype, b_name agtype, b_created agtype); + +-- Chained (non-terminal) MERGE with ON MATCH SET (second run = match) +SELECT * FROM cypher('merge_actions', $$ + MERGE (a:Person {name: 'Frank'}) + ON MATCH SET a.matched = true + MERGE (a)-[:KNOWS]->(b:Person {name: 'Grace'}) + ON MATCH SET b.matched = true + RETURN a.name, a.matched, b.name, b.matched +$$) AS (a_name agtype, a_matched agtype, b_name agtype, b_matched agtype); + -- cleanup SELECT * FROM cypher('merge_actions', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype); diff --git a/src/backend/parser/cypher_gram.y b/src/backend/parser/cypher_gram.y index 5120006e0..223ae5f11 100644 --- a/src/backend/parser/cypher_gram.y +++ b/src/backend/parser/cypher_gram.y @@ -2457,6 +2457,7 @@ safe_keywords: | MATCH { $$ = KEYWORD_STRDUP($1); } | MERGE { $$ = KEYWORD_STRDUP($1); } | NOT { $$ = KEYWORD_STRDUP($1); } + | ON { $$ = KEYWORD_STRDUP($1); } | OPERATOR { $$ = KEYWORD_STRDUP($1); } | OPTIONAL { $$ = KEYWORD_STRDUP($1); } | OR { $$ = KEYWORD_STRDUP($1); } From b6db79ad59affceb4d665dee84c542fa1b6abf30 Mon Sep 17 00:00:00 2001 From: Greg Felice Date: Fri, 6 Mar 2026 14:02:37 -0500 Subject: [PATCH 4/4] Address Copilot review: ExecStoreVirtualTuple, prop_expr assertion, ON keyword test - Move ExecStoreVirtualTuple before apply_update_list unconditionally in Case 1 non-terminal and terminal MERGE paths, matching the pattern at Case 3 (line 994). Ensures tts_nvalid is set for downstream ExecProject even when ON CREATE SET is absent. - Add resolve_merge_set_exprs() helper to deduplicate the prop_expr resolution loops for ON MATCH SET and ON CREATE SET. Includes ereport when target entry is missing (internal error, should never happen). - Add regression test for ON keyword as label name, confirming backward compatibility via safe_keywords grammar path. Co-Authored-By: Claude Opus 4.6 --- regress/expected/cypher_merge.out | 13 ++++++- regress/sql/cypher_merge.sql | 6 +++ src/backend/executor/cypher_merge.c | 2 + src/backend/parser/cypher_clause.c | 59 ++++++++++++++++------------- 4 files changed, 53 insertions(+), 27 deletions(-) diff --git a/regress/expected/cypher_merge.out b/regress/expected/cypher_merge.out index 234d9427e..2a1d0c010 100644 --- a/regress/expected/cypher_merge.out +++ b/regress/expected/cypher_merge.out @@ -2037,6 +2037,16 @@ $$) AS (a_name agtype, a_matched agtype, b_name agtype, b_matched agtype); "Frank" | true | "Grace" | true (1 row) +-- ON keyword as label name (backward compat via safe_keywords) +SELECT * FROM cypher('merge_actions', $$ + CREATE (n:on {name: 'test'}) + RETURN n.name +$$) AS (name agtype); + name +-------- + "test" +(1 row) + -- cleanup SELECT * FROM cypher('merge_actions', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype); a @@ -2047,11 +2057,12 @@ SELECT * FROM cypher('merge_actions', $$ MATCH (n) DETACH DELETE n $$) AS (a agt -- delete graphs -- SELECT drop_graph('merge_actions', true); -NOTICE: drop cascades to 4 other objects +NOTICE: drop cascades to 5 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" +drop cascades to table merge_actions."on" NOTICE: graph "merge_actions" has been dropped drop_graph ------------ diff --git a/regress/sql/cypher_merge.sql b/regress/sql/cypher_merge.sql index fd8107f15..ff1586b93 100644 --- a/regress/sql/cypher_merge.sql +++ b/regress/sql/cypher_merge.sql @@ -968,6 +968,12 @@ SELECT * FROM cypher('merge_actions', $$ RETURN a.name, a.matched, b.name, b.matched $$) AS (a_name agtype, a_matched agtype, b_name agtype, b_matched agtype); +-- ON keyword as label name (backward compat via safe_keywords) +SELECT * FROM cypher('merge_actions', $$ + CREATE (n:on {name: 'test'}) + RETURN n.name +$$) AS (name agtype); + -- cleanup SELECT * FROM cypher('merge_actions', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype); diff --git a/src/backend/executor/cypher_merge.c b/src/backend/executor/cypher_merge.c index 2e5aaa0fe..ca9e62af2 100644 --- a/src/backend/executor/cypher_merge.c +++ b/src/backend/executor/cypher_merge.c @@ -723,6 +723,7 @@ static TupleTableSlot *exec_cypher_merge(CustomScanState *node) css->created_paths_list = new_path; process_path(css, prebuilt_path_array, true); + ExecStoreVirtualTuple(econtext->ecxt_scantuple); /* ON CREATE SET: path was just created */ if (css->on_create_set_info) @@ -822,6 +823,7 @@ static TupleTableSlot *exec_cypher_merge(CustomScanState *node) css->created_paths_list = new_path; process_path(css, prebuilt_path_array, true); + ExecStoreVirtualTuple(econtext->ecxt_scantuple); /* ON CREATE SET: path was just created */ if (css->on_create_set_info) diff --git a/src/backend/parser/cypher_clause.c b/src/backend/parser/cypher_clause.c index 3be696ffd..bad756b52 100644 --- a/src/backend/parser/cypher_clause.c +++ b/src/backend/parser/cypher_clause.c @@ -6761,6 +6761,33 @@ Query *cypher_parse_sub_analyze(Node *parseTree, * similar to OPTIONAL MATCH, however with the added feature of creating the * path if not there, rather than just emitting NULL. */ +/* + * Resolve prop_expr for each SET item by looking up its target entry. + * The planner may strip SET expression target entries from the plan, + * so we embed the Expr in the update item for direct evaluation. + */ +static void +resolve_merge_set_exprs(List *set_items, List *targetList, + const char *clause_name) +{ + ListCell *lc; + + foreach(lc, set_items) + { + cypher_update_item *item = lfirst(lc); + TargetEntry *set_tle = get_tle_by_resno(targetList, + item->prop_position); + if (set_tle == NULL) + { + ereport(ERROR, + (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("%s target entry not found at position %d", + clause_name, item->prop_position))); + } + item->prop_expr = (Node *)set_tle->expr; + } +} + static Query *transform_cypher_merge(cypher_parsestate *cpstate, cypher_clause *clause) { @@ -6836,47 +6863,27 @@ static Query *transform_cypher_merge(cypher_parsestate *cpstate, /* Transform ON MATCH SET items, if any */ if (self->on_match != NIL) { - ListCell *lc2; - merge_information->on_match_set_info = transform_cypher_set_item_list(cpstate, self->on_match, query); merge_information->on_match_set_info->clause_name = "MERGE ON MATCH SET"; merge_information->on_match_set_info->graph_name = cpstate->graph_name; - /* - * Store prop_expr for direct evaluation in the MERGE executor. - * The planner may strip SET expression target entries from the plan, - * so we embed the Expr in the update item for direct evaluation. - */ - foreach(lc2, merge_information->on_match_set_info->set_items) - { - cypher_update_item *item = lfirst(lc2); - TargetEntry *set_tle = get_tle_by_resno(query->targetList, - item->prop_position); - if (set_tle != NULL) - item->prop_expr = (Node *)set_tle->expr; - } + resolve_merge_set_exprs( + merge_information->on_match_set_info->set_items, + query->targetList, "ON MATCH SET"); } /* Transform ON CREATE SET items, if any */ if (self->on_create != NIL) { - ListCell *lc2; - merge_information->on_create_set_info = transform_cypher_set_item_list(cpstate, self->on_create, query); merge_information->on_create_set_info->clause_name = "MERGE ON CREATE SET"; merge_information->on_create_set_info->graph_name = cpstate->graph_name; - /* Store prop_expr for MERGE executor (see comment above) */ - foreach(lc2, merge_information->on_create_set_info->set_items) - { - cypher_update_item *item = lfirst(lc2); - TargetEntry *set_tle = get_tle_by_resno(query->targetList, - item->prop_position); - if (set_tle != NULL) - item->prop_expr = (Node *)set_tle->expr; - } + resolve_merge_set_exprs( + merge_information->on_create_set_info->set_items, + query->targetList, "ON CREATE SET"); } if (!clause->next)