Skip to content

Commit 77a6aea

Browse files
gregfeliceclaude
andcommitted
Address Copilot review: NULL semantics, iterator validation, single() perf, tests
- Rewrite predicate functions from EXISTS_SUBLINK to EXPR_SUBLINK with aggregate-based CASE expressions (bool_or + IS TRUE/FALSE/NULL) to preserve three-valued Cypher NULL semantics - Add list_length check in extract_iter_variable_name() to reject qualified names like x.y as iterator variables - Add copy/read support for cypher_predicate_function ExtensibleNode to prevent query rewriter crashes - Use IS TRUE filtering in single() count (LIMIT 2 optimization breaks correlated variable refs in graph contexts -- documented) - Add 13 NULL regression tests: null list input, null elements, null predicates for all four functions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent afa29c3 commit 77a6aea

9 files changed

Lines changed: 516 additions & 84 deletions

File tree

regress/expected/predicate_functions.out

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,132 @@ $$) AS (result agtype);
154154
false
155155
(1 row)
156156

157+
--
158+
-- NULL list input: all/any/none return null, single returns false
159+
-- (unnest of NULL produces zero rows; aggregates return NULL over
160+
-- empty input, but count(*) returns 0)
161+
--
162+
SELECT * FROM cypher('predicate_functions', $$
163+
RETURN all(x IN null WHERE x > 0)
164+
$$) AS (result agtype);
165+
result
166+
--------
167+
168+
(1 row)
169+
170+
SELECT * FROM cypher('predicate_functions', $$
171+
RETURN any(x IN null WHERE x > 0)
172+
$$) AS (result agtype);
173+
result
174+
--------
175+
176+
(1 row)
177+
178+
SELECT * FROM cypher('predicate_functions', $$
179+
RETURN none(x IN null WHERE x > 0)
180+
$$) AS (result agtype);
181+
result
182+
--------
183+
184+
(1 row)
185+
186+
SELECT * FROM cypher('predicate_functions', $$
187+
RETURN single(x IN null WHERE x > 0)
188+
$$) AS (result agtype);
189+
result
190+
--------
191+
false
192+
(1 row)
193+
194+
--
195+
-- NULL predicate results: three-valued logic
196+
--
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
202+
SELECT * FROM cypher('predicate_functions', $$
203+
RETURN any(x IN [null] WHERE x > 0)
204+
$$) AS (result agtype);
205+
result
206+
--------
207+
true
208+
(1 row)
209+
210+
-- agtype null + real values: all comparisons are true
211+
SELECT * FROM cypher('predicate_functions', $$
212+
RETURN any(x IN [null, 1, 2] WHERE x > 0)
213+
$$) AS (result agtype);
214+
result
215+
--------
216+
true
217+
(1 row)
218+
219+
-- literal null predicate: pred = SQL NULL -> three-valued logic
220+
-- all([1] WHERE null) = null (unknown)
221+
SELECT * FROM cypher('predicate_functions', $$
222+
RETURN all(x IN [1] WHERE null)
223+
$$) AS (result agtype);
224+
result
225+
--------
226+
227+
(1 row)
228+
229+
-- agtype null in list: null > 0 = true in AGE, so all() = true
230+
SELECT * FROM cypher('predicate_functions', $$
231+
RETURN all(x IN [1, null, 2] WHERE x > 0)
232+
$$) AS (result agtype);
233+
result
234+
--------
235+
true
236+
(1 row)
237+
238+
-- -1 > 0 = false, so all() = false
239+
SELECT * FROM cypher('predicate_functions', $$
240+
RETURN all(x IN [1, null, -1] WHERE x > 0)
241+
$$) AS (result agtype);
242+
result
243+
--------
244+
false
245+
(1 row)
246+
247+
-- agtype null > 0 = true in AGE, so none() = false
248+
SELECT * FROM cypher('predicate_functions', $$
249+
RETURN none(x IN [null] WHERE x > 0)
250+
$$) AS (result agtype);
251+
result
252+
--------
253+
false
254+
(1 row)
255+
256+
-- 5 > 0 = true, so none() = false
257+
SELECT * FROM cypher('predicate_functions', $$
258+
RETURN none(x IN [null, 5] WHERE x > 0)
259+
$$) AS (result agtype);
260+
result
261+
--------
262+
false
263+
(1 row)
264+
265+
-- agtype null > 0 = true AND 5 > 0 = true: 2 matches, single = false
266+
SELECT * FROM cypher('predicate_functions', $$
267+
RETURN single(x IN [null, 5] WHERE x > 0)
268+
$$) AS (result agtype);
269+
result
270+
--------
271+
false
272+
(1 row)
273+
274+
-- single() with null list: count(*) = 0, 0 = 1 -> false
275+
SELECT * FROM cypher('predicate_functions', $$
276+
RETURN single(x IN null WHERE x > 0)
277+
$$) AS (result agtype);
278+
result
279+
--------
280+
false
281+
(1 row)
282+
157283
--
158284
-- Integration with graph data
159285
--

regress/sql/predicate_functions.sql

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,81 @@ SELECT * FROM cypher('predicate_functions', $$
9999
RETURN single(x IN [] WHERE x > 0)
100100
$$) AS (result agtype);
101101

102+
--
103+
-- NULL list input: all/any/none return null, single returns false
104+
-- (unnest of NULL produces zero rows; aggregates return NULL over
105+
-- empty input, but count(*) returns 0)
106+
--
107+
SELECT * FROM cypher('predicate_functions', $$
108+
RETURN all(x IN null WHERE x > 0)
109+
$$) AS (result agtype);
110+
111+
SELECT * FROM cypher('predicate_functions', $$
112+
RETURN any(x IN null WHERE x > 0)
113+
$$) AS (result agtype);
114+
115+
SELECT * FROM cypher('predicate_functions', $$
116+
RETURN none(x IN null WHERE x > 0)
117+
$$) AS (result agtype);
118+
119+
SELECT * FROM cypher('predicate_functions', $$
120+
RETURN single(x IN null WHERE x > 0)
121+
$$) AS (result agtype);
122+
123+
--
124+
-- NULL predicate results: three-valued logic
125+
--
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.
130+
131+
-- agtype null in list: null > 0 = true in AGE, so any() = true
132+
SELECT * FROM cypher('predicate_functions', $$
133+
RETURN any(x IN [null] WHERE x > 0)
134+
$$) AS (result agtype);
135+
136+
-- agtype null + real values: all comparisons are true
137+
SELECT * FROM cypher('predicate_functions', $$
138+
RETURN any(x IN [null, 1, 2] WHERE x > 0)
139+
$$) AS (result agtype);
140+
141+
-- literal null predicate: pred = SQL NULL -> three-valued logic
142+
-- all([1] WHERE null) = null (unknown)
143+
SELECT * FROM cypher('predicate_functions', $$
144+
RETURN all(x IN [1] WHERE null)
145+
$$) AS (result agtype);
146+
147+
-- agtype null in list: null > 0 = true in AGE, so all() = true
148+
SELECT * FROM cypher('predicate_functions', $$
149+
RETURN all(x IN [1, null, 2] WHERE x > 0)
150+
$$) AS (result agtype);
151+
152+
-- -1 > 0 = false, so all() = false
153+
SELECT * FROM cypher('predicate_functions', $$
154+
RETURN all(x IN [1, null, -1] WHERE x > 0)
155+
$$) AS (result agtype);
156+
157+
-- agtype null > 0 = true in AGE, so none() = false
158+
SELECT * FROM cypher('predicate_functions', $$
159+
RETURN none(x IN [null] WHERE x > 0)
160+
$$) AS (result agtype);
161+
162+
-- 5 > 0 = true, so none() = false
163+
SELECT * FROM cypher('predicate_functions', $$
164+
RETURN none(x IN [null, 5] WHERE x > 0)
165+
$$) AS (result agtype);
166+
167+
-- agtype null > 0 = true AND 5 > 0 = true: 2 matches, single = false
168+
SELECT * FROM cypher('predicate_functions', $$
169+
RETURN single(x IN [null, 5] WHERE x > 0)
170+
$$) AS (result agtype);
171+
172+
-- single() with null list: count(*) = 0, 0 = 1 -> false
173+
SELECT * FROM cypher('predicate_functions', $$
174+
RETURN single(x IN null WHERE x > 0)
175+
$$) AS (result agtype);
176+
102177
--
103178
-- Integration with graph data
104179
--

src/backend/nodes/ag_nodes.c

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ const ExtensibleNodeMethods node_methods[] = {
134134
DEFINE_NODE_METHODS_EXTENDED(cypher_delete_information),
135135
DEFINE_NODE_METHODS_EXTENDED(cypher_delete_item),
136136
DEFINE_NODE_METHODS_EXTENDED(cypher_merge_information),
137-
DEFINE_NODE_METHODS(cypher_predicate_function)
137+
DEFINE_NODE_METHODS_EXTENDED(cypher_predicate_function)
138138
};
139139

140140
static bool equal_ag_node(const ExtensibleNode *a, const ExtensibleNode *b)

src/backend/nodes/cypher_copyfuncs.c

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,15 @@ void copy_cypher_merge_information(ExtensibleNode *newnode, const ExtensibleNode
169169
COPY_SCALAR_FIELD(merge_function_attr);
170170
COPY_NODE_FIELD(path);
171171
}
172+
173+
/* copy function for cypher_predicate_function */
174+
void copy_cypher_predicate_function(ExtensibleNode *newnode,
175+
const ExtensibleNode *from)
176+
{
177+
COPY_LOCALS(cypher_predicate_function);
178+
179+
COPY_SCALAR_FIELD(kind);
180+
COPY_STRING_FIELD(varname);
181+
COPY_NODE_FIELD(expr);
182+
COPY_NODE_FIELD(where);
183+
}

src/backend/nodes/cypher_readfuncs.c

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,3 +311,17 @@ void read_cypher_merge_information(struct ExtensibleNode *node)
311311
READ_INT_FIELD(merge_function_attr);
312312
READ_NODE_FIELD(path);
313313
}
314+
315+
/*
316+
* Deserialize a string representing the cypher_predicate_function
317+
* data structure.
318+
*/
319+
void read_cypher_predicate_function(struct ExtensibleNode *node)
320+
{
321+
READ_LOCALS(cypher_predicate_function);
322+
323+
READ_ENUM_FIELD(kind, cypher_predicate_function_kind);
324+
READ_STRING_FIELD(varname);
325+
READ_NODE_FIELD(expr);
326+
READ_NODE_FIELD(where);
327+
}

0 commit comments

Comments
 (0)