• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

joaoh82 / rust_sqlite / 25425173432

06 May 2026 08:41AM UTC coverage: 65.306% (+0.7%) from 64.597%
25425173432

push

github

web-flow
feat(sql): JOINs — INNER, LEFT OUTER, RIGHT OUTER, FULL OUTER (SQLR-5) (#99)

Adds the four-flavor JOIN quartet with explicit ON conditions and
table aliases. SQLite ships only INNER and LEFT OUTER; we implement
RIGHT and FULL OUTER as well because the per-flavor delta on top of
a shared nested-loop driver is just NULL-padding policy. See
docs/design-decisions.md §14a for the reasoning.

Engine changes:
  - New RowScope trait abstracts column lookup; SingleTableScope
    preserves the legacy fast path verbatim, JoinedScope handles
    multi-table resolution with NULL padding.
  - eval_expr / eval_predicate / eval_function / json_fn_* / FTS
    helpers / vector-arg extractor refactored to take &dyn RowScope.
  - execute_select_rows_joined: left-folded nested-loop driver,
    O(N×M) per join level. ON only sees tables in scope at that
    join level (forward refs error). ON reuses eval_predicate_scope
    so non-zero ints are truthy, matching WHERE.
  - Parser: SelectQuery gains table_alias + joins; ProjectionKind
    Column carries an optional qualifier for t.col disambiguation.
    USING / NATURAL / CROSS / comma joins, plus aggregates / GROUP
    BY / DISTINCT over JOIN, surface as friendly NotImplemented.

17 new tests covering all four flavors, NULL padding both ways,
qualified/unqualified resolution, ambiguity, self-join rejection,
three-table chaining, chained LEFT OUTER, NULL ordering on outer
joins, ON-references-later-table errors, and truthy-int ON.

Docs: README + supported-sql.md + design-decisions.md +
architecture.md + sql-engine.md updated.

526 tests passing, 0 failures. Build, fmt, clippy clean.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

387 of 450 new or added lines in 2 files covered. (86.0%)

3 existing lines in 2 files now uncovered.

8704 of 13328 relevant lines covered (65.31%)

1.21 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

67.35
/src/sql/parser/select.rs
1
use sqlparser::ast::{
2
    DuplicateTreatment, Expr, FunctionArg, FunctionArgExpr, FunctionArguments, JoinConstraint,
3
    JoinOperator, LimitClause, OrderByKind, Query, Select, SelectItem, SetExpr, Statement,
4
    TableFactor, TableWithJoins,
5
};
6

7
use crate::error::{Result, SQLRiteError};
8

9
/// Aggregate function name. v1 covers the SQLite-classic five.
10
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11
pub enum AggregateFn {
12
    Count,
13
    Sum,
14
    Avg,
15
    Min,
16
    Max,
17
}
18

19
impl AggregateFn {
20
    pub fn as_str(self) -> &'static str {
1✔
21
        match self {
1✔
22
            AggregateFn::Count => "COUNT",
1✔
23
            AggregateFn::Sum => "SUM",
1✔
24
            AggregateFn::Avg => "AVG",
1✔
25
            AggregateFn::Min => "MIN",
1✔
26
            AggregateFn::Max => "MAX",
1✔
27
        }
28
    }
29

30
    fn from_name(name: &str) -> Option<Self> {
1✔
31
        match name.to_ascii_lowercase().as_str() {
2✔
32
            "count" => Some(AggregateFn::Count),
2✔
33
            "sum" => Some(AggregateFn::Sum),
3✔
34
            "avg" => Some(AggregateFn::Avg),
3✔
35
            "min" => Some(AggregateFn::Min),
3✔
36
            "max" => Some(AggregateFn::Max),
3✔
37
            _ => None,
×
38
        }
39
    }
40
}
41

42
/// What the aggregate is fed: `*` (only valid for COUNT) or a bare column.
43
#[derive(Debug, Clone, PartialEq, Eq)]
44
pub enum AggregateArg {
45
    Star,
46
    Column(String),
47
}
48

49
/// A parsed aggregate call like `COUNT(*)`, `SUM(salary)`, `COUNT(DISTINCT dept)`.
50
#[derive(Debug, Clone, PartialEq, Eq)]
51
pub struct AggregateCall {
52
    pub func: AggregateFn,
53
    pub arg: AggregateArg,
54
    /// `DISTINCT` inside the parens. v1 only allows it on COUNT.
55
    pub distinct: bool,
56
}
57

58
impl AggregateCall {
59
    /// Canonical display form used to match ORDER BY expressions against
60
    /// aggregate output columns when the user didn't supply an alias.
61
    /// Mirrors the output-header convention.
62
    pub fn display_name(&self) -> String {
1✔
63
        let inner = match &self.arg {
1✔
64
            AggregateArg::Star => "*".to_string(),
1✔
65
            AggregateArg::Column(c) => {
1✔
66
                if self.distinct {
1✔
67
                    format!("DISTINCT {c}")
1✔
68
                } else {
69
                    c.clone()
1✔
70
                }
71
            }
72
        };
73
        format!("{}({inner})", self.func.as_str())
2✔
74
    }
75
}
76

77
/// One entry in the projection list.
78
#[derive(Debug, Clone)]
79
pub struct ProjectionItem {
80
    pub kind: ProjectionKind,
81
    /// `AS alias` if explicitly supplied.
82
    pub alias: Option<String>,
83
}
84

85
impl ProjectionItem {
86
    /// Resolve the user-visible column header for this projection item.
87
    /// Alias if supplied, else the bare column name or aggregate display.
88
    /// For qualified `t.col` shapes the header is just `col` — this
89
    /// matches SQLite, where qualifiers don't propagate to output
90
    /// column names.
91
    pub fn output_name(&self) -> String {
1✔
92
        if let Some(a) = &self.alias {
1✔
93
            return a.clone();
1✔
94
        }
95
        match &self.kind {
1✔
96
            ProjectionKind::Column { name, .. } => name.clone(),
1✔
97
            ProjectionKind::Aggregate(a) => a.display_name(),
1✔
98
        }
99
    }
100
}
101

102
/// What an individual projection item produces.
103
#[derive(Debug, Clone)]
104
pub enum ProjectionKind {
105
    /// Column reference. `qualifier` is `Some` for `t.col` shapes
106
    /// (SQLR-5 — needed so JOIN execution can disambiguate
107
    /// same-named columns across tables); `None` for bare `col`.
108
    /// The single-table path ignores the qualifier and looks up the
109
    /// name directly, preserving legacy behavior.
110
    Column {
111
        qualifier: Option<String>,
112
        name: String,
113
    },
114
    /// Aggregate function call: `COUNT(*)`, `SUM(col)`, etc.
115
    Aggregate(AggregateCall),
116
}
117

118
/// What columns to project from a SELECT.
119
#[derive(Debug, Clone)]
120
pub enum Projection {
121
    /// `SELECT *` — every column in the table, in declaration order.
122
    All,
123
    /// Explicit, ordered projection list — possibly mixing bare columns
124
    /// with aggregate calls (`SELECT dept, COUNT(*) FROM t`).
125
    Items(Vec<ProjectionItem>),
126
}
127

128
/// A parsed `ORDER BY` clause: a single sort key (expression), ascending
129
/// by default. Phase 7b widened this from "bare column name" to
130
/// "arbitrary expression" so KNN queries of the form
131
/// `ORDER BY vec_distance_l2(col, [...]) LIMIT k` work end-to-end. The
132
/// expression is evaluated per-row at execution time via `eval_expr`;
133
/// the simple `ORDER BY col` form still works because that's just an
134
/// `Expr::Identifier` taking the same path.
135
#[derive(Debug, Clone)]
136
pub struct OrderByClause {
137
    pub expr: Expr,
138
    pub ascending: bool,
139
}
140

141
/// SQLR-5 — flavor of join. SQLite ships INNER and LEFT OUTER; we
142
/// implement the full quartet on top of a single nested-loop driver
143
/// because the per-flavor differences are small (NULL-padding policy
144
/// for unmatched left/right rows). RIGHT OUTER and FULL OUTER aren't
145
/// in SQLite — see `docs/design-decisions.md` for the rationale.
146
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147
pub enum JoinType {
148
    Inner,
149
    LeftOuter,
150
    RightOuter,
151
    FullOuter,
152
}
153

154
impl JoinType {
NEW
155
    pub fn as_str(self) -> &'static str {
×
NEW
156
        match self {
×
NEW
157
            JoinType::Inner => "INNER",
×
NEW
158
            JoinType::LeftOuter => "LEFT OUTER",
×
NEW
159
            JoinType::RightOuter => "RIGHT OUTER",
×
NEW
160
            JoinType::FullOuter => "FULL OUTER",
×
161
        }
162
    }
163
}
164

165
/// One JOIN clause from the FROM list. Multi-join queries
166
/// (`A JOIN B ... JOIN C ...`) become a `Vec<JoinClause>` evaluated
167
/// left-to-right against the accumulator. v1 requires an ON condition;
168
/// USING / NATURAL / CROSS are deferred.
169
#[derive(Debug, Clone)]
170
pub struct JoinClause {
171
    pub join_type: JoinType,
172
    pub right_table: String,
173
    /// `AS alias` if the right table introduced one. Stored separately
174
    /// from `right_table` so the executor can normalize on
175
    /// `alias.unwrap_or(right_table)` for qualifier matching.
176
    pub right_alias: Option<String>,
177
    /// `ON <expr>` — required. Evaluated per-row by the executor over
178
    /// the multi-table scope.
179
    pub on: Expr,
180
}
181

182
/// A parsed, simplified SELECT query.
183
#[derive(Debug, Clone)]
184
pub struct SelectQuery {
185
    pub table_name: String,
186
    /// Optional `AS alias` on the leading FROM table. The executor's
187
    /// scope resolver treats `alias.unwrap_or(table_name)` as the
188
    /// qualifier name.
189
    pub table_alias: Option<String>,
190
    /// SQLR-5 — JOIN clauses in source order. Empty = single-table
191
    /// SELECT, the existing fast path.
192
    pub joins: Vec<JoinClause>,
193
    pub projection: Projection,
194
    /// Raw sqlparser WHERE expression, evaluated by the executor at run time.
195
    pub selection: Option<Expr>,
196
    pub order_by: Option<OrderByClause>,
197
    pub limit: Option<usize>,
198
    /// `SELECT DISTINCT`.
199
    pub distinct: bool,
200
    /// `GROUP BY a, b` — bare column names. Empty = no GROUP BY.
201
    pub group_by: Vec<String>,
202
}
203

204
impl SelectQuery {
205
    pub fn new(statement: &Statement) -> Result<Self> {
1✔
206
        let Statement::Query(query) = statement else {
1✔
207
            return Err(SQLRiteError::Internal(
×
208
                "Error parsing SELECT: expected a Query statement".to_string(),
×
209
            ));
210
        };
211

212
        let Query {
1✔
213
            body,
1✔
214
            order_by,
1✔
215
            limit_clause,
1✔
216
            ..
×
217
        } = query.as_ref();
×
218

219
        let SetExpr::Select(select) = body.as_ref() else {
2✔
220
            return Err(SQLRiteError::NotImplemented(
×
221
                "Only simple SELECT queries are supported (no UNION / VALUES / CTEs yet)"
×
222
                    .to_string(),
×
223
            ));
224
        };
225
        let Select {
1✔
226
            projection,
1✔
227
            from,
1✔
228
            selection,
1✔
229
            distinct,
1✔
230
            group_by,
1✔
231
            having,
1✔
232
            ..
×
233
        } = select.as_ref();
×
234

235
        // SQLR-3: read DISTINCT instead of rejecting it. Postgres's
236
        // `DISTINCT ON (...)` stays unsupported — it's a per-group
237
        // tie-breaker that isn't part of the SQLite surface we mirror.
238
        let distinct_flag = match distinct {
2✔
239
            None => false,
1✔
240
            Some(sqlparser::ast::Distinct::Distinct) => true,
1✔
241
            Some(sqlparser::ast::Distinct::All) => false,
×
242
            Some(sqlparser::ast::Distinct::On(_)) => {
×
243
                return Err(SQLRiteError::NotImplemented(
×
244
                    "SELECT DISTINCT ON (...) is not supported".to_string(),
×
245
                ));
246
            }
247
        };
248
        if having.is_some() {
1✔
249
            return Err(SQLRiteError::NotImplemented(
×
250
                "HAVING is not supported yet".to_string(),
×
251
            ));
252
        }
253
        // SQLR-3: parse GROUP BY into a list of bare column names.
254
        // GroupByExpr::Expressions(v, _) with an empty v is the "no
255
        // GROUP BY" shape; non-empty means we've got grouping. Reject
256
        // GROUP BY ALL and GROUP BY on non-bare expressions for v1.
257
        let group_by_cols: Vec<String> = match group_by {
1✔
258
            sqlparser::ast::GroupByExpr::Expressions(exprs, _) => {
1✔
259
                let mut out = Vec::with_capacity(exprs.len());
1✔
260
                for e in exprs {
2✔
261
                    let col = match e {
1✔
262
                        Expr::Identifier(ident) => ident.value.clone(),
2✔
263
                        Expr::CompoundIdentifier(parts) => {
×
264
                            parts.last().map(|p| p.value.clone()).ok_or_else(|| {
×
265
                                SQLRiteError::Internal("empty compound identifier".to_string())
×
266
                            })?
267
                        }
268
                        other => {
×
269
                            return Err(SQLRiteError::NotImplemented(format!(
×
270
                                "GROUP BY only supports bare column references for now, got {other:?}"
×
271
                            )));
272
                        }
273
                    };
274
                    out.push(col);
2✔
275
                }
276
                out
1✔
277
            }
278
            _ => {
×
279
                return Err(SQLRiteError::NotImplemented(
×
280
                    "GROUP BY ALL is not supported".to_string(),
×
281
                ));
282
            }
283
        };
284

285
        let (table_name, table_alias, joins) = extract_from_clause(from)?;
3✔
286
        let projection = parse_projection(projection)?;
3✔
287
        let order_by = parse_order_by(order_by.as_ref())?;
2✔
288
        let limit = parse_limit(limit_clause.as_ref())?;
2✔
289

290
        // SQLR-3 validation: when GROUP BY is present, every bare-column
291
        // entry in the projection must appear in the GROUP BY list. Bare
292
        // columns in the SELECT are otherwise undefined per group.
293
        if !group_by_cols.is_empty()
1✔
294
            && let Projection::Items(items) = &projection
2✔
295
        {
296
            for item in items {
1✔
297
                if let ProjectionKind::Column { name: c, .. } = &item.kind
1✔
298
                    && !group_by_cols.contains(c)
1✔
299
                {
300
                    return Err(SQLRiteError::Internal(format!(
1✔
301
                        "column '{c}' must appear in GROUP BY or be used in an aggregate function"
×
302
                    )));
303
                }
304
            }
305
        }
306

307
        // SQLR-5 — aggregations across joined results aren't covered
308
        // by the current single-table grouping pipeline. Reject GROUP
309
        // BY / aggregates over a join up front so the user gets a clear
310
        // message rather than wrong results.
311
        if !joins.is_empty() {
2✔
312
            let has_agg = matches!(
2✔
313
                &projection,
1✔
314
                Projection::Items(items)
1✔
315
                    if items.iter().any(|i| matches!(i.kind, ProjectionKind::Aggregate(_)))
4✔
316
            );
317
            if has_agg || !group_by_cols.is_empty() {
2✔
318
                return Err(SQLRiteError::NotImplemented(
1✔
NEW
319
                    "GROUP BY / aggregate functions over JOIN results are not supported yet"
×
320
                        .to_string(),
1✔
321
                ));
322
            }
323
            if distinct_flag {
1✔
NEW
324
                return Err(SQLRiteError::NotImplemented(
×
NEW
325
                    "SELECT DISTINCT over JOIN results is not supported yet".to_string(),
×
326
                ));
327
            }
328
        }
329

330
        Ok(SelectQuery {
1✔
331
            table_name,
1✔
332
            table_alias,
1✔
333
            joins,
1✔
334
            projection,
1✔
335
            selection: selection.clone(),
1✔
336
            order_by,
1✔
337
            limit,
×
338
            distinct: distinct_flag,
1✔
339
            group_by: group_by_cols,
1✔
340
        })
341
    }
342
}
343

344
/// Pull the leading FROM table (with optional alias) and any JOIN
345
/// clauses out of the parsed FROM list. v1 supports a single base
346
/// table plus zero or more INNER / LEFT / RIGHT / FULL OUTER joins
347
/// with explicit `ON` conditions. Comma-separated FROM lists,
348
/// USING / NATURAL constraints, and CROSS / SEMI / ANTI / ASOF joins
349
/// surface as `NotImplemented`.
350
fn extract_from_clause(
1✔
351
    from: &[TableWithJoins],
352
) -> Result<(String, Option<String>, Vec<JoinClause>)> {
353
    if from.is_empty() {
1✔
NEW
354
        return Err(SQLRiteError::Internal(
×
NEW
355
            "SELECT requires a FROM clause".to_string(),
×
356
        ));
357
    }
358
    if from.len() != 1 {
1✔
359
        return Err(SQLRiteError::NotImplemented(
×
NEW
360
            "comma-separated FROM lists are not supported — use explicit JOIN syntax".to_string(),
×
361
        ));
362
    }
363
    let twj = &from[0];
2✔
364
    let (table_name, table_alias) = extract_table_factor(&twj.relation)?;
1✔
365

366
    let mut joins = Vec::with_capacity(twj.joins.len());
2✔
367
    for j in &twj.joins {
3✔
368
        let (right_table, right_alias) = extract_table_factor(&j.relation)?;
2✔
369
        let (join_type, on_expr) = match &j.join_operator {
6✔
370
            // Bare `JOIN` defaults to INNER per SQL standard.
371
            JoinOperator::Join(c) | JoinOperator::Inner(c) => (JoinType::Inner, parse_on(c)?),
5✔
372
            JoinOperator::Left(c) | JoinOperator::LeftOuter(c) => {
1✔
373
                (JoinType::LeftOuter, parse_on(c)?)
1✔
374
            }
375
            JoinOperator::Right(c) | JoinOperator::RightOuter(c) => {
1✔
376
                (JoinType::RightOuter, parse_on(c)?)
1✔
377
            }
378
            JoinOperator::FullOuter(c) => (JoinType::FullOuter, parse_on(c)?),
2✔
NEW
379
            other => {
×
NEW
380
                return Err(SQLRiteError::NotImplemented(format!(
×
381
                    "join flavor {other:?} is not supported \
382
                     (only INNER / LEFT OUTER / RIGHT OUTER / FULL OUTER with ON)"
383
                )));
384
            }
385
        };
386
        joins.push(JoinClause {
1✔
387
            join_type,
388
            right_table,
1✔
389
            right_alias,
1✔
390
            on: on_expr,
1✔
391
        });
392
    }
393

394
    Ok((table_name, table_alias, joins))
1✔
395
}
396

397
fn extract_table_factor(tf: &TableFactor) -> Result<(String, Option<String>)> {
1✔
398
    match tf {
1✔
399
        TableFactor::Table { name, alias, .. } => {
1✔
400
            let table_name = name.to_string();
1✔
401
            let alias_name = alias.as_ref().map(|a| a.name.value.clone());
4✔
402
            // We don't yet support alias column lists like `(c1, c2)` —
403
            // they only matter for table-valued functions / derived
404
            // tables, which we don't have either.
405
            if let Some(a) = alias.as_ref()
2✔
406
                && !a.columns.is_empty()
2✔
407
            {
NEW
408
                return Err(SQLRiteError::NotImplemented(
×
NEW
409
                    "table alias column lists are not supported".to_string(),
×
410
                ));
411
            }
412
            Ok((table_name, alias_name))
1✔
413
        }
414
        _ => Err(SQLRiteError::NotImplemented(
×
NEW
415
            "only plain table references are supported in FROM / JOIN".to_string(),
×
416
        )),
417
    }
418
}
419

420
fn parse_on(constraint: &JoinConstraint) -> Result<Expr> {
1✔
421
    match constraint {
1✔
422
        JoinConstraint::On(expr) => Ok(expr.clone()),
1✔
423
        JoinConstraint::Using(_) => Err(SQLRiteError::NotImplemented(
1✔
424
            "JOIN ... USING (...) is not supported yet — use JOIN ... ON instead".to_string(),
1✔
425
        )),
426
        JoinConstraint::Natural => Err(SQLRiteError::NotImplemented(
1✔
427
            "NATURAL JOIN is not supported".to_string(),
1✔
428
        )),
NEW
429
        JoinConstraint::None => Err(SQLRiteError::NotImplemented(
×
NEW
430
            "JOIN without an ON condition is not supported (use INNER JOIN ... ON ...)".to_string(),
×
431
        )),
432
    }
433
}
434

435
fn parse_projection(items: &[SelectItem]) -> Result<Projection> {
1✔
436
    // Special-case `SELECT *`.
437
    if items.len() == 1
1✔
438
        && let SelectItem::Wildcard(_) = &items[0]
1✔
439
    {
440
        return Ok(Projection::All);
1✔
441
    }
442
    let mut out = Vec::with_capacity(items.len());
1✔
443
    for item in items {
2✔
444
        out.push(parse_select_item(item)?);
2✔
445
    }
446
    Ok(Projection::Items(out))
1✔
447
}
448

449
fn parse_select_item(item: &SelectItem) -> Result<ProjectionItem> {
1✔
450
    match item {
1✔
451
        SelectItem::UnnamedExpr(expr) => parse_projection_expr(expr, None),
1✔
452
        SelectItem::ExprWithAlias { expr, alias } => {
1✔
453
            parse_projection_expr(expr, Some(alias.value.clone()))
1✔
454
        }
455
        SelectItem::Wildcard(_) | SelectItem::QualifiedWildcard(_, _) => {
456
            Err(SQLRiteError::NotImplemented(
×
457
                "Wildcard mixed with other columns is not supported".to_string(),
×
458
            ))
459
        }
460
    }
461
}
462

463
fn parse_projection_expr(expr: &Expr, alias: Option<String>) -> Result<ProjectionItem> {
1✔
464
    match expr {
1✔
465
        Expr::Identifier(ident) => Ok(ProjectionItem {
2✔
466
            kind: ProjectionKind::Column {
1✔
467
                qualifier: None,
1✔
468
                name: ident.value.clone(),
1✔
469
            },
470
            alias,
1✔
471
        }),
472
        Expr::CompoundIdentifier(parts) => match parts.as_slice() {
1✔
473
            [only] => Ok(ProjectionItem {
1✔
NEW
474
                kind: ProjectionKind::Column {
×
NEW
475
                    qualifier: None,
×
NEW
476
                    name: only.value.clone(),
×
477
                },
UNCOV
478
                alias,
×
479
            }),
480
            [q, c] => Ok(ProjectionItem {
3✔
481
                kind: ProjectionKind::Column {
1✔
482
                    qualifier: Some(q.value.clone()),
2✔
483
                    name: c.value.clone(),
1✔
484
                },
485
                alias,
1✔
486
            }),
NEW
487
            _ => Err(SQLRiteError::NotImplemented(format!(
×
488
                "compound identifier with {} parts is not supported in projection",
NEW
489
                parts.len()
×
490
            ))),
491
        },
492
        Expr::Function(func) => {
1✔
493
            let call = parse_aggregate_call(func)?;
2✔
494
            Ok(ProjectionItem {
1✔
495
                kind: ProjectionKind::Aggregate(call),
1✔
496
                alias,
1✔
497
            })
498
        }
499
        other => Err(SQLRiteError::NotImplemented(format!(
2✔
500
            "Only bare column references and aggregate functions are supported in the projection list (got {other:?})"
501
        ))),
502
    }
503
}
504

505
fn parse_aggregate_call(func: &sqlparser::ast::Function) -> Result<AggregateCall> {
1✔
506
    // Function name: only unqualified names like COUNT(...). Qualified
507
    // names like `pkg.fn(...)` are out of scope.
508
    let name = match func.name.0.as_slice() {
2✔
509
        [sqlparser::ast::ObjectNamePart::Identifier(ident)] => ident.value.clone(),
2✔
510
        _ => {
511
            return Err(SQLRiteError::NotImplemented(format!(
×
512
                "qualified function names not supported: {:?}",
513
                func.name
514
            )));
515
        }
516
    };
517
    let agg_fn = AggregateFn::from_name(&name).ok_or_else(|| {
2✔
518
        SQLRiteError::NotImplemented(format!(
×
519
            "function '{name}' is not supported in the projection list (only aggregate functions are: COUNT, SUM, AVG, MIN, MAX)"
520
        ))
521
    })?;
522

523
    // Aggregates only accept the basic List form. None / Subquery forms
524
    // (CURRENT_TIMESTAMP, scalar subqueries) don't apply here.
525
    let arg_list = match &func.args {
1✔
526
        FunctionArguments::List(l) => l,
1✔
527
        _ => {
528
            return Err(SQLRiteError::NotImplemented(format!(
×
529
                "{name}(...) — unsupported argument shape"
530
            )));
531
        }
532
    };
533

534
    let distinct = matches!(
2✔
535
        arg_list.duplicate_treatment,
2✔
536
        Some(DuplicateTreatment::Distinct)
537
    );
538

539
    if !arg_list.clauses.is_empty() {
1✔
540
        return Err(SQLRiteError::NotImplemented(format!(
×
541
            "{name}(...) — extra argument clauses (ORDER BY / LIMIT inside the call) are not supported"
542
        )));
543
    }
544
    if func.over.is_some() {
2✔
545
        return Err(SQLRiteError::NotImplemented(
×
546
            "window functions (OVER (...)) are not supported".to_string(),
×
547
        ));
548
    }
549
    if func.filter.is_some() {
2✔
550
        return Err(SQLRiteError::NotImplemented(
×
551
            "FILTER (WHERE ...) on aggregates is not supported".to_string(),
×
552
        ));
553
    }
554
    if !func.within_group.is_empty() {
2✔
555
        return Err(SQLRiteError::NotImplemented(
×
556
            "WITHIN GROUP on aggregates is not supported".to_string(),
×
557
        ));
558
    }
559

560
    if arg_list.args.len() != 1 {
2✔
561
        return Err(SQLRiteError::NotImplemented(format!(
×
562
            "{name}(...) expects exactly one argument, got {}",
563
            arg_list.args.len()
×
564
        )));
565
    }
566

567
    let arg = match &arg_list.args[0] {
3✔
568
        FunctionArg::Unnamed(FunctionArgExpr::Wildcard) => AggregateArg::Star,
1✔
569
        FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::Identifier(ident))) => {
1✔
570
            AggregateArg::Column(ident.value.clone())
2✔
571
        }
572
        FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::CompoundIdentifier(parts))) => {
×
573
            let c = parts
×
574
                .last()
×
575
                .map(|p| p.value.clone())
×
576
                .ok_or_else(|| SQLRiteError::Internal("empty compound identifier".to_string()))?;
×
577
            AggregateArg::Column(c)
×
578
        }
579
        other => {
×
580
            return Err(SQLRiteError::NotImplemented(format!(
×
581
                "{name}(...) — argument must be `*` or a bare column reference (got {other:?})"
582
            )));
583
        }
584
    };
585

586
    // v1: only COUNT(DISTINCT col) is supported. SUM/AVG/MIN/MAX with
587
    // DISTINCT are valid SQL but uncommon and add accumulator complexity
588
    // we don't yet need.
589
    if distinct && agg_fn != AggregateFn::Count {
3✔
590
        return Err(SQLRiteError::NotImplemented(format!(
×
591
            "DISTINCT is only supported on COUNT(...) for now, not {}",
592
            agg_fn.as_str()
×
593
        )));
594
    }
595
    if matches!(arg, AggregateArg::Star) && agg_fn != AggregateFn::Count {
2✔
596
        return Err(SQLRiteError::NotImplemented(format!(
×
597
            "{}(*) is not supported; use {}(<column>)",
598
            agg_fn.as_str(),
×
599
            agg_fn.as_str()
×
600
        )));
601
    }
602

603
    Ok(AggregateCall {
1✔
604
        func: agg_fn,
605
        arg,
1✔
606
        distinct,
1✔
607
    })
608
}
609

610
fn parse_order_by(order_by: Option<&sqlparser::ast::OrderBy>) -> Result<Option<OrderByClause>> {
1✔
611
    let Some(ob) = order_by else {
1✔
612
        return Ok(None);
1✔
613
    };
614
    let exprs = match &ob.kind {
1✔
615
        OrderByKind::Expressions(v) => v,
1✔
616
        OrderByKind::All(_) => {
617
            return Err(SQLRiteError::NotImplemented(
×
618
                "ORDER BY ALL is not supported".to_string(),
×
619
            ));
620
        }
621
    };
622
    if exprs.len() != 1 {
1✔
623
        return Err(SQLRiteError::NotImplemented(
×
624
            "ORDER BY must have exactly one column for now".to_string(),
×
625
        ));
626
    }
627
    let obe = &exprs[0];
1✔
628
    // Phase 7b: accept arbitrary expressions, not just bare column refs.
629
    // The executor's `sort_rowids` evaluates this expression per row via
630
    // `eval_expr`, which handles Identifier (column lookup), Function
631
    // (vec_distance_*), arithmetic, etc. uniformly. The previous
632
    // column-name-only restriction has been lifted.
633
    let expr = obe.expr.clone();
1✔
634
    // `asc == None` is the dialect default (ASC).
635
    let ascending = obe.options.asc.unwrap_or(true);
2✔
636
    Ok(Some(OrderByClause { expr, ascending }))
1✔
637
}
638

639
fn parse_limit(limit: Option<&LimitClause>) -> Result<Option<usize>> {
1✔
640
    let Some(lc) = limit else {
1✔
641
        return Ok(None);
1✔
642
    };
643
    let limit_expr = match lc {
1✔
644
        LimitClause::LimitOffset { limit, offset, .. } => {
1✔
645
            if offset.is_some() {
1✔
646
                return Err(SQLRiteError::NotImplemented(
×
647
                    "OFFSET is not supported yet".to_string(),
×
648
                ));
649
            }
650
            limit.as_ref()
1✔
651
        }
652
        LimitClause::OffsetCommaLimit { .. } => {
653
            return Err(SQLRiteError::NotImplemented(
×
654
                "`LIMIT <offset>, <limit>` syntax is not supported yet".to_string(),
×
655
            ));
656
        }
657
    };
658
    let Some(expr) = limit_expr else {
2✔
659
        return Ok(None);
×
660
    };
661
    let n = eval_const_usize(expr)?;
1✔
662
    Ok(Some(n))
1✔
663
}
664

665
fn eval_const_usize(expr: &Expr) -> Result<usize> {
1✔
666
    match expr {
1✔
667
        Expr::Value(v) => match &v.value {
1✔
668
            sqlparser::ast::Value::Number(n, _) => n.parse::<usize>().map_err(|e| {
1✔
669
                SQLRiteError::Internal(format!("LIMIT must be a non-negative integer: {e}"))
×
670
            }),
671
            _ => Err(SQLRiteError::Internal(
×
672
                "LIMIT must be an integer literal".to_string(),
×
673
            )),
674
        },
675
        _ => Err(SQLRiteError::NotImplemented(
×
676
            "LIMIT expression must be a literal number".to_string(),
×
677
        )),
678
    }
679
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc