Skip to content

Push down the array IN family unconditionally - #317

Merged
JoshDreamland merged 1 commit into
mainfrom
notin-any-array
Jul 23, 2026
Merged

Push down the array IN family unconditionally#317
JoshDreamland merged 1 commit into
mainfrom
notin-any-array

Conversation

@JoshDreamland

@JoshDreamland JoshDreamland commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

The array IN family (IN, NOT IN, = ANY, = ALL, <> ANY, <> ALL) now ships unconditionally, in every context, instead of falling back to local evaluation whenever non-nullability couldn't be proven.

saop_null_semantics_ok collapses to true (past the pre-existing empty-array and NULL-array-constant checks); deparseScalarArrayOpExpr picks the cheap native/has()/countEqual() form when it can prove neither side is NULL, and a unified deparseGuardedScalarArrayOp helper otherwise; see doc comment thereabove for details about guard mechanics.

The non-nullability proof also now follows basic arithmetic (+, -, *, unary -) over non-NULL constants and NOT NULL columns, via a hand-picked list of operator functions: being strict alone doesn't prove a function can't return NULL from non-NULL inputs (->> on a missing JSON key is a counterexample), so each entry is individually
checked. The list is sorted once at load time (a constructor-attribute function, since these OIDs aren't guaranteed identical across every supported PG version) so the check is a bsearch instead of a linear scan.

A trade-off (mitigated in #321): a constant list with a possible NULL previously shipped via native IN(...) specifically in a truth context, where FALSE-for-NULL substitution from ClickHouse makes no difference. The deparse function has no way to see context as of this commit, so it now always guards that case instead, which sucks, but buys us pushdown for the rest of the array IN family.

Also, finally patches chfdw_is_equal_op to return a ChfdwEqualOp enum (NONE/EQ/NE) instead of magic 0/1/2 ints.

@theory theory added pushdown Improvements to query pushdown sql Improve SQL coverage or FDW capabilities labels Jul 20, 2026
@JoshDreamland
JoshDreamland marked this pull request as ready for review July 22, 2026 15:35
@JoshDreamland
JoshDreamland force-pushed the notin-any-array branch 2 times, most recently from e945fe2 to 8986019 Compare July 22, 2026 16:03
@JoshDreamland
JoshDreamland requested a review from theory July 22, 2026 16:04
JoshDreamland added a commit that referenced this pull request Jul 22, 2026
deparseScalarArrayOpExpr's cheap native IN(...)/NOT IN(...) form
requires the array to be provably NULL-free. A constant list with a
literal NULL (x IN (1, 2, NULL)) always fails that and falls through
to the guarded CASE, even in a WHERE clause, where the only actual
divergence (a non-NULL, non-matching probe against a NULL-containing
list resolving to FALSE natively where Postgres computes NULL) is
invisible: NULL and FALSE are interchangeable there. This restores
the cheap form for that specific case when the surrounding context
tolerates it.

Deliberately IN-only, not NOT IN: with transform_null_in=0,
ClickHouse's native IN resolves a non-matching probe against a
NULL-containing list to FALSE (tolerable), but native NOT IN resolves
the same case to TRUE, which diverges from Postgres's NULL in every
context, not just value-observable ones -- confirmed empirically
against a live server.

Mechanism: a new deparse_expr_cxt.truth_ctx bool mirrors the walker's
ExprTruthCtx (foreign_expr_walker, EXPR_CTX_TRUTH specifically -- the
third state, EXPR_CTX_NEGATED, can't reach a plain ScalarArrayOpExpr,
since the planner folds NOT over one into its dual before we ever see
it; verified via EXPLAIN). Granted at deparse's mirrors of the
walker's EXPR_CTX_TRUTH grants (appendConditions, SubPlan quals,
aggregate FILTER, CASE WHEN conditions) and degraded to false
everywhere else via a nodeTag switch in deparseExpr.

Open concern raised in review, not yet resolved: this switch is a
second, hand-maintained copy of "which node types matter here" with
nothing forcing it to stay in sync with deparseExpr's real dispatch
switch as new node types are added, and the new context field is one
more thing to carry on deparse_expr_cxt for a single consumer. Filed
as its own PR against notin-any-array (#317) rather than folded in,
both because the net line count wasn't a savings once test coverage
for the new mechanism is included, and because the duplication
concern is worth resolving before this lands -- likely by having
deparse consult the walker's own already-computed verdict for a node
instead of re-deriving it a second time, rather than tracking a
parallel opinion.

@theory theory left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great, mostly just nits and desiring a bit more clarity of narrative text.

Comment thread src/include/fdw.h Outdated
Comment thread CHANGELOG.md Outdated
Comment thread CHANGELOG.md Outdated
Comment on lines +32 to +41
Like with `NOT IN`, a more elegant spelling is used when non-nullability can
be proven, and in other cases, an uglier shape is used which can handle NULL
values correctly (matching Postgres behavior).
* Extended that guarded shape to the rest of the array `IN` family
(`IN`/`NOT IN`, `= ANY`, `= ALL`, `<> ALL`), so all of them now push down
unconditionally, in every context (`SELECT` list and `GROUP BY` included),
instead of falling back to local evaluation whenever non-nullability
couldn't be proven. The non-nullability proof that selects the cheaper
unguarded form now also follows basic arithmetic (`+`, `-`, `*`, unary
`-`) over non-NULL constants and `NOT NULL` columns ([#317]).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might all be more detail than necessary TBH. Just say what changed and the impact.

Though I'll likely edit it all again during release prep.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplified. How's that?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Much better; the IN change should be roughly the same, yes?

Comment thread test/expected/in_null_semantics.out
Comment thread src/deparse.c Outdated
Comment thread src/deparse.c Outdated
Comment thread src/deparse.c Outdated
Comment thread src/deparse.c Outdated
Comment thread src/deparse.c Outdated
Comment thread src/deparse.c Outdated
@JoshDreamland
JoshDreamland force-pushed the notin-any-array branch 2 times, most recently from 825aabd to 1a06fe3 Compare July 23, 2026 01:07
The array IN family (`IN`, `NOT IN`, `= ANY`, `= ALL`, `<> ANY`, `<> ALL`)
now ships unconditionally, in every context, instead of falling back to
local evaluation whenever non-nullability couldn't be proven.

`saop_null_semantics_ok` collapses to true (past the pre-existing
empty-array and NULL-array-constant checks); deparseScalarArrayOpExpr
picks the cheap native/`has()`/`countEqual()` form when it can prove
neither side is NULL, and a unified `deparseGuardedScalarArrayOp` helper
otherwise; see doc comment thereabove for details about guard mechanics.

The non-nullability proof also now follows basic arithmetic
(`+`, `-`, `*`, unary `-`) over non-NULL constants and NOT NULL columns,
via a hand-picked list of operator functions: being strict alone doesn't
prove a function can't return NULL from non-NULL inputs (->> on a
missing JSON key is a counterexample), so each entry is individually
checked. The list is sorted once at load time (a constructor-attribute
function, since these OIDs aren't guaranteed identical across every
supported PG version) so the check is a bsearch instead of a linear
scan.

A trade-off (mitigated in #321): a constant list with a possible NULL
previously shipped via native `IN(...)` specifically in a truth context,
where FALSE-for-NULL substitution from ClickHouse makes no difference.
The deparse function has no way to see context as of this commit, so it
now always guards that case instead, which sucks, but buys us pushdown
for the rest of the array IN family.

Also, finally patches `chfdw_is_equal_op` to return a `CHEqualOp`
enum (NONE/EQ/NE) instead of magic 0/1/2 ints.
@JoshDreamland
JoshDreamland merged commit 183794d into main Jul 23, 2026
30 of 31 checks passed
@JoshDreamland
JoshDreamland deleted the notin-any-array branch July 23, 2026 14:31
JoshDreamland added a commit that referenced this pull request Jul 23, 2026
deparseScalarArrayOpExpr's cheap native IN(...)/NOT IN(...) form
requires the array to be provably NULL-free. A constant list with a
literal NULL (x IN (1, 2, NULL)) always fails that and falls through
to the guarded CASE, even in a WHERE clause, where the only actual
divergence (a non-NULL, non-matching probe against a NULL-containing
list resolving to FALSE natively where Postgres computes NULL) is
invisible: NULL and FALSE are interchangeable there. This restores
the cheap form for that specific case when the surrounding context
tolerates it.

Deliberately IN-only, not NOT IN: with transform_null_in=0,
ClickHouse's native IN resolves a non-matching probe against a
NULL-containing list to FALSE (tolerable), but native NOT IN resolves
the same case to TRUE, which diverges from Postgres's NULL in every
context, not just value-observable ones -- confirmed empirically
against a live server.

Mechanism: a new deparse_expr_cxt.truth_ctx bool mirrors the walker's
ExprTruthCtx (foreign_expr_walker, EXPR_CTX_TRUTH specifically -- the
third state, EXPR_CTX_NEGATED, can't reach a plain ScalarArrayOpExpr,
since the planner folds NOT over one into its dual before we ever see
it; verified via EXPLAIN). Granted at deparse's mirrors of the
walker's EXPR_CTX_TRUTH grants (appendConditions, SubPlan quals,
aggregate FILTER, CASE WHEN conditions) and degraded to false
everywhere else via a nodeTag switch in deparseExpr.

Open concern raised in review, not yet resolved: this switch is a
second, hand-maintained copy of "which node types matter here" with
nothing forcing it to stay in sync with deparseExpr's real dispatch
switch as new node types are added, and the new context field is one
more thing to carry on deparse_expr_cxt for a single consumer. Filed
as its own PR against notin-any-array (#317) rather than folded in,
both because the net line count wasn't a savings once test coverage
for the new mechanism is included, and because the duplication
concern is worth resolving before this lands -- likely by having
deparse consult the walker's own already-computed verdict for a node
instead of re-deriving it a second time, rather than tracking a
parallel opinion.
JoshDreamland added a commit that referenced this pull request Jul 29, 2026
deparseScalarArrayOpExpr's cheap native IN(...)/NOT IN(...) form
requires the array to be provably NULL-free. A constant list with a
literal NULL (x IN (1, 2, NULL)) always fails that and falls through
to the guarded CASE, even in a WHERE clause, where the only actual
divergence (a non-NULL, non-matching probe against a NULL-containing
list resolving to FALSE natively where Postgres computes NULL) is
invisible: NULL and FALSE are interchangeable there. This restores
the cheap form for that specific case when the surrounding context
tolerates it.

Deliberately IN-only, not NOT IN: with transform_null_in=0,
ClickHouse's native IN resolves a non-matching probe against a
NULL-containing list to FALSE (tolerable), but native NOT IN resolves
the same case to TRUE, which diverges from Postgres's NULL in every
context, not just value-observable ones -- confirmed empirically
against a live server.

Mechanism: a new deparse_expr_cxt.truth_ctx bool mirrors the walker's
ExprTruthCtx (foreign_expr_walker, EXPR_CTX_TRUTH specifically -- the
third state, EXPR_CTX_NEGATED, can't reach a plain ScalarArrayOpExpr,
since the planner folds NOT over one into its dual before we ever see
it; verified via EXPLAIN). Granted at deparse's mirrors of the
walker's EXPR_CTX_TRUTH grants (appendConditions, SubPlan quals,
aggregate FILTER, CASE WHEN conditions) and degraded to false
everywhere else via a nodeTag switch in deparseExpr.

Open concern raised in review, not yet resolved: this switch is a
second, hand-maintained copy of "which node types matter here" with
nothing forcing it to stay in sync with deparseExpr's real dispatch
switch as new node types are added, and the new context field is one
more thing to carry on deparse_expr_cxt for a single consumer. Filed
as its own PR against notin-any-array (#317) rather than folded in,
both because the net line count wasn't a savings once test coverage
for the new mechanism is included, and because the duplication
concern is worth resolving before this lands -- likely by having
deparse consult the walker's own already-computed verdict for a node
instead of re-deriving it a second time, rather than tracking a
parallel opinion.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pushdown Improvements to query pushdown sql Improve SQL coverage or FDW capabilities

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants