Skip to content

Commit c7f93e3

Browse files
crprashantCopilot
andcommitted
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. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 84e2954 commit c7f93e3

4 files changed

Lines changed: 160 additions & 8 deletions

File tree

regress/expected/agtype.out

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2167,8 +2167,8 @@ SELECT * FROM cypher('orderability_graph', $$ MATCH (n) RETURN n ORDER BY n.prop
21672167
{"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
21682168
{"id": 844424930131980, "label": "vertex", "properties": {"prop": {"id": 2, "label": "e", "end_id": 1, "start_id": 0, "properties": {"i": 0}}::edge}}::vertex
21692169
{"id": 844424930131979, "label": "vertex", "properties": {"prop": {"id": 0, "label": "v", "properties": {"i": 0}}::vertex}}::vertex
2170-
{"id": 844424930131978, "label": "vertex", "properties": {"prop": {"bool": true}}}::vertex
21712170
{"id": 844424930131977, "label": "vertex", "properties": {"prop": {"i": 0, "bool": true}}}::vertex
2171+
{"id": 844424930131978, "label": "vertex", "properties": {"prop": {"i": null, "bool": true}}}::vertex
21722172
{"id": 844424930131975, "label": "vertex", "properties": {"prop": [1, 2, 3]}}::vertex
21732173
{"id": 844424930131976, "label": "vertex", "properties": {"prop": [1, 2, 3, 4, 5]}}::vertex
21742174
{"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
21902190
{"id": 844424930131973, "label": "vertex", "properties": {"prop": "string"}}::vertex
21912191
{"id": 844424930131976, "label": "vertex", "properties": {"prop": [1, 2, 3, 4, 5]}}::vertex
21922192
{"id": 844424930131975, "label": "vertex", "properties": {"prop": [1, 2, 3]}}::vertex
2193+
{"id": 844424930131978, "label": "vertex", "properties": {"prop": {"i": null, "bool": true}}}::vertex
21932194
{"id": 844424930131977, "label": "vertex", "properties": {"prop": {"i": 0, "bool": true}}}::vertex
2194-
{"id": 844424930131978, "label": "vertex", "properties": {"prop": {"bool": true}}}::vertex
21952195
{"id": 844424930131979, "label": "vertex", "properties": {"prop": {"id": 0, "label": "v", "properties": {"i": 0}}::vertex}}::vertex
21962196
{"id": 844424930131980, "label": "vertex", "properties": {"prop": {"id": 2, "label": "e", "end_id": 1, "start_id": 0, "properties": {"i": 0}}::edge}}::vertex
21972197
{"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

regress/expected/expr.out

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,18 @@ SELECT * FROM cypher('expr', $$RETURN {}$$) AS r(c agtype);
4040
SELECT * FROM cypher('expr', $$
4141
RETURN {s: 's', i: 1, f: 1.0, b: true, z: null}
4242
$$) AS r(c agtype);
43-
c
44-
-----------------------------------------
45-
{"b": true, "f": 1.0, "i": 1, "s": "s"}
43+
c
44+
----------------------------------------------------
45+
{"b": true, "f": 1.0, "i": 1, "s": "s", "z": null}
4646
(1 row)
4747

4848
-- nested maps
4949
SELECT * FROM cypher('expr', $$
5050
RETURN {s: {s: 's'}, t: {i: 1, e: {f: 1.0}, s: {a: {b: true}}}, z: null}
5151
$$) AS r(c agtype);
52-
c
53-
----------------------------------------------------------------------------
54-
{"s": {"s": "s"}, "t": {"e": {"f": 1.0}, "i": 1, "s": {"a": {"b": true}}}}
52+
c
53+
---------------------------------------------------------------------------------------
54+
{"s": {"s": "s"}, "t": {"e": {"f": 1.0}, "i": 1, "s": {"a": {"b": true}}}, "z": null}
5555
(1 row)
5656

5757
--
@@ -9457,3 +9457,105 @@ NOTICE: graph "list" has been dropped
94579457
--
94589458
-- End of tests
94599459
--
9460+
--
9461+
-- Issue 2391 - map literals must preserve keys whose values are null
9462+
--
9463+
SELECT create_graph('issue_2391');
9464+
NOTICE: graph "issue_2391" has been created
9465+
create_graph
9466+
--------------
9467+
9468+
(1 row)
9469+
9470+
-- single-key null
9471+
SELECT * FROM cypher('issue_2391', $$
9472+
RETURN {a: null} AS m
9473+
$$) AS (m agtype);
9474+
m
9475+
-------------
9476+
{"a": null}
9477+
(1 row)
9478+
9479+
-- multiple null values
9480+
SELECT * FROM cypher('issue_2391', $$
9481+
RETURN {companyName: null, sinceYear: null} AS m
9482+
$$) AS (m agtype);
9483+
m
9484+
------------------------------------------
9485+
{"sinceYear": null, "companyName": null}
9486+
(1 row)
9487+
9488+
-- keys() must see the null-valued key
9489+
SELECT * FROM cypher('issue_2391', $$
9490+
RETURN keys({a: null}) AS ks
9491+
$$) AS (ks agtype);
9492+
ks
9493+
-------
9494+
["a"]
9495+
(1 row)
9496+
9497+
-- coalesce passes a non-null map (map itself is not null) through
9498+
SELECT * FROM cypher('issue_2391', $$
9499+
RETURN coalesce({a: null}, null) AS m
9500+
$$) AS (m agtype);
9501+
m
9502+
-------------
9503+
{"a": null}
9504+
(1 row)
9505+
9506+
-- nested map values inside an expression also preserve nulls
9507+
SELECT * FROM cypher('issue_2391', $$
9508+
RETURN {outer: {inner: null, kept: 1}} AS m
9509+
$$) AS (m agtype);
9510+
m
9511+
---------------------------------------
9512+
{"outer": {"kept": 1, "inner": null}}
9513+
(1 row)
9514+
9515+
-- mixed non-null and null values are all preserved in order
9516+
SELECT * FROM cypher('issue_2391', $$
9517+
RETURN {a: 1, b: null, c: 'x'} AS m
9518+
$$) AS (m agtype);
9519+
m
9520+
-------------------------------
9521+
{"a": 1, "b": null, "c": "x"}
9522+
(1 row)
9523+
9524+
-- control: empty map is still empty
9525+
SELECT * FROM cypher('issue_2391', $$
9526+
RETURN {} AS m
9527+
$$) AS (m agtype);
9528+
m
9529+
----
9530+
{}
9531+
(1 row)
9532+
9533+
-- control: CREATE must still strip top-level null properties so
9534+
-- setting a property to null removes it from storage
9535+
SELECT * FROM cypher('issue_2391', $$
9536+
CREATE (n:Item {keep: 1, drop: null}) RETURN n
9537+
$$) AS (n agtype);
9538+
n
9539+
-----------------------------------------------------------------------------
9540+
{"id": 844424930131969, "label": "Item", "properties": {"keep": 1}}::vertex
9541+
(1 row)
9542+
9543+
SELECT * FROM cypher('issue_2391', $$
9544+
MATCH (n:Item) RETURN n
9545+
$$) AS (n agtype);
9546+
n
9547+
-----------------------------------------------------------------------------
9548+
{"id": 844424930131969, "label": "Item", "properties": {"keep": 1}}::vertex
9549+
(1 row)
9550+
9551+
SELECT * FROM drop_graph('issue_2391', true);
9552+
NOTICE: drop cascades to 3 other objects
9553+
DETAIL: drop cascades to table issue_2391._ag_label_vertex
9554+
drop cascades to table issue_2391._ag_label_edge
9555+
drop cascades to table issue_2391."Item"
9556+
NOTICE: graph "issue_2391" has been dropped
9557+
drop_graph
9558+
------------
9559+
9560+
(1 row)
9561+

regress/sql/expr.sql

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3739,3 +3739,45 @@ SELECT * FROM drop_graph('list', true);
37393739
--
37403740
-- End of tests
37413741
--
3742+
3743+
--
3744+
-- Issue 2391 - map literals must preserve keys whose values are null
3745+
--
3746+
SELECT create_graph('issue_2391');
3747+
-- single-key null
3748+
SELECT * FROM cypher('issue_2391', $$
3749+
RETURN {a: null} AS m
3750+
$$) AS (m agtype);
3751+
-- multiple null values
3752+
SELECT * FROM cypher('issue_2391', $$
3753+
RETURN {companyName: null, sinceYear: null} AS m
3754+
$$) AS (m agtype);
3755+
-- keys() must see the null-valued key
3756+
SELECT * FROM cypher('issue_2391', $$
3757+
RETURN keys({a: null}) AS ks
3758+
$$) AS (ks agtype);
3759+
-- coalesce passes a non-null map (map itself is not null) through
3760+
SELECT * FROM cypher('issue_2391', $$
3761+
RETURN coalesce({a: null}, null) AS m
3762+
$$) AS (m agtype);
3763+
-- nested map values inside an expression also preserve nulls
3764+
SELECT * FROM cypher('issue_2391', $$
3765+
RETURN {outer: {inner: null, kept: 1}} AS m
3766+
$$) AS (m agtype);
3767+
-- mixed non-null and null values are all preserved in order
3768+
SELECT * FROM cypher('issue_2391', $$
3769+
RETURN {a: 1, b: null, c: 'x'} AS m
3770+
$$) AS (m agtype);
3771+
-- control: empty map is still empty
3772+
SELECT * FROM cypher('issue_2391', $$
3773+
RETURN {} AS m
3774+
$$) AS (m agtype);
3775+
-- control: CREATE must still strip top-level null properties so
3776+
-- setting a property to null removes it from storage
3777+
SELECT * FROM cypher('issue_2391', $$
3778+
CREATE (n:Item {keep: 1, drop: null}) RETURN n
3779+
$$) AS (n agtype);
3780+
SELECT * FROM cypher('issue_2391', $$
3781+
MATCH (n:Item) RETURN n
3782+
$$) AS (n agtype);
3783+
SELECT * FROM drop_graph('issue_2391', true);

src/backend/parser/cypher_gram.y

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2081,6 +2081,14 @@ map:
20812081

20822082
n = make_ag_node(cypher_map);
20832083
n->keyvals = $2;
2084+
/*
2085+
* By default, a Cypher map literal preserves keys whose
2086+
* values are null (openCypher / Neo4j semantics: e.g.
2087+
* RETURN {a: null} yields {a: null}, not {}). Callers
2088+
* that need property-stripping semantics (CREATE, SET =)
2089+
* override this to false in cypher_clause.c.
2090+
*/
2091+
n->keep_null = true;
20842092

20852093
$$ = (Node *)n;
20862094
}

0 commit comments

Comments
 (0)