nodedb_sql/types.rs
1//! SqlPlan intermediate representation types.
2//!
3//! These types represent the output of the nodedb-sql planner. Both Origin
4//! (server) and Lite (embedded) map these to their own execution model.
5
6/// The top-level plan produced by the SQL planner.
7#[derive(Debug, Clone)]
8pub enum SqlPlan {
9 // ── Constant ──
10 /// Query with no FROM clause: SELECT 1, SELECT 'hello' AS name, etc.
11 /// Produces a single row with evaluated constant expressions.
12 ConstantResult {
13 columns: Vec<String>,
14 values: Vec<SqlValue>,
15 },
16
17 // ── Reads ──
18 Scan {
19 collection: String,
20 alias: Option<String>,
21 engine: EngineType,
22 filters: Vec<Filter>,
23 projection: Vec<Projection>,
24 sort_keys: Vec<SortKey>,
25 limit: Option<usize>,
26 offset: usize,
27 distinct: bool,
28 window_functions: Vec<WindowSpec>,
29 },
30 PointGet {
31 collection: String,
32 alias: Option<String>,
33 engine: EngineType,
34 key_column: String,
35 key_value: SqlValue,
36 },
37 /// Document fetch via a secondary index: equality predicate on an
38 /// indexed field. The executor performs an index lookup to resolve
39 /// matching document IDs, reads each document, and applies any
40 /// remaining filters, projection, sort, and limit.
41 ///
42 /// Emitted by `document_schemaless::plan_scan` /
43 /// `document_strict::plan_scan` when the WHERE clause contains a
44 /// single equality predicate on a `Ready` indexed field. Any
45 /// additional predicates fall through as post-filters.
46 DocumentIndexLookup {
47 collection: String,
48 alias: Option<String>,
49 engine: EngineType,
50 /// Indexed field path used for the lookup.
51 field: String,
52 /// Equality value from the WHERE clause.
53 value: SqlValue,
54 /// Remaining filters after extracting the equality used for lookup.
55 filters: Vec<Filter>,
56 projection: Vec<Projection>,
57 sort_keys: Vec<SortKey>,
58 limit: Option<usize>,
59 offset: usize,
60 distinct: bool,
61 window_functions: Vec<WindowSpec>,
62 /// Whether the chosen index is COLLATE NOCASE — the executor
63 /// lowercases the lookup value before probing.
64 case_insensitive: bool,
65 },
66 RangeScan {
67 collection: String,
68 field: String,
69 lower: Option<SqlValue>,
70 upper: Option<SqlValue>,
71 limit: usize,
72 },
73
74 // ── Writes ──
75 Insert {
76 collection: String,
77 engine: EngineType,
78 rows: Vec<Vec<(String, SqlValue)>>,
79 /// Column defaults from schema: `(column_name, default_expr)`.
80 /// Used to auto-generate values for missing columns (e.g. `id` with `UUID_V7`).
81 column_defaults: Vec<(String, String)>,
82 /// `ON CONFLICT DO NOTHING` semantics: when true, duplicate-PK rows
83 /// are silently skipped instead of raising `unique_violation`. Plain
84 /// `INSERT` (no `ON CONFLICT` clause) sets this to `false`.
85 if_absent: bool,
86 },
87 /// KV INSERT: key and value are fundamentally separate.
88 /// Each entry is `(key, value_columns)`.
89 KvInsert {
90 collection: String,
91 entries: Vec<(SqlValue, Vec<(String, SqlValue)>)>,
92 /// TTL in seconds (0 = no expiry). Extracted from `ttl` column if present.
93 ttl_secs: u64,
94 /// INSERT-vs-UPSERT distinction. `KvOp::Put` is a Redis-SET-style
95 /// upsert by design; to honor SQL `INSERT` semantics the planner must
96 /// tell the converter whether a duplicate key should raise (plain
97 /// `INSERT`, `Insert`), be silently skipped (`ON CONFLICT DO NOTHING`,
98 /// `InsertIfAbsent`), or overwrite (`UPSERT` / `ON CONFLICT DO
99 /// UPDATE`, `Put`).
100 intent: KvInsertIntent,
101 /// `ON CONFLICT (key) DO UPDATE SET field = expr` assignments, carried
102 /// through when `intent == Put` via the ON-CONFLICT-DO-UPDATE path.
103 /// Empty for plain UPSERT (whole-value overwrite) and for INSERT
104 /// variants.
105 on_conflict_updates: Vec<(String, SqlExpr)>,
106 },
107 /// UPSERT: insert or merge if document exists.
108 Upsert {
109 collection: String,
110 engine: EngineType,
111 rows: Vec<Vec<(String, SqlValue)>>,
112 column_defaults: Vec<(String, String)>,
113 /// `ON CONFLICT (...) DO UPDATE SET field = expr` assignments.
114 /// When empty, upsert is a plain merge: new columns overwrite existing.
115 /// When non-empty, the engine applies these per-row against the
116 /// *existing* document instead of merging the inserted values.
117 on_conflict_updates: Vec<(String, SqlExpr)>,
118 },
119 InsertSelect {
120 target: String,
121 source: Box<SqlPlan>,
122 limit: usize,
123 },
124 Update {
125 collection: String,
126 engine: EngineType,
127 assignments: Vec<(String, SqlExpr)>,
128 filters: Vec<Filter>,
129 target_keys: Vec<SqlValue>,
130 returning: bool,
131 },
132 Delete {
133 collection: String,
134 engine: EngineType,
135 filters: Vec<Filter>,
136 target_keys: Vec<SqlValue>,
137 },
138 Truncate {
139 collection: String,
140 restart_identity: bool,
141 },
142
143 // ── Joins ──
144 Join {
145 left: Box<SqlPlan>,
146 right: Box<SqlPlan>,
147 on: Vec<(String, String)>,
148 join_type: JoinType,
149 condition: Option<SqlExpr>,
150 limit: usize,
151 /// Post-join projection: column names to keep (empty = all columns).
152 projection: Vec<Projection>,
153 /// Post-join filters (from WHERE clause).
154 filters: Vec<Filter>,
155 },
156
157 // ── Aggregation ──
158 Aggregate {
159 input: Box<SqlPlan>,
160 group_by: Vec<SqlExpr>,
161 aggregates: Vec<AggregateExpr>,
162 having: Vec<Filter>,
163 limit: usize,
164 },
165
166 // ── Timeseries ──
167 TimeseriesScan {
168 collection: String,
169 time_range: (i64, i64),
170 bucket_interval_ms: i64,
171 group_by: Vec<String>,
172 aggregates: Vec<AggregateExpr>,
173 filters: Vec<Filter>,
174 projection: Vec<Projection>,
175 gap_fill: String,
176 limit: usize,
177 tiered: bool,
178 },
179 TimeseriesIngest {
180 collection: String,
181 rows: Vec<Vec<(String, SqlValue)>>,
182 },
183
184 // ── Search (first-class) ──
185 VectorSearch {
186 collection: String,
187 field: String,
188 query_vector: Vec<f32>,
189 top_k: usize,
190 ef_search: usize,
191 filters: Vec<Filter>,
192 },
193 MultiVectorSearch {
194 collection: String,
195 query_vector: Vec<f32>,
196 top_k: usize,
197 ef_search: usize,
198 },
199 TextSearch {
200 collection: String,
201 query: String,
202 top_k: usize,
203 fuzzy: bool,
204 filters: Vec<Filter>,
205 },
206 HybridSearch {
207 collection: String,
208 query_vector: Vec<f32>,
209 query_text: String,
210 top_k: usize,
211 ef_search: usize,
212 vector_weight: f32,
213 fuzzy: bool,
214 },
215 SpatialScan {
216 collection: String,
217 field: String,
218 predicate: SpatialPredicate,
219 query_geometry: Vec<u8>,
220 distance_meters: f64,
221 attribute_filters: Vec<Filter>,
222 limit: usize,
223 projection: Vec<Projection>,
224 },
225
226 // ── Composite ──
227 Union {
228 inputs: Vec<SqlPlan>,
229 distinct: bool,
230 },
231 Intersect {
232 left: Box<SqlPlan>,
233 right: Box<SqlPlan>,
234 all: bool,
235 },
236 Except {
237 left: Box<SqlPlan>,
238 right: Box<SqlPlan>,
239 all: bool,
240 },
241 RecursiveScan {
242 collection: String,
243 base_filters: Vec<Filter>,
244 recursive_filters: Vec<Filter>,
245 /// Equi-join link for tree-traversal recursion:
246 /// `(collection_field, working_table_field)`.
247 /// e.g. `("parent_id", "id")` means each iteration finds rows
248 /// where `collection.parent_id` matches a `working_table.id`.
249 join_link: Option<(String, String)>,
250 max_iterations: usize,
251 distinct: bool,
252 limit: usize,
253 },
254
255 /// Non-recursive CTE: execute each definition, then the outer query.
256 Cte {
257 /// CTE definitions: `(name, subquery_plan)`.
258 definitions: Vec<(String, SqlPlan)>,
259 /// The outer query that references CTE names.
260 outer: Box<SqlPlan>,
261 },
262}
263
264/// INSERT-vs-UPSERT intent carried on `SqlPlan::KvInsert`.
265///
266/// The KV engine's `KvOp::Put` is a Redis-SET-style upsert: write wins
267/// unconditionally. SQL requires `INSERT` to raise `unique_violation`
268/// on duplicate keys, so the plan must carry the caller's intent through
269/// to the Data Plane where the hash-index existence probe happens.
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum KvInsertIntent {
272 /// Plain `INSERT`: duplicate key raises `SQLSTATE 23505`.
273 Insert,
274 /// `INSERT ... ON CONFLICT DO NOTHING`: duplicate key is a no-op.
275 InsertIfAbsent,
276 /// `UPSERT` / `INSERT ... ON CONFLICT (key) DO UPDATE` / RESP `SET`:
277 /// duplicate key overwrites. Also the shape used by the RESP SET path.
278 Put,
279}
280
281/// Database engine type for a collection.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum EngineType {
284 DocumentSchemaless,
285 DocumentStrict,
286 KeyValue,
287 Columnar,
288 Timeseries,
289 Spatial,
290}
291
292/// SQL join type.
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub enum JoinType {
295 Inner,
296 Left,
297 Right,
298 Full,
299 Semi,
300 Anti,
301 Cross,
302}
303
304impl JoinType {
305 pub fn as_str(&self) -> &'static str {
306 match self {
307 Self::Inner => "inner",
308 Self::Left => "left",
309 Self::Right => "right",
310 Self::Full => "full",
311 Self::Semi => "semi",
312 Self::Anti => "anti",
313 Self::Cross => "cross",
314 }
315 }
316}
317
318/// Spatial predicate types.
319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320pub enum SpatialPredicate {
321 DWithin,
322 Contains,
323 Intersects,
324 Within,
325}
326
327/// A filter predicate.
328#[derive(Debug, Clone)]
329pub struct Filter {
330 pub expr: FilterExpr,
331}
332
333/// Filter expression tree.
334#[derive(Debug, Clone)]
335pub enum FilterExpr {
336 Comparison {
337 field: String,
338 op: CompareOp,
339 value: SqlValue,
340 },
341 Like {
342 field: String,
343 pattern: String,
344 },
345 InList {
346 field: String,
347 values: Vec<SqlValue>,
348 },
349 Between {
350 field: String,
351 low: SqlValue,
352 high: SqlValue,
353 },
354 IsNull {
355 field: String,
356 },
357 IsNotNull {
358 field: String,
359 },
360 And(Vec<Filter>),
361 Or(Vec<Filter>),
362 Not(Box<Filter>),
363 /// Raw expression filter (for complex predicates that don't fit simple patterns).
364 Expr(SqlExpr),
365}
366
367/// Comparison operators.
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
369pub enum CompareOp {
370 Eq,
371 Ne,
372 Gt,
373 Ge,
374 Lt,
375 Le,
376}
377
378/// Projection item in SELECT.
379#[derive(Debug, Clone)]
380pub enum Projection {
381 /// Simple column reference: `SELECT name`
382 Column(String),
383 /// All columns: `SELECT *`
384 Star,
385 /// Qualified star: `SELECT t.*`
386 QualifiedStar(String),
387 /// Computed expression: `SELECT price * qty AS total`
388 Computed { expr: SqlExpr, alias: String },
389}
390
391/// Sort key for ORDER BY.
392#[derive(Debug, Clone)]
393pub struct SortKey {
394 pub expr: SqlExpr,
395 pub ascending: bool,
396 pub nulls_first: bool,
397}
398
399/// Aggregate expression: `COUNT(*)`, `SUM(amount)`, etc.
400#[derive(Debug, Clone)]
401pub struct AggregateExpr {
402 pub function: String,
403 pub args: Vec<SqlExpr>,
404 pub alias: String,
405 pub distinct: bool,
406}
407
408/// Window function specification.
409#[derive(Debug, Clone)]
410pub struct WindowSpec {
411 pub function: String,
412 pub args: Vec<SqlExpr>,
413 pub partition_by: Vec<SqlExpr>,
414 pub order_by: Vec<SortKey>,
415 pub alias: String,
416}
417
418// ── SQL value / expression / operator types ──
419// Extracted to `crate::types_expr` so this file stays under the 500-line limit.
420// Re-exported so downstream `use crate::types::*` continues to resolve these
421// symbols without change.
422pub use crate::types_expr::{BinaryOp, SqlDataType, SqlExpr, SqlValue, UnaryOp};
423
424// ── Catalog trait ──
425// The `SqlCatalog` trait itself and its error type live in
426// `crate::catalog` to keep this file under the 500-line limit.
427// Re-exported here so downstream modules that `use crate::types::*`
428// keep resolving `SqlCatalog` without changing their imports.
429pub use crate::catalog::{SqlCatalog, SqlCatalogError};
430
431/// Metadata about a collection for query planning.
432#[derive(Debug, Clone)]
433pub struct CollectionInfo {
434 pub name: String,
435 pub engine: EngineType,
436 pub columns: Vec<ColumnInfo>,
437 pub primary_key: Option<String>,
438 pub has_auto_tier: bool,
439 /// Secondary indexes available for planner rewrites. Populated by the
440 /// catalog adapter from `StoredCollection.indexes`. `Building` entries
441 /// are included so the planner can see them but MUST be skipped when
442 /// choosing an index lookup — only `Ready` indexes back query rewrites.
443 pub indexes: Vec<IndexSpec>,
444}
445
446/// Secondary index metadata surfaced to the SQL planner.
447#[derive(Debug, Clone)]
448pub struct IndexSpec {
449 pub name: String,
450 /// Canonical field path (`$.email`, `$.user.name`, or plain column name
451 /// for strict documents — the catalog layer stores them uniformly).
452 pub field: String,
453 pub unique: bool,
454 pub case_insensitive: bool,
455 /// Build state. Only `Ready` indexes drive query rewrites.
456 pub state: IndexState,
457 /// Partial-index predicate as raw SQL text (`WHERE <expr>` body
458 /// without the keyword), or `None` for full indexes. The planner
459 /// uses this to reject rewrites whose WHERE clause doesn't entail
460 /// the predicate — matching against such a partial index would
461 /// omit rows the index didn't cover.
462 pub predicate: Option<String>,
463}
464
465/// Planner-facing index state. Mirrors the catalog variant but lives here
466/// so the SQL crate doesn't depend on `nodedb` internals.
467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
468pub enum IndexState {
469 Building,
470 Ready,
471}
472
473/// Metadata about a single column.
474#[derive(Debug, Clone)]
475pub struct ColumnInfo {
476 pub name: String,
477 pub data_type: SqlDataType,
478 pub nullable: bool,
479 pub is_primary_key: bool,
480 /// Default value expression (e.g. "UUID_V7", "ULID", "NANOID(10)", "0", "'active'").
481 pub default: Option<String>,
482}