Skip to main content

nodedb_sql/engine_rules/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2
3pub mod array;
4pub mod columnar;
5pub mod document_schemaless;
6pub mod document_strict;
7pub mod kv;
8pub mod spatial;
9pub mod timeseries;
10
11use crate::error::Result;
12use crate::types::*;
13
14/// Parameters for planning an INSERT operation.
15pub struct InsertParams {
16    pub collection: String,
17    pub columns: Vec<String>,
18    pub rows: Vec<Vec<(String, SqlValue)>>,
19    pub column_defaults: Vec<(String, String)>,
20    /// `ON CONFLICT DO NOTHING` semantics: duplicate-PK rows are skipped
21    /// silently. `false` for plain `INSERT` (raises `unique_violation`).
22    pub if_absent: bool,
23    /// Raw column type strings from the catalog: `(column_name, type_str)`.
24    /// Used by columnar converters to reconstruct the exact `ColumnType` for
25    /// columns whose `SqlDataType` is ambiguous (e.g. both JSON and Bytes map
26    /// to `SqlDataType::Bytes`). Empty for engines that don't need it.
27    pub column_schema: Vec<(String, String)>,
28    /// Declared `PRIMARY KEY` column name (if any), from `CollectionInfo::primary_key`.
29    /// Used by the conversion layer to extract the document id from the
30    /// correct column instead of guessing at `id`/`document_id`/`key`.
31    pub primary_key: Option<String>,
32}
33
34/// Parameters for planning a SCAN operation.
35pub struct ScanParams {
36    pub collection: String,
37    pub alias: Option<String>,
38    pub filters: Vec<Filter>,
39    pub projection: Vec<Projection>,
40    pub sort_keys: Vec<SortKey>,
41    pub limit: Option<usize>,
42    pub offset: usize,
43    pub distinct: bool,
44    pub window_functions: Vec<WindowSpec>,
45    /// Secondary indexes available on the scan's collection. Document
46    /// engines consult this to rewrite equality-on-indexed-field into
47    /// [`SqlPlan::DocumentIndexLookup`]. Other engines ignore it today.
48    pub indexes: Vec<IndexSpec>,
49    /// Bitemporal qualifier propagated from `plan_sql`. Engines without
50    /// bitemporal storage support reject a non-default scope via
51    /// `SqlError::Unsupported` — silently ignoring it would return
52    /// current-state data when the user asked for history.
53    pub temporal: crate::temporal::TemporalScope,
54    /// Whether this collection was created with bitemporal storage. When
55    /// `true`, engines that support bitemporal reads route the scan
56    /// through versioned storage; when `false`, a non-default
57    /// [`Self::temporal`] is rejected.
58    pub bitemporal: bool,
59}
60
61/// Parameters for planning a POINT GET operation.
62pub struct PointGetParams {
63    pub collection: String,
64    pub alias: Option<String>,
65    pub key_column: String,
66    pub key_value: SqlValue,
67    /// Resolved SELECT target list, propagated onto [`SqlPlan::PointGet`] so the
68    /// physical plan self-describes its output columns.
69    pub projection: Vec<Projection>,
70}
71
72/// Parameters for planning an UPDATE operation.
73pub struct UpdateParams {
74    pub collection: String,
75    pub assignments: Vec<(String, SqlExpr)>,
76    pub filters: Vec<Filter>,
77    pub target_keys: Vec<SqlValue>,
78    pub returning: bool,
79}
80
81/// Parameters for planning an `UPDATE target SET ... FROM src WHERE ...` operation.
82pub struct UpdateFromParams {
83    pub collection: String,
84    /// The FROM source plan (Scan, Join, …).
85    pub source: Box<SqlPlan>,
86    /// Column in target used as the equi-join key.
87    pub target_join_col: String,
88    /// Column in source used as the equi-join key.
89    pub source_join_col: String,
90    /// SET assignments — RHS may reference source columns.
91    pub assignments: Vec<(String, SqlExpr)>,
92    /// Filters that apply only to the target.
93    pub target_filters: Vec<Filter>,
94    pub returning: bool,
95}
96
97/// Parameters for planning a DELETE operation.
98pub struct DeleteParams {
99    pub collection: String,
100    pub filters: Vec<Filter>,
101    pub target_keys: Vec<SqlValue>,
102}
103
104/// Parameters for planning a MERGE operation.
105pub struct MergeParams {
106    pub collection: String,
107    pub source: Box<SqlPlan>,
108    pub target_join_col: String,
109    pub source_join_col: String,
110    pub source_alias: String,
111    pub clauses: Vec<crate::types::MergePlanClause>,
112    pub returning: bool,
113}
114
115/// Parameters for planning an UPSERT operation.
116pub struct UpsertParams {
117    pub collection: String,
118    pub columns: Vec<String>,
119    pub rows: Vec<Vec<(String, SqlValue)>>,
120    pub column_defaults: Vec<(String, String)>,
121    /// `ON CONFLICT (...) DO UPDATE SET` assignments. Empty for plain
122    /// `UPSERT INTO ...`; populated when the caller is
123    /// `INSERT ... ON CONFLICT ... DO UPDATE SET`.
124    pub on_conflict_updates: Vec<(String, SqlExpr)>,
125    /// Raw column type strings from the catalog: `(column_name, type_str)`.
126    /// Mirrors `InsertParams::column_schema` — see that field for rationale.
127    pub column_schema: Vec<(String, String)>,
128    /// Declared `PRIMARY KEY` column name (if any). See `InsertParams::primary_key`.
129    pub primary_key: Option<String>,
130}
131
132/// Parameters for planning an AGGREGATE operation.
133pub struct AggregateParams {
134    pub collection: String,
135    pub alias: Option<String>,
136    pub filters: Vec<Filter>,
137    pub group_by: Vec<SqlExpr>,
138    pub aggregates: Vec<AggregateExpr>,
139    pub having: Vec<Filter>,
140    pub limit: usize,
141    /// Timeseries-specific: bucket interval from time_bucket() call.
142    pub bucket_interval_ms: Option<i64>,
143    /// Timeseries-specific: non-time GROUP BY columns.
144    pub group_columns: Vec<String>,
145    /// Whether the collection has auto-tiering enabled.
146    pub has_auto_tier: bool,
147    /// Whether this collection was created with bitemporal storage.
148    /// When `true`, the base scan inside the aggregate is allowed to
149    /// carry a non-default temporal scope.
150    pub bitemporal: bool,
151    /// System-time / valid-time scope to propagate into the underlying
152    /// scan so bitemporal aggregate queries project an as-of snapshot
153    /// before grouping.
154    pub temporal: crate::temporal::TemporalScope,
155}
156
157/// Engine-specific planning rules.
158///
159/// Each engine type implements this trait to produce the correct `SqlPlan`
160/// variant for each operation, or return an error if the operation is not
161/// supported. This is the single source of truth for operation routing —
162/// no downstream code should ever check engine type to decide routing.
163pub trait EngineRules {
164    /// Plan an INSERT. Returns `Err` if the engine does not support inserts
165    /// (e.g. timeseries routes to `TimeseriesIngest` instead).
166    fn plan_insert(&self, params: InsertParams) -> Result<Vec<SqlPlan>>;
167    /// Plan an UPSERT (insert-or-merge). Returns `Err` for append-only or
168    /// columnar engines that don't support merge semantics.
169    fn plan_upsert(&self, params: UpsertParams) -> Result<Vec<SqlPlan>>;
170    /// Plan a table scan (SELECT without point-get optimization).
171    fn plan_scan(&self, params: ScanParams) -> Result<SqlPlan>;
172    /// Plan a point lookup by primary key. Returns `Err` for engines
173    /// that don't support O(1) key lookups (e.g. timeseries).
174    fn plan_point_get(&self, params: PointGetParams) -> Result<SqlPlan>;
175    /// Plan an UPDATE. Returns `Err` for append-only engines.
176    fn plan_update(&self, params: UpdateParams) -> Result<Vec<SqlPlan>>;
177    /// Plan an `UPDATE target SET ... FROM src WHERE ...`.
178    ///
179    /// Returns `Err(SqlError::Unsupported)` for engines that cannot participate
180    /// as an update target in a cross-table update (timeseries, kv-with-opaque-keys).
181    fn plan_update_from(&self, params: UpdateFromParams) -> Result<Vec<SqlPlan>>;
182    /// Plan a DELETE (point or bulk).
183    fn plan_delete(&self, params: DeleteParams) -> Result<Vec<SqlPlan>>;
184    /// Plan a GROUP BY / aggregate query.
185    fn plan_aggregate(&self, params: AggregateParams) -> Result<SqlPlan>;
186    /// Plan a MERGE statement.
187    ///
188    /// Returns `Err(SqlError::Unsupported)` for engines that do not support
189    /// MERGE semantics (everything except `document_schemaless` and
190    /// `document_strict`).
191    fn plan_merge(&self, params: MergeParams) -> Result<Vec<SqlPlan>>;
192}
193
194/// Attempt to rewrite `ScanParams` into a [`SqlPlan::DocumentIndexLookup`]
195/// when exactly one of the filters is an equality predicate on a `Ready`
196/// indexed field. Returns `None` to fall through to a generic `Scan`.
197///
198/// Shared by the schemaless and strict document engines so the
199/// index-rewrite rule has one source of truth. Normalizes strict column
200/// names to `$.column` before matching against index fields because the
201/// catalog stores every document index in JSON-path canonical form.
202pub(crate) fn try_document_index_lookup(
203    params: &ScanParams,
204    engine: EngineType,
205) -> Option<SqlPlan> {
206    // Sort / window functions still force a full scan because the
207    // IndexedFetch handler does not yet order rows or evaluate window
208    // aggregates. DISTINCT is safe on the index path: the handler
209    // emits each matched doc exactly once and any further dedup is a
210    // cheap Control-Plane pass on the scan-shaped response.
211    if !params.sort_keys.is_empty() || !params.window_functions.is_empty() {
212        return None;
213    }
214
215    // Iterate filters to find the first equality candidate that lines up
216    // with a Ready index. Keep the remaining filters as post-filters.
217    // Predicates appear in three shapes:
218    //   - `FilterExpr::Comparison { field, Eq, value }` — resolver path.
219    //   - `FilterExpr::Expr(BinaryOp { Column, Eq, Literal })` — generic.
220    //   - `FilterExpr::Expr(BinaryOp { left AND right })` — the
221    //     top-level AND the `convert_where_to_filters` path produces
222    //     for compound WHERE clauses like `a = 1 AND b > 2`. Descend
223    //     into AND conjuncts so an equality nested in a conjunction
224    //     still picks up the index, and the non-equality siblings
225    //     survive as a residual conjunction carried on the
226    //     IndexedFetch node (not as a wrapping Filter plan node —
227    //     that wrapping is the Control-Plane post-filter anti-pattern
228    //     the handler's `filters` payload exists to eliminate).
229    // Pre-collect the query's top-level conjuncts once. Used for
230    // partial-index entailment: every conjunct of the index predicate
231    // must appear as a conjunct of the query WHERE for the rewrite to
232    // be sound, otherwise the index would silently omit rows the
233    // query expects to see.
234    let query_conjuncts: Vec<SqlExpr> = params
235        .filters
236        .iter()
237        .flat_map(|f| match &f.expr {
238            FilterExpr::Expr(e) => {
239                let mut v = Vec::new();
240                flatten_and(e, &mut v);
241                v
242            }
243            _ => Vec::new(),
244        })
245        .collect();
246
247    for (i, f) in params.filters.iter().enumerate() {
248        let Some((field, value, residual)) = extract_equality_with_residual(&f.expr) else {
249            continue;
250        };
251        let canonical = canonical_index_field(&field);
252        let Some(idx) = params.indexes.iter().find(|i| {
253            i.state == IndexState::Ready
254                && i.field == canonical
255                && partial_index_entailed(i.predicate.as_deref(), &query_conjuncts)
256        }) else {
257            continue;
258        };
259
260        let mut remaining = params.filters.clone();
261        match residual {
262            Some(expr) => {
263                remaining[i] = Filter {
264                    expr: FilterExpr::Expr(expr),
265                };
266            }
267            None => {
268                remaining.remove(i);
269            }
270        }
271
272        let lookup_value = if idx.case_insensitive {
273            lowercase_string_value(&value)
274        } else {
275            value
276        };
277
278        return Some(SqlPlan::DocumentIndexLookup {
279            collection: params.collection.clone(),
280            alias: params.alias.clone(),
281            engine,
282            field: idx.field.clone(),
283            value: lookup_value,
284            filters: remaining,
285            projection: params.projection.clone(),
286            sort_keys: params.sort_keys.clone(),
287            limit: params.limit,
288            offset: params.offset,
289            distinct: params.distinct,
290            window_functions: params.window_functions.clone(),
291            case_insensitive: idx.case_insensitive,
292            temporal: params.temporal,
293        });
294    }
295    None
296}
297
298/// Pull `(column_name, equality_value, residual_expr)` out of a filter
299/// expression if it contains a column-equals-literal predicate that can
300/// drive an index lookup. Returns `None` if no usable equality is
301/// present. The returned residual is the rest of the conjunction with
302/// the equality removed — `None` when the filter was a bare equality.
303fn extract_equality_with_residual(
304    expr: &FilterExpr,
305) -> Option<(String, SqlValue, Option<SqlExpr>)> {
306    match expr {
307        FilterExpr::Comparison {
308            field,
309            op: CompareOp::Eq,
310            value,
311        } => Some((field.clone(), value.clone(), None)),
312        FilterExpr::Expr(sql_expr) => {
313            let (col, lit, residual) = split_equality_from_expr(sql_expr)?;
314            Some((col, lit, residual))
315        }
316        _ => None,
317    }
318}
319
320/// Return `(column, literal, residual_expr)` for an equality found
321/// anywhere in a right-leaning AND conjunction tree, or `None` if no
322/// bare column-equals-literal predicate exists. The residual preserves
323/// every sibling conjunct in their original order; `None` means the
324/// expression was a bare equality with nothing left behind.
325fn split_equality_from_expr(expr: &SqlExpr) -> Option<(String, SqlValue, Option<SqlExpr>)> {
326    // Gather the conjuncts of a top-level AND chain left-to-right.
327    let mut conjuncts: Vec<SqlExpr> = Vec::new();
328    flatten_and(expr, &mut conjuncts);
329
330    // Find the first conjunct that is a bare column-equals-literal.
331    let eq_idx = conjuncts.iter().position(is_column_eq_literal)?;
332    let eq = conjuncts.remove(eq_idx);
333    let (col, lit) = match eq {
334        SqlExpr::BinaryOp { left, op, right } => match (*left, op, *right) {
335            (SqlExpr::Column { name, .. }, BinaryOp::Eq, SqlExpr::Literal(v)) => (name, v),
336            (SqlExpr::Literal(v), BinaryOp::Eq, SqlExpr::Column { name, .. }) => (name, v),
337            _ => return None,
338        },
339        _ => return None,
340    };
341
342    let residual = rebuild_and(conjuncts);
343    Some((col, lit, residual))
344}
345
346/// Append every leaf of a right-leaning `AND` tree to `out`. Non-AND
347/// expressions are a single leaf.
348fn flatten_and(expr: &SqlExpr, out: &mut Vec<SqlExpr>) {
349    match expr {
350        SqlExpr::BinaryOp {
351            left,
352            op: BinaryOp::And,
353            right,
354        } => {
355            flatten_and(left, out);
356            flatten_and(right, out);
357        }
358        other => out.push(other.clone()),
359    }
360}
361
362/// Reassemble a list of conjuncts into a right-leaning AND tree.
363/// Empty input returns `None`; single input returns itself.
364fn rebuild_and(mut conjuncts: Vec<SqlExpr>) -> Option<SqlExpr> {
365    let last = conjuncts.pop()?;
366    Some(
367        conjuncts
368            .into_iter()
369            .rfold(last, |acc, next| SqlExpr::BinaryOp {
370                left: Box::new(next),
371                op: BinaryOp::And,
372                right: Box::new(acc),
373            }),
374    )
375}
376
377/// Decide whether the query's WHERE conjuncts entail the partial-index
378/// predicate. `None` predicate means a full index — trivially entailed.
379/// Initial version uses conjunct-level structural equality: every
380/// conjunct of the index predicate must appear (by `PartialEq`) as a
381/// conjunct of the query. This is conservative and deliberately so —
382/// a false positive here would silently omit rows from query results.
383fn partial_index_entailed(predicate: Option<&str>, query_conjuncts: &[SqlExpr]) -> bool {
384    let Some(text) = predicate else {
385        return true;
386    };
387    let Ok(parsed) = crate::parse_expr_string(text) else {
388        // A catalog predicate we can't parse is not entailed — refuse
389        // to use the index rather than assume anything about its
390        // contents. The DDL path validates at CREATE INDEX time, so
391        // reaching this branch indicates drift.
392        return false;
393    };
394    let mut index_conjuncts: Vec<SqlExpr> = Vec::new();
395    flatten_and(&parsed, &mut index_conjuncts);
396    // Structural equality via the stable `Debug` representation:
397    // `SqlExpr` doesn't derive `PartialEq` (several nested variants
398    // carry types that can't derive it cheaply), but the Debug form is
399    // canonical for equivalent trees produced by the same parser. This
400    // is conservative — it matches only identical AST shapes and not,
401    // e.g., `a = 1` vs `1 = a`. Callers should write index predicates
402    // in the same normal form they use in query WHERE clauses, which
403    // is the convention everywhere else in the codebase.
404    index_conjuncts.iter().all(|ic| {
405        let ic_dbg = format!("{ic:?}");
406        query_conjuncts.iter().any(|qc| format!("{qc:?}") == ic_dbg)
407    })
408}
409
410fn is_column_eq_literal(expr: &SqlExpr) -> bool {
411    matches!(
412        expr,
413        SqlExpr::BinaryOp { left, op: BinaryOp::Eq, right }
414            if matches!(
415                (left.as_ref(), right.as_ref()),
416                (SqlExpr::Column { .. }, SqlExpr::Literal(_))
417                | (SqlExpr::Literal(_), SqlExpr::Column { .. }),
418            )
419    )
420}
421
422fn canonical_index_field(field: &str) -> String {
423    if field.starts_with("$.") || field.starts_with('$') {
424        field.to_string()
425    } else {
426        format!("$.{field}")
427    }
428}
429
430fn lowercase_string_value(v: &SqlValue) -> SqlValue {
431    if let SqlValue::String(s) = v {
432        SqlValue::String(s.to_lowercase())
433    } else {
434        v.clone()
435    }
436}
437
438/// Resolve the engine rules for a given engine type.
439///
440/// No catch-all — compiler enforces exhaustiveness.
441pub fn resolve_engine_rules(engine: EngineType) -> &'static dyn EngineRules {
442    match engine {
443        EngineType::DocumentSchemaless => &document_schemaless::SchemalessRules,
444        EngineType::DocumentStrict => &document_strict::StrictRules,
445        EngineType::KeyValue => &kv::KvRules,
446        EngineType::Columnar => &columnar::ColumnarRules,
447        EngineType::Timeseries => &timeseries::TimeseriesRules,
448        EngineType::Spatial => &spatial::SpatialRules,
449        EngineType::Array => &array::ArrayRules,
450    }
451}