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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 84 additions & 2 deletions docs/SQL_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,16 @@ Suggested fix order, by silent blast radius:
| | Finding | Why first |
|---|---|---|
| ~~1~~ | ~~[P21](#p21) windows evaluated before `WHERE`~~ | ✅ **Fixed 2026-08-02** |
| ~~2~~ | ~~[P13](#p13) trailing tokens discarded~~ | ✅ **Stage 1 done 2026-08-02**; stage 2 (`NULLS FIRST`/`LAST`) is item 6 below |
| ~~2~~ | ~~[P13](#p13) trailing tokens discarded~~ | ✅ **Stage 1 done 2026-08-02**; stage 2 (`NULLS FIRST`/`LAST`) is in row 9b below |
| ~~3~~ | ~~[P30](#p30) `cond AND col IN (list)` returns 0 rows~~ | ✅ **Fixed 2026-08-08** |
| ~~5~~ | ~~[P29](#p29) boolean operator after `IN (...)`~~ | ✅ **Fixed 2026-08-08** — same bug as P30, one change closed both |
| ~~4~~ | ~~[P28](#p28) `INTO #tmp` stages unfiltered rows~~ | ✅ **Fixed 2026-08-16** — turned out to stage the *whole source table*, and the sweep it prescribed found [P31](#p31) |
| ~~7~~ | ~~[P18](#p18)/[P19](#p19) three-valued logic~~ | ✅ **Fixed 2026-08-22** — 125 → 129 AGREE. Delivered as three slices ([R10](ENGINE_REFACTORING.md#r10)); the two no-op ones landed first, so the semantics change reviewed on its own |
| ~~8~~ | ~~[P24](#p24) `RANGE` treated as `ROWS`~~ | ✅ **Fixed 2026-08-30** — 129 → 133 AGREE (+2 fixed, +2 new coverage). One defect, not two: the parser already emitted the right default frame, so fixing peer groups closed both cases. Spun off [P33](#p33) |
| ~~9a~~ | ~~[P16](#p16) `ORDER BY <ordinal>` ignored~~ | ✅ **Fixed 2026-08-31** — 134 → 139 AGREE. The literal was being promoted into a hidden *constant* column, so the sort ran on a column where every row tied |
| **6** | [P37](#p37) window in `WHERE` returns 0 rows | **Silent — take this first.** Zero rows, success exit code, no error. Reads as "no matching data", not as a defect (the [P30](#p30) trap). Shares a fix site with [P15](#p15), so the two are one piece of work |
| 9b | [P14](#p14), [P17](#p17), [P20](#p20), [P23](#p23), P13 stage 2 | Smaller, self-contained, decisions already taken |
| 10 | [P22](#p22), [P25](#p25), [P26](#p26), [P15](#p15), [P32](#p32) | Hard errors — visible, so less urgent than any of the above |
| 10 | [P22](#p22), [P25](#p25), [P26](#p26), [P15](#p15), [P32](#p32), [P38](#p38) | Hard errors — visible, so less urgent than any of the above |
| 11 | [P35](#p35), [P36](#p36) | Not parity obligations — a DuckDB extension and a naming difference. Decide *whether*, not just when |
| — | [P27](#p27) `OR` in `JOIN ... ON` | **Reclassified 2026-08-08 — not a quick win.** `JoinCondition` is a `Vec<SingleJoinCondition>` implicitly AND-ed, so there is nowhere in the AST to put an `OR`; it needs join conditions to become an expression, which reaches the join execution code. Sequence it with the R-log, not here |

Expand Down Expand Up @@ -485,6 +486,14 @@ annotation be removed.
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.
- **Acceptance test for stage 2:** `examples/jsonl_logs.sql` carries the
`ORDER BY latency_ms DESC NULLS LAST LIMIT 5` statement that motivated this,
marked `-- [SKIP]` (2026-09-02) so the examples smoke run stays green, next
to the NULLS-free version that does run. Drop the directive when stage 2
lands. Note the JSONL fixture *does* contain NULLs inside the filtered set
(16 rows pass `status IS NOT NULL`, one of which has a NULL `latency_ms`),
so unlike the corpus it can pin real NULL-ordering semantics, not just the
lost `LIMIT`.
- **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
Expand Down Expand Up @@ -531,6 +540,10 @@ annotation be removed.
- **Decision:** **Fix.** Join conditions should accept the same boolean
expressions `WHERE` does; `AND` already works (multi-condition joins are
well covered — see P7/P8), so this is `OR` specifically.
- **Acceptance test:** the `examples/chemistry.sql` statement that found this
is still in the file, marked `-- [SKIP]` (2026-09-02) so the examples smoke
run stays green. Drop the directive when P27 is fixed — it is the
end-to-end check, alongside the corpus case.

### P28 — `SELECT ... INTO #tmp` stages the *unfiltered* rows
- **Status:** 🟢 FIXED (2026-08-16)
Expand Down Expand Up @@ -1272,6 +1285,75 @@ out of date.
finding a bug. Measure the churn first, then decide whether the naming is
worth it.

### P37 — A window function inline in `WHERE` silently returns zero rows
- **Status:** 🔴 OPEN — **silent wrong answer**
- **Corpus:** `09_window.toml :: window_in_where_inline` (OURS_ONLY).
- **Observed:** `SELECT region, amount FROM international_sales WHERE
ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) <= 2` returns
**0 rows** — header printed, success exit code, no error. The table has 20
rows. DuckDB refuses the query outright: *"Binder Error: WHERE clause cannot
contain window functions"*.
- **Why it is `OURS_ONLY` and not `GAP`:** we "succeed" where the reference
errors, so the harness scores it as an extension. It is not one — it is an
empty result standing in for an unimplemented feature, which is strictly worse
than the reference's hard error. The bucket is right; the behaviour is not.
- **Found:** 2026-09-02, while triaging examples smoke-test failures.
`examples/expander_rewriters.sql` advertises "expression lifter (window
function in WHERE)" as a working transformation. Half of that query fails
loudly ([P26](#p26)); this half fails silently, which is why it had never been
noticed.
- **Sibling of [P15](#p15) — same root cause, different symptom.** `ExpressionLifter`
only walks the **SELECT list**, so a window function written inline anywhere
else is never hoisted and arrives at `recursive_where_evaluator.rs` as a raw
`WindowFunction`. From QUALIFY that path errors (P15). From WHERE it evaluates
to something falsy for every row instead. One fix site — `expression_lifter` —
covers SELECT-list, QUALIFY and WHERE.
- **Decision:** **Fix.** Two acceptable end states, in order of preference:
1. Lift it, so `WHERE <window> <op> <val>` works as `QUALIFY` does once P15
lands — this is what `examples/expander_rewriters.sql` already claims and
it makes the two clauses consistent.
2. Failing that, **error** like DuckDB. Either is acceptable; returning zero
rows is not.
Whichever is chosen, the silent-empty path must go first — it is the dangerous
half and it is independent of the feature decision.
- **Related:** [P26](#p26) is the other half of the same example statement, and
[P21](#p21) is the same "when are windows evaluated" confusion from a third angle.

### P38 — A scalar subquery in a CTE's `SELECT` list is never evaluated
- **Status:** 🔴 OPEN
- **Corpus:** `06_ctes_setops.toml :: scalar_subquery_in_cte_select_list` (GAP).
Controls: `scalar_subquery_in_select_arithmetic`,
`plain_arithmetic_in_cte_select_list` (both AGREE).
- **Observed:** `WITH z AS (SELECT region, amount - (SELECT AVG(amount) FROM
international_sales) AS d FROM international_sales) SELECT * FROM z` →
*"Unsupported expression type for arithmetic evaluation: ScalarSubquery { … }"*.
- **Precisely located by the controls — narrower than it first looks.** The
first diagnosis was "scalar subqueries don't work in arithmetic". That is
wrong. Three probes:

| Query shape | Result |
|---|---|
| Top-level `SELECT amount - (SELECT AVG(…)) AS d` | ✅ works |
| Top-level `(a - (SELECT …)) / (SELECT …)` | ✅ works |
| Same expression inside a **CTE body** | 🚫 error |
| `SELECT (SELECT AVG(…)) AS a` inside a CTE body (no arithmetic at all) | 🚫 error |
| `SELECT amount * 2` inside a CTE body | ✅ works |

So neither scalar subqueries nor CTE arithmetic is broken in general. The CTE
SELECT-list path routes items through an evaluator whose expression match has
**no `ScalarSubquery` arm**, while the top-level path has one. The error text
says "arithmetic evaluation" only because that is the evaluator it lands in;
arithmetic is not the trigger.
- **Found:** 2026-09-02, in `examples/statistical_analysis.sql` — a z-score
block computing `(x - (SELECT AVG(x))) / (SELECT STDDEV(x))` inside a CTE.
The statement is restored in that file marked `-- [SKIP]`; drop the directive
when this is fixed and it becomes the end-to-end check.
- **Decision:** **Fix.** Two SELECT-list evaluation paths that disagree on which
expression types they support is a shape problem, not just a missing arm —
when picking this up, check against [`ENGINE_REFACTORING.md`](ENGINE_REFACTORING.md)
whether the honest fix is to converge the two paths rather than add the arm
twice. Adding the arm is the tactical fix if convergence is too large.

---

## Deferred / won't fix (intentional)
Expand Down
32 changes: 32 additions & 0 deletions examples/chemistry.sql
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,38 @@ FROM periodic_table
INNER JOIN year_analysis ON periodic_table.Year = year_analysis.max_discovery_year;
GO

-- [SKIP]
-- Show discovery statistics by joining CTEs
-- SKIPPED: OR in a JOIN ... ON clause is not supported (parity issue P27 --
-- see docs/SQL_PARITY.md). JoinCondition is a Vec of AND-ed conditions, so
-- there is nowhere in the AST to put an OR; this is a parse error today.
-- Restore this statement (and drop the [SKIP]) as the acceptance test when
-- P27 is fixed.
WITH discovery_stats AS (
SELECT
Year,
COUNT(*) as elements_discovered
FROM periodic_table
WHERE Year IS NOT NULL
GROUP BY Year
),
year_bounds AS (
SELECT
MAX(Year) as latest_year,
MIN(Year) as earliest_year
FROM periodic_table
WHERE Year IS NOT NULL
)
SELECT
Year,
elements_discovered,
latest_year,
earliest_year
FROM discovery_stats
INNER JOIN year_bounds ON Year = latest_year OR Year = earliest_year
ORDER BY Year;
GO


-- Self-join example: Find pairs of elements discovered in same year
-- Note: This demonstrates a workaround since table aliases don't work
Expand Down
1 change: 1 addition & 0 deletions examples/elasticsearch_app_events.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
-- [TEST:SKIP] needs a live Elasticsearch server
-- Elasticsearch: broad filter at the server, heavy lifting in SQL
--
-- Pattern: let Elasticsearch do what it's good at (time-window filtering,
Expand Down
1 change: 1 addition & 0 deletions examples/fix_message_analysis.sql
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
-- #! ../data/fix_messages.json
-- [TEST:SKIP] needs the message-query server on localhost:5050 (FORM_FILE upload)
-- Example: Analyzing FIX messages using Web CTE with file upload
-- This demonstrates how to upload a JSON file containing FIX messages to a selector service
-- and query the flattened results
Expand Down
1 change: 1 addition & 0 deletions examples/fix_messages_with_selectors.sql
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
-- #! data/fix_messages.json
-- [TEST:SKIP] needs the message-query server on localhost:5050
-- Example: Querying FIX messages with custom selectors
-- This shows how to specify the actual selector configuration in the SQL query

Expand Down
1 change: 1 addition & 0 deletions examples/format.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
-- [TEST:SKIP] nvim formatter fixture (deliberately unformatted); also needs live endpoints
-- Example: Testing SQL formatter - UNFORMATTED VERSION
-- Test these cases in nvim to verify formatting works correctly:
-- 1. JSON pretty-printing with $JSON$ delimiters
Expand Down
1 change: 1 addition & 0 deletions examples/format_body.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
-- [TEST:SKIP] nvim formatter fixture; posts to the placeholder host tradesapi
-- Example: Testing SQL formatter with WEB CTE using BODY with JSON

WITH WEB trades AS (
Expand Down
18 changes: 16 additions & 2 deletions examples/jsonl_logs.sql
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
-- How many events, and what services / levels are in play?
SELECT
COUNT(*) AS total_events,
COUNT(DISTINCT service) AS distinct_services,
COUNT(DISTINCT service) AS distinct_services,
COUNT(DISTINCT level) AS distinct_levels
FROM READ_JSONL('data/app_logs.jsonl');
GO
Expand Down Expand Up @@ -69,7 +69,21 @@ GO
SELECT method, path, status, latency_ms
FROM READ_JSONL('data/app_logs.jsonl')
WHERE status IS NOT NULL
ORDER BY latency_ms DESC
ORDER BY latency_ms DESC
LIMIT 5;
GO

-- [SKIP]
-- The same query as originally written, with an explicit null-ordering
-- clause. SKIPPED: NULLS FIRST / NULLS LAST is not parsed -- parity issue
-- P13 stage 2 (see docs/SQL_PARITY.md). There is no NULLS handling anywhere
-- in src/sql/. Before P13 stage 1 the clause was silently swallowed along
-- with the LIMIT below it, so this returned every row instead of 5; it is a
-- parse error today. Drop the [SKIP] when stage 2 lands.
SELECT method, path, status, latency_ms
FROM READ_JSONL('data/app_logs.jsonl')
WHERE status IS NOT NULL
ORDER BY latency_ms DESC NULLS LAST
LIMIT 5;
GO

Expand Down
1 change: 1 addition & 0 deletions examples/macro_templates_guide.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
-- [TEST:SKIP] nvim plugin macro guide -- prose only, contains no runnable statement
-- ============================================
-- SQL-CLI Macro Templates Guide
-- ============================================
Expand Down
1 change: 1 addition & 0 deletions examples/parameterized_trades.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
-- [TEST:SKIP] illustrative -- {{PARAM}} templates resolved by the nvim plugin, placeholder host
-- Example of parameterized trade queries
-- Use {{PARAM_NAME}} to define parameters that will be resolved at execution time

Expand Down
1 change: 1 addition & 0 deletions examples/solar_system_with_cte_future.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
-- [TEST:SKIP] aspirational design sketch, not bound to a data file
-- Solar System Queries with CTEs (Future Capability)
-- This file shows what will be possible once CTEs are implemented
-- It demonstrates how CTEs solve current limitations with window functions and ORDER BY
Expand Down
42 changes: 42 additions & 0 deletions examples/statistical_analysis.sql
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,48 @@ ORDER BY occurrences DESC
LIMIT 1;
GO

-- [SKIP]
-- Statistical summary for outlier detection
-- Values more than 2 standard deviations from mean might be outliers
-- SKIPPED: a scalar subquery inside a CTE body's SELECT list is never evaluated
-- -- "Unsupported expression type for arithmetic evaluation: ScalarSubquery".
-- Parity issue P38 (see docs/SQL_PARITY.md). The same expression works at the
-- top level; it is the CTE SELECT-list path that lacks the ScalarSubquery arm.
-- Drop the [SKIP] when P38 is fixed.
WITH sales_with_zscore AS (
SELECT
product,
region,
sales_amount,
(sales_amount - (SELECT AVG(sales_amount) FROM sales_data)) /
(SELECT STDDEV(sales_amount) FROM sales_data) as z_score_raw
FROM sales_data
),
with_classification AS (
SELECT
product,
region,
sales_amount,
ROUND(z_score_raw, 2) as z_score,
ABS(z_score_raw) as abs_z_score,
CASE
WHEN ABS(z_score_raw) > 2 THEN 'Potential Outlier'
WHEN ABS(z_score_raw) > 1 THEN 'Above/Below Average'
ELSE 'Normal Range'
END as classification
FROM sales_with_zscore
)
SELECT
product,
region,
sales_amount,
z_score,
classification
FROM with_classification
ORDER BY abs_z_score DESC
LIMIT 10;
GO


-- Coefficient of Variation (CV) - relative variability measure
-- CV = (Standard Deviation / Mean) * 100
Expand Down
1 change: 1 addition & 0 deletions examples/trade_queries_master.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
-- [TEST:SKIP] nvim plugin macro template (expand with \sTe); needs the test server on localhost:5001
-- ============================================
-- SQL-CLI Trade Queries Master Template
-- ============================================
Expand Down
1 change: 1 addition & 0 deletions examples/trade_reconciliation_final.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
-- [TEST:SKIP] template -- expects the user's own trades file (see the Run: line above)
-- ============================================================================
-- Trade Reconciliation - Final Version with Dynamic Root Order Extraction
-- Uses IndexOf('_') to find underscore and extract root order ID
Expand Down
1 change: 1 addition & 0 deletions examples/web_cte_advanced.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
-- [TEST:SKIP] illustrative -- posts to the placeholder host api.example.com
-- Example: Advanced WEB CTE features including POST requests, JSON path extraction, and authentication
-- This demonstrates the new capabilities added for REST API integration

Expand Down
1 change: 1 addition & 0 deletions examples/web_cte_file_upload.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
-- [TEST:SKIP] needs a web service to accept the FORM_FILE upload
-- Example: Using Web CTE with file uploads
-- This demonstrates the FORM_FILE syntax for uploading files to web services

Expand Down
27 changes: 27 additions & 0 deletions tests/comparison/corpus/06_ctes_setops.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,30 @@ sql = "SELECT symbol, price FROM trades WHERE price > (WITH avg_cte AS (SELECT A
# primary expression: With"). Fixed 2026-07-25 — the subquery-detection guards
# now accept a leading WITH (parse_subquery already dispatched it to the CTE
# parser). DuckDB allows a CTE inside a scalar subquery; now AGREE.

[[case]]
id = "scalar_subquery_in_cte_select_list"
data = "international_sales.csv"
sql = "WITH z AS (SELECT region, amount - (SELECT AVG(amount) FROM international_sales) AS d FROM international_sales) SELECT region, d FROM z ORDER BY region, d"
expect = "GAP"
# P38, added 2026-09-02. "Unsupported expression type for arithmetic
# evaluation: ScalarSubquery { ... }". A scalar subquery in a CTE body's SELECT
# list is never evaluated. Located by the two controls below: the same
# expression at the top level AGREEs, and plain arithmetic inside a CTE AGREEs
# -- so this is the CTE SELECT-list path specifically, not scalar subqueries
# and not CTEs.

[[case]]
id = "scalar_subquery_in_select_arithmetic"
data = "international_sales.csv"
sql = "SELECT region, amount - (SELECT AVG(amount) FROM international_sales) AS d FROM international_sales ORDER BY region, d"
# Control for P38: identical expression at the TOP level. AGREEs, which is what
# puts the P38 fix site in the CTE SELECT-list path rather than in the
# arithmetic evaluator's handling of ScalarSubquery in general.

[[case]]
id = "plain_arithmetic_in_cte_select_list"
data = "international_sales.csv"
sql = "WITH z AS (SELECT region, amount * 2 AS d FROM international_sales) SELECT region, d FROM z ORDER BY region, d"
# Control for P38: arithmetic inside a CTE with no subquery. AGREEs, so CTE
# SELECT-list arithmetic is not broken in general -- only the ScalarSubquery arm.
12 changes: 12 additions & 0 deletions tests/comparison/corpus/09_window.toml
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,15 @@ expect = "GAP"
# 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.

[[case]]
id = "window_in_where_inline"
data = "international_sales.csv"
sql = "SELECT region, amount FROM international_sales WHERE ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) <= 2 ORDER BY region, amount"
expect = "OURS_ONLY"
# P37, added 2026-09-02. DuckDB rejects this outright ("WHERE clause cannot
# contain window functions"). We do not error -- we return **zero rows**, with
# a header and a success exit code. Silent empty result, the same class as
# P30/P31. Sibling of P15: the ExpressionLifter only walks the SELECT list, so
# an inline window reaches the WHERE evaluator unlifted; QUALIFY errors there
# (P15) but WHERE quietly filters everything out instead.
Loading