nodedb_sql/types/plan/variants.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! The `SqlPlan` enum — top-level plan produced by the SQL planner.
4
5use crate::fts_types::FtsQuery;
6use crate::temporal::TemporalScope;
7use crate::types_array;
8use crate::types_expr::{SqlExpr, SqlPayloadAtom, SqlValue};
9pub use nodedb_types::vector_distance::DistanceMetric;
10
11use crate::types::filter::Filter;
12use crate::types::query::{
13 AggOutputSlot, AggregateExpr, EngineType, JoinType, Projection, SortKey, SpatialPredicate,
14 WindowSpec,
15};
16
17use super::merge_types::MergePlanClause;
18use super::row_types::{KvInsertIntent, VectorPrimaryRow};
19use super::vector_opts::{ArrayPrefilter, VectorAnnOptions};
20
21/// The top-level plan produced by the SQL planner.
22#[derive(Debug, Clone)]
23pub enum SqlPlan {
24 // ── Constant ──
25 /// Query with no FROM clause: SELECT 1, SELECT 'hello' AS name, etc.
26 /// Produces a single row with evaluated constant expressions.
27 ConstantResult {
28 columns: Vec<String>,
29 values: Vec<SqlValue>,
30 },
31
32 // ── Reads ──
33 Scan {
34 collection: String,
35 alias: Option<String>,
36 engine: EngineType,
37 filters: Vec<Filter>,
38 projection: Vec<Projection>,
39 sort_keys: Vec<SortKey>,
40 limit: Option<usize>,
41 offset: usize,
42 distinct: bool,
43 window_functions: Vec<WindowSpec>,
44 /// Bitemporal qualifier extracted from `FOR SYSTEM_TIME` /
45 /// `FOR VALID_TIME`. Default when the scan is current-state.
46 temporal: TemporalScope,
47 },
48 PointGet {
49 collection: String,
50 alias: Option<String>,
51 engine: EngineType,
52 key_column: String,
53 key_value: SqlValue,
54 /// Resolved SELECT target list, for output-schema derivation.
55 projection: Vec<Projection>,
56 },
57 /// Document fetch via a secondary index: equality predicate on an
58 /// indexed field. The executor performs an index lookup to resolve
59 /// matching document IDs, reads each document, and applies any
60 /// remaining filters, projection, sort, and limit.
61 ///
62 /// Emitted by `document_schemaless::plan_scan` /
63 /// `document_strict::plan_scan` when the WHERE clause contains a
64 /// single equality predicate on a `Ready` indexed field. Any
65 /// additional predicates fall through as post-filters.
66 DocumentIndexLookup {
67 collection: String,
68 alias: Option<String>,
69 engine: EngineType,
70 /// Indexed field path used for the lookup.
71 field: String,
72 /// Equality value from the WHERE clause.
73 value: SqlValue,
74 /// Remaining filters after extracting the equality used for lookup.
75 filters: Vec<Filter>,
76 projection: Vec<Projection>,
77 sort_keys: Vec<SortKey>,
78 limit: Option<usize>,
79 offset: usize,
80 distinct: bool,
81 window_functions: Vec<WindowSpec>,
82 /// Whether the chosen index is COLLATE NOCASE — the executor
83 /// lowercases the lookup value before probing.
84 case_insensitive: bool,
85 /// Bitemporal qualifier — mirrors `Scan::temporal`. Document
86 /// engines must honor it at the Ceiling stage.
87 temporal: TemporalScope,
88 },
89 RangeScan {
90 collection: String,
91 field: String,
92 lower: Option<SqlValue>,
93 upper: Option<SqlValue>,
94 limit: usize,
95 /// Resolved SELECT target list, for output-schema derivation.
96 projection: Vec<Projection>,
97 },
98
99 // ── Writes ──
100 Insert {
101 collection: String,
102 engine: EngineType,
103 rows: Vec<Vec<(String, SqlValue)>>,
104 /// Column defaults from schema: `(column_name, default_expr)`.
105 /// Used to auto-generate values for missing columns (e.g. `id` with `UUID_V7`).
106 column_defaults: Vec<(String, String)>,
107 /// `ON CONFLICT DO NOTHING` semantics: when true, duplicate-PK rows
108 /// are silently skipped instead of raising `unique_violation`. Plain
109 /// `INSERT` (no `ON CONFLICT` clause) sets this to `false`.
110 if_absent: bool,
111 /// Raw column type strings from the catalog: `(column_name, type_str)`.
112 /// Forwarded from `InsertParams::column_schema`. Used by columnar
113 /// converters to reconstruct the exact `ColumnType` for columns whose
114 /// `SqlDataType` is ambiguous (e.g. JSON and Bytes both map to Bytes).
115 column_schema: Vec<(String, String)>,
116 /// Declared `PRIMARY KEY` column name (if any), from
117 /// `InsertParams::primary_key`. Used by the conversion layer to
118 /// extract the document id from the correct column instead of
119 /// guessing at `id`/`document_id`/`key`.
120 primary_key: Option<String>,
121 },
122 /// KV INSERT: key and value are fundamentally separate.
123 /// Each entry is `(key, value_columns)`.
124 KvInsert {
125 collection: String,
126 entries: Vec<(SqlValue, Vec<(String, SqlValue)>)>,
127 /// TTL in seconds (0 = no expiry). Extracted from `ttl` column if present.
128 ttl_secs: u64,
129 /// INSERT-vs-UPSERT distinction. `KvOp::Put` is a Redis-SET-style
130 /// upsert by design; to honor SQL `INSERT` semantics the planner must
131 /// tell the converter whether a duplicate key should raise (plain
132 /// `INSERT`, `Insert`), be silently skipped (`ON CONFLICT DO NOTHING`,
133 /// `InsertIfAbsent`), or overwrite (`UPSERT` / `ON CONFLICT DO
134 /// UPDATE`, `Put`).
135 intent: KvInsertIntent,
136 /// `ON CONFLICT (key) DO UPDATE SET field = expr` assignments, carried
137 /// through when `intent == Put` via the ON-CONFLICT-DO-UPDATE path.
138 /// Empty for plain UPSERT (whole-value overwrite) and for INSERT
139 /// variants.
140 on_conflict_updates: Vec<(String, SqlExpr)>,
141 },
142 /// UPSERT: insert or merge if document exists.
143 Upsert {
144 collection: String,
145 engine: EngineType,
146 rows: Vec<Vec<(String, SqlValue)>>,
147 column_defaults: Vec<(String, String)>,
148 /// `ON CONFLICT (...) DO UPDATE SET field = expr` assignments.
149 /// When empty, upsert is a plain merge: new columns overwrite existing.
150 /// When non-empty, the engine applies these per-row against the
151 /// *existing* document instead of merging the inserted values.
152 on_conflict_updates: Vec<(String, SqlExpr)>,
153 /// Raw column type strings from the catalog: `(column_name, type_str)`.
154 /// Mirrors `Insert::column_schema` — see that field for rationale.
155 column_schema: Vec<(String, String)>,
156 /// Declared `PRIMARY KEY` column name (if any). See `Insert::primary_key`.
157 primary_key: Option<String>,
158 },
159 InsertSelect {
160 target: String,
161 source: Box<SqlPlan>,
162 limit: usize,
163 },
164 Update {
165 collection: String,
166 engine: EngineType,
167 assignments: Vec<(String, SqlExpr)>,
168 filters: Vec<Filter>,
169 target_keys: Vec<SqlValue>,
170 returning: bool,
171 },
172 /// `UPDATE target SET col = src.col2 FROM src WHERE target.id = src.id`
173 ///
174 /// Two-phase execution: scan `source` with `source_filters`, then for
175 /// each matched source row that satisfies the join predicates against a
176 /// target row, apply `assignments` (which may reference source columns
177 /// via qualified names `src.col`).
178 ///
179 /// `join_predicates` are equality pairs `(target_col, source_col)` extracted
180 /// from the WHERE clause linking the two tables. `target_filters` are
181 /// remaining WHERE predicates that reference only `target`.
182 UpdateFrom {
183 collection: String,
184 engine: EngineType,
185 /// The FROM source: a `Scan`, `Join`, or other read plan.
186 source: Box<SqlPlan>,
187 /// Column name used as the target's join key (e.g. `"id"`).
188 target_join_col: String,
189 /// Column name used as the source's join key (e.g. `"id"`).
190 source_join_col: String,
191 /// SET assignments — RHS may be `SqlExpr::Column { table: Some("src"), .. }`.
192 assignments: Vec<(String, SqlExpr)>,
193 /// Filters that apply only to the target collection.
194 target_filters: Vec<Filter>,
195 returning: bool,
196 },
197 Delete {
198 collection: String,
199 engine: EngineType,
200 filters: Vec<Filter>,
201 target_keys: Vec<SqlValue>,
202 },
203 Truncate {
204 collection: String,
205 restart_identity: bool,
206 },
207
208 // ── Joins ──
209 Join {
210 left: Box<SqlPlan>,
211 right: Box<SqlPlan>,
212 on: Vec<(String, String)>,
213 join_type: JoinType,
214 condition: Option<SqlExpr>,
215 /// `None` = no SQL `LIMIT` clause (output bounded downstream by the
216 /// memory byte budget, never silently truncated); `Some(n)` = explicit
217 /// `LIMIT n` (output capped at exactly `n`).
218 limit: Option<usize>,
219 /// Post-join projection: column names to keep (empty = all columns).
220 projection: Vec<Projection>,
221 /// Post-join filters (from WHERE clause).
222 filters: Vec<Filter>,
223 },
224
225 // ── Aggregation ──
226 Aggregate {
227 input: Box<SqlPlan>,
228 group_by: Vec<SqlExpr>,
229 /// SELECT-list output alias for each GROUP BY key, parallel to
230 /// `group_by`. `Some(alias)` when the projection aliased the key
231 /// (`SELECT k AS label ... GROUP BY k` → `Some("label")`); `None`
232 /// when the projection has no explicit alias for that key, so the
233 /// output column name falls back to the raw grouped column name.
234 /// Empty when the plan was built without a projection in scope
235 /// (treated the same as all-`None`).
236 group_by_aliases: Vec<Option<String>>,
237 /// SELECT-list interleaving of `group_by` keys and `aggregates`, in
238 /// output order. Empty when built without a projection in scope (see
239 /// `AggOutputSlot`) — output_schema falls back to group-keys-first.
240 output_order: Vec<AggOutputSlot>,
241 aggregates: Vec<AggregateExpr>,
242 having: Vec<Filter>,
243 limit: usize,
244 /// When the GROUP BY contains ROLLUP/CUBE/GROUPING SETS, this field holds
245 /// the expansion. Each inner `Vec<usize>` is one grouping set — the indices
246 /// into `group_by` (the canonical key list) that are *present* (non-NULL)
247 /// for rows in that set. `None` = plain single-set GROUP BY.
248 grouping_sets: Option<Vec<Vec<usize>>>,
249 /// ORDER BY applied to the aggregated rows. Empty = no sort
250 /// (executor returns groups in hash-map iteration order).
251 /// Populated by `apply_order_by` when an outer ORDER BY
252 /// targets a GROUP BY result; the Aggregate executor sorts the
253 /// finalized group rows before returning.
254 sort_keys: Vec<SortKey>,
255 },
256
257 // ── Timeseries ──
258 TimeseriesScan {
259 collection: String,
260 time_range: (i64, i64),
261 bucket_interval_ms: i64,
262 group_by: Vec<String>,
263 aggregates: Vec<AggregateExpr>,
264 filters: Vec<Filter>,
265 projection: Vec<Projection>,
266 gap_fill: String,
267 limit: usize,
268 tiered: bool,
269 /// Bitemporal system-time / valid-time scope. Only non-default
270 /// on collections created `WITH BITEMPORAL`; `TimeseriesRules::plan_scan`
271 /// rejects temporal scopes otherwise.
272 temporal: TemporalScope,
273 },
274 TimeseriesIngest {
275 collection: String,
276 rows: Vec<Vec<(String, SqlValue)>>,
277 },
278
279 // ── Search (first-class) ──
280 VectorSearch {
281 collection: String,
282 field: String,
283 query_vector: Vec<f32>,
284 top_k: usize,
285 ef_search: usize,
286 /// Distance metric requested by the query operator (`<->`, `<=>`, `<#>`).
287 /// Overrides the collection-default metric at search time.
288 metric: DistanceMetric,
289 filters: Vec<Filter>,
290 /// Optional cross-engine prefilter: when set, the ND-array slice
291 /// runs first and its output cells' surrogates form a bitmap that
292 /// gates the HNSW candidate set. Set by the planner when an
293 /// `ORDER BY vector_distance(...) LIMIT k` query is JOINed against
294 /// `ARRAY_SLICE(...)`. The convert layer lowers this to
295 /// `VectorOp::Search { inline_prefilter_plan: Some(ArrayOp::SurrogateBitmapScan) }`.
296 array_prefilter: Option<ArrayPrefilter>,
297 /// ANN knobs parsed from the optional third JSON-string argument
298 /// to `vector_distance(field, query, '{...}')`.
299 ann_options: VectorAnnOptions,
300 /// When `true`, the projection contains only the surrogate/PK column
301 /// and/or `vector_distance(...)` — no payload fields. The Data Plane
302 /// can skip the document-body fetch entirely for vector-primary
303 /// collections. Always `false` for non-vector-primary collections
304 /// (document body is the primary result).
305 skip_payload_fetch: bool,
306 /// Predicates against payload-indexed columns on a vector-primary
307 /// collection. Each atom is `Eq(field, value)`, `In(field, values)`,
308 /// or `Range(field, ...)`. The convert layer translates SqlValue →
309 /// nodedb_types::Value and emits them as
310 /// `VectorOp::Search::payload_filters`. The Data Plane intersects
311 /// the resulting bitmap with the HNSW candidate set via the
312 /// per-collection `PayloadIndexSet::pre_filter`.
313 payload_filters: Vec<SqlPayloadAtom>,
314 /// Resolved SELECT target list, for output-schema derivation.
315 projection: Vec<Projection>,
316 },
317 MultiVectorSearch {
318 collection: String,
319 query_vector: Vec<f32>,
320 top_k: usize,
321 ef_search: usize,
322 /// Resolved SELECT target list, for output-schema derivation.
323 projection: Vec<Projection>,
324 },
325 /// Sparse-vector inverted-index search.
326 ///
327 /// Produced by the planner when the leading ORDER BY expression is
328 /// `sparse_score(field, '{dim: weight, ...}')`. The query literal is
329 /// parsed into `(dimension, weight)` entries at plan time. The convert
330 /// layer lowers this to `VectorOp::SparseSearch`, which returns the
331 /// `top_k` documents with the highest dot-product score — matching the
332 /// `DESC` (similarity) ordering the SQL author wrote.
333 SparseSearch {
334 collection: String,
335 field: String,
336 query_entries: Vec<(u32, f32)>,
337 top_k: usize,
338 /// Resolved SELECT target list, for output-schema derivation.
339 projection: Vec<Projection>,
340 },
341 TextSearch {
342 collection: String,
343 /// Structured FTS query. Use `FtsQuery::Plain { text, fuzzy }` for
344 /// simple keyword search. `FtsQuery::And/Or/Prefix` are supported;
345 /// `FtsQuery::Phrase` and `FtsQuery::Not` are represented but rejected
346 /// by the executor with `Unsupported`.
347 query: FtsQuery,
348 top_k: usize,
349 filters: Vec<Filter>,
350 /// When set, the SELECT list contains `bm25_score(field, term)` and the
351 /// caller wants a full-collection scan with the score injected under this
352 /// alias. The converter emits `TextOp::BM25ScoreScan` instead of
353 /// `TextOp::Search` so that all documents — including non-matching ones —
354 /// appear in the response with `null` for the score when they do not
355 /// contain the term.
356 score_alias: Option<String>,
357 /// Resolved SELECT target list, for output-schema derivation.
358 projection: Vec<Projection>,
359 },
360 HybridSearch {
361 collection: String,
362 query_vector: Vec<f32>,
363 query_text: String,
364 top_k: usize,
365 ef_search: usize,
366 vector_weight: f32,
367 fuzzy: bool,
368 /// SELECT-list alias the response should use for the RRF score
369 /// column. `None` means the executor falls back to the fixed
370 /// internal field name `rrf_score`. Set by the planner from the
371 /// SELECT projection's `AS <alias>` for the `rrf_score(...)` call.
372 score_alias: Option<String>,
373 /// Resolved SELECT target list, for output-schema derivation.
374 projection: Vec<Projection>,
375 },
376
377 /// Three-source hybrid search: vector + BM25 text + graph BFS, fused via weighted RRF.
378 ///
379 /// Produced when the planner detects `rrf_score(vector_distance(...),
380 /// bm25_score(...), graph_score(...))` with three source arguments.
381 HybridSearchTriple {
382 collection: String,
383 query_vector: Vec<f32>,
384 query_text: String,
385 /// Node id used as the BFS seed for the graph leg.
386 graph_seed_id: String,
387 /// Maximum BFS depth from the seed node.
388 graph_depth: usize,
389 /// Edge label filter for graph BFS. `None` = all edges.
390 graph_edge_label: Option<String>,
391 top_k: usize,
392 ef_search: usize,
393 fuzzy: bool,
394 /// Per-source RRF k constants: (vector_k, text_k, graph_k).
395 rrf_k: (f64, f64, f64),
396 /// SELECT-list alias for the fused RRF score column.
397 score_alias: Option<String>,
398 /// Resolved SELECT target list, for output-schema derivation.
399 projection: Vec<Projection>,
400 },
401 SpatialScan {
402 collection: String,
403 field: String,
404 predicate: SpatialPredicate,
405 query_geometry: nodedb_types::geometry::Geometry,
406 distance_meters: f64,
407 attribute_filters: Vec<Filter>,
408 limit: usize,
409 projection: Vec<Projection>,
410 },
411
412 // ── Composite ──
413 Union {
414 inputs: Vec<SqlPlan>,
415 distinct: bool,
416 },
417 Intersect {
418 left: Box<SqlPlan>,
419 right: Box<SqlPlan>,
420 all: bool,
421 },
422 Except {
423 left: Box<SqlPlan>,
424 right: Box<SqlPlan>,
425 all: bool,
426 },
427 RecursiveScan {
428 collection: String,
429 base_filters: Vec<Filter>,
430 recursive_filters: Vec<Filter>,
431 /// Equi-join link for tree-traversal recursion:
432 /// `(collection_field, working_table_field)`.
433 /// e.g. `("parent_id", "id")` means each iteration finds rows
434 /// where `collection.parent_id` matches a `working_table.id`.
435 join_link: Option<(String, String)>,
436 max_iterations: usize,
437 distinct: bool,
438 limit: usize,
439 /// Resolved SELECT target list, for output-schema derivation.
440 projection: Vec<Projection>,
441 },
442
443 /// Value-generating recursive CTE (`WITH RECURSIVE name(cols) AS (anchor UNION [ALL] step)`).
444 ///
445 /// Unlike `RecursiveScan`, this variant carries no collection reference — the anchor
446 /// row is produced entirely from literal expressions and each iteration applies the
447 /// step expressions to the previous row. The executor evaluates this iteratively
448 /// in the Data Plane without touching storage.
449 ///
450 /// All expressions are stored as raw SQL text so they can be serialised across the
451 /// SPSC bridge without requiring `SqlExpr` to implement `Serialize`. The executor
452 /// parses them at execution time via the same lightweight expression evaluator used
453 /// by the procedural executor.
454 RecursiveValue {
455 /// CTE name (used in error messages).
456 cte_name: String,
457 /// Column names declared on the CTE (e.g. `(n)` in `c(n) AS ...`).
458 columns: Vec<String>,
459 /// Anchor SELECT expressions as raw SQL text (one per column).
460 init_exprs: Vec<String>,
461 /// Recursive step SELECT expressions as raw SQL text (one per column).
462 /// May reference column names from `columns`.
463 step_exprs: Vec<String>,
464 /// Optional WHERE condition as raw SQL text applied to each new row
465 /// to decide whether to continue. `None` → run until fixed point.
466 condition: Option<String>,
467 /// Maximum iterations before a `RecursionDepthExceeded` error is raised.
468 max_depth: usize,
469 /// `false` → UNION ALL (keep duplicates); `true` → UNION (deduplicate).
470 distinct: bool,
471 },
472
473 /// Non-recursive CTE: execute each definition, then the outer query.
474 Cte {
475 /// CTE definitions: `(name, subquery_plan)`.
476 definitions: Vec<(String, SqlPlan)>,
477 /// The outer query that references CTE names.
478 outer: Box<SqlPlan>,
479 },
480
481 // ── Array (ND sparse) ─────────────────────────────────────
482 /// `CREATE ARRAY <name> DIMS (...) ATTRS (...) TILE_EXTENTS (...)`.
483 /// AST is engine-agnostic — the Origin converter builds the typed
484 /// `nodedb_array::ArraySchema` and persists the catalog row.
485 CreateArray {
486 name: String,
487 dims: Vec<types_array::ArrayDimAst>,
488 attrs: Vec<types_array::ArrayAttrAst>,
489 tile_extents: Vec<i64>,
490 cell_order: types_array::ArrayCellOrderAst,
491 tile_order: types_array::ArrayTileOrderAst,
492 /// Hilbert-prefix bits for vShard routing (1–16, default 8).
493 prefix_bits: u8,
494 /// Audit-retention horizon in milliseconds. `None` = non-bitemporal.
495 audit_retain_ms: Option<u64>,
496 /// Compliance floor for `audit_retain_ms`. `None` = no floor.
497 minimum_audit_retain_ms: Option<u64>,
498 },
499 /// `DROP ARRAY [IF EXISTS] <name>` — pure Control-Plane catalog
500 /// mutation. Per-core array store cleanup happens lazily.
501 DropArray { name: String, if_exists: bool },
502 /// `ALTER ARRAY <name> SET (audit_retain_ms = N, ...)`.
503 ///
504 /// Double-`Option` semantics for each diff field:
505 /// - `None` = key was absent from SET clause → field unchanged.
506 /// - `Some(None)` = key present with value `NULL` → field set to NULL.
507 /// - `Some(Some(v))` = key present with integer value → field set to v.
508 AlterArray {
509 name: String,
510 /// New value for `audit_retain_ms`. `Some(None)` unregisters
511 /// the array from the bitemporal retention registry.
512 audit_retain_ms: Option<Option<i64>>,
513 /// New value for `minimum_audit_retain_ms`. Cannot be NULL.
514 minimum_audit_retain_ms: Option<u64>,
515 },
516 /// `INSERT INTO ARRAY <name> COORDS (...) VALUES (...) [, ...]`.
517 InsertArray {
518 name: String,
519 rows: Vec<types_array::ArrayInsertRow>,
520 },
521 /// `DELETE FROM ARRAY <name> WHERE COORDS IN ((...), (...))`.
522 DeleteArray {
523 name: String,
524 coords: Vec<Vec<types_array::ArrayCoordLiteral>>,
525 },
526 /// `SELECT * FROM ARRAY_SLICE(name, {dim:[lo,hi],..}, [attrs], limit)`.
527 ArraySlice {
528 name: String,
529 slice: types_array::ArraySliceAst,
530 /// Attribute names. Empty = all attrs.
531 attr_projection: Vec<String>,
532 /// 0 = unlimited.
533 limit: u32,
534 /// Bitemporal qualifier. When both axes are `None` / `Any`, the Data
535 /// Plane returns the live (current) state — the default fast path.
536 /// Populated from `AS OF SYSTEM TIME` / `AS OF VALID TIME` clauses.
537 temporal: TemporalScope,
538 },
539 /// `SELECT * FROM ARRAY_PROJECT(name, [attrs])`.
540 ArrayProject {
541 name: String,
542 /// Attribute names. Must be non-empty.
543 attr_projection: Vec<String>,
544 },
545 /// `SELECT * FROM ARRAY_AGG(name, attr, reducer [, group_by_dim])`.
546 ArrayAgg {
547 name: String,
548 attr: String,
549 reducer: types_array::ArrayReducerAst,
550 /// `None` = scalar fold; `Some(name)` = group by that dim.
551 group_by_dim: Option<String>,
552 /// Bitemporal qualifier. When both axes are `None` / `Any`, the Data
553 /// Plane aggregates against the live (current) state — the default
554 /// fast path. Populated from `AS OF SYSTEM TIME` / `AS OF VALID TIME`.
555 temporal: TemporalScope,
556 },
557 /// `SELECT * FROM ARRAY_ELEMENTWISE(left, right, op, attr)`.
558 ArrayElementwise {
559 left: String,
560 right: String,
561 op: types_array::ArrayBinaryOpAst,
562 attr: String,
563 },
564 /// `SELECT ARRAY_FLUSH(name)` — returns one row `{result: BOOL}`.
565 ArrayFlush { name: String },
566 /// `SELECT ARRAY_COMPACT(name)` — returns one row `{result: BOOL}`.
567 ArrayCompact { name: String },
568
569 // ── MERGE ──────────────────────────────────────────────────────────
570 /// `MERGE INTO target USING source ON ... WHEN ... THEN ...`
571 ///
572 /// Supported only for `document_schemaless` and `document_strict` engines.
573 /// The Data Plane handler evaluates WHEN arms in declaration order and
574 /// applies the first matching action to each joined or unmatched row.
575 Merge {
576 target: String,
577 engine: EngineType,
578 /// Source plan (Scan, DocumentIndexLookup, or Join of a sub-select).
579 source: Box<SqlPlan>,
580 /// Column in the target used for the equi-join (from ON clause).
581 target_join_col: String,
582 /// Column in the source used for the equi-join (from ON clause).
583 source_join_col: String,
584 /// Alias used to qualify source columns in expressions (e.g. `src.col`).
585 source_alias: String,
586 /// WHEN arms in declaration order.
587 clauses: Vec<MergePlanClause>,
588 returning: bool,
589 },
590
591 // ── Lateral joins ───────────────────────────────────────────────────
592 /// LATERAL subquery that is equi-correlated and has ORDER BY + LIMIT k.
593 ///
594 /// Emitted when the inner subquery has an equi-key correlation to the outer
595 /// table plus an `ORDER BY ... LIMIT k` clause. The Data Plane scans the
596 /// inner collection once per outer row applying the equi-filter, sorts by
597 /// `inner_order_by`, and retains at most `inner_limit` rows.
598 ///
599 /// `correlation_keys` is `(outer_col, inner_col)` — the equi-join pairs
600 /// that correlate inner to outer.
601 LateralTopK {
602 /// Plan producing outer rows.
603 outer: Box<SqlPlan>,
604 /// Alias used to qualify outer table columns (e.g. `"u"`).
605 outer_alias: Option<String>,
606 /// Inner collection to scan.
607 inner_collection: String,
608 /// Pre-filter applied to inner rows (non-correlated filters).
609 inner_filters: Vec<Filter>,
610 /// Sort keys for the inner per-outer-row result.
611 inner_order_by: Vec<SortKey>,
612 /// Maximum number of inner rows per outer row.
613 inner_limit: usize,
614 /// Equi-join pairs `(outer_col, inner_col)`.
615 correlation_keys: Vec<(String, String)>,
616 /// Alias under which inner rows are presented.
617 lateral_alias: String,
618 /// Post-lateral projection.
619 projection: Vec<Projection>,
620 /// LEFT join semantics: preserve outer rows even when inner is empty.
621 left_join: bool,
622 },
623
624 /// General LATERAL subquery — per-outer-row correlated nested loop.
625 ///
626 /// Emitted for LATERAL subqueries that cannot be rewritten as equi-join
627 /// hash joins or `LateralTopK`. The Control Plane drives execution: it
628 /// materialises outer rows, then for each row substitutes the correlation
629 /// values as additional filters on the inner plan and re-dispatches it.
630 ///
631 /// Bounded by `outer_row_cap`; queries that exceed the cap receive a typed
632 /// `SqlError::Unsupported` before any data is returned.
633 LateralLoop {
634 /// Plan producing outer rows.
635 outer: Box<SqlPlan>,
636 /// Alias used to qualify outer table columns.
637 outer_alias: Option<String>,
638 /// Inner subquery plan (correlation predicates are injected at runtime).
639 inner: Box<SqlPlan>,
640 /// Correlated predicates extracted from the inner WHERE that reference
641 /// outer columns. Each entry is `(inner_field, outer_field)`.
642 correlation_predicates: Vec<(String, String)>,
643 /// Alias under which inner rows are presented.
644 lateral_alias: String,
645 /// Post-lateral projection.
646 projection: Vec<Projection>,
647 /// Maximum outer rows allowed. Queries exceeding this return an error.
648 outer_row_cap: usize,
649 /// LEFT join semantics: preserve outer rows even when inner is empty.
650 left_join: bool,
651 },
652
653 // ── Vector-primary ──────────────────────────────────────────────────
654 /// INSERT into a vector-primary collection.
655 ///
656 /// Emitted by the planner instead of the generic `Insert` variant when the
657 /// target collection has `primary = PrimaryEngine::Vector`. The Data Plane
658 /// routes each row through `VectorOp::DirectUpsert`, bypassing full-document
659 /// MessagePack encoding.
660 VectorPrimaryInsert {
661 collection: String,
662 /// Vector column name (matches `VectorPrimaryConfig::vector_field`).
663 /// Plumbed to `VectorOp::DirectUpsert` so the Data Plane keys its
664 /// HNSW index by `(tid, collection, field)` — the same key the SELECT
665 /// path uses.
666 field: String,
667 /// Collection-level quantization. Applied via `set_quantization` on
668 /// the first DirectUpsert so subsequent seals trigger codec-dispatch
669 /// rebuilds against the configured codec.
670 quantization: nodedb_types::VectorQuantization,
671 /// Native storage dtype for vector values (F32 / F16 / BF16).
672 storage_dtype: nodedb_types::VectorStorageDtype,
673 /// Payload field names that get equality bitmap indexes. Registered
674 /// via `payload.add_index` on the first DirectUpsert.
675 payload_indexes: Vec<(String, nodedb_types::PayloadIndexKind)>,
676 rows: Vec<VectorPrimaryRow>,
677 },
678
679 // ── Index DDL ───────────────────────────────────────────────────────
680 /// `CREATE [UNIQUE] INDEX [IF NOT EXISTS] name ON collection (field)`
681 ///
682 /// Registers a secondary index on the named field of a document or KV
683 /// collection. The executor backtracks existing rows into the index so
684 /// it is immediately consistent.
685 CreateIndex {
686 /// Name of the index. `None` requests an auto-generated name.
687 index_name: Option<String>,
688 /// Target collection.
689 collection: String,
690 /// Indexed field path.
691 field: String,
692 /// Whether the index enforces uniqueness.
693 unique: bool,
694 /// `IF NOT EXISTS` — succeed silently if the index already exists.
695 if_not_exists: bool,
696 /// Case-insensitive string collation (`COLLATE NOCASE`).
697 case_insensitive: bool,
698 },
699
700 /// `DROP INDEX [IF EXISTS] name [ON collection]`
701 ///
702 /// Removes a secondary index from a collection. All index entries are
703 /// erased and the index metadata is unregistered.
704 DropIndex {
705 /// Name of the index.
706 index_name: String,
707 /// Target collection (may be inferred from the index catalog).
708 collection: Option<String>,
709 /// `IF EXISTS` — succeed silently if the index does not exist.
710 if_exists: bool,
711 },
712}