diff --git a/README.md b/README.md index c2a80a3..c1fb46c 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ sqlrite> DELETE FROM users WHERE age < 30; | `CREATE TABLE` | `PRIMARY KEY`, `UNIQUE`, `NOT NULL`; duplicate-column detection; types `INTEGER`/`INT`/`BIGINT`/`SMALLINT`, `TEXT`/`VARCHAR`, `REAL`/`FLOAT`/`DOUBLE`/`DECIMAL`, `BOOLEAN`. Auto-creates `sqlrite_autoindex__` for every PK + UNIQUE column | | `CREATE [UNIQUE] INDEX` | Single-column, named indexes; `IF NOT EXISTS`; persists as a dedicated cell-based B-Tree. INTEGER + TEXT columns only | | `INSERT INTO` | Explicit column list required; auto-ROWID for `INTEGER PRIMARY KEY`; multi-row `VALUES (…), (…)`; UNIQUE enforcement; clean type errors (no panics); NULL padding for omitted columns | -| `SELECT` | `*` or column list; `WHERE`; single-column `ORDER BY [ASC\|DESC]`; `LIMIT n`. `WHERE col = literal` probes an index when one exists | +| `SELECT` | `*` or column list with optional `AS alias`; `WHERE`; `DISTINCT`; `GROUP BY col[, col …]`; aggregate projections `COUNT(*)` / `COUNT([DISTINCT] col)` / `SUM` / `AVG` / `MIN` / `MAX`; single-column `ORDER BY [ASC\|DESC]` (also resolves alias and aggregate display names); `LIMIT n`. `WHERE col = literal` probes an index when one exists | | `UPDATE` | Multi-column `SET`; `WHERE`; UNIQUE + type enforcement; arithmetic in assignments (`SET age = age + 1`) | | `DELETE` | `WHERE` predicate or full-table delete | | `BEGIN` / `COMMIT` / `ROLLBACK` | Real transactions, snapshot-based; WAL-backed commit; single-level (no savepoints); auto-rollback if `COMMIT`'s disk write fails | @@ -166,12 +166,14 @@ Expressions in `WHERE` and `UPDATE`'s `SET` RHS: - Comparisons — `=`, `<>`, `<`, `<=`, `>`, `>=` - Null tests — `IS NULL`, `IS NOT NULL` +- Pattern matching — `LIKE`, `NOT LIKE`, `ILIKE` (`%` and `_` wildcards, `\`-escaped literals; case-insensitive ASCII to match SQLite's default) +- Set membership — `IN (list)`, `NOT IN (list)` (literal lists only; subquery form is not supported yet) - Logical — `AND`, `OR`, `NOT` (SQL three-valued logic; NULL-as-false in `WHERE`) - Arithmetic — `+`, `-`, `*`, `/`, `%` (integer ops stay integer; any `REAL` promotes to `f64`; divide/modulo by zero is a clean error) - String concat — `||` - Literals — integer + real numbers, `'single-quoted strings'`, `TRUE` / `FALSE`, `NULL`; parentheses for grouping -**Not yet supported** (common ones): joins, subqueries, CTEs, `GROUP BY` / aggregates, `DISTINCT`, `LIKE` / `IN`, expressions in the projection list, column aliases, `OFFSET`, multi-column `ORDER BY`, savepoints, `ALTER TABLE`, `DROP TABLE`, `DROP INDEX`. The [full list with context](docs/supported-sql.md#not-yet-supported) lives in the reference. +**Not yet supported** (common ones): joins, subqueries, CTEs, `HAVING`, `LIKE … ESCAPE ''`, `IN (subquery)`, `DISTINCT` on `SUM`/`AVG`/`MIN`/`MAX`, GROUP BY on expressions, expressions in the projection list, `OFFSET`, multi-column `ORDER BY`, savepoints, `ALTER TABLE`, `DROP TABLE`, `DROP INDEX`. The [full list with context](docs/supported-sql.md#not-yet-supported) lives in the reference. #### Meta commands @@ -310,7 +312,7 @@ Lockstep versioning — one dispatch bumps every product to the same `vX.Y.Z`. T **Possible extras** *(no committed phase)* - Joins (`INNER`, `LEFT OUTER`, `CROSS` — SQLite does not support `RIGHT`/`FULL OUTER`) -- `GROUP BY`, aggregates (`COUNT`, `SUM`, `AVG`, ...), `DISTINCT`, `LIKE`, `IN` +- `HAVING`, `IN (subquery)`, `BETWEEN`, `GLOB` / `REGEXP`, `GROUP_CONCAT`, window functions - Composite and expression indexes (with cost analysis) - Alternate storage engines — LSM/SSTable for write-heavy workloads alongside the B-Tree - Benchmarks against SQLite diff --git a/docs/sql-engine.md b/docs/sql-engine.md index 4c0d4b4..918ee3d 100644 --- a/docs/sql-engine.md +++ b/docs/sql-engine.md @@ -45,11 +45,13 @@ The `sqlparser` AST is designed to cover every SQL dialect, so its types are hug |---|---|---| | `Statement::CreateTable(CreateTable)` | `CreateQuery { table_name, columns: Vec }` | [`create.rs`](../src/sql/parser/create.rs) | | `Statement::Insert(Insert)` | `InsertQuery { table_name, columns, rows }` | [`insert.rs`](../src/sql/parser/insert.rs) | -| `Statement::Query(_)` | `SelectQuery { table_name, projection, selection, order_by, limit }` | [`select.rs`](../src/sql/parser/select.rs) | +| `Statement::Query(_)` | `SelectQuery { table_name, projection, selection, order_by, limit, distinct, group_by }` | [`select.rs`](../src/sql/parser/select.rs) | `UPDATE` and `DELETE` don't have a dedicated internal struct — the executor pattern-matches the sqlparser types directly because there's less transformation needed. -Each parser module also rejects features we don't implement with `SQLRiteError::NotImplemented` — `JOIN`, `GROUP BY`, `HAVING`, `DISTINCT`, `OFFSET`, multi-table DELETE, tuple assignment targets, etc. These errors carry the feature name in the message so the user knows what isn't there. +`SelectQuery::projection` is now `Projection::All | Projection::Items(Vec)`, where each item carries a `ProjectionKind::Column(name)` or `ProjectionKind::Aggregate(AggregateCall)` plus an optional `AS alias`. `AggregateCall` covers `COUNT(*)`, `COUNT([DISTINCT] col)`, `SUM` / `AVG` / `MIN` / `MAX` of a bare column. `group_by` is a `Vec` of bare column names (empty = no GROUP BY); the parser validates that every non-aggregate projection item appears in `GROUP BY`. + +Each parser module still rejects features we don't implement with `SQLRiteError::NotImplemented` — `JOIN`, `HAVING`, `DISTINCT ON (...)`, `GROUP BY` on expressions, `LIKE … ESCAPE ''`, `IN (subquery)`, `OFFSET`, multi-table DELETE, tuple assignment targets, etc. These errors carry the feature name in the message so the user knows what isn't there. ## Statement dispatch @@ -90,6 +92,9 @@ match query { |---|---| | Logical | `AND`, `OR`, `NOT` | | Comparison | `=`, `<>`, `<`, `<=`, `>`, `>=` | +| Null tests | `IS NULL`, `IS NOT NULL` | +| Pattern | `LIKE`, `NOT LIKE`, `ILIKE` (`%`, `_`, `\`-escape; case-insensitive ASCII) | +| Set | `IN (list)`, `NOT IN (list)` (literal lists only) | | Arithmetic | `+`, `-`, `*`, `/`, `%` | | String | `\|\|` | | Unary | `+`, `-`, `NOT` | @@ -139,6 +144,41 @@ Returns top-k from the inverted index in `O(query-term-count × k log k)`. The p The full canonical FTS reference is in [`docs/fts.md`](fts.md). +### Aggregation phase + +When a SELECT contains an aggregate projection or a GROUP BY clause, the +rowid-shaped optimizations don't compose with grouping (every row +contributes to its group), so the executor takes a separate path: + +1. Filter by `WHERE` exactly as before — including the index-probe fast + path — to get the matching rowid set. +2. For each matching rowid, derive a **group key** as a + `Vec` (one entry per `GROUP BY` column; empty key for + queries with aggregates but no `GROUP BY`). +3. Update one `AggState` per (group, aggregate-projection-slot) — + `AggState` lives in [`src/sql/agg.rs`](../src/sql/agg.rs) and tracks + the SQLite numeric type rules (`SUM` stays `INTEGER` until a `REAL` + input or `i64` overflow promotes it; `AVG` is always `REAL`; `MIN`/`MAX` + reuse the executor's total order; `COUNT(DISTINCT col)` uses a + `HashSet`). +4. Emit one output row per group, in projection order — bare-column + slots emit the captured group-key value, aggregate slots emit + `AggState::finalize()`. +5. Apply DISTINCT (post-projection dedup), then ORDER BY (resolved + against the *output* row by alias, bare column name, or aggregate + display form), then LIMIT. + +Aggregate function names (`COUNT`/`SUM`/`AVG`/`MIN`/`MAX`) used in WHERE +or any other scalar position get a friendly error redirecting the user +to the projection list (since `HAVING` isn't supported yet). DISTINCT +on `SUM`/`AVG`/`MIN`/`MAX` is rejected at parse time; only +`COUNT(DISTINCT col)` is in v1. + +`LIKE` / `ILIKE` use a hand-rolled iterative two-pointer matcher in +`agg.rs::like_match` (no regex dep). `IN (list)` follows SQLite's +three-valued logic for NULL on either side, which collapses to "row +excluded" under WHERE's NULL-as-false rule. + ## Two-pass pattern for UPDATE and DELETE Both `execute_update` and `execute_delete` use the same pattern to satisfy Rust's aliasing rules: diff --git a/docs/supported-sql.md b/docs/supported-sql.md index d53e523..0f18170 100644 --- a/docs/supported-sql.md +++ b/docs/supported-sql.md @@ -148,35 +148,63 @@ Hex literals, blob literals, and date/time functions are not supported. ## `SELECT` ```sql -SELECT {* | col1, col2, ...} +SELECT [DISTINCT] {* | [, , ...]} FROM
[WHERE ] - [ORDER BY [ASC|DESC]] + [GROUP BY [, , ...]] + [ORDER BY [ASC|DESC]] [LIMIT ]; ``` +`` is one of: + +``` + -- bare column reference +COUNT(*) -- counts every row, including all-NULL ones +COUNT([DISTINCT] ) -- counts non-NULL values, optionally deduping +{SUM | AVG | MIN | MAX}() -- aggregate over a single column + AS -- optional column alias +``` + ### What works -- **Projection**: `*` (all columns in declaration order) or a bare column list. Columns not declared on the table are rejected. -- **`WHERE`**: any [expression](#expressions). Evaluated per row; NULL-as-false in WHERE context (three-valued logic collapsed to two-valued for filtering). Includes **`IS NULL`** / **`IS NOT NULL`** for explicit null tests. -- **`ORDER BY`**: single sort key, `ASC` (default) or `DESC`. The sort key can be a bare column reference OR any expression — including function calls — so KNN queries like `ORDER BY vec_distance_l2(embedding, [...]) LIMIT k` work end-to-end *(Phase 7b)*. Sort key types must match; mixing `INTEGER` and `TEXT` across rows under a single `ORDER BY` is a runtime error. -- **`LIMIT`**: non-negative integer literal. `LIMIT 0` is valid (returns zero rows). +- **Projection**: `*` (all columns in declaration order), a bare column list, or an explicit list mixing bare columns and aggregate calls. Each item can carry an optional `AS alias` (the alias becomes the output column header and is recognized by `ORDER BY`). +- **`WHERE`**: any [expression](#expressions). Evaluated per row; NULL-as-false in WHERE context (three-valued logic collapsed to two-valued for filtering). Includes **`IS NULL`** / **`IS NOT NULL`** for explicit null tests, **`LIKE` / `NOT LIKE` / `ILIKE`** for pattern matching, and **`IN (list) / NOT IN (list)`** for set-membership against literal lists. +- **`DISTINCT`**: `SELECT DISTINCT` deduplicates result rows after projection (and after aggregation, when both apply). `NULL` values compare equal to other `NULL`s for dedupe, matching SQL's DISTINCT semantic. +- **`GROUP BY`**: one or more bare column names. Every non-aggregate item in the projection must appear in the `GROUP BY` list (the parser rejects the violation with a clear message). `GROUP BY ` without any aggregate behaves like an implicit `DISTINCT `. +- **Aggregates** (SQLR-3): `COUNT(*)`, `COUNT(col)`, `COUNT(DISTINCT col)`, `SUM(col)`, `AVG(col)`, `MIN(col)`, `MAX(col)`. `SUM` over an integer column stays `INTEGER` until a `REAL` input arrives or the running sum overflows `i64` (one-time promotion to `REAL`). `AVG` always returns `REAL` (or `NULL` on empty / all-NULL groups). `MIN` / `MAX` skip NULLs and use the same total order as `ORDER BY`. Aggregates over an empty table or empty group return `0` for `COUNT(*)` / `COUNT(col)` and `NULL` for the rest. +- **`ORDER BY`**: single sort key, `ASC` (default) or `DESC`. For non-aggregating queries the key is any expression — including function calls — so KNN queries like `ORDER BY vec_distance_l2(embedding, [...]) LIMIT k` work end-to-end *(Phase 7b)*. For aggregating queries the key resolves against the *output* row by name: a bare identifier matches an alias or a `GROUP BY` column, and a function call like `COUNT(*)` matches an aggregate projection by its canonical display form. Sort key types must match across rows. +- **`LIMIT`**: non-negative integer literal. `LIMIT 0` is valid (returns zero rows). When `DISTINCT` is in play, `LIMIT` is applied after deduplication so it counts unique rows. ### Index probing -The executor includes a tiny optimizer: if the `WHERE` is exactly ` = ` or ` = `, it probes the index and scans only matching rows. Mixed predicates (`WHERE a = 1 AND b > 2`), range predicates (`WHERE a > 1`), and OR-combined predicates fall back to a full table scan. +The executor includes a tiny optimizer: if the `WHERE` is exactly ` = ` or ` = `, it probes the index and scans only matching rows. Mixed predicates (`WHERE a = 1 AND b > 2`), range predicates (`WHERE a > 1`), and OR-combined predicates fall back to a full table scan. Aggregating queries (`GROUP BY` / aggregate functions) skip the rowid-shape optimizations (HNSW / FTS / bounded-heap top-k) since every matching row contributes to its group. + +### `LIKE` semantics + +- `%` matches any (possibly empty) char sequence; `_` matches exactly one char. `\` escapes the next character so `\%` matches a literal percent. Outside `\%` / `\_` / `\\`, a backslash is itself a literal — matching SQLite's loose default. +- Case folding is **ASCII-only and on by default**, mirroring SQLite's default `PRAGMA case_sensitive_like = OFF`. `LIKE 'a%'` matches both `Apple` and `apple`. Non-ASCII characters compare by code point (no Unicode case folding). +- `LIKE … ESCAPE ''` is not supported. `LIKE ANY (...)` is not supported. +- `NULL LIKE 'pattern'` evaluates to `NULL`; in a `WHERE` that excludes the row. + +### `IN` semantics + +- Only the literal-list form is supported: `WHERE x IN (1, 2, 3)` and `WHERE x NOT IN (...)`. +- Three-valued logic: if the LHS is `NULL`, the result is `NULL`; if the RHS list contains a `NULL` and no other entry matches, the result is `NULL`. In a `WHERE` both cases collapse to "row excluded", matching SQLite. +- `IN (subquery)`, `IN UNNEST(...)`, and `BETWEEN` are not supported yet. ### What doesn't work - **Joins** of any kind (`INNER`, `LEFT OUTER`, `CROSS`, comma-join) - **Subqueries**, CTEs (`WITH`), views -- **`GROUP BY`**, aggregate functions (`COUNT`, `SUM`, `AVG`, `MIN`, `MAX`), `HAVING` -- **`DISTINCT`** -- **`LIKE`**, **`IN`**, `BETWEEN` -- **Expressions in the projection list** (`SELECT age + 1 FROM users`) — projection is bare column references only -- **Multi-column `ORDER BY`**, `NULLS FIRST/LAST` (single sort key only; the sort key itself can be an expression as of Phase 7b) +- **`HAVING`** — pre-aggregation `WHERE` works; post-aggregation filtering does not yet +- **`DISTINCT`** on `SUM` / `AVG` / `MIN` / `MAX` (only `COUNT(DISTINCT col)` is supported) +- **`GROUP BY` on expressions** — bare column names only in v1 +- **`LIKE … ESCAPE ''`**, **`IN (subquery)`**, **`BETWEEN`**, **`GLOB`**, **`REGEXP`** +- **Expressions in the projection list** beyond aggregate calls (`SELECT age + 1 FROM users` is still rejected; aggregates are the one allowed expression form) +- **Multi-column `ORDER BY`**, `NULLS FIRST/LAST` (single sort key only) - **`OFFSET`** -- **Column aliases** (`SELECT name AS n FROM users`) +- **Window functions** (`OVER (...)`, `FILTER (WHERE ...)`, `WITHIN GROUP`) Any of the above reaches the executor as a parsed AST node that execution doesn't handle, producing either `NotImplemented` or a more specific error (e.g., `joins are not supported`). @@ -286,6 +314,9 @@ Expressions work inside `WHERE` (both in `SELECT`, `UPDATE`, `DELETE`) and on th | Category | Operators | |---|---| | Comparison | `=`, `<>`, `<`, `<=`, `>`, `>=` | +| Null tests | `IS NULL`, `IS NOT NULL` | +| Pattern | `LIKE`, `NOT LIKE`, `ILIKE` (`%`, `_`, `\`-escape; case-insensitive ASCII) | +| Set | `IN (list)`, `NOT IN (list)` (literal lists only) | | Logical | `AND`, `OR`, `NOT` | | Arithmetic | `+`, `-`, `*`, `/`, `%` | | String | `\|\|` (concatenation) | diff --git a/src/sql/agg.rs b/src/sql/agg.rs new file mode 100644 index 0000000..37592e4 --- /dev/null +++ b/src/sql/agg.rs @@ -0,0 +1,543 @@ +//! SQLR-3 aggregate runtime. +//! +//! Three concerns live here: +//! 1. `AggState` — per-group accumulator state for COUNT/SUM/AVG/MIN/MAX, +//! with SQLite-style numeric type rules (Sum stays Integer until a +//! Real input or i64 overflow forces a one-time promotion to f64). +//! 2. `DistinctKey` — a hashable typed wrapper around `Value`, used both +//! as the per-row key for GROUP BY and as the dedupe key for +//! `COUNT(DISTINCT col)` and `SELECT DISTINCT`. +//! 3. `like_match` — the iterative two-pointer LIKE matcher (case +//! insensitive ASCII to match SQLite's default). +//! +//! All of this is pure-functional in the sense that nothing here touches +//! the `Database`/`Table`. The executor walks rows and feeds values in. + +use std::collections::HashSet; + +use crate::sql::db::table::Value; +use crate::sql::parser::select::{AggregateArg, AggregateCall, AggregateFn}; + +/// SQLite-style numeric accumulator: stays `Int` while every input is +/// Integer and the running total fits in i64, otherwise promotes once to +/// `Real` and never demotes back. +#[derive(Debug, Clone)] +pub enum SumAcc { + Int(i64), + Real(f64), +} + +impl SumAcc { + fn add_int(&mut self, j: i64) { + match *self { + SumAcc::Int(i) => match i.checked_add(j) { + Some(s) => *self = SumAcc::Int(s), + None => *self = SumAcc::Real(i as f64 + j as f64), + }, + SumAcc::Real(r) => *self = SumAcc::Real(r + j as f64), + } + } + fn add_real(&mut self, r: f64) { + match *self { + SumAcc::Int(i) => *self = SumAcc::Real(i as f64 + r), + SumAcc::Real(x) => *self = SumAcc::Real(x + r), + } + } + fn as_value(&self) -> Value { + match self { + SumAcc::Int(i) => Value::Integer(*i), + SumAcc::Real(r) => Value::Real(*r), + } + } + fn as_f64(&self) -> f64 { + match self { + SumAcc::Int(i) => *i as f64, + SumAcc::Real(r) => *r, + } + } +} + +/// Per-aggregate accumulator. One instance per (group, projection-slot) +/// pair lives for the duration of the SELECT. +#[derive(Debug, Clone)] +pub enum AggState { + /// `COUNT(*)` — counts every row, including all-NULL rows. + CountStar(i64), + /// `COUNT(col)` — counts non-NULL values, optionally with DISTINCT. + Count { + non_null: i64, + distinct: Option>, + }, + /// `SUM(col)` — skips NULLs; `all_null` tracks the SQL semantic that + /// SUM over an all-NULL or empty set yields NULL (not 0). + Sum { + acc: SumAcc, + all_null: bool, + }, + /// `AVG(col)` — always returns Real (or NULL on empty / all-NULL). + Avg { + acc: SumAcc, + n: i64, + }, + /// `MIN(col)` / `MAX(col)` — track the running winner (or None until + /// the first non-NULL input). + Min(Option), + Max(Option), +} + +impl AggState { + /// Construct the initial accumulator for an aggregate call. + pub fn new(call: &AggregateCall) -> Self { + match call.func { + AggregateFn::Count => match &call.arg { + AggregateArg::Star => AggState::CountStar(0), + AggregateArg::Column(_) => AggState::Count { + non_null: 0, + distinct: if call.distinct { + Some(HashSet::new()) + } else { + None + }, + }, + }, + AggregateFn::Sum => AggState::Sum { + acc: SumAcc::Int(0), + all_null: true, + }, + AggregateFn::Avg => AggState::Avg { + acc: SumAcc::Int(0), + n: 0, + }, + AggregateFn::Min => AggState::Min(None), + AggregateFn::Max => AggState::Max(None), + } + } + + /// Fold one row's value into the accumulator. + /// For `COUNT(*)`, the value is irrelevant — pass anything. + pub fn update(&mut self, value: &Value) -> crate::error::Result<()> { + match self { + AggState::CountStar(c) => *c += 1, + AggState::Count { non_null, distinct } => { + if !matches!(value, Value::Null) { + if let Some(set) = distinct { + set.insert(DistinctKey::from_value(value)); + } else { + *non_null += 1; + } + } + } + AggState::Sum { acc, all_null } => match value { + Value::Null => {} + Value::Integer(i) => { + *all_null = false; + acc.add_int(*i); + } + Value::Real(r) => { + *all_null = false; + acc.add_real(*r); + } + Value::Bool(b) => { + *all_null = false; + acc.add_int(if *b { 1 } else { 0 }); + } + other => { + return Err(crate::error::SQLRiteError::Internal(format!( + "SUM expects a numeric column, got {}", + other.to_display_string() + ))); + } + }, + AggState::Avg { acc, n } => match value { + Value::Null => {} + Value::Integer(i) => { + acc.add_int(*i); + *n += 1; + } + Value::Real(r) => { + acc.add_real(*r); + *n += 1; + } + Value::Bool(b) => { + acc.add_int(if *b { 1 } else { 0 }); + *n += 1; + } + other => { + return Err(crate::error::SQLRiteError::Internal(format!( + "AVG expects a numeric column, got {}", + other.to_display_string() + ))); + } + }, + AggState::Min(cur) => { + if !matches!(value, Value::Null) { + match cur { + None => *cur = Some(value.clone()), + Some(c) => { + if compare_values_total(value, c).is_lt() { + *cur = Some(value.clone()); + } + } + } + } + } + AggState::Max(cur) => { + if !matches!(value, Value::Null) { + match cur { + None => *cur = Some(value.clone()), + Some(c) => { + if compare_values_total(value, c).is_gt() { + *cur = Some(value.clone()); + } + } + } + } + } + } + Ok(()) + } + + /// Produce the final SQL value emitted for this group. + pub fn finalize(&self) -> Value { + match self { + AggState::CountStar(c) => Value::Integer(*c), + AggState::Count { non_null, distinct } => match distinct { + Some(set) => Value::Integer(set.len() as i64), + None => Value::Integer(*non_null), + }, + AggState::Sum { acc, all_null } => { + if *all_null { + Value::Null + } else { + acc.as_value() + } + } + AggState::Avg { acc, n } => { + if *n == 0 { + Value::Null + } else { + Value::Real(acc.as_f64() / (*n as f64)) + } + } + AggState::Min(v) | AggState::Max(v) => v.clone().unwrap_or(Value::Null), + } + } +} + +/// A hashable typed wrapper around `Value`, used as the GROUP BY key +/// element and as the `COUNT(DISTINCT col)` set entry. We can't `impl +/// Hash for Value` because Value has a `Real(f64)` variant and `f64` +/// isn't `Hash + Eq`. Round-trip via `f64::to_bits` to keep the +/// canonical bit-pattern as the key — NaN keys remain distinguishable +/// by exact bit pattern, which is the safer choice for grouping (we +/// don't try to be cute about NaN==NaN). +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub enum DistinctKey { + Null, + Bool(bool), + Int(i64), + Real(u64), + Text(String), + Vector(Vec), +} + +impl DistinctKey { + pub fn from_value(v: &Value) -> Self { + match v { + Value::Null => DistinctKey::Null, + Value::Bool(b) => DistinctKey::Bool(*b), + Value::Integer(i) => DistinctKey::Int(*i), + Value::Real(r) => DistinctKey::Real(r.to_bits()), + Value::Text(s) => DistinctKey::Text(s.clone()), + Value::Vector(v) => { + let mut bytes = Vec::with_capacity(v.len() * 4); + for f in v { + bytes.extend_from_slice(&f.to_le_bytes()); + } + DistinctKey::Vector(bytes) + } + } + } +} + +/// Total-order comparison used by MIN/MAX. Mirrors the executor's +/// `compare_values` semantics (Int↔Real cross-coerce; otherwise stringify). +/// Kept separate to avoid a dependency from this module back into +/// executor.rs's private comparator. +fn compare_values_total(a: &Value, b: &Value) -> std::cmp::Ordering { + use std::cmp::Ordering; + match (a, b) { + (Value::Null, Value::Null) => Ordering::Equal, + (Value::Null, _) => Ordering::Less, + (_, Value::Null) => Ordering::Greater, + (Value::Integer(x), Value::Integer(y)) => x.cmp(y), + (Value::Real(x), Value::Real(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal), + (Value::Integer(x), Value::Real(y)) => { + (*x as f64).partial_cmp(y).unwrap_or(Ordering::Equal) + } + (Value::Real(x), Value::Integer(y)) => { + x.partial_cmp(&(*y as f64)).unwrap_or(Ordering::Equal) + } + (Value::Text(x), Value::Text(y)) => x.cmp(y), + (Value::Bool(x), Value::Bool(y)) => x.cmp(y), + (x, y) => x.to_display_string().cmp(&y.to_display_string()), + } +} + +/// SQL `LIKE` matcher. +/// +/// Wildcards: `%` matches any (possibly empty) char sequence; `_` +/// matches exactly one char. `\` escapes the next char (so `\%` matches +/// a literal percent). When `case_insensitive` is true, ASCII letters +/// fold; non-ASCII characters compare by code-point (we don't pull in +/// Unicode case folding for v1). +/// +/// Iterative two-pointer with backtracking — no recursion, so adversarial +/// patterns like `%a%a%a%a%a%b` against `aaaa…aa` can't blow the stack. +/// Worst case is O(|text| · |pattern|). +pub fn like_match(text: &str, pattern: &str, case_insensitive: bool) -> bool { + let text: Vec = text.chars().collect(); + let pat: Vec = pattern.chars().collect(); + let n = text.len(); + let m = pat.len(); + + let mut ti = 0usize; + let mut pi = 0usize; + // Backtrack point: the last position where we saw `%` and committed to + // matching zero characters with it. + let mut star_ti: Option = None; + let mut star_pi: Option = None; + + while ti < n { + if pi < m { + let pc = pat[pi]; + if pc == '%' { + star_pi = Some(pi); + star_ti = Some(ti); + pi += 1; + continue; + } + if pc == '_' { + pi += 1; + ti += 1; + continue; + } + // Escape support: `\X` matches a literal X for X in {%, _, \}. + // Outside that set the backslash is itself literal (matches + // SQLite's loose default). + let (effective_pat, advance) = if pc == '\\' && pi + 1 < m { + let nxt = pat[pi + 1]; + if nxt == '%' || nxt == '_' || nxt == '\\' { + (nxt, 2) + } else { + (pc, 1) + } + } else { + (pc, 1) + }; + if char_eq(text[ti], effective_pat, case_insensitive) { + pi += advance; + ti += 1; + continue; + } + } + // Mismatch (or pattern exhausted before text). If a backtrack point + // exists, expand the last `%` to absorb one more char and retry. + if let (Some(spi), Some(sti)) = (star_pi, star_ti) { + pi = spi + 1; + star_ti = Some(sti + 1); + ti = sti + 1; + } else { + return false; + } + } + // Text exhausted; pattern must be done (or all that's left is `%`). + while pi < m && pat[pi] == '%' { + pi += 1; + } + pi == m +} + +fn char_eq(a: char, b: char, case_insensitive: bool) -> bool { + if !case_insensitive { + return a == b; + } + if a.is_ascii() && b.is_ascii() { + a.eq_ignore_ascii_case(&b) + } else { + a == b + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn like_simple_literal() { + assert!(like_match("apple", "apple", true)); + assert!(!like_match("apple", "apples", true)); + } + + #[test] + fn like_percent_wildcard() { + assert!(like_match("apple", "a%", true)); + assert!(like_match("apple", "%le", true)); + assert!(like_match("apple", "%pp%", true)); + assert!(!like_match("banana", "a%", true)); + } + + #[test] + fn like_underscore_wildcard() { + assert!(like_match("abc", "a_c", true)); + assert!(!like_match("abbc", "a_c", true)); + } + + #[test] + fn like_case_insensitive_default() { + assert!(like_match("Apple", "a%", true)); + assert!(like_match("APPLE", "%le", true)); + assert!( + !like_match("Apple", "a%", false), + "case-sensitive should fail" + ); + } + + #[test] + fn like_escape_percent_literal() { + // pattern `100\%` should match literal "100%" + assert!(like_match("100%", "100\\%", true)); + assert!(!like_match("100x", "100\\%", true)); + } + + #[test] + fn like_no_pathological_recursion() { + // The classic "exponential naive matcher" stress case. + let text = "a".repeat(40); + let pat = "a%a%a%a%a%a%a%a%b"; + // Should return false in linear time; if we recurse we'd stack-OOM + // or hang; this test is mostly a smoke test. + assert!(!like_match(&text, pat, true)); + } + + #[test] + fn distinct_key_real_distinguishes_from_int() { + let a = DistinctKey::from_value(&Value::Integer(1)); + let b = DistinctKey::from_value(&Value::Real(1.0)); + assert_ne!(a, b, "Integer(1) vs Real(1.0) must hash differently"); + } + + #[test] + fn count_star_includes_nulls() { + let call = AggregateCall { + func: AggregateFn::Count, + arg: AggregateArg::Star, + distinct: false, + }; + let mut s = AggState::new(&call); + s.update(&Value::Null).unwrap(); + s.update(&Value::Integer(7)).unwrap(); + s.update(&Value::Null).unwrap(); + assert_eq!(s.finalize(), Value::Integer(3)); + } + + #[test] + fn count_col_skips_nulls() { + let call = AggregateCall { + func: AggregateFn::Count, + arg: AggregateArg::Column("x".into()), + distinct: false, + }; + let mut s = AggState::new(&call); + s.update(&Value::Null).unwrap(); + s.update(&Value::Integer(7)).unwrap(); + s.update(&Value::Null).unwrap(); + assert_eq!(s.finalize(), Value::Integer(1)); + } + + #[test] + fn count_distinct_dedupes() { + let call = AggregateCall { + func: AggregateFn::Count, + arg: AggregateArg::Column("x".into()), + distinct: true, + }; + let mut s = AggState::new(&call); + for v in [1, 1, 2, 2, 3, 3] { + s.update(&Value::Integer(v)).unwrap(); + } + s.update(&Value::Null).unwrap(); + assert_eq!(s.finalize(), Value::Integer(3)); + } + + #[test] + fn sum_int_stays_int_until_real() { + let call = AggregateCall { + func: AggregateFn::Sum, + arg: AggregateArg::Column("x".into()), + distinct: false, + }; + let mut s = AggState::new(&call); + s.update(&Value::Integer(2)).unwrap(); + s.update(&Value::Integer(3)).unwrap(); + assert_eq!(s.finalize(), Value::Integer(5)); + + s.update(&Value::Real(0.5)).unwrap(); + match s.finalize() { + Value::Real(r) => assert!((r - 5.5).abs() < 1e-9), + v => panic!("expected Real, got {:?}", v), + } + } + + #[test] + fn sum_all_null_is_null() { + let call = AggregateCall { + func: AggregateFn::Sum, + arg: AggregateArg::Column("x".into()), + distinct: false, + }; + let mut s = AggState::new(&call); + s.update(&Value::Null).unwrap(); + s.update(&Value::Null).unwrap(); + assert_eq!(s.finalize(), Value::Null); + } + + #[test] + fn avg_always_real() { + let call = AggregateCall { + func: AggregateFn::Avg, + arg: AggregateArg::Column("x".into()), + distinct: false, + }; + let mut s = AggState::new(&call); + s.update(&Value::Integer(2)).unwrap(); + s.update(&Value::Integer(4)).unwrap(); + match s.finalize() { + Value::Real(r) => assert!((r - 3.0).abs() < 1e-9), + v => panic!("expected Real, got {:?}", v), + } + } + + #[test] + fn min_max_skip_nulls() { + let mk = |f| AggregateCall { + func: f, + arg: AggregateArg::Column("x".into()), + distinct: false, + }; + let mut mn = AggState::new(&mk(AggregateFn::Min)); + let mut mx = AggState::new(&mk(AggregateFn::Max)); + for v in [ + Value::Null, + Value::Integer(7), + Value::Integer(3), + Value::Integer(9), + Value::Null, + ] { + mn.update(&v).unwrap(); + mx.update(&v).unwrap(); + } + assert_eq!(mn.finalize(), Value::Integer(3)); + assert_eq!(mx.finalize(), Value::Integer(9)); + } +} diff --git a/src/sql/executor.rs b/src/sql/executor.rs index f81d410..bb1e0a2 100644 --- a/src/sql/executor.rs +++ b/src/sql/executor.rs @@ -12,6 +12,7 @@ use sqlparser::ast::{ }; use crate::error::{Result, SQLRiteError}; +use crate::sql::agg::{AggState, DistinctKey, like_match}; use crate::sql::db::database::Database; use crate::sql::db::secondary_index::{IndexOrigin, SecondaryIndex}; use crate::sql::db::table::{ @@ -19,7 +20,9 @@ use crate::sql::db::table::{ }; use crate::sql::fts::{Bm25Params, PostingList}; use crate::sql::hnsw::{DistanceMetric, HnswIndex}; -use crate::sql::parser::select::{OrderByClause, Projection, SelectQuery}; +use crate::sql::parser::select::{ + AggregateArg, OrderByClause, Projection, ProjectionItem, ProjectionKind, SelectQuery, +}; /// Executes a parsed `SelectQuery` against the database and returns a /// human-readable rendering of the result set (prettytable). Also returns @@ -42,22 +45,43 @@ pub fn execute_select_rows(query: SelectQuery, db: &Database) -> Result = match &query.projection { - Projection::All => table.column_names(), - Projection::Columns(cols) => { - for c in cols { - if !table.contains_column(c.to_string()) { - return Err(SQLRiteError::Internal(format!( - "Column '{c}' does not exist on table '{}'", - query.table_name - ))); - } - } - cols.clone() - } + // SQLR-3: Materialize the projection as `Vec` so + // both the simple-row path and the aggregation path can iterate the + // same shape. `Projection::All` expands to bare-column items in + // declaration order; that path then runs the existing rowid pipeline. + let proj_items: Vec = match &query.projection { + Projection::All => table + .column_names() + .into_iter() + .map(|c| ProjectionItem { + kind: ProjectionKind::Column(c), + alias: None, + }) + .collect(), + Projection::Items(items) => items.clone(), }; - + let has_aggregates = proj_items + .iter() + .any(|i| matches!(i.kind, ProjectionKind::Aggregate(_))); + // Validate bare-column references against the table schema. + for item in &proj_items { + if let ProjectionKind::Column(c) = &item.kind + && !table.contains_column(c.clone()) + { + return Err(SQLRiteError::Internal(format!( + "Column '{c}' does not exist on table '{}'", + query.table_name + ))); + } + } + for c in &query.group_by { + if !table.contains_column(c.clone()) { + return Err(SQLRiteError::Internal(format!( + "GROUP BY references unknown column '{c}' on table '{}'", + query.table_name + ))); + } + } // Collect matching rowids. If the WHERE is the shape `col = literal` // and `col` has a secondary index, probe the index for an O(log N) // seek; otherwise fall back to the full table scan. @@ -66,10 +90,10 @@ pub fn execute_select_rows(query: SelectQuery, db: &Database) -> Result { let mut out = Vec::new(); for rowid in table.rowids() { - if let Some(expr) = &query.selection { - if !eval_predicate(expr, table, rowid)? { - continue; - } + if let Some(expr) = &query.selection + && !eval_predicate(expr, table, rowid)? + { + continue; } out.push(rowid); } @@ -78,6 +102,50 @@ pub fn execute_select_rows(query: SelectQuery, db: &Database) -> Result = proj_items.iter().map(|i| i.output_name()).collect(); + let mut rows = aggregate_rows(table, &matching, &query.group_by, &proj_items)?; + + if query.distinct { + rows = dedupe_rows(rows); + } + + if let Some(order) = &query.order_by { + sort_output_rows(&mut rows, &columns, &proj_items, order)?; + } + if let Some(k) = query.limit { + rows.truncate(k); + } + + return Ok(SelectResult { columns, rows }); + } + + // Non-aggregating path — same flow as before, with the extra + // affordances that (a) the projection list now goes through + // `ProjectionItem` and (b) DISTINCT applies after row materialization. + // Phase 7c — bounded-heap top-k optimization. // // The naive "ORDER BY " path (Phase 7b) sorts every matching @@ -107,6 +175,11 @@ pub fn execute_select_rows(query: SelectQuery, db: &Database) -> Result= |matching| → full sort. // 5. LIMIT without ORDER BY → just truncate. + // + // DISTINCT is applied post-projection (we'd over-truncate if LIMIT + // ran before DISTINCT had a chance to collapse duplicates), so when + // DISTINCT is on we defer truncation past the dedupe step. + let defer_limit_for_distinct = query.distinct; match (&query.order_by, query.limit) { (Some(order), Some(k)) if try_hnsw_probe(table, &order.expr, k).is_some() => { matching = try_hnsw_probe(table, &order.expr, k).unwrap(); @@ -116,21 +189,32 @@ pub fn execute_select_rows(query: SelectQuery, db: &Database) -> Result { + (Some(order), Some(k)) if !defer_limit_for_distinct && k < matching.len() => { matching = select_topk(&matching, table, order, k)?; } (Some(order), _) => { sort_rowids(&mut matching, table, order)?; - if let Some(k) = query.limit { + if let Some(k) = query.limit + && !defer_limit_for_distinct + { matching.truncate(k); } } - (None, Some(k)) => { + (None, Some(k)) if !defer_limit_for_distinct => { matching.truncate(k); } - (None, None) => {} + _ => {} } + let columns: Vec = proj_items.iter().map(|i| i.output_name()).collect(); + let projected_cols: Vec = proj_items + .iter() + .map(|i| match &i.kind { + ProjectionKind::Column(c) => c.clone(), + ProjectionKind::Aggregate(_) => unreachable!("aggregation handled above"), + }) + .collect(); + // Build typed rows. Missing cells surface as `Value::Null` — that // maps a column-not-present-for-this-rowid case onto the public // `Row::get` → `Option` surface cleanly. @@ -143,10 +227,14 @@ pub fn execute_select_rows(query: SelectQuery, db: &Database) -> Result Result { Ok(Value::Bool(!matches!(v, Value::Null))) } + // SQLR-3 — LIKE / NOT LIKE / ILIKE. Pattern matching uses our + // own iterative two-pointer matcher (see `agg::like_match`). + // SQLite's default is case-insensitive ASCII; we follow that. + // ILIKE is also case-insensitive (a no-op switch here, but we + // keep the arm explicit so SQLite users typing ILIKE get the + // expected semantics rather than a NotImplemented). + Expr::Like { + negated, + any, + expr: lhs, + pattern, + escape_char, + } => eval_like( + table, + rowid, + *negated, + *any, + lhs, + pattern, + escape_char.as_ref(), + true, + ), + Expr::ILike { + negated, + any, + expr: lhs, + pattern, + escape_char, + } => eval_like( + table, + rowid, + *negated, + *any, + lhs, + pattern, + escape_char.as_ref(), + true, + ), + + // SQLR-3 — IN (list) / NOT IN (list). Subquery form is rejected. + // Three-valued logic: if the LHS is NULL, return NULL; if any + // list entry is NULL and no match was found, return NULL too. + // WHERE coerces NULL → false at line ~1494, so the practical + // effect is "row excluded" — matches SQLite. + Expr::InList { + expr: lhs, + list, + negated, + } => eval_in_list(table, rowid, lhs, list, *negated), + Expr::InSubquery { .. } => Err(SQLRiteError::NotImplemented( + "IN (subquery) is not supported (only literal lists are)".to_string(), + )), + // Phase 7b — function-call dispatch. Currently only the three // vector-distance functions; this match arm becomes the single // place to register more SQL functions later (e.g. abs(), @@ -1708,6 +1849,13 @@ fn eval_function(func: &sqlparser::ast::Function, table: &Table, rowid: i64) -> let s = entry.index.score(rowid, &query, &Bm25Params::default()); Ok(Value::Real(s)) } + // SQLR-3: catch aggregate names used in scalar position (e.g. + // `WHERE COUNT(*) > 1`) with a clearer message than "unknown + // function". HAVING isn't supported yet, hence the explicit nudge. + "count" | "sum" | "avg" | "min" | "max" => Err(SQLRiteError::NotImplemented(format!( + "aggregate function '{name}' is not allowed in WHERE / projection-scalar position; \ + use it as a top-level projection item (HAVING is not yet supported)" + ))), other => Err(SQLRiteError::NotImplemented(format!( "unknown function: {other}(...)" ))), @@ -2267,6 +2415,304 @@ fn as_bool(v: &Value) -> Result { } } +// ----------------------------------------------------------------- +// SQLR-3 — LIKE / IN evaluators +// ----------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +fn eval_like( + table: &Table, + rowid: i64, + negated: bool, + any: bool, + lhs: &Expr, + pattern: &Expr, + escape_char: Option<&AstValue>, + case_insensitive: bool, +) -> Result { + if any { + return Err(SQLRiteError::NotImplemented( + "LIKE ANY (...) is not supported".to_string(), + )); + } + if escape_char.is_some() { + return Err(SQLRiteError::NotImplemented( + "LIKE ... ESCAPE '' is not supported (default `\\` escape only)".to_string(), + )); + } + + let l = eval_expr(lhs, table, rowid)?; + let p = eval_expr(pattern, table, rowid)?; + if matches!(l, Value::Null) || matches!(p, Value::Null) { + return Ok(Value::Null); + } + let text = match l { + Value::Text(s) => s, + other => other.to_display_string(), + }; + let pat = match p { + Value::Text(s) => s, + other => other.to_display_string(), + }; + let m = like_match(&text, &pat, case_insensitive); + Ok(Value::Bool(if negated { !m } else { m })) +} + +fn eval_in_list( + table: &Table, + rowid: i64, + lhs: &Expr, + list: &[Expr], + negated: bool, +) -> Result { + let l = eval_expr(lhs, table, rowid)?; + if matches!(l, Value::Null) { + return Ok(Value::Null); + } + let mut saw_null = false; + for item in list { + let r = eval_expr(item, table, rowid)?; + if matches!(r, Value::Null) { + saw_null = true; + continue; + } + if compare_values(Some(&l), Some(&r)) == Ordering::Equal { + return Ok(Value::Bool(!negated)); + } + } + if saw_null { + // SQLite three-valued IN: unmatched + a NULL on the RHS → NULL. + // WHERE coerces NULL → false, so the row is excluded either way. + Ok(Value::Null) + } else { + Ok(Value::Bool(negated)) + } +} + +// ----------------------------------------------------------------- +// SQLR-3 — Aggregation phase, DISTINCT, post-projection sort +// ----------------------------------------------------------------- + +/// Walk `matching` rowids, partition into groups (one synthetic group +/// when `group_by` is empty), update one `AggState` per aggregate +/// projection slot per group, then materialize one output row per +/// group in projection order. Group-key columns surface their original +/// `Value` (captured the first time the group was seen); aggregate +/// slots surface `AggState::finalize()`. +fn aggregate_rows( + table: &Table, + matching: &[i64], + group_by: &[String], + proj_items: &[ProjectionItem], +) -> Result>> { + // Build the per-projection-slot accumulator template once. Each + // group clones this template on first sight. Non-aggregate slots + // hold a "captured group-key value" (`None` until set). + let template: Vec> = proj_items + .iter() + .map(|i| match &i.kind { + ProjectionKind::Aggregate(call) => Some(AggState::new(call)), + ProjectionKind::Column(_) => None, + }) + .collect(); + + // Linear-scan group lookup. For typical ad-hoc queries (cardinality + // ≪ 10k), this is fine; if grouping cardinality grows, swap to a + // HashMap, usize> keyed by the same DistinctKey + // wrapper. Order-preserving for readable output (groups appear in + // first-occurrence order, matching SQLite's typical behavior). + let mut keys: Vec> = Vec::new(); + let mut group_states: Vec>> = Vec::new(); + let mut group_key_values: Vec> = Vec::new(); + + for &rowid in matching { + let mut key_values: Vec = Vec::with_capacity(group_by.len()); + let mut key: Vec = Vec::with_capacity(group_by.len()); + for col in group_by { + let v = table.get_value(col, rowid).unwrap_or(Value::Null); + key.push(DistinctKey::from_value(&v)); + key_values.push(v); + } + let idx = match keys.iter().position(|k| k == &key) { + Some(i) => i, + None => { + keys.push(key); + group_states.push(template.clone()); + group_key_values.push(key_values); + keys.len() - 1 + } + }; + + for (slot, item) in proj_items.iter().enumerate() { + if let ProjectionKind::Aggregate(call) = &item.kind { + let v = match &call.arg { + AggregateArg::Star => Value::Null, + AggregateArg::Column(c) => table.get_value(c, rowid).unwrap_or(Value::Null), + }; + if let Some(state) = group_states[idx][slot].as_mut() { + state.update(&v)?; + } + } + } + } + + // No groups but no aggregate-only "implicit one row" semantic to + // emit: e.g. `SELECT dept FROM t GROUP BY dept` over an empty + // matching set should produce zero rows. `SELECT COUNT(*) FROM t` + // (no GROUP BY) DOES produce one row even on empty input — the + // single-synthetic-group path below handles it. + if keys.is_empty() && group_by.is_empty() { + // Synthetic single empty group so we still emit one row with + // initial accumulator finals (e.g. COUNT(*) → 0). + keys.push(Vec::new()); + group_states.push(template.clone()); + group_key_values.push(Vec::new()); + } + + // Project: one row per group, in projection order. + let mut rows: Vec> = Vec::with_capacity(keys.len()); + for (group_idx, _) in keys.iter().enumerate() { + let mut row: Vec = Vec::with_capacity(proj_items.len()); + for (slot, item) in proj_items.iter().enumerate() { + match &item.kind { + ProjectionKind::Column(c) => { + // The validation in execute_select_rows guarantees + // bare-column projections are also in `group_by`. + let pos = group_by + .iter() + .position(|g| g == c) + .expect("validated to be in GROUP BY"); + row.push(group_key_values[group_idx][pos].clone()); + } + ProjectionKind::Aggregate(_) => { + let state = group_states[group_idx][slot] + .as_ref() + .expect("aggregate slot has state"); + row.push(state.finalize()); + } + } + } + rows.push(row); + } + Ok(rows) +} + +/// SELECT DISTINCT post-pass. Walks the rows once with a `HashSet` of +/// row-keys, preserving first-occurrence order. NULL == NULL for +/// dedupe purposes, which matches the SQL DISTINCT semantic. +fn dedupe_rows(rows: Vec>) -> Vec> { + use std::collections::HashSet; + let mut seen: HashSet> = HashSet::new(); + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + let key: Vec = row.iter().map(DistinctKey::from_value).collect(); + if seen.insert(key) { + out.push(row); + } + } + out +} + +/// Sort output rows for the aggregating path. ORDER BY can reference +/// either an output column name (alias or bare GROUP BY column) or an +/// aggregate function call by display form (e.g. `COUNT(*)`). +fn sort_output_rows( + rows: &mut [Vec], + columns: &[String], + proj_items: &[ProjectionItem], + order: &OrderByClause, +) -> Result<()> { + let target_idx = resolve_order_by_index(&order.expr, columns, proj_items)?; + rows.sort_by(|a, b| { + let va = &a[target_idx]; + let vb = &b[target_idx]; + let ord = compare_values(Some(va), Some(vb)); + if order.ascending { ord } else { ord.reverse() } + }); + Ok(()) +} + +/// Map an ORDER BY expression to the index of the output column that +/// should drive the sort. +fn resolve_order_by_index( + expr: &Expr, + columns: &[String], + proj_items: &[ProjectionItem], +) -> Result { + // Bare identifier — match against output names (alias-first). + let target_name: Option = match expr { + Expr::Identifier(ident) => Some(ident.value.clone()), + Expr::CompoundIdentifier(parts) => parts.last().map(|p| p.value.clone()), + Expr::Function(_) => None, + Expr::Nested(inner) => return resolve_order_by_index(inner, columns, proj_items), + other => { + return Err(SQLRiteError::NotImplemented(format!( + "ORDER BY expression not supported on aggregating queries: {other:?}" + ))); + } + }; + if let Some(name) = target_name { + if let Some(i) = columns.iter().position(|c| c.eq_ignore_ascii_case(&name)) { + return Ok(i); + } + return Err(SQLRiteError::Internal(format!( + "ORDER BY references unknown column '{name}' in the SELECT output" + ))); + } + // Function form: match by display name against any aggregate item + // whose canonical display equals the user's call. Tolerate case + // differences in the function name. + if let Expr::Function(func) = expr { + let user_disp = format_function_display(func); + for (i, item) in proj_items.iter().enumerate() { + if let ProjectionKind::Aggregate(call) = &item.kind + && call.display_name().eq_ignore_ascii_case(&user_disp) + { + return Ok(i); + } + } + return Err(SQLRiteError::Internal(format!( + "ORDER BY references aggregate '{user_disp}' that isn't in the SELECT output" + ))); + } + Err(SQLRiteError::Internal( + "ORDER BY expression could not be resolved against the output columns".to_string(), + )) +} + +/// Format a sqlparser function call into the same canonical form +/// `AggregateCall::display_name()` uses, so ORDER BY on +/// `COUNT(*)` / `SUM(salary)` matches its projection counterpart. +fn format_function_display(func: &sqlparser::ast::Function) -> String { + let name = match func.name.0.as_slice() { + [ObjectNamePart::Identifier(ident)] => ident.value.to_uppercase(), + _ => format!("{:?}", func.name).to_uppercase(), + }; + let inner = match &func.args { + FunctionArguments::List(l) => { + let distinct = matches!( + l.duplicate_treatment, + Some(sqlparser::ast::DuplicateTreatment::Distinct) + ); + let arg = l.args.first().map(|a| match a { + FunctionArg::Unnamed(FunctionArgExpr::Wildcard) => "*".to_string(), + FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::Identifier(i))) => i.value.clone(), + FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::CompoundIdentifier(parts))) => { + parts.last().map(|p| p.value.clone()).unwrap_or_default() + } + _ => String::new(), + }); + match (distinct, arg) { + (true, Some(a)) if a != "*" => format!("DISTINCT {a}"), + (_, Some(a)) => a, + _ => String::new(), + } + } + _ => String::new(), + }; + format!("{name}({inner})") +} + fn convert_literal(v: &sqlparser::ast::Value) -> Result { use sqlparser::ast::Value as AstValue; match v { @@ -2750,4 +3196,300 @@ mod tests { "IS NULL combined with AND should match exactly row 2, got: {response}" ); } + + // --------------------------------------------------------------------- + // SQLR-3 — LIKE / IN / DISTINCT / GROUP BY / aggregates + // --------------------------------------------------------------------- + + /// Seed a small employees table the analytical tests share. + fn seed_employees() -> Database { + let mut db = Database::new("t".to_string()); + crate::sql::process_command( + "CREATE TABLE emp (id INTEGER PRIMARY KEY, name TEXT, dept TEXT, salary INTEGER);", + &mut db, + ) + .unwrap(); + let rows = [ + "INSERT INTO emp (name, dept, salary) VALUES ('Alice', 'eng', 100);", + "INSERT INTO emp (name, dept, salary) VALUES ('alex', 'eng', 120);", + "INSERT INTO emp (name, dept, salary) VALUES ('Bob', 'eng', 100);", + "INSERT INTO emp (name, dept, salary) VALUES ('Carol', 'sales', 90);", + "INSERT INTO emp (name, dept, salary) VALUES ('Dave', 'sales', NULL);", + "INSERT INTO emp (name, dept, salary) VALUES ('Eve', 'ops', 80);", + ]; + for sql in rows { + crate::sql::process_command(sql, &mut db).unwrap(); + } + db + } + + /// Drive `execute_select_rows` directly so tests can assert on typed values. + fn run_rows(db: &Database, sql: &str) -> SelectResult { + let q = parse_select(sql); + execute_select_rows(q, db).expect("select") + } + + // ----- LIKE ----- + + #[test] + fn like_percent_prefix_case_insensitive() { + let db = seed_employees(); + let r = run_rows(&db, "SELECT name FROM emp WHERE name LIKE 'a%';"); + // Matches Alice and alex (case-insensitive ASCII). + let names: Vec<_> = r.rows.iter().map(|r| r[0].to_display_string()).collect(); + assert_eq!(names.len(), 2, "expected 2 rows, got {names:?}"); + assert!(names.contains(&"Alice".to_string())); + assert!(names.contains(&"alex".to_string())); + } + + #[test] + fn like_underscore_singlechar() { + let db = seed_employees(); + let r = run_rows(&db, "SELECT name FROM emp WHERE name LIKE '_ve';"); + // Eve matches; alex does not (3 chars vs 4). + let names: Vec<_> = r.rows.iter().map(|r| r[0].to_display_string()).collect(); + assert_eq!(names, vec!["Eve".to_string()]); + } + + #[test] + fn not_like_excludes_match() { + let db = seed_employees(); + let r = run_rows(&db, "SELECT name FROM emp WHERE name NOT LIKE 'a%';"); + // Excludes Alice + alex; 4 rows remain. + assert_eq!(r.rows.len(), 4); + } + + #[test] + fn like_with_null_excludes_row() { + let db = seed_employees(); + // Match 'sales' rows where salary is NULL → just Dave. + let r = run_rows( + &db, + "SELECT name FROM emp WHERE dept LIKE 'sales' AND salary IS NULL;", + ); + assert_eq!(r.rows.len(), 1); + assert_eq!(r.rows[0][0].to_display_string(), "Dave"); + } + + // ----- IN ----- + + #[test] + fn in_list_positive() { + let db = seed_employees(); + let r = run_rows(&db, "SELECT name FROM emp WHERE id IN (1, 3, 5);"); + let names: Vec<_> = r.rows.iter().map(|r| r[0].to_display_string()).collect(); + assert_eq!(names.len(), 3); + assert!(names.contains(&"Alice".to_string())); + assert!(names.contains(&"Bob".to_string())); + assert!(names.contains(&"Dave".to_string())); + } + + #[test] + fn not_in_excludes_listed() { + let db = seed_employees(); + let r = run_rows(&db, "SELECT name FROM emp WHERE id NOT IN (1, 2);"); + // 6 rows total - 2 excluded = 4. + assert_eq!(r.rows.len(), 4); + } + + #[test] + fn in_list_with_null_three_valued() { + let db = seed_employees(); + // x = 1 should match; for other rows the NULL in the list yields + // unknown → false in WHERE → excluded. + let r = run_rows(&db, "SELECT name FROM emp WHERE id IN (1, NULL);"); + assert_eq!(r.rows.len(), 1); + assert_eq!(r.rows[0][0].to_display_string(), "Alice"); + } + + // ----- DISTINCT ----- + + #[test] + fn distinct_single_column() { + let db = seed_employees(); + let r = run_rows(&db, "SELECT DISTINCT dept FROM emp;"); + // 3 distinct depts: eng, sales, ops. + assert_eq!(r.rows.len(), 3); + } + + #[test] + fn distinct_multi_column_with_null() { + let db = seed_employees(); + // (dept, salary) tuples — the two 'eng' / 100 rows collapse. + let r = run_rows(&db, "SELECT DISTINCT dept, salary FROM emp;"); + // 6 input rows; (eng, 100) appears twice → 5 distinct tuples. + assert_eq!(r.rows.len(), 5); + } + + // ----- Aggregates without GROUP BY ----- + + #[test] + fn count_star_no_groupby() { + let db = seed_employees(); + let r = run_rows(&db, "SELECT COUNT(*) FROM emp;"); + assert_eq!(r.rows.len(), 1); + assert_eq!(r.rows[0][0], Value::Integer(6)); + } + + #[test] + fn count_col_skips_nulls() { + let db = seed_employees(); + let r = run_rows(&db, "SELECT COUNT(salary) FROM emp;"); + // 6 rows, 1 NULL salary → COUNT(salary) = 5. + assert_eq!(r.rows[0][0], Value::Integer(5)); + } + + #[test] + fn count_distinct_dedupes_and_skips_nulls() { + let db = seed_employees(); + let r = run_rows(&db, "SELECT COUNT(DISTINCT salary) FROM emp;"); + // Distinct non-null salaries: {100, 120, 90, 80} → 4. + assert_eq!(r.rows[0][0], Value::Integer(4)); + } + + #[test] + fn sum_int_stays_integer() { + let db = seed_employees(); + let r = run_rows(&db, "SELECT SUM(salary) FROM emp;"); + // 100 + 120 + 100 + 90 + 80 = 490 (NULL skipped). + assert_eq!(r.rows[0][0], Value::Integer(490)); + } + + #[test] + fn avg_returns_real() { + let db = seed_employees(); + let r = run_rows(&db, "SELECT AVG(salary) FROM emp;"); + // 490 / 5 = 98.0 + match &r.rows[0][0] { + Value::Real(v) => assert!((v - 98.0).abs() < 1e-9), + other => panic!("expected Real, got {other:?}"), + } + } + + #[test] + fn min_max_skip_nulls() { + let db = seed_employees(); + let r = run_rows(&db, "SELECT MIN(salary), MAX(salary) FROM emp;"); + assert_eq!(r.rows[0][0], Value::Integer(80)); + assert_eq!(r.rows[0][1], Value::Integer(120)); + } + + #[test] + fn aggregates_on_empty_table_emit_one_row() { + let mut db = Database::new("t".to_string()); + crate::sql::process_command("CREATE TABLE t (x INTEGER);", &mut db).unwrap(); + let r = run_rows( + &db, + "SELECT COUNT(*), SUM(x), AVG(x), MIN(x), MAX(x) FROM t;", + ); + assert_eq!(r.rows.len(), 1); + assert_eq!(r.rows[0][0], Value::Integer(0)); + assert_eq!(r.rows[0][1], Value::Null); + assert_eq!(r.rows[0][2], Value::Null); + assert_eq!(r.rows[0][3], Value::Null); + assert_eq!(r.rows[0][4], Value::Null); + } + + // ----- GROUP BY ----- + + #[test] + fn group_by_single_col_with_count() { + let db = seed_employees(); + let r = run_rows(&db, "SELECT dept, COUNT(*) FROM emp GROUP BY dept;"); + assert_eq!(r.rows.len(), 3); + // Build a map for a stable assertion regardless of group order. + let mut by_dept: std::collections::HashMap = Default::default(); + for row in &r.rows { + let d = row[0].to_display_string(); + let c = match &row[1] { + Value::Integer(i) => *i, + v => panic!("expected Integer count, got {v:?}"), + }; + by_dept.insert(d, c); + } + assert_eq!(by_dept["eng"], 3); + assert_eq!(by_dept["sales"], 2); + assert_eq!(by_dept["ops"], 1); + } + + #[test] + fn group_by_with_where_filter() { + let db = seed_employees(); + let r = run_rows( + &db, + "SELECT dept, SUM(salary) FROM emp WHERE salary > 80 GROUP BY dept;", + ); + // After WHERE, ops drops out (Eve = 80 excluded). eng has 3 rows + // contributing (100+120+100=320); sales has 1 (90; Dave NULL skipped). + let by: std::collections::HashMap = r + .rows + .iter() + .map(|row| { + ( + row[0].to_display_string(), + match &row[1] { + Value::Integer(i) => *i, + v => panic!("expected Integer sum, got {v:?}"), + }, + ) + }) + .collect(); + assert_eq!(by.len(), 2); + assert_eq!(by["eng"], 320); + assert_eq!(by["sales"], 90); + } + + #[test] + fn group_by_without_aggregates_is_distinct() { + let db = seed_employees(); + let r = run_rows(&db, "SELECT dept FROM emp GROUP BY dept;"); + assert_eq!(r.rows.len(), 3); + } + + #[test] + fn order_by_count_desc() { + let db = seed_employees(); + let r = run_rows( + &db, + "SELECT dept, COUNT(*) AS n FROM emp GROUP BY dept ORDER BY n DESC LIMIT 2;", + ); + assert_eq!(r.rows.len(), 2); + // Top group is 'eng' with 3. + assert_eq!(r.rows[0][0].to_display_string(), "eng"); + assert_eq!(r.rows[0][1], Value::Integer(3)); + } + + #[test] + fn order_by_aggregate_call_form() { + let db = seed_employees(); + // No alias — ORDER BY references the aggregate by its display form. + let r = run_rows( + &db, + "SELECT dept, COUNT(*) FROM emp GROUP BY dept ORDER BY COUNT(*) DESC;", + ); + assert_eq!(r.rows.len(), 3); + assert_eq!(r.rows[0][0].to_display_string(), "eng"); + } + + #[test] + fn group_by_invalid_bare_column_errors() { + // `name` is neither aggregated nor in GROUP BY → must error at parse. + let mut db = Database::new("t".to_string()); + crate::sql::process_command( + "CREATE TABLE t (id INTEGER PRIMARY KEY, dept TEXT, name TEXT);", + &mut db, + ) + .unwrap(); + let err = crate::sql::process_command("SELECT dept, name FROM t GROUP BY dept;", &mut db); + assert!(err.is_err(), "should reject bare 'name' not in GROUP BY"); + } + + #[test] + fn aggregate_in_where_errors_friendly() { + let mut db = Database::new("t".to_string()); + crate::sql::process_command("CREATE TABLE t (x INTEGER);", &mut db).unwrap(); + crate::sql::process_command("INSERT INTO t (x) VALUES (1);", &mut db).unwrap(); + let err = crate::sql::process_command("SELECT x FROM t WHERE COUNT(*) > 0;", &mut db); + assert!(err.is_err(), "aggregates must not be allowed in WHERE"); + } } diff --git a/src/sql/mod.rs b/src/sql/mod.rs index 1311b76..c4b660a 100644 --- a/src/sql/mod.rs +++ b/src/sql/mod.rs @@ -1,3 +1,4 @@ +pub mod agg; pub mod db; pub mod executor; pub mod fts; diff --git a/src/sql/parser/select.rs b/src/sql/parser/select.rs index 20b29d4..eef8aa1 100644 --- a/src/sql/parser/select.rs +++ b/src/sql/parser/select.rs @@ -1,17 +1,117 @@ use sqlparser::ast::{ - Expr, LimitClause, OrderByKind, Query, Select, SelectItem, SetExpr, Statement, TableFactor, - TableWithJoins, + DuplicateTreatment, Expr, FunctionArg, FunctionArgExpr, FunctionArguments, LimitClause, + OrderByKind, Query, Select, SelectItem, SetExpr, Statement, TableFactor, TableWithJoins, }; use crate::error::{Result, SQLRiteError}; +/// Aggregate function name. v1 covers the SQLite-classic five. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AggregateFn { + Count, + Sum, + Avg, + Min, + Max, +} + +impl AggregateFn { + pub fn as_str(self) -> &'static str { + match self { + AggregateFn::Count => "COUNT", + AggregateFn::Sum => "SUM", + AggregateFn::Avg => "AVG", + AggregateFn::Min => "MIN", + AggregateFn::Max => "MAX", + } + } + + fn from_name(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "count" => Some(AggregateFn::Count), + "sum" => Some(AggregateFn::Sum), + "avg" => Some(AggregateFn::Avg), + "min" => Some(AggregateFn::Min), + "max" => Some(AggregateFn::Max), + _ => None, + } + } +} + +/// What the aggregate is fed: `*` (only valid for COUNT) or a bare column. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AggregateArg { + Star, + Column(String), +} + +/// A parsed aggregate call like `COUNT(*)`, `SUM(salary)`, `COUNT(DISTINCT dept)`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AggregateCall { + pub func: AggregateFn, + pub arg: AggregateArg, + /// `DISTINCT` inside the parens. v1 only allows it on COUNT. + pub distinct: bool, +} + +impl AggregateCall { + /// Canonical display form used to match ORDER BY expressions against + /// aggregate output columns when the user didn't supply an alias. + /// Mirrors the output-header convention. + pub fn display_name(&self) -> String { + let inner = match &self.arg { + AggregateArg::Star => "*".to_string(), + AggregateArg::Column(c) => { + if self.distinct { + format!("DISTINCT {c}") + } else { + c.clone() + } + } + }; + format!("{}({inner})", self.func.as_str()) + } +} + +/// One entry in the projection list. +#[derive(Debug, Clone)] +pub struct ProjectionItem { + pub kind: ProjectionKind, + /// `AS alias` if explicitly supplied. + pub alias: Option, +} + +impl ProjectionItem { + /// Resolve the user-visible column header for this projection item. + /// Alias if supplied, else the bare column name or aggregate display. + pub fn output_name(&self) -> String { + if let Some(a) = &self.alias { + return a.clone(); + } + match &self.kind { + ProjectionKind::Column(c) => c.clone(), + ProjectionKind::Aggregate(a) => a.display_name(), + } + } +} + +/// What an individual projection item produces. +#[derive(Debug, Clone)] +pub enum ProjectionKind { + /// Bare column reference: `SELECT a, b, c`. + Column(String), + /// Aggregate function call: `COUNT(*)`, `SUM(col)`, etc. + Aggregate(AggregateCall), +} + /// What columns to project from a SELECT. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone)] pub enum Projection { /// `SELECT *` — every column in the table, in declaration order. All, - /// `SELECT a, b, c` — explicit list. - Columns(Vec), + /// Explicit, ordered projection list — possibly mixing bare columns + /// with aggregate calls (`SELECT dept, COUNT(*) FROM t`). + Items(Vec), } /// A parsed `ORDER BY` clause: a single sort key (expression), ascending @@ -36,6 +136,10 @@ pub struct SelectQuery { pub selection: Option, pub order_by: Option, pub limit: Option, + /// `SELECT DISTINCT`. + pub distinct: bool, + /// `GROUP BY a, b` — bare column names. Empty = no GROUP BY. + pub group_by: Vec, } impl SelectQuery { @@ -69,40 +173,86 @@ impl SelectQuery { .. } = select.as_ref(); - if distinct.is_some() { - return Err(SQLRiteError::NotImplemented( - "SELECT DISTINCT is not supported yet".to_string(), - )); - } + // SQLR-3: read DISTINCT instead of rejecting it. Postgres's + // `DISTINCT ON (...)` stays unsupported — it's a per-group + // tie-breaker that isn't part of the SQLite surface we mirror. + let distinct_flag = match distinct { + None => false, + Some(sqlparser::ast::Distinct::Distinct) => true, + Some(sqlparser::ast::Distinct::All) => false, + Some(sqlparser::ast::Distinct::On(_)) => { + return Err(SQLRiteError::NotImplemented( + "SELECT DISTINCT ON (...) is not supported".to_string(), + )); + } + }; if having.is_some() { return Err(SQLRiteError::NotImplemented( "HAVING is not supported yet".to_string(), )); } - // GroupByExpr::Expressions(v, _) with an empty v is the "no GROUP BY" shape. - if let sqlparser::ast::GroupByExpr::Expressions(exprs, _) = group_by { - if !exprs.is_empty() { + // SQLR-3: parse GROUP BY into a list of bare column names. + // GroupByExpr::Expressions(v, _) with an empty v is the "no + // GROUP BY" shape; non-empty means we've got grouping. Reject + // GROUP BY ALL and GROUP BY on non-bare expressions for v1. + let group_by_cols: Vec = match group_by { + sqlparser::ast::GroupByExpr::Expressions(exprs, _) => { + let mut out = Vec::with_capacity(exprs.len()); + for e in exprs { + let col = match e { + Expr::Identifier(ident) => ident.value.clone(), + Expr::CompoundIdentifier(parts) => { + parts.last().map(|p| p.value.clone()).ok_or_else(|| { + SQLRiteError::Internal("empty compound identifier".to_string()) + })? + } + other => { + return Err(SQLRiteError::NotImplemented(format!( + "GROUP BY only supports bare column references for now, got {other:?}" + ))); + } + }; + out.push(col); + } + out + } + _ => { return Err(SQLRiteError::NotImplemented( - "GROUP BY is not supported yet".to_string(), + "GROUP BY ALL is not supported".to_string(), )); } - } else { - return Err(SQLRiteError::NotImplemented( - "GROUP BY ALL is not supported".to_string(), - )); - } + }; let table_name = extract_single_table_name(from)?; let projection = parse_projection(projection)?; let order_by = parse_order_by(order_by.as_ref())?; let limit = parse_limit(limit_clause.as_ref())?; + // SQLR-3 validation: when GROUP BY is present, every bare-column + // entry in the projection must appear in the GROUP BY list. Bare + // columns in the SELECT are otherwise undefined per group. + if !group_by_cols.is_empty() + && let Projection::Items(items) = &projection + { + for item in items { + if let ProjectionKind::Column(c) = &item.kind + && !group_by_cols.contains(c) + { + return Err(SQLRiteError::Internal(format!( + "column '{c}' must appear in GROUP BY or be used in an aggregate function" + ))); + } + } + } + Ok(SelectQuery { table_name, projection, selection: selection.clone(), order_by, limit, + distinct: distinct_flag, + group_by: group_by_cols, }) } } @@ -129,37 +279,163 @@ fn extract_single_table_name(from: &[TableWithJoins]) -> Result { fn parse_projection(items: &[SelectItem]) -> Result { // Special-case `SELECT *`. - if items.len() == 1 { - if let SelectItem::Wildcard(_) = &items[0] { - return Ok(Projection::All); - } + if items.len() == 1 + && let SelectItem::Wildcard(_) = &items[0] + { + return Ok(Projection::All); } - let mut cols = Vec::with_capacity(items.len()); + let mut out = Vec::with_capacity(items.len()); for item in items { - match item { - SelectItem::UnnamedExpr(Expr::Identifier(ident)) => cols.push(ident.value.clone()), - SelectItem::UnnamedExpr(Expr::CompoundIdentifier(parts)) => { - if let Some(last) = parts.last() { - cols.push(last.value.clone()); - } else { - return Err(SQLRiteError::Internal( - "empty qualified column reference".to_string(), - )); - } - } - SelectItem::Wildcard(_) | SelectItem::QualifiedWildcard(_, _) => { - return Err(SQLRiteError::NotImplemented( - "Wildcard mixed with other columns is not supported".to_string(), - )); - } - SelectItem::ExprWithAlias { .. } | SelectItem::UnnamedExpr(_) => { - return Err(SQLRiteError::NotImplemented( - "Only bare column references are supported in the projection list".to_string(), - )); - } + out.push(parse_select_item(item)?); + } + Ok(Projection::Items(out)) +} + +fn parse_select_item(item: &SelectItem) -> Result { + match item { + SelectItem::UnnamedExpr(expr) => parse_projection_expr(expr, None), + SelectItem::ExprWithAlias { expr, alias } => { + parse_projection_expr(expr, Some(alias.value.clone())) + } + SelectItem::Wildcard(_) | SelectItem::QualifiedWildcard(_, _) => { + Err(SQLRiteError::NotImplemented( + "Wildcard mixed with other columns is not supported".to_string(), + )) } } - Ok(Projection::Columns(cols)) +} + +fn parse_projection_expr(expr: &Expr, alias: Option) -> Result { + match expr { + Expr::Identifier(ident) => Ok(ProjectionItem { + kind: ProjectionKind::Column(ident.value.clone()), + alias, + }), + Expr::CompoundIdentifier(parts) => { + let name = parts.last().map(|p| p.value.clone()).ok_or_else(|| { + SQLRiteError::Internal("empty qualified column reference".to_string()) + })?; + Ok(ProjectionItem { + kind: ProjectionKind::Column(name), + alias, + }) + } + Expr::Function(func) => { + let call = parse_aggregate_call(func)?; + Ok(ProjectionItem { + kind: ProjectionKind::Aggregate(call), + alias, + }) + } + other => Err(SQLRiteError::NotImplemented(format!( + "Only bare column references and aggregate functions are supported in the projection list (got {other:?})" + ))), + } +} + +fn parse_aggregate_call(func: &sqlparser::ast::Function) -> Result { + // Function name: only unqualified names like COUNT(...). Qualified + // names like `pkg.fn(...)` are out of scope. + let name = match func.name.0.as_slice() { + [sqlparser::ast::ObjectNamePart::Identifier(ident)] => ident.value.clone(), + _ => { + return Err(SQLRiteError::NotImplemented(format!( + "qualified function names not supported: {:?}", + func.name + ))); + } + }; + let agg_fn = AggregateFn::from_name(&name).ok_or_else(|| { + SQLRiteError::NotImplemented(format!( + "function '{name}' is not supported in the projection list (only aggregate functions are: COUNT, SUM, AVG, MIN, MAX)" + )) + })?; + + // Aggregates only accept the basic List form. None / Subquery forms + // (CURRENT_TIMESTAMP, scalar subqueries) don't apply here. + let arg_list = match &func.args { + FunctionArguments::List(l) => l, + _ => { + return Err(SQLRiteError::NotImplemented(format!( + "{name}(...) — unsupported argument shape" + ))); + } + }; + + let distinct = matches!( + arg_list.duplicate_treatment, + Some(DuplicateTreatment::Distinct) + ); + + if !arg_list.clauses.is_empty() { + return Err(SQLRiteError::NotImplemented(format!( + "{name}(...) — extra argument clauses (ORDER BY / LIMIT inside the call) are not supported" + ))); + } + if func.over.is_some() { + return Err(SQLRiteError::NotImplemented( + "window functions (OVER (...)) are not supported".to_string(), + )); + } + if func.filter.is_some() { + return Err(SQLRiteError::NotImplemented( + "FILTER (WHERE ...) on aggregates is not supported".to_string(), + )); + } + if !func.within_group.is_empty() { + return Err(SQLRiteError::NotImplemented( + "WITHIN GROUP on aggregates is not supported".to_string(), + )); + } + + if arg_list.args.len() != 1 { + return Err(SQLRiteError::NotImplemented(format!( + "{name}(...) expects exactly one argument, got {}", + arg_list.args.len() + ))); + } + + let arg = match &arg_list.args[0] { + FunctionArg::Unnamed(FunctionArgExpr::Wildcard) => AggregateArg::Star, + FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::Identifier(ident))) => { + AggregateArg::Column(ident.value.clone()) + } + FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::CompoundIdentifier(parts))) => { + let c = parts + .last() + .map(|p| p.value.clone()) + .ok_or_else(|| SQLRiteError::Internal("empty compound identifier".to_string()))?; + AggregateArg::Column(c) + } + other => { + return Err(SQLRiteError::NotImplemented(format!( + "{name}(...) — argument must be `*` or a bare column reference (got {other:?})" + ))); + } + }; + + // v1: only COUNT(DISTINCT col) is supported. SUM/AVG/MIN/MAX with + // DISTINCT are valid SQL but uncommon and add accumulator complexity + // we don't yet need. + if distinct && agg_fn != AggregateFn::Count { + return Err(SQLRiteError::NotImplemented(format!( + "DISTINCT is only supported on COUNT(...) for now, not {}", + agg_fn.as_str() + ))); + } + if matches!(arg, AggregateArg::Star) && agg_fn != AggregateFn::Count { + return Err(SQLRiteError::NotImplemented(format!( + "{}(*) is not supported; use {}()", + agg_fn.as_str(), + agg_fn.as_str() + ))); + } + + Ok(AggregateCall { + func: agg_fn, + arg, + distinct, + }) } fn parse_order_by(order_by: Option<&sqlparser::ast::OrderBy>) -> Result> {