Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions docs/ENGINE_REFACTORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,8 @@ feature work**, and so we can tell the difference between "this is awkward" and
WHERE *operator* now delegates. **Slice 5 (method calls) done 2026-09-26**,
closing P56 and P57 and deciding D3. **Slice 4 finished 2026-10-01** (AND /
OR / NOT / CASE, one truth rule — closing P61 and P62, deciding D4): WHERE
evaluates nothing itself any more. Slice 6 is next.
evaluates nothing itself any more. **Slice 6 (the last) begun 2026-10-03**
with its pin — no engine change.
**The active workstream:** parity fixes that touch expression evaluation land
as slices of this entry, not as patches.
- **Where:** `src/data/arithmetic_evaluator.rs` (`ArithmeticEvaluator`, value →
Expand Down Expand Up @@ -791,6 +792,26 @@ feature work**, and so we can tell the difference between "this is awkward" and
let the loop and the `connector` field go; `in_operator_lifter` and
`subquery_executor` copy conditions one by one and want checking with it.
A natural companion to slice 6.
- **Slice 6, pin (2026-10-03).** No engine change; the parity report moved
by exactly the twelve new cases (217 → 229; 186 → 190 AGREE, the four
guards).
- *HAVING and `IIF` as truth-value sites.* The sixth matrix's table gains
two runs: a `Having` column (each row grouped on its own through
`QueryEngine`, `T` kept / `F` dropped / `E` failed) and `IIF(v, true,
false)` through both evaluators, expectations from DuckDB (`if()` for
`IIF`, which DuckDB lacks). **15 divergences**: 7 HAVING, 8 `IIF` (4 × 2
evaluators). The two copies disagree with D4 *and with each other* — empty
text keeps a group but is FALSE to `IIF`; dates TRUE to HAVING, FALSE to
`IIF` — and both read NaN as FALSE. Corpus: two HAVING cases waiting over
`predicate_text.csv` (P62) and one guard; `IIF` is matrix-only.
- *Joins.* `execute_join` routes a single plain-column equality to the hash
path (INNER, LEFT, RIGHT as a swapped LEFT) and everything else to three
nested-loop builders (`*_multi`). P52 now has a DIFFER case on each of the
six plus the expression form. Probing found **only equality wrong**: `<`,
`<>` and a LEFT join over an inequality already agree — the comparator's
NULL ordering never satisfies them — so those three are AGREE guards for
when the nested loop stops using the bare comparator.
`nested_loop_join_inner` / `nested_loop_join_left` have no callers.
- **Slices, in the R10 pattern — no-op slices kept apart from the one that
changes answers:**
1. ✅ **Pin the divergences, no engine change.** *(Done 2026-09-13.)* A per-operator matrix run through
Expand Down Expand Up @@ -822,7 +843,17 @@ feature work**, and so we can tell the difference between "this is awkward" and
`Trilean::from_value`) and JOIN's bare comparison go through the same
collapse. `WhereClause`'s connector list (above) can go with it. JOIN NULL keys need care on the *hash* path
too, not only the nested loop. [R8](#r8) stage 2 (the legacy WHERE stack) is
a natural lull task alongside.
a natural lull task alongside. In parts:
- 6a ✅ pin (2026-10-03, above).
- 6b HAVING and `IIF` onto `Trilean::from_value` — exactly the 15
matrix entries and the two HAVING corpus cases move.
- 6c P52: the nested loop compares through the predicate layer; the hash
path skips NULL keys on build and probe, LEFT still emitting the
unmatched row. Dead single-condition builders deleted first. Exactly
the seven join cases move.
- 6d `WhereClause`'s connector list → `BinaryOp AND`; the adapter folds
into the row filter and `RecursiveWhereEvaluator` goes. Parity
byte-identical. R13 closes.
- **Explicitly not in scope:** window evaluation (`BatchWindowEvaluator`),
aggregate registries ([R12](#r12)), correlated scoping ([R7](#r7) /
[P3](SQL_PARITY.md#p3), [P49](SQL_PARITY.md#p49)). And [P15](SQL_PARITY.md#p15)
Expand Down Expand Up @@ -926,7 +957,7 @@ R8 legacy WHERE ──── independent; stage 2 is self-contained, do it in a
R10 Trilean ──────── DONE; closed P18/P19 (parity 125 → 129)
R11 ORDER BY resolver ─ independent; small, but a behaviour change — wants its own parity run; now slice 3 of R14
R12 aggregate registries ─ independent; step 1 is a provable no-op, do it before the next aggregate fix
R13 one evaluator ─── ACTIVE from 2026-09-13; slices 1 (pin), 2 (3VL), 3 (construction + case mode) DONE; 4 operators DONE (BETWEEN/IN, comparisons, IS NULL, LIKE); 5 (method calls) DONE; 4 tail (AND/OR/NOT/CASE, D4) DONE 2026-10-01 → 6
R13 one evaluator ─── ACTIVE from 2026-09-13; slices 1 (pin), 2 (3VL), 3 (construction + case mode) DONE; 4 operators DONE (BETWEEN/IN, comparisons, IS NULL, LIKE); 5 (method calls) DONE; 4 tail (AND/OR/NOT/CASE, D4) DONE 2026-10-01 → 6 (6a pin DONE 2026-10-03 → 6b HAVING/IIF → 6c P52 joins → 6d retire WHERE evaluator)
R14 one column resolver ─ after R13; slice 1 (pin) any time → 2 (stop flattening ColumnRef; P58 join) → 3 (converge copies, absorbs R11) → 4 (P49/P60 strictness) ──→ feeds P3 scope spine
```

Expand Down Expand Up @@ -974,3 +1005,4 @@ AGREE count — which makes it safe to land well before the semantics change.
| 2026-09-26 | R13 slice 5: method calls. Pinned 55 divergences across both evaluators; P56 (NULL receiver → FALSE) and P57 (WHERE refused all but five methods on a comparison's left) filed and closed; D3 decided (search methods follow `--case-insensitive`). Registry search functions fixed first (180), then WHERE delegated and ~700 lines of its arms and readers deleted — 179 → **183 AGREE** / 213 | — |
| 2026-10-01 | R14 filed from P58 (`alias."quoted col"`): parser fix landed on a branch and worked in every clause but JOIN ON, whose resolver strips at the last dot. Five resolver copies inventoried; P59 (quoted alias) and P60 (GROUP BY unknown column → NULL group) filed | — |
| 2026-10-01 | R13 slice 4 finished: AND / OR / NOT / CASE. Pinned 19 divergences (sixth matrix, values as truth values); P61 (WHERE CASE without ELSE → FALSE) and P62 (five truth tables; `'f'` read as TRUE) filed and closed; D4 decided (DuckDB's cast to boolean). `Trilean::from_value` the one rule; WHERE evaluates nothing itself — 183 → **186 AGREE** / 217 | — |
| 2026-10-03 | R13 slice 6 pin: HAVING (new matrix column) and `IIF` as truth-value sites — 15 divergences, the two copies disagreeing with each other and reading NaN as FALSE; P52 cased on all six live join paths plus expressions, only equality wrong, three inequality guards AGREE. No engine change — 217 → 229 cases, 186 → 190 AGREE (the guards) | — |
45 changes: 39 additions & 6 deletions docs/SQL_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,11 @@ session apiece to fix properly, so **discovery is paused and the effort moves to
picking them off**. Widen the corpus again when the open list is short, or
opportunistically when a fix needs a case that doesn't exist yet.

Corpus coverage today: tiers 01–10, **217 cases** (186 AGREE / 15 DIFFER /
12 GAP / 1 OURS_ONLY / 3 BOTH_ERR as of 2026-10-01, after R13 slice 4 closed
[P61](#p61) and [P62](#p62); [P51](#p51), [P52](#p52) open, [P53](#p53) open
at low priority). The largest single movement so far remains the 2026-09-05
Corpus coverage today: tiers 01–10, **229 cases** (190 AGREE / 22 DIFFER /
12 GAP / 2 OURS_ONLY / 3 BOTH_ERR as of 2026-10-03, after R13 slice 6's pin
added P52's join paths and P62's HAVING copy — four guards AGREE, eight cases
await slice 6; [P51](#p51), [P52](#p52) open, [P53](#p53) open at low
priority). The largest single movement so far remains the 2026-09-05
NULL-ordering slice, which closed [P13](#p13) stage 2 and [P17](#p17) together —
eleven cases in one change. **Tier 10 (aggregate & NULL edges) is still
deliberately partial** — it holds the P14, P18–P20 and P41 cases and their
Expand Down Expand Up @@ -2438,7 +2439,25 @@ out of date.
- **Status:** 🔴 OPEN — silent, too many rows. [R13](ENGINE_REFACTORING.md#r13) slice 6.
- **Corpus:** `10_aggregate_nulls.toml :: join_on_null_key_both_sides`
(`expect = "DIFFER"`). Its neighbour `join_on_null_key` AGREEs and could not
see this: it has NULL on only one side.
see this: it has NULL on only one side. **Every live join path pinned
2026-10-03** (R13 slice 6), each `expect = "DIFFER"`:

| Case | Path | DuckDB | Ours |
|---|---|---|---|
| `join_on_null_key_both_sides` | hash INNER | 7 | 32 |
| `left_join_on_null_key_both_sides` | hash LEFT | 12 | 32 |
| `right_join_on_null_key_both_sides` | hash LEFT, tables swapped | 12 | 32 |
| `join_on_null_keys_two_conditions` | nested-loop INNER (multi) | 5 | 14 |
| `left_join_on_null_keys_two_conditions` | nested-loop LEFT (multi) | 12 | 12, NULL-label rows matched |
| `right_join_on_null_keys_two_conditions` | nested-loop RIGHT (multi) | 12 | 12, likewise |
| `join_on_null_key_expression` | nested loop over expressions | 7 | 32 |

**Only equality is wrong.** Three guards AGREE already —
`join_on_null_score_less_than`, `join_on_null_team_not_equal`,
`left_join_on_null_score_inequality` — because the comparator's ordering and
`<>` answers with a NULL side are never true; they are there to hold when the
nested loop's comparator changes. `nested_loop_join_inner` and
`nested_loop_join_left` (the single-condition forms) have no callers.
- **Observed:** `null_edges a JOIN null_edges b ON a.label = b.label` returns
**32** rows; DuckDB **7**. Seven distinct non-NULL labels match themselves;
the five NULL-label rows then pair with each other, 5 × 5 = 25.
Expand Down Expand Up @@ -2676,11 +2695,25 @@ out of date.
- **Status:** ✅ FIXED 2026-10-01 by [R13](ENGINE_REFACTORING.md#r13) slice 4
under [D4](#d4): `Trilean::from_value` is the one rule, and three of the
five copies below are gone (183 → 185 AGREE). HAVING's `is_truthy` and
`IIF` still carry their own tables — slice 6.
`IIF` still carry their own tables — slice 6, **pinned 2026-10-03**: 7
HAVING and 8 `IIF` divergences recorded in the matrix, two corpus cases
waiting (below).
- **Corpus:** `02_where.toml :: where_text_flag_as_predicate`,
`where_not_text_flag` (DIFFER → AGREE), `where_text_not_a_boolean`
(OURS_ONLY → `expect = "BOTH_ERR"`), over new `data/predicate_text.csv`. Evaluator
matrix: `TEXT_TRUTHY` / `TEXT_REFUSED` / `DATE_TRUTHY` entries.
Slice 6 (the two remaining copies): `07_grouping.toml ::
having_text_flag_as_condition` (`expect = "DIFFER"` — `'f'`, `'N'`, `'no'`
keep their groups), `having_text_not_a_boolean` (`expect = "OURS_ONLY"`, to
become BOTH_ERR) and the guard `having_not_text_flag`. Matrix:
`EXPECTED_TRUTH_HAVING` / `KNOWN_TRUTH_HAVING` (a new `Having` column — each
row a group of its own through `QueryEngine`) and `EXPECTED_TRUTH_IIF` /
`KNOWN_TRUTH_IIF`. **`IIF` has no corpus case:** DuckDB has no `IIF`; its
expectations were generated with `if()`, which takes the same arguments.
The pin also showed the two copies disagree with *each other* — empty text
keeps a HAVING group but is FALSE to `IIF`; a date is TRUE to one and FALSE
to the other — and that both read NaN as FALSE, where D4 reads it as
non-zero.
- **Observed:** `WHERE flag` over `yes`, `no`, `true`, `f`, `N` returns every
row with a value — `'no'` and `'f'` are TRUE because they are not empty.
DuckDB reads boolean text (`yes`/`true` TRUE, `no`/`f`/`N` FALSE) and raises
Expand Down
27 changes: 27 additions & 0 deletions tests/comparison/corpus/07_grouping.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,30 @@ sql = "SELECT team, COUNT(*) AS n FROM null_edges GROUP BY team HAVING NOT (MIN(
# beta, gamma and the NULL team, and drops delta. HAVING runs on the value
# evaluator, which answers the comparison false, negates it to TRUE, and admits
# delta. The same predicate in WHERE is right - see tests/evaluator_matrix_tests.rs.

# --- P62's HAVING copy: a value used as the HAVING condition (R13 slice 6) ---

[[case]]
id = "having_text_flag_as_condition"
data = "predicate_text.csv"
sql = "SELECT flag FROM predicate_text GROUP BY flag HAVING flag ORDER BY flag"
expect = "DIFFER"
# P62, pinned 2026-10-03 for R13 slice 6. Text used as a truth value is read as
# a boolean (D4): DuckDB keeps 'true' and 'yes'. HAVING collapses the value with
# its own is_truthy, which reads any text as TRUE, so 'f', 'N' and 'no' keep
# their groups too. The same predicate in WHERE is right (where_text_flag_as_predicate).

[[case]]
id = "having_not_text_flag"
data = "predicate_text.csv"
sql = "SELECT flag FROM predicate_text GROUP BY flag HAVING NOT flag ORDER BY flag"
# A guard, not a gap: NOT converts the text itself (Trilean::from_value), so
# is_truthy only ever sees a boolean or NULL here and the answer is right.

[[case]]
id = "having_text_not_a_boolean"
data = "predicate_text.csv"
sql = "SELECT word FROM predicate_text GROUP BY word HAVING word ORDER BY word"
expect = "OURS_ONLY"
# P62 / D4. Text that does not read as a boolean is an error in DuckDB, and in
# our WHERE; HAVING's is_truthy keeps every non-NULL group instead.
72 changes: 72 additions & 0 deletions tests/comparison/corpus/10_aggregate_nulls.toml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,78 @@ expect = "DIFFER"
# key, so a NULL probe finds the NULL build rows; the nested-loop path uses
# compare_with_op, which also reports NULL = NULL as equal. R13 slice 6.

# --- P52 on every live join path (pinned 2026-10-03, R13 slice 6) ---
# The case above is the hash INNER path. A single equality between two plain
# columns takes the hash path for INNER, LEFT and RIGHT; anything else - a
# second condition, an expression, an inequality - takes a nested loop, with
# separate INNER, LEFT and RIGHT builders. Only equality is wrong: the
# inequality cases at the end AGREE and guard the nested loop when its
# comparator changes.

[[case]]
id = "left_join_on_null_key_both_sides"
data = "null_edges.csv"
sql = "SELECT a.id AS aid, b.id AS bid FROM null_edges a LEFT JOIN null_edges b ON a.label = b.label ORDER BY a.id, b.id"
expect = "DIFFER"
# P52, hash LEFT path. DuckDB 12 rows: 7 matches plus the 5 NULL-label rows
# unmatched, b NULL. We return 32 - the NULL labels pair 5 x 5.

[[case]]
id = "right_join_on_null_key_both_sides"
data = "null_edges.csv"
sql = "SELECT a.id AS aid, b.id AS bid FROM null_edges a RIGHT JOIN null_edges b ON a.label = b.label ORDER BY b.id, a.id"
expect = "DIFFER"
# P52, hash path via the swapped LEFT join. DuckDB 12 rows, we return 32.

[[case]]
id = "join_on_null_keys_two_conditions"
data = "null_edges.csv"
sql = "SELECT a.id AS aid, b.id AS bid FROM null_edges a JOIN null_edges b ON a.label = b.label AND a.team = b.team ORDER BY a.id, b.id"
expect = "DIFFER"
# P52, nested-loop INNER path (two conditions). DuckDB 5 rows, we return 14.

[[case]]
id = "left_join_on_null_keys_two_conditions"
data = "null_edges.csv"
sql = "SELECT a.id AS aid, b.id AS bid FROM null_edges a LEFT JOIN null_edges b ON a.label = b.label AND a.id = b.id ORDER BY a.id, b.id"
expect = "DIFFER"
# P52, nested-loop LEFT path. 12 rows both ways, but for the NULL-label rows
# DuckDB leaves b NULL and we fill it with the row itself.

[[case]]
id = "right_join_on_null_keys_two_conditions"
data = "null_edges.csv"
sql = "SELECT a.id AS aid, b.id AS bid FROM null_edges a RIGHT JOIN null_edges b ON a.label = b.label AND a.id = b.id ORDER BY b.id, a.id"
expect = "DIFFER"
# P52, nested-loop RIGHT builder; the mirror of the LEFT case.

[[case]]
id = "join_on_null_key_expression"
data = "null_edges.csv"
sql = "SELECT a.id AS aid, b.id AS bid FROM null_edges a JOIN null_edges b ON UPPER(a.label) = UPPER(b.label) ORDER BY a.id, b.id"
expect = "DIFFER"
# P52, nested loop over evaluated expressions. UPPER(NULL) is NULL, which then
# meets the same comparator. DuckDB 7 rows, we return 32.

[[case]]
id = "join_on_null_score_less_than"
data = "null_edges.csv"
sql = "SELECT a.id AS aid, b.id AS bid FROM null_edges a JOIN null_edges b ON a.score < b.score ORDER BY a.id, b.id"
# Guard: an ordering comparison with a NULL side is already never true.

[[case]]
id = "join_on_null_team_not_equal"
data = "null_edges.csv"
sql = "SELECT a.id AS aid, b.id AS bid FROM null_edges a JOIN null_edges b ON a.team <> b.team ORDER BY a.id, b.id"
# Guard: NULL <> x is already never true, so the NULL-team rows join nothing.

[[case]]
id = "left_join_on_null_score_inequality"
data = "null_edges.csv"
sql = "SELECT a.id AS aid, b.id AS bid FROM null_edges a LEFT JOIN null_edges b ON a.score > b.score + 50 ORDER BY a.id, b.id"
# Guard: the nested-loop LEFT path over an expression with NULLs; unmatched
# rows (including every NULL score) still come back once with b NULL.

[[case]]
id = "null_arithmetic"
data = "null_edges.csv"
Expand Down
Loading
Loading