From b4cb92d091e03375ddce31f93e41901b6a28d94e Mon Sep 17 00:00:00 2001 From: Muhammad Taha Naveed Date: Wed, 22 Apr 2026 15:15:35 +0500 Subject: [PATCH] Propagate null through agtype arithmetic operators The scalar branch of agtype_add lacked an AGTV_NULL case and fell through to the scalar-scalar concat path, so `null + 1` produced the two-element list `[null, 1]` instead of null. This surfaced most visibly inside a list comprehension projection (e.g. `[x IN [1, null, 2] | x + 1]` yielded `[2, [null, 1], 3]`) because there both operands are typed agtype and bypass the agtype_any_add wrapper that already short-circuits on null. The sibling operators (-, *, /, %, ^, unary -) had the same gap but surfaced as "Invalid input parameter types" errors rather than concats. Short-circuit all seven functions to SQL NULL when any operand is AGTV_NULL. SQL NULL (not a wrapped AGTV_NULL scalar) matches AGE's existing convention: `RETURN null AS v` already yields SQL NULL at the row level, and agtype_any_add already returns PG NULL for AGTV_NULL input, so the two operator-resolution paths stay indistinguishable. List aggregation re-packs SQL NULL entries as AGTV_NULL, so `[x IN [1, null, 2] | x + 1]` now renders as `[2, null, 3]`. Co-Authored-By: Claude Opus 4.7 --- regress/expected/agtype.out | 44 ++++++++++++++++- regress/expected/list_comprehension.out | 63 +++++++++++++++++++++++++ regress/sql/agtype.sql | 8 +++- regress/sql/list_comprehension.sql | 14 ++++++ src/backend/utils/adt/agtype_ops.c | 42 +++++++++++++++++ 5 files changed, 168 insertions(+), 3 deletions(-) diff --git a/regress/expected/agtype.out b/regress/expected/agtype.out index 065f357f1..cb97b3bfa 100644 --- a/regress/expected/agtype.out +++ b/regress/expected/agtype.out @@ -704,6 +704,48 @@ SELECT '3.14::numeric'::agtype + '3.14::numeric'::agtype; 6.28::numeric (1 row) +SELECT 'null'::agtype - '1'; + ?column? +---------- + +(1 row) + +SELECT 'null'::agtype + '1'; + ?column? +---------- + +(1 row) + +SELECT 'null'::agtype * '1'; + ?column? +---------- + +(1 row) + +SELECT 'null'::agtype / '1'; + ?column? +---------- + +(1 row) + +SELECT 'null'::agtype % '1'; + ?column? +---------- + +(1 row) + +SELECT 'null'::agtype ^ '1'; + ?column? +---------- + +(1 row) + +SELECT -'null'::agtype; + ?column? +---------- + +(1 row) + -- -- Test operator - for extended functionality -- @@ -1065,8 +1107,6 @@ SELECT '{"a":1 , "b":2, "c":3}'::agtype - 'null'; ERROR: expected agtype string, not agtype NULL SELECT '{"a":1 , "b":2, "c":3}'::agtype - '["c","b"]' - '[1]' - '["a"]'; ERROR: expected agtype string, not agtype integer -SELECT 'null'::agtype - '1'; -ERROR: Invalid input parameter types for agtype_sub SELECT 'null'::agtype - '[1]'; ERROR: must be object or array, not a scalar value SELECT '{"id": 1125899906842625, "label": "Vertex", "properties": {"a": "xyz", "b": true, "c": -19.888, "e": {"f": "abcdef", "g": {}, "h": [[], {}]}, "i": {"j": 199, "k": {"l": "mnopq"}}}}::vertex'::agtype - '"a"'; diff --git a/regress/expected/list_comprehension.out b/regress/expected/list_comprehension.out index e12ad621d..2aab21eb0 100644 --- a/regress/expected/list_comprehension.out +++ b/regress/expected/list_comprehension.out @@ -721,6 +721,69 @@ SELECT * FROM cypher('list_comprehension', $$ MATCH (u {list: [0, 2, 4, 6, 8, 10 {"id": 281474976710668, "label": "", "properties": {"b": [0, 1, 2, 3, 4, 5], "c": [0, 2, 4, 6, 8, 10, 12], "list": [0, 2, 4, 6, 8, 10, 12]}}::vertex (2 rows) +-- Issue 2394 - projection over a null element should propagate null, +-- not concatenate the null and the projection result into a sublist and +-- not error out for operators other than '+'. +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null] | x + 1] $$) AS (result agtype); + result +-------- + [null] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x + 1] $$) AS (result agtype); + result +-------------- + [2, null, 3] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | 1 + x] $$) AS (result agtype); + result +-------------- + [2, null, 3] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x] $$) AS (result agtype); + result +-------------- + [1, null, 2] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x - 1] $$) AS (result agtype); + result +-------------- + [0, null, 1] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x * 2] $$) AS (result agtype); + result +-------------- + [2, null, 4] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x / 1] $$) AS (result agtype); + result +-------------- + [1, null, 2] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x % 2] $$) AS (result agtype); + result +-------------- + [1, null, 0] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x ^ 2] $$) AS (result agtype); + result +------------------ + [1.0, null, 4.0] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | -x] $$) AS (result agtype); + result +---------------- + [-1, null, -2] +(1 row) + -- Clean up SELECT * FROM drop_graph('list_comprehension', true); NOTICE: drop cascades to 4 other objects diff --git a/regress/sql/agtype.sql b/regress/sql/agtype.sql index 6dab6bc30..505097761 100644 --- a/regress/sql/agtype.sql +++ b/regress/sql/agtype.sql @@ -229,6 +229,13 @@ SELECT '3'::agtype + '3.14'::agtype; SELECT '3'::agtype + '3.14::numeric'::agtype; SELECT '3.14'::agtype + '3.14::numeric'::agtype; SELECT '3.14::numeric'::agtype + '3.14::numeric'::agtype; +SELECT 'null'::agtype - '1'; +SELECT 'null'::agtype + '1'; +SELECT 'null'::agtype * '1'; +SELECT 'null'::agtype / '1'; +SELECT 'null'::agtype % '1'; +SELECT 'null'::agtype ^ '1'; +SELECT -'null'::agtype; -- -- Test operator - for extended functionality @@ -305,7 +312,6 @@ SELECT '{"a":1 , "b":2, "c":3}'::agtype - '[null]'; SELECT '{"a":1 , "b":2, "c":3}'::agtype - '1'; SELECT '{"a":1 , "b":2, "c":3}'::agtype - 'null'; SELECT '{"a":1 , "b":2, "c":3}'::agtype - '["c","b"]' - '[1]' - '["a"]'; -SELECT 'null'::agtype - '1'; SELECT 'null'::agtype - '[1]'; SELECT '{"id": 1125899906842625, "label": "Vertex", "properties": {"a": "xyz", "b": true, "c": -19.888, "e": {"f": "abcdef", "g": {}, "h": [[], {}]}, "i": {"j": 199, "k": {"l": "mnopq"}}}}::vertex'::agtype - '"a"'; SELECT '{"id": 1125899906842625, "label": "Vertex", "properties": {"a": "xyz", "b": true, "c": -19.888, "e": {"f": "abcdef", "g": {}, "h": [[], {}]}, "i": {"j": 199, "k": {"l": "mnopq"}}}}::vertex'::agtype - '["a"]'; diff --git a/regress/sql/list_comprehension.sql b/regress/sql/list_comprehension.sql index 572b2e6bb..946042349 100644 --- a/regress/sql/list_comprehension.sql +++ b/regress/sql/list_comprehension.sql @@ -174,5 +174,19 @@ SELECT * FROM cypher('list_comprehension', $$ MATCH (u {list: [0, 2, 4, 6, 8, 10 SELECT * FROM cypher('list_comprehension', $$ MATCH (u {list: [0, 2, 4, 6, 8, 10, 12]}) WHERE u.list = [u IN [1, u]] RETURN u $$) AS (u agtype); SELECT * FROM cypher('list_comprehension', $$ MATCH (u {list: [0, 2, 4, 6, 8, 10, 12]}) WHERE u.list IN [u IN [1, u.list]] RETURN u $$) AS (u agtype); +-- Issue 2394 - projection over a null element should propagate null, +-- not concatenate the null and the projection result into a sublist and +-- not error out for operators other than '+'. +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null] | x + 1] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x + 1] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | 1 + x] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x - 1] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x * 2] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x / 1] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x % 2] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x ^ 2] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | -x] $$) AS (result agtype); + -- Clean up SELECT * FROM drop_graph('list_comprehension', true); \ No newline at end of file diff --git a/src/backend/utils/adt/agtype_ops.c b/src/backend/utils/adt/agtype_ops.c index d831447b0..f7f45b467 100644 --- a/src/backend/utils/adt/agtype_ops.c +++ b/src/backend/utils/adt/agtype_ops.c @@ -165,6 +165,12 @@ Datum agtype_add(PG_FUNCTION_ARGS) agtv_lhs = get_ith_agtype_value_from_container(&lhs->root, 0); agtv_rhs = get_ith_agtype_value_from_container(&rhs->root, 0); + /* openCypher: arithmetic over null yields null. */ + if (agtv_lhs->type == AGTV_NULL || agtv_rhs->type == AGTV_NULL) + { + PG_RETURN_NULL(); + } + /* * One or both values is a string OR one is a string and the other is * either an integer, float, or numeric. If so, concatenate them. @@ -525,6 +531,12 @@ Datum agtype_sub(PG_FUNCTION_ARGS) agtv_lhs = get_ith_agtype_value_from_container(&lhs->root, 0); agtv_rhs = get_ith_agtype_value_from_container(&rhs->root, 0); + /* openCypher: arithmetic over null yields null. */ + if (agtv_lhs->type == AGTV_NULL || agtv_rhs->type == AGTV_NULL) + { + PG_RETURN_NULL(); + } + if (agtv_lhs->type == AGTV_INTEGER && agtv_rhs->type == AGTV_INTEGER) { agtv_result.type = AGTV_INTEGER; @@ -615,6 +627,12 @@ Datum agtype_neg(PG_FUNCTION_ARGS) agtv_value = get_ith_agtype_value_from_container(&v->root, 0); + /* openCypher: arithmetic over null yields null. */ + if (agtv_value->type == AGTV_NULL) + { + PG_RETURN_NULL(); + } + if (agtv_value->type == AGTV_INTEGER) { agtv_result.type = AGTV_INTEGER; @@ -666,6 +684,12 @@ Datum agtype_mul(PG_FUNCTION_ARGS) agtv_lhs = get_ith_agtype_value_from_container(&lhs->root, 0); agtv_rhs = get_ith_agtype_value_from_container(&rhs->root, 0); + /* openCypher: arithmetic over null yields null. */ + if (agtv_lhs->type == AGTV_NULL || agtv_rhs->type == AGTV_NULL) + { + PG_RETURN_NULL(); + } + if (agtv_lhs->type == AGTV_INTEGER && agtv_rhs->type == AGTV_INTEGER) { agtv_result.type = AGTV_INTEGER; @@ -756,6 +780,12 @@ Datum agtype_div(PG_FUNCTION_ARGS) agtv_lhs = get_ith_agtype_value_from_container(&lhs->root, 0); agtv_rhs = get_ith_agtype_value_from_container(&rhs->root, 0); + /* openCypher: arithmetic over null yields null. */ + if (agtv_lhs->type == AGTV_NULL || agtv_rhs->type == AGTV_NULL) + { + PG_RETURN_NULL(); + } + if (agtv_lhs->type == AGTV_INTEGER && agtv_rhs->type == AGTV_INTEGER) { if (agtv_rhs->val.int_value == 0) @@ -874,6 +904,12 @@ Datum agtype_mod(PG_FUNCTION_ARGS) agtv_lhs = get_ith_agtype_value_from_container(&lhs->root, 0); agtv_rhs = get_ith_agtype_value_from_container(&rhs->root, 0); + /* openCypher: arithmetic over null yields null. */ + if (agtv_lhs->type == AGTV_NULL || agtv_rhs->type == AGTV_NULL) + { + PG_RETURN_NULL(); + } + if (agtv_lhs->type == AGTV_INTEGER && agtv_rhs->type == AGTV_INTEGER) { agtv_result.type = AGTV_INTEGER; @@ -964,6 +1000,12 @@ Datum agtype_pow(PG_FUNCTION_ARGS) agtv_lhs = get_ith_agtype_value_from_container(&lhs->root, 0); agtv_rhs = get_ith_agtype_value_from_container(&rhs->root, 0); + /* openCypher: arithmetic over null yields null. */ + if (agtv_lhs->type == AGTV_NULL || agtv_rhs->type == AGTV_NULL) + { + PG_RETURN_NULL(); + } + if (agtv_lhs->type == AGTV_INTEGER && agtv_rhs->type == AGTV_INTEGER) { agtv_result.type = AGTV_FLOAT;