Fix crash in PREPARE with property parameter when enable_containment is off (#2339)

* Fix crash in PREPARE with property parameter when enable_containment is off

When age.enable_containment is set to off, executing a PREPARE statement
with a property parameter (e.g., MATCH (n $props) RETURN n) causes a
segfault. The crash occurs in transform_map_to_ind_recursive because
the property_constraints node is a cypher_param, not a cypher_map, but
is blindly cast to cypher_map and its keyvals field is dereferenced.

Three fixes:
- In create_property_constraints, when enable_containment is off and the
  constraint is a cypher_param, fall back to the containment operator
  (@>) since map decomposition requires known keys at parse time.
- In transform_match_entities, guard the keep_null assignment for both
  vertex and edge property constraints with is_ag_node checks to avoid
  writing to the wrong struct layout.

Fixes #1964

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix @> vs @>> for =properties form with PREPARE and add tests

When MATCH uses the =properties form (e.g., MATCH (n = $props)), the
enable_containment=on path correctly uses @>> (top-level containment).
The parameter fallback path unconditionally used @> (deep containment),
ignoring the use_equals flag. Fix the fallback to mirror the
enable_containment path by selecting @>> when use_equals is set.

Add regression tests for =properties form with PREPARE for both
vertices and edges, with enable_containment on and off.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
diff --git a/regress/expected/cypher_match.out b/regress/expected/cypher_match.out
index ff2825a..94315f1 100644
--- a/regress/expected/cypher_match.out
+++ b/regress/expected/cypher_match.out
@@ -3633,6 +3633,110 @@
  
 (1 row)
 
+-- Issue 1964
+--
+-- PREPARE with property parameter ($props) crashed the server when
+-- age.enable_containment was set to off. The crash was in
+-- transform_map_to_ind_recursive which blindly cast cypher_param
+-- nodes to cypher_map, accessing invalid memory.
+--
+SELECT create_graph('issue_1964');
+NOTICE:  graph "issue_1964" has been created
+ create_graph 
+--------------
+ 
+(1 row)
+
+SELECT * FROM cypher('issue_1964', $$
+    CREATE (:Person {name: 'Alice', age: 30}),
+           (:Person {name: 'Bob', age: 25})
+$$) AS (result agtype);
+ result 
+--------
+(0 rows)
+
+SELECT * FROM cypher('issue_1964', $$
+    CREATE (:Person {name: 'Alice'})-[:KNOWS {since: 2020}]->(:Person {name: 'Bob'})
+$$) AS (result agtype);
+ result 
+--------
+(0 rows)
+
+-- Test PREPARE with enable_containment off (was crashing)
+SET age.enable_containment = off;
+PREPARE issue_1964_vertex(agtype) AS
+    SELECT * FROM cypher('issue_1964',
+        $$MATCH (n $props) RETURN n $$, $1) AS (p agtype);
+EXECUTE issue_1964_vertex('{"props": {"name": "Alice"}}');
+                                               p                                                
+------------------------------------------------------------------------------------------------
+ {"id": 844424930131969, "label": "Person", "properties": {"age": 30, "name": "Alice"}}::vertex
+ {"id": 844424930131971, "label": "Person", "properties": {"name": "Alice"}}::vertex
+(2 rows)
+
+EXECUTE issue_1964_vertex('{"props": {"age": 25}}');
+                                              p                                               
+----------------------------------------------------------------------------------------------
+ {"id": 844424930131970, "label": "Person", "properties": {"age": 25, "name": "Bob"}}::vertex
+(1 row)
+
+DEALLOCATE issue_1964_vertex;
+-- Test edge property parameter with enable_containment off
+PREPARE issue_1964_edge(agtype) AS
+    SELECT * FROM cypher('issue_1964',
+        $$MATCH ()-[r $props]->() RETURN r $$, $1) AS (p agtype);
+EXECUTE issue_1964_edge('{"props": {"since": 2020}}');
+                                                                    p                                                                    
+-----------------------------------------------------------------------------------------------------------------------------------------
+ {"id": 1125899906842625, "label": "KNOWS", "end_id": 844424930131972, "start_id": 844424930131971, "properties": {"since": 2020}}::edge
+(1 row)
+
+DEALLOCATE issue_1964_edge;
+-- Verify enable_containment on still works with PREPARE
+SET age.enable_containment = on;
+PREPARE issue_1964_vertex_on(agtype) AS
+    SELECT * FROM cypher('issue_1964',
+        $$MATCH (n $props) RETURN n $$, $1) AS (p agtype);
+EXECUTE issue_1964_vertex_on('{"props": {"name": "Alice"}}');
+                                               p                                                
+------------------------------------------------------------------------------------------------
+ {"id": 844424930131969, "label": "Person", "properties": {"age": 30, "name": "Alice"}}::vertex
+ {"id": 844424930131971, "label": "Person", "properties": {"name": "Alice"}}::vertex
+(2 rows)
+
+DEALLOCATE issue_1964_vertex_on;
+-- Test =properties form with PREPARE (uses @>> top-level containment)
+SET age.enable_containment = off;
+PREPARE issue_1964_vertex_eq(agtype) AS
+    SELECT * FROM cypher('issue_1964',
+        $$MATCH (n = $props) RETURN n $$, $1) AS (p agtype);
+EXECUTE issue_1964_vertex_eq('{"props": {"name": "Alice", "age": 25}}');
+ p 
+---
+(0 rows)
+
+DEALLOCATE issue_1964_vertex_eq;
+PREPARE issue_1964_edge_eq(agtype) AS
+    SELECT * FROM cypher('issue_1964',
+        $$MATCH ()-[r = $props]->() RETURN r $$, $1) AS (p agtype);
+EXECUTE issue_1964_edge_eq('{"props": {"since": 2020}}');
+                                                                    p                                                                    
+-----------------------------------------------------------------------------------------------------------------------------------------
+ {"id": 1125899906842625, "label": "KNOWS", "end_id": 844424930131972, "start_id": 844424930131971, "properties": {"since": 2020}}::edge
+(1 row)
+
+DEALLOCATE issue_1964_edge_eq;
+-- Same with enable_containment on
+SET age.enable_containment = on;
+PREPARE issue_1964_vertex_eq_on(agtype) AS
+    SELECT * FROM cypher('issue_1964',
+        $$MATCH (n = $props) RETURN n $$, $1) AS (p agtype);
+EXECUTE issue_1964_vertex_eq_on('{"props": {"name": "Alice", "age": 25}}');
+ p 
+---
+(0 rows)
+
+DEALLOCATE issue_1964_vertex_eq_on;
 --
 -- Clean up
 --
@@ -3721,6 +3825,18 @@
  
 (1 row)
 
+SELECT drop_graph('issue_1964', true);
+NOTICE:  drop cascades to 4 other objects
+DETAIL:  drop cascades to table issue_1964._ag_label_vertex
+drop cascades to table issue_1964._ag_label_edge
+drop cascades to table issue_1964."Person"
+drop cascades to table issue_1964."KNOWS"
+NOTICE:  graph "issue_1964" has been dropped
+ drop_graph 
+------------
+ 
+(1 row)
+
 --
 -- End
 --
diff --git a/regress/sql/cypher_match.sql b/regress/sql/cypher_match.sql
index ebcd67b..d14f45f 100644
--- a/regress/sql/cypher_match.sql
+++ b/regress/sql/cypher_match.sql
@@ -1491,6 +1491,72 @@
 $$) AS (val agtype);
 
 SELECT drop_graph('issue_2308', true);
+-- Issue 1964
+--
+-- PREPARE with property parameter ($props) crashed the server when
+-- age.enable_containment was set to off. The crash was in
+-- transform_map_to_ind_recursive which blindly cast cypher_param
+-- nodes to cypher_map, accessing invalid memory.
+--
+
+SELECT create_graph('issue_1964');
+SELECT * FROM cypher('issue_1964', $$
+    CREATE (:Person {name: 'Alice', age: 30}),
+           (:Person {name: 'Bob', age: 25})
+$$) AS (result agtype);
+SELECT * FROM cypher('issue_1964', $$
+    CREATE (:Person {name: 'Alice'})-[:KNOWS {since: 2020}]->(:Person {name: 'Bob'})
+$$) AS (result agtype);
+
+-- Test PREPARE with enable_containment off (was crashing)
+SET age.enable_containment = off;
+
+PREPARE issue_1964_vertex(agtype) AS
+    SELECT * FROM cypher('issue_1964',
+        $$MATCH (n $props) RETURN n $$, $1) AS (p agtype);
+EXECUTE issue_1964_vertex('{"props": {"name": "Alice"}}');
+EXECUTE issue_1964_vertex('{"props": {"age": 25}}');
+DEALLOCATE issue_1964_vertex;
+
+-- Test edge property parameter with enable_containment off
+PREPARE issue_1964_edge(agtype) AS
+    SELECT * FROM cypher('issue_1964',
+        $$MATCH ()-[r $props]->() RETURN r $$, $1) AS (p agtype);
+EXECUTE issue_1964_edge('{"props": {"since": 2020}}');
+DEALLOCATE issue_1964_edge;
+
+-- Verify enable_containment on still works with PREPARE
+SET age.enable_containment = on;
+
+PREPARE issue_1964_vertex_on(agtype) AS
+    SELECT * FROM cypher('issue_1964',
+        $$MATCH (n $props) RETURN n $$, $1) AS (p agtype);
+EXECUTE issue_1964_vertex_on('{"props": {"name": "Alice"}}');
+DEALLOCATE issue_1964_vertex_on;
+
+-- Test =properties form with PREPARE (uses @>> top-level containment)
+SET age.enable_containment = off;
+
+PREPARE issue_1964_vertex_eq(agtype) AS
+    SELECT * FROM cypher('issue_1964',
+        $$MATCH (n = $props) RETURN n $$, $1) AS (p agtype);
+EXECUTE issue_1964_vertex_eq('{"props": {"name": "Alice", "age": 25}}');
+DEALLOCATE issue_1964_vertex_eq;
+
+PREPARE issue_1964_edge_eq(agtype) AS
+    SELECT * FROM cypher('issue_1964',
+        $$MATCH ()-[r = $props]->() RETURN r $$, $1) AS (p agtype);
+EXECUTE issue_1964_edge_eq('{"props": {"since": 2020}}');
+DEALLOCATE issue_1964_edge_eq;
+
+-- Same with enable_containment on
+SET age.enable_containment = on;
+
+PREPARE issue_1964_vertex_eq_on(agtype) AS
+    SELECT * FROM cypher('issue_1964',
+        $$MATCH (n = $props) RETURN n $$, $1) AS (p agtype);
+EXECUTE issue_1964_vertex_eq_on('{"props": {"name": "Alice", "age": 25}}');
+DEALLOCATE issue_1964_vertex_eq_on;
 
 --
 -- Clean up
@@ -1501,6 +1567,7 @@
 SELECT drop_graph('issue_945', true);
 SELECT drop_graph('issue_1399', true);
 SELECT drop_graph('issue_1393', true);
+SELECT drop_graph('issue_1964', true);
 
 --
 -- End
diff --git a/src/backend/parser/cypher_clause.c b/src/backend/parser/cypher_clause.c
index 446e97b..6f06bbb 100644
--- a/src/backend/parser/cypher_clause.c
+++ b/src/backend/parser/cypher_clause.c
@@ -4261,6 +4261,38 @@
     }
     else
     {
+        /*
+         * Map decomposition into individual index lookups requires known
+         * keys at parse time. When the property constraint is a parameter
+         * (cypher_param), the keys are not available until execution, so
+         * fall back to the containment operator.
+         */
+        if (is_ag_node(property_constraints, cypher_param))
+        {
+            /*
+             * Use @>> (top-level containment) for =properties form,
+             * @> (deep containment) otherwise — matching the
+             * enable_containment=on path above.
+             */
+            if ((entity->type == ENT_VERTEX &&
+                 entity->entity.node->use_equals) ||
+                ((entity->type == ENT_EDGE ||
+                  entity->type == ENT_VLE_EDGE) &&
+                 entity->entity.rel->use_equals))
+            {
+                return (Node *)make_op(pstate,
+                                       list_make1(makeString("@>>")),
+                                       prop_expr, const_expr,
+                                       last_srf, -1);
+            }
+            else
+            {
+                return (Node *)make_op(pstate,
+                                       list_make1(makeString("@>")),
+                                       prop_expr, const_expr,
+                                       last_srf, -1);
+            }
+        }
         return (Node *)transform_map_to_ind(
             cpstate, entity, (cypher_map *)property_constraints);
     }
@@ -4690,7 +4722,10 @@
                                                   -1);
                 }
 
-                ((cypher_map*)node->props)->keep_null = true;
+                if (is_ag_node(node->props, cypher_map))
+                {
+                    ((cypher_map*)node->props)->keep_null = true;
+                }
                 n = create_property_constraints(cpstate, entity, node->props,
                                                 prop_expr);
 
@@ -4819,7 +4854,10 @@
                                                       false, -1);
                     }
 
-                    ((cypher_map*)rel->props)->keep_null = true;
+                    if (is_ag_node(rel->props, cypher_map))
+                    {
+                        ((cypher_map*)rel->props)->keep_null = true;
+                    }
                     r = create_property_constraints(cpstate, entity, rel->props,
                                                     prop_expr);