Skip to content

Commit 9142d61

Browse files
Propagate null through unnest and single() three-valued logic
Two related defects in how AGE handled cypher null inside list-iterating constructs. age_unnest packaged every iterated element as a non-SQL-NULL agtype datum, even AGTV_NULL scalars. SQL `IS NULL` / `IS NOT NULL` then couldn't see those nulls, so `[x IN [null, 1] WHERE x IS NULL]` dropped the null it was meant to keep, and `WHERE x IS NOT NULL` kept the null it was meant to drop. The same mismatch surfaced in UNWIND. AGE already treats SQL NULL as the row-level representation of cypher null elsewhere (`RETURN null AS v` yields SQL NULL, strict operators short-circuit on it); age_unnest now does the same by emitting the row with `nulls[0] = true` when the element is AGTV_NULL. single() previously transformed to `SELECT count(*) FROM unnest(list) AS x WHERE pred IS TRUE`, with the grammar wrapping the result as `(subquery) = 1`. With the unnest fix, `[null, 5] WHERE x > 0` left one definite true after the WHERE filter -> count = 1 -> true. Neo4j returns null because the unknown predicate could itself be a second match. Rewritten to a CASE built on `count(*) FILTER (WHERE pred IS TRUE)` and `bool_or(pred IS NULL)`: CASE WHEN count(*) FILTER (WHERE pred IS TRUE) >= 2 THEN false WHEN bool_or(pred IS NULL) THEN NULL WHEN count(*) FILTER (WHERE pred IS TRUE) = 1 THEN true ELSE false END The >=2 arm runs first so two definite trues dominate any unknowns. Fits inside the existing make_predicate_case_expr helper alongside all/any/none, removes the special-case transform branch and the grammar `= 1` wrap. A small `make_count_star_filter_agg` helper mirrors the existing `make_bool_or_agg`. Verified against Neo4j for the new edge cases (one-true-plus-null, two-trues-plus-null, all-nulls, mixed-true-false-null). The predicate_functions regression also picks up the corrected behavior of any/all/none over null elements: `null > 0` now yields SQL NULL instead of being silently treated as true, so the three-valued combinators in those functions produce the openCypher results the comments previously documented as buggy. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 84e2954 commit 9142d61

7 files changed

Lines changed: 351 additions & 133 deletions

File tree

regress/expected/list_comprehension.out

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,39 @@ SELECT * FROM cypher('list_comprehension', $$ MATCH (u {list: [0, 2, 4, 6, 8, 10
721721
{"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
722722
(2 rows)
723723

724+
-- Issue 2393 - WHERE filter over null elements should use openCypher's
725+
-- three-valued logic: IS NULL must keep nulls, IS NOT NULL must drop them.
726+
SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null, 1] WHERE x IS NULL] $$) AS (result agtype);
727+
result
728+
--------
729+
[null]
730+
(1 row)
731+
732+
SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null, 1, null] WHERE x IS NULL] $$) AS (result agtype);
733+
result
734+
--------------
735+
[null, null]
736+
(1 row)
737+
738+
SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null, 1] WHERE x IS NOT NULL] $$) AS (result agtype);
739+
result
740+
--------
741+
[1]
742+
(1 row)
743+
744+
SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, 2, 3] WHERE x IS NULL] $$) AS (result agtype);
745+
result
746+
--------
747+
[]
748+
(1 row)
749+
750+
SELECT * FROM cypher('list_comprehension', $$ UNWIND [null, 1] AS x RETURN x, x IS NULL, x IS NOT NULL $$) AS (x agtype, a agtype, b agtype);
751+
x | a | b
752+
---+-------+-------
753+
| true | false
754+
1 | false | true
755+
(2 rows)
756+
724757
-- Clean up
725758
SELECT * FROM drop_graph('list_comprehension', true);
726759
NOTICE: drop cascades to 4 other objects

regress/expected/predicate_functions.out

Lines changed: 100 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -194,20 +194,20 @@ $$) AS (result agtype);
194194
--
195195
-- NULL predicate results: three-valued logic
196196
--
197-
-- Note: In AGE's agtype, null is a first-class value. The comparison
198-
-- agtype_null > agtype_integer evaluates to true (not SQL NULL).
199-
-- Three-valued logic only applies when the predicate itself is a
200-
-- literal null constant, which becomes SQL NULL after coercion.
201-
-- agtype null in list: null > 0 = true in AGE, so any() = true
197+
-- Null list elements arrive at the predicate as SQL NULL, so the usual
198+
-- strict-operator short-circuit applies: `null > 0` yields NULL, and the
199+
-- predicate functions combine NULLs with the openCypher three-valued
200+
-- logic (true trumps null in any(), false trumps null in all(), etc.).
201+
-- [null]: only null predicate, no true -> any() = NULL
202202
SELECT * FROM cypher('predicate_functions', $$
203203
RETURN any(x IN [null] WHERE x > 0)
204204
$$) AS (result agtype);
205205
result
206206
--------
207-
true
207+
208208
(1 row)
209209

210-
-- agtype null + real values: all comparisons are true
210+
-- one true (1 > 0) is enough: any() = true
211211
SELECT * FROM cypher('predicate_functions', $$
212212
RETURN any(x IN [null, 1, 2] WHERE x > 0)
213213
$$) AS (result agtype);
@@ -226,16 +226,16 @@ $$) AS (result agtype);
226226

227227
(1 row)
228228

229-
-- agtype null in list: null > 0 = true in AGE, so all() = true
229+
-- no false, but one null -> all() = NULL
230230
SELECT * FROM cypher('predicate_functions', $$
231231
RETURN all(x IN [1, null, 2] WHERE x > 0)
232232
$$) AS (result agtype);
233233
result
234234
--------
235-
true
235+
236236
(1 row)
237237

238-
-- -1 > 0 = false, so all() = false
238+
-- -1 > 0 = false dominates the null -> all() = false
239239
SELECT * FROM cypher('predicate_functions', $$
240240
RETURN all(x IN [1, null, -1] WHERE x > 0)
241241
$$) AS (result agtype);
@@ -244,16 +244,16 @@ $$) AS (result agtype);
244244
false
245245
(1 row)
246246

247-
-- agtype null > 0 = true in AGE, so none() = false
247+
-- [null]: only null predicate, no true -> none() = NULL
248248
SELECT * FROM cypher('predicate_functions', $$
249249
RETURN none(x IN [null] WHERE x > 0)
250250
$$) AS (result agtype);
251251
result
252252
--------
253-
false
253+
254254
(1 row)
255255

256-
-- 5 > 0 = true, so none() = false
256+
-- one true (5 > 0) dominates: none() = false
257257
SELECT * FROM cypher('predicate_functions', $$
258258
RETURN none(x IN [null, 5] WHERE x > 0)
259259
$$) AS (result agtype);
@@ -262,24 +262,108 @@ $$) AS (result agtype);
262262
false
263263
(1 row)
264264

265-
-- agtype null > 0 = true AND 5 > 0 = true: 2 matches, single = false
265+
-- one definite true (5 > 0) and one null predicate: the null could also
266+
-- be a match, so we cannot conclude exactly-one -> single() = NULL
266267
SELECT * FROM cypher('predicate_functions', $$
267268
RETURN single(x IN [null, 5] WHERE x > 0)
268269
$$) AS (result agtype);
269270
result
270271
--------
272+
273+
(1 row)
274+
275+
-- two definite trues dominate any null -> single() = false
276+
SELECT * FROM cypher('predicate_functions', $$
277+
RETURN single(x IN [null, 5, 6] WHERE x > 0)
278+
$$) AS (result agtype);
279+
result
280+
--------
271281
false
272282
(1 row)
273283

274-
-- single() with null list: NULL (same as other predicate functions)
284+
-- only null predicates -> single() = NULL
275285
SELECT * FROM cypher('predicate_functions', $$
276-
RETURN single(x IN null WHERE x > 0)
286+
RETURN single(x IN [null, null] WHERE x > 0)
287+
$$) AS (result agtype);
288+
result
289+
--------
290+
291+
(1 row)
292+
293+
-- one true + one false + one null: the null could be the second true
294+
-- so we cannot conclude exactly-one -> single() = NULL
295+
SELECT * FROM cypher('predicate_functions', $$
296+
RETURN single(x IN [1, null, -1] WHERE x > 0)
297+
$$) AS (result agtype);
298+
result
299+
--------
300+
301+
(1 row)
302+
303+
--
304+
-- Additional null/three-valued coverage for any()/all()/none()
305+
--
306+
-- any() with no true and one null -> NULL
307+
SELECT * FROM cypher('predicate_functions', $$
308+
RETURN any(x IN [null, -1] WHERE x > 0)
309+
$$) AS (result agtype);
310+
result
311+
--------
312+
313+
(1 row)
314+
315+
-- all() with one true and one null -> NULL (the null might be false)
316+
SELECT * FROM cypher('predicate_functions', $$
317+
RETURN all(x IN [1, null] WHERE x > 0)
318+
$$) AS (result agtype);
319+
result
320+
--------
321+
322+
(1 row)
323+
324+
-- all() with one definite false dominates any null -> false
325+
SELECT * FROM cypher('predicate_functions', $$
326+
RETURN all(x IN [null, -1] WHERE x > 0)
327+
$$) AS (result agtype);
328+
result
329+
--------
330+
false
331+
(1 row)
332+
333+
-- none() with no true and one null -> NULL
334+
SELECT * FROM cypher('predicate_functions', $$
335+
RETURN none(x IN [null, -1] WHERE x > 0)
277336
$$) AS (result agtype);
278337
result
279338
--------
280339

281340
(1 row)
282341

342+
-- IS NULL predicate now sees unwound null elements (issue #2393)
343+
SELECT * FROM cypher('predicate_functions', $$
344+
RETURN any(x IN [1, null] WHERE x IS NULL)
345+
$$) AS (result agtype);
346+
result
347+
--------
348+
true
349+
(1 row)
350+
351+
SELECT * FROM cypher('predicate_functions', $$
352+
RETURN none(x IN [1, null] WHERE x IS NULL)
353+
$$) AS (result agtype);
354+
result
355+
--------
356+
false
357+
(1 row)
358+
359+
SELECT * FROM cypher('predicate_functions', $$
360+
RETURN all(x IN [null, null] WHERE x IS NULL)
361+
$$) AS (result agtype);
362+
result
363+
--------
364+
true
365+
(1 row)
366+
283367
--
284368
-- Integration with graph data
285369
--

regress/sql/list_comprehension.sql

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,5 +174,13 @@ SELECT * FROM cypher('list_comprehension', $$ MATCH (u {list: [0, 2, 4, 6, 8, 10
174174
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);
175175
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);
176176

177+
-- Issue 2393 - WHERE filter over null elements should use openCypher's
178+
-- three-valued logic: IS NULL must keep nulls, IS NOT NULL must drop them.
179+
SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null, 1] WHERE x IS NULL] $$) AS (result agtype);
180+
SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null, 1, null] WHERE x IS NULL] $$) AS (result agtype);
181+
SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null, 1] WHERE x IS NOT NULL] $$) AS (result agtype);
182+
SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, 2, 3] WHERE x IS NULL] $$) AS (result agtype);
183+
SELECT * FROM cypher('list_comprehension', $$ UNWIND [null, 1] AS x RETURN x, x IS NULL, x IS NOT NULL $$) AS (x agtype, a agtype, b agtype);
184+
177185
-- Clean up
178186
SELECT * FROM drop_graph('list_comprehension', true);

regress/sql/predicate_functions.sql

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -123,17 +123,17 @@ $$) AS (result agtype);
123123
--
124124
-- NULL predicate results: three-valued logic
125125
--
126-
-- Note: In AGE's agtype, null is a first-class value. The comparison
127-
-- agtype_null > agtype_integer evaluates to true (not SQL NULL).
128-
-- Three-valued logic only applies when the predicate itself is a
129-
-- literal null constant, which becomes SQL NULL after coercion.
126+
-- Null list elements arrive at the predicate as SQL NULL, so the usual
127+
-- strict-operator short-circuit applies: `null > 0` yields NULL, and the
128+
-- predicate functions combine NULLs with the openCypher three-valued
129+
-- logic (true trumps null in any(), false trumps null in all(), etc.).
130130

131-
-- agtype null in list: null > 0 = true in AGE, so any() = true
131+
-- [null]: only null predicate, no true -> any() = NULL
132132
SELECT * FROM cypher('predicate_functions', $$
133133
RETURN any(x IN [null] WHERE x > 0)
134134
$$) AS (result agtype);
135135

136-
-- agtype null + real values: all comparisons are true
136+
-- one true (1 > 0) is enough: any() = true
137137
SELECT * FROM cypher('predicate_functions', $$
138138
RETURN any(x IN [null, 1, 2] WHERE x > 0)
139139
$$) AS (result agtype);
@@ -144,34 +144,83 @@ SELECT * FROM cypher('predicate_functions', $$
144144
RETURN all(x IN [1] WHERE null)
145145
$$) AS (result agtype);
146146

147-
-- agtype null in list: null > 0 = true in AGE, so all() = true
147+
-- no false, but one null -> all() = NULL
148148
SELECT * FROM cypher('predicate_functions', $$
149149
RETURN all(x IN [1, null, 2] WHERE x > 0)
150150
$$) AS (result agtype);
151151

152-
-- -1 > 0 = false, so all() = false
152+
-- -1 > 0 = false dominates the null -> all() = false
153153
SELECT * FROM cypher('predicate_functions', $$
154154
RETURN all(x IN [1, null, -1] WHERE x > 0)
155155
$$) AS (result agtype);
156156

157-
-- agtype null > 0 = true in AGE, so none() = false
157+
-- [null]: only null predicate, no true -> none() = NULL
158158
SELECT * FROM cypher('predicate_functions', $$
159159
RETURN none(x IN [null] WHERE x > 0)
160160
$$) AS (result agtype);
161161

162-
-- 5 > 0 = true, so none() = false
162+
-- one true (5 > 0) dominates: none() = false
163163
SELECT * FROM cypher('predicate_functions', $$
164164
RETURN none(x IN [null, 5] WHERE x > 0)
165165
$$) AS (result agtype);
166166

167-
-- agtype null > 0 = true AND 5 > 0 = true: 2 matches, single = false
167+
-- one definite true (5 > 0) and one null predicate: the null could also
168+
-- be a match, so we cannot conclude exactly-one -> single() = NULL
168169
SELECT * FROM cypher('predicate_functions', $$
169170
RETURN single(x IN [null, 5] WHERE x > 0)
170171
$$) AS (result agtype);
171172

172-
-- single() with null list: NULL (same as other predicate functions)
173+
-- two definite trues dominate any null -> single() = false
173174
SELECT * FROM cypher('predicate_functions', $$
174-
RETURN single(x IN null WHERE x > 0)
175+
RETURN single(x IN [null, 5, 6] WHERE x > 0)
176+
$$) AS (result agtype);
177+
178+
-- only null predicates -> single() = NULL
179+
SELECT * FROM cypher('predicate_functions', $$
180+
RETURN single(x IN [null, null] WHERE x > 0)
181+
$$) AS (result agtype);
182+
183+
-- one true + one false + one null: the null could be the second true
184+
-- so we cannot conclude exactly-one -> single() = NULL
185+
SELECT * FROM cypher('predicate_functions', $$
186+
RETURN single(x IN [1, null, -1] WHERE x > 0)
187+
$$) AS (result agtype);
188+
189+
--
190+
-- Additional null/three-valued coverage for any()/all()/none()
191+
--
192+
193+
-- any() with no true and one null -> NULL
194+
SELECT * FROM cypher('predicate_functions', $$
195+
RETURN any(x IN [null, -1] WHERE x > 0)
196+
$$) AS (result agtype);
197+
198+
-- all() with one true and one null -> NULL (the null might be false)
199+
SELECT * FROM cypher('predicate_functions', $$
200+
RETURN all(x IN [1, null] WHERE x > 0)
201+
$$) AS (result agtype);
202+
203+
-- all() with one definite false dominates any null -> false
204+
SELECT * FROM cypher('predicate_functions', $$
205+
RETURN all(x IN [null, -1] WHERE x > 0)
206+
$$) AS (result agtype);
207+
208+
-- none() with no true and one null -> NULL
209+
SELECT * FROM cypher('predicate_functions', $$
210+
RETURN none(x IN [null, -1] WHERE x > 0)
211+
$$) AS (result agtype);
212+
213+
-- IS NULL predicate now sees unwound null elements (issue #2393)
214+
SELECT * FROM cypher('predicate_functions', $$
215+
RETURN any(x IN [1, null] WHERE x IS NULL)
216+
$$) AS (result agtype);
217+
218+
SELECT * FROM cypher('predicate_functions', $$
219+
RETURN none(x IN [1, null] WHERE x IS NULL)
220+
$$) AS (result agtype);
221+
222+
SELECT * FROM cypher('predicate_functions', $$
223+
RETURN all(x IN [null, null] WHERE x IS NULL)
175224
$$) AS (result agtype);
176225

177226
--

0 commit comments

Comments
 (0)