This is an automated email from the ASF dual-hosted git repository.

MuhammadTahaNaveed pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/age.git


The following commit(s) were added to refs/heads/master by this push:
     new 84e29542 Add MERGE ON CREATE SET / ON MATCH SET support (#2347)
84e29542 is described below

commit 84e29542f0d54c9d848a3d5ebbc7a8f4d5a67437
Author: Greg Felice <[email protected]>
AuthorDate: Wed Apr 22 05:06:59 2026 -0400

    Add MERGE ON CREATE SET / ON MATCH SET support (#2347)
    
    - Add MERGE ON CREATE SET / ON MATCH SET support
    
    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.
    
    - 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).
    - 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.
    - 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).
    - 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.
    - The four ExecStoreVirtualTuple calls in exec_cypher_merge were triggering
      an Assert failure under --enable-cassert:
    
        TRAP: failed Assert("TTS_EMPTY(slot)"), File: execTuples.c, Line: 1748
    
    ExecStoreVirtualTuple (execTuples.c:1748) asserts that its target slot
    is in the TTS_EMPTY state.  In our MERGE executor, process_path writes
    directly into the subquery's scan tuple slot -- which already holds the
    subquery's output tuple and therefore is NOT empty.  On a release build
    the assertion compiles out and ExecStoreVirtualTuple just clears the flag
    and sets tts_nvalid; on an --enable-cassert build the backend aborts and
    takes down the regression run.
    
    We only need the bookkeeping half of ExecStoreVirtualTuple (clear
    TTS_FLAG_EMPTY and set tts_nvalid = natts) -- not the "store semantics"
    that motivate the assertion.  Add a small static helper
    mark_scan_slot_valid() that does exactly the bookkeeping, and replace
    the four call sites.  Release-build behavior is byte-identical since
    Assert() compiles to nothing; cassert-build behavior now matches release.
    
    - Fix MERGE ON CREATE/MATCH SET crash when RHS references a bound variable
    
    When MERGE has a previous clause (e.g. MATCH, UNWIND), 
transform_cypher_merge
    takes the lateral-left-join path via transform_merge_make_lateral_join. That
    helper called addRangeTableEntryForJoin with nscolumns=NULL, leaving the 
join
    ParseNamespaceItem's p_nscolumns unset. For queries that did not 
subsequently
    resolve a column reference against that nsitem (e.g. RETURN, which runs in a
    fresh namespace built by handle_prev_clause), the NULL was harmless.
    
    Our ON CREATE / ON MATCH SET transform runs in-line, before the MERGE query
    becomes a subquery, so transform_cypher_set_item_list consulted the join's
    nsitem directly. colNameToVar -> scanNSItemForColumn then dereferenced
    p_nscolumns[attnum-1] = NULL[0] and the backend segfaulted on any ON SET
    whose RHS referenced a bound variable.
    
    Populate the join's p_nscolumns from res_colvars. The Var we end up
    producing for a bound entity lives inside prop_expr, which is opaque to
    the planner, so it is not rewritten to match the plan's output slots. At
    ExecEvalScalarVar time only varattno is consulted, and scantuple's layout
    mirrors the join's eref->colnames (via make_target_list_from_join). Use
    the join rtindex and 1-based eref position so scantuple[varattno - 1]
    resolves to the correct entity column at runtime; without this, Vars for
    a (varno=l_rte) and b (varno=r_rte) with varattno=1 both hit
    scantuple[0] and b.id evaluated to a.id.
    
    Also initialise apply_update_list's new_property_value at its declaration.
    All control paths reach the single alter_property_value call with the
    variable set, but -Wmaybe-uninitialized fires at -O2 because the compiler
    cannot prove remove_property == isnull when prop_expr is non-NULL.
    ---------
    
    Co-authored-by: Claude Opus 4.6 <[email protected]>
---
 .gitignore                           |   1 +
 regress/expected/cypher_merge.out    | 249 +++++++++++++++++++++++++++++++++++
 regress/sql/cypher_merge.sql         | 157 ++++++++++++++++++++++
 src/backend/executor/cypher_merge.c  | 133 +++++++++++++++++--
 src/backend/executor/cypher_set.c    |  67 ++++++++--
 src/backend/nodes/cypher_copyfuncs.c |   4 +
 src/backend/nodes/cypher_outfuncs.c  |   8 +-
 src/backend/nodes/cypher_readfuncs.c |   4 +
 src/backend/parser/cypher_clause.c   | 114 +++++++++++++++-
 src/backend/parser/cypher_gram.y     |  65 ++++++++-
 src/include/executor/cypher_utils.h  |   6 +
 src/include/nodes/cypher_nodes.h     |   7 +
 src/include/parser/cypher_kwlist.h   |   1 +
 13 files changed, 788 insertions(+), 28 deletions(-)

diff --git a/.gitignore b/.gitignore
index 03923b03..1e2f8f67 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 4242f2f5..ac08a971 100644
--- a/regress/expected/cypher_merge.out
+++ b/regress/expected/cypher_merge.out
@@ -2001,9 +2001,258 @@ SELECT * FROM cypher('issue_1954', $$ 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 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'})
+    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)
+
+-- 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'})
+    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', $$
+                                               ^
+-- 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)
+
+-- 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)
+
+-- Issue #2347: RHS of ON CREATE / ON MATCH SET referencing a bound
+-- variable crashed the backend when MERGE had a previous clause, because
+-- the lateral-join's ParseNamespaceItem had p_nscolumns=NULL.
+-- ON CREATE SET with RHS referencing the outer MATCH's variable
+SELECT * FROM cypher('merge_actions', $$ CREATE (:Person {name:'Anchor'}) $$) 
AS (a agtype);
+ a 
+---
+(0 rows)
+
+SELECT * FROM cypher('merge_actions', $$
+  MATCH (a:Person {name: 'Anchor'})
+  MERGE (b:Person {name: 'FromOuter'})
+    ON CREATE SET b.source_name = a.name
+  RETURN a.name, b.name, b.source_name
+$$) AS (a_name agtype, b_name agtype, b_source agtype);
+  a_name  |   b_name    | b_source 
+----------+-------------+----------
+ "Anchor" | "FromOuter" | "Anchor"
+(1 row)
+
+-- ON CREATE SET with RHS referencing the MERGE-bound variable itself
+SELECT * FROM cypher('merge_actions', $$
+  MATCH (a:Person {name: 'Anchor'})
+  MERGE (b:Person {name: 'SelfRef'})
+    ON CREATE SET b.echo_name = b.name
+  RETURN b.name, b.echo_name
+$$) AS (b_name agtype, b_echo agtype);
+  b_name   |  b_echo   
+-----------+-----------
+ "SelfRef" | "SelfRef"
+(1 row)
+
+-- ON CREATE SET driven by UNWIND with self-reference on the RHS
+-- (Muhammad's second reproducer)
+SELECT * FROM cypher('merge_actions', $$
+  UNWIND ['U1', 'U2'] AS nm
+  MERGE (n:Person {name: nm})
+    ON CREATE SET n.copy_name = n.name
+  RETURN n.name, n.copy_name
+$$) AS (n_name agtype, n_copy agtype);
+ n_name | n_copy 
+--------+--------
+ "U1"   | "U1"
+ "U2"   | "U2"
+(2 rows)
+
+-- Multiple SET items mixing outer-ref, self-ref, and literal RHS
+SELECT * FROM cypher('merge_actions', $$
+  MATCH (a:Person {name: 'Anchor'})
+  MERGE (b:Person {name: 'MultiItem'})
+    ON CREATE SET b.from_a = a.name, b.self = b.name, b.lit = 'literal'
+  RETURN b.from_a, b.self, b.lit
+$$) AS (fa agtype, sf agtype, lit agtype);
+    fa    |     sf      |    lit    
+----------+-------------+-----------
+ "Anchor" | "MultiItem" | "literal"
+(1 row)
+
+-- ON MATCH SET with variable RHS (second run on existing node)
+SELECT * FROM cypher('merge_actions', $$
+  MATCH (a:Person {name: 'Anchor'})
+  MERGE (b:Person {name: 'FromOuter'})
+    ON CREATE SET b.source_name = a.name
+    ON MATCH SET b.last_seen_by = a.name
+  RETURN b.source_name, b.last_seen_by
+$$) AS (src agtype, last agtype);
+   src    |   last   
+----------+----------
+ "Anchor" | "Anchor"
+(1 row)
+
+-- 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 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 
+------------
+ 
+(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 5939c42a..86b3e023 100644
--- a/regress/sql/cypher_merge.sql
+++ b/regress/sql/cypher_merge.sql
@@ -932,9 +932,166 @@ 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);
 SELECT * FROM cypher('issue_1954', $$ 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 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'})
+    ON CREATE SET b.source = 'merge_create'
+  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'})
+    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);
+
+-- 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);
+
+-- 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);
+
+-- Issue #2347: RHS of ON CREATE / ON MATCH SET referencing a bound
+-- variable crashed the backend when MERGE had a previous clause, because
+-- the lateral-join's ParseNamespaceItem had p_nscolumns=NULL.
+
+-- ON CREATE SET with RHS referencing the outer MATCH's variable
+SELECT * FROM cypher('merge_actions', $$ CREATE (:Person {name:'Anchor'}) $$) 
AS (a agtype);
+SELECT * FROM cypher('merge_actions', $$
+  MATCH (a:Person {name: 'Anchor'})
+  MERGE (b:Person {name: 'FromOuter'})
+    ON CREATE SET b.source_name = a.name
+  RETURN a.name, b.name, b.source_name
+$$) AS (a_name agtype, b_name agtype, b_source agtype);
+
+-- ON CREATE SET with RHS referencing the MERGE-bound variable itself
+SELECT * FROM cypher('merge_actions', $$
+  MATCH (a:Person {name: 'Anchor'})
+  MERGE (b:Person {name: 'SelfRef'})
+    ON CREATE SET b.echo_name = b.name
+  RETURN b.name, b.echo_name
+$$) AS (b_name agtype, b_echo agtype);
+
+-- ON CREATE SET driven by UNWIND with self-reference on the RHS
+-- (Muhammad's second reproducer)
+SELECT * FROM cypher('merge_actions', $$
+  UNWIND ['U1', 'U2'] AS nm
+  MERGE (n:Person {name: nm})
+    ON CREATE SET n.copy_name = n.name
+  RETURN n.name, n.copy_name
+$$) AS (n_name agtype, n_copy agtype);
+
+-- Multiple SET items mixing outer-ref, self-ref, and literal RHS
+SELECT * FROM cypher('merge_actions', $$
+  MATCH (a:Person {name: 'Anchor'})
+  MERGE (b:Person {name: 'MultiItem'})
+    ON CREATE SET b.from_a = a.name, b.self = b.name, b.lit = 'literal'
+  RETURN b.from_a, b.self, b.lit
+$$) AS (fa agtype, sf agtype, lit agtype);
+
+-- ON MATCH SET with variable RHS (second run on existing node)
+SELECT * FROM cypher('merge_actions', $$
+  MATCH (a:Person {name: 'Anchor'})
+  MERGE (b:Person {name: 'FromOuter'})
+    ON CREATE SET b.source_name = a.name
+    ON MATCH SET b.last_seen_by = a.name
+  RETURN b.source_name, b.last_seen_by
+$$) AS (src agtype, last 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 c2db878b..2b3d1f7d 100644
--- a/src/backend/executor/cypher_merge.c
+++ b/src/backend/executor/cypher_merge.c
@@ -81,6 +81,7 @@ static bool check_path(cypher_merge_custom_scan_state *css,
 static void process_path(cypher_merge_custom_scan_state *css,
                          path_entry **path_array, bool should_insert);
 static void mark_tts_isnull(TupleTableSlot *slot);
+static void mark_scan_slot_valid(TupleTableSlot *slot);
 
 const CustomExecMethods cypher_merge_exec_methods = {MERGE_SCAN_STATE_NAME,
                                                      begin_cypher_merge,
@@ -192,6 +193,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
@@ -322,11 +352,49 @@ 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)
+        {
+            mark_scan_slot_valid(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);
+        }
     }
 }
 
+/*
+ * mark_scan_slot_valid - mark a scan slot as populated after direct writes
+ * to tts_values[] by process_path.
+ *
+ * This does the same bookkeeping as ExecStoreVirtualTuple (clear TTS_EMPTY,
+ * set tts_nvalid = natts) but without the TTS_EMPTY precondition assertion.
+ * We cannot use ExecStoreVirtualTuple here because process_path writes into
+ * a scan slot that already holds the subquery's output tuple -- the slot is
+ * NOT empty, and asserting it is would fire under --enable-cassert while
+ * silently clearing the flag on release builds.
+ */
+static void mark_scan_slot_valid(TupleTableSlot *slot)
+{
+    slot->tts_flags &= ~TTS_FLAG_EMPTY;
+    slot->tts_nvalid = slot->tts_tupleDescriptor->natts;
+}
+
 /*
  * Iterate through the TupleTableSlot's tts_values and marks the isnull field
  * with true.
@@ -658,6 +726,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
                     {
@@ -669,8 +742,20 @@ static TupleTableSlot *exec_cypher_merge(CustomScanState 
*node)
                         css->created_paths_list = new_path;
 
                         process_path(css, prebuilt_path_array, true);
+                        mark_scan_slot_valid(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);
                     }
                 }
+                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 =
@@ -743,6 +828,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
                 {
@@ -753,8 +842,19 @@ static TupleTableSlot *exec_cypher_merge(CustomScanState 
*node)
                     css->created_paths_list = new_path;
 
                     process_path(css, prebuilt_path_array, true);
+                    mark_scan_slot_valid(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);
                 }
             }
+            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);
 
@@ -827,6 +927,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);
         }
@@ -887,20 +995,25 @@ static TupleTableSlot *exec_cypher_merge(CustomScanState 
*node)
             /* setup the scantuple that the process_path needs */
             econtext->ecxt_scantuple = sss->ss.ss_ScanTupleSlot;
 
+            /*
+             * 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);
+
             /* create the path */
             process_path(css, NULL, true);
 
-            /* mark the create_new_path flag to true. */
-            css->created_new_path = true;
+            /* mark the slot as valid so tts_nvalid reflects natts */
+            mark_scan_slot_valid(econtext->ecxt_scantuple);
 
-            /*
-             *  find the tts_values that process_path did not populate and
-             *  mark as null.
-             */
-            mark_tts_isnull(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);
 
-            /* store the heap tuble */
-            ExecStoreVirtualTuple(econtext->ecxt_scantuple);
+            /* 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
@@ -1036,6 +1149,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 8dfa6326..a6e64ba5 100644
--- a/src/backend/executor/cypher_set.c
+++ b/src/backend/executor/cypher_set.c
@@ -327,8 +327,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;
 
@@ -374,13 +373,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;
@@ -415,7 +419,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;
 
@@ -430,7 +434,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;
@@ -438,7 +442,7 @@ static void process_update_list(CustomScanState *node)
         agtype_value *id;
         agtype_value *label;
         agtype *original_entity;
-        agtype *new_property_value;
+        agtype *new_property_value = NULL;
         TupleTableSlot *slot;
         ResultRelInfo *resultRelInfo;
         ScanKeyData scan_keys[1];
@@ -448,7 +452,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;
         Oid index_oid = InvalidOid;
         Relation rel;
@@ -501,11 +505,43 @@ 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;
+
+            /*
+             * 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;
+
+            if (!isnull)
+            {
+                new_property_value = DATUM_GET_AGTYPE_P(val);
+            }
+        }
         else
         {
             remove_property = 
scanTupleSlot->tts_isnull[update_item->prop_position - 1];
@@ -519,7 +555,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]);
         }
@@ -552,7 +588,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);
 
         rel = resultRelInfo->ri_RelationDesc;
         relid = RelationGetRelid(rel);
@@ -799,6 +835,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 420ab1d2..283096ca 100644
--- a/src/backend/nodes/cypher_copyfuncs.c
+++ b/src/backend/nodes/cypher_copyfuncs.c
@@ -136,6 +136,8 @@ 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_NODE_FIELD(prop_expr_state);
 }
 
 /* copy function for cypher_delete_information */
@@ -168,6 +170,8 @@ 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);
 }
 
 /* copy function for cypher_predicate_function */
diff --git a/src/backend/nodes/cypher_outfuncs.c 
b/src/backend/nodes/cypher_outfuncs.c
index cf8a400f..84d32a8f 100644
--- a/src/backend/nodes/cypher_outfuncs.c
+++ b/src/backend/nodes/cypher_outfuncs.c
@@ -200,12 +200,14 @@ void out_cypher_predicate_function(StringInfo str, const 
ExtensibleNode *node)
     WRITE_NODE_FIELD(where);
 }
 
-/* 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. */
@@ -438,6 +440,8 @@ 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);
+    WRITE_NODE_FIELD(prop_expr_state);
 }
 
 /* serialization function for the cypher_delete_information ExtensibleNode. */
@@ -470,6 +474,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 14b553db..1e7e0ef8 100644
--- a/src/backend/nodes/cypher_readfuncs.c
+++ b/src/backend/nodes/cypher_readfuncs.c
@@ -269,6 +269,8 @@ 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);
+    READ_NODE_FIELD(prop_expr_state);
 }
 
 /*
@@ -310,6 +312,8 @@ 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 0ab574fe..3083c52e 100644
--- a/src/backend/parser/cypher_clause.c
+++ b/src/backend/parser/cypher_clause.c
@@ -7269,6 +7269,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)
 {
@@ -7341,6 +7368,32 @@ 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)
+    {
+        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;
+
+        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)
+    {
+        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;
+
+        resolve_merge_set_exprs(
+            merge_information->on_create_set_info->set_items,
+            query->targetList, "ON CREATE SET");
+    }
+
     if (!clause->next)
     {
         merge_information->flags |= CYPHER_CLAUSE_FLAG_TERMINAL;
@@ -7456,10 +7509,63 @@ transform_merge_make_lateral_join(cypher_parsestate 
*cpstate, Query *query,
      */
     get_res_cols(pstate, l_nsitem, r_nsitem, &res_colnames, &res_colvars);
 
-    /* make the RTE for the join */
-    jnsitem = addRangeTableEntryForJoin(pstate, res_colnames, NULL, 
j->jointype,
-                                        0, res_colvars, NIL, NIL, j->alias,
-                                        NULL, true);
+    /*
+     * Build a ParseNamespaceColumn array for the join RTE so that
+     * subsequent name lookups (e.g. transform_cypher_set_item_list for an
+     * ON CREATE SET / ON MATCH SET expression) can resolve references to
+     * variables bound in the prev clause or the MERGE's path via
+     * colNameToVar → scanNSItemForColumn, which dereferences
+     * nsitem->p_nscolumns. Passing NULL here left p_nscolumns unset and
+     * caused a segfault whenever an ON SET item's RHS referenced a bound
+     * variable (issue #2347).
+     *
+     * Each column's nscolumn references the join RTE (via its rtindex) with
+     * p_varattno = position in res_colnames. This matches the scantuple
+     * layout that apply_update_list sees at execution time: the join's
+     * target list (built by make_target_list_from_join below) iterates
+     * eref->colnames in order, so scantuple[i-1] corresponds to the i-th
+     * entry in eref->colnames. Using the underlying RTE's varno/varattno
+     * would be semantically equivalent for planner-rewritten Vars in the
+     * query tree, but the Vars we produce here end up inside prop_expr --
+     * opaque metadata the planner does not walk -- so they stay un-remapped
+     * and must index the scantuple layout directly.
+     *
+     * addRangeTableEntryForJoin appends the new RTE to pstate->p_rtable, so
+     * its rtindex is list_length(p_rtable) + 1 at this point.
+     */
+    {
+        int colcount = list_length(res_colvars);
+        int join_rtindex = list_length(pstate->p_rtable) + 1;
+        ParseNamespaceColumn *nscolumns;
+        ListCell *lvar;
+        int col_idx = 0;
+
+        nscolumns = (ParseNamespaceColumn *)
+                    palloc0(colcount * sizeof(ParseNamespaceColumn));
+
+        foreach (lvar, res_colvars)
+        {
+            Var *v = (Var *) lfirst(lvar);
+
+            /* res_colvars is populated by get_res_cols via expandRTE */
+            Assert(IsA(v, Var));
+
+            nscolumns[col_idx].p_varno = join_rtindex;
+            nscolumns[col_idx].p_varattno = col_idx + 1;
+            nscolumns[col_idx].p_vartype = v->vartype;
+            nscolumns[col_idx].p_vartypmod = v->vartypmod;
+            nscolumns[col_idx].p_varcollid = v->varcollid;
+            nscolumns[col_idx].p_varnosyn = join_rtindex;
+            nscolumns[col_idx].p_varattnosyn = col_idx + 1;
+            col_idx++;
+        }
+
+        /* make the RTE for the join */
+        jnsitem = addRangeTableEntryForJoin(pstate, res_colnames, nscolumns,
+                                            j->jointype, 0, res_colvars,
+                                            NIL, NIL, j->alias, NULL, true);
+        Assert(jnsitem->p_rtindex == join_rtindex);
+    }
 
     j->rtindex = jnsitem->p_rtindex;
 
diff --git a/src/backend/parser/cypher_gram.y b/src/backend/parser/cypher_gram.y
index e14c6b48..c857724d 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> INTEGER
@@ -89,7 +93,7 @@
                  LIMIT
                  MATCH MERGE
                  NONE NOT NULL_P
-                 OPERATOR OPTIONAL OR ORDER
+                 ON OPERATOR OPTIONAL OR ORDER
                  REMOVE RETURN
                  SET SINGLE SKIP STARTS
                  THEN TRUE_P
@@ -139,6 +143,7 @@
 
 /* MERGE clause */
 %type <node> merge
+%type <merge_actions> merge_actions_opt merge_actions merge_action
 
 /* CALL ... YIELD clause */
 %type <node> call_stmt yield_item
@@ -1139,17 +1144,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
  */
@@ -2423,6 +2483,7 @@ safe_keywords:
     | MERGE      { $$ = KEYWORD_STRDUP($1); }
     | NONE       { $$ = KEYWORD_STRDUP($1); }
     | NOT        { $$ = KEYWORD_STRDUP($1); }
+    | ON         { $$ = KEYWORD_STRDUP($1); }
     | OPERATOR   { $$ = KEYWORD_STRDUP($1); }
     | OPTIONAL   { $$ = KEYWORD_STRDUP($1); }
     | OR         { $$ = KEYWORD_STRDUP($1); }
diff --git a/src/include/executor/cypher_utils.h 
b/src/include/executor/cypher_utils.h
index cdd8fa33..ac4b5ea5 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 93bbe01d..3433bebb 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;
 
 /*
@@ -472,6 +474,9 @@ 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 */
+    ExprState *prop_expr_state;  /* initialized at plan init, not per-row */
 } cypher_update_item;
 
 typedef struct cypher_delete_information
@@ -498,6 +503,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 0de29497..44ac0945 100644
--- a/src/include/parser/cypher_kwlist.h
+++ b/src/include/parser/cypher_kwlist.h
@@ -31,6 +31,7 @@ PG_KEYWORD("merge", MERGE, RESERVED_KEYWORD)
 PG_KEYWORD("none", NONE, 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)

Reply via email to