` 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