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
53 changes: 49 additions & 4 deletions docs/ENGINE_REFACTORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,8 +475,9 @@ feature work**, and so we can tell the difference between "this is awkward" and
**slices 1 and 2 done 2026-09-13, slice 3 (with 3b) done 2026-09-14**, slice 4
in progress (`BETWEEN`, `IN`, `NOT IN` delegated 2026-09-15; comparisons
2026-09-18, closing P54; IS NULL and LIKE 2026-09-19, closing P55). Every
WHERE *operator* now delegates; what remains of slice 4 (AND / OR / NOT /
CASE) lands after slice 5.
WHERE *operator* now delegates. **Slice 5 (method calls) done 2026-09-26**,
closing P56 and P57 and deciding D3. What remains of slice 4 (AND / OR /
NOT / CASE) is next, then slice 6.
**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 @@ -704,6 +705,49 @@ feature work**, and so we can tell the difference between "this is awkward" and
left-side method-call arm, all waiting on slice 5. WHERE's readers
(`number_literal_value`, the `DATETIME` arms, `compare_trilean`) are now
reached only through that arm.
- **Slice 5 (2026-09-26) — method calls.** Five commits; one user decision.
- *Pin.* Three matrices (`EXPECTED_METHODS`, `…_CASE_INSENSITIVE`,
`EXPECTED_NUMBER_METHODS`; DuckDB's `contains` / `starts_with` /
`ends_with` / `length` / `instr` / `trim` family as the reference, with
`IndexOf` as `instr − 1`), then the function forms and the methods WHERE
refused. **55 divergences, both evaluators**: a NULL receiver answered
FALSE (**P56**) — so `WHERE NOT label.contains('e')` selected every NULL
label; WHERE's search methods always ignored case while the registry's
ignored the switch; the registry rejected a number; and WHERE accepted
only `Length` / `IndexOf` / `Trim*` on the left of a comparison, failing
`WHERE s.ToUpper() = 'ABC'` (**P57**). DuckDB's dot-call syntax
(`label.contains('e')`) made four of them real corpus cases.
- *D3, decided by the user:* the search methods and functions follow
`--case-insensitive` like `=` and LIKE, case-sensitive by default — "one
rule tied to the switch rather than decisions made at different times",
accepting that queries relying on WHERE's old behaviour change answer.
- *Registry first.* `SqlFunction::evaluate_with_case` (default: ignore the
mode) carries the switch into a function; `MethodFunction::evaluate_method`
takes it too; the value evaluator's three registry call sites pass it. The
five search functions (and LENGTH / TRIM*) read operands through one
`search_text`: NULL → NULL, a number as its displayed text (D2), lowercased
under the switch. Exactly the 27 value-evaluator and function-form entries
went FIXED; 179 → 180 AGREE.
- *Delegate.* WHERE's method-call predicate and its comparison arm delegate
unconditionally; `evaluate_binary_op`'s legacy tail, the method arms, and
everything reachable only from them went — WHERE's own column resolver,
literal readers, `compare_trilean` and "did you mean" suggester (~700
lines). The remaining 28 WHERE entries went FIXED; parity moved by exactly
the three WHERE cases, 180 → **183 AGREE** / 213.
- *What the pin missed.* A numeric `Trim()` receiver: WHERE trimmed its text,
the registry's TRIM errored, and the `presidents` FORMAL example failed on
`r.Greatness.Trim() <> 'NA'`. Fixed by the same reader and pinned. The
matrix covers what someone thought to put in it; the examples are a
second net.
- *Two tests stated the old rule* (`test_case_sensitivity`, and two Python
tests whose docstrings said "appears to be case-insensitive based on
logs"); rewritten to assert D3 in both modes. No captured output changed.
- *Cost:* nil — `Contains`, `Length() >`, `NOT StartsWith` over
`trades_100000.csv` all run at load time (~2.5 s), level with LIKE.
- *Left:* the functions that match text while transforming it (REPLACE,
FREQUENCY, SUBSTRING_BEFORE/AFTER, SPLIT_PART, LEFT/RIGHT with a
delimiter) do not yet follow the switch — listed under D3. LENGTH /
INDEXOF / INSTR count bytes, not characters (unpinned).
- **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 All @@ -727,7 +771,7 @@ feature work**, and so we can tell the difference between "this is awkward" and
the hand-written arm is deleted. After slice 2 the rules already agree, so
each commit is a provable no-op — parity exactly unchanged, and the slice-1
matrix still green.
5. **Method calls:** WHERE's hand-rolled `Contains`/`StartsWith`/`Trim`… move
5. ✅ **Method calls:** *(Done 2026-09-26 — see result above.)* WHERE's hand-rolled `Contains`/`StartsWith`/`Trim`… move
onto the registry mapping the value evaluator already uses.
6. **Collapse sites converge:** HAVING's `is_truthy` and JOIN's bare comparison
go through the same collapse. JOIN NULL keys need care on the *hash* path
Expand Down Expand Up @@ -769,7 +813,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
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) → 4 tail (AND/OR/NOT/CASE) → 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) → 6
```

**A note on ordering, from the P18/P19 work being next.** The WHERE evaluator
Expand Down Expand Up @@ -813,3 +857,4 @@ AGREE count — which makes it safe to land well before the semantics change.
| 2026-09-15 | R13 slice 4 (first part): WHERE's `BETWEEN`, `IN`, `NOT IN` delegate to the value evaluator. Latent alias-resolution fault pinned and fixed first; per-node TRACE logging found to be most of the value evaluator's cost and removed (SELECT/GROUP BY expressions ~2 s faster over 100k rows). Parity byte-identical at every commit | — |
| 2026-09-18 | R13 slice 4 (second part): WHERE comparisons delegate. Operand readers pinned first (third matrix); P53 and P54 filed; value evaluator's `DATETIME()` moved to local time before delegating. Closes P54 — 175 → **176 AGREE**. Part 1 found to have fixed P54 for IN/BETWEEN unnoticed | — |
| 2026-09-19 | R13 slice 4 (third part): IS NULL and LIKE delegate; one `sql_like` for both evaluators. P55 (LIKE compiled as an unescaped regex) filed and closed — 176 → **179 AGREE**; the value evaluator's exponential LIKE matcher replaced first; D2 (LIKE over a number matches its displayed text) decided; `EvaluationContext` removed | — |
| 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 | — |
75 changes: 75 additions & 0 deletions docs/SQL_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2542,6 +2542,43 @@ out of date.
`'price (usd)%'`.
- **Found:** 2026-09-19, pinning LIKE for R13 slice 4.

### P56 — A method call over a NULL receiver answers FALSE, not NULL
- **Status:** ✅ FIXED 2026-09-26 by [R13](ENGINE_REFACTORING.md#r13) slice 5,
in two steps: the registry's search functions read NULL as NULL
(179 → 180 AGREE), then WHERE delegated its method calls to them and its own
arms were deleted (with P57 and D3, 180 → **183 AGREE**).
- **Corpus:** `02_where.toml :: where_not_method_null_receiver`,
`select_method_null_receiver` (both AGREE). The methods DuckDB
has no twin for are pinned in the evaluator matrix (`EXPECTED_METHODS`).
- **Observed:** `WHERE NOT label.contains('e')` returns every NULL label along
with the labels lacking an `e`; `SELECT label.contains('e')` shows `false` for
a NULL label. In WHERE, `s.Length()`, `s.IndexOf(..)` and `s.Trim*()` on the
left of a comparison also give a non-NULL value for a NULL `s`:
`WHERE s.IndexOf('z') = -1` selects the NULL rows.
- **Cause:** both evaluators. WHERE's hand-rolled `Contains` / `StartsWith` /
`EndsWith` return `Trilean::False` for anything not a value, and its
`evaluate_length` / `evaluate_indexof` / `evaluate_trim` read NULL as empty
text. The registry's `CONTAINS` / `STARTSWITH` / `ENDSWITH` return
`Boolean(false)` for a NULL argument.
- **Why it matters:** the same NULL rule as [P18](#p18) — a function of NULL is
NULL, so `NOT` over it does not select the row.
- **Found:** 2026-09-26, pinning method calls for R13 slice 5.

### P57 — Only five methods may sit on the left of a WHERE comparison
- **Status:** ✅ FIXED 2026-09-26 by [R13](ENGINE_REFACTORING.md#r13) slice 5:
WHERE's comparisons delegate whatever the left operand is, so any registry
method works there.
- **Corpus:** `02_where.toml :: where_method_other_than_legacy_on_left`
(AGREE); matrix rows `s.ToUpper() = 'ABC'`,
`s.Replace('a', 'z') = 'zbc'`.
- **Observed:** `WHERE label.upper() = 'ECHO'` fails the query — "Method
'upper' cannot be used in comparisons". The same expression works in SELECT,
and chained forms such as `s.Trim().Length() = 2` already work in WHERE.
- **Cause:** a method call on the left of a comparison is the one shape WHERE
did not delegate to the value evaluator; its own arm knows `Length`,
`IndexOf`, `Trim`, `TrimStart` and `TrimEnd` and rejects everything else.
- **Found:** 2026-09-26, pinning method calls for R13 slice 5.

---

## Deferred / won't fix (intentional)
Expand Down Expand Up @@ -2575,6 +2612,44 @@ out of date.
- **Considered:** erroring like DuckDB (strict, but breaks existing SELECTs that
rely on it), and FALSE everywhere (a silent non-match is the least useful
answer).
- **Extended 2026-09-26 (R13 slice 5)** to the string methods: `n.Contains('5')`,
`n.StartsWith('1')` and `n.Length()` read a number as its displayed text.
WHERE already did; the registry functions behind the value evaluator errored.
Pinned in `EXPECTED_NUMBER_METHODS`. `Trim()` / `TrimStart()` / `TrimEnd()`
likewise: WHERE trimmed a number's text, and once WHERE delegated, the
registry's TRIM rejecting a number failed the `presidents` example
(`r.Greatness.Trim() <> 'NA'` over a mostly numeric column) — fixed the same
way and pinned (`n.Trim() = '7.5'`).

### D3 — String methods follow `--case-insensitive`, like every comparison
- **Status:** ⚪ DELIBERATE (decided 2026-09-26, R13 slice 5).
- **Corpus:** `02_where.toml :: where_method_contains_case_sensitive` (AGREE
since 2026-09-26). The switch itself has no DuckDB
counterpart; its expected answers are DuckDB's over `lower(s)`, in
`EXPECTED_METHODS_CASE_INSENSITIVE`.
- **Behaviour:** `.Contains()`, `.StartsWith()`, `.EndsWith()` and `.IndexOf()`
— and their function forms `CONTAINS`, `STARTSWITH`, `ENDSWITH`, `INDEXOF`,
`INSTR` — search case-sensitively by default and case-insensitively under
`--case-insensitive`: the same rule as `=`, `IN` and `LIKE`, in every clause.
The mode reaches a function through `SqlFunction::evaluate_with_case`; a
function that compares text overrides it, every other one ignores it.
- **Still to follow the switch:** the functions that *match* text while
transforming it — `REPLACE`, `FREQUENCY`, `SUBSTRING_BEFORE` /
`SUBSTRING_AFTER`, `SPLIT_PART`, `LEFT` / `RIGHT` with a delimiter. Each to be
pinned then moved onto `evaluate_with_case` (user decision 2026-09-26: the
switch applies across the whole range, even where captured smoke output has
to be regenerated).
- **Before:** WHERE's own versions always ignored case (an early TUI choice),
while the registry functions the same methods mean in SELECT never did, even
under the switch. So `'ABC'.Contains('b')` was TRUE in WHERE and FALSE in
SELECT.
- **Rationale:** one rule, tied to the one switch, rather than per-method
decisions made at different times. Queries that relied on WHERE's
`.Contains()` ignoring case change answer; they get it back with
`--case-insensitive`. Consistent with DuckDB's `contains`, which is
case-sensitive.
- **Considered:** keeping WHERE's behaviour and making the registry match it —
rejected: it makes one family of predicates disagree with `=` and LIKE.

_When we consciously diverge from the reference engine on results (rather than
simply not implementing a feature), record it here with the rationale so the
Expand Down
6 changes: 3 additions & 3 deletions src/data/arithmetic_evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -753,7 +753,7 @@ impl<'a> ArithmeticEvaluator<'a> {

// Get the function and call it
let func = self.function_registry.get(name).unwrap();
return func.evaluate(&evaluated_args);
return func.evaluate_with_case(&evaluated_args, self.case_insensitive);
}

// If not in registry, return error for unknown function
Expand Down Expand Up @@ -1297,7 +1297,7 @@ impl<'a> ArithmeticEvaluator<'a> {
for arg in args {
method_args.push(self.evaluate(arg, row_index)?);
}
return method_fn.evaluate_method(value, &method_args);
return method_fn.evaluate_method(value, &method_args, self.case_insensitive);
}

// Otherwise, proxy the method through the function registry.
Expand Down Expand Up @@ -1328,7 +1328,7 @@ impl<'a> ArithmeticEvaluator<'a> {

// Get the function and call it
let func = self.function_registry.get(function_name).unwrap();
return func.evaluate(&func_args);
return func.evaluate_with_case(&func_args, self.case_insensitive);
}

// If not in registry, the method is not supported
Expand Down
7 changes: 4 additions & 3 deletions src/data/query_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3474,11 +3474,12 @@ mod tests {
println!("Row {i}: status = {status:?}");
}

// Test 1: Basic string contains (should work)
println!("\n--- Test 1: status.Contains('pend') ---");
// Test 1: Basic string contains (should work). Case-sensitive outside
// --case-insensitive (D3), so the search is spelled as the data is.
println!("\n--- Test 1: status.Contains('Pend') ---");
let result = engine.execute(
table.clone(),
"SELECT * FROM test WHERE status.Contains('pend')",
"SELECT * FROM test WHERE status.Contains('Pend')",
);
match result {
Ok(view) => {
Expand Down
Loading
Loading