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