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 => ×eries::TimeseriesRules,
448 EngineType::Spatial => &spatial::SpatialRules,
449 EngineType::Array => &array::ArrayRules,
450 }
451}