Skip to main content

pylon_core/ir/
mod.rs

1//
2// This source file is part of the Pylon open source project.
3//
4// Copyright (c) 2026 Jaldis B.V.
5//
6// Licensed under the MIT OR Apache-2.0 license (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10//     https://opensource.org/licenses/MIT
11//     https://www.apache.org/licenses/LICENSE-2.0
12//
13// Unless required by applicable law or agreed to in writing, software
14// distributed under the License is distributed on an "AS IS" BASIS,
15// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16// See the License for the specific language governing permissions and
17// limitations under the License.
18//
19
20// Pylon IR — a typed, resolved query plan produced by compiling a PyQL AST
21// against a SchemaDescriptor.
22//
23// Deliberately simple: no set-semantics wrappers, no PathId deduplication.
24// Every node is already resolved to a concrete table/column.
25
26mod compiler;
27pub mod tags;
28
29pub use compiler::column_default_sql;
30pub use compiler::compile;
31pub use compiler::compile_computed_in_type;
32pub use compiler::compile_constraint_expr;
33pub use compiler::compile_expr_in_type;
34pub use compiler::compile_expr_unaliased;
35pub use compiler::compile_inlined_default;
36pub use compiler::compile_scalar_default;
37pub use compiler::compile_scalar_default_typed;
38pub use compiler::compile_trigger_handler;
39pub use compiler::compile_with_config;
40pub use compiler::default_blocker;
41pub(crate) use compiler::infer_ir_type;
42pub use compiler::inlined_pointer_defaults;
43pub use compiler::pg_type_to_pyql;
44pub(crate) use compiler::types_compatible;
45pub use compiler::{GLOBALS_ARG, compile_fn_body, functions_needing_globals};
46pub use compiler::{RewriteAssignment, compile_rewrite_assignments};
47
48use crate::parse::ast::{BinOpKind, UnaryOpKind};
49use std::collections::HashMap;
50
51// ── Session config ───────────────────────────────────────────────────────────────
52
53/// User-configurable session options affecting compile-time validation.
54/// Threaded from the client's `with_config()`
55/// (Python) via the ASGI `/api/query` "config" body field; see
56/// `pylon/config_options.py` for the full registry of known option names/
57/// defaults exposed to the frontend. `compile()` (the 2-arg convenience form,
58/// used throughout this crate's own tests and schema-time compilation, which
59/// never touches a live client-supplied config) always uses `default()`;
60/// `compile_with_config()` is the real entry point a live query request uses.
61#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
62pub struct SessionConfig {
63    /// When false (the default), an INSERT that explicitly assigns a value
64    /// to a primary-key ("id") property is a compile error. An UPDATE never
65    /// allows assigning `id`, regardless of this flag.
66    pub allow_user_specified_id: bool,
67}
68
69// ── Top-level statement ─────────────────────────────────────────────────────────
70
71#[derive(Debug, Clone)]
72pub enum IrStmt {
73    /// A SELECT — schema-bound (`select Type { .. }`), free (`select {1,2,3}`,
74    /// a set/tuple/free-object literal), or a mix via UNION — no distinction
75    /// at this level; see `IrSelect::rows`/`IrRowSource`.
76    Select(IrSelect),
77    /// A flat SELECT produced by absolute path traversal: `select TypeName.link.prop`.
78    PathSelect(IrPathSelect),
79    Insert(IrInsert),
80    Update(IrUpdate),
81    Delete(IrDelete),
82    /// `for var in iterator union body`
83    For(IrFor),
84    /// `group Type [shape] [using alias := expr, ...] by key, ...`
85    Group(IrGroup),
86    /// `select fn(args) { shape }` — a SELECT driven by a user-defined object-returning function.
87    FunctionSelect(IrFunctionSelect),
88    /// `select vector::search(Type, $vec) { object { … }, distance }` — pgvector similarity search.
89    VectorSearch(IrVectorSearch),
90    /// `select fts::search(Type, $query) { object { … }, score }` — full-text search.
91    FtsSearch(IrFtsSearch),
92    /// `a.id union b.id` — scalar sets concatenated, each operand a statement
93    /// yielding one value per row.
94    ScalarUnion(Vec<IrStmt>),
95}
96
97// ── FOR LOOP ─────────────────────────────────────────────────────────────────────
98
99#[derive(Debug, Clone)]
100pub struct IrFor {
101    pub var_name: String,
102    pub iterator: IrForIterator,
103    pub body: Box<IrStmt>,
104    /// WITH bindings the body declared. They are re-evaluated per iteration
105    /// (a binding may read the loop variable), so they belong inside the
106    /// body's own scope rather than the enclosing statement's WITH clause.
107    pub body_ctes: Vec<IrCteDef>,
108}
109
110#[derive(Debug, Clone)]
111pub enum IrForIterator {
112    /// Set literal of scalar values → SQL VALUES clause.
113    Values { exprs: Vec<IrExpr>, pg_type: String },
114    /// A derived set — `for x in (select Item.label)`. Emitted as a
115    /// one-column relation (column `v`, the same name the `VALUES` form
116    /// uses) that the loop body cross-joins against, so the body runs once
117    /// per row rather than once for the whole set.
118    Query {
119        stmt: Box<IrStmt>,
120        /// True when the statement yields scalars. When false it yields
121        /// objects and the loop variable binds their `id`.
122        scalar: bool,
123    },
124    /// A set-returning call — `for line in array_unpack(<array<json>>$lines)`.
125    /// Kept apart from `Values` because PostgreSQL forbids a set-returning
126    /// function inside `VALUES`; it goes in a select list instead.
127    SetReturning { expr: IrExpr, pg_type: String },
128}
129
130// ── GROUP ─────────────────────────────────────────────────────────────────────────
131
132#[derive(Debug, Clone)]
133pub struct IrGroup {
134    pub source: IrSource,
135    /// Pointers projected into each element of the `elements` array.
136    pub shape: Vec<IrShapePointer>,
137    /// Ordered list of (key_name, key_expr) — what we GROUP BY.
138    pub keys: Vec<(String, IrExpr)>,
139    /// Restricts which rows are grouped (a `WHERE`, applied before grouping).
140    pub filter: Option<IrExpr>,
141    /// Orders the elements *within* each group.
142    pub order_by: Vec<IrSort>,
143    /// Caps the elements within each group — "the newest row per key".
144    /// Emitted as a `row_number()` window partitioned by the keys, since a
145    /// plain `LIMIT` would cut whole groups instead.
146    pub offset: Option<IrExpr>,
147    pub limit: Option<IrExpr>,
148    pub output: IrGroupOutput,
149}
150
151#[derive(Debug, Clone)]
152pub struct IrGroupProjection {
153    pub pointers: Vec<IrShapePointer>,
154    /// Orders and caps the groups themselves, not their elements.
155    pub order_by: Vec<IrSort>,
156    pub offset: Option<IrExpr>,
157    pub limit: Option<IrExpr>,
158}
159
160/// What a group yields: the `{ key, grouping, elements }` rows themselves,
161/// a shape read off each of them, or the elements a `for` over the groups
162/// picks out of each one.
163#[derive(Debug, Clone)]
164pub enum IrGroupOutput {
165    Groups,
166    /// `select (group … by k) { n := count(.elements) }` — one row per key,
167    /// each pointer compiled over the group's rows so an aggregate reads the
168    /// whole group.
169    Projection(Box<IrGroupProjection>),
170    /// `for g in (group …) union (select g.elements … limit 1)` — the
171    /// elements themselves, `offset`/`limit` taken per key.
172    Elements,
173}
174
175// ── PATH SELECT (type-rooted path traversal) ────────────────────────────────────
176
177/// `select Person.company.name` — a flat SELECT that starts at a root type and
178/// follows links before projecting a final scalar column or object id.
179#[derive(Debug, Clone)]
180pub struct IrPathSelect {
181    pub root: IrSource,
182    pub joins: Vec<IrPathJoin>,
183    pub result: IrPathResult,
184    pub filter: Option<IrExpr>,
185    pub order_by: Vec<IrSort>,
186    pub offset: Option<IrExpr>,
187    pub limit: Option<IrExpr>,
188    pub distinct: bool,
189    /// Non-empty when the root is a polymorphic (abstract+materialized) type.
190    /// The FROM clause uses a UNION ALL of these instead of the root table directly.
191    pub poly_implementors: Vec<IrPolyImplementor>,
192}
193
194#[derive(Debug, Clone)]
195pub enum IrPathJoin {
196    /// Traverse a single (FK) link.
197    Single {
198        source_alias: String,
199        fk_col: String,
200        target: IrSource,
201    },
202    /// Traverse a multi-link via a junction table.
203    Multi {
204        source_alias: String,
205        junction_alias: String,
206        join: IrMultiLinkJoin,
207        target: IrSource,
208    },
209    /// Reverse of a single FK link: find owner rows whose FK column points to the current row.
210    BacklinkSingle {
211        source_alias: String,
212        fk_col: String,
213        target: IrSource,
214    },
215    /// An object-returning user function as the next row source —
216    /// `translation := latest(.id)` traversed as `.translation.value`.
217    /// Emitted as a `CROSS JOIN LATERAL`, since `args` reference the alias
218    /// the traversal has reached so far.
219    Function {
220        fn_module: String,
221        fn_name: String,
222        args: Vec<IrExpr>,
223        target: IrSource,
224    },
225    /// A computed pointer that carries its own FILTER/ORDER BY/LIMIT as the
226    /// next row source — `.translation.name`, where `translation` is
227    /// `select .translations filter … order by … limit 1`. Those modifiers
228    /// apply per source row, which a plain join cannot express, so the
229    /// computed's own traversal becomes a correlated `JOIN LATERAL`.
230    Lateral { inner: Box<IrPathSelect>, target: IrSource },
231    /// Reverse of a multi-link: traverse the junction table in reverse.
232    BacklinkMulti {
233        source_alias: String,
234        junction_alias: String,
235        junction_table: String,
236        module: String,
237        /// Junction column that points to the owner (forward source).
238        owner_col: String,
239        /// Junction column that points to the current row (forward target).
240        current_col: String,
241        target: IrSource,
242    },
243}
244
245impl IrPathJoin {
246    /// The row source this step reaches — every join kind has exactly one.
247    pub fn target(&self) -> &IrSource {
248        match self {
249            IrPathJoin::Single { target, .. }
250            | IrPathJoin::Multi { target, .. }
251            | IrPathJoin::BacklinkSingle { target, .. }
252            | IrPathJoin::BacklinkMulti { target, .. }
253            | IrPathJoin::Function { target, .. }
254            | IrPathJoin::Lateral { target, .. } => target,
255        }
256    }
257}
258
259#[derive(Debug, Clone)]
260pub enum IrPathResult {
261    /// Final result is a scalar expression (column ref or computed expr like EXISTS).
262    /// The second field carries a tuple-typed property's real member shape
263    /// (nominal or structural — see `resolve_property_tuple_shape`) so a bare
264    /// `select Type.tuple_property` gets the same rich `ShapeNode::NamedTuple`
265    /// a `Type { tuple_property }` shape query already does, instead of
266    /// falling back to an opaque `ShapeNode::Scalar`. `None` for anything
267    /// that isn't a bare tuple-typed property reference.
268    Scalar(IrExpr, Option<TupleCastShape>),
269    /// Final step is a link — return the linked objects with the given shape.
270    Object {
271        alias: String,
272        type_name: String,
273        shape: Vec<IrShapePointer>,
274    },
275}
276
277// ── FREE ROW EXPRESSIONS (set literals, tuples, free objects) ───────────────────
278
279#[derive(Debug, Clone)]
280pub enum IrFreeExpr {
281    /// A single scalar value: `SELECT {1}`, `SELECT 'hello'`, `SELECT func()`.
282    Scalar(IrExpr),
283    /// A free object: `SELECT { foo := 'bar', n := 42 }`.
284    FreeObject(Vec<(String, IrExpr)>),
285    /// A named tuple whose elements had to become a composite row rather than
286    /// jsonb, because one of them holds an object -- jsonb has no member kind
287    /// for an object (see `JsonMemberKind`), so encoding it there would
288    /// flatten it. Still a named tuple to the caller, not a free object.
289    NamedTupleRow(Vec<(String, IrExpr)>),
290    /// An anonymous tuple: `SELECT (1, 'x')`.
291    Tuple(Vec<IrExpr>),
292    /// A set-returning assert: `SELECT ROW(v) FROM unnest(_pylon.fn(ARRAY(inner))) v`.
293    /// Used for `assert_exists` and `assert_distinct` which pass through the set.
294    AssertSet {
295        fn_name: String,
296        inner: Box<IrArraySource>,
297        /// `message := …` — raised in place of the assert's own text.
298        message: Option<IrExpr>,
299    },
300    /// Pass all rows from a scalar CTE through: `SELECT "result" FROM "cte_name"`.
301    CtePassthrough(String),
302}
303
304/// Source for `ARRAY(SELECT scalar FROM ...)` — used by assert functions.
305#[derive(Debug, Clone)]
306pub enum IrArraySource {
307    /// Inner is a regular schema SELECT; first scalar pointer is the array element.
308    Select(IrSelect),
309    /// Inner is a path traversal SELECT; scalar result is the array element.
310    /// An object-returning function's rows, aggregated whole -- a computed
311    /// pointer declared as `fn(.id)` asks for the objects, not one column.
312    ObjectFunction(Box<IrFunctionSelect>),
313    /// A bound select whose rows are objects, aggregated whole rather than
314    /// reduced to one column the way `Select` is -- `(select Post filter …) {
315    /// title }` asked for the objects.
316    ObjectSelect(Box<IrSelect>),
317    PathSelect(Box<IrPathSelect>),
318    /// `(select (group …) { … })` — one free object per group.
319    Group(Box<IrGroup>),
320    /// One column of whatever rows a statement produces — the only way to
321    /// reach a `for … union`'s set, which has no single source to read from.
322    StmtColumn {
323        stmt: Box<IrStmt>,
324        column: String,
325    },
326    /// `ARRAY(SELECT expr FROM source)` — cross-scope type-is iteration.
327    RawExpr {
328        source: IrSource,
329        /// Non-empty when source is polymorphic; replaces `source` with a UNION ALL.
330        poly_implementors: Vec<IrPolyImplementor>,
331        poly_columns: Vec<String>,
332        expr: IrExpr,
333    },
334}
335
336// ── SELECT ──────────────────────────────────────────────────────────────────────
337
338#[derive(Debug, Clone)]
339pub struct IrSelect {
340    /// What each output row comes from. Almost always exactly one `Bound`
341    /// (a schema object) — more than one entry, or a `Free` entry, only
342    /// happens for a literal set/union (`select {1,2,3}`).
343    pub rows: Vec<IrRowSource>,
344    /// Only ever `Some` when `rows` is a single `Bound` — a free select can
345    /// never have a FILTER (enforced at compile time).
346    pub filter: Option<IrExpr>,
347    pub order_by: Vec<IrSort>,
348    pub offset: Option<IrExpr>,
349    pub limit: Option<IrExpr>,
350    pub distinct: bool,
351    /// When this SELECT wraps a DML statement (`SELECT (INSERT …) { … }`),
352    /// the inner DML is stored here and emitted as a CTE.
353    /// `None` for plain `SELECT Type { … }` and for any free select.
354    /// Only ever `Some` when `rows` is a single `Bound`.
355    pub dml_source: Option<Box<IrStmt>>,
356    /// True when this SELECT targets an interface type.
357    /// The SQL emitter builds a UNION ALL inline instead of hitting the view.
358    /// Only meaningful when `rows` is a single `Bound`.
359    pub polymorphic: bool,
360    /// Concrete implementors of the interface (populated when `polymorphic = true`).
361    pub poly_implementors: Vec<IrPolyImplementor>,
362    /// Interface column names used in the UNION ALL branches (e.g. `["id", "email"]`).
363    pub poly_columns: Vec<String>,
364    /// Trailing `FOR UPDATE`/`FOR SHARE`/... row-locking clause. Validated
365    /// at compile time (`Compiler::compile_select`) to only ever be `Some`
366    /// on a single schema-bound, non-polymorphic, non-DML-wrapped,
367    /// non-`DISTINCT` row source — the same shape Postgres itself requires
368    /// output rows to map 1:1 to physical table rows for locking to make
369    /// sense.
370    pub lock: Option<IrLockClause>,
371}
372
373#[derive(Debug, Clone)]
374pub struct IrLockClause {
375    pub strength: IrLockStrength,
376    pub wait: IrLockWait,
377}
378
379#[derive(Debug, Clone)]
380pub enum IrLockStrength {
381    Update,
382    NoKeyUpdate,
383    Share,
384    KeyShare,
385}
386
387#[derive(Debug, Clone)]
388pub enum IrLockWait {
389    Block,
390    NoWait,
391    SkipLocked,
392}
393
394/// One SELECT output row's source — either a real schema object (with a
395/// FROM clause and projected column shape) or a free literal expression
396/// (set/tuple/free-object/scalar). Replaces the former `IrSelect`/
397/// `IrFreeSelect` type-level split, which caused real bugs (the same logic
398/// reimplemented twice, independently, and drifting) before this merge.
399#[derive(Debug, Clone)]
400pub enum IrRowSource {
401    Bound {
402        source: IrSource,
403        shape: Vec<IrShapePointer>,
404    },
405    Free(IrFreeExpr),
406}
407
408impl IrSelect {
409    /// The overwhelming-majority shape: one schema-bound row, no DML, no
410    /// polymorphism — what most correlated-subquery/EXISTS builders want.
411    pub fn schema_bound(source: IrSource, shape: Vec<IrShapePointer>, filter: Option<IrExpr>) -> Self {
412        IrSelect {
413            rows: vec![IrRowSource::Bound { source, shape }],
414            filter,
415            order_by: vec![],
416            offset: None,
417            limit: None,
418            distinct: false,
419            dml_source: None,
420            polymorphic: false,
421            poly_implementors: vec![],
422            poly_columns: vec![],
423            lock: None,
424        }
425    }
426}
427
428/// One concrete type that implements a polymorphic interface.
429#[derive(Debug, Clone)]
430pub struct IrPolyImplementor {
431    /// Qualified type name, e.g. `default::Individual`.
432    pub type_name: String,
433    /// PostgreSQL table name.
434    pub table: String,
435    /// Schema / module name.
436    pub module: String,
437}
438
439/// The relation being queried: a resolved type with its PostgreSQL table name
440/// and a unique alias for this occurrence in the query.
441#[derive(Debug, Clone)]
442pub struct IrSource {
443    /// Qualified type name, e.g. `catalog::Product`.
444    pub type_name: String,
445    /// PostgreSQL table name, e.g. `catalog_product`.
446    pub table: String,
447    /// Alias used in emitted SQL, e.g. `t0`.
448    pub alias: String,
449    /// Set when `type_name` is an interface and the rows have to be read from
450    /// the implementors rather than the interface's own view.
451    ///
452    /// The view carries only the interface's columns and no discriminator, so
453    /// reading through it tags every row as the interface and hydrates the
454    /// interface class instead of the concrete one. The SQL emitters get no
455    /// schema of their own, so whether a source needs fanning out can only be
456    /// decided here, while the IR is built.
457    ///
458    /// `None` everywhere it would be wrong or pointless — a DML target cannot
459    /// be a union, and a concrete type has nothing to fan out to.
460    pub poly: Option<IrPolyFanout>,
461}
462
463/// The implementors an interface-typed source reads from, and the columns each
464/// branch of the union projects.
465#[derive(Debug, Clone)]
466pub struct IrPolyFanout {
467    pub implementors: Vec<IrPolyImplementor>,
468    pub columns: Vec<String>,
469}
470
471/// A set-valued scalar computed pointer from cross-scope `TypeIs`.
472/// Emits `COALESCE(array_agg(ROW(bool_expr)::record), ARRAY[]::record[]) FROM source`.
473#[derive(Debug, Clone)]
474pub struct IrScalarSetPointer {
475    pub alias: String,
476    pub source: IrSource,
477    pub poly_implementors: Vec<IrPolyImplementor>,
478    pub poly_columns: Vec<String>,
479    pub bool_expr: IrExpr,
480}
481
482#[derive(Debug, Clone)]
483pub enum IrShapePointer {
484    Scalar(IrScalarPointer),
485    SingleLink(IrSingleLinkPointer),
486    MultiLink(IrMultiLinkPointer),
487    Computed(IrComputedPointer),
488    ScalarSet(IrScalarSetPointer),
489    /// `p := assert_exists(.prices { … })` — the pointer the argument compiles
490    /// to, with a cardinality check over the rows it aggregates. A wrapper
491    /// rather than a field on every variant, because any object pointer can
492    /// carry one.
493    Asserted(Box<IrAssertedPointer>),
494}
495
496#[derive(Debug, Clone)]
497pub struct IrAssertedPointer {
498    /// `assert_exists` or `assert_distinct`. Both are `_pylon` functions over
499    /// `anyarray` returning it unchanged; `assert_single` is excluded because
500    /// it returns the element rather than the array, so it has no place in the
501    /// boolean the check is read as.
502    pub fn_name: String,
503    pub inner: IrShapePointer,
504    /// The set the check reads, when it is not the one the pointer returns —
505    /// `(select assert_distinct(X) … limit 1)` asserts over all of X while
506    /// returning one row of it. `None` when the two coincide.
507    pub check: Option<IrShapePointer>,
508    /// `message := …` — raised in place of the assert's own text.
509    pub message: Option<IrExpr>,
510}
511
512impl IrShapePointer {
513    /// The output key this pointer contributes to its object.
514    pub fn alias(&self) -> &str {
515        match self {
516            IrShapePointer::Scalar(p) => &p.alias,
517            IrShapePointer::SingleLink(p) => &p.alias,
518            IrShapePointer::MultiLink(p) => &p.alias,
519            IrShapePointer::Computed(p) => &p.alias,
520            IrShapePointer::ScalarSet(p) => &p.alias,
521            IrShapePointer::Asserted(p) => p.inner.alias(),
522        }
523    }
524
525    /// True when the pointer stands for a set of objects — the only thing a
526    /// cardinality assert can wrap. A scalar pointer's assert is an ordinary
527    /// function call and belongs on the expression path.
528    pub fn is_object_pointer(&self) -> bool {
529        match self {
530            IrShapePointer::SingleLink(_) | IrShapePointer::MultiLink(_) => true,
531            IrShapePointer::Asserted(a) => a.inner.is_object_pointer(),
532            IrShapePointer::Computed(c) => match &c.expr {
533                IrExpr::ObjectSubquery(_) | IrExpr::ObjectPathSubquery(_) | IrExpr::ObjectPathUnion { .. } => true,
534                // A computed pointer aggregates its objects to an array; only
535                // the sources carrying a shape are objects, the rest reduce to
536                // a single column.
537                IrExpr::ArrayFromSelect(source) => match source.as_ref() {
538                    IrArraySource::ObjectFunction(_) | IrArraySource::ObjectSelect(_) => true,
539                    IrArraySource::PathSelect(ps) => matches!(ps.result, IrPathResult::Object { .. }),
540                    _ => false,
541                },
542                _ => false,
543            },
544            IrShapePointer::Scalar(_) | IrShapePointer::ScalarSet(_) => false,
545        }
546    }
547}
548
549/// A property column included in the output shape.
550#[derive(Debug, Clone)]
551pub struct IrScalarPointer {
552    /// The output key (what the user wrote, e.g. `name`).
553    pub alias: String,
554    /// The PostgreSQL column name on the source table.
555    pub column: String,
556    /// The PostgreSQL type string, e.g. `text`, `int8`.
557    pub pg_type: String,
558    /// `Some` when this property is a named-tuple type (nominal, via the
559    /// `__nt__:` `pg_type` marker, or structural, via `pylon.Tuple[...]`)
560    /// whose member shape is statically known — see `TupleCastShape`.
561    pub tuple_shape: Option<TupleCastShape>,
562    /// Source byte offset of the originating `ast::ShapeElement`, if this
563    /// pointer came from one written directly in the query (`analyze`'s
564    /// marker placement — see `analyze.rs`); `None` for a pointer synthesized
565    /// by the compiler itself (splat expansion, implicit `{ id }`, etc.).
566    pub marker_offset: Option<usize>,
567    /// True only for the `id` the compiler injects into a shape that did not
568    /// ask for one — see `Compiler::implicit_id_in_shapes`. The value decodes
569    /// like any other property; the flag exists so JSON output can leave it
570    /// out.
571    pub implicit_id: bool,
572}
573
574/// A single-valued link included in the output shape. Emitted as a
575/// correlated scalar subquery — either a plain FK-column correlation, or,
576/// for a junction-backed single link, the same join shape a multi-link
577/// uses, just implicitly capped to at most one row per source.
578#[derive(Debug, Clone)]
579pub struct IrSingleLinkPointer {
580    pub alias: String,
581    pub correlation: IrSingleLinkCorrelation,
582    /// Nested SELECT producing the linked object.
583    pub subquery: IrSelect,
584    /// Extra scalar columns from a junction through type (`@prop` syntax) —
585    /// always empty for `IrSingleLinkCorrelation::Fk`, since a plain FK
586    /// column has no junction row to read properties from.
587    pub link_properties: Vec<IrLinkProp>,
588    /// See `IrScalarPointer::marker_offset`.
589    pub marker_offset: Option<usize>,
590}
591
592#[derive(Debug, Clone)]
593pub enum IrSingleLinkCorrelation {
594    /// `parent.<fk_column> = target.<target_pk>`.
595    Fk {
596        /// FK column on the source table, e.g. `category_id`.
597        fk_column: String,
598        /// PK column on the target table, e.g. `id`.
599        target_pk: String,
600    },
601    /// Junction-backed — reuses `IrMultiLinkJoin`, the same join a
602    /// multi-link's own correlated subquery uses.
603    Junction {
604        join: IrMultiLinkJoin,
605        /// PK column on the target table, e.g. `id`.
606        target_pk: String,
607    },
608}
609
610/// A multi-valued link included in the output shape.
611/// Emitted as a correlated subquery using `array_agg(ROW(...)::record)`.
612#[derive(Debug, Clone)]
613pub struct IrMultiLinkPointer {
614    pub alias: String,
615    /// Join table or FK column identifying the source side.
616    pub join: IrMultiLinkJoin,
617    /// Nested SELECT producing the linked objects.
618    pub subquery: IrSelect,
619    /// Extra scalar columns from a junction table (`@prop` syntax).
620    pub link_properties: Vec<IrLinkProp>,
621    /// See `IrScalarPointer::marker_offset`.
622    pub marker_offset: Option<usize>,
623    /// Read as one object: `(select .data … limit 1)` is at most one row,
624    /// where a shape element on the link itself keeps the link's many.
625    pub single: bool,
626}
627
628/// A single link property pulled from a junction table.
629#[derive(Debug, Clone)]
630pub struct IrLinkProp {
631    /// The column name in the junction table and alias in the output.
632    pub name: String,
633}
634
635#[derive(Debug, Clone)]
636pub enum IrMultiLinkJoin {
637    /// Standard Pylon junction table (`{source_table}.{link_name}`) with
638    /// `source uuid` and `target uuid` columns.
639    Standard {
640        /// Unqualified junction table name, e.g. `person.posts`.
641        junction_table: String,
642        /// Schema module for quoting, e.g. `default`.
643        module: String,
644    },
645    /// Explicit through type with resolved FK columns.
646    Through {
647        /// PostgreSQL table of the junction type.
648        junction_table: String,
649        /// Schema module of the junction type.
650        module: String,
651        /// Column on the junction table that references the source type's id.
652        source_col: String,
653        /// Column on the junction table that references the target type's id.
654        target_col: String,
655    },
656    /// Reverse of a single (FK) link — the owner type's rows are correlated
657    /// directly by their FK column, no junction table involved. Still
658    /// many-valued (several owner rows can point at the same current row),
659    /// so this shares `IrMultiLinkPointer`'s array_agg-based emission
660    /// rather than the singular `IrShapePointer::Link` representation.
661    BacklinkFk {
662        /// FK column on the owner (sub-select) table, e.g. `org_id`.
663        fk_col: String,
664    },
665    /// Reverse of a multi-link — same junction table a forward `Standard`/
666    /// `Through` multi-link would use, but with the owner/current column
667    /// roles swapped: the sub-select's own rows correlate via `owner_col`,
668    /// the current (outer) row correlates via `current_col`.
669    BacklinkJunction {
670        junction_table: String,
671        module: String,
672        /// Junction column that references the owner (sub-select) type's id.
673        owner_col: String,
674        /// Junction column that references the current (outer) row's id.
675        current_col: String,
676    },
677}
678
679/// A computed pointer: an expression aliased to a name.
680#[derive(Debug, Clone)]
681pub struct IrComputedPointer {
682    pub alias: String,
683    pub expr: IrExpr,
684    /// See `IrScalarPointer::marker_offset`.
685    pub marker_offset: Option<usize>,
686}
687
688// ── Vector index enqueue ────────────────────────────────────────────────────────
689
690/// Identifies one vector index that needs an outbox row written when a
691/// mutation touches its source pointers.
692#[derive(Debug, Clone)]
693pub struct VectorEnqueueInfo {
694    /// Schema-qualified type name, e.g. `"default::Product"`.
695    pub type_name: String,
696    /// `None` = default index, `Some(name)` = named index.
697    pub index_name: Option<String>,
698}
699
700/// Identifies one OpenSearch- or Meilisearch-backed SearchIndex that needs
701/// an outbox row written (`Postgres`-backed indexes use a synchronously
702/// trigger-maintained tsvector column instead — never enqueued here).
703#[derive(Debug, Clone)]
704pub struct SearchEnqueueInfo {
705    pub type_name: String,
706    pub index_name: Option<String>,
707    /// `"index"` for insert/update, `"delete"` for delete.
708    pub operation: &'static str,
709    /// `OpenSearch` or `Meilisearch` — determines the outbox row's
710    /// `_pylon."IndexKind"` value.
711    pub backend: crate::schema::SearchBackend,
712}
713
714// ── INSERT ──────────────────────────────────────────────────────────────────────
715
716#[derive(Debug, Clone)]
717pub struct IrInsert {
718    /// `(insert …) if cond else {}` — the condition, folded into the insert
719    /// itself. Guarding a reader would not do: the insert is a data-modifying
720    /// CTE, which Postgres runs whether or not anything reads it, so the row
721    /// would be written even when the condition is false.
722    pub guard: Option<IrExpr>,
723    pub target: IrSource,
724    /// Each element is (column_name, value_expr).
725    pub assignments: Vec<(String, IrExpr)>,
726    pub unless_conflict: Option<IrConflict>,
727    /// Schema-defined rewrites that override or augment the inserted columns.
728    pub rewrites: Vec<IrRewrite>,
729    /// Shape to return after insert (for RETURNING clause).
730    pub returning: Vec<IrShapePointer>,
731    /// Vector indexes on this type that need outbox rows written.
732    pub enqueue_vector: Vec<VectorEnqueueInfo>,
733    /// OpenSearch-backed SearchIndexes that need outbox rows written.
734    pub enqueue_search: Vec<SearchEnqueueInfo>,
735    /// `tags := expr` / `tags += expr` in the insert shape — populates the
736    /// junction table for a multi-link at creation time. Unlike IrUpdate,
737    /// there's no clear/remove list: a brand-new row has no prior junction
738    /// rows to clear or remove from.
739    pub multi_link_appends: Vec<IrMultiLinkMutation>,
740    /// SQL for the target's own `id` default, when the insert has to name the
741    /// id rather than let the column default supply it — a nested insert in a
742    /// `for` body, where the junction row must be paired with the row this
743    /// iteration inserted and `RETURNING` guarantees no order to pair on.
744    pub id_default_sql: Option<String>,
745    /// DML (INSERT/UPDATE/DELETE) discovered nested inside a link-assignment
746    /// value — `author := (select (insert Person {...}) { id })` — hoisted
747    /// into its own `WITH` CTE ahead of this insert, since Postgres has no
748    /// way to run a nested INSERT inside a VALUES list otherwise. When
749    /// non-empty, the emitter switches this insert's own row source from
750    /// `VALUES (...)` to `SELECT ... FROM <cte>, ...`, and any assignment
751    /// value referencing one of these CTEs is a plain `ColumnRef` to its
752    /// `id` column. See `Compiler::compile_link_subquery`.
753    pub nested_ctes: Vec<IrCteDef>,
754}
755
756#[derive(Debug, Clone)]
757pub struct IrConflict {
758    /// Column expression for `ON CONFLICT (col)`. None → any conflict.
759    pub on: Option<IrExpr>,
760    /// `DO UPDATE SET` assignments. None → `DO NOTHING`.
761    pub do_update: Option<Vec<(String, IrExpr)>>,
762    /// Predicate from the ELSE UPDATE's own `filter`, emitted as the
763    /// `DO UPDATE … WHERE` clause. Postgres infers *which* row conflicts from
764    /// the ON CONFLICT target, but whether to update it at all is still the
765    /// filter's business — `unless conflict on .key else (update T filter
766    /// .expires_at < now() set { … })` must leave a live row alone.
767    pub do_update_where: Option<IrExpr>,
768}
769
770// ── UPDATE ──────────────────────────────────────────────────────────────────────
771
772#[derive(Debug, Clone)]
773pub struct IrUpdate {
774    pub target: IrSource,
775    pub filter: Option<IrExpr>,
776    pub assignments: Vec<(String, IrExpr)>,
777    /// Schema-defined rewrites appended to the SET clause.
778    pub rewrites: Vec<IrRewrite>,
779    pub returning: Vec<IrShapePointer>,
780    /// Vector indexes whose source pointers are touched by this update.
781    pub enqueue_vector: Vec<VectorEnqueueInfo>,
782    /// OpenSearch-backed SearchIndexes that need outbox rows written.
783    pub enqueue_search: Vec<SearchEnqueueInfo>,
784    /// Populated when updating an interface type; one entry per concrete implementor.
785    pub poly_implementors: Vec<IrPolyImplementor>,
786    /// The interface's own physical columns (properties + `{link}_id`) — the
787    /// only columns every implementor table is guaranteed to share, so a
788    /// per-implementor UNION ALL fan-out (see poly_implementors) can only
789    /// ever RETURNING this common subset, never `*`. Empty unless
790    /// poly_implementors is also non-empty.
791    pub poly_columns: Vec<String>,
792    /// `friends := {}` — DELETE all junction rows for this object.
793    pub multi_link_clears: Vec<IrMultiLinkClear>,
794    /// `friends := expr` — clear + insert (both lists share the same index).
795    pub multi_link_replaces: Vec<IrMultiLinkMutation>,
796    /// `friends += expr` — INSERT junction rows.
797    pub multi_link_appends: Vec<IrMultiLinkMutation>,
798    /// `friends -= expr` — DELETE specific junction rows.
799    pub multi_link_removals: Vec<IrMultiLinkMutation>,
800    /// Same as `IrInsert::nested_ctes` — hoisted nested DML from a link
801    /// assignment value in this update's own SET shape. Only supported
802    /// (see `Compiler::compile_update`) when this update has no multi-link
803    /// mutation, no interface fan-out, and nothing to enqueue; combining
804    /// those with a nested DML value is a compile error for now rather than
805    /// attempting to emit anything, until that combination is implemented.
806    pub nested_ctes: Vec<IrCteDef>,
807}
808
809#[derive(Debug, Clone)]
810pub struct IrMultiLinkClear {
811    pub junction_table: String,
812    pub module: String,
813    /// Column on the junction table referencing the source object's id.
814    pub source_col: String,
815}
816
817/// A multi-link mutation: insert or delete specific rows in a junction table.
818#[derive(Debug, Clone)]
819pub struct IrMultiLinkMutation {
820    pub junction_table: String,
821    pub module: String,
822    /// Column on the junction table referencing the source (updated) object's id.
823    pub source_col: String,
824    /// Column on the junction table referencing the target object's id.
825    pub target_col: String,
826    /// The set of target objects (plus any `@prop := expr` link-property
827    /// assignments attached to them).
828    pub values: IrMultiLinkValues,
829    /// True for a junction-backed single link — the junction table's own
830    /// unique constraint is `PRIMARY KEY (source)` alone (not `(source,
831    /// target)`), so an `ON CONFLICT` target naming both columns would
832    /// reference a constraint that doesn't exist.
833    pub single: bool,
834}
835
836/// The set of target object ids for a multi-link mutation, plus any
837/// link-property assignments (`@prop := expr`) attached directly to this
838/// source — e.g. `(select Tag filter .id = $a) { @weight := <float64>$w }`.
839/// A `Union` node's own `link_props` is always empty; each side carries its
840/// own instead (different targets in one `+=` can have different property
841/// values — see `emit_ml_append_cte` in sql/mod.rs for how the SQL layer
842/// reconciles a heterogeneous property-name set across union branches).
843#[derive(Debug, Clone)]
844pub struct IrMultiLinkValues {
845    pub source: IrMultiLinkValueSource,
846    pub link_props: Vec<(String, IrExpr)>,
847}
848
849/// How to obtain the target object IDs for a multi-link mutation.
850#[derive(Debug, Clone)]
851pub enum IrMultiLinkValueSource {
852    /// Reference to a named CTE: `FROM "cte_name"`.
853    CteRef(String),
854    /// A regular schema SELECT (use source table + filter to get ids).
855    Select(Box<IrSelect>),
856    /// A path-traversal SELECT (root + joins, final result is the id).
857    PathSelect(Box<IrPathSelect>),
858    /// `members := account_of_transaction()` — the rows an object-returning
859    /// function yields.
860    Function(Box<IrFunctionSelect>),
861    /// `a union b` — combine two target sets (e.g. distinct-typed adds, or an
862    /// existing-select add alongside a same-batch forward-referenced insert).
863    Union(Box<IrMultiLinkValues>, Box<IrMultiLinkValues>),
864    /// `prices := assert_distinct(…)` — the targets the inner value names,
865    /// checked before they become junction rows. `fn_name` is a `_pylon`
866    /// assert over `anyarray`; the ids go through it as a `uuid[]`, so the
867    /// inner value is evaluated once even when it contains an `insert`.
868    Asserted {
869        fn_name: String,
870        inner: Box<IrMultiLinkValues>,
871        /// `message := …` — raised in place of the assert's own text.
872        message: Option<IrExpr>,
873    },
874}
875
876// ── DELETE ──────────────────────────────────────────────────────────────────────
877
878#[derive(Debug, Clone)]
879pub struct IrDelete {
880    pub target: IrSource,
881    pub filter: Option<IrExpr>,
882    pub returning: Vec<IrShapePointer>,
883    /// Populated when deleting from an interface type; one entry per concrete implementor.
884    pub poly_implementors: Vec<IrPolyImplementor>,
885    /// The interface's own physical columns — see IrUpdate::poly_columns.
886    pub poly_columns: Vec<String>,
887    /// OpenSearch-backed SearchIndexes that need delete outbox rows written.
888    pub enqueue_search: Vec<SearchEnqueueInfo>,
889}
890
891// ── Expressions ─────────────────────────────────────────────────────────────────
892
893/// What the caller wants of a set operation: the set itself, whether it has
894/// anything in it, or an aggregate over its elements. Read as an array either
895/// way, `count()` would count the array as one value.
896#[derive(Debug, Clone, PartialEq, Eq)]
897pub enum SetOpMode {
898    Array,
899    Exists,
900    Aggregate(String),
901}
902
903#[derive(Debug, Clone, Copy, PartialEq, Eq)]
904pub enum SetOpKind {
905    Intersect,
906    Except,
907}
908
909impl SetOpKind {
910    pub fn sql(self) -> &'static str {
911        match self {
912            Self::Intersect => "INTERSECT",
913            Self::Except => "EXCEPT",
914        }
915    }
916}
917
918#[derive(Debug, Clone)]
919pub enum IrExpr {
920    /// A resolved column reference, e.g. `t0.name`.
921    ColumnRef {
922        alias: String,
923        column: String,
924        pg_type: String,
925    },
926    /// A positional query parameter `$N` (0-based index internally).
927    Param {
928        index: usize,
929    },
930    Literal(IrLiteral),
931    BinOp(Box<IrBinOp>),
932    UnaryOp(Box<IrUnaryOp>),
933    FunctionCall(IrFunctionCall),
934    TypeCast(Box<IrTypeCast>),
935    IfElse(Box<IrIfElse>),
936    /// A scalar subquery (used for computed pointers that are themselves selects).
937    Subquery(Box<IrSelect>),
938    /// An array literal: `[1, 2, 3]`.
939    Array(Vec<IrExpr>),
940    /// The empty set `{}` used as an assignment value — emits SQL `NULL`.
941    Null,
942    /// An aggregate function applied to an inline set literal `fn({e1, e2, ...})`.
943    /// Emits: `(SELECT fn_name(v) FROM (SELECT e1 UNION ALL ...) AS _set(v))`
944    AggOverSet {
945        fn_name: String,
946        schema: Option<String>,
947        elems: Vec<IrExpr>,
948    },
949    /// An aggregate function applied to a full SELECT query: `count(Person)` or `count((select Person))`.
950    /// Emits: `(SELECT fn_name(*) FROM (inner) _agg)`
951    AggOverQuery {
952        fn_name: String,
953        inner: Box<IrSelect>,
954    },
955    /// An aggregate applied to a whole `with` binding: `count(memberships)`.
956    /// Emits: `(SELECT fn_name(column) FROM "cte")`, `column` being `*` for an
957    /// object binding and the scalar column for a scalar one. Read through
958    /// `CteRef` instead, a binding holding more than one row aborts the query
959    /// ("more than one row returned by a subquery used as an expression").
960    AggOverCte {
961        fn_name: String,
962        cte: String,
963        column: Option<String>,
964    },
965    /// `exists memberships` over a whole `with` binding. Emits
966    /// `EXISTS(SELECT 1 FROM "cte")`, restricted to a non-null `column` for a
967    /// scalar binding, whose single-value form holds a NULL row when empty.
968    /// Read through `CteRef` instead, a binding of more than one row aborts
969    /// the query the same way `AggOverCte` describes.
970    ExistsOverCte {
971        cte: String,
972        column: Option<String>,
973    },
974    /// `array_unpack(a) intersect array_unpack(b)` — a set operation between
975    /// two set-valued expressions. Emits the array of what it yields, or
976    /// `EXISTS` over it when that is all the caller wanted.
977    SetOp {
978        op: SetOpKind,
979        left: Box<IrExpr>,
980        right: Box<IrExpr>,
981        mode: SetOpMode,
982    },
983    /// A reference to a named CTE used in expression context.
984    /// `scalar = true`  → emits `(SELECT "result" FROM "cte_name")`
985    /// `scalar = false` → emits `(SELECT "id"     FROM "cte_name")`
986    CteRef {
987        name: String,
988        scalar: bool,
989        /// The bound value's PostgreSQL type when it is a scalar, so
990        /// overload resolution and operator type-checking can see through
991        /// the binding — without it, `contains(xs, 'a')` over a `with`-bound
992        /// value matched no overload and needed an explicit cast.
993        pg_type: Option<String>,
994    },
995    /// A single-field access on a WITH-bound free object: `with x := { a
996    /// := 1 } select x.a`. The CTE body exposes each free-object field as
997    /// its own named column (alongside the whole-object `result` column
998    /// `CtePassthrough`/`CteRef` use) so this can reference it directly
999    /// rather than reconstructing/decoding the opaque `result` composite.
1000    /// Emits `(SELECT "field" FROM "name")`.
1001    CteFieldRef {
1002        name: String,
1003        field: String,
1004        /// The field's PostgreSQL type, so a call over `x.a` resolves the same
1005        /// overload a call over the value bound to `a` would.
1006        pg_type: Option<String>,
1007    },
1008    /// Reference to the current for-loop iterator variable.
1009    /// Emits `"_for_{name}"."v"`.
1010    ForVar {
1011        name: String,
1012        /// The type of one iteration's value — the iterator's element type, or
1013        /// `uuid` for a loop over objects, whose variable holds the row key.
1014        /// Carried for the same reason `CteFieldRef` carries one.
1015        pg_type: Option<String>,
1016    },
1017    /// `ARRAY(SELECT scalar FROM source [JOINs] [WHERE filter])`.
1018    /// Used as the array argument to `_pylon.assert_single/exists/distinct`.
1019    ArrayFromSelect(Box<IrArraySource>),
1020    /// A free SELECT read for its single value — `(select count(Visit) filter
1021    /// …)` in expression position. Inline rather than a CTE because the inner
1022    /// statement may read the enclosing row, which nothing ahead of the FROM
1023    /// clause can see.
1024    ScalarSubquery(Box<IrSelect>),
1025    /// An enum member access: `default::Gender.Female` → `'Female'::"default"."Gender"`.
1026    EnumLiteral {
1027        pg_type: String,
1028        variant: String,
1029    },
1030    /// Named tuple construction: `(x := 1.0, y := 2.0)` → `jsonb_build_object('x', 1.0, 'y', 2.0)`.
1031    /// `is_free_object` is true when this actually came from `{ x := 1.0 }`
1032    /// (curly-brace shape syntax, no subject) rather than `(x := 1.0)`
1033    /// (paren tuple syntax) — same jsonb encoding/decoding either way, but
1034    /// the frontend needs to know which one it was to render an expandable
1035    /// `Object {x: 1.0}` vs a `(x := 1.0)` literal display correctly (see
1036    /// ShapeNode::NamedTuple's own is_free_object).
1037    NamedTuple {
1038        fields: Vec<(String, IrExpr)>,
1039        is_free_object: bool,
1040    },
1041    /// Several correlated walks read as one set -- `(.<prices[is Listing]
1042    /// union .<sale_prices[is Listing]) { id }`. Each branch hangs off the
1043    /// enclosing row, so none of them can be hoisted into a CTE of its own
1044    /// the way a standalone union's operands are.
1045    ObjectPathUnion {
1046        branches: Vec<IrPathSelect>,
1047        limit: Option<Box<IrExpr>>,
1048        /// True when the operands reach many objects, so the arms are
1049        /// aggregated to an array instead of being read as one record --
1050        /// a scalar subquery over more than one row is a run-time error.
1051        multi: bool,
1052    },
1053    /// The object a single-valued walk lands on, with its shape — the
1054    /// path-select twin of `ObjectSubquery`. `PathSubquery` over the same
1055    /// walk gives the object's id, which is what a path in plain expression
1056    /// position means; this is for a pointer that asked for the object.
1057    ObjectPathSubquery(Box<IrPathSelect>),
1058    /// An object, with its shape, standing as a value — a free object's
1059    /// object-valued field (`{ device := d { id } }`). A free shape is a real
1060    /// object type whose fields are real pointers, so an object field stays an
1061    /// object rather than degrading to its id; this is the same composite row
1062    /// a single link emits, just uncorrelated.
1063    ObjectSubquery(Box<IrSelect>),
1064    /// Positional tuple construction: `(1, 'x')` → `jsonb_build_array(1, 'x')`.
1065    Tuple(Vec<IrExpr>),
1066    /// One member of a composite row: `(expr)."name"`.
1067    ///
1068    /// A tuple's column is a composite type, so reading a member out of it
1069    /// is a field access on that row — not the jsonb lookup
1070    /// (`IrExpr::JsonbField`) that a tuple stored as jsonb needed. Unlike
1071    /// jsonb's, this value arrives as the member's own declared type, which
1072    /// `pg_type` carries for the sites that need to know it.
1073    CompositeField {
1074        expr: Box<IrExpr>,
1075        field: String,
1076        pg_type: Option<String>,
1077    },
1078    /// A tuple as the composite row it is — `ROW(1, 'x')`.
1079    ///
1080    /// What every tuple literal compiles to: one PostgreSQL type per member,
1081    /// so a `decimal` member stays a decimal beside a `float64` one. A
1082    /// `TypeCast` around it names the column's own type where the row is on
1083    /// its way into one (see `schema::tuple_type`).
1084    ///
1085    /// `Tuple`/`NamedTuple` remain the *jsonb* builders, which is what a
1086    /// tuple becomes on its way into `std::json` — where the member names
1087    /// have to survive as keys, and an anonymous row's do not.
1088    ///
1089    /// `names` is `None` for a positional tuple; the shape is built from
1090    /// this node, so they cannot live only in the SQL.
1091    ///
1092    /// `is_free_object` marks the curly-brace form (`{ a := 1 }`). It is the
1093    /// same row either way — which is what gives its fields their own types
1094    /// — but it reads as an object rather than a tuple, so it is described
1095    /// as one (`ShapeNode::Object`) and renders as one.
1096    Row {
1097        elements: Vec<IrExpr>,
1098        names: Option<Vec<String>>,
1099        is_free_object: bool,
1100    },
1101
1102    /// Session global: emits `$N::pg_type` directly. The parameter slot carries the `__global__` prefix.
1103    GlobalParam {
1104        index: usize,
1105        pg_type: String,
1106    },
1107    /// Computed global reference: emits `(SELECT "value" FROM "cte_name")`.
1108    GlobalRef {
1109        cte_name: String,
1110    },
1111    /// Index access `expr[i]`: `substr(expr, i+1, 1)` for strings/bytes, `(expr)[i+1]` for arrays.
1112    Subscript {
1113        expr: Box<IrExpr>,
1114        index: Box<IrExpr>,
1115        is_array: bool,
1116    },
1117    /// Named tuple / jsonb field access: `(expr)->'field'` (returns jsonb).
1118    JsonbField {
1119        expr: Box<IrExpr>,
1120        field: String,
1121    },
1122    /// Positional tuple index into a jsonb array: `(expr)->index` (returns jsonb).
1123    /// Runtime fallback for `.N` tuple indexing when `expr` isn't a literal
1124    /// tuple constant-foldable at compile time (e.g. a $param or cast result).
1125    JsonbIndex {
1126        expr: Box<IrExpr>,
1127        index: usize,
1128    },
1129    /// Slice access `expr[lower:upper]`: `substr` for strings/bytes, PG subscript for arrays.
1130    Slice {
1131        expr: Box<IrExpr>,
1132        lower: Option<Box<IrExpr>>,
1133        upper: Option<Box<IrExpr>>,
1134        is_array: bool,
1135    },
1136    /// An object-returning user function projected down to one of its
1137    /// columns: `(SELECT alias."col" FROM module.fn(args) AS alias …)`.
1138    /// `shape` always holds exactly one pointer — the projected column —
1139    /// which is what makes an otherwise object-valued call usable inside a
1140    /// larger expression.
1141    FnSubquery(Box<IrFunctionSelect>),
1142    /// Detached path as a scalar subquery: `(SELECT scalar FROM root [JOINs])`.
1143    /// Used when `detached TypeName.prop` appears in a schema-bound expression context.
1144    PathSubquery(Box<IrPathSelect>),
1145    /// A named parameter reference inside a user-defined function body.
1146    /// Emitted as a double-quoted SQL identifier: `"param_name"`.
1147    FnParam {
1148        name: String,
1149        pg_type: String,
1150    },
1151    /// Verbatim SQL text, emitted parenthesized exactly as given. Never
1152    /// produced by ordinary PyQL compilation — only used to substitute an
1153    /// INSERT rewrite's self-reference (`.name`) to a property that has no
1154    /// explicit assignment in this statement with that property's own
1155    /// `default_sql`, the same value Postgres's column DEFAULT would have
1156    /// produced. A plain `INSERT ... VALUES (...)` has no FROM-clause for a
1157    /// real `ColumnRef` to resolve against (confirmed live — "missing
1158    /// FROM-clause entry"), unlike UPDATE's SET clause, which can reference
1159    /// the table's own alias validly, so this substitution is INSERT-only.
1160    RawSql(String),
1161}
1162
1163// ── Vector search ─────────────────────────────────────────────────────────────
1164
1165/// `select vector::search(Type, $vec) { object { … }, distance }`
1166///
1167/// Emits a single SELECT from the type's table that computes the distance
1168/// inline and returns a virtual `{ object, distance }` shape.
1169///
1170/// Text overload: `vector::search(Type, query := $text)` — the Python layer
1171/// embeds the text first and injects the resulting vector as `__deferred_vec__`.
1172#[derive(Debug, Clone)]
1173pub struct IrVectorSearch {
1174    /// The searched type as an `IrSource` (table + alias).
1175    pub source: IrSource,
1176    /// pgvector column name, e.g. `__vector__`.
1177    pub vector_col: String,
1178    /// pgvector distance operator: `<=>`, `<->`, or `<#>`.
1179    pub distance_op: &'static str,
1180    /// The query vector expression (e.g. `$1::vector`).
1181    /// For the text overload this is the `__deferred_vec__` param cast to vector.
1182    pub query_expr: IrExpr,
1183    /// Pointers to include in the `object` sub-tuple (from the `object { … }` shape).
1184    /// Empty means no explicit shape was given; the SQL emitter uses all properties.
1185    pub object_shape: Vec<IrShapePointer>,
1186    pub filter: Option<IrExpr>,
1187    /// `None` = no ORDER BY; `Some(dir)` = ORDER BY distance in that direction.
1188    /// Only distance ordering is supported for v1.
1189    pub order_by_distance: Option<IrSortDir>,
1190    pub offset: Option<IrExpr>,
1191    pub limit: Option<IrExpr>,
1192    // ── text overload (inference) fields ──────────────────────────────────────
1193    /// Name of the user's `query :=` param; empty string when an inline literal.
1194    pub inference_query_param_name: Option<String>,
1195    /// Inline literal query text when `query := 'some text'`.
1196    pub inference_query_literal: Option<String>,
1197    /// Embedding model identifier from the `VectorIndexDescriptor`, e.g. `"mistral-embed"`.
1198    pub inference_model: Option<String>,
1199    /// Qualified type name for provider lookup, e.g. `"default::Product"`.
1200    pub inference_type_name: Option<String>,
1201    /// Vector index name for provider lookup (`None` = default index).
1202    pub inference_index_name: Option<Option<String>>,
1203}
1204
1205// ── Full-text search ──────────────────────────────────────────────────────────
1206
1207/// `select fts::search(Type, $query) { object { … }, score }`
1208///
1209/// Emits a SELECT with a `WHERE tsvector @@ tsquery` filter and a `ts_rank`
1210/// score returned alongside the matched object.
1211#[derive(Debug, Clone)]
1212pub struct IrFtsSearch {
1213    /// The searched type as an `IrSource` (table + alias).
1214    pub source: IrSource,
1215    /// Backend that owns this search index.
1216    pub backend: crate::schema::SearchBackend,
1217    /// tsvector column name, e.g. `__search__` (Postgres backend only).
1218    pub search_col: String,
1219    /// PostgreSQL tsquery constructor (Postgres backend only).
1220    pub tsquery_fn: &'static str,
1221    /// The query text expression (e.g. `$1`).
1222    pub query_expr: IrExpr,
1223    /// Pointers to include in the `object` sub-tuple.
1224    pub object_shape: Vec<IrShapePointer>,
1225    pub filter: Option<IrExpr>,
1226    pub order_by_rank: Option<IrSortDir>,
1227    pub offset: Option<IrExpr>,
1228    pub limit: Option<IrExpr>,
1229    /// Remote index name (derived from type + index_name, set for deferred backends).
1230    pub deferred_index_name: Option<String>,
1231    /// Name of the user's query-text param (e.g. "query"), for the deferred search plan.
1232    pub deferred_query_param_name: Option<String>,
1233    /// Inline literal query text (when not a param), for the deferred search plan.
1234    pub deferred_query_literal: Option<String>,
1235    /// Param index for the uuid[] IDs injected by the Python layer (deferred backend).
1236    pub deferred_ids_param: Option<usize>,
1237    /// Param index for the float8[] scores injected by the Python layer (deferred backend).
1238    pub deferred_scores_param: Option<usize>,
1239}
1240
1241// ── User-defined function SELECT ─────────────────────────────────────────────
1242
1243/// `select fn(args) { shape }` — a SELECT over the result set of a user-defined
1244/// object-returning function.  Emits `FROM "module"."fn"(args) AS alias`.
1245#[derive(Debug, Clone)]
1246pub struct IrFunctionSelect {
1247    pub fn_module: String,
1248    pub fn_name: String,
1249    pub fn_args: Vec<IrExpr>,
1250    /// Alias for the function result row source.
1251    pub alias: String,
1252    /// Qualified return type name (e.g. `account::Account`).
1253    pub type_name: String,
1254    /// True when the return type is a polymorphic interface.
1255    pub polymorphic: bool,
1256    /// Concrete implementors when polymorphic = true.
1257    pub poly_implementors: Vec<IrPolyImplementor>,
1258    /// Interface column names for the UNION ALL branches.
1259    pub poly_columns: Vec<String>,
1260    pub shape: Vec<IrShapePointer>,
1261    pub filter: Option<IrExpr>,
1262    pub order_by: Vec<IrSort>,
1263    pub offset: Option<IrExpr>,
1264    pub limit: Option<IrExpr>,
1265    pub distinct: bool,
1266}
1267
1268#[derive(Debug, Clone)]
1269pub struct IrBinOp {
1270    pub left: IrExpr,
1271    pub op: BinOpKind,
1272    pub right: IrExpr,
1273}
1274
1275#[derive(Debug, Clone)]
1276pub struct IrUnaryOp {
1277    pub op: UnaryOpKind,
1278    pub operand: IrExpr,
1279}
1280
1281#[derive(Debug, Clone)]
1282pub struct IrFunctionCall {
1283    /// What the resolved overload returns, when that is a nameable scalar.
1284    ///
1285    /// `name` alone cannot answer this: a `SqlBuiltin` overload is rewritten
1286    /// to its PostgreSQL name here (`str_lower` becomes `lower`), so looking
1287    /// the result back up in the stdlib registry by `name` finds nothing and
1288    /// silently types the call as unknown. Recording it at resolution time —
1289    /// the one place the chosen `FnDescriptor`/`FunctionDescriptor` is in
1290    /// hand — is what lets `infer_ir_type` see through a call at all, which
1291    /// in turn is what lets a nested call (`str_lower(str_trim(x))`) resolve
1292    /// its outer overload by type rather than by falling back to the first
1293    /// one registered. `None` for a synthesised call the compiler builds
1294    /// itself and for a polymorphic result (`max`, `min`, `sum`) that is
1295    /// typed from its argument instead.
1296    pub return_pg_type: Option<String>,
1297    pub schema: Option<String>,
1298    pub name: String,
1299    pub args: Vec<IrExpr>,
1300    /// Set for `SqlExpression` impls: raw SQL template where `$1`, `$2`, … are
1301    /// replaced with the emitted arg expressions.
1302    pub sql_template: Option<String>,
1303}
1304
1305impl IrExpr {
1306    /// A free object's fields, name and value together.
1307    ///
1308    /// `Row` keeps the names beside the elements rather than interleaved, so
1309    /// the places that read a free object *by field* — projecting one out,
1310    /// validating a path into a nested one — pair them back up here.
1311    /// `None` for anything that is not a free object.
1312    pub fn free_object_fields(&self) -> Option<Vec<(&str, &IrExpr)>> {
1313        let IrExpr::Row {
1314            elements,
1315            names: Some(names),
1316            is_free_object: true,
1317        } = self
1318        else {
1319            return None;
1320        };
1321        Some(names.iter().map(String::as_str).zip(elements).collect())
1322    }
1323}
1324
1325#[derive(Debug, Clone)]
1326pub struct IrTypeCast {
1327    pub expr: IrExpr,
1328    /// PostgreSQL cast target, e.g. `text`, `int8`, `uuid`.
1329    pub pg_type: String,
1330    /// `Some` when the cast target is a named-tuple type (nominal or
1331    /// structural) whose member shape is statically known — drives building
1332    /// a rich `ShapeNode::NamedTuple` with real per-member decode instead of
1333    /// an opaque jsonb blob.
1334    pub tuple_shape: Option<TupleCastShape>,
1335}
1336
1337#[derive(Debug, Clone)]
1338pub struct TupleCastShape {
1339    /// `Some` for a nominal `@pylon.named_tuple` cast target (hydrates to
1340    /// the registered dataclass); `None` for a structural `tuple<...>`.
1341    pub type_name: Option<String>,
1342    pub members: Vec<crate::query::JsonMember>,
1343}
1344
1345#[derive(Debug, Clone)]
1346pub struct IrIfElse {
1347    pub condition: IrExpr,
1348    pub if_: IrExpr,
1349    pub else_: IrExpr,
1350}
1351
1352#[derive(Debug, Clone)]
1353pub enum IrLiteral {
1354    Str(String),
1355    Int(i64),
1356    Float(f64),
1357    Bool(bool),
1358}
1359
1360// ── Sort ────────────────────────────────────────────────────────────────────────
1361
1362#[derive(Debug, Clone)]
1363pub struct IrSort {
1364    pub expr: IrExpr,
1365    pub direction: IrSortDir,
1366    pub nulls: IrNulls,
1367}
1368
1369#[derive(Debug, Clone)]
1370pub enum IrSortDir {
1371    Asc,
1372    Desc,
1373}
1374
1375#[derive(Debug, Clone)]
1376pub enum IrNulls {
1377    First,
1378    Last,
1379}
1380
1381// ── Compiled output ──────────────────────────────────────────────────────────────
1382
1383/// A compiled mutation rewrite: a property column whose value is overridden by
1384/// a schema-defined expression at INSERT/UPDATE time.
1385#[derive(Debug, Clone)]
1386pub struct IrRewrite {
1387    /// PostgreSQL column name of the property being overridden.
1388    pub column: String,
1389    /// Compiled expression that produces the override value.
1390    /// For INSERT: column refs are substituted with the corresponding assignment
1391    /// expressions so the result is self-contained in a VALUES clause.
1392    /// For UPDATE: column refs use the table alias and are valid in a SET clause.
1393    pub expr: IrExpr,
1394}
1395
1396/// One `name := (stmt)` binding from a WITH block.
1397#[derive(Debug, Clone)]
1398pub struct IrCteDef {
1399    pub name: String,
1400    pub stmt: IrStmt,
1401    /// Qualified type name of the result set (e.g. `"default::Person"`).
1402    /// Empty for free expressions.
1403    pub type_name: String,
1404    /// The `_for_<slot>` iterator this binding reads, when it reads one. Such
1405    /// a binding holds one value *per iteration*, not one for the statement,
1406    /// so it cannot be evaluated once ahead of the loop — it joins the
1407    /// iterator and carries its key for consumers to pair on.
1408    pub correlated_to: Option<String>,
1409}
1410
1411/// A session global CTE: `WITH "cte_name" AS (SELECT $N::pg_type AS "value")`.
1412#[derive(Debug, Clone)]
1413pub struct IrSessionGlobalCte {
1414    pub cte_name: String,
1415    pub qualified_name: String,
1416    pub param_index: usize,
1417    pub pg_type: String,
1418}
1419
1420/// A computed global CTE: `WITH "cte_name" AS (<compiled stmt returning "value" column>)`.
1421#[derive(Debug, Clone)]
1422pub struct IrComputedGlobalCte {
1423    pub cte_name: String,
1424    pub qualified_name: String,
1425    pub stmt: IrStmt,
1426}
1427
1428#[derive(Debug, Clone)]
1429pub enum IrGlobalCte {
1430    Session(IrSessionGlobalCte),
1431    /// Boxed: a computed global carries a whole compiled sub-select and is
1432    /// ~8x the size of a session global, and these live in a `Vec` where the
1433    /// session variant is the common case.
1434    Computed(Box<IrComputedGlobalCte>),
1435}
1436
1437impl IrGlobalCte {
1438    pub fn cte_name(&self) -> &str {
1439        match self {
1440            Self::Session(s) => &s.cte_name,
1441            Self::Computed(c) => &c.cte_name,
1442        }
1443    }
1444}
1445
1446/// The result of the IR compilation step.
1447/// Carries the query plan and the ordered list of named parameters, which the
1448/// SQL emitter uses to emit `$1 … $N` and the client uses to bind values.
1449pub struct IrOutput {
1450    pub stmt: IrStmt,
1451    /// Ordered parameter names, positionally matching `$1`, `$2`, … in the SQL.
1452    /// Global params use the `__global__module::name` prefix; user params use bare names.
1453    pub params: Vec<String>,
1454    /// Positionally matching `params`: the tuple type each one is cast to,
1455    /// where it is cast to one (see `crate::query::ParamTupleType`).
1456    pub param_tuple_types: Vec<Option<crate::query::ParamTupleType>>,
1457    /// User-defined CTE bindings from a WITH block, in declaration order.
1458    pub ctes: Vec<IrCteDef>,
1459    /// Global variable CTEs (session-injected or computed), in dependency order.
1460    pub global_ctes: Vec<IrGlobalCte>,
1461    /// Non-fatal warnings produced during compilation.
1462    pub warnings: Vec<String>,
1463    /// True when this is a function body that reads a session global, or
1464    /// forwards the globals argument to a callee that does — i.e. when the
1465    /// function needs `GLOBALS_ARG` in its signature.
1466    pub uses_globals_arg: bool,
1467    /// `(module, table)` of every concrete type with subtypes → the fan-out
1468    /// that reads it with them. A plain source over one of these tables reads
1469    /// through its fan-out; a write to it does not.
1470    pub subtype_fanouts: HashMap<(String, String), IrPolyFanout>,
1471}
1472
1473/// `(module, table)`.
1474pub type QualifiedTable = (String, String);
1475
1476/// Marks a junction table name that stands for the union of one multi-link's
1477/// junction tables across a concrete type and its subtypes, which each keep
1478/// their own (`"BrandAddon.prices"` beside `"BrandAddonBundle.prices"`).
1479const INHERITED_JUNCTION: &str = "@inherited:";
1480
1481/// The junction name a read of an inherited multi-link uses: every
1482/// `(module, table)` in `tables`, read through `columns`.
1483pub fn inherited_junction(tables: &[QualifiedTable], columns: &[String]) -> String {
1484    let tables = tables
1485        .iter()
1486        .map(|(module, table)| format!("{module}\u{1f}{table}"))
1487        .collect::<Vec<_>>()
1488        .join("\u{1e}");
1489    format!("{INHERITED_JUNCTION}{tables}\u{1d}{}", columns.join("\u{1f}"))
1490}
1491
1492/// The `(module, table)` pairs and columns an `inherited_junction` name
1493/// stands for, or `None` for a plain table name.
1494pub fn parse_inherited_junction(name: &str) -> Option<(Vec<QualifiedTable>, Vec<String>)> {
1495    let (tables, columns) = name.strip_prefix(INHERITED_JUNCTION)?.split_once('\u{1d}')?;
1496    let tables = tables
1497        .split('\u{1e}')
1498        .filter_map(|entry| entry.split_once('\u{1f}'))
1499        .map(|(module, table)| (module.to_string(), table.to_string()))
1500        .collect();
1501    Some((tables, columns.split('\u{1f}').map(str::to_string).collect()))
1502}
1503
1504#[cfg(test)]
1505mod tests {
1506    use super::*;
1507    #[allow(unused_imports)]
1508    use super::{IrFreeExpr, IrLiteral};
1509    use crate::parse;
1510    use crate::schema::{
1511        ChannelDescriptor, ChannelPayload, ComputedDescriptor, GlobalDescriptor, LinkDescriptor, MultiLinkDescriptor,
1512        PropertyDescriptor, SchemaDescriptor, TypeDescriptor,
1513    };
1514
1515    fn make_schema() -> SchemaDescriptor {
1516        SchemaDescriptor {
1517            types: vec![
1518                TypeDescriptor {
1519                    name: "Person".into(),
1520                    module: "default".into(),
1521                    table: "person".into(),
1522                    abstract_: false,
1523                    materialized: false,
1524                    description: None,
1525                    parents: vec![],
1526                    interfaces: vec![],
1527                    bases: vec![],
1528                    properties: vec![
1529                        PropertyDescriptor {
1530                            name: "id".into(),
1531                            pg_type: "uuid".into(),
1532                            nullable: false,
1533                            default_sql: Some("uuidv7()".into()),
1534                            default_pyql: None,
1535                            description: None,
1536                            check_constraints: vec![],
1537                            is_exclusive: true,
1538                            is_pk: true,
1539                            is_readonly: true,
1540                            rewrites: vec![],
1541                            tuple_members: None,
1542                            column_type: None,
1543                        },
1544                        PropertyDescriptor {
1545                            name: "name".into(),
1546                            pg_type: "text".into(),
1547                            nullable: false,
1548                            default_sql: None,
1549                            default_pyql: None,
1550                            description: None,
1551                            check_constraints: vec![],
1552                            is_exclusive: false,
1553                            is_pk: false,
1554                            is_readonly: false,
1555                            rewrites: vec![],
1556                            tuple_members: None,
1557                            column_type: None,
1558                        },
1559                        PropertyDescriptor {
1560                            name: "age".into(),
1561                            pg_type: "int8".into(),
1562                            nullable: true,
1563                            default_sql: None,
1564                            default_pyql: None,
1565                            description: None,
1566                            check_constraints: vec![],
1567                            is_exclusive: false,
1568                            is_pk: false,
1569                            is_readonly: false,
1570                            rewrites: vec![],
1571                            tuple_members: None,
1572                            column_type: None,
1573                        },
1574                    ],
1575                    links: vec![LinkDescriptor {
1576                        name: "company".into(),
1577                        target: "default::Company".into(),
1578                        nullable: true,
1579                        through: None,
1580                        description: None,
1581                        default_pyql: None,
1582                        is_exclusive: false,
1583                        is_readonly: false,
1584                        rewrites: vec![],
1585                        on_delete: vec![],
1586                    }],
1587                    multilinks: vec![MultiLinkDescriptor {
1588                        name: "posts".into(),
1589                        target: "default::Post".into(),
1590                        through: None,
1591                        nullable: false,
1592                        description: None,
1593                        default_pyql: None,
1594                        on_delete: vec![],
1595                        is_exclusive: false,
1596                    }],
1597                    computed: vec![],
1598                    constraints: vec![],
1599                    indexes: vec![],
1600                    partition: None,
1601                    vector_indexes: vec![],
1602                    search_indexes: vec![],
1603                    triggers: vec![],
1604                    junction: false,
1605                    signals: vec![],
1606                },
1607                TypeDescriptor {
1608                    name: "Company".into(),
1609                    module: "default".into(),
1610                    table: "company".into(),
1611                    abstract_: false,
1612                    materialized: false,
1613                    description: None,
1614                    parents: vec![],
1615                    interfaces: vec![],
1616                    bases: vec![],
1617                    properties: vec![PropertyDescriptor {
1618                        name: "name".into(),
1619                        pg_type: "text".into(),
1620                        nullable: false,
1621                        default_sql: None,
1622                        default_pyql: None,
1623                        description: None,
1624                        check_constraints: vec![],
1625                        is_exclusive: false,
1626                        is_pk: false,
1627                        is_readonly: false,
1628                        rewrites: vec![],
1629                        tuple_members: None,
1630                        column_type: None,
1631                    }],
1632                    links: vec![],
1633                    multilinks: vec![],
1634                    computed: vec![],
1635                    constraints: vec![],
1636                    indexes: vec![],
1637                    partition: None,
1638                    vector_indexes: vec![],
1639                    search_indexes: vec![],
1640                    triggers: vec![],
1641                    junction: false,
1642                    signals: vec![],
1643                },
1644                TypeDescriptor {
1645                    name: "Post".into(),
1646                    module: "default".into(),
1647                    table: "post".into(),
1648                    abstract_: false,
1649                    materialized: false,
1650                    description: None,
1651                    parents: vec![],
1652                    interfaces: vec![],
1653                    bases: vec![],
1654                    properties: vec![PropertyDescriptor {
1655                        name: "title".into(),
1656                        pg_type: "text".into(),
1657                        nullable: false,
1658                        default_sql: None,
1659                        default_pyql: None,
1660                        description: None,
1661                        check_constraints: vec![],
1662                        is_exclusive: false,
1663                        is_pk: false,
1664                        is_readonly: false,
1665                        rewrites: vec![],
1666                        tuple_members: None,
1667                        column_type: None,
1668                    }],
1669                    links: vec![],
1670                    multilinks: vec![],
1671                    computed: vec![],
1672                    constraints: vec![],
1673                    indexes: vec![],
1674                    partition: None,
1675                    vector_indexes: vec![],
1676                    search_indexes: vec![],
1677                    triggers: vec![],
1678                    junction: false,
1679                    signals: vec![],
1680                },
1681            ],
1682            scalars: vec![],
1683            enums: vec![],
1684            named_tuples: vec![],
1685            globals: vec![],
1686            functions: vec![],
1687            aliases: vec![],
1688            channels: vec![],
1689            ..Default::default()
1690        }
1691    }
1692
1693    fn compile(query: &str) -> IrOutput {
1694        let schema = make_schema();
1695        let ast = parse::parse(query).expect("parse failed");
1696        super::compile(&ast, &schema).expect("IR compile failed")
1697    }
1698
1699    /// Extract the single schema-bound row's `(source, shape)` from a
1700    /// `SELECT` — panics if the select isn't schema-bound (i.e. is a free
1701    /// select), which is what most tests expect.
1702    fn bound(sel: &IrSelect) -> (&IrSource, &[IrShapePointer]) {
1703        match sel.rows.as_slice() {
1704            [IrRowSource::Bound { source, shape }] => (source, shape),
1705            _ => panic!("expected a single schema-bound row"),
1706        }
1707    }
1708
1709    /// Extract the free-row items from a `SELECT` — panics if any row is
1710    /// schema-bound, which is what free-select tests expect.
1711    fn free_items(sel: &IrSelect) -> Vec<&IrFreeExpr> {
1712        sel.rows
1713            .iter()
1714            .map(|r| match r {
1715                IrRowSource::Free(item) => item,
1716                IrRowSource::Bound { .. } => panic!("expected a free row"),
1717            })
1718            .collect()
1719    }
1720
1721    #[test]
1722    fn test_select_resolves_source() {
1723        let ir = compile("SELECT Person { name, age }");
1724        let IrStmt::Select(sel) = ir.stmt else { panic!() };
1725        let (source, shape) = bound(&sel);
1726        assert_eq!(source.table, "person");
1727        assert_eq!(source.type_name, "default::Person");
1728        // `id` the query never asked for, then `name` and `age`.
1729        assert_eq!(shape.len(), 3);
1730        assert!(matches!(&shape[0], IrShapePointer::Scalar(p) if p.alias == "id" && p.implicit_id));
1731        assert!(matches!(shape[1], IrShapePointer::Scalar(_)));
1732    }
1733
1734    #[test]
1735    fn test_select_filter_param_ordering() {
1736        let ir = compile("SELECT Person { name } FILTER .name = $name AND .age > $min_age");
1737        assert_eq!(ir.params, vec!["name", "min_age"]);
1738        let IrStmt::Select(sel) = ir.stmt else { panic!() };
1739        assert!(sel.filter.is_some());
1740    }
1741
1742    #[test]
1743    fn test_select_single_link() {
1744        let ir = compile("SELECT Person { name, company { name } }");
1745        let IrStmt::Select(sel) = ir.stmt else { panic!() };
1746        let (_, shape) = bound(&sel);
1747        assert_eq!(shape.len(), 3);
1748        let IrShapePointer::SingleLink(link) = &shape[2] else {
1749            panic!("expected SingleLink")
1750        };
1751        assert_eq!(link.alias, "company");
1752        let IrSingleLinkCorrelation::Fk { fk_column, .. } = &link.correlation else {
1753            panic!("expected Fk correlation")
1754        };
1755        assert_eq!(fk_column, "company_id");
1756        assert_eq!(bound(&link.subquery).0.table, "company");
1757    }
1758
1759    #[test]
1760    fn test_select_multi_link() {
1761        let ir = compile("SELECT Person { name, posts { title } }");
1762        let IrStmt::Select(sel) = ir.stmt else { panic!() };
1763        let (_, shape) = bound(&sel);
1764        let IrShapePointer::MultiLink(ml) = &shape[2] else {
1765            panic!("expected MultiLink")
1766        };
1767        assert_eq!(ml.alias, "posts");
1768        assert_eq!(bound(&ml.subquery).0.table, "post");
1769        let IrMultiLinkJoin::Standard { junction_table, .. } = &ml.join else {
1770            panic!()
1771        };
1772        assert_eq!(junction_table, "person.posts");
1773    }
1774
1775    #[test]
1776    fn test_select_no_shape_returns_id_only() {
1777        let ir = compile("SELECT Person");
1778        let IrStmt::Select(sel) = ir.stmt else { panic!() };
1779        // Bare SELECT Type returns only { id }.
1780        let (_, shape) = bound(&sel);
1781        assert_eq!(shape.len(), 1);
1782        let IrShapePointer::Scalar(f) = &shape[0] else { panic!() };
1783        assert_eq!(f.alias, "id");
1784    }
1785
1786    #[test]
1787    fn test_free_select_set_literal() {
1788        let schema = make_schema();
1789        let ast = parse::parse("SELECT {1, 2, 3}").unwrap();
1790        let ir = super::compile(&ast, &schema).unwrap();
1791        let IrStmt::Select(sel) = ir.stmt else {
1792            panic!("expected Select")
1793        };
1794        let items = free_items(&sel);
1795        assert_eq!(items.len(), 3);
1796        assert!(matches!(
1797            items[0],
1798            IrFreeExpr::Scalar(IrExpr::Literal(IrLiteral::Int(1)))
1799        ));
1800    }
1801
1802    #[test]
1803    fn test_free_select_free_object() {
1804        let schema = make_schema();
1805        let ast = parse::parse("SELECT { foo := 'bar', n := 42 }").unwrap();
1806        let ir = super::compile(&ast, &schema).unwrap();
1807        let IrStmt::Select(sel) = ir.stmt else {
1808            panic!("expected Select")
1809        };
1810        let items = free_items(&sel);
1811        assert_eq!(items.len(), 1);
1812        let IrFreeExpr::FreeObject(fields) = &items[0] else {
1813            panic!("expected FreeObject")
1814        };
1815        assert_eq!(fields.len(), 2);
1816        assert_eq!(fields[0].0, "foo");
1817        assert_eq!(fields[1].0, "n");
1818    }
1819
1820    #[test]
1821    fn test_free_select_tuple() {
1822        let schema = make_schema();
1823        let ast = parse::parse("SELECT (1, 'hello')").unwrap();
1824        let ir = super::compile(&ast, &schema).unwrap();
1825        let IrStmt::Select(sel) = ir.stmt else {
1826            panic!("expected Select")
1827        };
1828        let items = free_items(&sel);
1829        assert_eq!(items.len(), 1);
1830        assert!(matches!(items[0], IrFreeExpr::Tuple(_)));
1831    }
1832
1833    #[test]
1834    fn test_free_select_scalar_literal() {
1835        let schema = make_schema();
1836        let ast = parse::parse("SELECT 42").unwrap();
1837        let ir = super::compile(&ast, &schema).unwrap();
1838        let IrStmt::Select(sel) = ir.stmt else {
1839            panic!("expected Select")
1840        };
1841        let items = free_items(&sel);
1842        assert_eq!(items.len(), 1);
1843        assert!(matches!(
1844            items[0],
1845            IrFreeExpr::Scalar(IrExpr::Literal(IrLiteral::Int(42)))
1846        ));
1847    }
1848
1849    #[test]
1850    fn test_free_select_function_call() {
1851        let schema = make_schema();
1852        let ast = parse::parse("SELECT str_lower('HELLO')").unwrap();
1853        let ir = super::compile(&ast, &schema).unwrap();
1854        let IrStmt::Select(sel) = ir.stmt else {
1855            panic!("expected Select")
1856        };
1857        let items = free_items(&sel);
1858        assert!(matches!(items[0], IrFreeExpr::Scalar(IrExpr::FunctionCall(_))));
1859    }
1860
1861    #[test]
1862    fn test_free_select_rejects_dot_path() {
1863        let schema = make_schema();
1864        let ast = parse::parse("SELECT {.name}").unwrap();
1865        assert!(super::compile(&ast, &schema).is_err());
1866    }
1867
1868    #[test]
1869    fn test_type_error_uuid_eq_str() {
1870        let schema = make_schema();
1871        let ast = parse::parse("SELECT Person FILTER .id = 'not-a-uuid'").unwrap();
1872        let err = super::compile(&ast, &schema).err().expect("expected type error");
1873        let msg = err.to_string();
1874        assert!(
1875            msg.contains("std::uuid") && msg.contains("std::str"),
1876            "unexpected: {msg}"
1877        );
1878    }
1879
1880    #[test]
1881    fn test_type_error_str_eq_int() {
1882        let schema = make_schema();
1883        let ast = parse::parse("SELECT Person FILTER .name = 42").unwrap();
1884        let err = super::compile(&ast, &schema).err().expect("expected type error");
1885        let msg = err.to_string();
1886        assert!(
1887            msg.contains("std::str") && msg.contains("std::int64"),
1888            "unexpected: {msg}"
1889        );
1890    }
1891
1892    #[test]
1893    fn test_int_literal_compatible_with_all_int_columns() {
1894        // age is int8; a bare integer literal is compatible with any int column
1895        let schema = make_schema();
1896        let ast = parse::parse("SELECT Person FILTER .age = 30").unwrap();
1897        assert!(super::compile(&ast, &schema).is_ok());
1898    }
1899
1900    #[test]
1901    fn test_cast_int16_compatible_with_int8_column() {
1902        let schema = make_schema();
1903        let ast = parse::parse("SELECT Person FILTER .age = <int16>30").unwrap();
1904        assert!(super::compile(&ast, &schema).is_ok());
1905    }
1906
1907    #[test]
1908    fn test_unknown_type_error() {
1909        let schema = make_schema();
1910        let ast = parse::parse("SELECT Ghost { name }").unwrap();
1911        assert!(super::compile(&ast, &schema).is_err());
1912    }
1913
1914    #[test]
1915    fn test_nested_dml_link_value_combines_with_multilink_mutation_in_the_same_update() {
1916        // A link value sourced from a hoisted nested INSERT/UPDATE/DELETE
1917        // (`company := (select (insert Company {...}) { id })`) and a
1918        // multi-link mutation (`posts +=`) in the same UPDATE both compile
1919        // — `IrUpdate` carries both `nested_ctes` and `multi_link_appends`,
1920        // and `emit_update_stmt`'s junction-CTE branch threads the former
1921        // through into the `_ids` UPDATE's own FROM clause. See the SQL-shape
1922        // assertion in `sql::tests::
1923        // test_update_link_value_from_nested_insert_combines_with_multilink_mutation`
1924        // for the actual emitted structure.
1925        let schema = make_schema();
1926        let ast = parse::parse(
1927            "UPDATE Person FILTER .id = $id SET { \
1928                 company := (select (insert Company { name := 'Acme' }) { id }), \
1929                 posts += (SELECT Post FILTER .title = $t) \
1930             }",
1931        )
1932        .unwrap();
1933        let ir = super::compile(&ast, &schema).unwrap();
1934        let IrStmt::Update(upd) = ir.stmt else {
1935            panic!("expected Update")
1936        };
1937        assert_eq!(upd.nested_ctes.len(), 1);
1938        assert_eq!(upd.multi_link_appends.len(), 1);
1939    }
1940
1941    #[test]
1942    fn test_unknown_pointer_error() {
1943        let schema = make_schema();
1944        let ast = parse::parse("SELECT Person { nonexistent }").unwrap();
1945        assert!(super::compile(&ast, &schema).is_err());
1946    }
1947
1948    #[test]
1949    fn test_insert_compiles_assignments() {
1950        let ir = compile("INSERT Person { name := 'Alice', age := 30 }");
1951        let IrStmt::Insert(ins) = ir.stmt else { panic!() };
1952        assert_eq!(ins.target.table, "person");
1953        assert_eq!(ins.assignments.len(), 2);
1954        assert_eq!(ins.assignments[0].0, "name");
1955        assert_eq!(ins.assignments[1].0, "age");
1956    }
1957
1958    #[test]
1959    fn test_delete_compiles_filter() {
1960        let ir = compile("DELETE Person FILTER .name = $name");
1961        let IrStmt::Delete(del) = ir.stmt else { panic!() };
1962        assert!(del.filter.is_some());
1963        assert_eq!(ir.params, vec!["name"]);
1964    }
1965
1966    fn make_schema_with_computed() -> SchemaDescriptor {
1967        let mut schema = make_schema();
1968        // Add a computed pointer to Person
1969        schema.types[0].computed.push(ComputedDescriptor {
1970            name: "upper_name".into(),
1971            expression: "str_upper(.name)".into(),
1972            return_type: Some("text".into()),
1973            link_target: None,
1974            link_multi: false,
1975        });
1976        schema
1977    }
1978
1979    #[test]
1980    fn test_computed_pointer_in_shape() {
1981        let schema = make_schema_with_computed();
1982        let ast = parse::parse("SELECT Person { upper_name }").unwrap();
1983        let ir = super::compile(&ast, &schema).expect("IR compile failed");
1984        let IrStmt::Select(sel) = ir.stmt else { panic!() };
1985        // upper_name should compile to a Computed shape pointer
1986        let (_, shape) = bound(&sel);
1987        assert!(
1988            shape
1989                .iter()
1990                .any(|f| matches!(f, IrShapePointer::Computed(c) if c.alias == "upper_name"))
1991        );
1992    }
1993
1994    #[test]
1995    fn test_computed_pointer_in_expression_context() {
1996        let schema = make_schema_with_computed();
1997        let ast = parse::parse("SELECT Person { x := str_lower(.upper_name) }").unwrap();
1998        let ir = super::compile(&ast, &schema).expect("IR compile failed");
1999        let IrStmt::Select(sel) = ir.stmt else { panic!() };
2000        let (_, shape) = bound(&sel);
2001        assert!(
2002            shape
2003                .iter()
2004                .any(|f| matches!(f, IrShapePointer::Computed(c) if c.alias == "x"))
2005        );
2006    }
2007
2008    #[test]
2009    fn test_count_over_multilink_in_computed_shape_element() {
2010        // Regression: `count(.posts)` inside a computed shape element
2011        // previously failed with "object type 'default::Person' has no link
2012        // or property 'posts'" — compile_path only checked scalar
2013        // properties/single-links, never multilinks.
2014        let ir = compile("SELECT Person { post_count := count(.posts) }");
2015        let IrStmt::Select(sel) = ir.stmt else { panic!() };
2016        let (_, shape) = bound(&sel);
2017        let computed = shape
2018            .iter()
2019            .find_map(|f| match f {
2020                IrShapePointer::Computed(c) if c.alias == "post_count" => Some(c),
2021                _ => None,
2022            })
2023            .expect("expected post_count computed pointer");
2024        assert!(matches!(computed.expr, IrExpr::AggOverQuery { .. }));
2025    }
2026
2027    #[test]
2028    fn test_multi_sort_with_then() {
2029        let ir = compile("SELECT Person { name } ORDER BY .name THEN .age");
2030        let IrStmt::Select(sel) = ir.stmt else { panic!() };
2031        assert_eq!(sel.order_by.len(), 2);
2032    }
2033
2034    #[test]
2035    fn test_multi_link_filter_emits_warning() {
2036        let ir = compile("SELECT Person { name } FILTER .posts.title = 'hello'");
2037        assert!(!ir.warnings.is_empty(), "expected a warning for multi-link in filter");
2038        assert!(ir.warnings[0].contains("posts"));
2039    }
2040
2041    #[test]
2042    fn test_session_global_produces_cte() {
2043        let mut schema = make_schema();
2044        schema.globals.push(GlobalDescriptor {
2045            name: "viewer_id".into(),
2046            module: "default".into(),
2047            scalar_type: "std::uuid".into(),
2048            required: false,
2049            default_expr: None,
2050            computed_expr: None,
2051        });
2052        let ast = parse::parse("SELECT Person FILTER .id = global viewer_id").unwrap();
2053        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2054        assert_eq!(ir.global_ctes.len(), 1);
2055        assert_eq!(ir.global_ctes[0].cte_name(), "__global__default::viewer_id");
2056        assert_eq!(ir.params, vec!["__global__default::viewer_id"]);
2057    }
2058
2059    #[test]
2060    fn test_session_global_pg_type_matches_pyql_type_name() {
2061        // Regression: `GlobalDescriptor.scalar_type` is a PyQL-style type
2062        // name built by the Python walker's `_pyql_type_name` (e.g.
2063        // "std::uuid"), never a bare class name like "UUID" —
2064        // `resolve_global_pg_type` used to match against the latter and
2065        // silently fall back to "text" for every builtin-typed session
2066        // global, which only surfaced once something actually compiled a
2067        // query/expression comparing the global against a real uuid column.
2068        let mut schema = make_schema();
2069        schema.globals.push(GlobalDescriptor {
2070            name: "viewer_id".into(),
2071            module: "default".into(),
2072            scalar_type: "std::uuid".into(),
2073            required: false,
2074            default_expr: None,
2075            computed_expr: None,
2076        });
2077        let ast = parse::parse("SELECT Person FILTER .id = global viewer_id").unwrap();
2078        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2079        let IrGlobalCte::Session(session) = &ir.global_ctes[0] else {
2080            panic!("expected a session global CTE");
2081        };
2082        assert_eq!(session.pg_type, "uuid");
2083    }
2084
2085    #[test]
2086    fn test_computed_global_field_access_compiles_as_path_select() {
2087        // Regression: `global name.field` previously wrapped the global's
2088        // opaque CTE reference in a jsonb `->` extraction (only valid for
2089        // tuple-typed values), producing "operator does not exist: uuid ->
2090        // unknown" for an object-typed computed global.
2091        let mut schema = make_schema();
2092        schema.globals.push(GlobalDescriptor {
2093            name: "current_user".into(),
2094            module: "default".into(),
2095            scalar_type: "Person".into(),
2096            required: false,
2097            default_expr: None,
2098            computed_expr: Some("select default::Person filter .id = <uuid>$session_user_id".into()),
2099        });
2100        let ast = parse::parse("SELECT global current_user.id").unwrap();
2101        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2102        let IrStmt::PathSelect(sel) = ir.stmt else {
2103            panic!("expected a path select, not a free select")
2104        };
2105        assert_eq!(sel.root.type_name, "default::Person");
2106    }
2107
2108    #[test]
2109    fn test_subquery_field_access_compiles_as_path_select() {
2110        // Regression: `(select Type filter ...).field` hit the generic free-
2111        // expression fallback ("expression is not valid in free SELECT
2112        // context") because bare subqueries aren't valid free expressions —
2113        // it should splice `.field` onto the inner select as a path step.
2114        let ast = parse::parse("SELECT (SELECT default::Person FILTER .age > 20).name").unwrap();
2115        let schema = make_schema();
2116        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2117        let IrStmt::PathSelect(sel) = ir.stmt else {
2118            panic!("expected a path select, not a free select")
2119        };
2120        assert_eq!(sel.root.type_name, "default::Person");
2121    }
2122
2123    #[test]
2124    fn test_string_index_compiles() {
2125        let ast = parse::parse("SELECT 'hello'[1]").unwrap();
2126        let schema = make_schema();
2127        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2128        let IrStmt::Select(sel) = ir.stmt else { panic!() };
2129        let items = free_items(&sel);
2130        assert!(matches!(
2131            items[0],
2132            IrFreeExpr::Scalar(IrExpr::Subscript { is_array: false, .. })
2133        ));
2134    }
2135
2136    #[test]
2137    fn test_array_index_compiles() {
2138        let ast = parse::parse("SELECT [1, 2, 3][0]").unwrap();
2139        let schema = make_schema();
2140        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2141        let IrStmt::Select(sel) = ir.stmt else { panic!() };
2142        let items = free_items(&sel);
2143        assert!(matches!(
2144            items[0],
2145            IrFreeExpr::Scalar(IrExpr::Subscript { is_array: true, .. })
2146        ));
2147    }
2148
2149    #[test]
2150    fn test_string_slice_compiles() {
2151        let ast = parse::parse("SELECT 'hello'[1:3]").unwrap();
2152        let schema = make_schema();
2153        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2154        let IrStmt::Select(sel) = ir.stmt else { panic!() };
2155        let items = free_items(&sel);
2156        assert!(matches!(
2157            items[0],
2158            IrFreeExpr::Scalar(IrExpr::Slice { is_array: false, .. })
2159        ));
2160    }
2161
2162    #[test]
2163    fn test_array_slice_compiles() {
2164        let ast = parse::parse("SELECT [1, 2, 3][0:2]").unwrap();
2165        let schema = make_schema();
2166        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2167        let IrStmt::Select(sel) = ir.stmt else { panic!() };
2168        let items = free_items(&sel);
2169        assert!(matches!(
2170            items[0],
2171            IrFreeExpr::Scalar(IrExpr::Slice { is_array: true, .. })
2172        ));
2173    }
2174
2175    fn make_schema_with_alias() -> SchemaDescriptor {
2176        use crate::schema::AliasDescriptor;
2177        let mut schema = make_schema();
2178        schema.aliases.push(AliasDescriptor {
2179            name: "ActivePersons".into(),
2180            module: "default".into(),
2181            expr: "select Person filter .age >= 18".into(),
2182        });
2183        schema
2184    }
2185
2186    fn make_schema_with_sequence() -> crate::schema::SchemaDescriptor {
2187        use crate::schema::ScalarDescriptor;
2188        let mut schema = make_schema();
2189        schema.scalars.push(ScalarDescriptor {
2190            name: "OrderNumber".into(),
2191            module: "default".into(),
2192            base: "Sequence".into(),
2193            pg_type: "int8".into(),
2194            check_constraints: vec![],
2195            is_sequence: true,
2196        });
2197        schema
2198    }
2199
2200    fn make_schema_with_channels() -> SchemaDescriptor {
2201        let mut schema = make_schema();
2202        schema.channels.push(ChannelDescriptor {
2203            name: "Pings".into(),
2204            module: "default".into(),
2205            wire_name: "default__pings".into(),
2206            payload: ChannelPayload::Scalar("text".into()),
2207            description: None,
2208        });
2209        schema.channels.push(ChannelDescriptor {
2210            name: "SearchReady".into(),
2211            module: "default".into(),
2212            wire_name: "default__search_ready".into(),
2213            payload: ChannelPayload::Object(vec![
2214                ("doc_id".into(), "uuid".into()),
2215                ("score".into(), "float8".into()),
2216            ]),
2217            description: None,
2218        });
2219        schema.channels.push(ChannelDescriptor {
2220            name: "PersonUpdates".into(),
2221            module: "default".into(),
2222            wire_name: "default__person_updates".into(),
2223            payload: ChannelPayload::Type("default::Person".into()),
2224            description: None,
2225        });
2226        schema
2227    }
2228
2229    fn compile_notify_expr(query: &str) -> String {
2230        let schema = make_schema_with_channels();
2231        let ast = parse::parse(query).expect("parse failed");
2232        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2233        let IrStmt::Select(sel) = ir.stmt else {
2234            panic!("expected Select")
2235        };
2236        let items = free_items(&sel);
2237        let IrFreeExpr::Scalar(expr) = items[0] else {
2238            panic!("expected scalar")
2239        };
2240        crate::sql::emit_expr(expr)
2241    }
2242
2243    fn notify_compile_err(query: &str) -> String {
2244        let schema = make_schema_with_channels();
2245        let ast = parse::parse(query).expect("parse failed");
2246        format!(
2247            "{}",
2248            super::compile(&ast, &schema).err().expect("expected a compile error")
2249        )
2250    }
2251
2252    #[test]
2253    fn test_notify_scalar_channel_emits_pg_notify() {
2254        let sql = compile_notify_expr("SELECT notify(Pings, 'hello')");
2255        assert_eq!(sql, "pg_notify('default__pings', ('hello')::text)", "got: {sql}");
2256    }
2257
2258    #[test]
2259    fn test_notify_rejects_unknown_channel() {
2260        let err = notify_compile_err("SELECT notify(NoSuchChannel, 'hi')");
2261        assert!(err.contains("not a known Channel"), "got: {err}");
2262    }
2263
2264    #[test]
2265    fn test_notify_object_channel_emits_jsonb_build_object() {
2266        let sql = compile_notify_expr(
2267            "SELECT notify(SearchReady, { doc_id := <uuid>'3fa85f64-5717-4562-b3fc-2c963f66afa6', score := 0.5 })",
2268        );
2269        assert_eq!(
2270            sql,
2271            "pg_notify('default__search_ready', (jsonb_build_object('doc_id', ('3fa85f64-5717-4562-b3fc-2c963f66afa6')::uuid, 'score', (0.5::float8)))::text)",
2272            "got: {sql}"
2273        );
2274    }
2275
2276    #[test]
2277    fn test_notify_object_channel_rejects_wrong_fields() {
2278        let err = notify_compile_err("SELECT notify(SearchReady, { doc_id := 'x' })");
2279        assert!(
2280            err.contains("payload fields") && err.contains("don't match"),
2281            "got: {err}"
2282        );
2283    }
2284
2285    #[test]
2286    fn test_notify_object_channel_rejects_non_shape_payload() {
2287        let err = notify_compile_err("SELECT notify(SearchReady, 'not an object')");
2288        assert!(err.contains("free object literal"), "got: {err}");
2289    }
2290
2291    #[test]
2292    fn test_notify_type_channel_rejects_arbitrary_payload() {
2293        let err = notify_compile_err("SELECT notify(PersonUpdates, 'not an anchor')");
2294        assert!(err.contains("must name an object of that type"), "got: {err}");
2295    }
2296
2297    #[test]
2298    fn notify_composes_with_a_with_block_binding() {
2299        // The shape a notify-after-write actually wants: the mutation and
2300        // the notification in one statement, in one transaction. This used
2301        // to be a compile error — `notify` on an object channel only
2302        // accepted the `__new__`/`__old__` anchors a trigger binds.
2303        let sql = compile_notify_expr(
2304            "WITH updated := (UPDATE Person FILTER .id = <uuid>$id SET { name := 'x' }) \
2305             SELECT notify(PersonUpdates, updated)",
2306        );
2307        assert!(sql.contains("pg_notify"), "got: {sql}");
2308        // The payload is the bound object's id, read out of its CTE.
2309        assert!(sql.contains("\"id\""), "payload should be the CTE's id: {sql}");
2310        assert!(sql.contains("updated"), "should reference the with-block CTE: {sql}");
2311    }
2312
2313    #[test]
2314    fn notify_rejects_a_with_block_binding_of_the_wrong_type() {
2315        let err = notify_compile_err("WITH other := (SELECT Company) SELECT notify(PersonUpdates, other)");
2316        assert!(err.contains("expects a payload of type"), "got: {err}");
2317    }
2318
2319    #[test]
2320    fn test_notify_type_channel_via_trigger_new_anchor() {
2321        let schema = make_schema_with_channels();
2322        let ir_out = super::compile_trigger_handler(
2323            "select notify(PersonUpdates, __new__)",
2324            "Person",
2325            1, // On::Insert — binds __new__ only (on_mask & 4 == 0), no __old__
2326            &schema,
2327        )
2328        .expect("trigger handler compile failed");
2329        let IrStmt::Select(sel) = ir_out.stmt else {
2330            panic!("expected Select")
2331        };
2332        let items = free_items(&sel);
2333        let IrFreeExpr::Scalar(expr) = items[0] else {
2334            panic!("expected scalar")
2335        };
2336        let sql = crate::sql::emit_expr(expr);
2337        assert_eq!(
2338            sql, "pg_notify('default__person_updates', (NEW.\"id\")::text)",
2339            "got: {sql}"
2340        );
2341    }
2342
2343    #[test]
2344    fn test_notify_scalar_channel_via_trigger_new_property_access() {
2345        // A bare `select notify(...)` trigger handler has no type at its own
2346        // root, so it compiles as a *free* select — `__new__.name` only
2347        // resolves at all because `compile_notify` falls back to a bound
2348        // anchor as a stand-in ctx when the ambient one is None (confirmed
2349        // live via live_execution_notify.rs before this fallback existed:
2350        // it failed with "expression is not valid in free SELECT context").
2351        let schema = make_schema_with_channels();
2352        let ir_out = super::compile_trigger_handler(
2353            "select notify(Pings, __new__.name)",
2354            "Person",
2355            1, // On::Insert
2356            &schema,
2357        )
2358        .expect("trigger handler compile failed");
2359        let IrStmt::Select(sel) = ir_out.stmt else {
2360            panic!("expected Select")
2361        };
2362        let items = free_items(&sel);
2363        let IrFreeExpr::Scalar(expr) = items[0] else {
2364            panic!("expected scalar")
2365        };
2366        let sql = crate::sql::emit_expr(expr);
2367        assert_eq!(sql, "pg_notify('default__pings', (NEW.\"name\")::text)", "got: {sql}");
2368    }
2369
2370    #[test]
2371    fn test_notify_type_channel_rejects_bare_reference_outside_trigger() {
2372        // __new__ has no binding at all in a plain (non-trigger) compile.
2373        let err = notify_compile_err("SELECT notify(PersonUpdates, __new__)");
2374        assert!(err.contains("only bound inside a trigger handler"), "got: {err}");
2375    }
2376
2377    #[test]
2378    fn notify_rejects_an_oversized_concatenation_at_compile_time() {
2379        // Neither half is over the cap on its own, so the old literal-only
2380        // check passed this straight through to fail at runtime — where it
2381        // aborts the transaction that sent the notification.
2382        let half = "x".repeat(4500);
2383        let err = notify_compile_err(&format!("SELECT notify_raw('c', '{half}' ++ '{half}')"));
2384        assert!(err.contains("8000-byte"), "got: {err}");
2385        assert!(err.contains("at least"), "got: {err}");
2386    }
2387
2388    #[test]
2389    fn notify_allows_a_concatenation_that_still_fits() {
2390        let part = "x".repeat(3000);
2391        let sql = compile_notify_expr(&format!("SELECT notify_raw('c', '{part}' ++ '{part}')"));
2392        assert!(sql.contains("pg_notify"), "got: {sql}");
2393    }
2394
2395    #[test]
2396    fn test_notify_raw_emits_pg_notify_with_two_args() {
2397        let sql = compile_notify_expr("SELECT notify_raw('any_channel', 'raw payload')");
2398        assert_eq!(sql, "pg_notify('any_channel', 'raw payload')", "got: {sql}");
2399    }
2400
2401    #[test]
2402    fn test_notify_payload_literal_over_cap_rejected() {
2403        let huge = "x".repeat(8000);
2404        let err = notify_compile_err(&format!("SELECT notify(Pings, '{huge}')"));
2405        assert!(err.contains("NOTIFY payload limit"), "got: {err}");
2406    }
2407
2408    #[test]
2409    fn test_notify_arity_error() {
2410        let err = notify_compile_err("SELECT notify(Pings)");
2411        assert!(err.contains("takes exactly 2 arguments"), "got: {err}");
2412    }
2413
2414    fn compile_seq(query: &str) -> String {
2415        let schema = make_schema_with_sequence();
2416        let ast = parse::parse(query).expect("parse failed");
2417        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2418        let IrStmt::Select(sel) = ir.stmt else {
2419            panic!("expected Select")
2420        };
2421        let items = free_items(&sel);
2422        let IrFreeExpr::Scalar(expr) = items[0] else {
2423            panic!("expected scalar")
2424        };
2425        crate::sql::emit_expr(expr)
2426    }
2427
2428    #[test]
2429    fn test_sequence_next_emits_nextval() {
2430        let sql = compile_seq("SELECT sequence_next(OrderNumber)");
2431        assert_eq!(sql, r#"nextval('"default"."OrderNumber_seq"')"#, "got: {sql}");
2432    }
2433
2434    #[test]
2435    fn test_sequence_reset_no_val_emits_setval_initial() {
2436        let sql = compile_seq("SELECT sequence_reset(OrderNumber)");
2437        assert_eq!(sql, r#"setval('"default"."OrderNumber_seq"', 1, false)"#, "got: {sql}");
2438    }
2439
2440    #[test]
2441    fn test_sequence_reset_with_val_emits_setval() {
2442        let sql = compile_seq("SELECT sequence_reset(OrderNumber, 1000)");
2443        assert_eq!(
2444            sql, r#"setval('"default"."OrderNumber_seq"', 1000, true)"#,
2445            "got: {sql}"
2446        );
2447    }
2448
2449    #[test]
2450    fn test_sequence_next_rejects_non_sequence_type() {
2451        let schema = make_schema();
2452        let ast = parse::parse("SELECT sequence_next(Person)").unwrap();
2453        assert!(super::compile(&ast, &schema).is_err());
2454    }
2455
2456    #[test]
2457    fn test_alias_bare_compiles_to_type_select() {
2458        let schema = make_schema_with_alias();
2459        let ast = parse::parse("SELECT ActivePersons").unwrap();
2460        let ir = super::compile(&ast, &schema).expect("compile failed");
2461        let sql = crate::sql::emit(&ir).sql;
2462        assert!(sql.contains("\"person\""), "expected person table, got: {sql}");
2463        assert!(sql.contains("18"), "expected age filter, got: {sql}");
2464    }
2465
2466    #[test]
2467    fn test_alias_with_outer_filter_merges() {
2468        let schema = make_schema_with_alias();
2469        let ast = parse::parse("SELECT ActivePersons FILTER .name = 'Alice'").unwrap();
2470        let ir = super::compile(&ast, &schema).expect("compile failed");
2471        let sql = crate::sql::emit(&ir).sql;
2472        assert!(sql.contains("\"person\""), "expected person table, got: {sql}");
2473        assert!(sql.contains("18"), "expected alias filter, got: {sql}");
2474        assert!(sql.contains("'Alice'"), "expected outer filter, got: {sql}");
2475    }
2476
2477    #[test]
2478    fn test_alias_module_qualified_resolves() {
2479        let schema = make_schema_with_alias();
2480        let ast = parse::parse("SELECT default::ActivePersons").unwrap();
2481        let ir = super::compile(&ast, &schema).expect("compile failed");
2482        let sql = crate::sql::emit(&ir).sql;
2483        assert!(sql.contains("\"person\""), "expected person table, got: {sql}");
2484    }
2485
2486    #[test]
2487    fn test_alias_with_shape() {
2488        let schema = make_schema_with_alias();
2489        let ast = parse::parse("SELECT ActivePersons { name, age }").unwrap();
2490        let ir = super::compile(&ast, &schema).expect("compile failed");
2491        let sql = crate::sql::emit(&ir).sql;
2492        assert!(sql.contains("\"name\""), "expected name pointer, got: {sql}");
2493        assert!(sql.contains("\"age\""), "expected age pointer, got: {sql}");
2494    }
2495
2496    #[test]
2497    fn test_alias_whose_own_body_has_a_shape_plus_outer_shape() {
2498        // Regression: when the alias's own body *also* declares a shape
2499        // (a legitimate, documented pattern — e.g. `select Type { field }
2500        // order by ... limit ...`), the outer shape used to wrap the
2501        // inner Shape node wholesale instead of the type reference inside
2502        // it, producing a Shape-of-a-Shape the compiler rejected with
2503        // "expected a type name as SELECT subject" (confirmed live).
2504        use crate::schema::AliasDescriptor;
2505        let mut schema = make_schema();
2506        schema.aliases.push(AliasDescriptor {
2507            name: "OldestActive".into(),
2508            module: "default".into(),
2509            expr: "select Person { name } order by .age desc limit 1".into(),
2510        });
2511        let ast = parse::parse("SELECT OldestActive { name, age }").unwrap();
2512        let ir = super::compile(&ast, &schema).expect("compile failed");
2513        let sql = crate::sql::emit(&ir).sql;
2514        assert!(sql.contains("\"name\""), "expected name pointer, got: {sql}");
2515        assert!(sql.contains("\"age\""), "expected age pointer, got: {sql}");
2516        assert!(
2517            sql.contains("ORDER BY") && sql.contains("LIMIT"),
2518            "alias's own order/limit must still apply, got: {sql}"
2519        );
2520    }
2521
2522    /// Every generated WITH name starts with an underscore, so a binding that
2523    /// starts with one is moved out of that space — `_dml` beside the wrapper
2524    /// of that name used to emit one name five times.
2525    #[test]
2526    fn test_a_binding_named_like_a_generated_cte_gets_its_own_name() {
2527        let ast = parse::parse("WITH _dml := (SELECT Person LIMIT 1) SELECT (UPDATE Person FILTER .id = _dml.id SET { age := 1 }) { name }").unwrap();
2528        let ir = super::compile(&ast, &make_schema()).expect("IR compile failed");
2529        let sql = crate::sql::emit(&ir).sql;
2530        assert_eq!(
2531            sql.matches("\"_dml\" AS (").count(),
2532            1,
2533            "the generated wrapper must keep the name to itself:\n{sql}"
2534        );
2535    }
2536
2537    /// Two `for` bodies may each declare `line`. One WITH clause cannot hold
2538    /// that name twice, so the second binding is emitted under a suffixed one.
2539    #[test]
2540    fn test_two_sibling_bindings_of_one_name_get_separate_with_names() {
2541        let ast = parse::parse(
2542            "WITH a := (FOR p IN (SELECT Person) UNION (WITH line := (SELECT Person) SELECT line.name)), \
2543             b := (FOR p IN (SELECT Person) UNION (WITH line := (SELECT Post) SELECT line.title)) \
2544             SELECT {a := a, b := b}",
2545        )
2546        .unwrap();
2547        let ir = super::compile(&ast, &make_schema()).expect("IR compile failed");
2548        let sql = crate::sql::emit(&ir).sql;
2549        assert_eq!(
2550            sql.matches("\"line\" AS (").count(),
2551            1,
2552            "one name can only be claimed once:\n{sql}"
2553        );
2554    }
2555
2556    /// One computed reached from two places in a statement inlines its `with`
2557    /// bindings twice, which used to emit two CTEs of one name — PostgreSQL's
2558    /// "WITH query name specified more than once". The bindings are identical,
2559    /// so the first stands for both.
2560    #[test]
2561    fn test_a_computed_inlined_twice_hoists_its_binding_once() {
2562        let mut schema = make_schema();
2563        schema.types[0].computed.push(ComputedDescriptor {
2564            name: "ranked".into(),
2565            expression: "(with ordering := ['a', 'b'] select array_get(ordering, 0))".into(),
2566            return_type: Some("text".into()),
2567            link_target: None,
2568            link_multi: false,
2569        });
2570        let ast = parse::parse("SELECT Person { ranked } FILTER .ranked = 'a'").unwrap();
2571        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2572        let sql = crate::sql::emit(&ir).sql;
2573        assert_eq!(
2574            sql.matches("\"ordering\" AS (").count(),
2575            1,
2576            "the shared binding must be hoisted once:\n{sql}"
2577        );
2578    }
2579
2580    /// `to_duration(seconds := …)` — the signature is named-only, and every
2581    /// call site writes the names.
2582    #[test]
2583    fn test_to_duration_takes_its_arguments_by_name() {
2584        let schema = make_schema();
2585        let ast = parse::parse("SELECT std::to_duration(seconds := 90.0)").unwrap();
2586        super::compile(&ast, &schema).expect("named arguments must resolve");
2587    }
2588
2589    /// `(.<backlink[is T].when < now) ?? true` — conduit's backoff gate. The
2590    /// walk is set-valued, so it was gathered as an array and the comparison
2591    /// came out as `timestamptz[] < timestamptz`, which PostgreSQL rejects
2592    /// outright. Read back as a scalar subquery the empty case is NULL and the
2593    /// `??` supplies the default.
2594    #[test]
2595    fn test_an_ordering_comparison_reads_a_set_walk_as_one_value() {
2596        let schema = make_schema();
2597        let ast = parse::parse("SELECT Company FILTER ((.<company[is Person].age < 30) ?? true)").unwrap();
2598        let ir = super::compile(&ast, &schema).expect("compile failed");
2599        let sql = crate::sql::emit(&ir).sql;
2600        assert!(!sql.contains("ARRAY(SELECT"), "the operand must be one value:\n{sql}");
2601        assert!(sql.contains("COALESCE"), "the coalesce must survive:\n{sql}");
2602    }
2603
2604    /// `(select T filter .id = $x).link.prop` is one value, not a one-element
2605    /// set: the filter pins an exclusive property and every step is a forward
2606    /// single link, so nothing multiplies — conduit decodes the result
2607    /// straight into a `bool`.
2608    #[test]
2609    fn test_a_single_link_walk_off_a_pinned_row_is_not_a_set() {
2610        let schema = make_schema();
2611        let ast =
2612            parse::parse("WITH i := (SELECT Person FILTER .id = <uuid>$0) SELECT { c := i.company.name }").unwrap();
2613        let ir = super::compile(&ast, &schema).expect("compile failed");
2614        let sql = crate::sql::emit(&ir).sql;
2615        assert!(!sql.contains("ARRAY(SELECT"), "expected a value, not a set:\n{sql}");
2616    }
2617
2618    /// The same walk off a row the filter does *not* pin stays a set — there
2619    /// may be many matching rows, so there may be many values.
2620    #[test]
2621    fn test_a_single_link_walk_off_an_unpinned_row_is_still_a_set() {
2622        let schema = make_schema();
2623        let ast = parse::parse("WITH i := (SELECT Person FILTER .name = 'x') SELECT { c := i.company.name }").unwrap();
2624        let ir = super::compile(&ast, &schema).expect("compile failed");
2625        let sql = crate::sql::emit(&ir).sql;
2626        assert!(sql.contains("ARRAY(SELECT"), "a walk off many rows is a set:\n{sql}");
2627    }
2628
2629    /// And a step through a multi-link is a set however the root is pinned.
2630    #[test]
2631    fn test_a_multi_link_step_is_a_set_even_off_a_pinned_row() {
2632        let schema = make_schema();
2633        let ast =
2634            parse::parse("WITH i := (SELECT Person FILTER .id = <uuid>$0) SELECT { t := i.posts.title }").unwrap();
2635        let ir = super::compile(&ast, &schema).expect("compile failed");
2636        let sql = crate::sql::emit(&ir).sql;
2637        assert!(sql.contains("ARRAY(SELECT"), "a multi-link step is a set:\n{sql}");
2638    }
2639
2640    /// `(update T set { multi += (insert X …) }).multi { … }` — automator reads
2641    /// back the config field it just appended. Postgres shows a sibling CTE's
2642    /// inserts neither in the junction table nor in the target's own, so both
2643    /// sides of the walk have to read the CTEs that wrote them; reading the
2644    /// base tables returned no rows at all.
2645    #[test]
2646    fn test_a_plain_read_does_not_see_the_statements_own_write() {
2647        // The other side of the boundary the test below draws. One statement
2648        // is one snapshot: a read of the table sees what was there before it
2649        // ran, and only a walk rooted at the mutation sees what it wrote.
2650        // The answer for this shape is 1 and 1.
2651        let schema = make_schema();
2652        let ast =
2653            parse::parse("WITH made := (INSERT Person { name := 'a', age := 1 }) SELECT { after := count(Person) }")
2654                .unwrap();
2655        let ir = super::compile(&ast, &schema).expect("compile failed");
2656        let sql = crate::sql::emit(&ir).sql;
2657        assert!(
2658            sql.to_lowercase().contains("from \"public\".\"person\""),
2659            "the count must read the table, not the CTE that wrote to it:\n{sql}"
2660        );
2661    }
2662
2663    #[test]
2664    fn test_a_walk_off_a_mutation_sees_the_rows_it_just_wrote() {
2665        let schema = make_schema();
2666        let ast = parse::parse(
2667            "SELECT (UPDATE Person FILTER .name = 'a' SET { posts += (INSERT Post { title := 't' }) }).posts { title }",
2668        )
2669        .unwrap();
2670        let ir = super::compile(&ast, &schema).expect("compile failed");
2671        let sql = crate::sql::emit(&ir).sql;
2672        assert!(
2673            sql.contains("__ml_add_0\" AS \"") || sql.contains("JOIN \"_nested_dml_1__ml_add_0\""),
2674            "the junction must be read from the CTE that wrote it:\n{sql}"
2675        );
2676        assert!(
2677            !sql.contains("JOIN \"public\".\"Post\""),
2678            "the targets must come from their own CTE, not the base table:\n{sql}"
2679        );
2680    }
2681
2682    /// `(select …).company[is Company].name` — conduit derives a trust tier
2683    /// through a walk like this, which is legal. A `[is T]` step has no
2684    /// expression form, so the parser used to reject the whole walk; it is now
2685    /// carried as `PathStepOn` and re-rooted at a binding.
2686    #[test]
2687    fn test_a_type_intersection_may_follow_a_sub_select() {
2688        let schema = make_schema();
2689        let ast = parse::parse("SELECT (SELECT Person LIMIT 1).company[is Company].name").unwrap();
2690        let ir = super::compile(&ast, &schema).expect("compile failed");
2691        let sql = crate::sql::emit(&ir).sql;
2692        // The trailing field must be read as a column. Reading it as jsonb off
2693        // the object id compiled fine and then failed at execution with
2694        // `operator does not exist: uuid -> unknown`.
2695        assert!(
2696            !sql.contains("->'name'"),
2697            "the field must not be jsonb off an id:\n{sql}"
2698        );
2699        assert!(sql.contains("\"name\""), "the field must be read as a column:\n{sql}");
2700    }
2701
2702    /// A step that has no path, binding or sub-select to walk off is still an
2703    /// error — it just reports at compile time now rather than at parse time.
2704    #[test]
2705    fn test_a_type_intersection_on_a_value_is_rejected() {
2706        let schema = make_schema();
2707        let ast = parse::parse("SELECT (1 + 2)[is Company]").unwrap();
2708        let Err(error) = super::compile(&ast, &schema) else {
2709            panic!("a type intersection on a number is not meaningful");
2710        };
2711        assert!(error.to_string().contains("needs a path, a binding"), "got: {error}");
2712    }
2713
2714    /// `select (select (A union B) { … } limit 1) { … }` — ledger resolves a
2715    /// listing from either of two backlinks this way. The inner select
2716    /// compiles alone and the `with l := … select l { … }` spelling works;
2717    /// only wrapping it inline was rejected, because a union subject has no
2718    /// single type name for the outer select to take.
2719    #[test]
2720    fn test_a_select_may_wrap_a_nested_union_subject_select() {
2721        let schema = make_schema();
2722        let ast =
2723            parse::parse("SELECT (SELECT (Person.company UNION Person.company) { name } LIMIT 1) { name }").unwrap();
2724        let error = super::compile(&ast, &schema)
2725            .err()
2726            .map(|e| e.to_string())
2727            .unwrap_or_default();
2728        assert!(
2729            !error.contains("expected a type name as SELECT subject"),
2730            "the union subject must be hoisted, got: {error}"
2731        );
2732    }
2733
2734    /// `select (update T …).link { … }` — automator reads back the config
2735    /// field it just appended. `(select …).link { … }` already worked; only a
2736    /// walk off a *mutation* was rejected.
2737    #[test]
2738    fn test_a_select_subject_may_walk_off_a_mutation() {
2739        let schema = make_schema();
2740        let ast =
2741            parse::parse("SELECT (UPDATE Person FILTER .name = 'a' SET { name := 'b' }).company { name }").unwrap();
2742        let ir = super::compile(&ast, &schema).expect("compile failed");
2743        let sql = crate::sql::emit(&ir).sql;
2744        assert!(
2745            sql.contains("\"_nested_dml_0\" AS ("),
2746            "the mutation must run as a CTE:\n{sql}"
2747        );
2748        assert!(sql.contains("UPDATE"), "the mutation must still run:\n{sql}");
2749    }
2750
2751    /// `update (select T filter …).link set { … }` — conduit refreshes a
2752    /// connector's stored credentials this way. The equivalent
2753    /// `with s := (select …) update s.link set …` already worked; only the
2754    /// inline sub-select was rejected.
2755    #[test]
2756    fn test_an_update_subject_may_walk_off_a_sub_select() {
2757        let schema = make_schema();
2758        let inline = parse::parse("UPDATE (SELECT Person FILTER .name = 'a').company SET { name := 'b' }").unwrap();
2759        let bound =
2760            parse::parse("WITH s := (SELECT Person FILTER .name = 'a') UPDATE s.company SET { name := 'b' }").unwrap();
2761        // `make_schema`'s Company has no `id`, so both forms stop at the same
2762        // later point — which is the assertion: the inline one is no longer
2763        // rejected earlier than the binding-based one.
2764        let inline_err = super::compile(&inline, &schema).err().map(|e| e.to_string());
2765        let bound_err = super::compile(&bound, &schema).err().map(|e| e.to_string());
2766        assert_eq!(inline_err, bound_err, "the two spellings must compile alike");
2767        assert!(
2768            !inline_err
2769                .unwrap_or_default()
2770                .contains("expected a type name as SELECT subject"),
2771            "the sub-select subject must be accepted"
2772        );
2773    }
2774
2775    /// `select (for … union …)` — automator counts runs per status this way.
2776    /// The loop compiles on its own; only the wrapper was rejected.
2777    #[test]
2778    fn test_a_for_loop_may_be_a_select_subject() {
2779        let schema = make_schema();
2780        let ast = parse::parse("SELECT (FOR s IN {1, 2} UNION (SELECT { a := s }))").unwrap();
2781        let ir = super::compile(&ast, &schema).expect("compile failed");
2782        assert!(matches!(ir.stmt, super::IrStmt::For(_)), "expected the loop itself");
2783    }
2784
2785    /// A `<json>` cast as a shape element is an ordinary column of the row, so
2786    /// it must carry the pointer's name and position. Describing it with the
2787    /// root-only `JsonScalar` (which carries neither) made `pylon-client` panic
2788    /// with "shape node kind never appears as an object's own pointer".
2789    #[test]
2790    fn test_a_json_cast_in_a_shape_is_a_named_pointer() {
2791        use crate::query::ShapeNode;
2792        let schema = make_schema();
2793        let ast = parse::parse("SELECT Person { j := <json>.name }").unwrap();
2794        let ir = super::compile(&ast, &schema).expect("compile failed");
2795        let shape = crate::sql::emit(&ir).shape;
2796        let ShapeNode::Object { pointers, .. } = &shape.root else {
2797            panic!("expected an object shape, got {:?}", shape.root);
2798        };
2799        let pointer = pointers
2800            .iter()
2801            .find(|node| matches!(node, ShapeNode::Scalar { name, .. } if name == "j"))
2802            .unwrap_or_else(|| panic!("no scalar pointer named 'j' in {pointers:?}"));
2803        assert!(matches!(pointer, ShapeNode::Scalar { .. }));
2804    }
2805
2806    /// The same cast at the top level keeps `JsonScalar`: there the result
2807    /// column is the value itself rather than a field of a row tuple.
2808    #[test]
2809    fn test_a_top_level_json_cast_stays_a_root_shaped_node() {
2810        use crate::query::ShapeNode;
2811        let schema = make_schema();
2812        let ast = parse::parse("SELECT <json>'x'").unwrap();
2813        let ir = super::compile(&ast, &schema).expect("compile failed");
2814        let shape = crate::sql::emit(&ir).shape;
2815        assert!(matches!(shape.root, ShapeNode::JsonScalar), "got {:?}", shape.root);
2816    }
2817
2818    /// `std::Endian` is a stdlib enum with no PostgreSQL enum type behind it,
2819    /// so its members have to compile to a plain text literal.
2820    #[test]
2821    fn test_stdlib_enum_member_compiles_to_a_text_literal() {
2822        let schema = make_schema();
2823        let ast = parse::parse("SELECT std::Endian.Big").unwrap();
2824        let ir = super::compile(&ast, &schema).expect("compile failed");
2825        let sql = crate::sql::emit(&ir).sql;
2826        assert!(sql.contains("'Big'::text"), "expected a text literal, got: {sql}");
2827    }
2828
2829    #[test]
2830    fn test_unknown_stdlib_enum_member_is_rejected() {
2831        let schema = make_schema();
2832        let ast = parse::parse("SELECT std::Endian.Middle").unwrap();
2833        let Err(error) = super::compile(&ast, &schema) else {
2834            panic!("Middle is not a member of std::Endian");
2835        };
2836        assert!(error.to_string().contains("has no member 'Middle'"), "got: {error}");
2837    }
2838
2839    /// The single-argument form has to reach `to_bytes_uuid`, not the
2840    /// two-argument `to_bytes(str, encoding)` — picking the latter compiled
2841    /// fine and then failed at runtime with "function _pylon.to_bytes(uuid)
2842    /// does not exist".
2843    #[test]
2844    fn test_to_bytes_of_a_uuid_selects_the_uuid_overload() {
2845        let schema = make_schema();
2846        let ast = parse::parse("SELECT std::to_int32(std::to_bytes(<uuid>$0)[12:16], std::Endian.Big)").unwrap();
2847        let ir = super::compile(&ast, &schema).expect("compile failed");
2848        let sql = crate::sql::emit(&ir).sql;
2849        assert!(sql.contains("to_bytes_uuid"), "expected to_bytes_uuid, got: {sql}");
2850        assert!(sql.contains("to_int32_bytes"), "expected to_int32_bytes, got: {sql}");
2851    }
2852
2853    /// A call no overload can accept used to resolve to whichever overload
2854    /// was registered first, so `str_lower(a, b)` compiled to `lower(a, b)`
2855    /// and only failed once PostgreSQL saw a signature nobody wrote.
2856    #[test]
2857    fn a_stdlib_call_with_the_wrong_argument_count_is_rejected() {
2858        let schema = make_schema();
2859        let ast = parse::parse("SELECT std::str_lower('A', 'B')").unwrap();
2860        let Err(err) = super::compile(&ast, &schema) else {
2861            panic!("wrong arity must not compile")
2862        };
2863        let msg = err.to_string();
2864        assert!(msg.contains("std::str_lower"), "{msg}");
2865        assert!(msg.contains("takes 1 argument(s), got 2"), "{msg}");
2866    }
2867
2868    /// A near-miss name gets pointed at the real one — the shape the
2869    /// `uuid_generate_v7j` default took, one character away from a function
2870    /// that does exist.
2871    #[test]
2872    fn an_unknown_function_suggests_the_closest_real_one() {
2873        let schema = make_schema();
2874        let ast = parse::parse("SELECT std::uuid_generate_v7j()").unwrap();
2875        let Err(err) = super::compile(&ast, &schema) else {
2876            panic!("an unknown function must not compile")
2877        };
2878        let msg = err.to_string();
2879        assert!(msg.contains("does not exist"), "{msg}");
2880        assert!(msg.contains("did you mean std::uuid_generate_v7()?"), "{msg}");
2881    }
2882
2883    /// A name that is real but reached for in the wrong namespace gets told
2884    /// which one holds it, rather than fuzzy-matched against a neighbour
2885    /// that merely looks similar.
2886    #[test]
2887    fn a_function_in_another_namespace_says_where_it_lives() {
2888        let schema = make_schema();
2889        let ast = parse::parse("SELECT std::pi()").unwrap();
2890        let Err(err) = super::compile(&ast, &schema) else {
2891            panic!("pi lives in math, not std")
2892        };
2893        assert!(err.to_string().contains("it lives in math, use math::pi()"), "{err}");
2894    }
2895
2896    /// The arities are listed, not just the first overload's — `str_trim`
2897    /// takes one argument or two, and a three-argument call has to say so.
2898    #[test]
2899    fn an_arity_error_names_every_arity_the_overload_set_accepts() {
2900        let schema = make_schema();
2901        let ast = parse::parse("SELECT std::str_trim('A', 'B', 'C')").unwrap();
2902        let Err(err) = super::compile(&ast, &schema) else {
2903            panic!("wrong arity must not compile")
2904        };
2905        assert!(err.to_string().contains("takes 1 or 2 argument(s), got 3"), "{err}");
2906    }
2907
2908    /// A trailing variadic parameter absorbs any number of arguments, so the
2909    /// arity gate must not reject the calls it exists to allow.
2910    #[test]
2911    fn a_variadic_stdlib_call_accepts_extra_arguments() {
2912        let schema = make_schema();
2913        for query in [
2914            "SELECT std::json_get(<json>$0, 'a')",
2915            "SELECT std::json_get(<json>$0, 'a', 'b', 'c')",
2916        ] {
2917            let ast = parse::parse(query).unwrap();
2918            super::compile(&ast, &schema).unwrap_or_else(|e| panic!("{query} must compile: {e}"));
2919        }
2920    }
2921
2922    /// Right count, wrong types — reported as the overload mismatch it is,
2923    /// listing what the function does accept.
2924    #[test]
2925    fn a_stdlib_call_with_an_unacceptable_argument_type_is_rejected() {
2926        let schema = make_schema();
2927        let ast = parse::parse("SELECT std::str_lower(<int64>$0)").unwrap();
2928        let Err(err) = super::compile(&ast, &schema) else {
2929            panic!("wrong argument type must not compile")
2930        };
2931        let msg = err.to_string();
2932        assert!(msg.contains("no overload accepting (int8)"), "{msg}");
2933        assert!(msg.contains("(str)"), "{msg}");
2934    }
2935
2936    /// `??` and `if … else` are transparent to overload resolution: both yield
2937    /// a value of their branches' own type. While they read as untyped, every
2938    /// `str_lower(x ?? default)` — the shape a jurisdiction, a locale or any
2939    /// other optional-with-a-fallback is written in — failed to resolve.
2940    #[test]
2941    fn a_stdlib_call_over_a_coalesce_or_conditional_resolves_its_branch_type() {
2942        let schema = make_schema();
2943        for query in [
2944            "SELECT std::str_lower(<optional str>$0 ?? 'DE')",
2945            "SELECT std::str_lower('DE' ?? <optional str>$0)",
2946            "WITH j := (<optional str>$0 ?? 'DE') SELECT std::str_lower(j)",
2947            "SELECT std::str_lower(<str>$0 if <bool>$1 else 'DE')",
2948            "SELECT std::len(<optional str>$0 ?? 'DE')",
2949        ] {
2950            let ast = parse::parse(query).unwrap();
2951            super::compile(&ast, &schema).unwrap_or_else(|e| panic!("{query} must compile: {e}"));
2952        }
2953    }
2954
2955    /// Every other expression that carries a value of a type it does not spell
2956    /// out itself. Each of these reads as untyped without inference of its own,
2957    /// which under overload resolution is the difference between compiling and
2958    /// not.
2959    #[test]
2960    fn a_stdlib_call_over_a_pass_through_expression_resolves_the_value_type() {
2961        let schema = make_schema();
2962        for query in [
2963            // A loop variable holds one element of what it iterates.
2964            "SELECT (FOR code IN std::array_unpack(<array<str>>$0) UNION (SELECT std::str_lower(code)))",
2965            // A free object's field is typed by what the field binds.
2966            "WITH x := { a := 'DE' } SELECT std::str_lower(x.a)",
2967            // Negation keeps its operand's type.
2968            "SELECT math::abs(-3)",
2969            "SELECT math::abs(-(<int64>$0))",
2970            // An array subscript is one element, not the array.
2971            "SELECT std::str_lower((<array<str>>$0)[0])",
2972            // The array a stdlib call returns is named by its own overload.
2973            "SELECT std::str_title(std::str_split(<str>$0, '::')[0])",
2974            // A datetime minus a datetime is a duration.
2975            "SELECT std::duration_to_seconds(std::datetime_of_transaction() - <datetime>$0)",
2976            "SELECT std::duration_to_seconds(<duration>$0 + <duration>$0)",
2977        ] {
2978            let ast = parse::parse(query).unwrap();
2979            super::compile(&ast, &schema).unwrap_or_else(|e| panic!("{query} must compile: {e}"));
2980        }
2981    }
2982
2983    /// Looking through a `??` types the call, it does not excuse it: a branch
2984    /// whose type is known and wrong is still no overload's argument.
2985    #[test]
2986    fn a_stdlib_call_over_a_coalesce_of_the_wrong_type_is_still_rejected() {
2987        let schema = make_schema();
2988        let ast = parse::parse("SELECT std::str_lower(<optional int64>$0 ?? 3)").unwrap();
2989        let Err(err) = super::compile(&ast, &schema) else {
2990            panic!("wrong argument type must not compile")
2991        };
2992        assert!(err.to_string().contains("no overload accepting (int8)"), "{err}");
2993    }
2994
2995    /// The byte-order forms sit beside the one-argument `to_intN(str)` casts,
2996    /// so each has to resolve on arity to its own `_bytes` function rather
2997    /// than to the cast that shares its name.
2998    #[test]
2999    fn test_to_int16_and_to_int64_over_bytes_select_the_bytes_overload() {
3000        let schema = make_schema();
3001        for (query, expected) in [
3002            (
3003                "SELECT std::to_int16(std::to_bytes(<uuid>$0)[14:16], std::Endian.Big)",
3004                "to_int16_bytes",
3005            ),
3006            (
3007                "SELECT std::to_int64(std::to_bytes(<uuid>$0)[0:8], std::Endian.Little)",
3008                "to_int64_bytes",
3009            ),
3010        ] {
3011            let ast = parse::parse(query).unwrap();
3012            let ir = super::compile(&ast, &schema).expect("compile failed");
3013            let sql = crate::sql::emit(&ir).sql;
3014            assert!(sql.contains(expected), "expected {expected}, got: {sql}");
3015        }
3016    }
3017
3018    #[test]
3019    fn test_positional_param_names() {
3020        let schema = make_schema();
3021        let ast = parse::parse("SELECT Person FILTER .name = $0").unwrap();
3022        let ir = super::compile(&ast, &schema).expect("compile failed");
3023        assert_eq!(ir.params, vec!["0"]);
3024    }
3025
3026    #[test]
3027    fn test_multiple_positional_param_names_in_order() {
3028        let schema = make_schema();
3029        let ast = parse::parse("SELECT Person FILTER .name = $0 AND .age > $1").unwrap();
3030        let ir = super::compile(&ast, &schema).expect("compile failed");
3031        assert_eq!(ir.params, vec!["0", "1"]);
3032    }
3033
3034    #[test]
3035    fn test_repeated_positional_param_single_slot() {
3036        let schema = make_schema();
3037        let ast = parse::parse("SELECT Person FILTER .name = $0 OR .name = $0").unwrap();
3038        let ir = super::compile(&ast, &schema).expect("compile failed");
3039        assert_eq!(ir.params, vec!["0"], "repeated $0 must occupy a single slot");
3040    }
3041
3042    /// What a parameter's tuple cast recorded: whether it is an array, the
3043    /// nominal type name, and the member names the value is keyed by. A
3044    /// client holding `("X-Foo", "bar")` cannot know those names; the cast
3045    /// does.
3046    #[derive(Debug, PartialEq)]
3047    struct ParamTupleKeys {
3048        is_array: bool,
3049        type_name: Option<String>,
3050        keys: Vec<String>,
3051    }
3052
3053    fn param_tuple_keys(query: &str, schema: &SchemaDescriptor) -> Vec<Option<ParamTupleKeys>> {
3054        let ast = parse::parse(query).unwrap();
3055        let ir = super::compile(&ast, schema).expect("compile failed");
3056        ir.param_tuple_types
3057            .iter()
3058            .map(|plan| {
3059                plan.as_ref().map(|plan| ParamTupleKeys {
3060                    is_array: plan.is_array,
3061                    type_name: plan.type_name.clone(),
3062                    keys: plan.members.iter().map(|m| m.key.clone().unwrap_or_default()).collect(),
3063                })
3064            })
3065            .collect()
3066    }
3067
3068    #[test]
3069    fn test_a_tuple_cast_records_its_member_names_against_the_parameter() {
3070        let schema = make_schema();
3071        assert_eq!(
3072            param_tuple_keys("SELECT <tuple<street: str, zip: str>>$address", &schema),
3073            vec![Some(ParamTupleKeys {
3074                is_array: false,
3075                type_name: None,
3076                keys: vec!["street".to_string(), "zip".to_string()],
3077            })]
3078        );
3079    }
3080
3081    #[test]
3082    fn test_an_array_of_tuples_cast_records_one_element_s_member_names() {
3083        let schema = make_schema();
3084        assert_eq!(
3085            param_tuple_keys("SELECT <array<tuple<name: str, value: str>>>$headers", &schema),
3086            vec![Some(ParamTupleKeys {
3087                is_array: true,
3088                type_name: None,
3089                keys: vec!["name".to_string(), "value".to_string()],
3090            })]
3091        );
3092    }
3093
3094    #[test]
3095    fn test_a_nominal_named_tuple_cast_records_its_type_name_too() {
3096        use crate::schema::{NamedTupleDescriptor, TupleMemberDescriptor, TupleMemberKind};
3097        let mut schema = make_schema();
3098        schema.named_tuples.push(NamedTupleDescriptor {
3099            name: "Point".into(),
3100            module: "default".into(),
3101            members: vec![
3102                TupleMemberDescriptor {
3103                    name: Some("x".into()),
3104                    kind: TupleMemberKind::Scalar {
3105                        pg_type: "float8".into(),
3106                    },
3107                },
3108                TupleMemberDescriptor {
3109                    name: Some("y".into()),
3110                    kind: TupleMemberKind::Scalar {
3111                        pg_type: "float8".into(),
3112                    },
3113                },
3114            ],
3115        });
3116        assert_eq!(
3117            param_tuple_keys("SELECT <array<default::Point>>$points", &schema),
3118            vec![Some(ParamTupleKeys {
3119                is_array: true,
3120                type_name: Some("default::Point".to_string()),
3121                keys: vec!["x".to_string(), "y".to_string()],
3122            })]
3123        );
3124    }
3125
3126    #[test]
3127    fn test_a_parameter_cast_to_a_plain_scalar_records_no_tuple_plan() {
3128        let schema = make_schema();
3129        assert_eq!(param_tuple_keys("SELECT <str>$name", &schema), vec![None]);
3130    }
3131
3132    #[test]
3133    fn test_a_tuple_cast_in_expression_position_records_its_member_names() {
3134        // The write that matters — `set { headers := <array<tuple<…>>>$headers }`
3135        // — compiles its cast in expression position rather than as the free
3136        // select the cases above take.
3137        let schema = make_schema();
3138        assert_eq!(
3139            param_tuple_keys(
3140                "SELECT { name := <str>$name, address := <tuple<street: str, zip: str>>$address }",
3141                &schema
3142            ),
3143            vec![
3144                None,
3145                Some(ParamTupleKeys {
3146                    is_array: false,
3147                    type_name: None,
3148                    keys: vec!["street".to_string(), "zip".to_string()],
3149                })
3150            ]
3151        );
3152    }
3153}