From 0040d0cf3218073ed50cb08b672d53fddf571b59 Mon Sep 17 00:00:00 2001 From: TimelordUK Date: Sun, 2 Aug 2026 09:18:17 +0100 Subject: [PATCH 1/6] test(parity): add corpus tiers 08-10; file P13-P15 The P-log was exhausted apart from P3, so new findings now come from widening the corpus rather than working the backlog. Three tiers seeded with the three divergences found while planning, each paired with the control cases that locate the defect rather than merely observing it. 83 -> 96 cases. P13 - trailing unparsed tokens are silently discarded. Filed from `ORDER BY ... NULLS LAST LIMIT 3` returning all 20 rows, but the root cause is much broader: there is no NULLS handling in src/sql/ at all, and the parser silently ignores everything after the first token it cannot place. A nonsense token proves it - `... ORDER BY amount DESC FROBNICATE LIMIT 3` runs clean and drops the LIMIT. Any typo, or any clause we don't support, degrades into a different query that succeeds. Pinned directly as an OURS_ONLY case, since the reference engine correctly rejects it. P14 - an ungrouped aggregate over an empty set returns no row where standard SQL returns exactly one (COUNT -> 0, others -> NULL). The grouped form is already correct, and a control case pins that, so the fix stays narrow. P15 - QUALIFY rejects an inline window function. Controls show QUALIFY works fine against a SELECT-list alias and the same window expression evaluates fine in the SELECT list, which locates the defect in ExpressionLifter - it walks the SELECT list only, so an inline window fn in QUALIFY is never hoisted and reaches the WHERE evaluator raw. Fix site is expression_lifter, not qualify_to_where_transformer, despite the name. Also records the expected corpus churn for P13: fixing the parser moves the NULLS cases DIFFER -> GAP, not to AGREE, since NULLS ordering is a separate missing feature. A hard error is the correct intermediate state. R-log updated: expression_lifter now has a parity case behind it, the two transformers migrated since the table was written are marked done, and P15 is added to R3's list of confirmed live bugs from unvisited branches. Parity contract holds at 96 cases. Co-Authored-By: Claude Opus 5 --- docs/ENGINE_REFACTORING.md | 15 +-- docs/SQL_PARITY.md | 91 +++++++++++++++++++ tests/comparison/corpus/08_ordering.toml | 55 +++++++++++ tests/comparison/corpus/09_window.toml | 41 +++++++++ .../comparison/corpus/10_aggregate_nulls.toml | 44 +++++++++ 5 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 tests/comparison/corpus/08_ordering.toml create mode 100644 tests/comparison/corpus/09_window.toml create mode 100644 tests/comparison/corpus/10_aggregate_nulls.toml diff --git a/docs/ENGINE_REFACTORING.md b/docs/ENGINE_REFACTORING.md index c9119f39..cff7f6ce 100644 --- a/docs/ENGINE_REFACTORING.md +++ b/docs/ENGINE_REFACTORING.md @@ -85,7 +85,7 @@ feature work**, and so we can tell the difference between "this is awkward" and ### R2 — Branch logic over `SqlExpression` is copy-pasted everywhere - **Status:** 🟡 IN PROGRESS — helpers landed (#31), crossing forms made primitive - (#35), 4 of 11 transformers migrated; 7 outstanding + (#35), 5 of 11 transformers migrated; 6 outstanding - **Where:** `src/query_plan/*.rs`, `src/data/*.rs`, `src/analysis/*.rs` - **Observed:** No traversal abstraction existed. **446 `SqlExpression::` patterns across 40 files**, every consumer @@ -129,13 +129,13 @@ feature work**, and so we can tell the difference between "this is awkward" and | Transformer | Patterns | Note | |---|---|---| | ~~`having_alias_transformer`~~ | ~~30~~ | ✅ Done 2026-07-19 — closed [P9](SQL_PARITY.md). | - | `where_alias_expander` | 60 | Largest. Outer-alias scoping is correct only *by omission* — the opaque `map_children` is load-bearing here. | - | `group_by_alias_expander` | 34 | Same scoping caveat. | - | `order_by_alias_transformer` | 18 | Same scoping caveat. | - | `expression_lifter` | 35 | | + | ~~`where_alias_expander`~~ | ~~60~~ | ✅ Done 2026-07-25 — closed [P11](SQL_PARITY.md); ~230 hand-rolled lines retired. | + | `expression_lifter` | 35 | **Now has a parity case behind it: [P15](SQL_PARITY.md).** It lifts window functions from the SELECT list only, so an inline window fn in `QUALIFY` is never hoisted and dies in the WHERE evaluator. Migrate *and* extend it to the QUALIFY clause. | + | `group_by_alias_expander` | 34 | Same scoping caveat as the two done. | | `in_operator_lifter` | 21 | Related to [P11](SQL_PARITY.md). | + | `order_by_alias_transformer` | 18 | Same scoping caveat. Tier 08 now exercises it. | | `correlated_subquery_analyzer` | 12 | Touches P3; likely wants the crossing form. | - | `pivot_expander` / `qualify_to_where_transformer` | 10 / 9 | Smallest; good warm-ups. | + | `pivot_expander` / `qualify_to_where_transformer` | 10 / 9 | Smallest; good warm-ups. Note P15 is *not* fixed here despite the name — see `expression_lifter`. | One commit each. **`WindowSpec::order_by` is now descended into**, so every migration needs a per-transformer behaviour check — a blanket "pure refactor" @@ -167,6 +167,7 @@ feature work**, and so we can tell the difference between "this is awkward" and |---|---|---| | [P9](SQL_PARITY.md) | `HAVING` with an aggregate inside `BETWEEN` / `IN` / `CASE` returns wrong rows, **silently, in both directions** | **wrong results, no error** | | [P11](SQL_PARITY.md) | A `SELECT` alias on the LHS of an `IN` subquery → `Column not found` | hard error | + | [P15](SQL_PARITY.md) | An inline window function in `QUALIFY` is never lifted (the lifter walks the SELECT list only) → `Expected column name, got: WindowFunction` | hard error | | *(fixed, PR #33)* | `ILIKE` inside `OVER (ORDER BY ...)` left unrewritten, reaching the executor as an unknown operator | hard error | | *(fixed, PR #33)* | `INTO` inside `(a, b) IN (SELECT ...)` never removed | reaches executor | @@ -281,3 +282,5 @@ R5 dead code ─────── opportunistic | 2026-07-18 | R3 evidence: P9–P12 filed after probing the engine; corpus gains tier 07 (grouping) | — | | 2026-07-18 | R2: `*_crossing` helpers become the primitives; the three crossing transformers stop hand-listing subquery variants | #35 | | 2026-07-19 | R2: `having_alias_transformer` migrated — closes P9, three corpus cases DIFFER → AGREE | — | +| 2026-07-25 | R2: `where_alias_expander` migrated — closes P11; the four subquery-LHS variants came for free | — | +| 2026-08-02 | Corpus tiers 08 (ordering), 09 (window/QUALIFY), 10 (aggregate/NULL) added; P13–P15 filed. 83 → 96 cases | — | diff --git a/docs/SQL_PARITY.md b/docs/SQL_PARITY.md index 6f064a65..e8565b62 100644 --- a/docs/SQL_PARITY.md +++ b/docs/SQL_PARITY.md @@ -356,6 +356,97 @@ annotation be removed. (expression-position CTE hoisting never had an input); they now receive real input. +### P13 — Unparsed trailing tokens are silently discarded, taking later clauses with them +- **Status:** 🔴 OPEN +- **Corpus:** `08_ordering.toml :: trailing_garbage_token` (OURS_ONLY — the root + cause, pinned directly), `order_by_nulls_last_limit` and + `order_by_nulls_first_limit` (DIFFER — the instance users actually hit). + Controls: `order_by_limit`, `order_by_nulls_last_no_limit` (both AGREE). +- **Observed:** `ORDER BY amount DESC NULLS LAST LIMIT 3` returns **all 20 rows** + instead of 3. No error. The `LIMIT` is simply gone. +- **Root cause — broader than it first looks.** `NULLS` is not the issue; there + is **no `NULLS` handling anywhere in `src/sql/`**. The parser stops at the + first token it cannot place and **silently ignores the entire remainder of the + statement**, including every clause after it. Verified with a nonsense token: + + | Query | Result | + |---|---| + | `... ORDER BY amount DESC FROBNICATE LIMIT 3` | 20 rows, no error (LIMIT dropped) | + | `... GROUP BY country FROBNICATE LIMIT 2` | 13 rows, no error (LIMIT dropped) | + | `SELECT country FROM international_sales FROBNICATE` | 20 rows, no error | + + So *any* typo, or any clause we don't support, degrades into a **different + query that runs successfully**. This is the same silent-wrong-answer class as + P9, but at the parser level and therefore unbounded in scope — it is not + confined to one clause or one transformer. +- **Decision:** **Fix**, in two stages, and keep them separate: + 1. **Reject trailing input.** After parsing a statement, require EOF (or a + statement separator) and error otherwise. This converts an unbounded class + of silent wrong answers into loud parse errors. + 2. **Implement `NULLS FIRST` / `NULLS LAST`** as a real `OrderByItem` option. + DuckDB defaults to NULLS LAST for ASC and NULLS FIRST for DESC; our current + NULL ordering is untested (see note below). +- **Expected corpus churn — plan for it.** Stage 1 alone moves the two NULLS + cases **DIFFER → GAP**, not to AGREE, and moves `trailing_garbage_token` to + BOTH_ERR. That is the correct intermediate state: a hard error is strictly + better than a silently different answer. Only stage 2 flips the NULLS cases to + AGREE. +- **Note on fixtures:** every corpus data file is NULL-free, so these cases pin + the *lost LIMIT* only — the actual NULL ordering semantics remain untested. + Tier 08 needs a fixture containing NULLs before that can be asserted either + way. + +### P14 — An ungrouped aggregate over an empty set returns no row +- **Status:** 🔴 OPEN +- **Corpus:** `10_aggregate_nulls.toml :: count_star_empty`, `sum_empty`, + `min_max_empty` (all DIFFER). Controls: `count_nonempty`, + `grouped_aggregate_empty` (both AGREE). +- **Observed:** When the WHERE clause matches nothing, an aggregate with no + `GROUP BY` returns **zero rows**; standard SQL (and DuckDB) returns **exactly + one row**: + + | Query | DuckDB | sql-cli | + |---|---|---| + | `SELECT COUNT(*) FROM t WHERE ` | 1 row: `0` | **0 rows** | + | `SELECT SUM(amount) FROM t WHERE ` | 1 row: `NULL` | **0 rows** | + | `SELECT MIN(a), MAX(a) FROM t WHERE ` | 1 row: `NULL, NULL` | **0 rows** | + +- **Scope — deliberately narrow.** The *grouped* form is already correct: + `... WHERE GROUP BY region` returns zero rows in both engines, + which is right — no rows means no groups. The defect is confined to the + ungrouped case, where the aggregate is over the whole (empty) input and must + still produce its one row. +- **Decision:** **Fix.** An ungrouped aggregate query has exactly one output row + by definition, independent of input cardinality. The fix must fill *every* + output column (hence the multi-aggregate corpus case): `COUNT` → `0`, every + other aggregate → `NULL`. +- **Why it matters:** this is the shape a dashboard or summary query takes. + Returning no row where the caller expects one number is a wrong answer that + reads as "no data" rather than "zero". + +### P15 — `QUALIFY` rejects an inline window function +- **Status:** 🔴 OPEN +- **Corpus:** `09_window.toml :: qualify_row_number` (GAP). Controls: + `window_row_number`, `qualify_select_list_alias` (both AGREE). +- **Observed:** `QUALIFY ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount + DESC) = 1` → `Expected column name, got: WindowFunction { ... }` + (`recursive_where_evaluator.rs:987`). +- **Precisely located by the controls.** QUALIFY is **not** broken in general: + `QUALIFY rn = 1`, referencing an alias defined in the SELECT list, AGREEs. And + the same window expression evaluates correctly in the SELECT list. It is only + the **inline** form that fails. +- **Root cause:** the design is lifter-first — `ExpressionLifter` hoists window + functions into a CTE column, then `QualifyToWhereTransformer` rewrites QUALIFY + into a WHERE against that column. But the lifter only walks the **SELECT + list**, so a window function written inline in QUALIFY is never hoisted and + reaches the WHERE evaluator as a raw `WindowFunction`. The fix site is + `expression_lifter`, not `qualify_to_where_transformer`. +- **Decision:** **Fix** via the [R2](ENGINE_REFACTORING.md) migration of + `expression_lifter` (35 patterns, currently hand-rolled with a catch-all) — + extending it to lift from the QUALIFY clause as well as the SELECT list. This + is the R3 pattern again: the clause the transformer doesn't visit fails + silently or loudly depending only on luck. + --- ## Deferred / won't fix (intentional) diff --git a/tests/comparison/corpus/08_ordering.toml b/tests/comparison/corpus/08_ordering.toml new file mode 100644 index 00000000..e15d4281 --- /dev/null +++ b/tests/comparison/corpus/08_ordering.toml @@ -0,0 +1,55 @@ +# Tier 8 — ORDER BY and LIMIT/OFFSET. +# +# Added 2026-08-02. The corpus had no tier for ordering or row-limiting: ORDER BY +# appeared only incidentally, as a determinism aid on other tiers' cases, and was +# never itself the thing under test. P13 is the immediate consequence — see +# SQL_PARITY.md. + +# --- The baseline that works, so P13 is isolated to the NULLS clause --- + +[[case]] +id = "order_by_limit" +data = "international_sales.csv" +sql = "SELECT country, amount FROM international_sales ORDER BY amount DESC LIMIT 3" +# NB ordered by `amount`, whose 20 values are all distinct. Ordering by a column +# with ties makes LIMIT non-deterministic across engines and the case would +# flap; keep the sort key total. + +[[case]] +id = "order_by_nulls_last_no_limit" +data = "international_sales.csv" +sql = "SELECT country, amount FROM international_sales ORDER BY amount DESC NULLS LAST" +# AGREEs. This is the control that pins P13 down: the NULLS clause parses and +# orders correctly on its own, so the defect below is purely the lost LIMIT. + +# --- P13: a NULLS FIRST/LAST clause silently discards the LIMIT --- + +[[case]] +id = "order_by_nulls_last_limit" +data = "international_sales.csv" +sql = "SELECT country, amount FROM international_sales ORDER BY amount DESC NULLS LAST LIMIT 3" +expect = "DIFFER" +# P13. Returns all 20 rows instead of 3, with no error. Identical to +# `order_by_limit` above but for the NULLS LAST clause. + +[[case]] +id = "order_by_nulls_first_limit" +data = "international_sales.csv" +sql = "SELECT country, amount FROM international_sales ORDER BY amount NULLS FIRST LIMIT 3" +expect = "DIFFER" +# P13, the FIRST/ASC form — same defect, so the fix must clear both. + +[[case]] +id = "trailing_garbage_token" +data = "international_sales.csv" +sql = "SELECT country, amount FROM international_sales ORDER BY amount DESC FROBNICATE LIMIT 3" +expect = "OURS_ONLY" +# P13's ACTUAL root cause, pinned directly: `FROBNICATE` is not SQL by any +# reading, yet we accept the statement and silently drop the LIMIT after it. +# The reference engine rejects it, which is the correct behaviour and why this +# sits in OURS_ONLY rather than DIFFER. NULLS LAST above is merely the instance +# a user is likely to hit; this case is the defect. +# +# When P13 is fixed this becomes a parse error and moves to BOTH_ERR, and the +# two NULLS cases above move DIFFER -> GAP (not AGREE) until NULLS ordering is +# actually implemented. Expect that churn; it is progress, not regression. diff --git a/tests/comparison/corpus/09_window.toml b/tests/comparison/corpus/09_window.toml new file mode 100644 index 00000000..7ec9a773 --- /dev/null +++ b/tests/comparison/corpus/09_window.toml @@ -0,0 +1,41 @@ +# Tier 9 — window functions and QUALIFY. +# +# Added 2026-08-02, seeded with P15. Window functions are a large, working, and +# until now completely untested surface: the corpus had no OVER clause anywhere. +# This file starts with the QUALIFY gap and its control; the ranking, frame and +# navigation cases follow. +# +# NB window evaluation has TWO code paths — `query_engine.rs` batch-evaluates by +# default, with SQL_CLI_BATCH_WINDOW=0 as the opt-out — and they have never been +# compared against each other. Worth running this tier both ways. + +# --- The baseline that works, so P15 is isolated to QUALIFY --- + +[[case]] +id = "window_row_number" +data = "international_sales.csv" +sql = "SELECT region, amount, ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) AS rn FROM international_sales ORDER BY region, amount DESC" +# AGREEs. The same window expression P15 rejects in a QUALIFY clause evaluates +# correctly in the select list, so the gap below is in QUALIFY, not the window. + +[[case]] +id = "qualify_select_list_alias" +data = "international_sales.csv" +sql = "SELECT region, amount, ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) AS rn FROM international_sales QUALIFY rn = 1 ORDER BY region" +# AGREEs. QUALIFY itself works — but only against an alias the ExpressionLifter +# has already hoisted out of the SELECT list. This is the control that locates +# P15 in the lifter rather than in QUALIFY. + +# --- P15: QUALIFY does not accept an INLINE window function --- + +[[case]] +id = "qualify_row_number" +data = "international_sales.csv" +sql = "SELECT region, amount FROM international_sales QUALIFY ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) = 1 ORDER BY region" +expect = "GAP" +# P15: "Expected column name, got: WindowFunction { ... }" from +# `recursive_where_evaluator.rs`. The design is ExpressionLifter-first — window +# functions are hoisted to a CTE column, then QUALIFY becomes a WHERE on that +# column. But the lifter only walks the SELECT list, so a window function +# written inline in QUALIFY is never lifted and arrives at the WHERE evaluator +# as a raw WindowFunction. Fix site is `expression_lifter`, not QUALIFY. diff --git a/tests/comparison/corpus/10_aggregate_nulls.toml b/tests/comparison/corpus/10_aggregate_nulls.toml new file mode 100644 index 00000000..26466dcd --- /dev/null +++ b/tests/comparison/corpus/10_aggregate_nulls.toml @@ -0,0 +1,44 @@ +# Tier 10 — aggregate and NULL edge semantics. +# +# Added 2026-08-02, seeded with P14. Tier 7 covers GROUP BY / HAVING over +# non-empty groups; what it never asked is what an aggregate does when there is +# nothing to aggregate. See SQL_PARITY.md. + +# --- The baselines that work, so P14 is isolated to the empty ungrouped case --- + +[[case]] +id = "count_nonempty" +data = "international_sales.csv" +sql = "SELECT COUNT(*) AS n FROM international_sales WHERE region = 'Europe'" + +[[case]] +id = "grouped_aggregate_empty" +data = "international_sales.csv" +sql = "SELECT region, COUNT(*) AS n FROM international_sales WHERE region = 'Nowhere' GROUP BY region" +# AGREEs on zero rows, correctly: a GROUPED aggregate over no rows has no groups +# to report. This is what makes P14 specifically about the UNGROUPED form. + +# --- P14: an ungrouped aggregate over an empty set returns no row at all --- + +[[case]] +id = "count_star_empty" +data = "international_sales.csv" +sql = "SELECT COUNT(*) AS n FROM international_sales WHERE region = 'Nowhere'" +expect = "DIFFER" +# P14. Returns 0 rows; standard SQL returns exactly one row containing 0. + +[[case]] +id = "sum_empty" +data = "international_sales.csv" +sql = "SELECT SUM(amount) AS total FROM international_sales WHERE region = 'Nowhere'" +expect = "DIFFER" +# P14, the NULL-returning half: one row containing NULL, not zero rows. COUNT +# and SUM differ in the value they must produce, so both are pinned. + +[[case]] +id = "min_max_empty" +data = "international_sales.csv" +sql = "SELECT MIN(amount) AS lo, MAX(amount) AS hi FROM international_sales WHERE region = 'Nowhere'" +expect = "DIFFER" +# P14, multi-aggregate form — the single output row must carry a NULL per +# aggregate, so a fix that emits one row must fill every column. From 81b7bee96b5b445297f5dc2e24be8992069e78ed Mon Sep 17 00:00:00 2001 From: TimelordUK Date: Sun, 2 Aug 2026 09:29:32 +0100 Subject: [PATCH 2/6] test(parity): build out tier 08 with a NULL fixture; file P16-P20 Adds data/null_edges.csv, a 12-row fixture built specifically to expose NULL behaviour - every other corpus CSV is NULL-free, so NULL semantics were simply unassertable. It carries NULLs in a sort key, a group/partition key, a string column, a join key, and an entirely-NULL column, plus deliberate ties, and a unique never-NULL `id` as a total-order tiebreak. Verified before building on it that BOTH engines see the same NULLs in all five nullable columns - otherwise these cases would be testing the CSV reader rather than the SQL engine. Two harness properties shaped the design and are recorded in the tier header: normalize.py canonicalises "" to NULL so the ''-vs-NULL distinction is invisible to the comparison, and has_order_by() is a substring check so an OVER(ORDER BY) alone forces ordered comparison - every case therefore needs a total ordering. Five new divergences, each pinned with the baseline that isolates it: P16 - ORDER BY is silently ignored; rows come back in insertion order with no error. The integer is evaluated as a constant so every row compares equal. Unambiguous bug: ordinals are standard SQL. P17 - default NULL placement differs on ASC. We sort NULL as the minimum value (SQLite/MySQL); DuckDB pins NULLS LAST in both directions. The rules coincide on DESC and diverge on ASC. Standard SQL leaves this implementation-defined, so it is filed as a decision, not a defect. P18 - `= NULL` matches NULL rows instead of yielding UNKNOWN. Produces extra rows, which is the more dangerous direction. P19 - NOT IN does not exclude NULLs, though `<>` does. The inconsistency inside our own NULL handling is what makes this a bug rather than a stance. P20 - `||` treats NULL as an empty string while arithmetic correctly propagates NULL. Defensible as coercion-first (Oracle agrees), but the internal inconsistency with `+` is not. Filed as a decision. Cases are built one-variable-at-a-time: the multi-key, alias, expression and two-direction ordering cases filter NULLs out of the sort key so they test ordering machinery rather than re-testing P17, and all four AGREE. 83 -> 123 cases; contract holds. Co-Authored-By: Claude Opus 5 --- data/null_edges.csv | 13 ++ docs/SQL_PARITY.md | 103 ++++++++++++++ tests/comparison/corpus/08_ordering.toml | 129 +++++++++++++++++- .../comparison/corpus/10_aggregate_nulls.toml | 108 +++++++++++++++ 4 files changed, 346 insertions(+), 7 deletions(-) create mode 100644 data/null_edges.csv diff --git a/data/null_edges.csv b/data/null_edges.csv new file mode 100644 index 00000000..5f018f71 --- /dev/null +++ b/data/null_edges.csv @@ -0,0 +1,13 @@ +id,team,score,label,bonus,partner_id +1,alpha,50,delta,,3 +2,alpha,50,,,1 +3,alpha,,charlie,, +4,beta,70,bravo,,5 +5,beta,30,,,4 +6,beta,70,alpha,, +7,,90,echo,,8 +8,,10,foxtrot,,7 +9,gamma,20,golf,,11 +10,gamma,,,, +11,delta,,,, +12,delta,,,, diff --git a/docs/SQL_PARITY.md b/docs/SQL_PARITY.md index e8565b62..8dad49f8 100644 --- a/docs/SQL_PARITY.md +++ b/docs/SQL_PARITY.md @@ -447,6 +447,109 @@ annotation be removed. is the R3 pattern again: the clause the transformer doesn't visit fails silently or loudly depending only on luck. +### P16 — `ORDER BY ` is silently ignored +- **Status:** 🔴 OPEN +- **Corpus:** `08_ordering.toml :: order_by_ordinal`, `order_by_ordinal_desc` + (both DIFFER). +- **Observed:** `ORDER BY 2` and `ORDER BY 2 DESC` return rows in **natural + insertion order** — no sorting is applied at all, and no error is raised. The + integer is evaluated as a constant expression, so every row compares equal. +- **Distinct from P13.** Nothing is dropped here; `ORDER BY 2` parses fine. The + defect is that a positional reference is treated as a literal instead of being + resolved to the 2nd select-list item. +- **Not implementation-defined.** Unlike P17 below, ordinals are standard SQL and + every major engine resolves them. This is unambiguously a bug. +- **Decision:** **Fix.** Resolve an integer literal in `ORDER BY` to the + corresponding select-list item (1-based), and error on out-of-range. Both + corpus cases are needed: a fix that resolves the ordinal but drops `ASC`/`DESC` + would still pass the first one. +- **Note:** this also silently corrupted an earlier probe of mine — + `ORDER BY 2 DESC LIMIT 3` returned three rows, so it *looked* fine, but they + were the first three in file order rather than the top three. Row count is not + evidence of correct ordering. + +### P17 — Default NULL placement differs on `ASC` +- **Status:** 🔴 OPEN — **decision needed, not a reflex fix** +- **Corpus:** `08_ordering.toml :: order_by_null_default_asc_numeric`, + `order_by_null_default_asc_string` (DIFFER); `order_by_null_default_desc` + (AGREE). +- **Observed:** the two engines follow different rules, which happen to coincide + on `DESC` and diverge on `ASC`: + + | | sql-cli | DuckDB | + |---|---|---| + | rule | NULL sorts as the **minimum value** | **NULLS LAST**, always | + | `ORDER BY score` (ASC) | NULLs **first** | NULLs **last** | + | `ORDER BY score DESC` | NULLs last | NULLs last | + +- **Standard SQL leaves this implementation-defined**, and the major engines + genuinely disagree: SQLite and MySQL treat NULL as smallest (our behaviour), + PostgreSQL treats it as largest, DuckDB pins NULLS LAST in both directions. + So this is a **choice to record**, not a defect to correct. +- **Options:** + 1. **Match DuckDB** (NULLS LAST always) — consistent with our reference engine + and the least surprising to explain, but changes existing behaviour. + 2. **Keep NULL-as-minimum** and record as ⚪ WON'T FIX with this rationale. + 3. Either of the above **plus** implementing `NULLS FIRST` / `NULLS LAST` + (see P13 stage 2), after which the default matters much less because users + can be explicit. +- **Recommendation:** option 3 with option 1 as the default — but the default is + a behaviour change for existing users, so it wants a deliberate call. +- **Note:** `order_by_null_default_desc` AGREEs *for the wrong reason* — the two + different rules coincide there. It is kept as a case precisely to document + that, and it will start failing the day the rule changes, which is the point. + +### P18 — `= NULL` matches NULL rows instead of yielding UNKNOWN +- **Status:** 🔴 OPEN +- **Corpus:** `10_aggregate_nulls.toml :: where_equals_null` (DIFFER). + Baseline: `where_is_null` (AGREE). +- **Observed:** `WHERE score = NULL` returns the four NULL-score rows. It is + being treated as `IS NULL`. Under SQL three-valued logic `x = NULL` evaluates + to UNKNOWN for **every** row — including rows where `x` is itself NULL — so + the correct result is **zero rows**. +- **Decision:** **Fix.** `IS NULL` already works and is the only correct way to + match a NULL, so no capability is lost by making `= NULL` never match. +- **Why it matters:** this is the direction that produces *extra* rows. A + `WHERE col = ` that receives a NULL parameter silently returns the + NULL rows instead of nothing — a wrong answer in the more dangerous direction. + +### P19 — `NOT IN` does not exclude NULLs +- **Status:** 🔴 OPEN — same family as P18 +- **Corpus:** `10_aggregate_nulls.toml :: where_not_in_excludes_null` (DIFFER). + Baselines: `where_not_equal_excludes_null`, `where_in_with_null_col` (AGREE). +- **Observed:** `WHERE score NOT IN (50, 70)` returns 8 rows including the + NULL-score rows; DuckDB returns 4. `NULL NOT IN (50, 70)` is UNKNOWN, not + TRUE, so those rows must not pass. +- **Internally inconsistent, which is what makes it a bug.** The equivalent + `WHERE score <> 50` already excludes NULLs correctly (pinned as a baseline). + So we are not applying a considered "NULLs are comparable" rule — one operator + propagates NULL and another does not. +- **Decision:** **Fix**, alongside P18 — both are the same missing + three-valued-logic propagation, reached through different operators. Worth + auditing `IN`, `NOT IN`, `BETWEEN`, `NOT BETWEEN` and `LIKE` together rather + than patching the one operator the corpus happened to catch. + +### P20 — `||` treats NULL as an empty string +- **Status:** 🔴 OPEN — **decision needed** +- **Corpus:** `10_aggregate_nulls.toml :: null_concat` (DIFFER). + Baseline: `null_arithmetic` (AGREE). +- **Observed:** `team || '-' || label` on a row where `label` is NULL gives + `'alpha-'`; DuckDB gives `NULL`. Standard SQL propagates NULL through + concatenation. +- **Arguably deliberate.** Oracle takes our view (NULL concatenates as empty), + and "coercion-first" is an explicit design stance + ([FEATURE_ROADMAP_2026_Q2.md](FEATURE_ROADMAP_2026_Q2.md)), so treating a + missing string as empty is defensible for a data-exploration tool. +- **But note the inconsistency:** `score + 1` correctly yields NULL + (`null_arithmetic` AGREEs). So arithmetic propagates NULL and concatenation + does not. Whichever way this is decided, the two should agree on a principle. +- **Options:** (1) propagate NULL through `||` to match standard SQL and our own + arithmetic; (2) keep empty-string coercion and record as ⚪ WON'T FIX, noting + `CONCAT()`-style semantics as the rationale. +- **Recommendation:** option 1 — the internal inconsistency with arithmetic is + harder to defend than either rule on its own — but this is a user-facing + behaviour change and wants a deliberate call. + --- ## Deferred / won't fix (intentional) diff --git a/tests/comparison/corpus/08_ordering.toml b/tests/comparison/corpus/08_ordering.toml index e15d4281..2d1b316a 100644 --- a/tests/comparison/corpus/08_ordering.toml +++ b/tests/comparison/corpus/08_ordering.toml @@ -2,10 +2,37 @@ # # Added 2026-08-02. The corpus had no tier for ordering or row-limiting: ORDER BY # appeared only incidentally, as a determinism aid on other tiers' cases, and was -# never itself the thing under test. P13 is the immediate consequence — see -# SQL_PARITY.md. +# never itself the thing under test. P13, P16 and P17 are the consequence. +# +# --------------------------------------------------------------------------- +# Fixture: null_edges.csv (12 rows) — purpose-built, because every other corpus +# CSV is NULL-free and NULL behaviour was therefore unassertable. +# +# id 1..12, unique, NEVER NULL -> the total-order tiebreak +# team partition/group key WITH NULLs (2) and an all-NULL-score group +# score sort key / aggregate input, WITH NULLs (4) and ties (50,50 70,70) +# label string sort key WITH NULLs (5) +# bonus entirely NULL +# partner_id self-join key WITH NULLs (5) +# +# Verified before use: both engines see the SAME NULLs in all five columns, so +# these cases test SQL semantics and not the CSV reader. +# +# Two harness properties drive the design here: +# 1. `normalize.py` canonicalises "" to NULL, so '' vs NULL is INVISIBLE to the +# comparison. Do not try to test that distinction with a corpus case. +# 2. `has_order_by()` is a substring check, so an `OVER (ORDER BY ...)` alone +# puts the comparison in ORDERED mode. Every case must therefore end in a +# TOTAL ordering — in practice ", id" — or it will flap on ties. +# +# `bonus` is deliberately not summed anywhere: DuckDB's CSV sniffer types an +# all-NULL column as VARCHAR, so SUM(bonus) is a binder error there while we +# return NULL. That is a reference-engine inference quirk, not a semantic +# divergence, and pinning it would lock CI to DuckDB's sniffer. +# --------------------------------------------------------------------------- -# --- The baseline that works, so P13 is isolated to the NULLS clause --- +# --- Baselines: ordering machinery that works, NULLs kept OUT of the sort key +# --- so each case tests one variable and cannot be confused with P17. [[case]] id = "order_by_limit" @@ -15,14 +42,56 @@ sql = "SELECT country, amount FROM international_sales ORDER BY amount DESC LIMI # with ties makes LIMIT non-deterministic across engines and the case would # flap; keep the sort key total. +[[case]] +id = "order_by_multi_key" +data = "null_edges.csv" +sql = "SELECT id, team, score FROM null_edges WHERE team IS NOT NULL ORDER BY team, score DESC, id" + +[[case]] +id = "order_by_two_directions" +data = "null_edges.csv" +sql = "SELECT id, team, score FROM null_edges WHERE team IS NOT NULL AND score IS NOT NULL ORDER BY team ASC, score DESC, id" + +[[case]] +id = "order_by_alias" +data = "null_edges.csv" +sql = "SELECT id, score * 2 AS dbl FROM null_edges WHERE score IS NOT NULL ORDER BY dbl, id" + +[[case]] +id = "order_by_expression" +data = "null_edges.csv" +sql = "SELECT id, score FROM null_edges WHERE score IS NOT NULL ORDER BY score * -1, id" + +[[case]] +id = "order_by_desc_ties" +data = "null_edges.csv" +sql = "SELECT id, team, score FROM null_edges ORDER BY score DESC, id" +# Ties on 50 and 70 are the point; `, id` keeps the output deterministic anyway. + +[[case]] +id = "limit_offset" +data = "null_edges.csv" +sql = "SELECT id, score FROM null_edges ORDER BY id LIMIT 4 OFFSET 3" + +[[case]] +id = "limit_zero" +data = "null_edges.csv" +sql = "SELECT id FROM null_edges ORDER BY id LIMIT 0" + +[[case]] +id = "limit_beyond_end" +data = "null_edges.csv" +sql = "SELECT id FROM null_edges ORDER BY id LIMIT 100" + +# --- P13: a NULLS FIRST/LAST clause silently discards the LIMIT --- + [[case]] id = "order_by_nulls_last_no_limit" data = "international_sales.csv" sql = "SELECT country, amount FROM international_sales ORDER BY amount DESC NULLS LAST" -# AGREEs. This is the control that pins P13 down: the NULLS clause parses and -# orders correctly on its own, so the defect below is purely the lost LIMIT. - -# --- P13: a NULLS FIRST/LAST clause silently discards the LIMIT --- +# AGREEs, and this is the control that pins P13 down: with no LIMIT to lose, +# the statement survives. NB it also agrees for a second, accidental reason — +# see the P17 cases below — but on a NULL-free fixture only the first applies. [[case]] id = "order_by_nulls_last_limit" @@ -53,3 +122,49 @@ expect = "OURS_ONLY" # When P13 is fixed this becomes a parse error and moves to BOTH_ERR, and the # two NULLS cases above move DIFFER -> GAP (not AGREE) until NULLS ordering is # actually implemented. Expect that churn; it is progress, not regression. + +# --- P16: ORDER BY is silently ignored --- + +[[case]] +id = "order_by_ordinal" +data = "null_edges.csv" +sql = "SELECT id, score FROM null_edges WHERE score IS NOT NULL ORDER BY 2, 1" +expect = "DIFFER" +# P16. Rows come back in natural (insertion) order — the ordinal is evaluated as +# a constant, so every row compares equal and nothing sorts. No error is raised. +# NULLs are filtered out so this cannot be confused with P17. + +[[case]] +id = "order_by_ordinal_desc" +data = "null_edges.csv" +sql = "SELECT id, score FROM null_edges WHERE score IS NOT NULL ORDER BY 2 DESC, 1" +expect = "DIFFER" +# P16 with a direction, which is also ignored. Worth pinning separately: a fix +# that resolves the ordinal but drops ASC/DESC would still pass the case above. + +# --- P17: default NULL placement differs on ASC --- + +[[case]] +id = "order_by_null_default_desc" +data = "null_edges.csv" +sql = "SELECT id, score FROM null_edges ORDER BY score DESC, id" +# AGREEs — both engines put NULLs last on DESC, but for DIFFERENT reasons (we +# sort NULL as the minimum value; DuckDB always sorts NULLS LAST). The two +# rules coincide here and diverge on ASC below. + +[[case]] +id = "order_by_null_default_asc_numeric" +data = "null_edges.csv" +sql = "SELECT id, score FROM null_edges ORDER BY score, id" +expect = "DIFFER" +# P17. We sort NULL as the smallest value (SQLite/MySQL convention) so NULLs +# come FIRST; DuckDB sorts NULLS LAST regardless of direction. Standard SQL +# leaves this implementation-defined, so this needs a decision, not a reflex fix. + +[[case]] +id = "order_by_null_default_asc_string" +data = "null_edges.csv" +sql = "SELECT id, label FROM null_edges ORDER BY label, id" +expect = "DIFFER" +# P17 on a string column — pinned separately to prove the rule is type-independent +# and that a fix has to cover both comparators. diff --git a/tests/comparison/corpus/10_aggregate_nulls.toml b/tests/comparison/corpus/10_aggregate_nulls.toml index 26466dcd..a33eb74a 100644 --- a/tests/comparison/corpus/10_aggregate_nulls.toml +++ b/tests/comparison/corpus/10_aggregate_nulls.toml @@ -42,3 +42,111 @@ sql = "SELECT MIN(amount) AS lo, MAX(amount) AS hi FROM international_sales WHER expect = "DIFFER" # P14, multi-aggregate form — the single output row must carry a NULL per # aggregate, so a fix that emits one row must fill every column. + +# --------------------------------------------------------------------------- +# NULL semantics. Uses null_edges.csv — see the fixture notes in 08_ordering.toml. +# --------------------------------------------------------------------------- + +# --- Baselines: the NULL handling that is already correct --- + +[[case]] +id = "agg_ignores_nulls" +data = "null_edges.csv" +sql = "SELECT COUNT(*) AS rows_n, COUNT(score) AS score_n, SUM(score) AS s, AVG(score) AS a FROM null_edges" +# COUNT(*) counts rows, COUNT(col) skips NULLs, SUM/AVG skip NULLs. All correct. + +[[case]] +id = "agg_all_null_group" +data = "null_edges.csv" +sql = "SELECT COUNT(*) AS n, COUNT(score) AS nn, SUM(score) AS s FROM null_edges WHERE team = 'delta'" +# Every score in team 'delta' is NULL: COUNT(*)=2, COUNT(score)=0, SUM=NULL. +# Distinct from P14 — here there ARE rows, they just aggregate to nothing. + +[[case]] +id = "group_by_null_key" +data = "null_edges.csv" +sql = "SELECT team, COUNT(*) AS n FROM null_edges GROUP BY team" +# NULL forms its own group, correctly. Deliberately has NO ORDER BY so the +# harness compares as a multiset — adding `ORDER BY team` would drag P17's NULL +# placement in and make this case about ordering instead of grouping. + +[[case]] +id = "distinct_with_nulls" +data = "null_edges.csv" +sql = "SELECT DISTINCT score FROM null_edges" + +[[case]] +id = "count_distinct_with_nulls" +data = "null_edges.csv" +sql = "SELECT COUNT(DISTINCT score) AS d FROM null_edges" + +[[case]] +id = "where_is_null" +data = "null_edges.csv" +sql = "SELECT id FROM null_edges WHERE score IS NULL ORDER BY id" + +[[case]] +id = "where_not_equal_excludes_null" +data = "null_edges.csv" +sql = "SELECT id FROM null_edges WHERE score <> 50 ORDER BY id" +# Correct: `<>` excludes NULL rows. Contrast with NOT IN below, which does not — +# the inconsistency is what makes P19 a bug rather than a design choice. + +[[case]] +id = "where_in_with_null_col" +data = "null_edges.csv" +sql = "SELECT id FROM null_edges WHERE score IN (50, 70) ORDER BY id" + +[[case]] +id = "join_on_null_key" +data = "null_edges.csv" +sql = "SELECT a.id AS aid, b.id AS bid FROM null_edges a JOIN null_edges b ON a.partner_id = b.id ORDER BY a.id" +# NULL never equals NULL in a join key, so the 5 NULL partner_id rows drop out. + +[[case]] +id = "null_arithmetic" +data = "null_edges.csv" +sql = "SELECT id, score + 1 AS plus FROM null_edges ORDER BY id" +# NULL + 1 = NULL. Correct — which makes P20 below the odd one out. + +[[case]] +id = "coalesce_null" +data = "null_edges.csv" +sql = "SELECT id, COALESCE(score, -1) AS c FROM null_edges ORDER BY id" + +# --- P18: `= NULL` matches instead of yielding UNKNOWN --- + +[[case]] +id = "where_equals_null" +data = "null_edges.csv" +sql = "SELECT id FROM null_edges WHERE score = NULL" +expect = "DIFFER" +# P18. We return the four NULL-score rows — `= NULL` is being treated as +# `IS NULL`. Under SQL three-valued logic `x = NULL` is UNKNOWN for every row +# including NULL ones, so the correct answer is zero rows. `IS NULL` (above) +# is the only way to match a NULL and it already works. + +# --- P19: NOT IN does not exclude NULLs --- + +[[case]] +id = "where_not_in_excludes_null" +data = "null_edges.csv" +sql = "SELECT id FROM null_edges WHERE score NOT IN (50, 70) ORDER BY id" +expect = "DIFFER" +# P19. We return 8 rows, including the NULL-score rows; DuckDB returns 4. +# `NULL NOT IN (50, 70)` is UNKNOWN, not TRUE, so those rows must not pass. +# Note `where_not_equal_excludes_null` above gets the equivalent case right, +# so this is an inconsistency inside our own NULL handling. + +# --- P20: `||` treats NULL as an empty string --- + +[[case]] +id = "null_concat" +data = "null_edges.csv" +sql = "SELECT id, team || '-' || label AS c FROM null_edges ORDER BY id" +expect = "DIFFER" +# P20. Row 2 (label IS NULL) gives us 'alpha-' where DuckDB gives NULL. +# Concatenating NULL yields NULL in standard SQL. Oracle takes our view, so +# this one is arguably a coercion-first design choice rather than a bug — it +# needs a decision recorded, not an automatic fix. + From 3382c16d619796860e8deb44b43e5fe42bd0fffe Mon Sep 17 00:00:00 2001 From: TimelordUK Date: Sun, 2 Aug 2026 09:34:10 +0100 Subject: [PATCH 3/6] =?UTF-8?q?docs(parity):=20record=20the=20P17=20decisi?= =?UTF-8?q?on=20=E2=80=94=20follow=20the=20reference=20engine=20on=20NULL?= =?UTF-8?q?=20ordering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where the SQL standard leaves a choice open, we follow DuckDB rather than making a case-by-case judgement: NULLs sort last in both directions, plus explicit NULLS FIRST/LAST from P13 stage 2. Notes that this is a user-visible change to ORDER BY over NULL-bearing data, and which corpus cases flip when it lands. Co-Authored-By: Claude Opus 5 --- docs/SQL_PARITY.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/SQL_PARITY.md b/docs/SQL_PARITY.md index 8dad49f8..a07a13ad 100644 --- a/docs/SQL_PARITY.md +++ b/docs/SQL_PARITY.md @@ -469,7 +469,9 @@ annotation be removed. evidence of correct ordering. ### P17 — Default NULL placement differs on `ASC` -- **Status:** 🔴 OPEN — **decision needed, not a reflex fix** +- **Status:** 🔴 OPEN — **decision made 2026-08-02: follow the reference engine + (NULLS LAST in both directions), plus explicit `NULLS FIRST`/`LAST` from P13 + stage 2.** Implementation pending. - **Corpus:** `08_ordering.toml :: order_by_null_default_asc_numeric`, `order_by_null_default_asc_string` (DIFFER); `order_by_null_default_desc` (AGREE). @@ -493,11 +495,19 @@ annotation be removed. 3. Either of the above **plus** implementing `NULLS FIRST` / `NULLS LAST` (see P13 stage 2), after which the default matters much less because users can be explicit. -- **Recommendation:** option 3 with option 1 as the default — but the default is - a behaviour change for existing users, so it wants a deliberate call. +- **Decision (2026-08-02): option 3, with option 1 as the default.** Where the + standard leaves a choice open, we follow the reference engine — that is the + whole point of having one, and it keeps "broad-brush parity" a single rule + rather than a series of case-by-case judgements. Concretely: + 1. Change the default comparator so NULLs sort **last in both directions**. + 2. Implement explicit `NULLS FIRST` / `NULLS LAST` (P13 stage 2), after which + the default matters much less because users can override it. + This is a user-visible behaviour change on `ORDER BY ` over NULL-bearing + data; call it out in the changelog when it lands. - **Note:** `order_by_null_default_desc` AGREEs *for the wrong reason* — the two different rules coincide there. It is kept as a case precisely to document - that, and it will start failing the day the rule changes, which is the point. + that. Under the decision above it will keep AGREEing, now for the right reason; + the two ASC cases flip DIFFER → AGREE and their `expect` should be dropped. ### P18 — `= NULL` matches NULL rows instead of yielding UNKNOWN - **Status:** 🔴 OPEN From 75e26bb0b9c8345c0185c15d058a067a2378518c Mon Sep 17 00:00:00 2001 From: TimelordUK Date: Sun, 2 Aug 2026 09:36:02 +0100 Subject: [PATCH 4/6] docs(parity): decide P20 (propagate NULL through ||); promote the rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both implementation-defined findings so far (P17, P20) resolved the same way, so the preamble now states it as policy: where the standard leaves a choice open, match the reference engine. Diverging stays available but has to be argued on design grounds and recorded under Deferred. Notes the consequence — pinning implementation-defined cases makes DuckDB's version part of the contract, so it should be bumped deliberately rather than floating — and records CONCAT() as the escape hatch if empty-string coercion turns out to matter for messy-data exploration. Co-Authored-By: Claude Opus 5 --- docs/SQL_PARITY.md | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/docs/SQL_PARITY.md b/docs/SQL_PARITY.md index a07a13ad..7eaa1d50 100644 --- a/docs/SQL_PARITY.md +++ b/docs/SQL_PARITY.md @@ -32,6 +32,21 @@ semantics where reasonable, and consciously diverge where our design ([heterogeneous one-shop querying, coercion-first](FEATURE_ROADMAP_2026_Q2.md)) makes a different choice better. +### Where the standard leaves a choice open, follow the reference engine + +Established 2026-08-02 while deciding P17 and P20, both of which are cases the +SQL standard leaves implementation-defined and where the major engines genuinely +disagree. Rather than judge each one on its merits, the default answer is +**match DuckDB** — that is what having a reference engine is *for*, and it keeps +"broad-brush parity" a single rule instead of a growing pile of one-off +rationales. Diverging remains available, but it now has to be argued for on +design grounds and recorded in *Deferred / won't fix* below. + +One consequence worth naming: this makes the reference engine's *version* part +of our contract, not just its behaviour. Implementation-defined cases pin +whatever DuckDB currently chooses, so the pinned DuckDB version should be +bumped deliberately rather than floating. + ## Status legend | Status | Meaning | @@ -540,7 +555,8 @@ annotation be removed. than patching the one operator the corpus happened to catch. ### P20 — `||` treats NULL as an empty string -- **Status:** 🔴 OPEN — **decision needed** +- **Status:** 🔴 OPEN — **decision made 2026-08-02: propagate NULL through `||`, + matching the reference engine.** Implementation pending. - **Corpus:** `10_aggregate_nulls.toml :: null_concat` (DIFFER). Baseline: `null_arithmetic` (AGREE). - **Observed:** `team || '-' || label` on a row where `label` is NULL gives @@ -553,12 +569,17 @@ annotation be removed. - **But note the inconsistency:** `score + 1` correctly yields NULL (`null_arithmetic` AGREEs). So arithmetic propagates NULL and concatenation does not. Whichever way this is decided, the two should agree on a principle. -- **Options:** (1) propagate NULL through `||` to match standard SQL and our own - arithmetic; (2) keep empty-string coercion and record as ⚪ WON'T FIX, noting - `CONCAT()`-style semantics as the rationale. -- **Recommendation:** option 1 — the internal inconsistency with arithmetic is - harder to defend than either rule on its own — but this is a user-facing - behaviour change and wants a deliberate call. +- **Decision (2026-08-02): propagate NULL through `||`.** Follows the rule above + — the standard is clear here and the reference engine agrees with it — and it + removes the internal inconsistency, which was the harder thing to defend: a + user cannot reasonably be told that `+` propagates NULL but `||` does not. +- **Watch for the coercion-first tension.** Empty-string coercion is presumably + *convenient* when eyeballing concatenated columns over messy data, which is + our core use case. If that turns out to matter in practice, the right answer + is a `CONCAT()` function with the coercing behaviour — an explicit opt-in — + rather than overloading `||`. Not needed until someone asks. +- **User-visible change:** any query concatenating a nullable column starts + returning NULL rather than a partial string. Changelog it when it lands. --- From bd6dc2f3e413c345c59c57010043c98409335a60 Mon Sep 17 00:00:00 2001 From: TimelordUK Date: Sun, 2 Aug 2026 09:40:19 +0100 Subject: [PATCH 5/6] test(parity): build out tier 09 (window functions); file P21-P26 Window functions were a large, working, completely untested surface - the corpus had no OVER clause anywhere. First pass produces six findings, four of them silent, plus fifteen baselines pinning the parts that are correct. P21 - window functions are evaluated BEFORE the WHERE clause, so they see the unfiltered row set. The identical query without a WHERE agrees with DuckDB exactly, which proves partitioning and the functions themselves are fine and the defect is purely pipeline position. Affects essentially every real window query, silently. Pinned through COUNT(*), ROW_NUMBER and a derived table. P22 - FIRST_VALUE, NTH_VALUE, NTILE, PERCENT_RANK and CUME_DIST return NULL for every row rather than erroring. LAST_VALUE, LAG, LEAD, ROW_NUMBER, RANK, DENSE_RANK and aggregate-OVER all work, so this is five missing functions, not a missing family. Making an unknown window function a hard error is worth doing independently of implementing them. P23 - LAG/LEAD honour the offset but drop the third (default) argument. P24 - a RANGE frame is treated as ROWS, including the implicit default frame when a window has ORDER BY and no explicit frame. Only detectable because the fixture has ties; on distinct keys ROWS and RANGE coincide. P25 - a window's ORDER BY accepts only a plain column, though the outer ORDER BY handles expressions fine. P26 - a window over an aggregate is rejected by the GROUP BY validity check. Same pipeline-position confusion as P21 from the other end. CLAUDE.md documents a CTE workaround for this, so it was known in practice but never filed. Two near-misses recorded in the comments because they show how the tier could have concluded the opposite: SUM-over-partition AGREES under the same WHERE that exposes P21 (the filtered rows carry NULLs that SUM ignores), and ROWS/RANGE coincide entirely without ties. Also CHECKED and clear: the batch window path (SQL_CLI_BATCH_WINDOW, on by default) agrees with its fallback across 12 queries, so these cases test one behaviour rather than two. 123 -> 150 cases; contract holds. Co-Authored-By: Claude Opus 5 --- docs/SQL_PARITY.md | 103 ++++++++++++ tests/comparison/corpus/09_window.toml | 219 +++++++++++++++++++++++-- 2 files changed, 310 insertions(+), 12 deletions(-) diff --git a/docs/SQL_PARITY.md b/docs/SQL_PARITY.md index 7eaa1d50..c69203cc 100644 --- a/docs/SQL_PARITY.md +++ b/docs/SQL_PARITY.md @@ -581,6 +581,109 @@ annotation be removed. - **User-visible change:** any query concatenating a nullable column starts returning NULL rather than a partial string. Changelog it when it lands. +### P21 — Window functions are evaluated *before* the `WHERE` clause +- **Status:** 🔴 OPEN — **the most consequential window finding** +- **Corpus:** `09_window.toml :: win_count_over_filtered`, + `win_row_number_filtered`, `win_in_derived_table_filtered` (all DIFFER). + Control: `win_partition_null_key` (AGREE). +- **Observed:** with a `WHERE` clause present, window functions see the + **unfiltered** row set. `COUNT(*) OVER (PARTITION BY team)` under + `WHERE score IS NOT NULL` reports partition sizes alpha=3, gamma=2; the + filtered sizes are alpha=2, gamma=1. `ROW_NUMBER` shows the same thing as + rank slots consumed by rows that were filtered out. +- **Proved by the control:** the *identical* query without the `WHERE` AGREEs + exactly with DuckDB, including the NULL partition. So partitioning, ordering + and the functions themselves are correct — the defect is purely the position + of window evaluation in the pipeline. +- **Correct semantics:** SQL evaluates window functions **after** `FROM`/`WHERE`/ + `GROUP BY`/`HAVING` and before `SELECT`-list projection, `ORDER BY` and + `LIMIT`. Filtering must therefore happen first. +- **Scope:** affects *every* window query that also filters — which is most real + ones. Silent in all cases. +- **A near miss worth recording:** `win_sum_partition_ordered` AGREEs under the + same `WHERE`, purely because the filtered-out rows carry NULL scores that `SUM` + ignores anyway. `COUNT(*)` is what makes this visible. A tier built only from + `SUM` windows would have concluded windows were fine. +- **Decision:** **Fix.** Move window evaluation after filtering in + `query_engine.rs`. The derived-table case is pinned separately so a fix applied + only to the top-level SELECT does not look complete. + +### P22 — Unimplemented window functions return NULL instead of erroring +- **Status:** 🔴 OPEN +- **Corpus:** `09_window.toml :: win_first_value`, `win_nth_value`, `win_ntile`, + `win_percent_rank`, `win_cume_dist` (all DIFFER). +- **Observed:** `FIRST_VALUE`, `NTH_VALUE`, `NTILE`, `PERCENT_RANK` and + `CUME_DIST` return **NULL for every row**. No error, no warning. +- **Not a whole missing family:** `LAST_VALUE`, `LAG`, `LEAD`, `ROW_NUMBER`, + `RANK`, `DENSE_RANK` and the aggregate-OVER forms all work and are pinned as + baselines. `FIRST_VALUE` being absent while `LAST_VALUE` works is the odd part. +- **Decision:** **Fix in two steps, and do the second first if the first is + slow.** (1) Implement the five functions. (2) Independently, make an + unrecognised window function a **hard error** rather than a NULL column — the + silence is worse than the absence, because a NULL column reads as "no data" + rather than "unsupported". +- **Related:** same class as P13 — unsupported input degrading into a plausible + wrong answer instead of a refusal. + +### P23 — `LAG`/`LEAD` ignore the third (default) argument +- **Status:** 🔴 OPEN +- **Corpus:** `09_window.toml :: win_lag_offset_default` (DIFFER). + Baselines: `win_lag`, `win_lead` (AGREE). +- **Observed:** `LAG(score, 2, -1)` honours the offset — the 1-arg form is + already correct — but drops the default, so rows past the partition edge come + back NULL instead of `-1`. +- **Decision:** **Fix.** Small and self-contained: thread the third argument + through as the out-of-range fallback. + +### P24 — A `RANGE` frame is treated as `ROWS` +- **Status:** 🔴 OPEN +- **Corpus:** `09_window.toml :: win_range_frame_with_ties`, + `win_default_frame_ordered` (both DIFFER). Baselines: the three explicit + `ROWS` frame cases (all AGREE). +- **Observed:** with ties in the ORDER BY key, `RANGE BETWEEN UNBOUNDED + PRECEDING AND CURRENT ROW` must include **all peer rows** at the current + value. At `score = 50` (two peers) DuckDB returns 160; we return 110 — one + peer only, i.e. ROWS behaviour. +- **The damaging half is the default frame.** With an `ORDER BY` in the window + and no explicit frame, the SQL default is `RANGE UNBOUNDED PRECEDING AND + CURRENT ROW`. `SUM(x) OVER (ORDER BY y)` is a far more common way to write a + running total than any explicit frame, and it is silently wrong wherever `y` + has duplicates. Explicit `ROWS` frames are unaffected and already correct. +- **Only detectable because the fixture has ties** — on distinct keys ROWS and + RANGE coincide, which is why this survived until `null_edges.csv` existed. +- **Decision:** **Fix.** Implement peer-group semantics for `RANGE`, and make + the no-frame-with-ORDER-BY default resolve to `RANGE` rather than `ROWS`. + +### P25 — A window's `ORDER BY` accepts only a plain column +- **Status:** 🔴 OPEN +- **Corpus:** `09_window.toml :: win_order_by_expression` (GAP). +- **Observed:** `RANK() OVER (ORDER BY score * -1)` → "Window function ORDER BY + ...". An expression inside the window's `ORDER BY` is rejected, though the + *outer* `ORDER BY` handles expressions fine (`08_ordering.toml :: + order_by_expression` AGREEs). +- **Decision:** **Fix** — a hard error, so no silent-wrong-answer urgency, but + it is an arbitrary restriction that the outer clause does not share. +- **Note:** [R2](ENGINE_REFACTORING.md) records that `WindowSpec::order_by` is + now descended into by the walk helpers, so the AST side is already reachable; + this looks like an evaluator restriction rather than a traversal gap. + +### P26 — A window function over an aggregate is rejected under `GROUP BY` +- **Status:** 🔴 OPEN +- **Corpus:** `09_window.toml :: win_over_aggregate_with_group_by` (GAP). +- **Observed:** `SELECT team, SUM(score) AS s, RANK() OVER (ORDER BY SUM(score) + DESC) FROM ... GROUP BY team` → "Expression 'v' must appear in GROUP BY + clause". The window alias is being subjected to the GROUP BY validity check, + although window functions are evaluated *after* grouping and are not + themselves grouped expressions. +- **Why it matters:** ranking groups by an aggregate is the standard "top N per + group" shape. `CLAUDE.md` already documents a CTE workaround ("Window + functions can't handle expressions directly. Use CTEs to pre-calculate"), so + this restriction is known in practice but was never written down as a gap. +- **Decision:** **Fix.** Exclude window-function outputs from the GROUP BY + validity check; they belong to the post-aggregation stage. Note this is the + same pipeline-position confusion as P21, approached from the other end — both + come down to *when* windows are evaluated relative to the rest of the query. + --- ## Deferred / won't fix (intentional) diff --git a/tests/comparison/corpus/09_window.toml b/tests/comparison/corpus/09_window.toml index 7ec9a773..84288f05 100644 --- a/tests/comparison/corpus/09_window.toml +++ b/tests/comparison/corpus/09_window.toml @@ -1,22 +1,219 @@ # Tier 9 — window functions and QUALIFY. # -# Added 2026-08-02, seeded with P15. Window functions are a large, working, and -# until now completely untested surface: the corpus had no OVER clause anywhere. -# This file starts with the QUALIFY gap and its control; the ranking, frame and -# navigation cases follow. +# Added 2026-08-02. Window functions were a large, working and completely +# untested surface: the corpus had no OVER clause anywhere. Six findings came +# out of the first pass (P15, P21-P25), four of them silent. # -# NB window evaluation has TWO code paths — `query_engine.rs` batch-evaluates by -# default, with SQL_CLI_BATCH_WINDOW=0 as the opt-out — and they have never been -# compared against each other. Worth running this tier both ways. +# Fixture: null_edges.csv — see the notes in 08_ordering.toml. The ties (50,50 +# and 70,70) are load-bearing here: they are what separates RANK from +# DENSE_RANK, and ROWS from RANGE. +# +# Every case ends in a TOTAL ordering (", id"). `normalize.py::has_order_by` is +# a substring check, so `OVER (ORDER BY ...)` alone already forces ordered +# comparison — without a total order these would flap on the ties above. +# +# CHECKED 2026-08-02: window evaluation has two code paths (`query_engine.rs` +# batch-evaluates by default, SQL_CLI_BATCH_WINDOW=0 opts out). Ran 12 window +# queries through both and they agree on all of them, so the batch optimisation +# is consistent with its fallback and these cases test one behaviour, not two. -# --- The baseline that works, so P15 is isolated to QUALIFY --- +# --- Baselines: the window machinery that is correct --- [[case]] id = "window_row_number" data = "international_sales.csv" sql = "SELECT region, amount, ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) AS rn FROM international_sales ORDER BY region, amount DESC" -# AGREEs. The same window expression P15 rejects in a QUALIFY clause evaluates -# correctly in the select list, so the gap below is in QUALIFY, not the window. + +[[case]] +id = "win_rank" +data = "null_edges.csv" +sql = "SELECT id, team, RANK() OVER (PARTITION BY team ORDER BY score DESC) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" + +[[case]] +id = "win_dense_rank" +data = "null_edges.csv" +sql = "SELECT id, team, DENSE_RANK() OVER (PARTITION BY team ORDER BY score DESC) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" + +[[case]] +id = "win_lag" +data = "null_edges.csv" +sql = "SELECT id, LAG(score) OVER (ORDER BY score, id) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" + +[[case]] +id = "win_lead" +data = "null_edges.csv" +sql = "SELECT id, LEAD(score) OVER (ORDER BY score, id) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" + +[[case]] +id = "win_last_value" +data = "null_edges.csv" +sql = "SELECT id, team, LAST_VALUE(score) OVER (PARTITION BY team ORDER BY score DESC, id) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" +# LAST_VALUE works, which is what makes FIRST_VALUE's silence (P22) a gap rather +# than a whole missing family. + +[[case]] +id = "win_sum_partition_ordered" +data = "null_edges.csv" +sql = "SELECT id, team, SUM(score) OVER (PARTITION BY team ORDER BY score DESC, id) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" + +[[case]] +id = "win_avg_over_empty_spec" +data = "null_edges.csv" +sql = "SELECT id, AVG(score) OVER () AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" + +[[case]] +id = "win_minmax_over" +data = "null_edges.csv" +sql = "SELECT id, MIN(score) OVER (PARTITION BY team) AS lo, MAX(score) OVER (PARTITION BY team) AS hi FROM null_edges WHERE score IS NOT NULL ORDER BY id" + +[[case]] +id = "win_rows_1_preceding" +data = "null_edges.csv" +sql = "SELECT id, SUM(score) OVER (ORDER BY score, id ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" + +[[case]] +id = "win_rows_unbounded_preceding" +data = "null_edges.csv" +sql = "SELECT id, SUM(score) OVER (ORDER BY score, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" + +[[case]] +id = "win_rows_unbounded_following" +data = "null_edges.csv" +sql = "SELECT id, SUM(score) OVER (ORDER BY score, id ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" + +[[case]] +id = "win_partition_null_key" +data = "null_edges.csv" +sql = "SELECT id, team, COUNT(*) OVER (PARTITION BY team) AS v FROM null_edges ORDER BY id" +# NULL forms its own partition, correctly. Also the P21 control — see below. + +[[case]] +id = "win_multi_partition_key" +data = "null_edges.csv" +sql = "SELECT id, COUNT(*) OVER (PARTITION BY team, score) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" + +[[case]] +id = "win_over_nulls" +data = "null_edges.csv" +sql = "SELECT id, score, LAG(score) OVER (ORDER BY id) AS v FROM null_edges ORDER BY id" +# LAG across NULL values returns the NULL, rather than skipping to a non-NULL. + +# --- P21: window functions are evaluated BEFORE the WHERE clause --- + +[[case]] +id = "win_count_over_filtered" +data = "null_edges.csv" +sql = "SELECT id, team, COUNT(*) OVER (PARTITION BY team) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" +expect = "DIFFER" +# P21. We report the UNFILTERED partition sizes (alpha=3, gamma=2); the filtered +# sizes are alpha=2, gamma=1. `win_partition_null_key` above is the same query +# without the WHERE and AGREEs exactly, which is what proves the partitioning +# itself is right and the defect is the evaluation ORDER. +# +# NB `win_sum_partition_ordered` AGREEs despite the same WHERE — purely because +# the filtered-out rows have NULL scores that SUM ignores anyway. COUNT(*) is +# the discriminating probe; a SUM-only tier would have missed this entirely. + +[[case]] +id = "win_row_number_filtered" +data = "null_edges.csv" +sql = "SELECT id, team, ROW_NUMBER() OVER (PARTITION BY team ORDER BY score DESC, id) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" +expect = "DIFFER" +# P21 seen through ranking: id 1 gets rank 2 because the filtered-out id 3 still +# occupies a slot in its partition. + +[[case]] +id = "win_in_derived_table_filtered" +data = "null_edges.csv" +sql = "SELECT id, v FROM (SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC, id) AS v FROM null_edges WHERE score IS NOT NULL) x ORDER BY id" +expect = "DIFFER" +# P21 again, through a derived table — pinned because a fix applied only to the +# top-level SELECT would leave this one wrong. + +# --- P22: unimplemented window functions return NULL instead of erroring --- + +[[case]] +id = "win_first_value" +data = "null_edges.csv" +sql = "SELECT id, team, FIRST_VALUE(score) OVER (PARTITION BY team ORDER BY score DESC, id) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" +expect = "DIFFER" +# P22: NULL for every row. + +[[case]] +id = "win_nth_value" +data = "null_edges.csv" +sql = "SELECT id, NTH_VALUE(score, 2) OVER (ORDER BY score DESC, id) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" +expect = "DIFFER" + +[[case]] +id = "win_ntile" +data = "null_edges.csv" +sql = "SELECT id, NTILE(3) OVER (ORDER BY score DESC, id) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" +expect = "DIFFER" + +[[case]] +id = "win_percent_rank" +data = "null_edges.csv" +sql = "SELECT id, PERCENT_RANK() OVER (ORDER BY score) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" +expect = "DIFFER" + +[[case]] +id = "win_cume_dist" +data = "null_edges.csv" +sql = "SELECT id, CUME_DIST() OVER (ORDER BY score) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" +expect = "DIFFER" + +# --- P23: LAG/LEAD ignore the third (default) argument --- + +[[case]] +id = "win_lag_offset_default" +data = "null_edges.csv" +sql = "SELECT id, LAG(score, 2, -1) OVER (ORDER BY score, id) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" +expect = "DIFFER" +# P23. The offset (2) is honoured — `win_lag` proves the 1-arg form works — but +# the default is dropped, so rows past the partition edge come back NULL +# instead of -1. + +# --- P24: a RANGE frame is treated as ROWS --- + +[[case]] +id = "win_range_frame_with_ties" +data = "null_edges.csv" +sql = "SELECT id, score, SUM(score) OVER (ORDER BY score RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" +expect = "DIFFER" +# P24. At score 50 (a tie) RANGE must include BOTH peer rows -> 160; we return +# 110, i.e. only one of them, which is ROWS behaviour. The ties in the fixture +# are the entire reason this is detectable. + +[[case]] +id = "win_default_frame_ordered" +data = "null_edges.csv" +sql = "SELECT id, score, SUM(score) OVER (ORDER BY score) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" +expect = "DIFFER" +# P24, and the more damaging half: with an ORDER BY and no explicit frame the +# default is RANGE UNBOUNDED PRECEDING TO CURRENT ROW. Users write this form far +# more often than an explicit RANGE, and it is silently wrong on ties. + +# --- P25 / P26: window placements we reject --- + +[[case]] +id = "win_order_by_expression" +data = "null_edges.csv" +sql = "SELECT id, RANK() OVER (ORDER BY score * -1) AS v FROM null_edges WHERE score IS NOT NULL ORDER BY id" +expect = "GAP" +# P25: "Window function ORDER BY ...". A window's ORDER BY accepts only a plain +# column. Note the outer ORDER BY handles expressions fine (tier 08). + +[[case]] +id = "win_over_aggregate_with_group_by" +data = "null_edges.csv" +sql = "SELECT team, SUM(score) AS s, RANK() OVER (ORDER BY SUM(score) DESC) AS v FROM null_edges WHERE score IS NOT NULL GROUP BY team ORDER BY team" +expect = "GAP" +# P26: "Expression 'v' must appear in GROUP BY clause". Ranking groups by an +# aggregate is the standard "top N per group" shape, and the CTE workaround in +# CLAUDE.md exists precisely because of this. + +# --- P15: QUALIFY does not accept an INLINE window function --- [[case]] id = "qualify_select_list_alias" @@ -26,8 +223,6 @@ sql = "SELECT region, amount, ROW_NUMBER() OVER (PARTITION BY region ORDER BY am # has already hoisted out of the SELECT list. This is the control that locates # P15 in the lifter rather than in QUALIFY. -# --- P15: QUALIFY does not accept an INLINE window function --- - [[case]] id = "qualify_row_number" data = "international_sales.csv" From 535aa36d3d7c0e37afaa417b7a77a918a6ec2bab Mon Sep 17 00:00:00 2001 From: TimelordUK Date: Sun, 2 Aug 2026 09:46:06 +0100 Subject: [PATCH 6/6] build(parity): pin DuckDB at 1.5.5; close the discovery phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the reference engine in pyproject.toml [dependency-groups].test and points CI at that group instead of `uv pip install duckdb`, so the version lives in one place. Verified 1.5.5 first: all 150 cases stay in their existing buckets, zero drift, so this is the highest pin available without reworking anything already agreed. Why pin at all: the corpus now contains cases where the SQL standard leaves behaviour implementation-defined and we follow the reference engine by policy (P17 NULL ordering, P24 frame defaults). Those cases encode DuckDB's current choices, so an unpinned bump could redden the gate for reasons unrelated to our engine and point at the wrong place while doing it. Also qualifies that policy, which as written read too absolutely: DuckDB is a reference point, not a specification. The goal is to stop being accidentally different, not to reproduce DuckDB exactly — where a difference is a genuine DuckDB idiosyncrasy we can mark it WON'T FIX and move on. The rule saves us re-litigating ambiguous cases; it is not a commitment to chase quirks. And records where the effort is up to: discovery is paused. Two days took the corpus 83 -> 150 and the open findings 1 -> 15, several of which need a session apiece, so the effort moves to fixing. Adds a suggested fix order ranked by silent blast radius (P21 and P13 first), and notes that tier 10 is deliberately partial and wants finishing during a lull. Co-Authored-By: Claude Opus 5 --- .github/workflows/test-complete.yml | 14 ++++-- docs/SQL_PARITY.md | 46 +++++++++++++++-- pyproject.toml | 9 +++- uv.lock | 76 ++++++++++++++--------------- 4 files changed, 97 insertions(+), 48 deletions(-) diff --git a/.github/workflows/test-complete.yml b/.github/workflows/test-complete.yml index afbe9d2d..5d0d1cac 100644 --- a/.github/workflows/test-complete.yml +++ b/.github/workflows/test-complete.yml @@ -218,16 +218,20 @@ jobs: run: cargo build --release - name: Install Python dependencies - run: | - # Only create .venv if the cache step didn't restore one. - [ -d .venv ] || uv venv - uv pip install duckdb + # Resolves the pinned DuckDB from pyproject.toml [dependency-groups].test + # rather than installing whatever is latest. The corpus contains cases + # where the SQL standard leaves behaviour implementation-defined and we + # follow the reference engine by policy, so those cases encode DuckDB's + # current choices. An unpinned bump could redden this gate for reasons + # unrelated to our engine — and point at the wrong place when it did. + # Bump the pin in pyproject.toml deliberately and review any drift. + run: uv sync --group test - name: Run parity check (sql-cli vs DuckDB) # Fails if any case drifts from its expected bucket: a regressed AGREE, a # silently-closed gap, or a new un-annotated non-AGREE case. See # docs/SQL_PARITY.md for the contract. - run: uv run python tests/comparison/runner.py --check + run: uv run --group test python tests/comparison/runner.py --check - name: Upload parity report if: always() diff --git a/docs/SQL_PARITY.md b/docs/SQL_PARITY.md index c69203cc..3b9cca2f 100644 --- a/docs/SQL_PARITY.md +++ b/docs/SQL_PARITY.md @@ -39,13 +39,51 @@ SQL standard leaves implementation-defined and where the major engines genuinely disagree. Rather than judge each one on its merits, the default answer is **match DuckDB** — that is what having a reference engine is *for*, and it keeps "broad-brush parity" a single rule instead of a growing pile of one-off -rationales. Diverging remains available, but it now has to be argued for on -design grounds and recorded in *Deferred / won't fix* below. +rationales. Diverging remains available, but it has to be argued for on design +grounds and recorded in *Deferred / won't fix* below. + +**DuckDB is a reference point, not a specification.** The goal is to be brought +*in line* — to stop being accidentally different — not to reproduce DuckDB +exactly. Where a difference is a genuine DuckDB idiosyncrasy rather than +standard or widely-shared behaviour, we are under no obligation to follow it; +mark it ⚪ WON'T FIX with the reasoning and move on. The rule above is a default +that saves us re-litigating the ambiguous cases, not a commitment to chase +quirks. One consequence worth naming: this makes the reference engine's *version* part of our contract, not just its behaviour. Implementation-defined cases pin -whatever DuckDB currently chooses, so the pinned DuckDB version should be -bumped deliberately rather than floating. +whatever DuckDB currently chooses, so the DuckDB version is pinned in +`pyproject.toml` (`[dependency-groups].test`) and used by CI — bump it +deliberately and review the drift, rather than letting it float. + +## Where this effort is up to + +**Phase: fixing.** 2026-08-01/02 was a deliberate discovery push — the corpus +went from 83 to 150 cases and the open findings from one (P3) to fifteen. That +is enough surfaced work to be going on with, and several of these will take a +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. **Tier 10 (aggregate & NULL edges) is +deliberately partial** — it holds the P14 and P18–P20 cases and their baselines, +but was never built out the way tiers 08 and 09 were. Finish it during a lull; +the aggregate-function surface (`STDDEV`, `DISTINCT` aggregates, `FILTER`, +empty-vs-all-NULL distinctions) is largely unexamined. + +Suggested fix order, by silent blast radius: + +| | Finding | Why first | +|---|---|---| +| 1 | [P21](#p21) windows evaluated before `WHERE` | Silent, and wrong for essentially every window query that filters | +| 2 | [P13](#p13) trailing tokens discarded | Silent, unbounded scope — any typo becomes a different working query | +| 3 | [P18](#p18)/[P19](#p19) three-valued logic | Silent, and P18 produces *extra* rows | +| 4 | [P24](#p24) `RANGE` treated as `ROWS` | Silent, hits the common `SUM(x) OVER (ORDER BY y)` running-total form | +| 5 | [P14](#p14), [P16](#p16), [P17](#p17), [P20](#p20), [P23](#p23) | Smaller, self-contained, decisions already taken | +| 6 | [P22](#p22), [P25](#p25), [P26](#p26), [P15](#p15) | Hard errors — visible, so less urgent than any of the above | + +P3 (correlated subqueries) stays gated on the R7/R6 structural work in +[`ENGINE_REFACTORING.md`](ENGINE_REFACTORING.md) and is not part of this queue. ## Status legend diff --git a/pyproject.toml b/pyproject.toml index 2b68ebf5..94d96e95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,5 +30,12 @@ build-backend = "hatchling.build" [dependency-groups] test = [ - "duckdb>=1.5.4", + # Pinned, not floating. The parity corpus contains cases where the SQL + # standard leaves behaviour implementation-defined (see "Where the standard + # leaves a choice open" in docs/SQL_PARITY.md) — P17 NULL ordering and P24 + # frame defaults among them. Those cases encode whatever DuckDB currently + # chooses, so an unpinned bump can redden the gate for reasons that have + # nothing to do with our engine, and point at the wrong place when it does. + # Bump deliberately: raise this, run the harness, review any drift. + "duckdb==1.5.5", ] diff --git a/uv.lock b/uv.lock index 288cc5b5..c9dd5082 100644 --- a/uv.lock +++ b/uv.lock @@ -129,44 +129,44 @@ toml = [ [[package]] name = "duckdb" -version = "1.5.4" +version = "1.5.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/29/9bad86ed7aa812d8c822a27c15c355b6d5423b991feeec86ed18027b6daa/duckdb-1.5.4.tar.gz", hash = "sha256:f9e32f1cdd106793d79d190186bed9e75289d51e68bd9174e47c04bffedeab6f", size = 18046634, upload-time = "2026-06-17T10:48:52.499Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/19/e57151753576373c6696a12022648546cca6038e8833fda2908ee2342d9b/duckdb-1.5.5.tar.gz", hash = "sha256:72f33ee57ca7595b23957671a2cc7f7fe2be0ecc2d68f63abedcfcaa3a5c1238", size = 18066741, upload-time = "2026-07-22T10:55:17.819Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/ee/69340af74a3aa21838f14c16a0dd3e58461896ccba41f6bc7f0a01536e23/duckdb-1.5.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3ddd9533ce80f9b851bdd6276960a9286166514a9ceca43d5bc2f0d5842c490d", size = 32656177, upload-time = "2026-06-17T10:47:31.044Z" }, - { url = "https://files.pythonhosted.org/packages/73/18/9da267ade389d4e7e533ac0c77b3a7041513a66efab93beb84f27627b0b8/duckdb-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:360f2542d09759c3739400f8b787e29b43ba0da665c21756216291458bf6fc59", size = 17318966, upload-time = "2026-06-17T10:47:33.688Z" }, - { url = "https://files.pythonhosted.org/packages/76/b4/ad73c1a396288e443b18af50819448060b318c1e933305167c1d7f98a507/duckdb-1.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0cf932055061e544d3fa27cc6c147da25f3f681ee5980157fb55e77d6c2d9c63", size = 15467572, upload-time = "2026-06-17T10:47:36.071Z" }, - { url = "https://files.pythonhosted.org/packages/6f/06/2c52ce3b97c3f21111f3c98a2121ed002e33f86488f55098a37825af6d4a/duckdb-1.5.4-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2d58d39f5e65419cdc27e3875cba4a729a3bbf6bf4016aefb4a2a65335a1d42", size = 19344044, upload-time = "2026-06-17T10:47:38.174Z" }, - { url = "https://files.pythonhosted.org/packages/5a/7d/5c0cc66fb90a1b14474eebb5ab535eacc51cb20b0e45358348b51c07abc9/duckdb-1.5.4-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9a10dc40469b9c0e458625d2a8359461a982c6151bb53ff259fea00c4695ad4", size = 21448215, upload-time = "2026-06-17T10:47:40.617Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2c/16c3ea201855cdfb7dde52ea3678d0536861cb485ffad46cf345436d658d/duckdb-1.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:3565550adbf160ef7a2ee3395470570182f11233983ad818bd7d5f9e349f92b2", size = 13132138, upload-time = "2026-06-17T10:47:43.085Z" }, - { url = "https://files.pythonhosted.org/packages/56/bb/7921dabd50daef3969f14cd8a5a14c24eee337db7914a462f2defa8add92/duckdb-1.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3fb41d9cfccb7e44511eeeed263ae98143ca63bdb1ef84631ba637c314efa1b5", size = 32663142, upload-time = "2026-06-17T10:47:45.471Z" }, - { url = "https://files.pythonhosted.org/packages/a6/83/2137765eaba6a9aefe3bb9848ddaac7407fe3ba19b292f98b31f3b7ab27f/duckdb-1.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8ba7b666bc9c78d6a930ee9f469024149f0c6a23fb7d2c3418aad6774339bec0", size = 17321485, upload-time = "2026-06-17T10:47:47.778Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b2/a02c1ee43fd7e8cf1fc2e3d377f3dcf9d4a3e58a4549557516e1866ff0da/duckdb-1.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9d9e6817fcbc09d2605a2c8c041ac7824d738d917c35a4d427e977647e1d7944", size = 15470820, upload-time = "2026-06-17T10:47:49.977Z" }, - { url = "https://files.pythonhosted.org/packages/d8/48/a243d30223b024bc6057abe472b002cff01e97efefb4d2f0b0dcc5aece0b/duckdb-1.5.4-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02dd9f9a6124069213f13e3a474c208028c472fe1acdae12b38761f954fe4fc6", size = 19341849, upload-time = "2026-06-17T10:47:52.205Z" }, - { url = "https://files.pythonhosted.org/packages/08/ff/a5d48de4771e2403a8ef26a20dc7457b1c8f7e398ff0caf9c0cad8805f89/duckdb-1.5.4-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccc7f2694d02b4763fee61021d45e12f7bc5743993686563957df0cef799fbae", size = 21451698, upload-time = "2026-06-17T10:47:54.653Z" }, - { url = "https://files.pythonhosted.org/packages/79/b8/8244d7741b4afae67775cf0cb0d4eb9e923a83110907e4801e17fa078480/duckdb-1.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:4c430e788d99b50854209bf2833ba36a45df75e57f86efb477046cd408bbd077", size = 13132643, upload-time = "2026-06-17T10:47:56.75Z" }, - { url = "https://files.pythonhosted.org/packages/e4/57/8169822a37f6dd7d561c567f9007e3cf04bf97bccb619afe90db849c0962/duckdb-1.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:e2dc8340cfb6006025a798c50f40126d6e945a1d2487be94667bb4166556ce7b", size = 13986386, upload-time = "2026-06-17T10:47:59.345Z" }, - { url = "https://files.pythonhosted.org/packages/c8/f2/e2f4b477ae3a3b40e8b5f429832e48edb62ed9da99807cc4902e157e5646/duckdb-1.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:291a9e7502551170af989ff63139a7a49e99d68edbc5ef5017ac27541fe54c65", size = 32708876, upload-time = "2026-06-17T10:48:01.527Z" }, - { url = "https://files.pythonhosted.org/packages/2e/2b/b698d82a5e1e30b6a05748d72045f672994c6b22f4f0f8423523608b991f/duckdb-1.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83e8c089bbb756ca4471d8b05943b80a106058697cf00615e70423106bb783bc", size = 17346125, upload-time = "2026-06-17T10:48:04.035Z" }, - { url = "https://files.pythonhosted.org/packages/71/75/37e13f39268eaf34864453b3a039c4a1ff0b088d3eae45a4289b41c98c1b/duckdb-1.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ff96d2a342b200e1ec6f1f19986c77f4ac16a49b6112f71c5b763989203a9d60", size = 15488133, upload-time = "2026-06-17T10:48:06.312Z" }, - { url = "https://files.pythonhosted.org/packages/cc/59/2d082af578f689231798245b54562c61416e49049b0bda81a06c56a4b53e/duckdb-1.5.4-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f935ef210ab00bc94bb1e3052697adaa36bb0ce7bdfeda8b0f34e2ff1643870", size = 19367895, upload-time = "2026-06-17T10:48:08.59Z" }, - { url = "https://files.pythonhosted.org/packages/52/2b/55c34d2863a76ca824ef8274691e84240b4ff1acde3d231709e82557c240/duckdb-1.5.4-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cda263d8c20addb8d4f95464787cbe0af1144f7ab7e21db3709fb826ee01725", size = 21486499, upload-time = "2026-06-17T10:48:10.963Z" }, - { url = "https://files.pythonhosted.org/packages/cf/30/ade5952b8182fac86fab43b95ebe3836e66381d0ad64eb1e54bd8207c988/duckdb-1.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:266c7c909558ce7377f57d082cee408aadebdd9111be017558ca54e44a031037", size = 13147934, upload-time = "2026-06-17T10:48:13.061Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/278f0f70e25b9911afe2fd227b9460f2e6d76177f0dcc03f7f1454afefa5/duckdb-1.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:f14e79a006341f29ee5a2692a24dac5114e77533d579c57ec39124adf0135033", size = 13965235, upload-time = "2026-06-17T10:48:15.782Z" }, - { url = "https://files.pythonhosted.org/packages/da/69/3fcb34e523a9bad1f0557a6c7691a71ba66c43a05e5be9ee96a9a841ed65/duckdb-1.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:42a612e67d64450b446eb69695290d460713eef46e0f64467ab9dfe96264ee05", size = 32708366, upload-time = "2026-06-17T10:48:18.084Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5f/bff5054c2c1d65decab36aa6296621e51a2a575a9f250db7ab9b83a325d6/duckdb-1.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3fb6f07d54ecf4d0d3c5179a2361fdddfafa14de4fc42696de4632479b703421", size = 17345735, upload-time = "2026-06-17T10:48:20.67Z" }, - { url = "https://files.pythonhosted.org/packages/93/12/d1b2b344e9699246aada6f9de5156e708fb476e2780e5bff9b5d95fe11d9/duckdb-1.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f32ad7e0286c1c29ab6c73b29118c86101f8eee46aae54f54d0b50916f542f6", size = 15488568, upload-time = "2026-06-17T10:48:23.038Z" }, - { url = "https://files.pythonhosted.org/packages/c1/d1/ac56c6096e3e95da60b2c5dd5a0f0eb5540a80622e2e4f8faab893ec4e96/duckdb-1.5.4-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:698ec90bd5d5538bd5f6d212a4b61af443d240703cf45f134738535026556ea5", size = 19368184, upload-time = "2026-06-17T10:48:25.601Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0b/2ae4c3e157a19d9b4ac1f09a5dea6f93012334cc2db09f1e0c71eb99693d/duckdb-1.5.4-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136cea7f886b78caf4035485b4b1e766e8b309e999f9e83a966f81ebb8122844", size = 21486523, upload-time = "2026-06-17T10:48:27.817Z" }, - { url = "https://files.pythonhosted.org/packages/64/7b/c3d8d21e0d0db8faa81eeeb3a55b9932f5a0a16466cb968dc713a653d701/duckdb-1.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:bd6777e8ddd74fb603a6d09766bfcff28638189f8aaa61fc0dffd9e9a4baa8e5", size = 13147807, upload-time = "2026-06-17T10:48:30.017Z" }, - { url = "https://files.pythonhosted.org/packages/44/48/ddf8d3740e3d28582944f70d84e720b5dc28c10ec22b668a0e0bd965f2f2/duckdb-1.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:73f4878a3012283024a64a1909e440aac12091ef336f671fc142f7e87449ce0c", size = 13965189, upload-time = "2026-06-17T10:48:32.251Z" }, - { url = "https://files.pythonhosted.org/packages/62/01/67ac4cbc8e552a1e14c029b5c443d828e68f94d5d913c574f577e1db277e/duckdb-1.5.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4647968629d0677bbcc2416c7aeda8685eb84e4ca15a6dbd4f82a66cfc91a532", size = 32714364, upload-time = "2026-06-17T10:48:34.724Z" }, - { url = "https://files.pythonhosted.org/packages/4e/0e/eb44d983fa56b175f971eea251bde284a36d26cbb93fcb68035061f54078/duckdb-1.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e8fcef301cf68d3951ea1eb8ac4d76cea0a6f6a08f4c78fe4026fc96d217bebc", size = 17349820, upload-time = "2026-06-17T10:48:37.126Z" }, - { url = "https://files.pythonhosted.org/packages/10/b2/b9dc7624b105d414585b8530451c1162c0b4750c0be9be2e497bb47a8a9b/duckdb-1.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f6f39cd0dc6948dee17fd130aec55114f97a8ef6e1db519b9774087962bc5c8c", size = 15498160, upload-time = "2026-06-17T10:48:40.032Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/61356444f6a8c62dec3c3d129abfc53f428de1d484093d1bb381db441231/duckdb-1.5.4-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:262f068158beb5943f2c618f4e54b46db8306b959f90dce956f90a89f613673d", size = 19374183, upload-time = "2026-06-17T10:48:42.698Z" }, - { url = "https://files.pythonhosted.org/packages/b0/f4/d5d633dd7c5138d8f7c434e6ac2553c831b7fb658494efa8d0bc73df8623/duckdb-1.5.4-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d2307a76d199077b0055b354e90e857479461a0d875437535dd4833172c8b6d", size = 21487202, upload-time = "2026-06-17T10:48:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/c0/26/5be13bbd5c3421dccfc1ad4ca9da4b97c5a3ddd73f66542092f3167ec52c/duckdb-1.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:6dcbb81a1276bc48deb4d562bce4f8895e4fc6348750a096e30052345c6d6552", size = 13666989, upload-time = "2026-06-17T10:48:47.764Z" }, - { url = "https://files.pythonhosted.org/packages/dc/82/4d52f3f9f9703a226b26b80bdae3f6905aeefe5221bf1815fc93ff02ca25/duckdb-1.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:0f8722346024e5d9f02b58bf7b0491a629f97fdc8a04a10e432940f471ee387a", size = 14449863, upload-time = "2026-06-17T10:48:50.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/d4/298acf9331a80b3ce6ac64dd940e7e13f4058fb69d18914445f02e3c7bfe/duckdb-1.5.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b805507f88171b428b21c966c30e9a3d54e30b24528918a44ed0032542bc26f", size = 32702934, upload-time = "2026-07-22T10:53:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/c489fb63d64b2e7ee109ce8460bdede003a0f256e5b41a03a2a1c4764058/duckdb-1.5.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b08e19cc856220d8a26fa62abc2264b349aff67255e9373c6a3f607addd56dc6", size = 17343604, upload-time = "2026-07-22T10:53:22.767Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/effa80a15b1f0c61c235622f797868485359e8c9ad6a8e358e7a0c479151/duckdb-1.5.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a17e6a922e42a5c06ed2353fe78c5dff2610f6632d603836f9606ad0bf754079", size = 15488179, upload-time = "2026-07-22T10:53:25.945Z" }, + { url = "https://files.pythonhosted.org/packages/5d/07/21212345c8d24ba62dceaa20be3b21f5c46f1510b1b42ce93bb058afe0c4/duckdb-1.5.5-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bdc38922c365c37720149f90d90b1e9823eb82dad6830855b5f87537fa6fc0c", size = 19367323, upload-time = "2026-07-22T10:53:30.23Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d0/10371ae875fb4b5ef61bb892743b4b2e90c512b371fdf29317deb744857d/duckdb-1.5.5-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e238060db5ca59879882a6e9b015e2c65d5c64ddf281ba1d7a9a2033764152cf", size = 21476568, upload-time = "2026-07-22T10:53:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/09568ce617dd7bc0757b3d7b6a981660b9e4f0b7594de8ed776755eae740/duckdb-1.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:4acc72798ba1885a9c17d1242903d2cd502f13b1271c7677f7cab25d8578eceb", size = 13156129, upload-time = "2026-07-22T10:53:37.55Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c2/b62ec24d57bb8df4e24b0b58f7f8facb32f5fdb9f1895aed9e9fcdded168/duckdb-1.5.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b543841b0ae18a9c982345cfa3987e9c065d3a4b0f067daa473d92d1e65f528", size = 32708371, upload-time = "2026-07-22T10:53:41.642Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ce/769171ba45f0b73632dc3bc3108d891e81dd6c6bbfba630a34a75b4dcc0f/duckdb-1.5.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a925d06c2a4c3b64553d6cc1aced5028d376d4479bed689a7d47e9b1dccd80a", size = 17343979, upload-time = "2026-07-22T10:53:44.951Z" }, + { url = "https://files.pythonhosted.org/packages/46/59/a8e3384ee916e00d5dcf985194c1511d61978540778a1e96fa47f9fb3e0d/duckdb-1.5.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0c42757cb34722144bd4dfb94b6f336339e7b2468f6813fa7fa9a319ba07bab4", size = 15493704, upload-time = "2026-07-22T10:53:47.912Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1d/9840179c2607b90523a2884a129c4d4e6dbdc1178ba62a976c1043beba88/duckdb-1.5.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e72f9e1a4f90a5c8483ad4d540e495bf0834ba61c360b52499a573d7ed62a3f", size = 19366574, upload-time = "2026-07-22T10:53:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/b5/55/f9641a4eebcc2f4df631287d6c3b9ed2eea3b92644f93acbad825e3972b6/duckdb-1.5.5-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9b6f86ed85d4ef5e0211eaebf75d057bd8bb520bba438a95dd0f4e42234bbfe", size = 21477952, upload-time = "2026-07-22T10:53:55.575Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3a/07c3556e37a5c97b95917b029c8fdde4a25fbd76a660bacdac195cf20dcb/duckdb-1.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:9f4287f97ccf0c1f3d471e7115be2b067cbf99627e2d34bffd462dd64703cddc", size = 13156986, upload-time = "2026-07-22T10:53:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ff/07b48eef2078ca033847e9caa46cc7633b714c5f91ad1ce091c8ca89d792/duckdb-1.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:179633a3fc6296c75d57c69c1e239fa9e5cdcb670fd1dbff88a02663f932905c", size = 14001317, upload-time = "2026-07-22T10:54:01.724Z" }, + { url = "https://files.pythonhosted.org/packages/d6/40/2e05d324400fdaa5656c9f48d6298da421cb034d85e509fa0e6e325cf04b/duckdb-1.5.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d4dd65f8941a604b947e0b9b4b4f7165988e29a23ec0b69b4038520956d9933e", size = 32753858, upload-time = "2026-07-22T10:54:05.514Z" }, + { url = "https://files.pythonhosted.org/packages/79/15/5ceb58ffb5bb8a62b3fd7abb39c41467cdf94850ece02e6d88664dfc75ce/duckdb-1.5.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33db46679b071f108d57139493dee2d37e1f5efcf5c5c039c2969eed11a6c8a7", size = 17368293, upload-time = "2026-07-22T10:54:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5c/bf02da0b354fe83cca4f95a4fbf762181af466f7d551ab2a093f7698882a/duckdb-1.5.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f0b88535a5d86fdd63dba6ea02ab68c003dfb9e4892b11256ef24c4da208baae", size = 15509131, upload-time = "2026-07-22T10:54:12.228Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a9/5f1f09da421d8e930e0b063d11c1b3f90363f40ede74438cd188afdd13a2/duckdb-1.5.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f316eae2323d9a851883fdf2dee91c1f9efe251ab33e14a2272f82a913422ed6", size = 19391959, upload-time = "2026-07-22T10:54:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/4f/98/6549769f158126fa64fd6c1ac2eb59a18282146c939867a3eb31b7c1db07/duckdb-1.5.5-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a6d2d11859d82a936ebdcb30ce3d8a1cbb3e990bff05c12abb9b54c44fa7bd1", size = 21510909, upload-time = "2026-07-22T10:54:19.681Z" }, + { url = "https://files.pythonhosted.org/packages/af/b7/5753b41d3124838f868f9f523362812d9fc45409e9e4dd70dcbb0a25826e/duckdb-1.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:ddfbdb096c11d51ee22492397d342c90a82e62c5d09961477895934d0a25372f", size = 13168544, upload-time = "2026-07-22T10:54:22.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/28/44b679c7d46245f8398feae7edac959d1b83d4eb143e25b3fce0630b78bd/duckdb-1.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:2725d2b9ace3a4e75d72fc5a239f6a44b502c580edadb8fb2676db772c5f9282", size = 13988684, upload-time = "2026-07-22T10:54:26.003Z" }, + { url = "https://files.pythonhosted.org/packages/47/37/4a38116e7700720fd152c666292214fd3abdf916496991296d8d1f66efbf/duckdb-1.5.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd98829b67788609017e65c761bd42a5dd0f9129441bed8bda4d6881ccf819f0", size = 32754294, upload-time = "2026-07-22T10:54:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/7d392f1ba1eee0eaf4ab4c8c7a604bfe3536cd63f979cf5c98798664f807/duckdb-1.5.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:feead93c56679b79592d437c62975d39cb67adedffa7592c763baf8160ac7366", size = 17368211, upload-time = "2026-07-22T10:54:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a5/0a6f4fa60562faa615e55e15bd1953a2f2b17a8edd8105e5cda215e43457/duckdb-1.5.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:49c963d9469373d7aba8d750d9ea565ab823e94166efed953f184dd9b169b98c", size = 15509136, upload-time = "2026-07-22T10:54:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/e4/cb/023c89f51978545b9fab318581bba0c457a58e7530d2d933e54ae7d8647c/duckdb-1.5.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a736217825461732b5442d05a220f3da2e23a0dae114efbf08c9bf171b53098a", size = 19392147, upload-time = "2026-07-22T10:54:39.551Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c5/41bef391fb8b23dbc133c9f2ba016e7a7a8124513d2cc1b430f1897d87e4/duckdb-1.5.5-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:078e6a60dd8eedde5832f45422ca5c4a6b8c837aeabd8a56ca0b7d933f588053", size = 21511060, upload-time = "2026-07-22T10:54:42.788Z" }, + { url = "https://files.pythonhosted.org/packages/07/9f/c44dfc1f924ac29b3252dc1b91393c01d009dbfe9f8ed33f10b986151bd1/duckdb-1.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:6826504277dba513c0c5d71d828456c94d729c9d2482f94b2e289f90a9167e28", size = 13168028, upload-time = "2026-07-22T10:54:46.127Z" }, + { url = "https://files.pythonhosted.org/packages/ca/88/591384b2cd59abddd6f5dc175e60374f9abae6064429f0c4402854c10f44/duckdb-1.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:baa9c5702002fabb559ded2a39008f9f421fcbc7237d388b8213eff1e08858de", size = 13989955, upload-time = "2026-07-22T10:54:49.262Z" }, + { url = "https://files.pythonhosted.org/packages/3e/56/12c65bfa2d2605b81981b264788891bcf11ec72227889554cead5d8d13b9/duckdb-1.5.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8e6413dd40facb7b8ab21bd844450cd8f549b29e138635be9cf090ef4d2049e2", size = 32761946, upload-time = "2026-07-22T10:54:53.412Z" }, + { url = "https://files.pythonhosted.org/packages/b9/46/682ce155f17e0d2822d4f13ee3db9ca4b5b7c2da61b841b2629035e1f4bc/duckdb-1.5.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:64078acfd16541132ac6e191eb81b2845554444a0305cc1aa581ba107e514aa8", size = 17375069, upload-time = "2026-07-22T10:54:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/39/ce/a24bcbd3289c8f305a430759c5fc12242740b4af3e17f7593f3a34e333d2/duckdb-1.5.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8c11775cc99a447618d5f1840126db17f2652f3eae05529df4f81f40e2df7151", size = 15519791, upload-time = "2026-07-22T10:55:00.681Z" }, + { url = "https://files.pythonhosted.org/packages/d9/76/3a01afbc615c1d418c0de58a6b68ac5ce2a8563232c0464bfbc2ce552398/duckdb-1.5.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77bbc1e6ba12e1e06f9020117bdf848627ecfdf36f907550e62e008e6109dece", size = 19398251, upload-time = "2026-07-22T10:55:04.168Z" }, + { url = "https://files.pythonhosted.org/packages/a1/43/3a5e81d1728f4d234c79bfe385808ee7c04834f7c37a4b5c257459c25614/duckdb-1.5.5-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbf0f2d48b43c6c304d00463b463c27ead6c4b01c3c1816b750f728decf71afe", size = 21513851, upload-time = "2026-07-22T10:55:07.864Z" }, + { url = "https://files.pythonhosted.org/packages/91/41/fc7c829172c60ca22485251eab285f4f1a0d87b486a024c726f21471d86e/duckdb-1.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:9dc826c4b50e64f6c4e4d07a3a9cb075ef70ba3899dc43ec5493dc3d7b04b353", size = 13691858, upload-time = "2026-07-22T10:55:11.181Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/95d9216b79e9273689d7ebce125a54503ed0c9bd7da931f0265888e99779/duckdb-1.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:63e48d4b74b15aeacd688976432a7225163df8c226eddeb8536bba2d4d4ff433", size = 14470180, upload-time = "2026-07-22T10:55:14.445Z" }, ] [[package]] @@ -174,7 +174,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -629,7 +629,7 @@ requires-dist = [ ] [package.metadata.requires-dev] -test = [{ name = "duckdb", specifier = ">=1.5.4" }] +test = [{ name = "duckdb", specifier = "==1.5.5" }] [[package]] name = "tomli"