From df92b80b08a235f988f1b84f5e2facdea02b725a Mon Sep 17 00:00:00 2001 From: crprashant <5108573+crprashant@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:59:23 +0000 Subject: [PATCH] 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. --- regress/expected/agtype.out | 4 +- regress/expected/expr.out | 114 +++++++++++++++++++++++++++++-- regress/sql/expr.sql | 42 ++++++++++++ src/backend/parser/cypher_gram.y | 8 +++ 4 files changed, 160 insertions(+), 8 deletions(-) diff --git a/regress/expected/agtype.out b/regress/expected/agtype.out index 065f357f1..49a8f419a 100644 --- a/regress/expected/agtype.out +++ b/regress/expected/agtype.out @@ -2167,8 +2167,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 @@ -2190,8 +2190,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 4a332a335..7e03262e3 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) -- @@ -9457,3 +9457,105 @@ NOTICE: graph "list" has been dropped -- -- End of tests -- +-- +-- 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 in order +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) + +SELECT * FROM drop_graph('issue_2391', true); +NOTICE: drop cascades to 3 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" +NOTICE: graph "issue_2391" has been dropped + drop_graph +------------ + +(1 row) + diff --git a/regress/sql/expr.sql b/regress/sql/expr.sql index 7304d4d65..001f383c6 100644 --- a/regress/sql/expr.sql +++ b/regress/sql/expr.sql @@ -3739,3 +3739,45 @@ SELECT * FROM drop_graph('list', true); -- -- End of tests -- + +-- +-- 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 in order +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); +SELECT * FROM drop_graph('issue_2391', true); diff --git a/src/backend/parser/cypher_gram.y b/src/backend/parser/cypher_gram.y index c857724dd..4561e42a3 100644 --- a/src/backend/parser/cypher_gram.y +++ b/src/backend/parser/cypher_gram.y @@ -2081,6 +2081,14 @@ 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 {}). Callers + * that need property-stripping semantics (CREATE, SET =) + * override this to false in cypher_clause.c. + */ + n->keep_null = true; $$ = (Node *)n; }