Skip to main content

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}