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

joaoh82 / rust_sqlite / 27258057219

10 Jun 2026 06:36AM UTC coverage: 69.507% (+0.3%) from 69.256%
27258057219

Pull #164

github

web-flow
Merge b0c1ffde5 into e67ca01c0
Pull Request #164: feat(sql): aggregates / GROUP BY / DISTINCT / HAVING over JOIN results (SQLR-6)

217 of 239 new or added lines in 3 files covered. (90.79%)

2 existing lines in 1 file now uncovered.

11762 of 16922 relevant lines covered (69.51%)

1.25 hits per line

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

70.75
/src/sql/parser/select.rs
1
use sqlparser::ast::{
2
    DuplicateTreatment, Expr, FunctionArg, FunctionArgExpr, FunctionArguments, JoinConstraint,
3
    JoinOperator, LimitClause, ObjectName, ObjectNamePart, OrderByKind, Query, Select, SelectItem,
4
    SetExpr, Statement, TableFactor, TableWithJoins, Value,
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
    pub(crate) 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 column
43
/// reference. SQLR-6 — the column carries its optional `t.` qualifier
44
/// so joined aggregation (`SUM(orders.amount)`) can disambiguate
45
/// same-named columns across in-scope tables; the single-table path
46
/// ignores it, same as projection qualifiers.
47
#[derive(Debug, Clone, PartialEq, Eq)]
48
pub enum AggregateArg {
49
    Star,
50
    Column {
51
        qualifier: Option<String>,
52
        name: String,
53
    },
54
}
55

56
/// A parsed aggregate call like `COUNT(*)`, `SUM(salary)`, `COUNT(DISTINCT dept)`.
57
#[derive(Debug, Clone, PartialEq, Eq)]
58
pub struct AggregateCall {
59
    pub func: AggregateFn,
60
    pub arg: AggregateArg,
61
    /// `DISTINCT` inside the parens. v1 only allows it on COUNT.
62
    pub distinct: bool,
63
}
64

65
impl AggregateCall {
66
    /// Canonical display form used to match ORDER BY expressions against
67
    /// aggregate output columns when the user didn't supply an alias.
68
    /// Mirrors the output-header convention.
69
    pub fn display_name(&self) -> String {
1✔
70
        self.display_name_impl(true)
1✔
71
    }
72

73
    /// Display form with the argument's `t.` qualifier stripped. Used
74
    /// as a fallback when matching ORDER BY function calls against
75
    /// projection slots, so `ORDER BY SUM(amount)` still finds a
76
    /// `SELECT SUM(o.amount)` slot (and vice versa).
77
    pub(crate) fn display_name_unqualified(&self) -> String {
1✔
78
        self.display_name_impl(false)
1✔
79
    }
80

81
    fn display_name_impl(&self, qualified: bool) -> String {
1✔
82
        let inner = match &self.arg {
1✔
83
            AggregateArg::Star => "*".to_string(),
1✔
84
            AggregateArg::Column { qualifier, name } => {
1✔
85
                let col = match qualifier {
1✔
86
                    Some(q) if qualified => format!("{q}.{name}"),
2✔
87
                    _ => name.clone(),
1✔
88
                };
89
                if self.distinct {
2✔
90
                    format!("DISTINCT {col}")
2✔
91
                } else {
92
                    col
1✔
93
                }
94
            }
95
        };
96
        format!("{}({inner})", self.func.as_str())
2✔
97
    }
98
}
99

100
/// One entry in the projection list.
101
#[derive(Debug, Clone)]
102
pub struct ProjectionItem {
103
    pub kind: ProjectionKind,
104
    /// `AS alias` if explicitly supplied.
105
    pub alias: Option<String>,
106
}
107

108
impl ProjectionItem {
109
    /// Resolve the user-visible column header for this projection item.
110
    /// Alias if supplied, else the bare column name or aggregate display.
111
    /// For qualified `t.col` shapes the header is just `col` — this
112
    /// matches SQLite, where qualifiers don't propagate to output
113
    /// column names.
114
    pub fn output_name(&self) -> String {
1✔
115
        if let Some(a) = &self.alias {
1✔
116
            return a.clone();
1✔
117
        }
118
        match &self.kind {
1✔
119
            ProjectionKind::Column { name, .. } => name.clone(),
1✔
120
            ProjectionKind::Aggregate(a) => a.display_name(),
1✔
121
        }
122
    }
123
}
124

125
/// What an individual projection item produces.
126
#[derive(Debug, Clone)]
127
pub enum ProjectionKind {
128
    /// Column reference. `qualifier` is `Some` for `t.col` shapes
129
    /// (SQLR-5 — needed so JOIN execution can disambiguate
130
    /// same-named columns across tables); `None` for bare `col`.
131
    /// The single-table path ignores the qualifier and looks up the
132
    /// name directly, preserving legacy behavior.
133
    Column {
134
        qualifier: Option<String>,
135
        name: String,
136
    },
137
    /// Aggregate function call: `COUNT(*)`, `SUM(col)`, etc.
138
    Aggregate(AggregateCall),
139
}
140

141
/// What columns to project from a SELECT.
142
#[derive(Debug, Clone)]
143
pub enum Projection {
144
    /// `SELECT *` — every column in the table, in declaration order.
145
    All,
146
    /// Explicit, ordered projection list — possibly mixing bare columns
147
    /// with aggregate calls (`SELECT dept, COUNT(*) FROM t`).
148
    Items(Vec<ProjectionItem>),
149
}
150

151
/// SQLR-6 — one GROUP BY key: an optionally-qualified column reference
152
/// (`dept` or `t.dept`). The qualifier matters for joined SELECTs,
153
/// where the same column name can exist on several in-scope tables;
154
/// the single-table path ignores it, mirroring projection qualifiers.
155
#[derive(Debug, Clone, PartialEq, Eq)]
156
pub struct GroupByKey {
157
    pub qualifier: Option<String>,
158
    pub name: String,
159
}
160

161
impl GroupByKey {
162
    /// Does a (possibly qualified) column reference name this key?
163
    /// Names must match exactly; qualifiers must match (ASCII
164
    /// case-insensitively) only when both sides carry one — a bare
165
    /// reference matches a qualified key and vice versa. Callers that
166
    /// need strict table-resolution equality (the joined executor)
167
    /// layer that check on top.
168
    pub(crate) fn matches_column(&self, qualifier: Option<&str>, name: &str) -> bool {
1✔
169
        self.name == name
1✔
170
            && match (self.qualifier.as_deref(), qualifier) {
2✔
171
                (Some(kq), Some(q)) => kq.eq_ignore_ascii_case(q),
1✔
172
                _ => true,
1✔
173
            }
174
    }
175
}
176

177
/// A parsed `ORDER BY` clause: a single sort key (expression), ascending
178
/// by default. Phase 7b widened this from "bare column name" to
179
/// "arbitrary expression" so KNN queries of the form
180
/// `ORDER BY vec_distance_l2(col, [...]) LIMIT k` work end-to-end. The
181
/// expression is evaluated per-row at execution time via `eval_expr`;
182
/// the simple `ORDER BY col` form still works because that's just an
183
/// `Expr::Identifier` taking the same path.
184
#[derive(Debug, Clone)]
185
pub struct OrderByClause {
186
    pub expr: Expr,
187
    pub ascending: bool,
188
}
189

190
/// SQLR-5 — flavor of join. SQLite ships INNER and LEFT OUTER; we
191
/// implement the full quartet on top of a single nested-loop driver
192
/// because the per-flavor differences are small (NULL-padding policy
193
/// for unmatched left/right rows). RIGHT OUTER and FULL OUTER aren't
194
/// in SQLite — see `docs/design-decisions.md` for the rationale.
195
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196
pub enum JoinType {
197
    Inner,
198
    LeftOuter,
199
    RightOuter,
200
    FullOuter,
201
}
202

203
impl JoinType {
204
    pub fn as_str(self) -> &'static str {
×
205
        match self {
×
206
            JoinType::Inner => "INNER",
×
207
            JoinType::LeftOuter => "LEFT OUTER",
×
208
            JoinType::RightOuter => "RIGHT OUTER",
×
209
            JoinType::FullOuter => "FULL OUTER",
×
210
        }
211
    }
212
}
213

214
/// How a JOIN matches rows. SQLR-5 originally shipped `ON` only; the
215
/// USING / NATURAL increment adds the two name-based constraints.
216
/// `ON` carries its predicate straight from the parser. `USING` and
217
/// `NATURAL` defer their equality synthesis to the executor because
218
/// they need table schemas (which column names exist, and — for
219
/// `NATURAL` — which are shared) that the parser doesn't have. The
220
/// executor turns both into the same `left.col = right.col [AND …]`
221
/// predicate the `ON` path already evaluates. `CROSS JOIN` is rewritten
222
/// to `ON true` at parse time (no schema needed) and so reuses the
223
/// `On` variant directly.
224
#[derive(Debug, Clone)]
225
pub enum JoinConstraintKind {
226
    /// `ON <expr>` (and the parse-time rewrite of `CROSS JOIN` to
227
    /// `ON true`). Evaluated per-row over the multi-table scope. Boxed
228
    /// to keep this enum small — `Expr` dwarfs the other variants.
229
    On(Box<Expr>),
230
    /// `USING (col[, col…])` — equality on each named column, plus the
231
    /// SQLite convention that each named column appears once in
232
    /// `SELECT *`. Columns are validated and the predicate is
233
    /// synthesized at execution time.
234
    Using(Vec<String>),
235
    /// `NATURAL` — the shared column names of the two sides are
236
    /// discovered at execution time, then treated exactly like
237
    /// `USING (<shared cols>)`. No shared columns ⇒ a cross product.
238
    Natural,
239
}
240

241
/// One JOIN clause from the FROM list. Multi-join queries
242
/// (`A JOIN B ... JOIN C ...`) become a `Vec<JoinClause>` evaluated
243
/// left-to-right against the accumulator. The match condition is one
244
/// of `ON` / `USING` / `NATURAL` (see [`JoinConstraintKind`]);
245
/// `CROSS JOIN` arrives here already rewritten to `ON true`.
246
#[derive(Debug, Clone)]
247
pub struct JoinClause {
248
    pub join_type: JoinType,
249
    pub right_table: String,
250
    /// `AS alias` if the right table introduced one. Stored separately
251
    /// from `right_table` so the executor can normalize on
252
    /// `alias.unwrap_or(right_table)` for qualifier matching.
253
    pub right_alias: Option<String>,
254
    /// What the join matches on. See [`JoinConstraintKind`].
255
    pub constraint: JoinConstraintKind,
256
}
257

258
/// A parsed, simplified SELECT query.
259
#[derive(Debug, Clone)]
260
pub struct SelectQuery {
261
    pub table_name: String,
262
    /// Optional `AS alias` on the leading FROM table. The executor's
263
    /// scope resolver treats `alias.unwrap_or(table_name)` as the
264
    /// qualifier name.
265
    pub table_alias: Option<String>,
266
    /// SQLR-5 — JOIN clauses in source order. Empty = single-table
267
    /// SELECT, the existing fast path.
268
    pub joins: Vec<JoinClause>,
269
    pub projection: Projection,
270
    /// Raw sqlparser WHERE expression, evaluated by the executor at run time.
271
    pub selection: Option<Expr>,
272
    pub order_by: Option<OrderByClause>,
273
    pub limit: Option<usize>,
274
    /// `SELECT DISTINCT`.
275
    pub distinct: bool,
276
    /// `GROUP BY a, t.b` — optionally-qualified column references in
277
    /// source order. Empty = no GROUP BY.
278
    pub group_by: Vec<GroupByKey>,
279
    /// SQLR-52 — raw sqlparser HAVING expression, evaluated by the
280
    /// executor against each group's output row after aggregation.
281
    /// Parser-level invariant: `Some` implies `group_by` is non-empty
282
    /// (HAVING without GROUP BY is rejected in v0).
283
    pub having: Option<Expr>,
284
}
285

286
impl SelectQuery {
287
    pub fn new(statement: &Statement) -> Result<Self> {
1✔
288
        let Statement::Query(query) = statement else {
1✔
289
            return Err(SQLRiteError::Internal(
×
290
                "Error parsing SELECT: expected a Query statement".to_string(),
×
291
            ));
292
        };
293

294
        let Query {
1✔
295
            body,
1✔
296
            order_by,
1✔
297
            limit_clause,
1✔
298
            ..
×
299
        } = query.as_ref();
×
300

301
        let SetExpr::Select(select) = body.as_ref() else {
2✔
302
            return Err(SQLRiteError::NotImplemented(
×
303
                "Only simple SELECT queries are supported (no UNION / VALUES / CTEs yet)"
×
304
                    .to_string(),
×
305
            ));
306
        };
307
        let Select {
1✔
308
            projection,
1✔
309
            from,
1✔
310
            selection,
1✔
311
            distinct,
1✔
312
            group_by,
1✔
313
            having,
1✔
314
            ..
×
315
        } = select.as_ref();
×
316

317
        // SQLR-3: read DISTINCT instead of rejecting it. Postgres's
318
        // `DISTINCT ON (...)` stays unsupported — it's a per-group
319
        // tie-breaker that isn't part of the SQLite surface we mirror.
320
        let distinct_flag = match distinct {
2✔
321
            None => false,
1✔
322
            Some(sqlparser::ast::Distinct::Distinct) => true,
1✔
323
            Some(sqlparser::ast::Distinct::All) => false,
×
324
            Some(sqlparser::ast::Distinct::On(_)) => {
×
325
                return Err(SQLRiteError::NotImplemented(
×
326
                    "SELECT DISTINCT ON (...) is not supported".to_string(),
×
327
                ));
328
            }
329
        };
330
        // SQLR-3: parse GROUP BY into a list of column references.
331
        // GroupByExpr::Expressions(v, _) with an empty v is the "no
332
        // GROUP BY" shape; non-empty means we've got grouping. Reject
333
        // GROUP BY ALL and GROUP BY on non-column expressions for v1.
334
        // SQLR-6 — keys keep their `t.` qualifier so joined grouping
335
        // (`GROUP BY customers.name`) can disambiguate.
336
        let group_by_cols: Vec<GroupByKey> = match group_by {
1✔
337
            sqlparser::ast::GroupByExpr::Expressions(exprs, _) => {
1✔
338
                let mut out = Vec::with_capacity(exprs.len());
1✔
339
                for e in exprs {
2✔
340
                    let key = match e {
1✔
341
                        Expr::Identifier(ident) => GroupByKey {
342
                            qualifier: None,
343
                            name: ident.value.clone(),
1✔
344
                        },
345
                        Expr::CompoundIdentifier(parts) => match parts.as_slice() {
1✔
346
                            [only] => GroupByKey {
347
                                qualifier: None,
NEW
348
                                name: only.value.clone(),
×
349
                            },
350
                            [q, c] => GroupByKey {
351
                                qualifier: Some(q.value.clone()),
2✔
352
                                name: c.value.clone(),
1✔
353
                            },
NEW
354
                            _ => {
×
NEW
355
                                return Err(SQLRiteError::NotImplemented(format!(
×
NEW
356
                                    "GROUP BY identifier with {} parts is not supported",
×
NEW
357
                                    parts.len()
×
358
                                )));
359
                            }
360
                        },
361
                        other => {
×
362
                            return Err(SQLRiteError::NotImplemented(format!(
×
363
                                "GROUP BY only supports bare column references for now, got {other:?}"
×
364
                            )));
365
                        }
366
                    };
367
                    out.push(key);
2✔
368
                }
369
                out
1✔
370
            }
371
            _ => {
×
372
                return Err(SQLRiteError::NotImplemented(
×
373
                    "GROUP BY ALL is not supported".to_string(),
×
374
                ));
375
            }
376
        };
377

378
        // SQLR-52 — HAVING is the post-aggregation filter, so it only
379
        // makes sense against grouped output. SQLite allows the
380
        // degenerate no-GROUP-BY single-group form, but the Phase 9e
381
        // executor's grouping pipeline assumes an explicit GROUP BY;
382
        // reject the degenerate shape rather than special-casing it.
383
        if having.is_some() && group_by_cols.is_empty() {
3✔
384
            return Err(SQLRiteError::NotImplemented(
1✔
385
                "HAVING without GROUP BY is not supported in v0; use WHERE for row-level \
×
386
                 filters or restructure with a subquery"
×
387
                    .to_string(),
1✔
388
            ));
389
        }
390

391
        let (table_name, table_alias, joins) = extract_from_clause(from)?;
2✔
392
        let projection = parse_projection(projection)?;
3✔
393
        let order_by = parse_order_by(order_by.as_ref())?;
2✔
394
        let limit = parse_limit(limit_clause.as_ref())?;
2✔
395

396
        // SQLR-3 validation: when GROUP BY is present, every bare-column
397
        // entry in the projection must appear in the GROUP BY list. Bare
398
        // columns in the SELECT are otherwise undefined per group.
399
        // SQLR-6 — only the single-table case validates here; the joined
400
        // case needs the table schemas to resolve qualifiers, so the
401
        // joined executor performs the equivalent check against the
402
        // full in-scope table list.
403
        if joins.is_empty()
1✔
404
            && !group_by_cols.is_empty()
1✔
405
            && let Projection::Items(items) = &projection
1✔
406
        {
407
            for item in items {
1✔
408
                if let ProjectionKind::Column { qualifier, name: c } = &item.kind
1✔
409
                    && !group_by_cols
3✔
410
                        .iter()
1✔
411
                        .any(|g| g.matches_column(qualifier.as_deref(), c))
3✔
412
                {
413
                    return Err(SQLRiteError::Internal(format!(
1✔
414
                        "column '{c}' must appear in GROUP BY or be used in an aggregate function"
×
415
                    )));
416
                }
417
            }
418
        }
419

420
        Ok(SelectQuery {
1✔
421
            table_name,
1✔
422
            table_alias,
1✔
423
            joins,
1✔
424
            projection,
1✔
425
            selection: selection.clone(),
1✔
426
            order_by,
1✔
427
            limit,
×
428
            distinct: distinct_flag,
1✔
429
            group_by: group_by_cols,
1✔
430
            having: having.clone(),
1✔
431
        })
432
    }
433
}
434

435
/// Pull the leading FROM table (with optional alias) and any JOIN
436
/// clauses out of the parsed FROM list. Supports a single base table
437
/// plus zero or more INNER / LEFT / RIGHT / FULL OUTER joins with an
438
/// `ON`, `USING (...)`, or `NATURAL` constraint, and `CROSS JOIN`
439
/// (rewritten to `INNER ... ON true`). Comma-separated FROM lists and
440
/// SEMI / ANTI / ASOF / APPLY joins surface as `NotImplemented`.
441
fn extract_from_clause(
1✔
442
    from: &[TableWithJoins],
443
) -> Result<(String, Option<String>, Vec<JoinClause>)> {
444
    if from.is_empty() {
1✔
445
        return Err(SQLRiteError::Internal(
×
446
            "SELECT requires a FROM clause".to_string(),
×
447
        ));
448
    }
449
    if from.len() != 1 {
1✔
450
        return Err(SQLRiteError::NotImplemented(
×
451
            "comma-separated FROM lists are not supported — use explicit JOIN syntax".to_string(),
×
452
        ));
453
    }
454
    let twj = &from[0];
2✔
455
    let (table_name, table_alias) = extract_table_factor(&twj.relation)?;
1✔
456

457
    let mut joins = Vec::with_capacity(twj.joins.len());
2✔
458
    for j in &twj.joins {
3✔
459
        let (right_table, right_alias) = extract_table_factor(&j.relation)?;
2✔
460
        let (join_type, constraint) = match &j.join_operator {
6✔
461
            // Bare `JOIN` defaults to INNER per SQL standard.
462
            JoinOperator::Join(c) | JoinOperator::Inner(c) => {
2✔
463
                (JoinType::Inner, convert_constraint(c)?)
2✔
464
            }
465
            JoinOperator::Left(c) | JoinOperator::LeftOuter(c) => {
1✔
466
                (JoinType::LeftOuter, convert_constraint(c)?)
1✔
467
            }
468
            JoinOperator::Right(c) | JoinOperator::RightOuter(c) => {
1✔
469
                (JoinType::RightOuter, convert_constraint(c)?)
1✔
470
            }
471
            JoinOperator::FullOuter(c) => (JoinType::FullOuter, convert_constraint(c)?),
2✔
472
            // `CROSS JOIN` is the cross product: INNER with an always-true
473
            // ON. A constraint on a CROSS JOIN is non-standard, but if the
474
            // parser handed us `USING` / `NATURAL` / `ON` we honor it
475
            // rather than silently dropping it.
476
            JoinOperator::CrossJoin(c) => (JoinType::Inner, convert_cross_constraint(c)?),
2✔
477
            other => {
×
478
                return Err(SQLRiteError::NotImplemented(format!(
×
479
                    "join flavor {other:?} is not supported \
480
                     (only INNER / LEFT OUTER / RIGHT OUTER / FULL OUTER / CROSS, \
481
                     with ON / USING / NATURAL)"
482
                )));
483
            }
484
        };
485
        joins.push(JoinClause {
1✔
486
            join_type,
487
            right_table,
1✔
488
            right_alias,
1✔
489
            constraint,
1✔
490
        });
491
    }
492

493
    Ok((table_name, table_alias, joins))
1✔
494
}
495

496
fn extract_table_factor(tf: &TableFactor) -> Result<(String, Option<String>)> {
1✔
497
    match tf {
1✔
498
        TableFactor::Table { name, alias, .. } => {
1✔
499
            let table_name = name.to_string();
1✔
500
            let alias_name = alias.as_ref().map(|a| a.name.value.clone());
4✔
501
            // We don't yet support alias column lists like `(c1, c2)` —
502
            // they only matter for table-valued functions / derived
503
            // tables, which we don't have either.
504
            if let Some(a) = alias.as_ref()
2✔
505
                && !a.columns.is_empty()
2✔
506
            {
507
                return Err(SQLRiteError::NotImplemented(
×
508
                    "table alias column lists are not supported".to_string(),
×
509
                ));
510
            }
511
            Ok((table_name, alias_name))
1✔
512
        }
513
        _ => Err(SQLRiteError::NotImplemented(
×
514
            "only plain table references are supported in FROM / JOIN".to_string(),
×
515
        )),
516
    }
517
}
518

519
/// Lower a `sqlparser` join constraint into our [`JoinConstraintKind`].
520
/// `ON` passes through; `USING` is narrowed to a list of bare column
521
/// names; `NATURAL` defers to the executor. A constraint-less join
522
/// (`A JOIN B` with no `ON` / `USING`) is rejected — `CROSS JOIN` is
523
/// the supported way to ask for a cross product and is handled by
524
/// [`convert_cross_constraint`].
525
fn convert_constraint(constraint: &JoinConstraint) -> Result<JoinConstraintKind> {
1✔
526
    match constraint {
1✔
527
        JoinConstraint::On(expr) => Ok(JoinConstraintKind::On(Box::new(expr.clone()))),
1✔
528
        JoinConstraint::Using(cols) => {
1✔
529
            let names = cols
2✔
530
                .iter()
1✔
531
                .map(extract_using_column)
1✔
532
                .collect::<Result<Vec<String>>>()?;
1✔
533
            Ok(JoinConstraintKind::Using(names))
1✔
534
        }
535
        JoinConstraint::Natural => Ok(JoinConstraintKind::Natural),
1✔
536
        JoinConstraint::None => Err(SQLRiteError::NotImplemented(
×
537
            "JOIN without an ON / USING / NATURAL condition is not supported \
538
             (use `... ON ...`, `... USING (...)`, `NATURAL JOIN`, or `CROSS JOIN`)"
539
                .to_string(),
×
540
        )),
541
    }
542
}
543

544
/// Constraint handling for `CROSS JOIN`. The standard form carries no
545
/// constraint and means "cross product", which we express as `ON true`
546
/// so it flows through the same executor path as any other join.
547
fn convert_cross_constraint(constraint: &JoinConstraint) -> Result<JoinConstraintKind> {
1✔
548
    match constraint {
1✔
549
        JoinConstraint::None => Ok(JoinConstraintKind::On(Box::new(true_literal()))),
1✔
550
        // Non-standard, but if a constraint was attached to a CROSS JOIN,
551
        // honor it instead of dropping it on the floor.
552
        other => convert_constraint(other),
×
553
    }
554
}
555

556
/// Pull a bare column name out of a `USING (...)` entry. `USING`
557
/// columns are always simple identifiers; anything qualified or
558
/// multi-part is rejected.
559
fn extract_using_column(name: &ObjectName) -> Result<String> {
1✔
560
    match name.0.as_slice() {
2✔
561
        [ObjectNamePart::Identifier(ident)] => Ok(ident.value.clone()),
2✔
562
        _ => Err(SQLRiteError::NotImplemented(format!(
×
563
            "USING column must be a simple column name, got {name}"
564
        ))),
565
    }
566
}
567

568
/// An always-true boolean literal expression, used to rewrite
569
/// `CROSS JOIN` into `INNER JOIN ... ON true`.
570
fn true_literal() -> Expr {
1✔
571
    Expr::Value(Value::Boolean(true).with_empty_span())
1✔
572
}
573

574
fn parse_projection(items: &[SelectItem]) -> Result<Projection> {
1✔
575
    // Special-case `SELECT *`.
576
    if items.len() == 1
1✔
577
        && let SelectItem::Wildcard(_) = &items[0]
1✔
578
    {
579
        return Ok(Projection::All);
1✔
580
    }
581
    let mut out = Vec::with_capacity(items.len());
1✔
582
    for item in items {
2✔
583
        out.push(parse_select_item(item)?);
2✔
584
    }
585
    Ok(Projection::Items(out))
1✔
586
}
587

588
fn parse_select_item(item: &SelectItem) -> Result<ProjectionItem> {
1✔
589
    match item {
1✔
590
        SelectItem::UnnamedExpr(expr) => parse_projection_expr(expr, None),
1✔
591
        SelectItem::ExprWithAlias { expr, alias } => {
1✔
592
            parse_projection_expr(expr, Some(alias.value.clone()))
1✔
593
        }
594
        SelectItem::Wildcard(_) | SelectItem::QualifiedWildcard(_, _) => {
595
            Err(SQLRiteError::NotImplemented(
×
596
                "Wildcard mixed with other columns is not supported".to_string(),
×
597
            ))
598
        }
599
    }
600
}
601

602
fn parse_projection_expr(expr: &Expr, alias: Option<String>) -> Result<ProjectionItem> {
1✔
603
    match expr {
1✔
604
        Expr::Identifier(ident) => Ok(ProjectionItem {
2✔
605
            kind: ProjectionKind::Column {
1✔
606
                qualifier: None,
1✔
607
                name: ident.value.clone(),
1✔
608
            },
609
            alias,
1✔
610
        }),
611
        Expr::CompoundIdentifier(parts) => match parts.as_slice() {
1✔
612
            [only] => Ok(ProjectionItem {
1✔
613
                kind: ProjectionKind::Column {
×
614
                    qualifier: None,
×
615
                    name: only.value.clone(),
×
616
                },
617
                alias,
×
618
            }),
619
            [q, c] => Ok(ProjectionItem {
3✔
620
                kind: ProjectionKind::Column {
1✔
621
                    qualifier: Some(q.value.clone()),
2✔
622
                    name: c.value.clone(),
1✔
623
                },
624
                alias,
1✔
625
            }),
626
            _ => Err(SQLRiteError::NotImplemented(format!(
×
627
                "compound identifier with {} parts is not supported in projection",
628
                parts.len()
×
629
            ))),
630
        },
631
        Expr::Function(func) => {
1✔
632
            let call = parse_aggregate_call(func)?;
2✔
633
            Ok(ProjectionItem {
1✔
634
                kind: ProjectionKind::Aggregate(call),
1✔
635
                alias,
1✔
636
            })
637
        }
638
        other => Err(SQLRiteError::NotImplemented(format!(
2✔
639
            "Only bare column references and aggregate functions are supported in the projection list (got {other:?})"
640
        ))),
641
    }
642
}
643

644
pub(crate) fn parse_aggregate_call(func: &sqlparser::ast::Function) -> Result<AggregateCall> {
1✔
645
    // Function name: only unqualified names like COUNT(...). Qualified
646
    // names like `pkg.fn(...)` are out of scope.
647
    let name = match func.name.0.as_slice() {
2✔
648
        [sqlparser::ast::ObjectNamePart::Identifier(ident)] => ident.value.clone(),
2✔
649
        _ => {
650
            return Err(SQLRiteError::NotImplemented(format!(
×
651
                "qualified function names not supported: {:?}",
652
                func.name
653
            )));
654
        }
655
    };
656
    let agg_fn = AggregateFn::from_name(&name).ok_or_else(|| {
2✔
657
        SQLRiteError::NotImplemented(format!(
×
658
            "function '{name}' is not supported in the projection list (only aggregate functions are: COUNT, SUM, AVG, MIN, MAX)"
659
        ))
660
    })?;
661

662
    // Aggregates only accept the basic List form. None / Subquery forms
663
    // (CURRENT_TIMESTAMP, scalar subqueries) don't apply here.
664
    let arg_list = match &func.args {
1✔
665
        FunctionArguments::List(l) => l,
1✔
666
        _ => {
667
            return Err(SQLRiteError::NotImplemented(format!(
×
668
                "{name}(...) — unsupported argument shape"
669
            )));
670
        }
671
    };
672

673
    let distinct = matches!(
2✔
674
        arg_list.duplicate_treatment,
2✔
675
        Some(DuplicateTreatment::Distinct)
676
    );
677

678
    if !arg_list.clauses.is_empty() {
1✔
679
        return Err(SQLRiteError::NotImplemented(format!(
×
680
            "{name}(...) — extra argument clauses (ORDER BY / LIMIT inside the call) are not supported"
681
        )));
682
    }
683
    if func.over.is_some() {
2✔
684
        return Err(SQLRiteError::NotImplemented(
×
685
            "window functions (OVER (...)) are not supported".to_string(),
×
686
        ));
687
    }
688
    if func.filter.is_some() {
2✔
689
        return Err(SQLRiteError::NotImplemented(
×
690
            "FILTER (WHERE ...) on aggregates is not supported".to_string(),
×
691
        ));
692
    }
693
    if !func.within_group.is_empty() {
2✔
694
        return Err(SQLRiteError::NotImplemented(
×
695
            "WITHIN GROUP on aggregates is not supported".to_string(),
×
696
        ));
697
    }
698

699
    if arg_list.args.len() != 1 {
2✔
700
        return Err(SQLRiteError::NotImplemented(format!(
×
701
            "{name}(...) expects exactly one argument, got {}",
702
            arg_list.args.len()
×
703
        )));
704
    }
705

706
    let arg = match &arg_list.args[0] {
3✔
707
        FunctionArg::Unnamed(FunctionArgExpr::Wildcard) => AggregateArg::Star,
1✔
708
        FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::Identifier(ident))) => {
1✔
709
            AggregateArg::Column {
710
                qualifier: None,
711
                name: ident.value.clone(),
1✔
712
            }
713
        }
714
        FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::CompoundIdentifier(parts))) => {
1✔
715
            match parts.as_slice() {
1✔
716
                [only] => AggregateArg::Column {
717
                    qualifier: None,
NEW
718
                    name: only.value.clone(),
×
719
                },
720
                [q, c] => AggregateArg::Column {
721
                    qualifier: Some(q.value.clone()),
2✔
722
                    name: c.value.clone(),
1✔
723
                },
724
                _ => {
NEW
725
                    return Err(SQLRiteError::NotImplemented(format!(
×
726
                        "{name}(...) — argument identifier with {} parts is not supported",
NEW
727
                        parts.len()
×
728
                    )));
729
                }
730
            }
731
        }
732
        other => {
×
733
            return Err(SQLRiteError::NotImplemented(format!(
×
734
                "{name}(...) — argument must be `*` or a bare column reference (got {other:?})"
735
            )));
736
        }
737
    };
738

739
    // v1: only COUNT(DISTINCT col) is supported. SUM/AVG/MIN/MAX with
740
    // DISTINCT are valid SQL but uncommon and add accumulator complexity
741
    // we don't yet need.
742
    if distinct && agg_fn != AggregateFn::Count {
3✔
743
        return Err(SQLRiteError::NotImplemented(format!(
×
744
            "DISTINCT is only supported on COUNT(...) for now, not {}",
745
            agg_fn.as_str()
×
746
        )));
747
    }
748
    if matches!(arg, AggregateArg::Star) && agg_fn != AggregateFn::Count {
3✔
749
        return Err(SQLRiteError::NotImplemented(format!(
×
750
            "{}(*) is not supported; use {}(<column>)",
751
            agg_fn.as_str(),
×
752
            agg_fn.as_str()
×
753
        )));
754
    }
755

756
    Ok(AggregateCall {
1✔
757
        func: agg_fn,
758
        arg,
1✔
759
        distinct,
1✔
760
    })
761
}
762

763
fn parse_order_by(order_by: Option<&sqlparser::ast::OrderBy>) -> Result<Option<OrderByClause>> {
1✔
764
    let Some(ob) = order_by else {
1✔
765
        return Ok(None);
1✔
766
    };
767
    let exprs = match &ob.kind {
1✔
768
        OrderByKind::Expressions(v) => v,
1✔
769
        OrderByKind::All(_) => {
770
            return Err(SQLRiteError::NotImplemented(
×
771
                "ORDER BY ALL is not supported".to_string(),
×
772
            ));
773
        }
774
    };
775
    if exprs.len() != 1 {
1✔
776
        return Err(SQLRiteError::NotImplemented(
×
777
            "ORDER BY must have exactly one column for now".to_string(),
×
778
        ));
779
    }
780
    let obe = &exprs[0];
1✔
781
    // Phase 7b: accept arbitrary expressions, not just bare column refs.
782
    // The executor's `sort_rowids` evaluates this expression per row via
783
    // `eval_expr`, which handles Identifier (column lookup), Function
784
    // (vec_distance_*), arithmetic, etc. uniformly. The previous
785
    // column-name-only restriction has been lifted.
786
    let expr = obe.expr.clone();
1✔
787
    // `asc == None` is the dialect default (ASC).
788
    let ascending = obe.options.asc.unwrap_or(true);
2✔
789
    Ok(Some(OrderByClause { expr, ascending }))
1✔
790
}
791

792
fn parse_limit(limit: Option<&LimitClause>) -> Result<Option<usize>> {
1✔
793
    let Some(lc) = limit else {
1✔
794
        return Ok(None);
1✔
795
    };
796
    let limit_expr = match lc {
1✔
797
        LimitClause::LimitOffset { limit, offset, .. } => {
1✔
798
            if offset.is_some() {
1✔
799
                return Err(SQLRiteError::NotImplemented(
×
800
                    "OFFSET is not supported yet".to_string(),
×
801
                ));
802
            }
803
            limit.as_ref()
1✔
804
        }
805
        LimitClause::OffsetCommaLimit { .. } => {
806
            return Err(SQLRiteError::NotImplemented(
×
807
                "`LIMIT <offset>, <limit>` syntax is not supported yet".to_string(),
×
808
            ));
809
        }
810
    };
811
    let Some(expr) = limit_expr else {
2✔
812
        return Ok(None);
×
813
    };
814
    let n = eval_const_usize(expr)?;
1✔
815
    Ok(Some(n))
1✔
816
}
817

818
fn eval_const_usize(expr: &Expr) -> Result<usize> {
1✔
819
    match expr {
1✔
820
        Expr::Value(v) => match &v.value {
1✔
821
            sqlparser::ast::Value::Number(n, _) => n.parse::<usize>().map_err(|e| {
1✔
822
                SQLRiteError::Internal(format!("LIMIT must be a non-negative integer: {e}"))
×
823
            }),
824
            _ => Err(SQLRiteError::Internal(
×
825
                "LIMIT must be an integer literal".to_string(),
×
826
            )),
827
        },
828
        _ => Err(SQLRiteError::NotImplemented(
×
829
            "LIMIT expression must be a literal number".to_string(),
×
830
        )),
831
    }
832
}
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