nodedb 0.2.0

Local-first, real-time, edge-to-cloud hybrid database for multi-modal workloads
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
// SPDX-License-Identifier: BUSL-1.1

//! Integration tests for GROUP BY ROLLUP, CUBE, and GROUPING SETS.
//!
//! Each test creates a small `orders` collection, inserts fixture data,
//! then queries with one of the grouping-set constructs and verifies
//! the expected row shapes are present.

mod common;

use common::pgwire_harness::TestServer;

// ── Fixture ──────────────────────────────────────────────────────────────────

async fn create_orders(server: &TestServer) {
    server
        .exec("CREATE COLLECTION orders TYPE DOCUMENT SCHEMALESS")
        .await
        .unwrap();

    server
        .exec(
            "INSERT INTO orders (id, region, country, sales) VALUES \
             ('1', 'AMER', 'US',  100), \
             ('2', 'AMER', 'US',  200), \
             ('3', 'AMER', 'CA',  150), \
             ('4', 'EMEA', 'DE',  300), \
             ('5', 'EMEA', 'FR',  250)",
        )
        .await
        .unwrap();
}

// ── Tests ─────────────────────────────────────────────────────────────────────

/// `GROUP BY ROLLUP (region, country)` should produce 4 row shapes:
/// - (region, country)  — grouped by both
/// - (region, NULL)     — regional subtotal
/// - (NULL, NULL)       — grand total
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rollup_two_cols_produces_correct_shapes() {
    let server = TestServer::start().await;
    create_orders(&server).await;

    let rows = server
        .query_rows(
            "SELECT region, country, SUM(sales) AS total \
             FROM orders \
             GROUP BY ROLLUP (region, country)",
        )
        .await
        .unwrap();

    // ROLLUP(region, country) = GROUPING SETS ((region, country), (region), ()).
    // The fixture has 4 distinct (region, country) combinations:
    //   (AMER, US) [100+200=300], (AMER, CA) [150],
    //   (EMEA, DE) [300], (EMEA, FR) [250]
    // Plus 2 regional subtotals (AMER, EMEA) plus 1 grand total = 7 rows.
    assert_eq!(
        rows.len(),
        7,
        "expected exactly 7 ROLLUP rows (4 grouped + 2 regional subtotals + 1 grand total): {:?}",
        rows
    );

    // Grand total row: region = NULL, country = NULL.
    let grand_total_rows: Vec<_> = rows
        .iter()
        .filter(|r| {
            r.first().map(|s| s.is_empty()).unwrap_or(false)
                && r.get(1).map(|s| s.is_empty()).unwrap_or(false)
        })
        .collect();
    assert_eq!(
        grand_total_rows.len(),
        1,
        "expected exactly 1 grand-total row (region=NULL, country=NULL): {:?}",
        rows
    );

    // Grand total should be 1000.
    let grand = grand_total_rows[0]
        .get(2)
        .unwrap_or(&String::new())
        .parse::<f64>()
        .unwrap_or(0.0);
    assert!(
        (grand - 1000.0).abs() < 0.01,
        "grand total SUM(sales) should be 1000, got {grand}"
    );

    // Regional subtotal rows: country = NULL, region = non-empty.
    let regional_rows: Vec<_> = rows
        .iter()
        .filter(|r| {
            r.first().map(|s| !s.is_empty()).unwrap_or(false)
                && r.get(1).map(|s| s.is_empty()).unwrap_or(false)
        })
        .collect();
    assert_eq!(
        regional_rows.len(),
        2,
        "expected 2 regional subtotal rows (AMER, EMEA): {:?}",
        rows
    );
}

/// `GROUP BY CUBE (region, country)` should produce all 4 subset shapes:
/// (region, country), (region), (country), ()
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn cube_two_cols_produces_all_subsets() {
    let server = TestServer::start().await;
    create_orders(&server).await;

    let rows = server
        .query_rows(
            "SELECT region, country, SUM(sales) AS total \
             FROM orders \
             GROUP BY CUBE (region, country)",
        )
        .await
        .unwrap();

    // CUBE(region, country) = GROUPING SETS ((region,country), (region), (country), ()).
    // Set 1 (region,country): 4 unique combos
    // Set 2 (region only):    2 rows (AMER, EMEA)
    // Set 3 (country only):   4 rows (US, CA, DE, FR)
    // Set 4 ():               1 grand total
    // Total = 11 rows.
    assert_eq!(
        rows.len(),
        11,
        "expected 11 CUBE rows (4 + 2 + 4 + 1), got {}: {:?}",
        rows.len(),
        rows
    );

    // There must be exactly one grand-total row (both NULL).
    let grand_total = rows
        .iter()
        .filter(|r| {
            r.first().map(|s| s.is_empty()).unwrap_or(false)
                && r.get(1).map(|s| s.is_empty()).unwrap_or(false)
        })
        .count();
    assert_eq!(
        grand_total, 1,
        "CUBE must produce exactly 1 grand-total row"
    );

    // country-only rows: region = NULL, country != NULL.
    let country_only = rows
        .iter()
        .filter(|r| {
            r.first().map(|s| s.is_empty()).unwrap_or(false)
                && r.get(1).map(|s| !s.is_empty()).unwrap_or(false)
        })
        .count();
    assert!(
        country_only >= 4,
        "CUBE must produce country-only subtotal rows (at least 4), got {country_only}"
    );
}

/// `GROUP BY GROUPING SETS ((region, country), (region), ())` should produce
/// exactly those three row-shape categories.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn grouping_sets_explicit_shapes() {
    let server = TestServer::start().await;
    create_orders(&server).await;

    let rows = server
        .query_rows(
            "SELECT region, country, SUM(sales) AS total \
             FROM orders \
             GROUP BY GROUPING SETS ((region, country), (region), ())",
        )
        .await
        .unwrap();

    // Set 1: (region, country) — 5 rows (US×2 merged, CA, DE, FR) but US has 2 rows
    // so should be 4 unique combinations: US→300, CA→150, DE→300, FR→250
    // Set 2: (region) — 2 rows: AMER→450, EMEA→550
    // Set 3: () — 1 row: grand total 1000
    // Total = 7
    assert_eq!(
        rows.len(),
        7,
        "expected 7 rows for explicit GROUPING SETS, got {}: {:?}",
        rows.len(),
        rows
    );

    // Grand-total row (both NULL).
    let grand = rows
        .iter()
        .filter(|r| {
            r.first().map(|s| s.is_empty()).unwrap_or(false)
                && r.get(1).map(|s| s.is_empty()).unwrap_or(false)
        })
        .count();
    assert_eq!(grand, 1, "expected 1 grand-total row");

    // Regional rows (country NULL, region non-empty).
    let regional = rows
        .iter()
        .filter(|r| {
            r.first().map(|s| !s.is_empty()).unwrap_or(false)
                && r.get(1).map(|s| s.is_empty()).unwrap_or(false)
        })
        .count();
    assert_eq!(regional, 2, "expected 2 regional subtotals");

    // Fully-grouped rows (both non-empty).
    let detailed = rows
        .iter()
        .filter(|r| {
            r.first().map(|s| !s.is_empty()).unwrap_or(false)
                && r.get(1).map(|s| !s.is_empty()).unwrap_or(false)
        })
        .count();
    assert_eq!(detailed, 4, "expected 4 (region, country) rows");
}

/// `GROUPING(col)` returns 0 for real values and 1 for NULL-filled positions.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn grouping_function_returns_correct_indicator() {
    let server = TestServer::start().await;
    create_orders(&server).await;

    let rows = server
        .query_rows(
            "SELECT region, country, SUM(sales) AS total, \
                    GROUPING(region) AS gr_r, GROUPING(country) AS gr_c \
             FROM orders \
             GROUP BY ROLLUP (region, country)",
        )
        .await
        .unwrap();

    // Grand-total row: both GROUPING() values should be 1.
    let grand: Vec<_> = rows
        .iter()
        .filter(|r| {
            r.first().map(|s| s.is_empty()).unwrap_or(false)
                && r.get(1).map(|s| s.is_empty()).unwrap_or(false)
        })
        .collect();
    assert_eq!(grand.len(), 1, "expected 1 grand-total row");

    let gr_r = grand[0].get(3).map(|s| s.as_str()).unwrap_or("");
    let gr_c = grand[0].get(4).map(|s| s.as_str()).unwrap_or("");
    assert_eq!(
        gr_r, "1",
        "GROUPING(region) on grand-total row should be 1, got {gr_r:?}"
    );
    assert_eq!(
        gr_c, "1",
        "GROUPING(country) on grand-total row should be 1, got {gr_c:?}"
    );

    // Fully-grouped rows: both GROUPING() values should be 0.
    let detailed: Vec<_> = rows
        .iter()
        .filter(|r| {
            r.first().map(|s| !s.is_empty()).unwrap_or(false)
                && r.get(1).map(|s| !s.is_empty()).unwrap_or(false)
        })
        .collect();
    assert!(!detailed.is_empty(), "expected some fully-grouped rows");
    for row in &detailed {
        let gr_r = row.get(3).map(|s| s.as_str()).unwrap_or("");
        let gr_c = row.get(4).map(|s| s.as_str()).unwrap_or("");
        assert_eq!(
            gr_r, "0",
            "GROUPING(region) on detail row should be 0, got {gr_r:?}: {row:?}"
        );
        assert_eq!(
            gr_c, "0",
            "GROUPING(country) on detail row should be 0, got {gr_c:?}: {row:?}"
        );
    }
}

/// Mixed: `GROUP BY region, ROLLUP (country)` — plain key always present,
/// ROLLUP adds (country) and () set variants.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn mixed_plain_and_rollup() {
    let server = TestServer::start().await;
    create_orders(&server).await;

    let rows = server
        .query_rows(
            "SELECT region, country, SUM(sales) AS total \
             FROM orders \
             GROUP BY region, ROLLUP (country)",
        )
        .await
        .unwrap();

    // ROLLUP(country) with plain region produces:
    // Set 1: (region, country) — 4 combos: AMER/US, AMER/CA, EMEA/DE, EMEA/FR
    // Set 2: (region) — 2 rows: AMER, EMEA (country=NULL)
    // Total = 6
    assert_eq!(
        rows.len(),
        6,
        "expected 6 rows for mixed plain+rollup, got {}: {:?}",
        rows.len(),
        rows
    );

    // region is NEVER NULL (it's a plain key, always present).
    for row in &rows {
        let region = row.first().map(|s| s.as_str()).unwrap_or("");
        assert!(
            !region.is_empty(),
            "region must never be NULL in mixed plain+rollup, but got: {row:?}"
        );
    }

    // ROLLUP suffix: rows with country=NULL represent regional subtotals.
    let regional = rows
        .iter()
        .filter(|r| r.get(1).map(|s| s.is_empty()).unwrap_or(false))
        .count();
    assert_eq!(
        regional, 2,
        "expected 2 regional subtotal rows (AMER, EMEA)"
    );
}

/// Empty grouping set `()` must produce exactly one grand-total row.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn empty_grouping_set_produces_grand_total() {
    let server = TestServer::start().await;
    create_orders(&server).await;

    let rows = server
        .query_rows(
            "SELECT SUM(sales) AS grand_total \
             FROM orders \
             GROUP BY GROUPING SETS (())",
        )
        .await
        .unwrap();

    assert_eq!(
        rows.len(),
        1,
        "GROUPING SETS (()) must produce exactly 1 row, got {}: {:?}",
        rows.len(),
        rows
    );

    let total = rows[0]
        .first()
        .unwrap_or(&String::new())
        .parse::<f64>()
        .unwrap_or(0.0);
    assert!(
        (total - 1000.0).abs() < 0.01,
        "grand total should be 1000, got {total}"
    );
}

/// SUM / COUNT / AVG must compute correctly within each grouping set.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn aggregates_compute_correctly_per_set() {
    let server = TestServer::start().await;
    create_orders(&server).await;

    let rows = server
        .query_rows(
            "SELECT region, SUM(sales) AS total, COUNT(*) AS cnt \
             FROM orders \
             GROUP BY ROLLUP (region)",
        )
        .await
        .unwrap();

    // ROLLUP(region): (region) + () = 2 + 1 = 3 rows.
    assert_eq!(
        rows.len(),
        3,
        "expected 3 rows for ROLLUP(region), got {}: {:?}",
        rows.len(),
        rows
    );

    // AMER: 100 + 200 + 150 = 450, count = 3.
    let amer: Vec<_> = rows
        .iter()
        .filter(|r| r.first().map(|s| s.as_str()) == Some("AMER"))
        .collect();
    assert_eq!(amer.len(), 1, "expected exactly 1 AMER row");
    let amer_sum = amer[0]
        .get(1)
        .unwrap_or(&String::new())
        .parse::<f64>()
        .unwrap_or(-1.0);
    let amer_cnt = amer[0]
        .get(2)
        .unwrap_or(&String::new())
        .parse::<i64>()
        .unwrap_or(-1);
    assert!(
        (amer_sum - 450.0).abs() < 0.01,
        "AMER SUM should be 450, got {amer_sum}"
    );
    assert_eq!(amer_cnt, 3, "AMER COUNT should be 3, got {amer_cnt}");

    // EMEA: 300 + 250 = 550, count = 2.
    let emea: Vec<_> = rows
        .iter()
        .filter(|r| r.first().map(|s| s.as_str()) == Some("EMEA"))
        .collect();
    assert_eq!(emea.len(), 1, "expected exactly 1 EMEA row");
    let emea_sum = emea[0]
        .get(1)
        .unwrap_or(&String::new())
        .parse::<f64>()
        .unwrap_or(-1.0);
    assert!(
        (emea_sum - 550.0).abs() < 0.01,
        "EMEA SUM should be 550, got {emea_sum}"
    );

    // Grand total: 1000, count = 5.
    let grand: Vec<_> = rows
        .iter()
        .filter(|r| r.first().map(|s| s.is_empty()).unwrap_or(false))
        .collect();
    assert_eq!(grand.len(), 1, "expected 1 grand-total row");
    let grand_sum = grand[0]
        .get(1)
        .unwrap_or(&String::new())
        .parse::<f64>()
        .unwrap_or(-1.0);
    let grand_cnt = grand[0]
        .get(2)
        .unwrap_or(&String::new())
        .parse::<i64>()
        .unwrap_or(-1);
    assert!(
        (grand_sum - 1000.0).abs() < 0.01,
        "grand SUM should be 1000, got {grand_sum}"
    );
    assert_eq!(grand_cnt, 5, "grand COUNT should be 5, got {grand_cnt}");
}

/// ORDER BY on grouped columns must work: NULLs sort after non-NULLs with ASC
/// (NodeDB's default NULL handling for ASC).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn order_by_on_rollup_result() {
    let server = TestServer::start().await;
    create_orders(&server).await;

    let rows = server
        .query_rows(
            "SELECT region, SUM(sales) AS total \
             FROM orders \
             GROUP BY ROLLUP (region) \
             ORDER BY region ASC",
        )
        .await
        .unwrap();

    assert_eq!(rows.len(), 3, "expected 3 rows");

    // The first two rows should have non-NULL region (AMER, EMEA alphabetically).
    // The last row should be the grand-total (NULL region).
    let last = rows.last().unwrap();
    assert!(
        last.first().map(|s| s.is_empty()).unwrap_or(false),
        "last row with ORDER BY region ASC should be the grand-total (NULL), got: {last:?}"
    );
}