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