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