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/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/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..3b9cca2f 100644 --- a/docs/SQL_PARITY.md +++ b/docs/SQL_PARITY.md @@ -32,6 +32,59 @@ 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 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 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 | Status | Meaning | @@ -356,6 +409,319 @@ 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. + +### 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 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). +- **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. +- **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. 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 +- **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 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 + `'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. +- **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. + +### 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/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/tests/comparison/corpus/08_ordering.toml b/tests/comparison/corpus/08_ordering.toml new file mode 100644 index 00000000..2d1b316a --- /dev/null +++ b/tests/comparison/corpus/08_ordering.toml @@ -0,0 +1,170 @@ +# 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, 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. +# --------------------------------------------------------------------------- + +# --- 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" +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_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, 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" +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. + +# --- 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/09_window.toml b/tests/comparison/corpus/09_window.toml new file mode 100644 index 00000000..84288f05 --- /dev/null +++ b/tests/comparison/corpus/09_window.toml @@ -0,0 +1,236 @@ +# Tier 9 — window functions and QUALIFY. +# +# 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. +# +# 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. + +# --- 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" + +[[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" +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. + +[[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..a33eb74a --- /dev/null +++ b/tests/comparison/corpus/10_aggregate_nulls.toml @@ -0,0 +1,152 @@ +# 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. + +# --------------------------------------------------------------------------- +# 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. + 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"