powdb_query/plan.rs
1use crate::ast::{
2 AggFunc, AggregateMode, AlterAction, Assignment, Expr, GroupKey, JoinKind, Literal, ViaLink,
3 WindowFunc,
4};
5use powdb_storage::stored_json_path::StoredJsonPathV1;
6
7/// A column definition carried by `PlanNode::CreateTable`. Replaces the
8/// old `(name, type_name, required)` tuple so the `unique` modifier can
9/// flow from the parser through to the executor's DDL arm.
10#[derive(Debug, Clone)]
11pub struct CreateField {
12 pub name: String,
13 pub type_name: String,
14 pub required: bool,
15 pub unique: bool,
16 /// Literal default applied when an insert omits this column.
17 pub default: Option<Literal>,
18 /// `true` when declared `auto` — integer column auto-assigned from a
19 /// per-table sequence when an insert omits it.
20 pub auto: bool,
21}
22
23/// Physical plan nodes — what the executor actually runs.
24#[derive(Debug, Clone)]
25pub enum PlanNode {
26 SeqScan {
27 table: String,
28 },
29 /// Mission E1.2: sequential scan that renames output columns to
30 /// `alias.field`. Used exclusively as the leaves of a join plan so
31 /// downstream `NestedLoopJoin` + `Filter` + `Project` nodes can resolve
32 /// `Expr::QualifiedField` lookups by direct column-name match. Kept
33 /// separate from `SeqScan` so the single-table fast paths (which match
34 /// on `PlanNode::SeqScan { .. }` in many places) stay untouched.
35 AliasScan {
36 table: String,
37 alias: String,
38 },
39 IndexScan {
40 table: String,
41 column: String,
42 key: Expr,
43 },
44 /// B+tree range scan: returns rows where the indexed column falls within
45 /// the given bounds. Generated by the planner when it detects inequality
46 /// predicates (>, >=, <, <=, BETWEEN) on an indexed column. The executor
47 /// falls back to SeqScan+Filter if no index exists on the column.
48 RangeScan {
49 table: String,
50 column: String,
51 /// Lower bound: (expr, inclusive). None = unbounded below.
52 start: Option<(Expr, bool)>,
53 /// Upper bound: (expr, inclusive). None = unbounded above.
54 end: Option<(Expr, bool)>,
55 },
56 /// Speculative equality lookup through an expression index. The planner is
57 /// catalog-pure; the executor resolves `path` at runtime and reconstructs
58 /// the equivalent path predicate if no matching index exists.
59 ExprIndexScan {
60 table: String,
61 path: StoredJsonPathV1,
62 key: Expr,
63 },
64 /// Speculative bounded lookup through an expression index. `path` is the
65 /// persisted table-local identity, never a runtime catalog identifier.
66 ExprRangeScan {
67 table: String,
68 path: StoredJsonPathV1,
69 start: Option<(Expr, bool)>,
70 end: Option<(Expr, bool)>,
71 },
72 /// Exact single-path ORDER BY + LIMIT/OFFSET candidate. Missing or
73 /// incompatible runtime indexes fall back to a normal scan/sort/slice.
74 OrderedExprIndexScan {
75 table: String,
76 path: StoredJsonPathV1,
77 descending: bool,
78 limit: Expr,
79 offset: Option<Expr>,
80 },
81 Filter {
82 input: Box<PlanNode>,
83 predicate: Expr,
84 },
85 Project {
86 input: Box<PlanNode>,
87 fields: Vec<ProjectField>,
88 },
89 /// Language-lab slice: projection with one or more nested sub-query
90 /// fields. `input` is the parent pipeline (AliasScan-based so qualified
91 /// references resolve by column name); each nested field is assembled by
92 /// a single hash-build pass over its child table, O(parent + child).
93 /// Kept separate from `Project` so the single-table fast paths and the
94 /// plan cache never see this shape.
95 NestedProject {
96 input: Box<PlanNode>,
97 fields: Vec<NestedProjectField>,
98 },
99 Sort {
100 input: Box<PlanNode>,
101 keys: Vec<SortKey>,
102 },
103 Limit {
104 input: Box<PlanNode>,
105 count: Expr,
106 },
107 Offset {
108 input: Box<PlanNode>,
109 count: Expr,
110 },
111 Aggregate {
112 input: Box<PlanNode>,
113 function: AggFunc,
114 argument: Option<Expr>,
115 mode: AggregateMode,
116 /// Source alias whose physical row identity controls symmetric
117 /// de-duplication. `None` means raw aggregation semantics.
118 provenance_alias: Option<String>,
119 },
120 /// Mission E1.2: nested-loop join. Correctness-first implementation —
121 /// O(L × R) scan for every join. E1.3 will add a hash-join fast path
122 /// for equijoins (the common case). The executor handles `Inner`,
123 /// `Cross`, and `LeftOuter`; `RightOuter` is rewritten by the planner
124 /// into a `LeftOuter` with swapped inputs.
125 NestedLoopJoin {
126 left: Box<PlanNode>,
127 right: Box<PlanNode>,
128 /// Join predicate. `None` for `Cross` joins (emit every pair).
129 on: Option<Expr>,
130 kind: JoinKind,
131 },
132 Distinct {
133 input: Box<PlanNode>,
134 },
135 /// Mission E2b: grouped aggregation. Output columns are
136 /// `keys ++ [agg.output_name for agg in aggregates]`. The optional
137 /// `having` predicate is evaluated against each output row *after*
138 /// aggregation — it can reference both key columns and aggregate
139 /// output names (the planner rewrites `FunctionCall` nodes in the
140 /// HAVING expression into `Field("__agg_N")` references).
141 GroupBy {
142 input: Box<PlanNode>,
143 keys: Vec<GroupKey>,
144 aggregates: Vec<GroupAgg>,
145 having: Option<Expr>,
146 },
147 AlterTable {
148 table: String,
149 action: AlterAction,
150 },
151 DropTable {
152 name: String,
153 /// `drop if exists` — a missing table is a no-op instead of an error.
154 if_exists: bool,
155 },
156 Insert {
157 table: String,
158 /// One assignment-block per row to insert. Always at least one.
159 rows: Vec<Vec<Assignment>>,
160 /// `true` when `returning` was requested — executor returns the
161 /// inserted rows instead of a modified-count.
162 returning: bool,
163 },
164 /// UPSERT: probe index on `key_column` — if miss, insert; if hit, update.
165 Upsert {
166 table: String,
167 key_column: String,
168 assignments: Vec<Assignment>,
169 on_conflict: Vec<Assignment>,
170 },
171 Update {
172 input: Box<PlanNode>,
173 table: String,
174 assignments: Vec<Assignment>,
175 /// `true` when `returning` was requested — executor returns the
176 /// post-update rows instead of a modified-count.
177 returning: bool,
178 },
179 Delete {
180 input: Box<PlanNode>,
181 table: String,
182 /// `true` when `returning` was requested — executor returns the
183 /// pre-delete rows instead of a modified-count.
184 returning: bool,
185 },
186 CreateTable {
187 name: String,
188 fields: Vec<CreateField>,
189 /// `type X if not exists { ... }` — re-declaring an existing type is
190 /// a no-op instead of an error.
191 if_not_exists: bool,
192 },
193 /// `link <Owner>.<name> -> <Target> on <local> = <target>`: declare a
194 /// persistent entity link. Lowers to `Catalog::create_link`, which
195 /// validates the tables/columns and derives the cardinality.
196 CreateLink {
197 owner: String,
198 name: String,
199 target: String,
200 local_key: String,
201 target_key: String,
202 },
203 /// `schema` — list every type (table) in the catalog. Read-only; reads
204 /// live catalog state at execution time, so a cached plan is never stale.
205 ListTypes,
206 /// `describe <Type>` — the columns and indexes of one type. Read-only.
207 Describe {
208 table: String,
209 },
210 /// `schema links`: list every declared entity link. Read-only; reads
211 /// live catalog state at execution time, so a cached plan is never stale.
212 ListLinks,
213 /// Create a materialized view: execute query, store results, register.
214 CreateView {
215 name: String,
216 query_text: String,
217 },
218 /// Explicitly refresh a materialized view.
219 RefreshView {
220 name: String,
221 },
222 /// Drop a materialized view (backing table + registry entry).
223 DropView {
224 name: String,
225 /// `drop view if exists` — a missing view is a no-op instead of an error.
226 if_exists: bool,
227 },
228 /// Window function computation layer.
229 Window {
230 input: Box<PlanNode>,
231 windows: Vec<WindowDef>,
232 },
233 /// `UNION [ALL]`: execute both sides, concatenate (ALL) or deduplicate.
234 Union {
235 left: Box<PlanNode>,
236 right: Box<PlanNode>,
237 all: bool,
238 },
239 /// EXPLAIN: format the inner plan tree as a text result without executing.
240 Explain {
241 input: Box<PlanNode>,
242 },
243 Begin,
244 Commit,
245 Rollback,
246}
247
248#[derive(Debug, Clone)]
249pub struct ProjectField {
250 pub alias: Option<String>,
251 pub expr: Expr,
252}
253
254/// One output column of a `PlanNode::NestedProject`.
255#[derive(Debug, Clone)]
256pub enum NestedProjectField {
257 /// A scalar field, evaluated against the parent row like `Project`.
258 Plain(ProjectField),
259 /// A nested sub-query field, emitted as a PJ1 JSON array of objects.
260 Nested(Box<NestedProjection>),
261 /// A scalar link traversal field (`o.user.name`), emitted as the
262 /// traversed target column's value (or empty when the FK is NULL or
263 /// dangling at any hop).
264 Link(Box<ScalarLinkField>),
265}
266
267/// A scalar link traversal projection field. Like `via_link` on
268/// [`NestedProjection`], the planner cannot resolve link names (it never
269/// touches the catalog), so `resolved` is `None` out of the planner and is
270/// filled in by the executor before assembly.
271#[derive(Debug, Clone)]
272pub struct ScalarLinkField {
273 /// Output column name (the field alias, or the dotted source spelling).
274 pub name: String,
275 /// The outer scan alias the path starts from (`o`).
276 pub outer_alias: String,
277 /// Declared to-one link names in traversal order (`["user", "company"]`).
278 pub links: Vec<String>,
279 /// The column read on the final target (`name`).
280 pub column: String,
281 /// Filled by the executor's catalog resolution; `None` out of the planner.
282 pub resolved: Option<ScalarLinkResolved>,
283}
284
285/// The catalog-resolved form of a scalar link path.
286#[derive(Debug, Clone)]
287pub struct ScalarLinkResolved {
288 /// The FK column on the parent scan, alias-qualified as the parent's
289 /// `AliasScan` emits it (`o.user_id`).
290 pub first_fk: String,
291 /// One hop per link name, in traversal order.
292 pub hops: Vec<ScalarLinkHop>,
293}
294
295/// One resolved hop of a scalar link path: look the incoming FK value up by
296/// `key_col` in `table`, then read `out_col` (the next hop's FK column, or the
297/// final target column on the last hop).
298#[derive(Debug, Clone)]
299pub struct ScalarLinkHop {
300 pub table: String,
301 pub key_col: String,
302 pub out_col: String,
303}
304
305/// The resolved form of one nested sub-query projection field. Correlation
306/// columns are pre-split by the planner: at the top level `parent_key` is
307/// the qualified parent column name (`u.id`) as produced by an `AliasScan`;
308/// for a nested-in-nested field it is the bare column name of the enclosing
309/// child table. `child_key` is always the bare child column name.
310#[derive(Debug, Clone)]
311pub struct NestedProjection {
312 /// Output column name (the projection field's alias).
313 pub name: String,
314 pub table: String,
315 /// Set when this level came from a block link traversal
316 /// (`orders: u.orders { ... }`). While set, `table`/`child_key`/
317 /// `parent_key` are placeholders; the executor resolves them from the
318 /// persistent catalog before assembly (like `RangeScan` late lowering)
319 /// and clears this. The planner never touches the catalog, so it cannot
320 /// resolve it.
321 pub via_link: Option<ViaLink>,
322 /// The child alias from the source text, kept for EXPLAIN and errors.
323 pub alias: String,
324 /// The enclosing scope's alias, kept for EXPLAIN (the executor
325 /// correlates through `parent_key` alone).
326 pub parent_alias: String,
327 pub child_key: String,
328 pub parent_key: String,
329 /// Residual filter conditions beyond the correlation predicate,
330 /// rewritten by the planner to reference bare child columns.
331 pub residual: Option<Expr>,
332 /// Per-parent ordering: `(bare child column, descending)` keys applied
333 /// to each parent's child bucket before truncation.
334 pub order: Vec<(String, bool)>,
335 /// Per-parent truncation bounds, applied after ordering. Must evaluate
336 /// to non-negative integer literals (like top-level limit/offset).
337 pub limit: Option<Expr>,
338 pub offset: Option<Expr>,
339 /// Source wrote `offset` before `limit`; the plan cache refuses this
340 /// form (see `plan_cache::nested_projection_defeats_cache`).
341 pub offset_before_limit: bool,
342 pub fields: Vec<NestedField>,
343}
344
345/// One entry in a nested projection's output object.
346#[derive(Debug, Clone)]
347pub enum NestedField {
348 /// A scalar child column emitted under `key`.
349 Scalar { key: String, column: String },
350 /// A deeper nested sub-query, correlated to this child table by its
351 /// (bare) `parent_key` column.
352 Nested(Box<NestedProjection>),
353}
354
355impl NestedProjection {
356 /// Visit this projection's child table and every deeper nested child
357 /// table. Used for dirty-view escalation and auto-refresh.
358 pub fn visit_tables(&self, visit: &mut dyn FnMut(&str)) {
359 visit(&self.table);
360 for field in &self.fields {
361 if let NestedField::Nested(inner) = field {
362 inner.visit_tables(visit);
363 }
364 }
365 }
366}
367
368#[derive(Debug, Clone)]
369pub struct SortKey {
370 pub expr: Expr,
371 pub descending: bool,
372}
373
374/// One aggregate computation inside a `PlanNode::GroupBy`.
375#[derive(Debug, Clone)]
376pub struct GroupAgg {
377 pub function: AggFunc,
378 pub argument: Expr,
379 pub mode: AggregateMode,
380 /// Source alias whose physical row identity controls symmetric
381 /// de-duplication. `None` means raw aggregation semantics.
382 pub provenance_alias: Option<String>,
383 /// Synthetic output column name (`__agg_0`, `__agg_1`, …).
384 pub output_name: String,
385}
386
387/// One window function definition inside a `PlanNode::Window`.
388#[derive(Debug, Clone)]
389pub struct WindowDef {
390 pub function: WindowFunc,
391 pub args: Vec<Expr>,
392 pub mode: AggregateMode,
393 pub partition_by: Vec<Expr>,
394 pub order_by: Vec<SortKey>,
395 pub output_name: String,
396}