Skip to main content

powdb_query/
ast.rs

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