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}