This is an automated email from the ASF dual-hosted git repository. jrgemignani pushed a commit to branch PG19 in repository https://gitbox.apache.org/repos/asf/age.git
commit c94cf8f2ed62dad27a50c95a4c91608693f6a285 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; }
