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
// SPDX-License-Identifier: Apache-2.0

//! Single SELECT statement planning (no UNION, no CTE wrapper).

use sqlparser::ast::{self, Select};

use super::comma_lateral::try_plan_comma_lateral;
use super::derived_from::try_plan_derived_from;
use super::helpers::{convert_projection, convert_where_to_filters};
use super::query_tail::QueryTail;
use super::where_search::{SearchBodyClauses, refuse_dropped_clauses, try_extract_where_search};
use crate::error::{Result, SqlError};
use crate::functions::registry::FunctionRegistry;
use crate::planner::ast_helpers::{single_table_qualifiers, strip_single_table_qualifiers};
use crate::resolver::columns::{ColumnNaming, TableScope};
use crate::temporal::TemporalScope;
use crate::types::*;

/// A planned SELECT body and the column namespace it resolved against.
pub(in crate::planner::select) struct PlannedSelect {
    pub plan: SqlPlan,
    pub scope: TableScope,
}

/// Plan a single SELECT statement (no UNION, no CTE wrapper).
///
/// `tail` carries the enclosing query's ORDER BY / LIMIT so the base scan can
/// be built with them in place — see [`QueryTail`] for why the engine rules
/// need them before `plan_scan` runs.
///
/// `statement_output` says whether this SELECT produces the statement's
/// result rows. Only such a SELECT list may hold a Control-Plane-computed
/// item; the flag lands on the scope, and every clause reads it from there.
pub(super) fn plan_select(
    select: &Select,
    catalog: &dyn SqlCatalog,
    functions: &FunctionRegistry,
    temporal: TemporalScope,
    tail: &QueryTail<'_>,
    statement_output: bool,
) -> Result<PlannedSelect> {
    // 0. Intercept array table-valued functions before catalog resolution
    //    so a name like `ARRAY_SLICE` is not looked up as a collection.
    if let Some(plan) =
        crate::planner::array_fn::try_plan_array_table_fn(&select.from, catalog, temporal)?
    {
        // `resolve_from` synthesizes a relation from the array's dims and
        // attrs, so ORDER BY and the tail clauses resolve its columns.
        return Ok(PlannedSelect {
            plan,
            scope: TableScope::resolve_from(catalog, functions, temporal, &select.from)?,
        });
    }

    // 0.5. Derived FROM subquery: `FROM (SELECT ...) AS t`.
    //
    // Plan the inner subquery first, then desugar into a synthetic CTE
    // so the outer SELECT — which may reference `t` like any other
    // relation — plans against a catalog that resolves the alias to a
    // schemaless source. Until this branch existed the resolver
    // dropped non-LATERAL derived factors silently, the scope ended
    // up empty, and the planner errored with "multi-table FROM
    // without JOIN".
    if let Some(planned) =
        try_plan_derived_from(select, catalog, functions, temporal, tail, statement_output)?
    {
        return Ok(planned);
    }

    // 1. Resolve FROM tables.
    let scope = TableScope::resolve_from(catalog, functions, temporal, &select.from)?;
    let scope = if statement_output {
        scope.as_statement_output()
    } else {
        scope
    };

    // 2. Handle constant queries (no FROM clause): SELECT 1, SELECT 'hello', etc.
    if select.from.is_empty() {
        // Intercept maintenance functions (ARRAY_FLUSH / ARRAY_COMPACT)
        // before falling through to constant evaluation.
        if let Some(plan) =
            crate::planner::array_fn::try_plan_array_maint_fn(&select.projection, catalog)?
        {
            return Ok(PlannedSelect { plan, scope });
        }
        let projection = convert_projection(&select.projection, &scope)?;
        let mut columns = Vec::new();
        let mut values = Vec::new();
        let mut volatile = false;
        for (i, proj) in projection.iter().enumerate() {
            match proj {
                Projection::Computed { expr, alias } => {
                    columns.push(alias.clone());
                    volatile |= crate::types::plan::expr_is_volatile(expr);
                    values.push(crate::planner::catalog_expr_fold::eval_catalog_constant(
                        expr, catalog, functions,
                    )?);
                }
                Projection::Column(name) => {
                    columns.push(name.clone());
                    values.push(SqlValue::Null);
                }
                _ => {
                    columns.push(format!("col{i}"));
                    values.push(SqlValue::Null);
                }
            }
        }
        return Ok(PlannedSelect {
            plan: SqlPlan::ConstantResult {
                columns,
                values,
                volatile,
            },
            scope,
        });
    }

    // 3. Check for JOINs (including LATERAL).
    if let Some(plan) = try_plan_join(select, &scope, catalog, functions, temporal)? {
        // ORDER BY sorts the rows the join plan emits. A join emits merged
        // rows keyed `qualifier.column`. An aggregate over a join emits
        // group rows keyed by bare column name. A LATERAL plan keeps
        // references as written.
        let naming = match &plan {
            SqlPlan::Join { .. } => ColumnNaming::JoinRow,
            SqlPlan::Aggregate { .. } => ColumnNaming::OutputRow,
            _ => ColumnNaming::AsWritten,
        };
        return Ok(PlannedSelect {
            plan,
            scope: scope.with_column_naming(naming),
        });
    }

    // 3b. Comma-LATERAL syntax: `FROM t, LATERAL (SELECT ...) x`.
    if let Some(plan) = try_plan_comma_lateral(select, &scope, catalog, functions, temporal)? {
        return Ok(PlannedSelect { plan, scope });
    }

    // 4. Single-table query.
    // Cloned rather than borrowed: `scope` moves into the returned
    // `PlannedSelect` while this relation is still in use.
    let single = scope
        .single_table()
        .cloned()
        .ok_or_else(|| SqlError::Unsupported {
            detail: "multi-table FROM without JOIN".into(),
        })?;
    let table = &single;

    // For a single table the column qualifier (`t.` or its alias) is always
    // redundant, so strip it from the projection, WHERE, and GROUP BY here —
    // before it can become a literal `"t.col"` field-lookup string that
    // silently matches zero rows / projects an empty value. A qualifier that
    // matches neither the table name nor its alias becomes a typed error. This
    // is scoped to the single-table branch only: the JOIN path (handled above
    // by `try_plan_join`) deliberately keeps qualifiers for merged-document
    // evaluation and is never reached here.
    let valid_qualifiers = single_table_qualifiers(table);
    let normalized_select = strip_single_table_qualifiers(select, &valid_qualifiers)?;
    let select = &normalized_select;

    // 4. Extract subqueries from WHERE and rewrite as semi/anti joins.
    let (subquery_joins, effective_where) = if let Some(expr) = &select.selection {
        let extraction = crate::planner::subquery::extract_subqueries(
            expr, &scope, catalog, functions, temporal,
        )?;
        (extraction.joins, extraction.remaining_where)
    } else {
        (Vec::new(), None)
    };

    // 5. Convert remaining WHERE filters. When a WHERE clause is present the
    // projection is converted here (needed for WHERE-search detection) and
    // cached so step 7 doesn't redo the same conversion.
    // Qualifiers were already stripped from `select` (and thus from
    // `effective_where`) by `strip_single_table_qualifiers` above, so the
    // WHERE expr here carries only bare column names.
    let mut cached_projection = None;
    let filters = match &effective_where {
        Some(expr) => {
            // Check for search-triggering functions in WHERE. The resolved
            // SELECT target list is threaded through so the search plan
            // self-describes its output columns.
            let where_projection = convert_projection(&select.projection, &scope)?;
            if let Some(plan) = try_extract_where_search(expr, table, functions, &where_projection)?
            {
                refuse_dropped_clauses(
                    &plan,
                    SearchBodyClauses {
                        temporal: &temporal,
                        has_subqueries: !subquery_joins.is_empty(),
                        aggregates: has_aggregation(select, functions),
                        distinct: select.distinct.is_some(),
                        windows: !crate::planner::window::extract_window_functions(
                            select, functions, &scope,
                        )?
                        .is_empty(),
                    },
                )?;
                return Ok(PlannedSelect { plan, scope });
            }
            cached_projection = Some(where_projection);
            convert_where_to_filters(expr, &scope)?
        }
        None => Vec::new(),
    };

    // 6. Check for GROUP BY / aggregation.
    if has_aggregation(select, functions) {
        let input_joins = subquery_joins
            .iter()
            .any(|sq| sq.join_type != JoinType::Cross);
        let mut plan = crate::planner::aggregate::plan_aggregate(
            crate::planner::aggregate::AggregateQuery {
                select,
                order_by: tail.order_by,
                table,
                filters: &filters,
                scope: &scope,
                temporal: &temporal,
                input_joins,
            },
            functions,
        )?;

        // Semi/anti subquery joins belong below the aggregate so they filter
        // the input rows before grouping. Scalar subqueries remain above the
        // aggregate because their column-vs-column comparison is evaluated
        // after the cross join materializes the scalar result row.
        if let SqlPlan::Aggregate { input, .. } = &mut plan {
            let mut base_input = std::mem::replace(
                input,
                Box::new(SqlPlan::ConstantResult {
                    columns: Vec::new(),
                    values: Vec::new(),
                    volatile: false,
                }),
            );
            for sq in subquery_joins
                .iter()
                .filter(|sq| sq.join_type != JoinType::Cross)
            {
                base_input = Box::new(SqlPlan::Join {
                    left: base_input,
                    right: Box::new(sq.inner_plan.clone()),
                    on: sq.on.clone(),
                    join_type: sq.join_type,
                    condition: None,
                    limit: None,
                    projection: Vec::new(),
                    filters: Vec::new(),
                });
            }
            *input = base_input;
        }

        for sq in subquery_joins
            .into_iter()
            .filter(|sq| sq.join_type == JoinType::Cross)
        {
            plan = SqlPlan::Join {
                left: Box::new(plan),
                right: Box::new(sq.inner_plan),
                on: sq.on,
                join_type: sq.join_type,
                condition: None,
                limit: None,
                projection: Vec::new(),
                filters: Vec::new(),
            };
        }
        return Ok(PlannedSelect { plan, scope });
    }

    // 7. Convert projection (reuse the WHERE-search conversion if we already
    // did it in step 5, to avoid converting the same projection twice).
    let projection = match cached_projection {
        Some(p) => p,
        None => convert_projection(&select.projection, &scope)?,
    };

    // 8. Convert window functions (SELECT with OVER).
    let window_functions =
        crate::planner::window::extract_window_functions(select, functions, &scope)?;

    // 9. Build base scan plan.
    let scan_projection = if subquery_joins.is_empty() {
        projection.clone()
    } else {
        Vec::new()
    };

    // The enclosing query's ORDER BY / LIMIT are resolved here, before
    // `plan_scan`, because the engine rules decide the access path from them:
    // the document index-lookup rewrite must decline when a sort is requested,
    // and must carry the real row bound rather than a default. `apply_order_by`
    // and `apply_limit` re-derive the same values on the finished plan and
    // overwrite these fields with an identical result.
    //
    // A scan that is about to be wrapped in subquery joins takes neither:
    // ordering and bounding the join's input instead of its output would
    // answer the query from the wrong rows. Those clauses land on the join
    // itself downstream — the same reason `scan_projection` is empty here.
    let (sort_keys, limit, offset) = if subquery_joins.is_empty() {
        let (limit, offset) = tail.limit_offset()?;
        // ORDER BY resolves against the output names the SELECT list
        // introduces as well as the input columns.
        let order_scope = scope.with_output_names(super::select_output_aliases(&select.projection));
        (tail.sort_keys(&order_scope)?, limit, offset)
    } else {
        (Vec::new(), None, 0)
    };

    let rules = crate::engine_rules::resolve_engine_rules(table.info.engine);
    let mut plan = rules.plan_scan(crate::engine_rules::ScanParams {
        collection: table.name.clone(),
        alias: table.alias.clone(),
        filters,
        projection: scan_projection,
        sort_keys,
        limit,
        offset,
        distinct: select.distinct.is_some(),
        window_functions,
        indexes: table.info.indexes.clone(),
        temporal,
        bitemporal: table.info.bitemporal,
        has_auto_tier: table.info.has_auto_tier,
    })?;

    // 10. Wrap with subquery joins (semi/anti/cross) if any.
    for sq in subquery_joins {
        // For cross-joins (scalar subqueries), move column-referencing filters
        // from the base scan to the join's post-filters. The filter compares
        // a field from the base scan with a field from the subquery result,
        // so it can only be evaluated after the join merges both sides.
        let join_filters = if sq.join_type == JoinType::Cross {
            if let SqlPlan::Scan {
                ref mut filters, ..
            } = plan
            {
                // Move filters that reference the scalar result column to the join.
                let mut moved = Vec::new();
                filters.retain(|f| {
                    if has_column_ref_filter(&f.expr) {
                        moved.push(f.clone());
                        false
                    } else {
                        true
                    }
                });
                moved
            } else {
                Vec::new()
            }
        } else {
            Vec::new()
        };

        plan = SqlPlan::Join {
            left: Box::new(plan),
            right: Box::new(sq.inner_plan),
            on: sq.on,
            join_type: sq.join_type,
            condition: None,
            limit: None,
            projection: Vec::new(),
            filters: join_filters,
        };
    }

    if let SqlPlan::Join {
        projection: ref mut join_projection,
        ..
    } = plan
    {
        *join_projection = projection;
    }

    Ok(PlannedSelect { plan, scope })
}

/// Check if a filter expression contains a column-vs-column comparison
/// (from scalar subquery rewriting). These filters must be evaluated
/// post-join, not pre-join, since one column comes from the subquery result.
fn has_column_ref_filter(expr: &FilterExpr) -> bool {
    match expr {
        FilterExpr::Expr(sql_expr) => has_column_comparison(sql_expr),
        FilterExpr::And(filters) => filters.iter().any(|f| has_column_ref_filter(&f.expr)),
        FilterExpr::Or(filters) => filters.iter().any(|f| has_column_ref_filter(&f.expr)),
        _ => false,
    }
}

fn has_column_comparison(expr: &SqlExpr) -> bool {
    match expr {
        SqlExpr::BinaryOp { left, right, .. } => {
            let left_is_col = matches!(left.as_ref(), SqlExpr::Column { .. });
            let right_is_col = matches!(right.as_ref(), SqlExpr::Column { .. });
            if left_is_col && right_is_col {
                return true;
            }
            has_column_comparison(left) || has_column_comparison(right)
        }
        _ => false,
    }
}

/// Check if a SELECT has aggregation (GROUP BY or aggregate functions in projection).
pub(crate) fn has_aggregation(select: &Select, functions: &FunctionRegistry) -> bool {
    let group_by_non_empty = match &select.group_by {
        ast::GroupByExpr::All(_) => true,
        ast::GroupByExpr::Expressions(exprs, _) => !exprs.is_empty(),
    };
    if group_by_non_empty {
        return true;
    }
    for item in &select.projection {
        if let ast::SelectItem::UnnamedExpr(expr) | ast::SelectItem::ExprWithAlias { expr, .. } =
            item
            && crate::aggregate_walk::contains_aggregate(expr, functions)
        {
            return true;
        }
    }
    false
}

/// Dispatch to the JOIN planner if the FROM contains joins.
fn try_plan_join(
    select: &Select,
    scope: &TableScope,
    catalog: &dyn SqlCatalog,
    functions: &FunctionRegistry,
    temporal: TemporalScope,
) -> Result<Option<SqlPlan>> {
    if select.from.len() != 1 {
        return Ok(None);
    }
    let from = &select.from[0];
    if from.joins.is_empty() {
        return Ok(None);
    }
    crate::planner::join::plan_join_from_select(select, scope, catalog, functions, temporal)
}