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