Skip to main content

powdb_query/
ast.rs

1use powdb_storage::types::Value;
2
3pub(crate) const OUTER_CORRELATION_QUALIFIER: &str = "\0powdb_outer_correlated_reference";
4
5pub(crate) fn outer_correlation_qualifier(visible: &str) -> String {
6    format!("{OUTER_CORRELATION_QUALIFIER}{visible}")
7}
8
9pub(crate) fn outer_correlation_alias(qualifier: &str) -> Option<&str> {
10    qualifier
11        .strip_prefix(OUTER_CORRELATION_QUALIFIER)
12        .filter(|alias| !alias.is_empty())
13}
14
15/// Top-level PowQL statement.
16#[derive(Debug, Clone, PartialEq)]
17pub enum Statement {
18    Query(QueryExpr),
19    Insert(InsertExpr),
20    UpdateQuery(UpdateExpr),
21    DeleteQuery(DeleteExpr),
22    CreateType(CreateTypeExpr),
23    /// `link <Owner>.<name> -> <Target> on <local> = <target>`: declare a
24    /// persistent entity link (catalog v7). Lowers to `Catalog::create_link`.
25    CreateLink(CreateLinkExpr),
26    /// `drop link <Owner>.<name>`: remove a persistent entity link. Lowers to
27    /// `Catalog::drop_link`.
28    DropLink(DropLinkExpr),
29    AlterTable(AlterTableExpr),
30    DropTable(DropTableExpr),
31    CreateView(CreateViewExpr),
32    RefreshView(RefreshViewExpr),
33    DropView(DropViewExpr),
34    Union(UnionExpr),
35    Upsert(UpsertExpr),
36    Explain(Box<Statement>),
37    Begin,
38    Commit,
39    Rollback,
40    /// `schema` — introspection: list every type (table) in the catalog.
41    ListTypes,
42    /// `describe <Type>` / `schema <Type>` — introspection: the columns and
43    /// indexes of one type.
44    Describe(String),
45    /// `schema links`: introspection: list every declared entity link.
46    ListLinks,
47}
48
49/// `alter User add column status: str` / `alter User drop column status`
50#[derive(Debug, Clone, PartialEq)]
51pub struct AlterTableExpr {
52    pub table: String,
53    pub action: AlterAction,
54}
55
56/// A persistent entity-link declaration, written either as a bare statement
57/// (`link <Owner>.<name> -> <Target> on <local> = <target>`) or as an
58/// `alter <Owner> add link <name> -> <Target> on <local> = <target>` action.
59/// The `on <local> = <target>` clause reads "the owner's `local_key` equals
60/// the target's `target_key`". Cardinality (`ToOne`/`ToMany`) is NOT written
61/// here: it is derived by `Catalog::create_link` from whether `target_key` is
62/// backed by a unique index on the target.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct CreateLinkExpr {
65    /// The type the link is declared on (traversed as `<alias>.<name>`).
66    pub owner: String,
67    /// Traversal name, unique per owner type.
68    pub name: String,
69    /// Target type the link resolves to.
70    pub target: String,
71    /// Column on the owner supplying the join value.
72    pub local_key: String,
73    /// Column on the target matched against `local_key`.
74    pub target_key: String,
75}
76
77/// `drop link <Owner>.<name>`, also spelled
78/// `alter <Owner> drop link <name>`.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct DropLinkExpr {
81    /// The type that declared the link.
82    pub owner: String,
83    /// The link's name on that type.
84    pub name: String,
85    /// `if exists`: a link that is already gone is not an error.
86    pub if_exists: bool,
87}
88
89/// An unresolved link traversal on a projection, e.g. `orders: u.orders
90/// { total }` (block) or the parent side of a scalar path. Carried on
91/// [`NestedQuery`]/[`NestedProjection`](crate::plan::NestedProjection) so
92/// the planner stays catalog-pure; the executor resolves it against the
93/// persistent catalog at query time (the correlation columns and child
94/// table are not known until then).
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct ViaLink {
97    /// The outer scan alias the link hangs off (`u` in `u.orders`).
98    pub outer_alias: String,
99    /// The declared link name (`orders`).
100    pub link_name: String,
101}
102
103/// A persisted index target. Stored JSON paths are table-local and therefore
104/// never retain a query alias or runtime catalog/index identifier.
105#[derive(Debug, Clone, PartialEq, Eq, Hash)]
106pub enum IndexTarget {
107    Column(String),
108    JsonPath(powdb_storage::stored_json_path::StoredJsonPathV1),
109}
110
111/// An individual ALTER TABLE action.
112#[derive(Debug, Clone, PartialEq)]
113pub enum AlterAction {
114    AddColumn {
115        name: String,
116        type_name: String,
117        required: bool,
118    },
119    DropColumn {
120        name: String,
121        /// `drop column if exists` — a missing column is a no-op instead of
122        /// an error.
123        if_exists: bool,
124    },
125    /// `alter <Table> add index [if not exists] .<column>` — creates a
126    /// B+Tree index on `column`. No-op if the index already exists.
127    AddIndex {
128        target: IndexTarget,
129        /// Parsed for symmetry with the other DDL; `add index` is already
130        /// idempotent, so this does not change behavior.
131        if_not_exists: bool,
132    },
133    /// `alter <Table> add unique [if not exists] .<column>` — creates a
134    /// UNIQUE B+Tree index on `column`. Scans existing data first and fails
135    /// if any duplicate (non-null) value is present. Without `if not exists`
136    /// it errors when the column is already indexed (no in-place upgrade);
137    /// with it, an existing index is a no-op.
138    AddUnique {
139        target: IndexTarget,
140        if_not_exists: bool,
141    },
142    /// `alter <Table> drop index [if exists] <target>` — removes either a
143    /// stored-column index or an expression index with the exact path identity.
144    DropIndex {
145        target: IndexTarget,
146        if_exists: bool,
147    },
148    /// `alter <Owner> add link <name> -> <Target> on <local> = <target>`:
149    /// declare a persistent entity link on the altered type. Lowers to
150    /// `Catalog::create_link`; cardinality is derived there.
151    AddLink {
152        name: String,
153        target: String,
154        local_key: String,
155        target_key: String,
156    },
157}
158
159/// `drop [if exists] User`
160#[derive(Debug, Clone, PartialEq)]
161pub struct DropTableExpr {
162    pub table: String,
163    /// `drop if exists` — a missing table is a no-op instead of an error.
164    pub if_exists: bool,
165}
166
167/// `create [materialized] view ActiveUsers as User filter .active = true`
168#[derive(Debug, Clone, PartialEq)]
169pub struct CreateViewExpr {
170    pub name: String,
171    pub query: QueryExpr,
172    /// The original source query text, stored for re-execution on refresh.
173    pub query_text: String,
174}
175
176/// `refresh ActiveUsers`
177#[derive(Debug, Clone, PartialEq)]
178pub struct RefreshViewExpr {
179    pub name: String,
180}
181
182/// `drop view [if exists] ActiveUsers`
183#[derive(Debug, Clone, PartialEq)]
184pub struct DropViewExpr {
185    pub name: String,
186    /// `drop view if exists` — a missing view is a no-op instead of an error.
187    pub if_exists: bool,
188}
189
190/// `User filter .age > 30 union User filter .status = "vip"`
191#[derive(Debug, Clone, PartialEq)]
192pub struct UnionExpr {
193    pub left: Box<Statement>,
194    pub right: Box<Statement>,
195    /// `true` for `union all` (keep duplicates), `false` for `union` (deduplicate).
196    pub all: bool,
197}
198
199/// A query expression: Type [join ...]* [filter ...] [order ...] [limit ...] [{ projection }]
200#[derive(Debug, Clone, PartialEq)]
201pub struct QueryExpr {
202    pub source: String,
203    /// Optional alias for the primary source (e.g. `User as u`). Used to
204    /// disambiguate qualified column references in join queries. `None` for
205    /// single-table queries.
206    pub alias: Option<String>,
207    /// Zero or more join clauses chained to the primary source. For a
208    /// single-table query this is always empty so existing code paths are
209    /// untouched.
210    pub joins: Vec<JoinClause>,
211    pub filter: Option<Expr>,
212    pub order: Option<OrderClause>,
213    pub limit: Option<Expr>,
214    pub offset: Option<Expr>,
215    pub projection: Option<Vec<ProjectionField>>,
216    pub aggregation: Option<AggregateExpr>,
217    pub distinct: bool,
218    pub group_by: Option<GroupByClause>,
219}
220
221/// GROUP BY clause: `group .field1, alias.field2 [having <expr>]`.
222#[derive(Debug, Clone, PartialEq)]
223pub struct GroupByClause {
224    pub keys: Vec<GroupKey>,
225    pub having: Option<Expr>,
226}
227
228/// A single expression-valued GROUP BY key.
229#[derive(Debug, Clone, PartialEq)]
230pub struct GroupKey {
231    pub expr: Expr,
232    pub output_name: String,
233}
234
235impl GroupKey {
236    /// Name of the output column this key produces. Unqualified keys keep
237    /// their bare field name; qualified keys are emitted as `alias.field` so
238    /// HAVING and downstream projections can reference them consistently.
239    pub fn output_name(&self) -> String {
240        self.output_name.clone()
241    }
242}
243
244/// A join clause appended to a query's primary source.
245///
246/// Example syntax (Mission E1.1 parser accepts this; executor still errors):
247///   `User as u inner join Order as o on u.id = o.user_id filter o.total > 100`
248#[derive(Debug, Clone, PartialEq)]
249pub struct JoinClause {
250    pub kind: JoinKind,
251    pub source: String,
252    pub alias: Option<String>,
253    /// `on <expr>` — required for every kind except `Cross`.
254    pub on: Option<Expr>,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub enum JoinKind {
259    Inner,
260    LeftOuter,
261    RightOuter,
262    Cross,
263}
264
265#[derive(Debug, Clone, PartialEq)]
266pub struct ProjectionField {
267    pub alias: Option<String>,
268    pub expr: Expr,
269}
270
271/// Language-lab slice: a nested sub-query projection value, e.g.
272/// `orders: Order as o filter o.user_id = u.id { o.total, o.product_id }`.
273/// The filter must be a single equi-correlation predicate between the child
274/// alias and the outer alias (validated by the planner, which knows the
275/// outer alias); the parser stores it as a plain `Expr`.
276#[derive(Debug, Clone, PartialEq)]
277pub struct NestedQuery {
278    pub source: String,
279    pub alias: String,
280    /// Set when this nested query was written as a block link traversal
281    /// (`orders: u.orders { ... }`) rather than an explicit correlated scan.
282    /// When set, `source` is a placeholder and `filter` holds only the
283    /// residual child conditions; the correlation is resolved from the
284    /// persistent catalog at execution time.
285    pub via_link: Option<ViaLink>,
286    pub filter: Expr,
287    /// Per-parent ordering, applied to each parent's child bucket.
288    pub order: Option<OrderClause>,
289    /// Per-parent truncation, applied after ordering.
290    pub limit: Option<Expr>,
291    pub offset: Option<Expr>,
292    /// True when the source text wrote `offset` before `limit`. The plan
293    /// cache refuses to cache that form: its source literal order is
294    /// [offset, limit] while the substitution walk visits limit first.
295    pub offset_before_limit: bool,
296    pub fields: Vec<ProjectionField>,
297}
298
299#[derive(Debug, Clone, PartialEq)]
300pub struct OrderClause {
301    pub keys: Vec<OrderKey>,
302}
303
304#[derive(Debug, Clone, PartialEq)]
305pub struct OrderKey {
306    pub expr: Expr,
307    pub descending: bool,
308}
309
310#[derive(Debug, Clone, PartialEq)]
311pub struct InsertExpr {
312    pub target: String,
313    /// One assignment-block per row. Always contains at least one row;
314    /// `insert T { .. }` yields one, `insert T { .. }, { .. }` yields many.
315    pub rows: Vec<Vec<Assignment>>,
316    /// `true` when the statement ends with `returning` — the executor returns
317    /// the inserted rows (all columns) instead of a modified-count.
318    pub returning: bool,
319}
320
321#[derive(Debug, Clone, PartialEq)]
322pub struct UpdateExpr {
323    pub source: String,
324    /// Optional alias for the source (`Mem as m filter m.x = 1 update ...`).
325    /// Lets the planner resolve `m.col` qualifiers the same way reads do.
326    pub alias: Option<String>,
327    pub filter: Option<Expr>,
328    pub assignments: Vec<Assignment>,
329    /// `true` when the statement ends with `returning` — the executor returns
330    /// the post-update rows (all columns) instead of a modified-count.
331    pub returning: bool,
332}
333
334#[derive(Debug, Clone, PartialEq)]
335pub struct DeleteExpr {
336    pub source: String,
337    /// Optional alias for the source (`Mem as m filter m.x = 1 delete`).
338    /// Lets the planner resolve `m.col` qualifiers the same way reads do.
339    pub alias: Option<String>,
340    pub filter: Option<Expr>,
341    /// `true` when the statement ends with `returning` — the executor returns
342    /// the pre-delete rows (all columns) instead of a modified-count.
343    pub returning: bool,
344}
345
346#[derive(Debug, Clone, PartialEq)]
347pub struct Assignment {
348    pub field: String,
349    pub value: Expr,
350}
351
352#[derive(Debug, Clone, PartialEq)]
353/// `upsert User on .id { id := 1, name := "Alice" } [on conflict { name := "Alice" }]`
354pub struct UpsertExpr {
355    pub target: String,
356    pub key_column: String,
357    pub assignments: Vec<Assignment>,
358    /// Assignments to apply on conflict. If empty, all non-key assignments
359    /// from `assignments` are used as the update set.
360    pub on_conflict: Vec<Assignment>,
361}
362
363#[derive(Debug, Clone, PartialEq)]
364pub struct CreateTypeExpr {
365    pub name: String,
366    pub fields: Vec<FieldDef>,
367    /// `type X if not exists { ... }` — re-declaring an existing type is a
368    /// no-op instead of an error.
369    pub if_not_exists: bool,
370}
371
372#[derive(Debug, Clone, PartialEq)]
373pub struct FieldDef {
374    pub name: String,
375    pub type_name: String,
376    pub required: bool,
377    /// `true` when declared with the `unique` modifier — auto-creates a
378    /// unique B+Tree index on this column at table-create time.
379    pub unique: bool,
380    /// Literal default applied when an insert omits this column. `None` means
381    /// the column has no default (omitting it yields the empty set / null).
382    pub default: Option<Literal>,
383    /// `true` when declared `auto` — an integer column whose value is assigned
384    /// from a monotonic per-table sequence when an insert omits it.
385    pub auto: bool,
386}
387
388#[derive(Debug, Clone, PartialEq)]
389pub struct AggregateExpr {
390    pub function: AggFunc,
391    pub argument: Option<Expr>,
392    pub mode: AggregateMode,
393}
394
395#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
396pub enum AggregateMode {
397    Symmetric,
398    Raw,
399}
400
401#[derive(Debug, Clone, Copy, PartialEq)]
402pub enum AggFunc {
403    Count,
404    CountDistinct,
405    Avg,
406    Sum,
407    Min,
408    Max,
409}
410
411/// Window function identifier.
412#[derive(Debug, Clone, Copy, PartialEq)]
413pub enum WindowFunc {
414    RowNumber,
415    Rank,
416    DenseRank,
417    Sum,
418    Avg,
419    Count,
420    Min,
421    Max,
422}
423
424/// Scalar (non-aggregate) function — operates on single values.
425#[derive(Debug, Clone, Copy, PartialEq)]
426pub enum ScalarFn {
427    Upper,
428    Lower,
429    Length,
430    Trim,
431    Substring, // substring(expr, start, len) — 1-indexed
432    Concat,    // concat(expr, expr, ...) — variadic
433    // Math
434    Abs,
435    Round, // round(expr) or round(expr, decimals)
436    Ceil,
437    Floor,
438    Sqrt,
439    Pow, // pow(base, exponent)
440    // Date/time
441    Now,      // now() — returns current unix timestamp in microseconds
442    Extract,  // extract("year"|"month"|..., datetime_expr)
443    DateAdd,  // date_add(datetime_expr, amount, "unit")
444    DateDiff, // date_diff(dt1, dt2, "unit")
445    // JSON
446    JsonType, // json_type(expr) — 'null'|'string'|'number'|'bool'|'object'|'array', Empty when missing
447    JsonText, // json_text(expr) — SQL ->> text, canonical JSON for object/array
448}
449
450/// Target type for CAST expressions.
451#[derive(Debug, Clone, Copy, PartialEq)]
452pub enum CastType {
453    Int,
454    Float,
455    Str,
456    Bool,
457    DateTime,
458    Uuid,
459    Bytes,
460}
461
462/// A single step in a JSON `->` path expression.
463///
464/// This is the query-crate's OWNED path-segment type (distinct from
465/// [`powdb_storage::pj1::PathSeg`], which borrows `&str` keys). Segments are
466/// STRUCTURAL: they are part of the query shape, never literal slots, so the
467/// plan cache hashes them into the canonical token stream and neither counts
468/// nor substitutes them (see `plan_cache::count_expr` / `substitute_expr`).
469#[derive(Debug, Clone, PartialEq, Eq, Hash)]
470pub enum PathSeg {
471    /// Object member access: `->author` or `->"weird key!"`.
472    Key(String),
473    /// Array element access: `->0`.
474    Index(u32),
475}
476
477/// Expressions.
478#[derive(Debug, Clone, PartialEq)]
479pub enum Expr {
480    Field(String),
481    /// A table-qualified field reference: `table.field` or `alias.field`.
482    /// Used by join queries to disambiguate columns that appear in multiple
483    /// sources. The single-table read path never emits this variant, so
484    /// existing fast paths keep matching `Expr::Field` unchanged.
485    QualifiedField {
486        qualifier: String,
487        field: String,
488    },
489    Literal(Literal),
490    Param(String),
491    BinaryOp(Box<Expr>, BinOp, Box<Expr>),
492    UnaryOp(UnaryOp, Box<Expr>),
493    FunctionCall(AggFunc, Box<Expr>, AggregateMode),
494    /// Scalar (non-aggregate) function call.
495    ScalarFunc(ScalarFn, Vec<Expr>),
496    Coalesce(Box<Expr>, Box<Expr>),
497    /// `expr in (val1, val2, ...)` or `expr not in (val1, val2, ...)`
498    InList {
499        expr: Box<Expr>,
500        list: Vec<Expr>,
501        negated: bool,
502    },
503    /// `expr [not] in (subquery)` — the subquery is a full QueryExpr
504    /// that produces a single column.
505    InSubquery {
506        expr: Box<Expr>,
507        subquery: Box<QueryExpr>,
508        negated: bool,
509    },
510    /// `[not] exists (subquery)` — the subquery is a full QueryExpr.
511    /// Currently uncorrelated only: the executor runs the subquery once
512    /// before the scan loop and replaces this node with a Bool literal.
513    ExistsSubquery {
514        subquery: Box<QueryExpr>,
515        negated: bool,
516    },
517    /// CASE WHEN ... THEN ... [ELSE ...] END
518    Case {
519        whens: Vec<(Box<Expr>, Box<Expr>)>,
520        else_expr: Option<Box<Expr>>,
521    },
522    /// Window function: `func(args) over (partition ... order ...)`
523    Window {
524        function: WindowFunc,
525        args: Vec<Expr>,
526        mode: AggregateMode,
527        partition_by: Vec<Expr>,
528        order_by: Vec<OrderKey>,
529    },
530    /// Type cast: `cast(expr, "int")` or `cast(expr, "str")` etc.
531    Cast(Box<Expr>, CastType),
532    /// A runtime-materialized literal carrying a concrete Value. Produced only
533    /// after planning, by subquery/correlated substitution and by the
534    /// typed-literal coercion pass, for values that have no Literal form (NULL,
535    /// datetime, uuid, bytes); never emitted by the parser/canonicalizer.
536    ValueLit(Value),
537    /// The `null` literal — produces `Value::Empty`.
538    Null,
539    /// A nested sub-query projection value (language-lab slice). Only valid
540    /// directly inside a projection field; the planner turns it into a
541    /// `NestedProject` plan node and it never reaches expression evaluation.
542    NestedQuery(Box<NestedQuery>),
543    /// A scalar link traversal projection value: `o.user.name` or the
544    /// multi-hop `o.user.company.name`. Only valid directly inside a
545    /// projection field on an aliased scan; the planner turns it into a
546    /// `NestedProjectField::Link` and it never reaches expression evaluation.
547    LinkPath {
548        /// The outer scan alias (`o`).
549        outer_alias: String,
550        /// One or more declared to-one link names, in traversal order
551        /// (`["user"]`, or `["user", "company"]` for multi-hop).
552        links: Vec<String>,
553        /// The target column read at the end of the chain (`name`).
554        column: String,
555    },
556    /// A JSON path access: `base->seg->seg...`. `base` is restricted at parse
557    /// time to `Field`, `QualifiedField`, or (nested) `JsonPath`. Evaluating it
558    /// walks the base `Value::Json` document and scalarizes the addressed node
559    /// (see `eval_expr`); the `segments` are structural (see [`PathSeg`]).
560    JsonPath {
561        base: Box<Expr>,
562        segments: Vec<PathSeg>,
563    },
564}
565
566/// Versioned structural identity for a query JSON path. Unlike the stored
567/// table-local form, this retains a qualified root until binding resolves it.
568#[derive(Debug, Clone, PartialEq, Eq, Hash)]
569pub struct JsonPathIdentityV1 {
570    pub root: JsonPathRootV1,
571    pub segments: Vec<PathSeg>,
572}
573
574#[derive(Debug, Clone, PartialEq, Eq, Hash)]
575pub enum JsonPathRootV1 {
576    Unqualified(String),
577    Qualified { qualifier: String, field: String },
578}
579
580impl JsonPathIdentityV1 {
581    pub const VERSION: u8 = 1;
582
583    pub fn from_expr(expr: &Expr) -> Option<Self> {
584        let Expr::JsonPath { base, segments } = expr else {
585            return None;
586        };
587        let root = match base.as_ref() {
588            Expr::Field(field) => JsonPathRootV1::Unqualified(field.clone()),
589            Expr::QualifiedField { qualifier, field } => JsonPathRootV1::Qualified {
590                qualifier: qualifier.clone(),
591                field: field.clone(),
592            },
593            _ => return None,
594        };
595        Some(Self {
596            root,
597            segments: segments.clone(),
598        })
599    }
600
601    pub fn canonical_bytes(&self) -> Vec<u8> {
602        let mut out = vec![Self::VERSION];
603        match &self.root {
604            JsonPathRootV1::Unqualified(field) => {
605                out.push(1);
606                push_identity_str(&mut out, field);
607            }
608            JsonPathRootV1::Qualified { qualifier, field } => {
609                out.push(2);
610                push_identity_str(&mut out, qualifier);
611                push_identity_str(&mut out, field);
612            }
613        }
614        out.extend_from_slice(&(self.segments.len() as u32).to_le_bytes());
615        for segment in &self.segments {
616            match segment {
617                PathSeg::Key(key) => {
618                    out.push(1);
619                    push_identity_str(&mut out, key);
620                }
621                PathSeg::Index(index) => {
622                    out.push(2);
623                    out.extend_from_slice(&index.to_le_bytes());
624                }
625            }
626        }
627        out
628    }
629
630    pub fn canonical_text(&self) -> String {
631        let mut out = match &self.root {
632            JsonPathRootV1::Unqualified(field) => format!("v1:.{field}"),
633            JsonPathRootV1::Qualified { qualifier, field } => {
634                format!("v1:{qualifier}.{field}")
635            }
636        };
637        for segment in &self.segments {
638            match segment {
639                PathSeg::Key(key) => {
640                    out.push_str("->\"");
641                    push_identity_escaped(&mut out, key);
642                    out.push('"');
643                }
644                PathSeg::Index(index) => {
645                    out.push_str("->");
646                    out.push_str(&index.to_string());
647                }
648            }
649        }
650        out
651    }
652
653    /// Bind an unqualified root, or a qualified root matching `qualifier`, to
654    /// the storage-owned table-local identity used by future expression-index
655    /// catalog entries.
656    pub fn bind_table_local(
657        &self,
658        qualifier: Option<&str>,
659    ) -> Option<powdb_storage::stored_json_path::StoredJsonPathV1> {
660        use powdb_storage::stored_json_path::{StoredJsonPathSegmentV1, StoredJsonPathV1};
661        let column = match &self.root {
662            JsonPathRootV1::Unqualified(field) => field.clone(),
663            JsonPathRootV1::Qualified {
664                qualifier: actual,
665                field,
666            } if qualifier == Some(actual.as_str()) => field.clone(),
667            JsonPathRootV1::Qualified { .. } => return None,
668        };
669        Some(StoredJsonPathV1::new(
670            column,
671            self.segments
672                .iter()
673                .map(|segment| match segment {
674                    PathSeg::Key(key) => StoredJsonPathSegmentV1::Key(key.clone()),
675                    PathSeg::Index(index) => StoredJsonPathSegmentV1::Index(*index),
676                })
677                .collect(),
678        ))
679    }
680}
681
682/// The column name an unaliased projection field carries: the field's own
683/// source text, rendered back from the AST.
684///
685/// Every projection path used to inline its own `Expr::Field(name) => name, _
686/// => "?"`, so `D { .j->a, .j->b }` came back as two columns both called `?`
687/// and a caller reading by name could not tell them apart or even say which
688/// one it had. Rendering the expression names each one after what it computes,
689/// the way an alias would, and the shapes still not rendered (a subquery, a
690/// window, a case) keep `?` rather than a name that lies.
691pub fn projection_output_name(expr: &Expr) -> String {
692    match expr {
693        // A bare column keeps its own name, with no leading dot: that is the
694        // name every caller already reads it by.
695        Expr::Field(name) => name.clone(),
696        Expr::QualifiedField { qualifier, field } => format!("{qualifier}.{field}"),
697        other => render_expr(other).unwrap_or_else(|| "?".into()),
698    }
699}
700
701fn render_expr(expr: &Expr) -> Option<String> {
702    Some(match expr {
703        Expr::Field(name) => format!(".{name}"),
704        Expr::QualifiedField { qualifier, field } => format!("{qualifier}.{field}"),
705        Expr::Literal(literal) => render_literal(literal),
706        Expr::Null => "null".into(),
707        Expr::Param(name) => format!("${name}"),
708        Expr::BinaryOp(left, op, right) => format!(
709            "{} {} {}",
710            render_expr(left)?,
711            render_binop(*op),
712            render_expr(right)?
713        ),
714        Expr::UnaryOp(op, inner) => match op {
715            UnaryOp::Not => format!("not {}", render_expr(inner)?),
716            UnaryOp::Exists => format!("exists {}", render_expr(inner)?),
717            UnaryOp::NotExists => format!("not exists {}", render_expr(inner)?),
718            UnaryOp::IsNull => format!("{} is null", render_expr(inner)?),
719            UnaryOp::IsNotNull => format!("{} is not null", render_expr(inner)?),
720        },
721        Expr::FunctionCall(func, argument, _) => match func {
722            AggFunc::CountDistinct => format!("count(distinct {})", render_expr(argument)?),
723            _ => format!("{}({})", render_agg(*func), render_expr(argument)?),
724        },
725        Expr::ScalarFunc(func, args) => {
726            let mut rendered = Vec::with_capacity(args.len());
727            for arg in args {
728                rendered.push(render_expr(arg)?);
729            }
730            format!("{}({})", render_scalar_fn(*func), rendered.join(", "))
731        }
732        Expr::Coalesce(left, right) => {
733            format!("coalesce({}, {})", render_expr(left)?, render_expr(right)?)
734        }
735        Expr::Cast(inner, target) => format!("{}({})", render_cast(*target), render_expr(inner)?),
736        Expr::JsonPath { base, segments } => {
737            let mut out = render_expr(base)?;
738            for segment in segments {
739                out.push_str("->");
740                match segment {
741                    PathSeg::Key(key) => out.push_str(key),
742                    PathSeg::Index(index) => out.push_str(&index.to_string()),
743                }
744            }
745            out
746        }
747        _ => return None,
748    })
749}
750
751fn render_literal(literal: &Literal) -> String {
752    match literal {
753        Literal::Int(v) => v.to_string(),
754        Literal::Float(v) => v.to_string(),
755        Literal::Bool(v) => v.to_string(),
756        Literal::String(s) => format!("\"{s}\""),
757    }
758}
759
760fn render_binop(op: BinOp) -> &'static str {
761    match op {
762        BinOp::Eq => "=",
763        BinOp::Neq => "!=",
764        BinOp::Lt => "<",
765        BinOp::Gt => ">",
766        BinOp::Lte => "<=",
767        BinOp::Gte => ">=",
768        BinOp::And => "and",
769        BinOp::Or => "or",
770        BinOp::Add => "+",
771        BinOp::Sub => "-",
772        BinOp::Mul => "*",
773        BinOp::Div => "/",
774        BinOp::Like => "like",
775    }
776}
777
778fn render_agg(func: AggFunc) -> &'static str {
779    match func {
780        AggFunc::Count | AggFunc::CountDistinct => "count",
781        AggFunc::Avg => "avg",
782        AggFunc::Sum => "sum",
783        AggFunc::Min => "min",
784        AggFunc::Max => "max",
785    }
786}
787
788fn render_scalar_fn(func: ScalarFn) -> &'static str {
789    match func {
790        ScalarFn::Upper => "upper",
791        ScalarFn::Lower => "lower",
792        ScalarFn::Length => "length",
793        ScalarFn::Trim => "trim",
794        ScalarFn::Substring => "substring",
795        ScalarFn::Concat => "concat",
796        ScalarFn::Abs => "abs",
797        ScalarFn::Round => "round",
798        ScalarFn::Ceil => "ceil",
799        ScalarFn::Floor => "floor",
800        ScalarFn::Sqrt => "sqrt",
801        ScalarFn::Pow => "pow",
802        ScalarFn::Now => "now",
803        ScalarFn::Extract => "extract",
804        ScalarFn::DateAdd => "date_add",
805        ScalarFn::DateDiff => "date_diff",
806        ScalarFn::JsonType => "json_type",
807        ScalarFn::JsonText => "json_text",
808    }
809}
810
811fn render_cast(target: CastType) -> &'static str {
812    match target {
813        CastType::Int => "int",
814        CastType::Float => "float",
815        CastType::Str => "str",
816        CastType::Bool => "bool",
817        CastType::DateTime => "datetime",
818        CastType::Uuid => "uuid",
819        CastType::Bytes => "bytes",
820    }
821}
822
823pub fn expression_output_name(expr: &Expr) -> String {
824    match expr {
825        Expr::Field(field) => field.clone(),
826        Expr::QualifiedField { qualifier, field } => format!("{qualifier}.{field}"),
827        Expr::JsonPath { .. } => JsonPathIdentityV1::from_expr(expr)
828            .map(|path| path.canonical_text())
829            .unwrap_or_else(|| "?".into()),
830        _ => "?".into(),
831    }
832}
833
834fn push_identity_str(out: &mut Vec<u8>, value: &str) {
835    out.extend_from_slice(&(value.len() as u32).to_le_bytes());
836    out.extend_from_slice(value.as_bytes());
837}
838
839fn push_identity_escaped(out: &mut String, value: &str) {
840    for ch in value.chars() {
841        match ch {
842            '"' => out.push_str("\\\""),
843            '\\' => out.push_str("\\\\"),
844            '\n' => out.push_str("\\n"),
845            '\r' => out.push_str("\\r"),
846            '\t' => out.push_str("\\t"),
847            c if c <= '\u{1f}' => {
848                use std::fmt::Write;
849                let _ = write!(out, "\\u{:04x}", c as u32);
850            }
851            c => out.push(c),
852        }
853    }
854}
855
856#[derive(Debug, Clone, PartialEq)]
857pub enum Literal {
858    Int(i64),
859    Float(f64),
860    String(String),
861    Bool(bool),
862}
863
864/// A bound value supplied for a `$N` placeholder in
865/// [`crate::parser::parse_with_params`].
866///
867/// Unlike [`Literal`], this carries a `Null` variant so a parameter can
868/// bind PowQL `null` (substituted as `Token::Null`, not a string). Values
869/// are turned into literal *tokens* before parsing, so an injection-shaped
870/// string is inert data — it can never change the query's shape.
871#[derive(Debug, Clone, PartialEq)]
872pub enum ParamValue {
873    Null,
874    Int(i64),
875    Float(f64),
876    Bool(bool),
877    Str(String),
878}
879
880#[derive(Debug, Clone, Copy, PartialEq)]
881pub enum BinOp {
882    Eq,
883    Neq,
884    Lt,
885    Gt,
886    Lte,
887    Gte,
888    And,
889    Or,
890    Add,
891    Sub,
892    Mul,
893    Div,
894    Like,
895}
896
897#[derive(Debug, Clone, Copy, PartialEq)]
898pub enum UnaryOp {
899    Not,
900    Exists,
901    NotExists,
902    IsNull,
903    IsNotNull,
904}
905
906#[cfg(test)]
907mod json_path_identity_tests {
908    use super::*;
909
910    #[test]
911    fn canonical_path_identity_goldens() {
912        let unquoted = JsonPathIdentityV1 {
913            root: JsonPathRootV1::Unqualified("data".into()),
914            segments: vec![PathSeg::Key("author".into())],
915        };
916        let quoted = JsonPathIdentityV1 {
917            root: JsonPathRootV1::Unqualified("data".into()),
918            segments: vec![PathSeg::Key("author".into())],
919        };
920        assert_eq!(unquoted, quoted);
921        assert_eq!(unquoted.canonical_text(), "v1:.data->\"author\"");
922        assert_eq!(
923            unquoted.canonical_bytes(),
924            vec![
925                1, 1, 4, 0, 0, 0, b'd', b'a', b't', b'a', 1, 0, 0, 0, 1, 6, 0, 0, 0, b'a', b'u',
926                b't', b'h', b'o', b'r',
927            ]
928        );
929
930        let key_zero = JsonPathIdentityV1 {
931            root: JsonPathRootV1::Unqualified("data".into()),
932            segments: vec![PathSeg::Key("0".into())],
933        };
934        let index_zero = JsonPathIdentityV1 {
935            root: JsonPathRootV1::Unqualified("data".into()),
936            segments: vec![PathSeg::Index(0)],
937        };
938        assert_ne!(key_zero.canonical_bytes(), index_zero.canonical_bytes());
939
940        let unicode = JsonPathIdentityV1 {
941            root: JsonPathRootV1::Unqualified("data".into()),
942            segments: vec![PathSeg::Key("café\n\"x\\y".into())],
943        };
944        assert_eq!(unicode.canonical_text(), "v1:.data->\"café\\n\\\"x\\\\y\"");
945    }
946
947    #[test]
948    fn qualified_root_binding_is_explicit() {
949        let qualified = JsonPathIdentityV1 {
950            root: JsonPathRootV1::Qualified {
951                qualifier: "p".into(),
952                field: "data".into(),
953            },
954            segments: vec![PathSeg::Key("age".into())],
955        };
956        assert!(qualified.bind_table_local(Some("other")).is_none());
957        let stored = qualified
958            .bind_table_local(Some("p"))
959            .expect("matching root");
960        assert_eq!(stored.canonical_text(), "v1:.data->\"age\"");
961    }
962}