This is an automated email from the ASF dual-hosted git repository.
jrgemignani 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 54668e1b Preserve null-valued keys in map literals (#2391) (#2412)
54668e1b is described below
commit 54668e1b50aa22e76b8b522ef4531134c80c7ca2
Author: Prashant Chinnam <[email protected]>
AuthorDate: Mon Jun 29 10:16:57 2026 -0700
Preserve null-valued keys in map literals (#2391) (#2412)
* Preserve null-valued keys in map literals (#2391)
Map literals such as RETURN {a: null} previously dropped keys whose
values were null, producing {} instead of {"a": null}. This
diverged from the openCypher / Neo4j semantics where map literals
preserve every key the user wrote, including those bound to null.
Root cause: cypher_map.keep_null defaulted to false (zero-initialised),
so the grammar-produced node fed agtype_build_map_nonull, which strips
null entries. Call sites that legitimately need strip-null semantics
(CREATE node/edge property maps and SET = assignments) already set
keep_null=false explicitly, and the MATCH pattern path sets it to true
explicitly. Flipping the grammar default to true therefore only affects
the cases that were buggy (bare map expressions and nested map values),
and leaves CREATE/SET behaviour unchanged.
Two preexisting tests encoded the old buggy output and are updated:
expr.out (bare RETURN maps now keep the null value) and agtype.out
(a nested map inside an orderability test no longer drops its null
entry, shifting one row in the ORDER BY result). Dedicated regression
coverage for #2391 is added to regress/sql/expr.sql.
* Address review: move 'End of tests' marker and drop order claim
- Move 'End of tests' to after the Issue 2391 test block so it marks
the actual end of the file.
- Remove 'in order' from the mixed-values comment since map key
ordering is not guaranteed.
Co-authored-by: Copilot <[email protected]>
* Update accessor EXPLAIN expected output for null-preserving map literals
Map literals now build with agtype_build_map (keep_null) instead of
agtype_build_map_nonull, so the accessor-optimization EXPLAIN plan in
expr.out must reflect the new function name. Also strip a stray trailing
CRLF on the last line of expr.sql/expr.out that leaked a carriage return
into the regression output.
Co-authored-by: Copilot <[email protected]>
* Add nested-map write coverage and clarify top-level strip wording
A reviewer noted the keep_null=true default reaches further than the
PR summary stated: CREATE / SET = only override keep_null=false on the
top-level property map, not recursively. A nested map value is its own
cypher_map node, so it now inherits the new default and preserves its
null-valued keys (e.g. CREATE (n {a: {b: null}}) stores a -> {"b": null}).
- regress/sql/expr.sql, regress/expected/expr.out: pin the nested case
under a write with CREATE (n:Nested {a: {b: null}}), MATCH ... SET
n = {a: {b: null}}, and a MATCH verify.
- src/backend/parser/cypher_gram.y: clarify the map: rule comment to
state the CREATE / SET override is top-level only and nested map
values keep the null-preserving default.
Co-authored-by: Copilot <[email protected]>
---------
Co-authored-by: Copilot <[email protected]>
---
regress/expected/agtype.out | 4 +-
regress/expected/expr.out | 148 ++++++++++++++++++++++++++++++++++++---
regress/sql/expr.sql | 54 ++++++++++++++
src/backend/parser/cypher_gram.y | 10 +++
4 files changed, 205 insertions(+), 11 deletions(-)
diff --git a/regress/expected/agtype.out b/regress/expected/agtype.out
index b2f3b83e..0f4775b8 100644
--- a/regress/expected/agtype.out
+++ b/regress/expected/agtype.out
@@ -2207,8 +2207,8 @@ SELECT * FROM cypher('orderability_graph', $$ MATCH (n)
RETURN n ORDER BY n.prop
{"id": 844424930131981, "label": "vertex", "properties": {"prop": [{"id": 0,
"label": "v", "properties": {"i": 0}}::vertex, {"id": 2, "label": "e",
"end_id": 1, "start_id": 0, "properties": {"i": 0}}::edge, {"id": 1, "label":
"v", "properties": {"i": 0}}::vertex]::path}}::vertex
{"id": 844424930131980, "label": "vertex", "properties": {"prop": {"id": 2,
"label": "e", "end_id": 1, "start_id": 0, "properties": {"i":
0}}::edge}}::vertex
{"id": 844424930131979, "label": "vertex", "properties": {"prop": {"id": 0,
"label": "v", "properties": {"i": 0}}::vertex}}::vertex
- {"id": 844424930131978, "label": "vertex", "properties": {"prop": {"bool":
true}}}::vertex
{"id": 844424930131977, "label": "vertex", "properties": {"prop": {"i": 0,
"bool": true}}}::vertex
+ {"id": 844424930131978, "label": "vertex", "properties": {"prop": {"i": null,
"bool": true}}}::vertex
{"id": 844424930131975, "label": "vertex", "properties": {"prop": [1, 2,
3]}}::vertex
{"id": 844424930131976, "label": "vertex", "properties": {"prop": [1, 2, 3,
4, 5]}}::vertex
{"id": 844424930131973, "label": "vertex", "properties": {"prop":
"string"}}::vertex
@@ -2230,8 +2230,8 @@ SELECT * FROM cypher('orderability_graph', $$ MATCH (n)
RETURN n ORDER BY n.prop
{"id": 844424930131973, "label": "vertex", "properties": {"prop":
"string"}}::vertex
{"id": 844424930131976, "label": "vertex", "properties": {"prop": [1, 2, 3,
4, 5]}}::vertex
{"id": 844424930131975, "label": "vertex", "properties": {"prop": [1, 2,
3]}}::vertex
+ {"id": 844424930131978, "label": "vertex", "properties": {"prop": {"i": null,
"bool": true}}}::vertex
{"id": 844424930131977, "label": "vertex", "properties": {"prop": {"i": 0,
"bool": true}}}::vertex
- {"id": 844424930131978, "label": "vertex", "properties": {"prop": {"bool":
true}}}::vertex
{"id": 844424930131979, "label": "vertex", "properties": {"prop": {"id": 0,
"label": "v", "properties": {"i": 0}}::vertex}}::vertex
{"id": 844424930131980, "label": "vertex", "properties": {"prop": {"id": 2,
"label": "e", "end_id": 1, "start_id": 0, "properties": {"i":
0}}::edge}}::vertex
{"id": 844424930131981, "label": "vertex", "properties": {"prop": [{"id": 0,
"label": "v", "properties": {"i": 0}}::vertex, {"id": 2, "label": "e",
"end_id": 1, "start_id": 0, "properties": {"i": 0}}::edge, {"id": 1, "label":
"v", "properties": {"i": 0}}::vertex]::path}}::vertex
diff --git a/regress/expected/expr.out b/regress/expected/expr.out
index 3c80bcae..d6b9ac15 100644
--- a/regress/expected/expr.out
+++ b/regress/expected/expr.out
@@ -40,18 +40,18 @@ SELECT * FROM cypher('expr', $$RETURN {}$$) AS r(c agtype);
SELECT * FROM cypher('expr', $$
RETURN {s: 's', i: 1, f: 1.0, b: true, z: null}
$$) AS r(c agtype);
- c
------------------------------------------
- {"b": true, "f": 1.0, "i": 1, "s": "s"}
+ c
+----------------------------------------------------
+ {"b": true, "f": 1.0, "i": 1, "s": "s", "z": null}
(1 row)
-- nested maps
SELECT * FROM cypher('expr', $$
RETURN {s: {s: 's'}, t: {i: 1, e: {f: 1.0}, s: {a: {b: true}}}, z: null}
$$) AS r(c agtype);
- c
-----------------------------------------------------------------------------
- {"s": {"s": "s"}, "t": {"e": {"f": 1.0}, "i": 1, "s": {"a": {"b": true}}}}
+ c
+---------------------------------------------------------------------------------------
+ {"s": {"s": "s"}, "t": {"e": {"f": 1.0}, "i": 1, "s": {"a": {"b": true}}},
"z": null}
(1 row)
--
@@ -9866,10 +9866,10 @@ SELECT * FROM cypher('accessor_opt', $$
MATCH (n:Person)
RETURN {id: id(n), name: n.name}
$$) AS (plan agtype);
- QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------
+ QUERY PLAN
+------------------------------------------------------------------------------------------------------------------------------------
Seq Scan on accessor_opt."Person" n
- Output: agtype_build_map_nonull('id'::text, n.id, 'name'::text,
agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]))
+ Output: agtype_build_map('id'::text, n.id, 'name'::text,
agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]))
(2 rows)
SELECT * FROM cypher('accessor_opt', $$
@@ -10558,6 +10558,136 @@ NOTICE: graph "list" has been dropped
(1 row)
+--
+-- Issue 2391 - map literals must preserve keys whose values are null
+--
+SELECT create_graph('issue_2391');
+NOTICE: graph "issue_2391" has been created
+ create_graph
+--------------
+
+(1 row)
+
+-- single-key null
+SELECT * FROM cypher('issue_2391', $$
+ RETURN {a: null} AS m
+$$) AS (m agtype);
+ m
+-------------
+ {"a": null}
+(1 row)
+
+-- multiple null values
+SELECT * FROM cypher('issue_2391', $$
+ RETURN {companyName: null, sinceYear: null} AS m
+$$) AS (m agtype);
+ m
+------------------------------------------
+ {"sinceYear": null, "companyName": null}
+(1 row)
+
+-- keys() must see the null-valued key
+SELECT * FROM cypher('issue_2391', $$
+ RETURN keys({a: null}) AS ks
+$$) AS (ks agtype);
+ ks
+-------
+ ["a"]
+(1 row)
+
+-- coalesce passes a non-null map (map itself is not null) through
+SELECT * FROM cypher('issue_2391', $$
+ RETURN coalesce({a: null}, null) AS m
+$$) AS (m agtype);
+ m
+-------------
+ {"a": null}
+(1 row)
+
+-- nested map values inside an expression also preserve nulls
+SELECT * FROM cypher('issue_2391', $$
+ RETURN {outer: {inner: null, kept: 1}} AS m
+$$) AS (m agtype);
+ m
+---------------------------------------
+ {"outer": {"kept": 1, "inner": null}}
+(1 row)
+
+-- mixed non-null and null values are all preserved
+SELECT * FROM cypher('issue_2391', $$
+ RETURN {a: 1, b: null, c: 'x'} AS m
+$$) AS (m agtype);
+ m
+-------------------------------
+ {"a": 1, "b": null, "c": "x"}
+(1 row)
+
+-- control: empty map is still empty
+SELECT * FROM cypher('issue_2391', $$
+ RETURN {} AS m
+$$) AS (m agtype);
+ m
+----
+ {}
+(1 row)
+
+-- control: CREATE must still strip top-level null properties so
+-- setting a property to null removes it from storage
+SELECT * FROM cypher('issue_2391', $$
+ CREATE (n:Item {keep: 1, drop: null}) RETURN n
+$$) AS (n agtype);
+ n
+-----------------------------------------------------------------------------
+ {"id": 844424930131969, "label": "Item", "properties": {"keep": 1}}::vertex
+(1 row)
+
+SELECT * FROM cypher('issue_2391', $$
+ MATCH (n:Item) RETURN n
+$$) AS (n agtype);
+ n
+-----------------------------------------------------------------------------
+ {"id": 844424930131969, "label": "Item", "properties": {"keep": 1}}::vertex
+(1 row)
+
+-- nested map values under a write (CREATE / SET =) are preserved: the
+-- top-level property map is null-stripped, but a nested map literal is
+-- its own node and keeps its null-valued keys
+SELECT * FROM cypher('issue_2391', $$
+ CREATE (n:Nested {a: {b: null}}) RETURN n
+$$) AS (n agtype);
+ n
+---------------------------------------------------------------------------------------
+ {"id": 1125899906842625, "label": "Nested", "properties": {"a": {"b":
null}}}::vertex
+(1 row)
+
+SELECT * FROM cypher('issue_2391', $$
+ MATCH (n:Nested) SET n = {a: {b: null}} RETURN n
+$$) AS (n agtype);
+ n
+---------------------------------------------------------------------------------------
+ {"id": 1125899906842625, "label": "Nested", "properties": {"a": {"b":
null}}}::vertex
+(1 row)
+
+SELECT * FROM cypher('issue_2391', $$
+ MATCH (n:Nested) RETURN n
+$$) AS (n agtype);
+ n
+---------------------------------------------------------------------------------------
+ {"id": 1125899906842625, "label": "Nested", "properties": {"a": {"b":
null}}}::vertex
+(1 row)
+
+SELECT * FROM drop_graph('issue_2391', true);
+NOTICE: drop cascades to 4 other objects
+DETAIL: drop cascades to table issue_2391._ag_label_vertex
+drop cascades to table issue_2391._ag_label_edge
+drop cascades to table issue_2391."Item"
+drop cascades to table issue_2391."Nested"
+NOTICE: graph "issue_2391" has been dropped
+ drop_graph
+------------
+
+(1 row)
+
--
-- End of tests
--
diff --git a/regress/sql/expr.sql b/regress/sql/expr.sql
index b951a236..251349ce 100644
--- a/regress/sql/expr.sql
+++ b/regress/sql/expr.sql
@@ -4136,6 +4136,60 @@ SELECT * FROM drop_graph('regex', true);
SELECT * FROM drop_graph('keys', true);
SELECT * FROM drop_graph('list', true);
+--
+-- Issue 2391 - map literals must preserve keys whose values are null
+--
+SELECT create_graph('issue_2391');
+-- single-key null
+SELECT * FROM cypher('issue_2391', $$
+ RETURN {a: null} AS m
+$$) AS (m agtype);
+-- multiple null values
+SELECT * FROM cypher('issue_2391', $$
+ RETURN {companyName: null, sinceYear: null} AS m
+$$) AS (m agtype);
+-- keys() must see the null-valued key
+SELECT * FROM cypher('issue_2391', $$
+ RETURN keys({a: null}) AS ks
+$$) AS (ks agtype);
+-- coalesce passes a non-null map (map itself is not null) through
+SELECT * FROM cypher('issue_2391', $$
+ RETURN coalesce({a: null}, null) AS m
+$$) AS (m agtype);
+-- nested map values inside an expression also preserve nulls
+SELECT * FROM cypher('issue_2391', $$
+ RETURN {outer: {inner: null, kept: 1}} AS m
+$$) AS (m agtype);
+-- mixed non-null and null values are all preserved
+SELECT * FROM cypher('issue_2391', $$
+ RETURN {a: 1, b: null, c: 'x'} AS m
+$$) AS (m agtype);
+-- control: empty map is still empty
+SELECT * FROM cypher('issue_2391', $$
+ RETURN {} AS m
+$$) AS (m agtype);
+-- control: CREATE must still strip top-level null properties so
+-- setting a property to null removes it from storage
+SELECT * FROM cypher('issue_2391', $$
+ CREATE (n:Item {keep: 1, drop: null}) RETURN n
+$$) AS (n agtype);
+SELECT * FROM cypher('issue_2391', $$
+ MATCH (n:Item) RETURN n
+$$) AS (n agtype);
+-- nested map values under a write (CREATE / SET =) are preserved: the
+-- top-level property map is null-stripped, but a nested map literal is
+-- its own node and keeps its null-valued keys
+SELECT * FROM cypher('issue_2391', $$
+ CREATE (n:Nested {a: {b: null}}) RETURN n
+$$) AS (n agtype);
+SELECT * FROM cypher('issue_2391', $$
+ MATCH (n:Nested) SET n = {a: {b: null}} RETURN n
+$$) AS (n agtype);
+SELECT * FROM cypher('issue_2391', $$
+ MATCH (n:Nested) RETURN n
+$$) AS (n agtype);
+SELECT * FROM drop_graph('issue_2391', true);
+
--
-- End of tests
--
diff --git a/src/backend/parser/cypher_gram.y b/src/backend/parser/cypher_gram.y
index 83d69c83..ddd62d7e 100644
--- a/src/backend/parser/cypher_gram.y
+++ b/src/backend/parser/cypher_gram.y
@@ -2127,6 +2127,16 @@ map:
n = make_ag_node(cypher_map);
n->keyvals = $2;
+ /*
+ * By default, a Cypher map literal preserves keys whose
+ * values are null (openCypher / Neo4j semantics: e.g.
+ * RETURN {a: null} yields {a: null}, not {}). CREATE and
+ * SET = override this to false on the top-level property
+ * map in cypher_clause.c so null properties are stripped
+ * on write; a nested map value is its own node and keeps
+ * this default, preserving its null-valued keys.
+ */
+ n->keep_null = true;
$$ = (Node *)n;
}