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 /// Create a materialized view: execute query, store results, register.
211 CreateView {
212 name: String,
213 query_text: String,
214 },
215 /// Explicitly refresh a materialized view.
216 RefreshView {
217 name: String,
218 },
219 /// Drop a materialized view (backing table + registry entry).
220 DropView {
221 name: String,
222 /// `drop view if exists` — a missing view is a no-op instead of an error.
223 if_exists: bool,
224 },
225 /// Window function computation layer.
226 Window {
227 input: Box<PlanNode>,
228 windows: Vec<WindowDef>,
229 },
230 /// `UNION [ALL]`: execute both sides, concatenate (ALL) or deduplicate.
231 Union {
232 left: Box<PlanNode>,
233 right: Box<PlanNode>,
234 all: bool,
235 },
236 /// EXPLAIN: format the inner plan tree as a text result without executing.
237 Explain {
238 input: Box<PlanNode>,
239 },
240 Begin,
241 Commit,
242 Rollback,
243}
244
245#[derive(Debug, Clone)]
246pub struct ProjectField {
247 pub alias: Option<String>,
248 pub expr: Expr,
249}
250
251/// One output column of a `PlanNode::NestedProject`.
252#[derive(Debug, Clone)]
253pub enum NestedProjectField {
254 /// A scalar field, evaluated against the parent row like `Project`.
255 Plain(ProjectField),
256 /// A nested sub-query field, emitted as a PJ1 JSON array of objects.
257 Nested(Box<NestedProjection>),
258 /// A scalar link traversal field (`o.user.name`), emitted as the
259 /// traversed target column's value (or empty when the FK is NULL or
260 /// dangling at any hop).
261 Link(Box<ScalarLinkField>),
262}
263
264/// A scalar link traversal projection field. Like `via_link` on
265/// [`NestedProjection`], the planner cannot resolve link names (it never
266/// touches the catalog), so `resolved` is `None` out of the planner and is
267/// filled in by the executor before assembly.
268#[derive(Debug, Clone)]
269pub struct ScalarLinkField {
270 /// Output column name (the field alias, or the dotted source spelling).
271 pub name: String,
272 /// The outer scan alias the path starts from (`o`).
273 pub outer_alias: String,
274 /// Declared to-one link names in traversal order (`["user", "company"]`).
275 pub links: Vec<String>,
276 /// The column read on the final target (`name`).
277 pub column: String,
278 /// Filled by the executor's catalog resolution; `None` out of the planner.
279 pub resolved: Option<ScalarLinkResolved>,
280}
281
282/// The catalog-resolved form of a scalar link path.
283#[derive(Debug, Clone)]
284pub struct ScalarLinkResolved {
285 /// The FK column on the parent scan, alias-qualified as the parent's
286 /// `AliasScan` emits it (`o.user_id`).
287 pub first_fk: String,
288 /// One hop per link name, in traversal order.
289 pub hops: Vec<ScalarLinkHop>,
290}
291
292/// One resolved hop of a scalar link path: look the incoming FK value up by
293/// `key_col` in `table`, then read `out_col` (the next hop's FK column, or the
294/// final target column on the last hop).
295#[derive(Debug, Clone)]
296pub struct ScalarLinkHop {
297 pub table: String,
298 pub key_col: String,
299 pub out_col: String,
300}
301
302/// The resolved form of one nested sub-query projection field. Correlation
303/// columns are pre-split by the planner: at the top level `parent_key` is
304/// the qualified parent column name (`u.id`) as produced by an `AliasScan`;
305/// for a nested-in-nested field it is the bare column name of the enclosing
306/// child table. `child_key` is always the bare child column name.
307#[derive(Debug, Clone)]
308pub struct NestedProjection {
309 /// Output column name (the projection field's alias).
310 pub name: String,
311 pub table: String,
312 /// Set when this level came from a block link traversal
313 /// (`orders: u.orders { ... }`). While set, `table`/`child_key`/
314 /// `parent_key` are placeholders; the executor resolves them from the
315 /// persistent catalog before assembly (like `RangeScan` late lowering)
316 /// and clears this. The planner never touches the catalog, so it cannot
317 /// resolve it.
318 pub via_link: Option<ViaLink>,
319 /// The child alias from the source text, kept for EXPLAIN and errors.
320 pub alias: String,
321 /// The enclosing scope's alias, kept for EXPLAIN (the executor
322 /// correlates through `parent_key` alone).
323 pub parent_alias: String,
324 pub child_key: String,
325 pub parent_key: String,
326 /// Residual filter conditions beyond the correlation predicate,
327 /// rewritten by the planner to reference bare child columns.
328 pub residual: Option<Expr>,
329 /// Per-parent ordering: `(bare child column, descending)` keys applied
330 /// to each parent's child bucket before truncation.
331 pub order: Vec<(String, bool)>,
332 /// Per-parent truncation bounds, applied after ordering. Must evaluate
333 /// to non-negative integer literals (like top-level limit/offset).
334 pub limit: Option<Expr>,
335 pub offset: Option<Expr>,
336 /// Source wrote `offset` before `limit`; the plan cache refuses this
337 /// form (see `plan_cache::nested_projection_defeats_cache`).
338 pub offset_before_limit: bool,
339 pub fields: Vec<NestedField>,
340}
341
342/// One entry in a nested projection's output object.
343#[derive(Debug, Clone)]
344pub enum NestedField {
345 /// A scalar child column emitted under `key`.
346 Scalar { key: String, column: String },
347 /// A deeper nested sub-query, correlated to this child table by its
348 /// (bare) `parent_key` column.
349 Nested(Box<NestedProjection>),
350}
351
352impl NestedProjection {
353 /// Visit this projection's child table and every deeper nested child
354 /// table. Used for dirty-view escalation and auto-refresh.
355 pub fn visit_tables(&self, visit: &mut dyn FnMut(&str)) {
356 visit(&self.table);
357 for field in &self.fields {
358 if let NestedField::Nested(inner) = field {
359 inner.visit_tables(visit);
360 }
361 }
362 }
363}
364
365#[derive(Debug, Clone)]
366pub struct SortKey {
367 pub expr: Expr,
368 pub descending: bool,
369}
370
371/// One aggregate computation inside a `PlanNode::GroupBy`.
372#[derive(Debug, Clone)]
373pub struct GroupAgg {
374 pub function: AggFunc,
375 pub argument: Expr,
376 pub mode: AggregateMode,
377 /// Source alias whose physical row identity controls symmetric
378 /// de-duplication. `None` means raw aggregation semantics.
379 pub provenance_alias: Option<String>,
380 /// Synthetic output column name (`__agg_0`, `__agg_1`, …).
381 pub output_name: String,
382}
383
384/// One window function definition inside a `PlanNode::Window`.
385#[derive(Debug, Clone)]
386pub struct WindowDef {
387 pub function: WindowFunc,
388 pub args: Vec<Expr>,
389 pub mode: AggregateMode,
390 pub partition_by: Vec<Expr>,
391 pub order_by: Vec<SortKey>,
392 pub output_name: String,
393}