Skip to content

Guard pushed-down parquet filters against NULL rows (#728) - #730

Merged
philcunliffe merged 3 commits into
masterfrom
fix/issue-728
Aug 13, 2026
Merged

Guard pushed-down parquet filters against NULL rows (#728)#730
philcunliffe merged 3 commits into
masterfrom
fix/issue-728

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Guards pushed-down parquet filters against NULL rows. whereToParquetFilter emitted bare relational operators, and hyparquet's matchFilter evaluates them with raw JS comparison, where null <= 300n is true. Since parquet-source.js:51 sets appliedWhere = Boolean(filter), the engine never re-filtered and the NULL rows reached the caller.

More operators leaked than the issue reported

op leaked? action
$lt $lte yes (null <= 300n is true) $ne: null guard
$gt $gte yes - correct only for positive bounds. neg > -400 over -500, NULL, -300, NULL, -100 returned 2,3,4,5 instead of 3,5 $ne: null guard
$ne (!=, <>) yes (!equals(null, 300n)) $and: [{col:{$ne:null}}, {col:{$ne:v}}] - its guard key collides with its own
$nin (NOT IN) yes, not in the issue: ts NOT IN (300) returned 1,2,4,5 $ne: null guard
$in only when the list itself holds NULL: ts IN (300, NULL) returned 2,3,4 $ne: null guard
$eq no - equals(null, <non-null>, false) is already false, and $eq: null is how IS NULL spells itself unchanged

Three predicates are UNKNOWN for every row under three-valued logic and are now not pushed down at all, since no hyparquet operator means "never match": col = NULL, col <op> NULL, and col NOT IN (..., NULL). Pre-fix, col = NULL pushed $eq: null and behaved exactly like IS NULL, returning the NULL rows.

The shape matters, and it recovers pruning rather than costing it

The guard rides as a second key in the same condition object, {col: {$ne: null, $lte: v}}, not the $and wrapping the issue suggested. matchFilter ANDs the entries of a condition object, so both are correct - but they prune very differently.

canSkipRowGroup computes matchingNulls = matchFilter({value: null}, {value: condition}) and refuses statistics-based skipping for any condition a NULL could satisfy; filterPageRanges does the same per page. A bare {ts: {$lte: 500n}} looks NULL-matching, so the bug was already forfeiting row-group and page skipping on every chunk holding a NULL.

Measured on a 5-row-group x 1000-row file with a counting AsyncBuffer, ts monotonic per group, 10% NULLs, only group 0 matching:

no filter (whole file)                              rows=5000  reads=5  bytes=64791
bare        {ts:{$lte:500n}}          (pre-fix)     rows= 950  reads=5  bytes=64791
compact     {ts:{$ne:null,$lte:500n}} (this fix)    rows= 450  reads=1  bytes=12933
$and-wrapped [{ts:{$ne:null}},{ts:{$lte:500n}}]     rows= 450  reads=5  bytes=64791

Same result for a negative bound on >. So the $and shape is correct but keeps the pre-fix un-pruned read; the compact shape is correct and 5x cheaper here. For $ne, which is forced into $and, the cost measured as zero: with no NULLs in the column nullCount === 0 makes matchingNulls false and stats skipping still fires; with NULLs present neither form prunes. Bloom pruning is untouched, since it keys only off $eq/$in and neither target changed.

Evidence

The regression tests were written first and run against the unmodified converter: 7 fail, each for the right reason (extra NULL rows). Independently re-derived by the reconciler.

'WHERE ts <= 300 -> got [1,2,3,4], SQL says [1,3]'
'WHERE ts <  300 -> got [1,2,4],   SQL says [1]'
'WHERE ts != 300 -> got [1,2,4,5], SQL says [1,5]'
'WHERE ts NOT IN (300) -> got [1,2,4,5], SQL says [1,5]'
'WHERE neg > -400 -> got [2,3,4,5], SQL says [3,5]'
'WHERE ts = NULL -> got [2,4], SQL says []'
'WHERE ts IN (300, NULL) -> got [2,3,4], SQL says [3]'

After the fix: 18/18 in that file. Full suite 3978 pass / 0 fail / 1 pre-existing skip; typecheck clean; npm run smoke -- local_parquet_export ok.

Tests run end to end through a real parquetDataSource, not by asserting filter shape alone, because the whole bug is that a plausible-looking filter behaves wrongly at evaluation time.

Interaction with PR #721 - please read before merging either

#721 deletes this file and re-exports icebird's converter. It is currently neutral:stuck because the same null hazard is newly reachable there via its typed-literal fold. This PR will conflict with it.

The same guard must move upstream into icebird's whereToParquetFilter before #721 can land, or merging it re-introduces this exact wrong-answer path. And the shape matters there too: icebird must use the compact {col: {$ne: null, <op>: v}} form, not $and, or it silently gives up row-group and page pruning as measured above.

Deliberately not done

WHERE ts NOT IN (300, NULL) still returns rows ([1,5] instead of []). This change correctly declines to push it down, so it falls to squirreling's engine, and the engine gets three-valued NOT IN with a NULL list element wrong. That is a squirreling bug, not a pushdown one, and the answer is no worse than before, so it was left out of the test expectations rather than papered over in this layer. = NULL and != NULL fall back the same way and the engine handles those correctly.

Fixes #728

`whereToParquetFilter` emitted a bare relational operator, e.g.
`{ ts: { $lte: 300n } }`. hyparquet's `matchFilter` evaluates that with a
raw JavaScript comparison, and `null <= 300n` is true (null coerces to 0),
so every NULL row matched. `$ne` and `$nin` leak the same way by negating a
failed comparison. Because `parquetDataSource` sets `appliedWhere` from the
presence of a filter, the engine never re-filters and the NULL rows reach
the caller: a silent wrong answer on any `<`, `<=`, `!=`, `<>`, or `NOT IN`
predicate over a nullable column.

`>` and `>=` leak too. They only look correct in the issue's table because
a coerced NULL reads as 0 and loses to a positive bound; with a negative
bound (`neg > -400`) they return every NULL row.

Every operator whose evaluation can admit NULL now carries a `$ne: null`
conjunct. It rides inside the same condition object rather than an outer
`$and`, which also recovers pruning the bug was costing: `canSkipRowGroup`
and `filterPageRanges` refuse to skip on statistics for any condition a
NULL could satisfy, so a bare bound read the whole file. On a 5-row-group
fixture, `ts <= 500` went from 5 reads / 64791 bytes to 1 read / 12933.
The `$and` shape would have kept the unpruned behavior. `$ne` is the one
operator whose guard key collides with its own, so it takes the `$and`
form, which measurement shows costs no pruning.

Predicates that are UNKNOWN for every row under three-valued logic
(`col = NULL`, `col < NULL`, `col NOT IN (…, NULL)`) are no longer pushed
down at all: there is no hyparquet operator for "never match", and pushing
`$eq: null` made `col = NULL` behave like `IS NULL`.

The regression test drives a real `parquetDataSource` over a nullable
fixture and reports every mismatching predicate at once. Against the
pre-fix converter it lists 11 leaking predicates plus 3 wrong NULL-literal
results; after the fix the list is empty.
The NULL guard added for #728 stops at the leaves. `NOT (a OR b)` compiled
to a `$nor` wrapper, and hyparquet's `matchFilter` evaluates `$nor` as a
two-valued complement: a row that is UNKNOWN for every disjunct reads as
"no child matched, therefore true" and is returned. SQL excludes it. The
scan claims `appliedWhere` for any convertible predicate, so the engine
never re-filters and those rows reach the caller.

Pre-fix the bug hid itself: a bare `{ts: {$lt: 100n}}` matched the NULL row
by coercion (`null < 100n` is `0 < 100`), which flipped `$nor` to exclude
it. Adding `$ne: null` removed the accident and the NULL row escaped.

Use De Morgan instead, as the AND branch already does: `NOT (a OR b)` is
`(NOT a) AND (NOT b)` in Kleene 3VL, so each leaf carries its own guard.
Nothing conservative is lost (a non-convertible child already forced
`undefined` on both paths), and it is a pruning win: `canSkipRowGroup` and
`filterPageRanges` bail out unconditionally on `$nor`, while `$and` prunes
if any branch prunes.

Also: note in the guardNulls JSDoc that per-leaf guards are only sound
while every negation reaches a leaf; correct the stated reason for
declining `NOT IN (..., NULL)` (it is FALSE for a listed value and UNKNOWN
elsewhere, not UNKNOWN everywhere); and cite LLP 0098 as a whole rather
than its `#wrapper-duties` section, which enumerates kernel scanColumn
wrapper duties and does not cover this converter.

Extends the #728 census with seven `NOT (a OR b)` cases over nullable
columns; six of them fail on the previous commit.
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 1 of 9d80c49. Verdict: findings - 1 blocker, 1 major, 2 minor. All four fixed and pushed as fb6cc1d.

The guard is right, the compact shape is right, and the pruning claim reproduces. But the fix stopped at the leaves and turned three previously-correct queries into wrong-row queries.

1. blocker - NOT (a OR b) compiles to $nor, which leaks NULL rows. FIXED

convertBinary's OR branch realized negation with a $nor wrapper instead of pushing it into the children. matchFilter implements $nor as !some(...), a two-valued complement. SQL's NOT (a OR b) is three-valued: if every disjunct is UNKNOWN the row is excluded, but hyparquet returns "no child matched, therefore true". appliedWhere is set, so the engine never re-filters - the exact failure mode of #728.

predicate                          SQL     pre-fix   as-submitted
NOT (ts > 300 OR ts < 100)         [1,3]   [1,3]     [1,2,3,4]   <- REGRESSION
NOT (ts >= 300 OR ts <= 100)       []      []        [2,4]       <- REGRESSION
NOT (ts < 300 OR ts > 300)         [3]     [3]       [2,3,4]     <- REGRESSION

The mechanism is worth recording: pre-fix, a bare {ts: {$lt: 100n}} accidentally matched the NULL row (null < 100n coerces to 0 < 100), which flipped $nor to exclude it. The coercion bug was cancelling itself out. Adding $ne: null removed the accident and the NULL row escaped through $nor. So the PR's "more operators leaked than the issue reported" table was incomplete in the direction that matters: $lt/$lte under a $nor parent were correct before and wrong after.

Fixed with De Morgan, matching what the AND branch already did: NOT (a OR b) is NOT a AND NOT b in Kleene 3VL, so negation reaches the leaves and each leaf carries its own guard. It loses nothing conservative (a non-convertible child already forced undefined on both paths) and is a pruning win: canSkipRowGroup and filterPageRanges bail out unconditionally on $nor, whereas $and prunes if any branch prunes. NOT (ts > 300 OR ts < 100) now compiles to {$and: [{ts: {$ne: null, $lte: 300n}}, {ts: {$ne: null, $gte: 100n}}]} - two prunable bounded leaves, each guarded. The guardNulls JSDoc now records that the per-leaf guard is only sound because every negation reaches a leaf.

Sweep: 1 of 9 NOT (a OR b) cases SQL-correct before, 9 of 9 after.

2. major - the regression suite covered every path except the wrong one. FIXED

The mismatches() census is well built (real parquetDataSource, end to end, full census on failure rather than first-failure), and it covered NOT (ts > 300), NOT (ts IS NULL), AND, and De Morgan through NOT (a AND b). It had no NOT (a OR b) case over a nullable column at all. The only $nor assertion was a shape assertion on the non-nullable id column. That is why 18/18 passed on a converter returning wrong rows.

Fixed with seven cases added, reusing the existing neg and label columns rather than extending the fixture. Six fail before the blocker fix and pass after; the seventh (NOT (ts IS NULL OR ts > 300)) passes both ways because IS NULL is two-valued, and was kept as a non-regression anchor. The $nor shape assertion was updated to $and.

3. minor - the stated reason for declining NOT IN (..., NULL) was wrong. FIXED

The comment said it "is UNKNOWN for every row". It is not: for x NOT IN (300, NULL), a row with x = 300 evaluates FALSE AND UNKNOWN = FALSE, not UNKNOWN. Only non-members are UNKNOWN. The conclusion survives but the rationale was the load-bearing justification for the undefined return. The reviewer confirmed the distinction is real: NOT (ts NOT IN (300, NULL)) correctly returns [3] precisely because the inner predicate is FALSE for row 3, which the old comment's logic could not explain. Reworded.

4. minor - the @ref over-claimed its source. FIXED

@ref LLP 0098#wrapper-duties resolves, so ref-check passed, but that section enumerates the duties of three specific kernel scanColumn wrapper layers. guardNulls is not a wrapper, and parquet-source.js implements scan(), not scanColumn(). Both refs now cite LLP 0098 as a whole. The three other #wrapper-duties refs in the repo are genuine wrapper layers and were left alone; LLP 0098 was not edited.

Also checked, clean

  • The shape claim, verified and correct. matchFilter does Object.entries(condition).every(...) resolving the same value per field, so a second key is an unconditional AND. Brute-forced compact-versus-$and equivalence over {$lt,$lte,$gt,$gte,$in,$nin} x seven operand shapes x ten record shapes (including the bound-equal row, the NULL row, and the schema-drift missing-key row), under both strict: true and false: zero disagreements. parquet-source.js:99 passes filterStrict: false, which is what makes the missing-key row behave as NULL via undefined == null, and both shapes agree there.
  • No false row-group or page skip - the sharp risk, and it does not materialize. Differential test reading every row unfiltered and applying matchFilter in JS versus the pruned read, over two multi-row-group fixtures (all-NULL group, constant-value group, mixed group; strings, DOUBLE, negative INT64), across 17 guarded shapes: all 17 agree exactly. Reading canSkipStats, the only path where the added key could fire a skip requires equals(minVal, maxVal) && equals(minVal, null), but convertMetadata returns undefined, never null, and haveStats already excludes undefined. The guard key is provably inert in the pruner.
  • The pruning measurement reproduces. Row counts match the PR exactly and the reads 5 to 1 claim is real. Absolute byte figures differ (39258/7850 versus the PR's 64791/12933), attributed to writer settings - the ratio, read counts and row counts all match.
  • $ne forced into $and - confirmed, and no better spelling exists. There is a non-colliding spelling via $not, but canSkipStats has no $not branch, so it would forfeit the constant-chunk $ne skip that $and keeps. Verified the "costs no pruning" claim on a non-nullable fixture: identical reads and bytes both ways.
  • Non-nullable columns: genuine no-op. Same rows, same reads=1 bytes=140 with and without the guard.
  • Bloom pruning untouched, verified by direct call rather than by reading: drove canSkipRowGroup with an all-zero SBBF. $eq skips, $in skips, $ne: null alongside $in still skips. The one case that loses bloom skipping is $in: [5n, null], and it loses it identically with and without the guard - pre-existing, not caused here.
  • The declined predicates, verified end to end. ts = NULL, ts != NULL, ts <> NULL, ts < NULL, ts >= NULL, NULL = ts, ts IN (NULL) all return [], and ts = NULL OR ts = 300 returns [3]. The PR's "the engine handles those correctly" is accurate, and its "deliberately not done" note about NOT IN (300, NULL) is honest.
  • Completeness sweep over 45 predicates: flipped operands, LIKE and NOT LIKE declining, IS NULL/IS NOT NULL and their negations, BETWEEN/NOT BETWEEN, NOT over each comparison, double negation, mixed-column AND/OR with one side guarded. cast nodes fall through as before. The only shape that came out wrong was $nor.
  • Conventions clean; typecheck clean; suite 3978 pass / 0 fail / 1 pre-existing skip.

Worth knowing, out of scope

The squirreling engine is broadly wrong on three-valued logic under NOT (NOT (ts > 300) gives [1,2,3,4], ts NOT IN (300) gives [1,2,4,5]). That means the pushdown path is now more SQL-correct than the fallback. Not a regression and not this PR's to fix, but it bears on how much to trust declining a predicate as a safety valve.

For #721

icebird@0.8.19/src/sql/whereFilter.js is a near-verbatim twin of this file: same unguarded leaf emission, same $nor at line 71, same LIKE decline. So the PR body's "port the guard upstream" note was under-specified. Porting needs the guard, the De Morgan rewrite, and the compact single-object shape - all three, or #721 re-introduces this wrong-answer path with the guard applied and the hole still open.

The head has moved to fb6cc1d, so the next tick reviews that head (round 2).

`col NOT IN (..., NULL)` matches no row under three-valued logic, and the
converter declined it on the grounds that no hyparquet operator says
"never match". That premise was wrong twice over.

It is expressible. `{col: {$in: []}}` never matches: `matchesIn` folds an
empty target list to `[].some(...)`, which is false. It also prunes
perfectly: `canSkipStats`'s `$in` branch folds to `[].every(...)`, which
is true, so every row group is skipped on statistics alone. Measured on a
1000-row / 5-row-group fixture, `$in: []` reads the footer and nothing
else (rows=0, slices=1, bytes=4144) against rows=1000, slices=6,
bytes=7879 unfiltered.

And declining was not safe. squirreling's own WHERE evaluation is
two-valued for NULLs, so handing the predicate back reproduced the very
leak #728 is about: `ts NOT IN (300, NULL)` returned [1,5] where SQL
returns []. Push the never-match filter instead.

The docstring's "let the SQL engine filter the rows itself" read as if
declining were a correctness guarantee. It is not, on a nullable column;
say so. Those remaining divergences are squirreling's to fix and out of
this converter's reach, so nothing else changes behaviour here.

Adds `ts NOT IN (300, NULL)` to the NULL-literal census, which failed on
the parent commit with got [1,5].
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 2 of fb6cc1d. Verdict: findings - 1 minor, fixed and pushed as f285b47.

The De Morgan rewrite is correct, and it was attacked hard. Across 600 randomly generated predicate trees (depth 3, mixing NOT/AND/OR/IN/NOT IN/IS NULL/IS NOT NULL/flipped operands over four columns, three nullable with different value shapes), run end to end through a real parquetDataSource and compared against an independently written Kleene-3VL evaluator: 0 mismatches out of 600, all 600 pushed down. The same 600 against round 1's converter produced 56 silent wrong-row answers.

1. minor - the reason for declining NOT IN (..., NULL) was false, and the fallback returned wrong rows. FIXED

The comment declined on the grounds that the never-match result is "unexpressible as a hyparquet operator, so the engine keeps it". Both halves were wrong.

It is expressible. {col: {$in: []}} never matches (matchesIn folds [].some(...) to false) and canSkipStats's $in branch folds [].every(...) to true, so it skips every row group.

The engine does not keep it correctly. squirreling 0.15.0's WHERE evaluation is two-valued for NULLs, so handing the predicate back produced the exact leak #728 is about:

ts NOT IN (100, NULL)   was [3,5]   SQL 3VL says []
ts NOT IN (300, NULL)   was [1,5]   SQL 3VL says []

Not a regression - pre-PR pushed {ts: {$nin: [100n, null]}} and returned the identical [3,5] - but this PR reworded that exact comment in round 1, so the premise was in scope, and the comment three lines above already said "matches no row" while the code shipped something that returned rows.

Fixed by pushing {$in: []} instead of declining. The fixer verified both hyparquet claims independently rather than taking them on trust (reading filter.js and then measuring matchFilter and canSkipRowGroup directly), and reported two honest deltas from the review's numbers: their harness counts the footer read, so the floor is slices=1 not slices=0; and on 1.28.1 {$eq: null, $ne: null} also prunes every row group, so it is not distinguishable by bytes. $in: [] remains the better spelling - a single-operator never-match that reads as one, rather than a self-contradicting pair that reads as an accident.

The new census case ['ts NOT IN (300, NULL)', []] fails on the prior head with exactly the predicted got [1,5] and passes after.

One edit beyond the brief, correctly flagged: the existing unit test whereToParquetFilter declines predicates whose SQL result is always UNKNOWN pinned the old undefined return, so the fix broke it. It was retargeted to { id: { $in: [] } } and renamed declines to handles, since it no longer only declines. The three NULL-literal comparison assertions are untouched and still expect undefined.

Docstring corrected too. The module header said a non-convertible predicate means the caller must "let the SQL engine filter the rows itself", which read as if declining were the safe outcome. It is not, for nullable columns:

NOT (ts > 300 OR label LIKE 'a%')         engine=[2,3,4,6,7]   SQL 3VL=[3,6]
NOT (CAST(ts AS BIGINT) > 300 OR ts<100)  engine=[1,2,3,4,6,7] SQL 3VL=[1,3,6]

Those are squirreling's to fix and out of this converter's reach, so no behaviour changed - one honest sentence was added noting that declining is a correctness fallback, not a guarantee.

Verified from round 1

  • (blocker) $nor dropped for De Morgan - landed correctly, and the claims are understated. Verified as implemented, not in theory: 36 hand-built adversarial shapes plus the 600 fuzzed trees. Covered and correct: nested NOT (a OR (b AND c)); three-way NOT (a OR b OR c) in all three associations; double and triple negation (NOT (NOT (a OR b)) correctly regenerates the un-negated $or); different nullable columns per side; one side IS NULL and the other a guarded comparison in all four polarity combinations, including the contradiction NOT (ts IS NOT NULL OR ts > 300) which compiles to a never-satisfiable $and and returns []; flipped operands under negation; IN/NOT IN inside a negated OR; and BETWEEN, which squirreling desugars at parse time. Decline propagation is correct on the negation path - a LIKE child returns undefined from both child orders and from a nested position, so appliedWhere stays false. The "1 of 9 correct before, 9 of 9 after" claim holds exactly on the census set and far more broadly than claimed. Pruning measured: NOT (ts > 300 OR ts < 100) on 1000 rows / 5 groups reads 2 slices / 5018 bytes and returns the SQL-exact 197 rows, versus $nor at 5 slices / 12575 bytes returning 217 (20 NULL rows leaked).
  • (major) Seven census cases - landed, discrimination claim exact. The prior converter was recovered with git show and all seven driven through a parameterized source: six genuinely fail pre-fix and pass post-fix, with the exact row sets. The seventh (NOT (ts IS NULL OR ts > 300)) passes both ways because IS NULL is two-valued - a legitimate non-regression anchor, not a test that fails to pin.
  • (minor) NOT IN comment 3VL - now correct. NOT IN unfolds to NOT (v = a OR ... OR v = NULL), TRUE for a listed match and UNKNOWN otherwise. The remaining defect was the "unexpressible" clause, which is finding 1.
  • (minor) @ref LLP 0098 - landed and honest. #wrapper-duties enumerates duties of the three kernel layers wrapping an inner scanColumn and says nothing about the converter or scan(). The doc as a whole does settle the cited constraint (its Context section on reporting appliedWhere honestly), so the whole-doc citation earns its place rather than being a vague fallback.

Also checked, clean

  • False-skip hunt at scale: 1000 rows / 10 row groups including a deliberate 100-row span where ts is entirely NULL (so a whole group has no ts statistics), 600 fuzzed trees, 600/600 pushed, 0 mismatches. No row group is ever skipped that holds a matching row.
  • A useful correction to the guard's own JSDoc rationale: parquet-source.js sets useOffsetIndex: safeOffset > 0 || safeLimit < rowCount, which is always false when a WHERE is present, so filterPageRanges is not on this code path at all - only canSkipRowGroup is live here. The JSDoc cites page pruning as motivation, which is fair as background but not as a live mechanism.
  • Compact shape, bloom pruning, non-nullable no-op all unchanged and re-confirmed. Bloom acts only on $eq/$in; the added $ne: null key does not gate it, and $eq carries no guard at all.
  • AST-shape assumptions verified against squirreling 0.15.0, since the converter depends on them: NOT IN parses as unary NOT wrapping a bare in valuelist with no negated flag, so reading only the negate parameter is correct and there is no ignored-flag hole; BETWEEN desugars before the converter sees it; NOT LIKE is unary NOT over a LIKE binary, so it declines.
  • Conventions clean in both files and both commits; typecheck clean; llp-ref-hygiene 11/11; suite 3978 pass / 0 fail / 1 pre-existing skip.

The head has moved to f285b47, so the round budget (2) is spent at an unreviewed head: the next tick triages rather than opening a round 3.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Triage after the review budget (LLP 0017). Two rounds ran (5 findings, all fixed, including a blocker the fix itself introduced). Judged mergeable. Deferred items in #734.

The triage did not take the review records on trust. It ran its own 71-predicate battery against an independently written Kleene-3VL evaluator, including cases the PR's own tests lack (zero bounds where a coerced NULL reads as 0, empty-string comparisons, double negation, NOT BETWEEN, mixed-column trees): head 0/71 mismatches, master 39/71 wrong on the same battery. It also confirmed the committed tests are not circular - they assert hand-derived SQL 3VL row-id lists against the full end-to-end path, and the shape assertions are pins rather than the correctness evidence.

The judgement call: the engine is now less correct than the pushdown

This PR makes convertible predicates fully NULL-correct while declined ones still fall through to squirreling's two-valued WHERE. That asymmetry is the one thing that could argue for holding, so here is the reasoning for shipping anyway:

  • The asymmetry is not new; only its sign flipped. On master both paths were wrong, on overlapping but different predicates. Head makes one path fully correct and leaves the other exactly as master had it. Holding would compare head against an ideal neither branch offers - the real alternative on offer is master, which is wrong strictly more often.
  • It is deterministic, not flappy. Convertibility is a pure function of predicate shape, so the same query always takes the same path and always gets the same answer. Nobody sees ts <= 300 right on Monday and wrong on Tuesday.
  • Holding couples a local, verified fix to an upstream release. The engine gap is squirreling's to fix, and during that timeline every s3 parquet dataset keeps returning NULL rows for ts <= 300.
  • The PR already encodes the right doctrine. Its header comment states declining is a fallback, not a guarantee, and the $in: [] never-match push acts on that.

Merge-time note, please read before touching #721

#721 must not land as-written after this merges. icebird 0.8.15's src/sql/whereFilter.js is the pre-#728 shape: bare relational operators, $nor for negated OR at line 58, unguarded $nin at line 153. Re-exporting it would reintroduce every NULL leak this PR fixes plus the round-1 NOT (a OR b) wrongness.

Merge order (#730 first) is protective rather than a trap: test/core/parquet-source.test.js imports parquet-pushdown.js directly, so #721's deletion breaks loudly, and once re-pointed the end-to-end 3VL cases (which are shape-agnostic) gate whatever converter replaces it. A silent revert would require deleting the tests too. Whoever unsticks #721 has two honest exits: upstream these guards into icebird first, or keep the local converter and shrink #721's scope.

@philcunliffe
philcunliffe marked this pull request as ready for review August 13, 2026 02:15
@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 13, 2026
@philcunliffe
philcunliffe merged commit 36760cc into master Aug 13, 2026
9 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-728 branch August 13, 2026 06:38
platypii added a commit that referenced this pull request Aug 13, 2026
…al fold

icebird's converter still disagrees with SQL on NULLs (11 of 24 predicates
in a three-valued battery), so adopting it wholesale would undo #730 and
#743. Take the one thing it had that we lacked instead: constant-folding of
typed literals, plus the truthiness gate on boolean-position casts. Folded
bounds carry the same $ne: null guard plain bounds do.

Renumber the LLP to 0219; 0212 is taken.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pushed-down <, <=, != leak NULL rows past the filter on nullable columns (live on master)

1 participant