nodedb-sql 0.6.2

SQL parser, planner, and optimizer for NodeDB
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
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
// SPDX-License-Identifier: Apache-2.0

//! INSERT planning.

use sqlparser::ast;

use super::super::dml_helpers::{
    KvInsertParams, VectorPrimaryInsertParams, bind_insert_select_columns, build_kv_insert_plan,
    build_vector_primary_insert_plan, insert_select_targets, is_vector_primary,
    name_insert_select_outputs, refuse_vector_primary_shape, resolve_insert_columns,
};
use super::target::{
    OnConflict, classify_on_conflict, column_schema, insert_columns, resolve_target, target_scope,
    typed_rows, values_rows,
};
use super::upsert::plan_upsert_with_on_conflict;
use crate::engine_rules::{self, InsertParams, InsertSelectParams};
use crate::error::Result;
use crate::types::*;

/// Plan an INSERT statement.
pub fn plan_insert(
    ins: &ast::Insert,
    catalog: &dyn SqlCatalog,
    functions: &crate::functions::registry::FunctionRegistry,
    temporal: crate::TemporalScope,
) -> Result<Vec<SqlPlan>> {
    let (table_name, info) = resolve_target(ins, "INSERT", catalog)?;
    let target_scope = target_scope(&table_name, &info)?;

    // `INSERT ... ON CONFLICT DO UPDATE SET` reroutes to the upsert path
    // with the assignments carried through. `DO NOTHING` stays on the
    // INSERT path with `if_absent=true`.
    let if_absent = match classify_on_conflict(ins, &target_scope)? {
        OnConflict::None => false,
        OnConflict::DoNothing => true,
        OnConflict::DoUpdate(updates) => {
            return plan_upsert_with_on_conflict(ins, catalog, updates);
        }
    };

    let columns = insert_columns(&ins.columns, &target_scope)?;

    // Check for INSERT...SELECT.
    if let Some(source) = &ins.source
        && let ast::SetExpr::Select(select) = &*source.body
    {
        return plan_insert_select(
            InsertSelectTarget {
                table_name,
                info: &info,
                columns: &columns,
            },
            source,
            select,
            catalog,
            functions,
            temporal,
        );
    }

    let rows_ast = values_rows(ins, "INSERT")?;

    // KV engine: key and value are fundamentally separate — handle directly.
    // Positional column binding (below) does not apply here: the KV path
    // matches columns by name against `pk_col`/`"key"`/`"ttl"`, which is
    // orthogonal to declared column order.
    if info.engine == EngineType::KeyValue {
        let intent = if if_absent {
            KvInsertIntent::InsertIfAbsent
        } else {
            KvInsertIntent::Insert
        };
        return build_kv_insert_plan(KvInsertParams {
            collection: table_name,
            columns: &columns,
            rows_ast,
            intent,
            on_conflict_updates: Vec::new(),
            pk_col: info.primary_key.as_deref(),
            declared_columns: &info.columns,
            catalog,
        });
    }

    // Positional INSERT (no column list): bind values to the collection's
    // declared column order so named projections/predicates can find them.
    // No-op for named inserts and schemaless collections.
    let columns = resolve_insert_columns(columns, &info, rows_ast)?;

    // One typing pass for every remaining engine: defaults materialized,
    // then every cell coerced and range-checked against its declared type.
    let typed = typed_rows(&info, &columns, rows_ast, catalog)?;

    // Vector-primary collection: bypass document encoding. The row's
    // existence intent travels with the plan, as it does for KV.
    if is_vector_primary(&info)
        && let Some(ref vpc) = info.vector_primary
    {
        let intent = if if_absent {
            VectorPrimaryInsertIntent::InsertIfAbsent
        } else {
            VectorPrimaryInsertIntent::Insert
        };
        return build_vector_primary_insert_plan(VectorPrimaryInsertParams {
            collection: &table_name,
            vpc,
            rows: typed.rows,
            volatile_defaults: typed.volatile_defaults,
            intent,
            on_conflict_updates: Vec::new(),
            primary_key: info.primary_key.clone(),
        });
    }

    // All other engines: delegate to engine rules.
    let column_schema = column_schema(&info);
    let rules = engine_rules::resolve_engine_rules(info.engine);
    rules.plan_insert(InsertParams {
        collection: table_name,
        columns,
        rows: typed.rows,
        volatile_defaults: typed.volatile_defaults,
        if_absent,
        column_schema,
        primary_key: info.primary_key.clone(),
    })
}

/// The target side of an `INSERT ... SELECT`.
struct InsertSelectTarget<'a> {
    table_name: String,
    info: &'a CollectionInfo,
    /// The explicit target column list, empty when the statement gives none.
    columns: &'a [String],
}

/// Plan an `INSERT ... SELECT`.
///
/// The source SELECT plans as an ordinary query, with every output column
/// named so the copy can pair it with its target column. The target engine
/// decides through `EngineRules::plan_insert_select` whether it accepts the
/// copy. A vector-primary target is refused: the copy writes document
/// bodies, which its vector index never reads.
fn plan_insert_select(
    target: InsertSelectTarget<'_>,
    source: &ast::Query,
    select: &ast::Select,
    catalog: &dyn SqlCatalog,
    functions: &crate::functions::registry::FunctionRegistry,
    temporal: crate::TemporalScope,
) -> Result<Vec<SqlPlan>> {
    refuse_vector_primary_shape(target.info, "INSERT ... SELECT")?;
    let names = insert_select_targets(target.columns, select, target.info)?;
    let (named_source, source_columns) = name_insert_select_outputs(source, &names)?;
    let source_plan =
        super::super::select::plan_query(&named_source, catalog, functions, temporal)?;
    // A plain document scan is read as stored rows, so each target column
    // is bound as an expression over the stored row as well.
    let column_map = match &source_plan {
        SqlPlan::Scan {
            engine: EngineType::DocumentSchemaless | EngineType::DocumentStrict,
            ..
        } => bind_insert_select_columns(catalog, functions, temporal, &names, select)?,
        _ => Vec::new(),
    };
    engine_rules::resolve_engine_rules(target.info.engine).plan_insert_select(InsertSelectParams {
        collection: target.table_name,
        source: Box::new(source_plan),
        column_map,
        source_columns,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::catalog::{SqlCatalog, SqlCatalogError};
    use crate::parser::statement::parse_sql;
    use nodedb_types::columnar::IntWidth;
    use nodedb_types::datetime::NdbDateTime;

    fn column(name: &str, data_type: SqlDataType, default: Option<&str>) -> ColumnInfo {
        ColumnInfo {
            name: name.to_string(),
            data_type,
            nullable: true,
            is_primary_key: false,
            default: default.map(str::to_string),
            raw_type: None,
            int_width: None,
            float_width: None,
        }
    }

    fn collection(engine: EngineType, columns: Vec<ColumnInfo>) -> CollectionInfo {
        let primary_key = columns
            .iter()
            .find(|c| c.is_primary_key)
            .map(|c| c.name.clone());
        CollectionInfo {
            name: "t".into(),
            engine,
            columns,
            primary_key,
            has_auto_tier: false,
            indexes: Vec::new(),
            bitemporal: false,
            primary: nodedb_types::PrimaryEngine::Document,
            vector_primary: None,
            partition_strategy: nodedb_types::PartitionStrategy::CollectionHomed,
            open_schema: CollectionInfo::open_schema_for(engine),
        }
    }

    /// A catalog with one collection named `t`.
    struct OneCollection(CollectionInfo);

    impl SqlCatalog for OneCollection {
        fn get_collection(
            &self,
            _: nodedb_types::DatabaseId,
            name: &str,
        ) -> std::result::Result<Option<CollectionInfo>, SqlCatalogError> {
            Ok((name == "t").then(|| self.0.clone()))
        }
    }

    fn plan(sql: &str, catalog: &dyn SqlCatalog) -> Result<SqlPlan> {
        let statements = parse_sql(sql)?;
        let sqlparser::ast::Statement::Insert(ins) = &statements[0] else {
            panic!("expected an INSERT statement");
        };
        let mut plans = plan_insert(
            ins,
            catalog,
            &crate::functions::registry::FunctionRegistry::new(),
            crate::TemporalScope::default(),
        )?;
        Ok(plans.remove(0))
    }

    /// `2020-03-05T10:00:00Z` as microseconds since the Unix epoch.
    const EARLY_MICROS: i64 = 1_583_402_400_000_000;

    fn cell<'a>(row: &'a [(String, SqlValue)], name: &str) -> &'a SqlValue {
        &row.iter()
            .find(|(n, _)| n == name)
            .unwrap_or_else(|| panic!("row carries {name}"))
            .1
    }

    /// A numeric DEFAULT on a TIMESTAMP column is materialized, then coerced
    /// like a supplied literal: the plan carries the typed instant.
    #[test]
    fn a_numeric_timestamp_default_is_materialized_then_coerced() {
        let mut id = column("id", SqlDataType::String, None);
        id.is_primary_key = true;
        let catalog = OneCollection(collection(
            EngineType::DocumentSchemaless,
            vec![
                id,
                column("at", SqlDataType::Timestamp, Some("1583402400000")),
            ],
        ));
        let plan = plan("INSERT INTO t (id) VALUES ('r1')", &catalog).expect("plans");
        let SqlPlan::Insert(InsertPlan {
            rows,
            volatile_defaults,
            ..
        }) = plan
        else {
            panic!("expected SqlPlan::Insert, got {plan:?}");
        };
        assert!(!volatile_defaults, "a literal DEFAULT is not volatile");
        assert_eq!(
            cell(&rows[0], "at"),
            &SqlValue::Timestamp(NdbDateTime::from_micros(EARLY_MICROS))
        );
    }

    /// A DEFAULT the column cannot hold is refused at the insert, naming the
    /// column, exactly as the same literal in VALUES is.
    #[test]
    fn a_default_out_of_declared_range_is_refused() {
        let mut id = column("id", SqlDataType::String, None);
        id.is_primary_key = true;
        let mut small = column("s", SqlDataType::Int64, Some("999999"));
        small.int_width = Some(IntWidth::I16);
        let catalog = OneCollection(collection(EngineType::DocumentStrict, vec![id, small]));
        let err = plan("INSERT INTO t (id) VALUES ('r1')", &catalog)
            .expect_err("DEFAULT 999999 does not fit SMALLINT");
        assert!(
            matches!(err, crate::SqlError::IntegerOutOfRange { ref column, .. } if column == "s"),
            "{err}"
        );
    }

    /// A timeseries plan carries the declared default of an omitted column
    /// in its rows, the TIME_KEY column included.
    #[test]
    fn a_timeseries_plan_carries_defaults_in_its_rows() {
        let catalog = OneCollection(collection(
            EngineType::Timeseries,
            vec![
                column("at", SqlDataType::Timestamp, Some("1583402400000")),
                column("host", SqlDataType::String, Some("'h0'")),
                column("v", SqlDataType::Float64, None),
            ],
        ));
        let plan = plan("INSERT INTO t (v) VALUES (1.0)", &catalog).expect("plans");
        let SqlPlan::TimeseriesIngest(TimeseriesIngestPlan {
            rows,
            volatile_defaults,
            ..
        }) = plan
        else {
            panic!("expected SqlPlan::TimeseriesIngest, got {plan:?}");
        };
        assert!(!volatile_defaults);
        assert_eq!(
            cell(&rows[0], "at"),
            &SqlValue::Timestamp(NdbDateTime::from_micros(EARLY_MICROS))
        );
        assert_eq!(cell(&rows[0], "host"), &SqlValue::String("h0".into()));
        assert_eq!(cell(&rows[0], "v"), &SqlValue::Float(1.0));
    }

    /// A catalog with a target `t` and a source `s`.
    struct TwoCollections {
        target: CollectionInfo,
        source: CollectionInfo,
    }

    impl SqlCatalog for TwoCollections {
        fn get_collection(
            &self,
            _: nodedb_types::DatabaseId,
            name: &str,
        ) -> std::result::Result<Option<CollectionInfo>, SqlCatalogError> {
            Ok(match name {
                "t" => Some(self.target.clone()),
                "s" => Some(self.source.clone()),
                _ => None,
            })
        }
    }

    fn copy_catalog(target: EngineType, source: EngineType) -> TwoCollections {
        let mut id = column("id", SqlDataType::String, None);
        id.is_primary_key = true;
        let columns = vec![id, column("v", SqlDataType::String, None)];
        let mut source = collection(source, columns.clone());
        source.name = "s".into();
        TwoCollections {
            target: collection(target, columns),
            source,
        }
    }

    fn source_columns(plan: &SqlPlan) -> &[(String, String)] {
        let SqlPlan::InsertSelect { source_columns, .. } = plan else {
            panic!("expected SqlPlan::InsertSelect, got {plan:?}");
        };
        source_columns
    }

    /// Every unaliased SELECT-list item gets an output name the copy reads
    /// back, paired with its target column. A document scan source also
    /// binds each target column over the stored row.
    #[test]
    fn insert_select_names_every_output_column() {
        let catalog = copy_catalog(
            EngineType::DocumentSchemaless,
            EngineType::DocumentSchemaless,
        );
        let plan = plan("INSERT INTO t (id, v) SELECT id, v FROM s", &catalog).expect("plans");
        assert_eq!(
            source_columns(&plan),
            [
                ("id".to_string(), "__nodedb_insert_select_0".to_string()),
                ("v".to_string(), "__nodedb_insert_select_1".to_string()),
            ]
        );
        let SqlPlan::InsertSelect { column_map, .. } = &plan else {
            unreachable!()
        };
        assert_eq!(column_map.len(), 2);
    }

    /// An aliased item keeps its alias, so an ORDER BY over the alias keeps
    /// its meaning.
    #[test]
    fn insert_select_keeps_a_user_alias() {
        let catalog = copy_catalog(
            EngineType::DocumentSchemaless,
            EngineType::DocumentSchemaless,
        );
        let plan = plan(
            "INSERT INTO t (id, v) SELECT id, v AS score FROM s ORDER BY score",
            &catalog,
        )
        .expect("plans");
        assert_eq!(
            source_columns(&plan)[1],
            ("v".to_string(), "score".to_string())
        );
    }

    #[test]
    fn insert_select_with_a_repeated_alias_is_refused() {
        let catalog = copy_catalog(
            EngineType::DocumentSchemaless,
            EngineType::DocumentSchemaless,
        );
        let err = plan(
            "INSERT INTO t (id, v) SELECT id AS x, v AS x FROM s",
            &catalog,
        )
        .expect_err("two outputs named x");
        assert!(matches!(err, crate::SqlError::Unsupported { .. }), "{err}");
    }

    /// A key-value source plans as a query: no stored-row binding, only the
    /// output pairing.
    #[test]
    fn insert_select_from_a_kv_source_pairs_outputs_only() {
        let catalog = copy_catalog(EngineType::DocumentSchemaless, EngineType::KeyValue);
        let plan = plan("INSERT INTO t (id, v) SELECT id, v FROM s", &catalog).expect("plans");
        let SqlPlan::InsertSelect {
            column_map,
            source_columns,
            ..
        } = &plan
        else {
            panic!("expected SqlPlan::InsertSelect, got {plan:?}");
        };
        assert!(column_map.is_empty());
        assert_eq!(source_columns.len(), 2);
    }

    /// The target engine refuses at plan time, before any row is read.
    #[test]
    fn insert_select_into_a_non_document_target_is_refused() {
        for engine in [
            EngineType::KeyValue,
            EngineType::Columnar,
            EngineType::Spatial,
            EngineType::Timeseries,
        ] {
            let catalog = copy_catalog(engine, EngineType::DocumentSchemaless);
            let err = plan("INSERT INTO t (id, v) SELECT id, v FROM s", &catalog)
                .expect_err("a non-document target refuses the copy");
            assert!(
                matches!(err, crate::SqlError::Unsupported { .. }),
                "{engine:?}: {err}"
            );
        }
    }

    /// A vector-primary target is refused: the copy writes document bodies
    /// its vector index never reads.
    #[test]
    fn insert_select_into_a_vector_primary_target_is_refused() {
        let mut catalog = copy_catalog(
            EngineType::DocumentSchemaless,
            EngineType::DocumentSchemaless,
        );
        catalog.target.primary = nodedb_types::PrimaryEngine::Vector;
        catalog.target.vector_primary = Some(nodedb_types::VectorPrimaryConfig::default());
        let err = plan("INSERT INTO t (id, v) SELECT id, v FROM s", &catalog)
            .expect_err("a vector-primary target refuses the copy");
        assert!(
            matches!(err, crate::SqlError::Unsupported { ref detail } if detail.contains("vector-primary")),
            "{err}"
        );
    }

    /// A volatile DEFAULT marks the plan so the plan cache never replays it.
    #[test]
    fn a_volatile_default_marks_the_plan() {
        let mut id = column("id", SqlDataType::String, Some("UUID_V7"));
        id.is_primary_key = true;
        let catalog = OneCollection(collection(
            EngineType::DocumentStrict,
            vec![id, column("n", SqlDataType::Int64, None)],
        ));
        let plan = plan("INSERT INTO t (n) VALUES (1)", &catalog).expect("plans");
        assert!(
            !plan.cache_eligibility().is_cacheable(),
            "a plan carrying a volatile DEFAULT is never cached"
        );
    }
}