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}