Skip to content
Merged
7 changes: 7 additions & 0 deletions data/pattern_matching.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
id,s
1,abc
2,a.c
3,a(bc
4,ABC
5,[a]bc
6,a.b.c
51 changes: 41 additions & 10 deletions docs/ENGINE_REFACTORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,9 @@ feature work**, and so we can tell the difference between "this is awkward" and
- **Status:** 🟡 IN PROGRESS — filed 2026-09-13 out of [P46](SQL_PARITY.md#p46);
**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).
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.
**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 @@ -666,14 +668,42 @@ feature work**, and so we can tell the difference between "this is awkward" and
parity moved by exactly one case (P54, 175 → **176 AGREE** / 206).
`evaluate_binary_op` now gets the node itself, so neither the delegation nor
the P37 value-as-predicate branch clones it per row.
- *Remaining in slice 4:* `LIKE` (WHERE compiles through `EvaluationContext`'s
regex cache; the value evaluator has its own matcher, and they disagree on a
non-string operand — `5.0 LIKE '5%'` is FALSE in WHERE, TRUE in the value
evaluator, and an error in DuckDB), `IS NULL` / `IS NOT NULL` (small), and
then `AND` / `OR` / `NOT` / `CASE`, which wait on slice 5 for their
method-call children. WHERE's own readers (`number_literal_value`, the
`DATETIME` arms) are now only reached through a legacy left-side method
call or LIKE.
- *Left after part 2:* LIKE and IS NULL (part 3, below), then `AND` / `OR` /
`NOT` / `CASE`, which wait on slice 5 for their method-call children.
- **Slice 4, third part (2026-09-19) — IS NULL and LIKE.** Six commits:
- *IS NULL / IS NOT NULL delegate* — a true no-op: the parser always puts a
`Null` literal on the right, and no reader difference changes whether the
left operand is NULL. WHERE's bare `IS` / `IS NOT` arms and `==` went with
it: the parser never emits them.
- *Pin LIKE.* A fourth matrix (`EXPECTED_LIKE`, DuckDB-generated) aimed at
what separated the two matchers. WHERE built its matcher by replacing `%`
and `_` in the pattern and escaping nothing else, so it got six of ten
wrong — **P55**: `LIKE 'a.c'` matched `abc`, `'[a]bc'` was a character
class, `'a(b%'` failed the whole query, and `%` stopped at a newline. Three
corpus cases over new `data/pattern_matching.csv`.
- *The value evaluator's matcher was exponential.* Right on all ten patterns,
but recursive over every split for every `%`: one 80-character value
against `'%a%a%a%a%a%a%a%b'` ran for over a minute (SELECT has had this all
along). Replaced with the iterative matcher — backtrack only to the most
recent `%`, O(text × pattern) — **before** WHERE delegated to it, so the fix
did not swap wrong rows for a hang. 28 unit cases plus a 2,000-character
pathological one on a 1 s budget.
- *LIKE over a number — a decision, D2.* DuckDB rejects it, so there is no
reference answer. Chosen: match the number's displayed text, as the value
evaluator already did (`5.0` displays as `5`). Pinned as a fifth matrix set
whose expected answers are the decision, not DuckDB output; WHERE's
"always FALSE" recorded against it.
- *Delegate.* All of LIKE is now one function, `sql_like`, called by the
value evaluator and by WHERE's one remaining legacy arm. Exactly the ten
WHERE pins flipped; parity moved by exactly the three P55 cases,
176 → **179 AGREE** / 209. Flat on the benchmarks without the regex cache.
- *Plumbing removed.* `EvaluationContext` (the regex cache), WHERE's
`'ctx` lifetime and context constructors, and `with_config` (no callers) —
147 lines out, parity byte-identical.
- *Left in slice 4:* `AND` / `OR` / `NOT` / `CASE` and WHERE's legacy
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.
- **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 @@ -739,7 +769,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 IN PROGRESS (BETWEEN/IN, comparisons done; LIKE, IS NULL next) → 5–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) → 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 @@ -782,3 +812,4 @@ AGREE count — which makes it safe to land well before the semantics change.
| 2026-09-14 | R13 slice 3: registries built once per process; one `ArithmeticEvaluator` constructor, dead date-notation plumbing deleted — parity byte-identical, high-cardinality GROUP BY ~2× faster. 3b: case-insensitive mode pinned (11 matrix + 5 clause cases) then handed in at every construction site; all flipped, parity byte-identical | — |
| 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 | — |
61 changes: 56 additions & 5 deletions docs/SQL_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,10 @@ 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, **206 cases** (176 AGREE / 15 DIFFER /
12 GAP / 1 OURS_ONLY / 2 BOTH_ERR as of 2026-09-18, after R13 slice 4's
comparison delegation closed [P54](#p54); [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, **209 cases** (179 AGREE / 15 DIFFER /
12 GAP / 1 OURS_ONLY / 2 BOTH_ERR as of 2026-09-19, after R13 slice 4 closed
[P54](#p54) and [P55](#p55); [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 All @@ -93,7 +93,7 @@ Suggested fix order, by silent blast radius:
| ~~9d~~ | ~~[P37](#p37) window in `WHERE` returns 0 rows~~ | ✅ **Fixed 2026-09-05** — corpus count unchanged, and that is the finding: the case is `OURS_ONLY` before *and* after, so the harness cannot see this fix or a future regression of it (first entry of that kind — the regression test is a Rust module). The filed root cause was wrong: `ExpressionLifter` *does* lift from `WHERE`. The real defect was one arm in the WHERE evaluator answering FALSE for any bare value used as a predicate — `WHERE true` returned zero rows too. It did **not** close [P15](#p15), which needs the opposite change |
| ~~9e~~ | ~~[P41](#p41) `MODE` tie-break is random per run~~ | ✅ **Fixed 2026-09-06** — 152 → **156 AGREE** (four new cases). Small, as predicted, but not where it was filed: the named `ModeState` was a *shadowed* implementation and fixing it moved nothing. Reference does specify a rule and it is **first-occurrence**, not the "smallest value wins" this row proposed. Unblocked both example files, now FORMAL. Spun off [P42](#p42), [P43](#p43), [R12](ENGINE_REFACTORING.md#r12) |
| ~~9g~~ | ~~[P46](#p46) column-vs-column `WHERE` returns 0 rows~~ | ✅ **Fixed 2026-09-13** — 157 → **168 AGREE**. Wider than filed: *every* non-literal right-hand operand read as NULL (comparisons, `IN` items, `BETWEEN` bounds), and a literal on the left errored (`WHERE 1=0`). One resolver for all operands closed it. Did **not** need [P48](#p48) alongside, as this row predicted — the fix kept the comparison in the WHERE evaluator. Spun off [P49](#p49) (correlated outer refs bind to the inner table), accepted knowingly |
| **NEXT** | [R13](ENGINE_REFACTORING.md#r13) one expression evaluator — **the active workstream from 2026-09-13** | **Slices 1–2 done 2026-09-13** (value evaluator three-valued, 168 → 174 AGREE, closed P48/P50); **slice 3 done 2026-09-14** (one construction path; case-insensitive mode now honoured outside WHERE, parity unmoved); **slice 4 in progress** (WHERE arms delegate, one operator per commit: BETWEEN/IN 2026-09-15, comparisons 2026-09-18 closing [P54](#p54), 175 → 176 AGREE; LIKE and IS NULL next). Not a finding but the reason several are cheap to fix only once. WHERE and the value evaluator implement every boolean operator separately with different NULL rules, so the same predicate answers differently in WHERE, HAVING, SELECT and JOIN ON. **Working rule:** a parity fix that touches expression evaluation lands as an R13 slice, not a patch. [P52](#p52) (NULL join keys) is slice 6 |
| **NEXT** | [R13](ENGINE_REFACTORING.md#r13) one expression evaluator — **the active workstream from 2026-09-13** | **Slices 1–2 done 2026-09-13** (value evaluator three-valued, 168 → 174 AGREE, closed P48/P50); **slice 3 done 2026-09-14** (one construction path; case-insensitive mode now honoured outside WHERE, parity unmoved); **slice 4 in progress** (WHERE arms delegate, one operator per commit: BETWEEN/IN 2026-09-15, comparisons 2026-09-18 closing [P54](#p54), IS NULL and LIKE 2026-09-19 closing [P55](#p55), 175 → 179 AGREE; every WHERE operator now delegates — AND/OR/NOT/CASE follow slice 5). Not a finding but the reason several are cheap to fix only once. WHERE and the value evaluator implement every boolean operator separately with different NULL rules, so the same predicate answers differently in WHERE, HAVING, SELECT and JOIN ON. **Working rule:** a parity fix that touches expression evaluation lands as an R13 slice, not a patch. [P52](#p52) (NULL join keys) is slice 6 |
| then | [P47](#p47) `MIN`/`MAX` ranked by type | Silent and cheap, and outside the evaluator (aggregate registries — confirm the live one first, [R12](ENGINE_REFACTORING.md#r12)), so it can land alongside R13 without breaking the working rule |
| then | [P14](#p14), [P20](#p20), [P23](#p23) | Smaller, self-contained, decisions already taken. Was row 9b, then NEXT until the 2026-09-12 findings displaced it. **Check each against the R13 working rule first** — P20 (`\|\|` with NULL) is an operator in the value evaluator |
| 9f | [P42](#p42) `MODE` is numeric-only | Companion to [R12](ENGINE_REFACTORING.md#r12), and cheap if taken with it: the shadowed implementation already handles non-numerics and preserves type, so the fix is largely to stop the live path throwing away what it knows. Also buys the corpus its clearest tie-break case |
Expand Down Expand Up @@ -2510,6 +2510,38 @@ out of date.
the next such change is seen.
- **Found:** 2026-09-18, pinning the operand readers for R13 slice 4.

### P55 — A `LIKE` pattern in `WHERE` is read as a regex
- **Status:** ✅ FIXED 2026-09-19 by [R13](ENGINE_REFACTORING.md#r13) slice 4:
WHERE's LIKE delegates to the value evaluator, and both now call one
function, `sql_like`, which treats only `%` and `_` as special. The regex
path is gone. 176 → **179 AGREE**; the six matrix entries went FIXED and
nothing else moved.
- **Corpus:** `02_where.toml :: where_like_dot_is_literal`,
`where_like_bracket_is_literal`, `where_like_paren_is_literal` (all AGREE).
The newline shapes are pinned in the evaluator matrix only (`EXPECTED_LIKE`);
a CSV value with an embedded newline would test the loader as much as LIKE.
- **Found on the way:** the value evaluator's own matcher, correct on all ten
patterns, was exponential — one 80-character value against
`'%a%a%a%a%a%a%a%b'` ran for over a minute. Replaced with an iterative
matcher before WHERE delegated to it, so the fix did not trade wrong rows for
a hang.
- **Observed:** `WHERE s LIKE 'a.c'` returns `abc` as well as `a.c`;
`LIKE '[a]bc'` returns `abc` and misses `[a]bc`; `LIKE 'a(b%'` fails the
query with "regex parse error … unclosed group"; `LIKE '%'` misses a value
containing a newline.
- **Cause:** WHERE builds its matcher by string replacement —
`pattern.replace('%', ".*").replace('_', ".")` — in both
`EvaluationContext::get_or_compile_like_regex` (the cached path every query
takes) and the uncached fallback in `recursive_where_evaluator.rs`. Nothing
else is escaped, so every regex metacharacter (`. [ ] ( ) + * ? ^ $ | \ { }`)
in a pattern is live, and `.` does not match `\n`. The value evaluator's
`sql_like_match` walks characters and treats only `%` and `_` specially — it
gets all ten matrix patterns right.
- **Why it matters:** `.` and `(` are ordinary in the data people filter —
file names (`LIKE '%.csv'` matches `xcsv`), versions, email addresses,
`'price (usd)%'`.
- **Found:** 2026-09-19, pinning LIKE for R13 slice 4.

---

## Deferred / won't fix (intentional)
Expand All @@ -2525,6 +2557,25 @@ out of date.
for the current "vanilla SQL consistency" effort. Revisit if/when that scope
layer is pursued. Not a bug; do not let the corpus case churn — keep `expect = "GAP"`.

### D2 — `LIKE` over a number matches the number's displayed text
- **Status:** ⚪ DELIBERATE (decided 2026-09-19, R13 slice 4).
- **Corpus:** none — DuckDB errors, so there is no reference answer to agree
with. Pinned in the evaluator matrix (`EXPECTED_NUMBER_LIKE`), whose expected
answers for these rows are this decision, not DuckDB output.
- **Behaviour:** a non-text operand of `LIKE` is matched as the text sql-cli
displays for it: `51.5 LIKE '5%'` is TRUE; `5.0` displays as `5`, so it
matches `'5%'` but not `'5.0'`. DuckDB rejects the query (no
`LIKE(DOUBLE, VARCHAR)`) and asks for an explicit cast.
- **Rationale:** the tool is for exploring data, often loosely typed CSVs where
an id or code column is inferred as a number; "does this id start with
`9007`" should just work. Matching the displayed text means the pattern
matches what the user can see. The value evaluator (SELECT, HAVING, CASE)
already behaved this way; before R13 slice 4, WHERE answered FALSE for every
number, so the same predicate disagreed with itself between clauses.
- **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).

_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
DIFFER is understood, not mistaken for a bug._
Expand Down
Loading
Loading