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