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

joaoh82 / rust_sqlite / 25417219010

06 May 2026 04:54AM UTC coverage: 64.597% (+1.0%) from 63.607%
25417219010

push

github

web-flow
feat(sql): GROUP BY, aggregates, DISTINCT, LIKE, IN (SQLR-3) (#97)

Closes the analytical-SQL gap that was sitting under "Possible extras":
- LIKE / NOT LIKE / ILIKE with %, _, \-escape; case-insensitive ASCII
  to match SQLite's default
- IN (list) / NOT IN (list); subquery form rejected with NotImplemented
- SELECT DISTINCT (single + multi-column; NULL == NULL for dedupe)
- GROUP BY on bare columns (implicit DISTINCT when no aggregates)
- Aggregates: COUNT(*), COUNT([DISTINCT] col), SUM, AVG, MIN, MAX
- Column aliases (COUNT(*) AS n) and ORDER BY by alias or display form
- Friendly error when an aggregate name lands in WHERE

SUM stays Integer until a Real input or i64 overflow promotes it once
to Real (SQLite-style); AVG always returns Real (NULL on empty).
HAVING, LIKE...ESCAPE, IN (subquery), DISTINCT on SUM/AVG/MIN/MAX, and
GROUP BY on expressions are explicit follow-ups.

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

596 of 735 new or added lines in 3 files covered. (81.09%)

8366 of 12951 relevant lines covered (64.6%)

1.21 hits per line

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

81.38
/src/sql/agg.rs
1
//! SQLR-3 aggregate runtime.
2
//!
3
//! Three concerns live here:
4
//!   1. `AggState` — per-group accumulator state for COUNT/SUM/AVG/MIN/MAX,
5
//!      with SQLite-style numeric type rules (Sum stays Integer until a
6
//!      Real input or i64 overflow forces a one-time promotion to f64).
7
//!   2. `DistinctKey` — a hashable typed wrapper around `Value`, used both
8
//!      as the per-row key for GROUP BY and as the dedupe key for
9
//!      `COUNT(DISTINCT col)` and `SELECT DISTINCT`.
10
//!   3. `like_match` — the iterative two-pointer LIKE matcher (case
11
//!      insensitive ASCII to match SQLite's default).
12
//!
13
//! All of this is pure-functional in the sense that nothing here touches
14
//! the `Database`/`Table`. The executor walks rows and feeds values in.
15

16
use std::collections::HashSet;
17

18
use crate::sql::db::table::Value;
19
use crate::sql::parser::select::{AggregateArg, AggregateCall, AggregateFn};
20

21
/// SQLite-style numeric accumulator: stays `Int` while every input is
22
/// Integer and the running total fits in i64, otherwise promotes once to
23
/// `Real` and never demotes back.
24
#[derive(Debug, Clone)]
25
pub enum SumAcc {
26
    Int(i64),
27
    Real(f64),
28
}
29

30
impl SumAcc {
31
    fn add_int(&mut self, j: i64) {
1✔
32
        match *self {
1✔
33
            SumAcc::Int(i) => match i.checked_add(j) {
1✔
34
                Some(s) => *self = SumAcc::Int(s),
1✔
NEW
35
                None => *self = SumAcc::Real(i as f64 + j as f64),
×
36
            },
NEW
37
            SumAcc::Real(r) => *self = SumAcc::Real(r + j as f64),
×
38
        }
39
    }
40
    fn add_real(&mut self, r: f64) {
1✔
41
        match *self {
1✔
42
            SumAcc::Int(i) => *self = SumAcc::Real(i as f64 + r),
1✔
NEW
43
            SumAcc::Real(x) => *self = SumAcc::Real(x + r),
×
44
        }
45
    }
46
    fn as_value(&self) -> Value {
1✔
47
        match self {
1✔
48
            SumAcc::Int(i) => Value::Integer(*i),
1✔
49
            SumAcc::Real(r) => Value::Real(*r),
1✔
50
        }
51
    }
52
    fn as_f64(&self) -> f64 {
1✔
53
        match self {
1✔
54
            SumAcc::Int(i) => *i as f64,
1✔
NEW
55
            SumAcc::Real(r) => *r,
×
56
        }
57
    }
58
}
59

60
/// Per-aggregate accumulator. One instance per (group, projection-slot)
61
/// pair lives for the duration of the SELECT.
62
#[derive(Debug, Clone)]
63
pub enum AggState {
64
    /// `COUNT(*)` — counts every row, including all-NULL rows.
65
    CountStar(i64),
66
    /// `COUNT(col)` — counts non-NULL values, optionally with DISTINCT.
67
    Count {
68
        non_null: i64,
69
        distinct: Option<HashSet<DistinctKey>>,
70
    },
71
    /// `SUM(col)` — skips NULLs; `all_null` tracks the SQL semantic that
72
    /// SUM over an all-NULL or empty set yields NULL (not 0).
73
    Sum {
74
        acc: SumAcc,
75
        all_null: bool,
76
    },
77
    /// `AVG(col)` — always returns Real (or NULL on empty / all-NULL).
78
    Avg {
79
        acc: SumAcc,
80
        n: i64,
81
    },
82
    /// `MIN(col)` / `MAX(col)` — track the running winner (or None until
83
    /// the first non-NULL input).
84
    Min(Option<Value>),
85
    Max(Option<Value>),
86
}
87

88
impl AggState {
89
    /// Construct the initial accumulator for an aggregate call.
90
    pub fn new(call: &AggregateCall) -> Self {
1✔
91
        match call.func {
1✔
92
            AggregateFn::Count => match &call.arg {
1✔
93
                AggregateArg::Star => AggState::CountStar(0),
94
                AggregateArg::Column(_) => AggState::Count {
95
                    non_null: 0,
96
                    distinct: if call.distinct {
2✔
97
                        Some(HashSet::new())
98
                    } else {
99
                        None
100
                    },
101
                },
102
            },
103
            AggregateFn::Sum => AggState::Sum {
104
                acc: SumAcc::Int(0),
1✔
105
                all_null: true,
106
            },
107
            AggregateFn::Avg => AggState::Avg {
108
                acc: SumAcc::Int(0),
1✔
109
                n: 0,
110
            },
111
            AggregateFn::Min => AggState::Min(None),
1✔
112
            AggregateFn::Max => AggState::Max(None),
1✔
113
        }
114
    }
115

116
    /// Fold one row's value into the accumulator.
117
    /// For `COUNT(*)`, the value is irrelevant — pass anything.
118
    pub fn update(&mut self, value: &Value) -> crate::error::Result<()> {
1✔
119
        match self {
1✔
120
            AggState::CountStar(c) => *c += 1,
2✔
121
            AggState::Count { non_null, distinct } => {
1✔
122
                if !matches!(value, Value::Null) {
1✔
123
                    if let Some(set) = distinct {
2✔
124
                        set.insert(DistinctKey::from_value(value));
1✔
125
                    } else {
126
                        *non_null += 1;
1✔
127
                    }
128
                }
129
            }
130
            AggState::Sum { acc, all_null } => match value {
1✔
NEW
131
                Value::Null => {}
×
132
                Value::Integer(i) => {
1✔
133
                    *all_null = false;
1✔
134
                    acc.add_int(*i);
1✔
135
                }
136
                Value::Real(r) => {
1✔
137
                    *all_null = false;
1✔
138
                    acc.add_real(*r);
1✔
139
                }
NEW
140
                Value::Bool(b) => {
×
NEW
141
                    *all_null = false;
×
NEW
142
                    acc.add_int(if *b { 1 } else { 0 });
×
143
                }
NEW
144
                other => {
×
NEW
145
                    return Err(crate::error::SQLRiteError::Internal(format!(
×
NEW
146
                        "SUM expects a numeric column, got {}",
×
NEW
147
                        other.to_display_string()
×
148
                    )));
149
                }
150
            },
151
            AggState::Avg { acc, n } => match value {
1✔
NEW
152
                Value::Null => {}
×
153
                Value::Integer(i) => {
1✔
154
                    acc.add_int(*i);
1✔
155
                    *n += 1;
2✔
156
                }
NEW
157
                Value::Real(r) => {
×
NEW
158
                    acc.add_real(*r);
×
NEW
159
                    *n += 1;
×
160
                }
NEW
161
                Value::Bool(b) => {
×
NEW
162
                    acc.add_int(if *b { 1 } else { 0 });
×
NEW
163
                    *n += 1;
×
164
                }
NEW
165
                other => {
×
NEW
166
                    return Err(crate::error::SQLRiteError::Internal(format!(
×
NEW
167
                        "AVG expects a numeric column, got {}",
×
NEW
168
                        other.to_display_string()
×
169
                    )));
170
                }
171
            },
172
            AggState::Min(cur) => {
1✔
173
                if !matches!(value, Value::Null) {
1✔
174
                    match cur {
1✔
175
                        None => *cur = Some(value.clone()),
1✔
176
                        Some(c) => {
1✔
177
                            if compare_values_total(value, c).is_lt() {
2✔
178
                                *cur = Some(value.clone());
1✔
179
                            }
180
                        }
181
                    }
182
                }
183
            }
184
            AggState::Max(cur) => {
1✔
185
                if !matches!(value, Value::Null) {
1✔
186
                    match cur {
1✔
187
                        None => *cur = Some(value.clone()),
1✔
188
                        Some(c) => {
1✔
189
                            if compare_values_total(value, c).is_gt() {
2✔
190
                                *cur = Some(value.clone());
1✔
191
                            }
192
                        }
193
                    }
194
                }
195
            }
196
        }
197
        Ok(())
1✔
198
    }
199

200
    /// Produce the final SQL value emitted for this group.
201
    pub fn finalize(&self) -> Value {
1✔
202
        match self {
3✔
203
            AggState::CountStar(c) => Value::Integer(*c),
1✔
204
            AggState::Count { non_null, distinct } => match distinct {
1✔
205
                Some(set) => Value::Integer(set.len() as i64),
1✔
206
                None => Value::Integer(*non_null),
1✔
207
            },
208
            AggState::Sum { acc, all_null } => {
1✔
209
                if *all_null {
2✔
210
                    Value::Null
1✔
211
                } else {
212
                    acc.as_value()
1✔
213
                }
214
            }
215
            AggState::Avg { acc, n } => {
1✔
216
                if *n == 0 {
3✔
217
                    Value::Null
1✔
218
                } else {
219
                    Value::Real(acc.as_f64() / (*n as f64))
1✔
220
                }
221
            }
222
            AggState::Min(v) | AggState::Max(v) => v.clone().unwrap_or(Value::Null),
3✔
223
        }
224
    }
225
}
226

227
/// A hashable typed wrapper around `Value`, used as the GROUP BY key
228
/// element and as the `COUNT(DISTINCT col)` set entry. We can't `impl
229
/// Hash for Value` because Value has a `Real(f64)` variant and `f64`
230
/// isn't `Hash + Eq`. Round-trip via `f64::to_bits` to keep the
231
/// canonical bit-pattern as the key — NaN keys remain distinguishable
232
/// by exact bit pattern, which is the safer choice for grouping (we
233
/// don't try to be cute about NaN==NaN).
234
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
235
pub enum DistinctKey {
236
    Null,
237
    Bool(bool),
238
    Int(i64),
239
    Real(u64),
240
    Text(String),
241
    Vector(Vec<u8>),
242
}
243

244
impl DistinctKey {
245
    pub fn from_value(v: &Value) -> Self {
1✔
246
        match v {
1✔
247
            Value::Null => DistinctKey::Null,
1✔
NEW
248
            Value::Bool(b) => DistinctKey::Bool(*b),
×
249
            Value::Integer(i) => DistinctKey::Int(*i),
1✔
250
            Value::Real(r) => DistinctKey::Real(r.to_bits()),
1✔
251
            Value::Text(s) => DistinctKey::Text(s.clone()),
1✔
NEW
252
            Value::Vector(v) => {
×
NEW
253
                let mut bytes = Vec::with_capacity(v.len() * 4);
×
NEW
254
                for f in v {
×
NEW
255
                    bytes.extend_from_slice(&f.to_le_bytes());
×
256
                }
NEW
257
                DistinctKey::Vector(bytes)
×
258
            }
259
        }
260
    }
261
}
262

263
/// Total-order comparison used by MIN/MAX. Mirrors the executor's
264
/// `compare_values` semantics (Int↔Real cross-coerce; otherwise stringify).
265
/// Kept separate to avoid a dependency from this module back into
266
/// executor.rs's private comparator.
267
fn compare_values_total(a: &Value, b: &Value) -> std::cmp::Ordering {
1✔
268
    use std::cmp::Ordering;
269
    match (a, b) {
2✔
NEW
270
        (Value::Null, Value::Null) => Ordering::Equal,
×
NEW
271
        (Value::Null, _) => Ordering::Less,
×
NEW
272
        (_, Value::Null) => Ordering::Greater,
×
273
        (Value::Integer(x), Value::Integer(y)) => x.cmp(y),
1✔
NEW
274
        (Value::Real(x), Value::Real(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
×
NEW
275
        (Value::Integer(x), Value::Real(y)) => {
×
NEW
276
            (*x as f64).partial_cmp(y).unwrap_or(Ordering::Equal)
×
277
        }
NEW
278
        (Value::Real(x), Value::Integer(y)) => {
×
NEW
279
            x.partial_cmp(&(*y as f64)).unwrap_or(Ordering::Equal)
×
280
        }
NEW
281
        (Value::Text(x), Value::Text(y)) => x.cmp(y),
×
NEW
282
        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
×
NEW
283
        (x, y) => x.to_display_string().cmp(&y.to_display_string()),
×
284
    }
285
}
286

287
/// SQL `LIKE` matcher.
288
///
289
/// Wildcards: `%` matches any (possibly empty) char sequence; `_`
290
/// matches exactly one char. `\` escapes the next char (so `\%` matches
291
/// a literal percent). When `case_insensitive` is true, ASCII letters
292
/// fold; non-ASCII characters compare by code-point (we don't pull in
293
/// Unicode case folding for v1).
294
///
295
/// Iterative two-pointer with backtracking — no recursion, so adversarial
296
/// patterns like `%a%a%a%a%a%b` against `aaaa…aa` can't blow the stack.
297
/// Worst case is O(|text| · |pattern|).
298
pub fn like_match(text: &str, pattern: &str, case_insensitive: bool) -> bool {
1✔
299
    let text: Vec<char> = text.chars().collect();
1✔
300
    let pat: Vec<char> = pattern.chars().collect();
2✔
301
    let n = text.len();
2✔
302
    let m = pat.len();
1✔
303

304
    let mut ti = 0usize;
1✔
305
    let mut pi = 0usize;
1✔
306
    // Backtrack point: the last position where we saw `%` and committed to
307
    // matching zero characters with it.
308
    let mut star_ti: Option<usize> = None;
1✔
309
    let mut star_pi: Option<usize> = None;
1✔
310

311
    while ti < n {
2✔
312
        if pi < m {
1✔
313
            let pc = pat[pi];
1✔
314
            if pc == '%' {
1✔
315
                star_pi = Some(pi);
1✔
316
                star_ti = Some(ti);
1✔
317
                pi += 1;
2✔
318
                continue;
319
            }
320
            if pc == '_' {
1✔
321
                pi += 1;
2✔
322
                ti += 1;
2✔
323
                continue;
324
            }
325
            // Escape support: `\X` matches a literal X for X in {%, _, \}.
326
            // Outside that set the backslash is itself literal (matches
327
            // SQLite's loose default).
328
            let (effective_pat, advance) = if pc == '\\' && pi + 1 < m {
3✔
329
                let nxt = pat[pi + 1];
2✔
330
                if nxt == '%' || nxt == '_' || nxt == '\\' {
2✔
331
                    (nxt, 2)
1✔
332
                } else {
NEW
333
                    (pc, 1)
×
334
                }
335
            } else {
336
                (pc, 1)
1✔
337
            };
338
            if char_eq(text[ti], effective_pat, case_insensitive) {
2✔
339
                pi += advance;
2✔
340
                ti += 1;
2✔
341
                continue;
342
            }
343
        }
344
        // Mismatch (or pattern exhausted before text). If a backtrack point
345
        // exists, expand the last `%` to absorb one more char and retry.
346
        if let (Some(spi), Some(sti)) = (star_pi, star_ti) {
3✔
347
            pi = spi + 1;
1✔
348
            star_ti = Some(sti + 1);
2✔
349
            ti = sti + 1;
2✔
350
        } else {
351
            return false;
1✔
352
        }
353
    }
354
    // Text exhausted; pattern must be done (or all that's left is `%`).
355
    while pi < m && pat[pi] == '%' {
2✔
NEW
356
        pi += 1;
×
357
    }
358
    pi == m
1✔
359
}
360

361
fn char_eq(a: char, b: char, case_insensitive: bool) -> bool {
1✔
362
    if !case_insensitive {
1✔
363
        return a == b;
1✔
364
    }
365
    if a.is_ascii() && b.is_ascii() {
1✔
366
        a.eq_ignore_ascii_case(&b)
1✔
367
    } else {
NEW
368
        a == b
×
369
    }
370
}
371

372
#[cfg(test)]
373
mod tests {
374
    use super::*;
375

376
    #[test]
377
    fn like_simple_literal() {
3✔
378
        assert!(like_match("apple", "apple", true));
1✔
379
        assert!(!like_match("apple", "apples", true));
1✔
380
    }
381

382
    #[test]
383
    fn like_percent_wildcard() {
3✔
384
        assert!(like_match("apple", "a%", true));
1✔
385
        assert!(like_match("apple", "%le", true));
1✔
386
        assert!(like_match("apple", "%pp%", true));
1✔
387
        assert!(!like_match("banana", "a%", true));
1✔
388
    }
389

390
    #[test]
391
    fn like_underscore_wildcard() {
3✔
392
        assert!(like_match("abc", "a_c", true));
1✔
393
        assert!(!like_match("abbc", "a_c", true));
1✔
394
    }
395

396
    #[test]
397
    fn like_case_insensitive_default() {
3✔
398
        assert!(like_match("Apple", "a%", true));
1✔
399
        assert!(like_match("APPLE", "%le", true));
1✔
NEW
400
        assert!(
×
401
            !like_match("Apple", "a%", false),
1✔
402
            "case-sensitive should fail"
403
        );
404
    }
405

406
    #[test]
407
    fn like_escape_percent_literal() {
3✔
408
        // pattern `100\%` should match literal "100%"
409
        assert!(like_match("100%", "100\\%", true));
1✔
410
        assert!(!like_match("100x", "100\\%", true));
1✔
411
    }
412

413
    #[test]
414
    fn like_no_pathological_recursion() {
4✔
415
        // The classic "exponential naive matcher" stress case.
416
        let text = "a".repeat(40);
1✔
417
        let pat = "a%a%a%a%a%a%a%a%b";
1✔
418
        // Should return false in linear time; if we recurse we'd stack-OOM
419
        // or hang; this test is mostly a smoke test.
420
        assert!(!like_match(&text, pat, true));
2✔
421
    }
422

423
    #[test]
424
    fn distinct_key_real_distinguishes_from_int() {
3✔
425
        let a = DistinctKey::from_value(&Value::Integer(1));
1✔
426
        let b = DistinctKey::from_value(&Value::Real(1.0));
1✔
427
        assert_ne!(a, b, "Integer(1) vs Real(1.0) must hash differently");
2✔
428
    }
429

430
    #[test]
431
    fn count_star_includes_nulls() {
3✔
432
        let call = AggregateCall {
433
            func: AggregateFn::Count,
434
            arg: AggregateArg::Star,
435
            distinct: false,
436
        };
437
        let mut s = AggState::new(&call);
1✔
438
        s.update(&Value::Null).unwrap();
2✔
439
        s.update(&Value::Integer(7)).unwrap();
1✔
440
        s.update(&Value::Null).unwrap();
1✔
441
        assert_eq!(s.finalize(), Value::Integer(3));
1✔
442
    }
443

444
    #[test]
445
    fn count_col_skips_nulls() {
4✔
446
        let call = AggregateCall {
447
            func: AggregateFn::Count,
448
            arg: AggregateArg::Column("x".into()),
1✔
449
            distinct: false,
450
        };
451
        let mut s = AggState::new(&call);
1✔
452
        s.update(&Value::Null).unwrap();
2✔
453
        s.update(&Value::Integer(7)).unwrap();
1✔
454
        s.update(&Value::Null).unwrap();
1✔
455
        assert_eq!(s.finalize(), Value::Integer(1));
1✔
456
    }
457

458
    #[test]
459
    fn count_distinct_dedupes() {
3✔
460
        let call = AggregateCall {
461
            func: AggregateFn::Count,
462
            arg: AggregateArg::Column("x".into()),
1✔
463
            distinct: true,
464
        };
465
        let mut s = AggState::new(&call);
1✔
466
        for v in [1, 1, 2, 2, 3, 3] {
3✔
467
            s.update(&Value::Integer(v)).unwrap();
1✔
468
        }
469
        s.update(&Value::Null).unwrap();
1✔
470
        assert_eq!(s.finalize(), Value::Integer(3));
1✔
471
    }
472

473
    #[test]
474
    fn sum_int_stays_int_until_real() {
3✔
475
        let call = AggregateCall {
476
            func: AggregateFn::Sum,
477
            arg: AggregateArg::Column("x".into()),
1✔
478
            distinct: false,
479
        };
480
        let mut s = AggState::new(&call);
1✔
481
        s.update(&Value::Integer(2)).unwrap();
2✔
482
        s.update(&Value::Integer(3)).unwrap();
1✔
483
        assert_eq!(s.finalize(), Value::Integer(5));
1✔
484

485
        s.update(&Value::Real(0.5)).unwrap();
1✔
486
        match s.finalize() {
1✔
487
            Value::Real(r) => assert!((r - 5.5).abs() < 1e-9),
2✔
NEW
488
            v => panic!("expected Real, got {:?}", v),
×
489
        }
490
    }
491

492
    #[test]
493
    fn sum_all_null_is_null() {
3✔
494
        let call = AggregateCall {
495
            func: AggregateFn::Sum,
496
            arg: AggregateArg::Column("x".into()),
1✔
497
            distinct: false,
498
        };
499
        let mut s = AggState::new(&call);
1✔
500
        s.update(&Value::Null).unwrap();
2✔
501
        s.update(&Value::Null).unwrap();
1✔
502
        assert_eq!(s.finalize(), Value::Null);
1✔
503
    }
504

505
    #[test]
506
    fn avg_always_real() {
3✔
507
        let call = AggregateCall {
508
            func: AggregateFn::Avg,
509
            arg: AggregateArg::Column("x".into()),
1✔
510
            distinct: false,
511
        };
512
        let mut s = AggState::new(&call);
1✔
513
        s.update(&Value::Integer(2)).unwrap();
2✔
514
        s.update(&Value::Integer(4)).unwrap();
1✔
515
        match s.finalize() {
1✔
516
            Value::Real(r) => assert!((r - 3.0).abs() < 1e-9),
2✔
NEW
517
            v => panic!("expected Real, got {:?}", v),
×
518
        }
519
    }
520

521
    #[test]
522
    fn min_max_skip_nulls() {
3✔
523
        let mk = |f| AggregateCall {
524
            func: f,
525
            arg: AggregateArg::Column("x".into()),
1✔
526
            distinct: false,
527
        };
528
        let mut mn = AggState::new(&mk(AggregateFn::Min));
1✔
529
        let mut mx = AggState::new(&mk(AggregateFn::Max));
1✔
530
        for v in [
2✔
531
            Value::Null,
1✔
532
            Value::Integer(7),
1✔
533
            Value::Integer(3),
1✔
534
            Value::Integer(9),
1✔
535
            Value::Null,
1✔
536
        ] {
537
            mn.update(&v).unwrap();
2✔
538
            mx.update(&v).unwrap();
1✔
539
        }
540
        assert_eq!(mn.finalize(), Value::Integer(3));
1✔
541
        assert_eq!(mx.finalize(), Value::Integer(9));
1✔
542
    }
543
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc