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    /// Session global: emits `$N::pg_type` directly. The parameter slot carries the `__global__` prefix.
1067    GlobalParam {
1068        index: usize,
1069        pg_type: String,
1070    },
1071    /// Computed global reference: emits `(SELECT "value" FROM "cte_name")`.
1072    GlobalRef {
1073        cte_name: String,
1074    },
1075    /// Index access `expr[i]`: `substr(expr, i+1, 1)` for strings/bytes, `(expr)[i+1]` for arrays.
1076    Subscript {
1077        expr: Box<IrExpr>,
1078        index: Box<IrExpr>,
1079        is_array: bool,
1080    },
1081    /// Named tuple / jsonb field access: `(expr)->'field'` (returns jsonb).
1082    JsonbField {
1083        expr: Box<IrExpr>,
1084        field: String,
1085    },
1086    /// Positional tuple index into a jsonb array: `(expr)->index` (returns jsonb).
1087    /// Runtime fallback for `.N` tuple indexing when `expr` isn't a literal
1088    /// tuple constant-foldable at compile time (e.g. a $param or cast result).
1089    JsonbIndex {
1090        expr: Box<IrExpr>,
1091        index: usize,
1092    },
1093    /// Slice access `expr[lower:upper]`: `substr` for strings/bytes, PG subscript for arrays.
1094    Slice {
1095        expr: Box<IrExpr>,
1096        lower: Option<Box<IrExpr>>,
1097        upper: Option<Box<IrExpr>>,
1098        is_array: bool,
1099    },
1100    /// An object-returning user function projected down to one of its
1101    /// columns: `(SELECT alias."col" FROM module.fn(args) AS alias …)`.
1102    /// `shape` always holds exactly one pointer — the projected column —
1103    /// which is what makes an otherwise object-valued call usable inside a
1104    /// larger expression.
1105    FnSubquery(Box<IrFunctionSelect>),
1106    /// Detached path as a scalar subquery: `(SELECT scalar FROM root [JOINs])`.
1107    /// Used when `detached TypeName.prop` appears in a schema-bound expression context.
1108    PathSubquery(Box<IrPathSelect>),
1109    /// A named parameter reference inside a user-defined function body.
1110    /// Emitted as a double-quoted SQL identifier: `"param_name"`.
1111    FnParam {
1112        name: String,
1113        pg_type: String,
1114    },
1115    /// Verbatim SQL text, emitted parenthesized exactly as given. Never
1116    /// produced by ordinary PyQL compilation — only used to substitute an
1117    /// INSERT rewrite's self-reference (`.name`) to a property that has no
1118    /// explicit assignment in this statement with that property's own
1119    /// `default_sql`, the same value Postgres's column DEFAULT would have
1120    /// produced. A plain `INSERT ... VALUES (...)` has no FROM-clause for a
1121    /// real `ColumnRef` to resolve against (confirmed live — "missing
1122    /// FROM-clause entry"), unlike UPDATE's SET clause, which can reference
1123    /// the table's own alias validly, so this substitution is INSERT-only.
1124    RawSql(String),
1125}
1126
1127// ── Vector search ─────────────────────────────────────────────────────────────
1128
1129/// `select vector::search(Type, $vec) { object { … }, distance }`
1130///
1131/// Emits a single SELECT from the type's table that computes the distance
1132/// inline and returns a virtual `{ object, distance }` shape.
1133///
1134/// Text overload: `vector::search(Type, query := $text)` — the Python layer
1135/// embeds the text first and injects the resulting vector as `__deferred_vec__`.
1136#[derive(Debug, Clone)]
1137pub struct IrVectorSearch {
1138    /// The searched type as an `IrSource` (table + alias).
1139    pub source: IrSource,
1140    /// pgvector column name, e.g. `__vector__`.
1141    pub vector_col: String,
1142    /// pgvector distance operator: `<=>`, `<->`, or `<#>`.
1143    pub distance_op: &'static str,
1144    /// The query vector expression (e.g. `$1::vector`).
1145    /// For the text overload this is the `__deferred_vec__` param cast to vector.
1146    pub query_expr: IrExpr,
1147    /// Pointers to include in the `object` sub-tuple (from the `object { … }` shape).
1148    /// Empty means no explicit shape was given; the SQL emitter uses all properties.
1149    pub object_shape: Vec<IrShapePointer>,
1150    pub filter: Option<IrExpr>,
1151    /// `None` = no ORDER BY; `Some(dir)` = ORDER BY distance in that direction.
1152    /// Only distance ordering is supported for v1.
1153    pub order_by_distance: Option<IrSortDir>,
1154    pub offset: Option<IrExpr>,
1155    pub limit: Option<IrExpr>,
1156    // ── text overload (inference) fields ──────────────────────────────────────
1157    /// Name of the user's `query :=` param; empty string when an inline literal.
1158    pub inference_query_param_name: Option<String>,
1159    /// Inline literal query text when `query := 'some text'`.
1160    pub inference_query_literal: Option<String>,
1161    /// Embedding model identifier from the `VectorIndexDescriptor`, e.g. `"mistral-embed"`.
1162    pub inference_model: Option<String>,
1163    /// Qualified type name for provider lookup, e.g. `"default::Product"`.
1164    pub inference_type_name: Option<String>,
1165    /// Vector index name for provider lookup (`None` = default index).
1166    pub inference_index_name: Option<Option<String>>,
1167}
1168
1169// ── Full-text search ──────────────────────────────────────────────────────────
1170
1171/// `select fts::search(Type, $query) { object { … }, score }`
1172///
1173/// Emits a SELECT with a `WHERE tsvector @@ tsquery` filter and a `ts_rank`
1174/// score returned alongside the matched object.
1175#[derive(Debug, Clone)]
1176pub struct IrFtsSearch {
1177    /// The searched type as an `IrSource` (table + alias).
1178    pub source: IrSource,
1179    /// Backend that owns this search index.
1180    pub backend: crate::schema::SearchBackend,
1181    /// tsvector column name, e.g. `__search__` (Postgres backend only).
1182    pub search_col: String,
1183    /// PostgreSQL tsquery constructor (Postgres backend only).
1184    pub tsquery_fn: &'static str,
1185    /// The query text expression (e.g. `$1`).
1186    pub query_expr: IrExpr,
1187    /// Pointers to include in the `object` sub-tuple.
1188    pub object_shape: Vec<IrShapePointer>,
1189    pub filter: Option<IrExpr>,
1190    pub order_by_rank: Option<IrSortDir>,
1191    pub offset: Option<IrExpr>,
1192    pub limit: Option<IrExpr>,
1193    /// Remote index name (derived from type + index_name, set for deferred backends).
1194    pub deferred_index_name: Option<String>,
1195    /// Name of the user's query-text param (e.g. "query"), for the deferred search plan.
1196    pub deferred_query_param_name: Option<String>,
1197    /// Inline literal query text (when not a param), for the deferred search plan.
1198    pub deferred_query_literal: Option<String>,
1199    /// Param index for the uuid[] IDs injected by the Python layer (deferred backend).
1200    pub deferred_ids_param: Option<usize>,
1201    /// Param index for the float8[] scores injected by the Python layer (deferred backend).
1202    pub deferred_scores_param: Option<usize>,
1203}
1204
1205// ── User-defined function SELECT ─────────────────────────────────────────────
1206
1207/// `select fn(args) { shape }` — a SELECT over the result set of a user-defined
1208/// object-returning function.  Emits `FROM "module"."fn"(args) AS alias`.
1209#[derive(Debug, Clone)]
1210pub struct IrFunctionSelect {
1211    pub fn_module: String,
1212    pub fn_name: String,
1213    pub fn_args: Vec<IrExpr>,
1214    /// Alias for the function result row source.
1215    pub alias: String,
1216    /// Qualified return type name (e.g. `account::Account`).
1217    pub type_name: String,
1218    /// True when the return type is a polymorphic interface.
1219    pub polymorphic: bool,
1220    /// Concrete implementors when polymorphic = true.
1221    pub poly_implementors: Vec<IrPolyImplementor>,
1222    /// Interface column names for the UNION ALL branches.
1223    pub poly_columns: Vec<String>,
1224    pub shape: Vec<IrShapePointer>,
1225    pub filter: Option<IrExpr>,
1226    pub order_by: Vec<IrSort>,
1227    pub offset: Option<IrExpr>,
1228    pub limit: Option<IrExpr>,
1229    pub distinct: bool,
1230}
1231
1232#[derive(Debug, Clone)]
1233pub struct IrBinOp {
1234    pub left: IrExpr,
1235    pub op: BinOpKind,
1236    pub right: IrExpr,
1237}
1238
1239#[derive(Debug, Clone)]
1240pub struct IrUnaryOp {
1241    pub op: UnaryOpKind,
1242    pub operand: IrExpr,
1243}
1244
1245#[derive(Debug, Clone)]
1246pub struct IrFunctionCall {
1247    /// What the resolved overload returns, when that is a nameable scalar.
1248    ///
1249    /// `name` alone cannot answer this: a `SqlBuiltin` overload is rewritten
1250    /// to its PostgreSQL name here (`str_lower` becomes `lower`), so looking
1251    /// the result back up in the stdlib registry by `name` finds nothing and
1252    /// silently types the call as unknown. Recording it at resolution time —
1253    /// the one place the chosen `FnDescriptor`/`FunctionDescriptor` is in
1254    /// hand — is what lets `infer_ir_type` see through a call at all, which
1255    /// in turn is what lets a nested call (`str_lower(str_trim(x))`) resolve
1256    /// its outer overload by type rather than by falling back to the first
1257    /// one registered. `None` for a synthesised call the compiler builds
1258    /// itself and for a polymorphic result (`max`, `min`, `sum`) that is
1259    /// typed from its argument instead.
1260    pub return_pg_type: Option<String>,
1261    pub schema: Option<String>,
1262    pub name: String,
1263    pub args: Vec<IrExpr>,
1264    /// Set for `SqlExpression` impls: raw SQL template where `$1`, `$2`, … are
1265    /// replaced with the emitted arg expressions.
1266    pub sql_template: Option<String>,
1267}
1268
1269#[derive(Debug, Clone)]
1270pub struct IrTypeCast {
1271    pub expr: IrExpr,
1272    /// PostgreSQL cast target, e.g. `text`, `int8`, `uuid`.
1273    pub pg_type: String,
1274    /// `Some` when the cast target is a named-tuple type (nominal or
1275    /// structural) whose member shape is statically known — drives building
1276    /// a rich `ShapeNode::NamedTuple` with real per-member decode instead of
1277    /// an opaque jsonb blob.
1278    pub tuple_shape: Option<TupleCastShape>,
1279}
1280
1281#[derive(Debug, Clone)]
1282pub struct TupleCastShape {
1283    /// `Some` for a nominal `@pylon.named_tuple` cast target (hydrates to
1284    /// the registered dataclass); `None` for a structural `tuple<...>`.
1285    pub type_name: Option<String>,
1286    pub members: Vec<crate::query::JsonMember>,
1287}
1288
1289#[derive(Debug, Clone)]
1290pub struct IrIfElse {
1291    pub condition: IrExpr,
1292    pub if_: IrExpr,
1293    pub else_: IrExpr,
1294}
1295
1296#[derive(Debug, Clone)]
1297pub enum IrLiteral {
1298    Str(String),
1299    Int(i64),
1300    Float(f64),
1301    Bool(bool),
1302}
1303
1304// ── Sort ────────────────────────────────────────────────────────────────────────
1305
1306#[derive(Debug, Clone)]
1307pub struct IrSort {
1308    pub expr: IrExpr,
1309    pub direction: IrSortDir,
1310    pub nulls: IrNulls,
1311}
1312
1313#[derive(Debug, Clone)]
1314pub enum IrSortDir {
1315    Asc,
1316    Desc,
1317}
1318
1319#[derive(Debug, Clone)]
1320pub enum IrNulls {
1321    First,
1322    Last,
1323}
1324
1325// ── Compiled output ──────────────────────────────────────────────────────────────
1326
1327/// A compiled mutation rewrite: a property column whose value is overridden by
1328/// a schema-defined expression at INSERT/UPDATE time.
1329#[derive(Debug, Clone)]
1330pub struct IrRewrite {
1331    /// PostgreSQL column name of the property being overridden.
1332    pub column: String,
1333    /// Compiled expression that produces the override value.
1334    /// For INSERT: column refs are substituted with the corresponding assignment
1335    /// expressions so the result is self-contained in a VALUES clause.
1336    /// For UPDATE: column refs use the table alias and are valid in a SET clause.
1337    pub expr: IrExpr,
1338}
1339
1340/// One `name := (stmt)` binding from a WITH block.
1341#[derive(Debug, Clone)]
1342pub struct IrCteDef {
1343    pub name: String,
1344    pub stmt: IrStmt,
1345    /// Qualified type name of the result set (e.g. `"default::Person"`).
1346    /// Empty for free expressions.
1347    pub type_name: String,
1348    /// The `_for_<slot>` iterator this binding reads, when it reads one. Such
1349    /// a binding holds one value *per iteration*, not one for the statement,
1350    /// so it cannot be evaluated once ahead of the loop — it joins the
1351    /// iterator and carries its key for consumers to pair on.
1352    pub correlated_to: Option<String>,
1353}
1354
1355/// A session global CTE: `WITH "cte_name" AS (SELECT $N::pg_type AS "value")`.
1356#[derive(Debug, Clone)]
1357pub struct IrSessionGlobalCte {
1358    pub cte_name: String,
1359    pub qualified_name: String,
1360    pub param_index: usize,
1361    pub pg_type: String,
1362}
1363
1364/// A computed global CTE: `WITH "cte_name" AS (<compiled stmt returning "value" column>)`.
1365#[derive(Debug, Clone)]
1366pub struct IrComputedGlobalCte {
1367    pub cte_name: String,
1368    pub qualified_name: String,
1369    pub stmt: IrStmt,
1370}
1371
1372#[derive(Debug, Clone)]
1373pub enum IrGlobalCte {
1374    Session(IrSessionGlobalCte),
1375    /// Boxed: a computed global carries a whole compiled sub-select and is
1376    /// ~8x the size of a session global, and these live in a `Vec` where the
1377    /// session variant is the common case.
1378    Computed(Box<IrComputedGlobalCte>),
1379}
1380
1381impl IrGlobalCte {
1382    pub fn cte_name(&self) -> &str {
1383        match self {
1384            Self::Session(s) => &s.cte_name,
1385            Self::Computed(c) => &c.cte_name,
1386        }
1387    }
1388}
1389
1390/// The result of the IR compilation step.
1391/// Carries the query plan and the ordered list of named parameters, which the
1392/// SQL emitter uses to emit `$1 … $N` and the client uses to bind values.
1393pub struct IrOutput {
1394    pub stmt: IrStmt,
1395    /// Ordered parameter names, positionally matching `$1`, `$2`, … in the SQL.
1396    /// Global params use the `__global__module::name` prefix; user params use bare names.
1397    pub params: Vec<String>,
1398    /// Positionally matching `params`: the tuple type each one is cast to,
1399    /// where it is cast to one (see `crate::query::ParamTupleType`).
1400    pub param_tuple_types: Vec<Option<crate::query::ParamTupleType>>,
1401    /// User-defined CTE bindings from a WITH block, in declaration order.
1402    pub ctes: Vec<IrCteDef>,
1403    /// Global variable CTEs (session-injected or computed), in dependency order.
1404    pub global_ctes: Vec<IrGlobalCte>,
1405    /// Non-fatal warnings produced during compilation.
1406    pub warnings: Vec<String>,
1407    /// True when this is a function body that reads a session global, or
1408    /// forwards the globals argument to a callee that does — i.e. when the
1409    /// function needs `GLOBALS_ARG` in its signature.
1410    pub uses_globals_arg: bool,
1411    /// `(module, table)` of every concrete type with subtypes → the fan-out
1412    /// that reads it with them. A plain source over one of these tables reads
1413    /// through its fan-out; a write to it does not.
1414    pub subtype_fanouts: HashMap<(String, String), IrPolyFanout>,
1415}
1416
1417/// `(module, table)`.
1418pub type QualifiedTable = (String, String);
1419
1420/// Marks a junction table name that stands for the union of one multi-link's
1421/// junction tables across a concrete type and its subtypes, which each keep
1422/// their own (`"BrandAddon.prices"` beside `"BrandAddonBundle.prices"`).
1423const INHERITED_JUNCTION: &str = "@inherited:";
1424
1425/// The junction name a read of an inherited multi-link uses: every
1426/// `(module, table)` in `tables`, read through `columns`.
1427pub fn inherited_junction(tables: &[QualifiedTable], columns: &[String]) -> String {
1428    let tables = tables
1429        .iter()
1430        .map(|(module, table)| format!("{module}\u{1f}{table}"))
1431        .collect::<Vec<_>>()
1432        .join("\u{1e}");
1433    format!("{INHERITED_JUNCTION}{tables}\u{1d}{}", columns.join("\u{1f}"))
1434}
1435
1436/// The `(module, table)` pairs and columns an `inherited_junction` name
1437/// stands for, or `None` for a plain table name.
1438pub fn parse_inherited_junction(name: &str) -> Option<(Vec<QualifiedTable>, Vec<String>)> {
1439    let (tables, columns) = name.strip_prefix(INHERITED_JUNCTION)?.split_once('\u{1d}')?;
1440    let tables = tables
1441        .split('\u{1e}')
1442        .filter_map(|entry| entry.split_once('\u{1f}'))
1443        .map(|(module, table)| (module.to_string(), table.to_string()))
1444        .collect();
1445    Some((tables, columns.split('\u{1f}').map(str::to_string).collect()))
1446}
1447
1448#[cfg(test)]
1449mod tests {
1450    use super::*;
1451    #[allow(unused_imports)]
1452    use super::{IrFreeExpr, IrLiteral};
1453    use crate::parse;
1454    use crate::schema::{
1455        ChannelDescriptor, ChannelPayload, ComputedDescriptor, GlobalDescriptor, LinkDescriptor, MultiLinkDescriptor,
1456        PropertyDescriptor, SchemaDescriptor, TypeDescriptor,
1457    };
1458
1459    fn make_schema() -> SchemaDescriptor {
1460        SchemaDescriptor {
1461            types: vec![
1462                TypeDescriptor {
1463                    name: "Person".into(),
1464                    module: "default".into(),
1465                    table: "person".into(),
1466                    abstract_: false,
1467                    materialized: false,
1468                    description: None,
1469                    parents: vec![],
1470                    interfaces: vec![],
1471                    bases: vec![],
1472                    properties: vec![
1473                        PropertyDescriptor {
1474                            name: "id".into(),
1475                            pg_type: "uuid".into(),
1476                            nullable: false,
1477                            default_sql: Some("uuidv7()".into()),
1478                            default_pyql: None,
1479                            description: None,
1480                            check_constraints: vec![],
1481                            is_exclusive: true,
1482                            is_pk: true,
1483                            is_readonly: true,
1484                            rewrites: vec![],
1485                            tuple_members: None,
1486                            column_type: None,
1487                        },
1488                        PropertyDescriptor {
1489                            name: "name".into(),
1490                            pg_type: "text".into(),
1491                            nullable: false,
1492                            default_sql: None,
1493                            default_pyql: None,
1494                            description: None,
1495                            check_constraints: vec![],
1496                            is_exclusive: false,
1497                            is_pk: false,
1498                            is_readonly: false,
1499                            rewrites: vec![],
1500                            tuple_members: None,
1501                            column_type: None,
1502                        },
1503                        PropertyDescriptor {
1504                            name: "age".into(),
1505                            pg_type: "int8".into(),
1506                            nullable: true,
1507                            default_sql: None,
1508                            default_pyql: None,
1509                            description: None,
1510                            check_constraints: vec![],
1511                            is_exclusive: false,
1512                            is_pk: false,
1513                            is_readonly: false,
1514                            rewrites: vec![],
1515                            tuple_members: None,
1516                            column_type: None,
1517                        },
1518                    ],
1519                    links: vec![LinkDescriptor {
1520                        name: "company".into(),
1521                        target: "default::Company".into(),
1522                        nullable: true,
1523                        through: None,
1524                        description: None,
1525                        default_pyql: None,
1526                        is_exclusive: false,
1527                        is_readonly: false,
1528                        rewrites: vec![],
1529                        on_delete: vec![],
1530                    }],
1531                    multilinks: vec![MultiLinkDescriptor {
1532                        name: "posts".into(),
1533                        target: "default::Post".into(),
1534                        through: None,
1535                        nullable: false,
1536                        description: None,
1537                        default_pyql: None,
1538                        on_delete: vec![],
1539                        is_exclusive: false,
1540                    }],
1541                    computed: vec![],
1542                    constraints: vec![],
1543                    indexes: vec![],
1544                    partition: None,
1545                    vector_indexes: vec![],
1546                    search_indexes: vec![],
1547                    triggers: vec![],
1548                    junction: false,
1549                    signals: vec![],
1550                },
1551                TypeDescriptor {
1552                    name: "Company".into(),
1553                    module: "default".into(),
1554                    table: "company".into(),
1555                    abstract_: false,
1556                    materialized: false,
1557                    description: None,
1558                    parents: vec![],
1559                    interfaces: vec![],
1560                    bases: vec![],
1561                    properties: vec![PropertyDescriptor {
1562                        name: "name".into(),
1563                        pg_type: "text".into(),
1564                        nullable: false,
1565                        default_sql: None,
1566                        default_pyql: None,
1567                        description: None,
1568                        check_constraints: vec![],
1569                        is_exclusive: false,
1570                        is_pk: false,
1571                        is_readonly: false,
1572                        rewrites: vec![],
1573                        tuple_members: None,
1574                        column_type: None,
1575                    }],
1576                    links: vec![],
1577                    multilinks: vec![],
1578                    computed: vec![],
1579                    constraints: vec![],
1580                    indexes: vec![],
1581                    partition: None,
1582                    vector_indexes: vec![],
1583                    search_indexes: vec![],
1584                    triggers: vec![],
1585                    junction: false,
1586                    signals: vec![],
1587                },
1588                TypeDescriptor {
1589                    name: "Post".into(),
1590                    module: "default".into(),
1591                    table: "post".into(),
1592                    abstract_: false,
1593                    materialized: false,
1594                    description: None,
1595                    parents: vec![],
1596                    interfaces: vec![],
1597                    bases: vec![],
1598                    properties: vec![PropertyDescriptor {
1599                        name: "title".into(),
1600                        pg_type: "text".into(),
1601                        nullable: false,
1602                        default_sql: None,
1603                        default_pyql: None,
1604                        description: None,
1605                        check_constraints: vec![],
1606                        is_exclusive: false,
1607                        is_pk: false,
1608                        is_readonly: false,
1609                        rewrites: vec![],
1610                        tuple_members: None,
1611                        column_type: None,
1612                    }],
1613                    links: vec![],
1614                    multilinks: vec![],
1615                    computed: vec![],
1616                    constraints: vec![],
1617                    indexes: vec![],
1618                    partition: None,
1619                    vector_indexes: vec![],
1620                    search_indexes: vec![],
1621                    triggers: vec![],
1622                    junction: false,
1623                    signals: vec![],
1624                },
1625            ],
1626            scalars: vec![],
1627            enums: vec![],
1628            named_tuples: vec![],
1629            globals: vec![],
1630            functions: vec![],
1631            aliases: vec![],
1632            channels: vec![],
1633            ..Default::default()
1634        }
1635    }
1636
1637    fn compile(query: &str) -> IrOutput {
1638        let schema = make_schema();
1639        let ast = parse::parse(query).expect("parse failed");
1640        super::compile(&ast, &schema).expect("IR compile failed")
1641    }
1642
1643    /// Extract the single schema-bound row's `(source, shape)` from a
1644    /// `SELECT` — panics if the select isn't schema-bound (i.e. is a free
1645    /// select), which is what most tests expect.
1646    fn bound(sel: &IrSelect) -> (&IrSource, &[IrShapePointer]) {
1647        match sel.rows.as_slice() {
1648            [IrRowSource::Bound { source, shape }] => (source, shape),
1649            _ => panic!("expected a single schema-bound row"),
1650        }
1651    }
1652
1653    /// Extract the free-row items from a `SELECT` — panics if any row is
1654    /// schema-bound, which is what free-select tests expect.
1655    fn free_items(sel: &IrSelect) -> Vec<&IrFreeExpr> {
1656        sel.rows
1657            .iter()
1658            .map(|r| match r {
1659                IrRowSource::Free(item) => item,
1660                IrRowSource::Bound { .. } => panic!("expected a free row"),
1661            })
1662            .collect()
1663    }
1664
1665    #[test]
1666    fn test_select_resolves_source() {
1667        let ir = compile("SELECT Person { name, age }");
1668        let IrStmt::Select(sel) = ir.stmt else { panic!() };
1669        let (source, shape) = bound(&sel);
1670        assert_eq!(source.table, "person");
1671        assert_eq!(source.type_name, "default::Person");
1672        // `id` the query never asked for, then `name` and `age`.
1673        assert_eq!(shape.len(), 3);
1674        assert!(matches!(&shape[0], IrShapePointer::Scalar(p) if p.alias == "id" && p.implicit_id));
1675        assert!(matches!(shape[1], IrShapePointer::Scalar(_)));
1676    }
1677
1678    #[test]
1679    fn test_select_filter_param_ordering() {
1680        let ir = compile("SELECT Person { name } FILTER .name = $name AND .age > $min_age");
1681        assert_eq!(ir.params, vec!["name", "min_age"]);
1682        let IrStmt::Select(sel) = ir.stmt else { panic!() };
1683        assert!(sel.filter.is_some());
1684    }
1685
1686    #[test]
1687    fn test_select_single_link() {
1688        let ir = compile("SELECT Person { name, company { name } }");
1689        let IrStmt::Select(sel) = ir.stmt else { panic!() };
1690        let (_, shape) = bound(&sel);
1691        assert_eq!(shape.len(), 3);
1692        let IrShapePointer::SingleLink(link) = &shape[2] else {
1693            panic!("expected SingleLink")
1694        };
1695        assert_eq!(link.alias, "company");
1696        let IrSingleLinkCorrelation::Fk { fk_column, .. } = &link.correlation else {
1697            panic!("expected Fk correlation")
1698        };
1699        assert_eq!(fk_column, "company_id");
1700        assert_eq!(bound(&link.subquery).0.table, "company");
1701    }
1702
1703    #[test]
1704    fn test_select_multi_link() {
1705        let ir = compile("SELECT Person { name, posts { title } }");
1706        let IrStmt::Select(sel) = ir.stmt else { panic!() };
1707        let (_, shape) = bound(&sel);
1708        let IrShapePointer::MultiLink(ml) = &shape[2] else {
1709            panic!("expected MultiLink")
1710        };
1711        assert_eq!(ml.alias, "posts");
1712        assert_eq!(bound(&ml.subquery).0.table, "post");
1713        let IrMultiLinkJoin::Standard { junction_table, .. } = &ml.join else {
1714            panic!()
1715        };
1716        assert_eq!(junction_table, "person.posts");
1717    }
1718
1719    #[test]
1720    fn test_select_no_shape_returns_id_only() {
1721        let ir = compile("SELECT Person");
1722        let IrStmt::Select(sel) = ir.stmt else { panic!() };
1723        // Bare SELECT Type returns only { id }.
1724        let (_, shape) = bound(&sel);
1725        assert_eq!(shape.len(), 1);
1726        let IrShapePointer::Scalar(f) = &shape[0] else { panic!() };
1727        assert_eq!(f.alias, "id");
1728    }
1729
1730    #[test]
1731    fn test_free_select_set_literal() {
1732        let schema = make_schema();
1733        let ast = parse::parse("SELECT {1, 2, 3}").unwrap();
1734        let ir = super::compile(&ast, &schema).unwrap();
1735        let IrStmt::Select(sel) = ir.stmt else {
1736            panic!("expected Select")
1737        };
1738        let items = free_items(&sel);
1739        assert_eq!(items.len(), 3);
1740        assert!(matches!(
1741            items[0],
1742            IrFreeExpr::Scalar(IrExpr::Literal(IrLiteral::Int(1)))
1743        ));
1744    }
1745
1746    #[test]
1747    fn test_free_select_free_object() {
1748        let schema = make_schema();
1749        let ast = parse::parse("SELECT { foo := 'bar', n := 42 }").unwrap();
1750        let ir = super::compile(&ast, &schema).unwrap();
1751        let IrStmt::Select(sel) = ir.stmt else {
1752            panic!("expected Select")
1753        };
1754        let items = free_items(&sel);
1755        assert_eq!(items.len(), 1);
1756        let IrFreeExpr::FreeObject(fields) = &items[0] else {
1757            panic!("expected FreeObject")
1758        };
1759        assert_eq!(fields.len(), 2);
1760        assert_eq!(fields[0].0, "foo");
1761        assert_eq!(fields[1].0, "n");
1762    }
1763
1764    #[test]
1765    fn test_free_select_tuple() {
1766        let schema = make_schema();
1767        let ast = parse::parse("SELECT (1, 'hello')").unwrap();
1768        let ir = super::compile(&ast, &schema).unwrap();
1769        let IrStmt::Select(sel) = ir.stmt else {
1770            panic!("expected Select")
1771        };
1772        let items = free_items(&sel);
1773        assert_eq!(items.len(), 1);
1774        assert!(matches!(items[0], IrFreeExpr::Tuple(_)));
1775    }
1776
1777    #[test]
1778    fn test_free_select_scalar_literal() {
1779        let schema = make_schema();
1780        let ast = parse::parse("SELECT 42").unwrap();
1781        let ir = super::compile(&ast, &schema).unwrap();
1782        let IrStmt::Select(sel) = ir.stmt else {
1783            panic!("expected Select")
1784        };
1785        let items = free_items(&sel);
1786        assert_eq!(items.len(), 1);
1787        assert!(matches!(
1788            items[0],
1789            IrFreeExpr::Scalar(IrExpr::Literal(IrLiteral::Int(42)))
1790        ));
1791    }
1792
1793    #[test]
1794    fn test_free_select_function_call() {
1795        let schema = make_schema();
1796        let ast = parse::parse("SELECT str_lower('HELLO')").unwrap();
1797        let ir = super::compile(&ast, &schema).unwrap();
1798        let IrStmt::Select(sel) = ir.stmt else {
1799            panic!("expected Select")
1800        };
1801        let items = free_items(&sel);
1802        assert!(matches!(items[0], IrFreeExpr::Scalar(IrExpr::FunctionCall(_))));
1803    }
1804
1805    #[test]
1806    fn test_free_select_rejects_dot_path() {
1807        let schema = make_schema();
1808        let ast = parse::parse("SELECT {.name}").unwrap();
1809        assert!(super::compile(&ast, &schema).is_err());
1810    }
1811
1812    #[test]
1813    fn test_type_error_uuid_eq_str() {
1814        let schema = make_schema();
1815        let ast = parse::parse("SELECT Person FILTER .id = 'not-a-uuid'").unwrap();
1816        let err = super::compile(&ast, &schema).err().expect("expected type error");
1817        let msg = err.to_string();
1818        assert!(
1819            msg.contains("std::uuid") && msg.contains("std::str"),
1820            "unexpected: {msg}"
1821        );
1822    }
1823
1824    #[test]
1825    fn test_type_error_str_eq_int() {
1826        let schema = make_schema();
1827        let ast = parse::parse("SELECT Person FILTER .name = 42").unwrap();
1828        let err = super::compile(&ast, &schema).err().expect("expected type error");
1829        let msg = err.to_string();
1830        assert!(
1831            msg.contains("std::str") && msg.contains("std::int64"),
1832            "unexpected: {msg}"
1833        );
1834    }
1835
1836    #[test]
1837    fn test_int_literal_compatible_with_all_int_columns() {
1838        // age is int8; a bare integer literal is compatible with any int column
1839        let schema = make_schema();
1840        let ast = parse::parse("SELECT Person FILTER .age = 30").unwrap();
1841        assert!(super::compile(&ast, &schema).is_ok());
1842    }
1843
1844    #[test]
1845    fn test_cast_int16_compatible_with_int8_column() {
1846        let schema = make_schema();
1847        let ast = parse::parse("SELECT Person FILTER .age = <int16>30").unwrap();
1848        assert!(super::compile(&ast, &schema).is_ok());
1849    }
1850
1851    #[test]
1852    fn test_unknown_type_error() {
1853        let schema = make_schema();
1854        let ast = parse::parse("SELECT Ghost { name }").unwrap();
1855        assert!(super::compile(&ast, &schema).is_err());
1856    }
1857
1858    #[test]
1859    fn test_nested_dml_link_value_combines_with_multilink_mutation_in_the_same_update() {
1860        // A link value sourced from a hoisted nested INSERT/UPDATE/DELETE
1861        // (`company := (select (insert Company {...}) { id })`) and a
1862        // multi-link mutation (`posts +=`) in the same UPDATE both compile
1863        // — `IrUpdate` carries both `nested_ctes` and `multi_link_appends`,
1864        // and `emit_update_stmt`'s junction-CTE branch threads the former
1865        // through into the `_ids` UPDATE's own FROM clause. See the SQL-shape
1866        // assertion in `sql::tests::
1867        // test_update_link_value_from_nested_insert_combines_with_multilink_mutation`
1868        // for the actual emitted structure.
1869        let schema = make_schema();
1870        let ast = parse::parse(
1871            "UPDATE Person FILTER .id = $id SET { \
1872                 company := (select (insert Company { name := 'Acme' }) { id }), \
1873                 posts += (SELECT Post FILTER .title = $t) \
1874             }",
1875        )
1876        .unwrap();
1877        let ir = super::compile(&ast, &schema).unwrap();
1878        let IrStmt::Update(upd) = ir.stmt else {
1879            panic!("expected Update")
1880        };
1881        assert_eq!(upd.nested_ctes.len(), 1);
1882        assert_eq!(upd.multi_link_appends.len(), 1);
1883    }
1884
1885    #[test]
1886    fn test_unknown_pointer_error() {
1887        let schema = make_schema();
1888        let ast = parse::parse("SELECT Person { nonexistent }").unwrap();
1889        assert!(super::compile(&ast, &schema).is_err());
1890    }
1891
1892    #[test]
1893    fn test_insert_compiles_assignments() {
1894        let ir = compile("INSERT Person { name := 'Alice', age := 30 }");
1895        let IrStmt::Insert(ins) = ir.stmt else { panic!() };
1896        assert_eq!(ins.target.table, "person");
1897        assert_eq!(ins.assignments.len(), 2);
1898        assert_eq!(ins.assignments[0].0, "name");
1899        assert_eq!(ins.assignments[1].0, "age");
1900    }
1901
1902    #[test]
1903    fn test_delete_compiles_filter() {
1904        let ir = compile("DELETE Person FILTER .name = $name");
1905        let IrStmt::Delete(del) = ir.stmt else { panic!() };
1906        assert!(del.filter.is_some());
1907        assert_eq!(ir.params, vec!["name"]);
1908    }
1909
1910    fn make_schema_with_computed() -> SchemaDescriptor {
1911        let mut schema = make_schema();
1912        // Add a computed pointer to Person
1913        schema.types[0].computed.push(ComputedDescriptor {
1914            name: "upper_name".into(),
1915            expression: "str_upper(.name)".into(),
1916            return_type: Some("text".into()),
1917            link_target: None,
1918            link_multi: false,
1919        });
1920        schema
1921    }
1922
1923    #[test]
1924    fn test_computed_pointer_in_shape() {
1925        let schema = make_schema_with_computed();
1926        let ast = parse::parse("SELECT Person { upper_name }").unwrap();
1927        let ir = super::compile(&ast, &schema).expect("IR compile failed");
1928        let IrStmt::Select(sel) = ir.stmt else { panic!() };
1929        // upper_name should compile to a Computed shape pointer
1930        let (_, shape) = bound(&sel);
1931        assert!(
1932            shape
1933                .iter()
1934                .any(|f| matches!(f, IrShapePointer::Computed(c) if c.alias == "upper_name"))
1935        );
1936    }
1937
1938    #[test]
1939    fn test_computed_pointer_in_expression_context() {
1940        let schema = make_schema_with_computed();
1941        let ast = parse::parse("SELECT Person { x := str_lower(.upper_name) }").unwrap();
1942        let ir = super::compile(&ast, &schema).expect("IR compile failed");
1943        let IrStmt::Select(sel) = ir.stmt else { panic!() };
1944        let (_, shape) = bound(&sel);
1945        assert!(
1946            shape
1947                .iter()
1948                .any(|f| matches!(f, IrShapePointer::Computed(c) if c.alias == "x"))
1949        );
1950    }
1951
1952    #[test]
1953    fn test_count_over_multilink_in_computed_shape_element() {
1954        // Regression: `count(.posts)` inside a computed shape element
1955        // previously failed with "object type 'default::Person' has no link
1956        // or property 'posts'" — compile_path only checked scalar
1957        // properties/single-links, never multilinks.
1958        let ir = compile("SELECT Person { post_count := count(.posts) }");
1959        let IrStmt::Select(sel) = ir.stmt else { panic!() };
1960        let (_, shape) = bound(&sel);
1961        let computed = shape
1962            .iter()
1963            .find_map(|f| match f {
1964                IrShapePointer::Computed(c) if c.alias == "post_count" => Some(c),
1965                _ => None,
1966            })
1967            .expect("expected post_count computed pointer");
1968        assert!(matches!(computed.expr, IrExpr::AggOverQuery { .. }));
1969    }
1970
1971    #[test]
1972    fn test_multi_sort_with_then() {
1973        let ir = compile("SELECT Person { name } ORDER BY .name THEN .age");
1974        let IrStmt::Select(sel) = ir.stmt else { panic!() };
1975        assert_eq!(sel.order_by.len(), 2);
1976    }
1977
1978    #[test]
1979    fn test_multi_link_filter_emits_warning() {
1980        let ir = compile("SELECT Person { name } FILTER .posts.title = 'hello'");
1981        assert!(!ir.warnings.is_empty(), "expected a warning for multi-link in filter");
1982        assert!(ir.warnings[0].contains("posts"));
1983    }
1984
1985    #[test]
1986    fn test_session_global_produces_cte() {
1987        let mut schema = make_schema();
1988        schema.globals.push(GlobalDescriptor {
1989            name: "viewer_id".into(),
1990            module: "default".into(),
1991            scalar_type: "std::uuid".into(),
1992            required: false,
1993            default_expr: None,
1994            computed_expr: None,
1995        });
1996        let ast = parse::parse("SELECT Person FILTER .id = global viewer_id").unwrap();
1997        let ir = super::compile(&ast, &schema).expect("IR compile failed");
1998        assert_eq!(ir.global_ctes.len(), 1);
1999        assert_eq!(ir.global_ctes[0].cte_name(), "__global__default::viewer_id");
2000        assert_eq!(ir.params, vec!["__global__default::viewer_id"]);
2001    }
2002
2003    #[test]
2004    fn test_session_global_pg_type_matches_pyql_type_name() {
2005        // Regression: `GlobalDescriptor.scalar_type` is a PyQL-style type
2006        // name built by the Python walker's `_pyql_type_name` (e.g.
2007        // "std::uuid"), never a bare class name like "UUID" —
2008        // `resolve_global_pg_type` used to match against the latter and
2009        // silently fall back to "text" for every builtin-typed session
2010        // global, which only surfaced once something actually compiled a
2011        // query/expression comparing the global against a real uuid column.
2012        let mut schema = make_schema();
2013        schema.globals.push(GlobalDescriptor {
2014            name: "viewer_id".into(),
2015            module: "default".into(),
2016            scalar_type: "std::uuid".into(),
2017            required: false,
2018            default_expr: None,
2019            computed_expr: None,
2020        });
2021        let ast = parse::parse("SELECT Person FILTER .id = global viewer_id").unwrap();
2022        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2023        let IrGlobalCte::Session(session) = &ir.global_ctes[0] else {
2024            panic!("expected a session global CTE");
2025        };
2026        assert_eq!(session.pg_type, "uuid");
2027    }
2028
2029    #[test]
2030    fn test_computed_global_field_access_compiles_as_path_select() {
2031        // Regression: `global name.field` previously wrapped the global's
2032        // opaque CTE reference in a jsonb `->` extraction (only valid for
2033        // tuple-typed values), producing "operator does not exist: uuid ->
2034        // unknown" for an object-typed computed global.
2035        let mut schema = make_schema();
2036        schema.globals.push(GlobalDescriptor {
2037            name: "current_user".into(),
2038            module: "default".into(),
2039            scalar_type: "Person".into(),
2040            required: false,
2041            default_expr: None,
2042            computed_expr: Some("select default::Person filter .id = <uuid>$session_user_id".into()),
2043        });
2044        let ast = parse::parse("SELECT global current_user.id").unwrap();
2045        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2046        let IrStmt::PathSelect(sel) = ir.stmt else {
2047            panic!("expected a path select, not a free select")
2048        };
2049        assert_eq!(sel.root.type_name, "default::Person");
2050    }
2051
2052    #[test]
2053    fn test_subquery_field_access_compiles_as_path_select() {
2054        // Regression: `(select Type filter ...).field` hit the generic free-
2055        // expression fallback ("expression is not valid in free SELECT
2056        // context") because bare subqueries aren't valid free expressions —
2057        // it should splice `.field` onto the inner select as a path step.
2058        let ast = parse::parse("SELECT (SELECT default::Person FILTER .age > 20).name").unwrap();
2059        let schema = make_schema();
2060        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2061        let IrStmt::PathSelect(sel) = ir.stmt else {
2062            panic!("expected a path select, not a free select")
2063        };
2064        assert_eq!(sel.root.type_name, "default::Person");
2065    }
2066
2067    #[test]
2068    fn test_string_index_compiles() {
2069        let ast = parse::parse("SELECT 'hello'[1]").unwrap();
2070        let schema = make_schema();
2071        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2072        let IrStmt::Select(sel) = ir.stmt else { panic!() };
2073        let items = free_items(&sel);
2074        assert!(matches!(
2075            items[0],
2076            IrFreeExpr::Scalar(IrExpr::Subscript { is_array: false, .. })
2077        ));
2078    }
2079
2080    #[test]
2081    fn test_array_index_compiles() {
2082        let ast = parse::parse("SELECT [1, 2, 3][0]").unwrap();
2083        let schema = make_schema();
2084        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2085        let IrStmt::Select(sel) = ir.stmt else { panic!() };
2086        let items = free_items(&sel);
2087        assert!(matches!(
2088            items[0],
2089            IrFreeExpr::Scalar(IrExpr::Subscript { is_array: true, .. })
2090        ));
2091    }
2092
2093    #[test]
2094    fn test_string_slice_compiles() {
2095        let ast = parse::parse("SELECT 'hello'[1:3]").unwrap();
2096        let schema = make_schema();
2097        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2098        let IrStmt::Select(sel) = ir.stmt else { panic!() };
2099        let items = free_items(&sel);
2100        assert!(matches!(
2101            items[0],
2102            IrFreeExpr::Scalar(IrExpr::Slice { is_array: false, .. })
2103        ));
2104    }
2105
2106    #[test]
2107    fn test_array_slice_compiles() {
2108        let ast = parse::parse("SELECT [1, 2, 3][0:2]").unwrap();
2109        let schema = make_schema();
2110        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2111        let IrStmt::Select(sel) = ir.stmt else { panic!() };
2112        let items = free_items(&sel);
2113        assert!(matches!(
2114            items[0],
2115            IrFreeExpr::Scalar(IrExpr::Slice { is_array: true, .. })
2116        ));
2117    }
2118
2119    fn make_schema_with_alias() -> SchemaDescriptor {
2120        use crate::schema::AliasDescriptor;
2121        let mut schema = make_schema();
2122        schema.aliases.push(AliasDescriptor {
2123            name: "ActivePersons".into(),
2124            module: "default".into(),
2125            expr: "select Person filter .age >= 18".into(),
2126        });
2127        schema
2128    }
2129
2130    fn make_schema_with_sequence() -> crate::schema::SchemaDescriptor {
2131        use crate::schema::ScalarDescriptor;
2132        let mut schema = make_schema();
2133        schema.scalars.push(ScalarDescriptor {
2134            name: "OrderNumber".into(),
2135            module: "default".into(),
2136            base: "Sequence".into(),
2137            pg_type: "int8".into(),
2138            check_constraints: vec![],
2139            is_sequence: true,
2140        });
2141        schema
2142    }
2143
2144    fn make_schema_with_channels() -> SchemaDescriptor {
2145        let mut schema = make_schema();
2146        schema.channels.push(ChannelDescriptor {
2147            name: "Pings".into(),
2148            module: "default".into(),
2149            wire_name: "default__pings".into(),
2150            payload: ChannelPayload::Scalar("text".into()),
2151            description: None,
2152        });
2153        schema.channels.push(ChannelDescriptor {
2154            name: "SearchReady".into(),
2155            module: "default".into(),
2156            wire_name: "default__search_ready".into(),
2157            payload: ChannelPayload::Object(vec![
2158                ("doc_id".into(), "uuid".into()),
2159                ("score".into(), "float8".into()),
2160            ]),
2161            description: None,
2162        });
2163        schema.channels.push(ChannelDescriptor {
2164            name: "PersonUpdates".into(),
2165            module: "default".into(),
2166            wire_name: "default__person_updates".into(),
2167            payload: ChannelPayload::Type("default::Person".into()),
2168            description: None,
2169        });
2170        schema
2171    }
2172
2173    fn compile_notify_expr(query: &str) -> String {
2174        let schema = make_schema_with_channels();
2175        let ast = parse::parse(query).expect("parse failed");
2176        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2177        let IrStmt::Select(sel) = ir.stmt else {
2178            panic!("expected Select")
2179        };
2180        let items = free_items(&sel);
2181        let IrFreeExpr::Scalar(expr) = items[0] else {
2182            panic!("expected scalar")
2183        };
2184        crate::sql::emit_expr(expr)
2185    }
2186
2187    fn notify_compile_err(query: &str) -> String {
2188        let schema = make_schema_with_channels();
2189        let ast = parse::parse(query).expect("parse failed");
2190        format!(
2191            "{}",
2192            super::compile(&ast, &schema).err().expect("expected a compile error")
2193        )
2194    }
2195
2196    #[test]
2197    fn test_notify_scalar_channel_emits_pg_notify() {
2198        let sql = compile_notify_expr("SELECT notify(Pings, 'hello')");
2199        assert_eq!(sql, "pg_notify('default__pings', ('hello')::text)", "got: {sql}");
2200    }
2201
2202    #[test]
2203    fn test_notify_rejects_unknown_channel() {
2204        let err = notify_compile_err("SELECT notify(NoSuchChannel, 'hi')");
2205        assert!(err.contains("not a known Channel"), "got: {err}");
2206    }
2207
2208    #[test]
2209    fn test_notify_object_channel_emits_jsonb_build_object() {
2210        let sql = compile_notify_expr(
2211            "SELECT notify(SearchReady, { doc_id := <uuid>'3fa85f64-5717-4562-b3fc-2c963f66afa6', score := 0.5 })",
2212        );
2213        assert_eq!(
2214            sql,
2215            "pg_notify('default__search_ready', (jsonb_build_object('doc_id', ('3fa85f64-5717-4562-b3fc-2c963f66afa6')::uuid, 'score', (0.5::float8)))::text)",
2216            "got: {sql}"
2217        );
2218    }
2219
2220    #[test]
2221    fn test_notify_object_channel_rejects_wrong_fields() {
2222        let err = notify_compile_err("SELECT notify(SearchReady, { doc_id := 'x' })");
2223        assert!(
2224            err.contains("payload fields") && err.contains("don't match"),
2225            "got: {err}"
2226        );
2227    }
2228
2229    #[test]
2230    fn test_notify_object_channel_rejects_non_shape_payload() {
2231        let err = notify_compile_err("SELECT notify(SearchReady, 'not an object')");
2232        assert!(err.contains("free object literal"), "got: {err}");
2233    }
2234
2235    #[test]
2236    fn test_notify_type_channel_rejects_arbitrary_payload() {
2237        let err = notify_compile_err("SELECT notify(PersonUpdates, 'not an anchor')");
2238        assert!(err.contains("must name an object of that type"), "got: {err}");
2239    }
2240
2241    #[test]
2242    fn notify_composes_with_a_with_block_binding() {
2243        // The shape a notify-after-write actually wants: the mutation and
2244        // the notification in one statement, in one transaction. This used
2245        // to be a compile error — `notify` on an object channel only
2246        // accepted the `__new__`/`__old__` anchors a trigger binds.
2247        let sql = compile_notify_expr(
2248            "WITH updated := (UPDATE Person FILTER .id = <uuid>$id SET { name := 'x' }) \
2249             SELECT notify(PersonUpdates, updated)",
2250        );
2251        assert!(sql.contains("pg_notify"), "got: {sql}");
2252        // The payload is the bound object's id, read out of its CTE.
2253        assert!(sql.contains("\"id\""), "payload should be the CTE's id: {sql}");
2254        assert!(sql.contains("updated"), "should reference the with-block CTE: {sql}");
2255    }
2256
2257    #[test]
2258    fn notify_rejects_a_with_block_binding_of_the_wrong_type() {
2259        let err = notify_compile_err("WITH other := (SELECT Company) SELECT notify(PersonUpdates, other)");
2260        assert!(err.contains("expects a payload of type"), "got: {err}");
2261    }
2262
2263    #[test]
2264    fn test_notify_type_channel_via_trigger_new_anchor() {
2265        let schema = make_schema_with_channels();
2266        let ir_out = super::compile_trigger_handler(
2267            "select notify(PersonUpdates, __new__)",
2268            "Person",
2269            1, // On::Insert — binds __new__ only (on_mask & 4 == 0), no __old__
2270            &schema,
2271        )
2272        .expect("trigger handler compile failed");
2273        let IrStmt::Select(sel) = ir_out.stmt else {
2274            panic!("expected Select")
2275        };
2276        let items = free_items(&sel);
2277        let IrFreeExpr::Scalar(expr) = items[0] else {
2278            panic!("expected scalar")
2279        };
2280        let sql = crate::sql::emit_expr(expr);
2281        assert_eq!(
2282            sql, "pg_notify('default__person_updates', (NEW.\"id\")::text)",
2283            "got: {sql}"
2284        );
2285    }
2286
2287    #[test]
2288    fn test_notify_scalar_channel_via_trigger_new_property_access() {
2289        // A bare `select notify(...)` trigger handler has no type at its own
2290        // root, so it compiles as a *free* select — `__new__.name` only
2291        // resolves at all because `compile_notify` falls back to a bound
2292        // anchor as a stand-in ctx when the ambient one is None (confirmed
2293        // live via live_execution_notify.rs before this fallback existed:
2294        // it failed with "expression is not valid in free SELECT context").
2295        let schema = make_schema_with_channels();
2296        let ir_out = super::compile_trigger_handler(
2297            "select notify(Pings, __new__.name)",
2298            "Person",
2299            1, // On::Insert
2300            &schema,
2301        )
2302        .expect("trigger handler compile failed");
2303        let IrStmt::Select(sel) = ir_out.stmt else {
2304            panic!("expected Select")
2305        };
2306        let items = free_items(&sel);
2307        let IrFreeExpr::Scalar(expr) = items[0] else {
2308            panic!("expected scalar")
2309        };
2310        let sql = crate::sql::emit_expr(expr);
2311        assert_eq!(sql, "pg_notify('default__pings', (NEW.\"name\")::text)", "got: {sql}");
2312    }
2313
2314    #[test]
2315    fn test_notify_type_channel_rejects_bare_reference_outside_trigger() {
2316        // __new__ has no binding at all in a plain (non-trigger) compile.
2317        let err = notify_compile_err("SELECT notify(PersonUpdates, __new__)");
2318        assert!(err.contains("only bound inside a trigger handler"), "got: {err}");
2319    }
2320
2321    #[test]
2322    fn notify_rejects_an_oversized_concatenation_at_compile_time() {
2323        // Neither half is over the cap on its own, so the old literal-only
2324        // check passed this straight through to fail at runtime — where it
2325        // aborts the transaction that sent the notification.
2326        let half = "x".repeat(4500);
2327        let err = notify_compile_err(&format!("SELECT notify_raw('c', '{half}' ++ '{half}')"));
2328        assert!(err.contains("8000-byte"), "got: {err}");
2329        assert!(err.contains("at least"), "got: {err}");
2330    }
2331
2332    #[test]
2333    fn notify_allows_a_concatenation_that_still_fits() {
2334        let part = "x".repeat(3000);
2335        let sql = compile_notify_expr(&format!("SELECT notify_raw('c', '{part}' ++ '{part}')"));
2336        assert!(sql.contains("pg_notify"), "got: {sql}");
2337    }
2338
2339    #[test]
2340    fn test_notify_raw_emits_pg_notify_with_two_args() {
2341        let sql = compile_notify_expr("SELECT notify_raw('any_channel', 'raw payload')");
2342        assert_eq!(sql, "pg_notify('any_channel', 'raw payload')", "got: {sql}");
2343    }
2344
2345    #[test]
2346    fn test_notify_payload_literal_over_cap_rejected() {
2347        let huge = "x".repeat(8000);
2348        let err = notify_compile_err(&format!("SELECT notify(Pings, '{huge}')"));
2349        assert!(err.contains("NOTIFY payload limit"), "got: {err}");
2350    }
2351
2352    #[test]
2353    fn test_notify_arity_error() {
2354        let err = notify_compile_err("SELECT notify(Pings)");
2355        assert!(err.contains("takes exactly 2 arguments"), "got: {err}");
2356    }
2357
2358    fn compile_seq(query: &str) -> String {
2359        let schema = make_schema_with_sequence();
2360        let ast = parse::parse(query).expect("parse failed");
2361        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2362        let IrStmt::Select(sel) = ir.stmt else {
2363            panic!("expected Select")
2364        };
2365        let items = free_items(&sel);
2366        let IrFreeExpr::Scalar(expr) = items[0] else {
2367            panic!("expected scalar")
2368        };
2369        crate::sql::emit_expr(expr)
2370    }
2371
2372    #[test]
2373    fn test_sequence_next_emits_nextval() {
2374        let sql = compile_seq("SELECT sequence_next(OrderNumber)");
2375        assert_eq!(sql, r#"nextval('"default"."OrderNumber_seq"')"#, "got: {sql}");
2376    }
2377
2378    #[test]
2379    fn test_sequence_reset_no_val_emits_setval_initial() {
2380        let sql = compile_seq("SELECT sequence_reset(OrderNumber)");
2381        assert_eq!(sql, r#"setval('"default"."OrderNumber_seq"', 1, false)"#, "got: {sql}");
2382    }
2383
2384    #[test]
2385    fn test_sequence_reset_with_val_emits_setval() {
2386        let sql = compile_seq("SELECT sequence_reset(OrderNumber, 1000)");
2387        assert_eq!(
2388            sql, r#"setval('"default"."OrderNumber_seq"', 1000, true)"#,
2389            "got: {sql}"
2390        );
2391    }
2392
2393    #[test]
2394    fn test_sequence_next_rejects_non_sequence_type() {
2395        let schema = make_schema();
2396        let ast = parse::parse("SELECT sequence_next(Person)").unwrap();
2397        assert!(super::compile(&ast, &schema).is_err());
2398    }
2399
2400    #[test]
2401    fn test_alias_bare_compiles_to_type_select() {
2402        let schema = make_schema_with_alias();
2403        let ast = parse::parse("SELECT ActivePersons").unwrap();
2404        let ir = super::compile(&ast, &schema).expect("compile failed");
2405        let sql = crate::sql::emit(&ir).sql;
2406        assert!(sql.contains("\"person\""), "expected person table, got: {sql}");
2407        assert!(sql.contains("18"), "expected age filter, got: {sql}");
2408    }
2409
2410    #[test]
2411    fn test_alias_with_outer_filter_merges() {
2412        let schema = make_schema_with_alias();
2413        let ast = parse::parse("SELECT ActivePersons FILTER .name = 'Alice'").unwrap();
2414        let ir = super::compile(&ast, &schema).expect("compile failed");
2415        let sql = crate::sql::emit(&ir).sql;
2416        assert!(sql.contains("\"person\""), "expected person table, got: {sql}");
2417        assert!(sql.contains("18"), "expected alias filter, got: {sql}");
2418        assert!(sql.contains("'Alice'"), "expected outer filter, got: {sql}");
2419    }
2420
2421    #[test]
2422    fn test_alias_module_qualified_resolves() {
2423        let schema = make_schema_with_alias();
2424        let ast = parse::parse("SELECT default::ActivePersons").unwrap();
2425        let ir = super::compile(&ast, &schema).expect("compile failed");
2426        let sql = crate::sql::emit(&ir).sql;
2427        assert!(sql.contains("\"person\""), "expected person table, got: {sql}");
2428    }
2429
2430    #[test]
2431    fn test_alias_with_shape() {
2432        let schema = make_schema_with_alias();
2433        let ast = parse::parse("SELECT ActivePersons { name, age }").unwrap();
2434        let ir = super::compile(&ast, &schema).expect("compile failed");
2435        let sql = crate::sql::emit(&ir).sql;
2436        assert!(sql.contains("\"name\""), "expected name pointer, got: {sql}");
2437        assert!(sql.contains("\"age\""), "expected age pointer, got: {sql}");
2438    }
2439
2440    #[test]
2441    fn test_alias_whose_own_body_has_a_shape_plus_outer_shape() {
2442        // Regression: when the alias's own body *also* declares a shape
2443        // (a legitimate, documented pattern — e.g. `select Type { field }
2444        // order by ... limit ...`), the outer shape used to wrap the
2445        // inner Shape node wholesale instead of the type reference inside
2446        // it, producing a Shape-of-a-Shape the compiler rejected with
2447        // "expected a type name as SELECT subject" (confirmed live).
2448        use crate::schema::AliasDescriptor;
2449        let mut schema = make_schema();
2450        schema.aliases.push(AliasDescriptor {
2451            name: "OldestActive".into(),
2452            module: "default".into(),
2453            expr: "select Person { name } order by .age desc limit 1".into(),
2454        });
2455        let ast = parse::parse("SELECT OldestActive { name, age }").unwrap();
2456        let ir = super::compile(&ast, &schema).expect("compile failed");
2457        let sql = crate::sql::emit(&ir).sql;
2458        assert!(sql.contains("\"name\""), "expected name pointer, got: {sql}");
2459        assert!(sql.contains("\"age\""), "expected age pointer, got: {sql}");
2460        assert!(
2461            sql.contains("ORDER BY") && sql.contains("LIMIT"),
2462            "alias's own order/limit must still apply, got: {sql}"
2463        );
2464    }
2465
2466    /// Every generated WITH name starts with an underscore, so a binding that
2467    /// starts with one is moved out of that space — `_dml` beside the wrapper
2468    /// of that name used to emit one name five times.
2469    #[test]
2470    fn test_a_binding_named_like_a_generated_cte_gets_its_own_name() {
2471        let ast = parse::parse("WITH _dml := (SELECT Person LIMIT 1) SELECT (UPDATE Person FILTER .id = _dml.id SET { age := 1 }) { name }").unwrap();
2472        let ir = super::compile(&ast, &make_schema()).expect("IR compile failed");
2473        let sql = crate::sql::emit(&ir).sql;
2474        assert_eq!(
2475            sql.matches("\"_dml\" AS (").count(),
2476            1,
2477            "the generated wrapper must keep the name to itself:\n{sql}"
2478        );
2479    }
2480
2481    /// Two `for` bodies may each declare `line`. One WITH clause cannot hold
2482    /// that name twice, so the second binding is emitted under a suffixed one.
2483    #[test]
2484    fn test_two_sibling_bindings_of_one_name_get_separate_with_names() {
2485        let ast = parse::parse(
2486            "WITH a := (FOR p IN (SELECT Person) UNION (WITH line := (SELECT Person) SELECT line.name)), \
2487             b := (FOR p IN (SELECT Person) UNION (WITH line := (SELECT Post) SELECT line.title)) \
2488             SELECT {a := a, b := b}",
2489        )
2490        .unwrap();
2491        let ir = super::compile(&ast, &make_schema()).expect("IR compile failed");
2492        let sql = crate::sql::emit(&ir).sql;
2493        assert_eq!(
2494            sql.matches("\"line\" AS (").count(),
2495            1,
2496            "one name can only be claimed once:\n{sql}"
2497        );
2498    }
2499
2500    /// One computed reached from two places in a statement inlines its `with`
2501    /// bindings twice, which used to emit two CTEs of one name — PostgreSQL's
2502    /// "WITH query name specified more than once". The bindings are identical,
2503    /// so the first stands for both.
2504    #[test]
2505    fn test_a_computed_inlined_twice_hoists_its_binding_once() {
2506        let mut schema = make_schema();
2507        schema.types[0].computed.push(ComputedDescriptor {
2508            name: "ranked".into(),
2509            expression: "(with ordering := ['a', 'b'] select array_get(ordering, 0))".into(),
2510            return_type: Some("text".into()),
2511            link_target: None,
2512            link_multi: false,
2513        });
2514        let ast = parse::parse("SELECT Person { ranked } FILTER .ranked = 'a'").unwrap();
2515        let ir = super::compile(&ast, &schema).expect("IR compile failed");
2516        let sql = crate::sql::emit(&ir).sql;
2517        assert_eq!(
2518            sql.matches("\"ordering\" AS (").count(),
2519            1,
2520            "the shared binding must be hoisted once:\n{sql}"
2521        );
2522    }
2523
2524    /// `to_duration(seconds := …)` — the signature is named-only, and every
2525    /// call site writes the names.
2526    #[test]
2527    fn test_to_duration_takes_its_arguments_by_name() {
2528        let schema = make_schema();
2529        let ast = parse::parse("SELECT std::to_duration(seconds := 90.0)").unwrap();
2530        super::compile(&ast, &schema).expect("named arguments must resolve");
2531    }
2532
2533    /// `(.<backlink[is T].when < now) ?? true` — conduit's backoff gate. The
2534    /// walk is set-valued, so it was gathered as an array and the comparison
2535    /// came out as `timestamptz[] < timestamptz`, which PostgreSQL rejects
2536    /// outright. Read back as a scalar subquery the empty case is NULL and the
2537    /// `??` supplies the default.
2538    #[test]
2539    fn test_an_ordering_comparison_reads_a_set_walk_as_one_value() {
2540        let schema = make_schema();
2541        let ast = parse::parse("SELECT Company FILTER ((.<company[is Person].age < 30) ?? true)").unwrap();
2542        let ir = super::compile(&ast, &schema).expect("compile failed");
2543        let sql = crate::sql::emit(&ir).sql;
2544        assert!(!sql.contains("ARRAY(SELECT"), "the operand must be one value:\n{sql}");
2545        assert!(sql.contains("COALESCE"), "the coalesce must survive:\n{sql}");
2546    }
2547
2548    /// `(select T filter .id = $x).link.prop` is one value, not a one-element
2549    /// set: the filter pins an exclusive property and every step is a forward
2550    /// single link, so nothing multiplies — conduit decodes the result
2551    /// straight into a `bool`.
2552    #[test]
2553    fn test_a_single_link_walk_off_a_pinned_row_is_not_a_set() {
2554        let schema = make_schema();
2555        let ast =
2556            parse::parse("WITH i := (SELECT Person FILTER .id = <uuid>$0) SELECT { c := i.company.name }").unwrap();
2557        let ir = super::compile(&ast, &schema).expect("compile failed");
2558        let sql = crate::sql::emit(&ir).sql;
2559        assert!(!sql.contains("ARRAY(SELECT"), "expected a value, not a set:\n{sql}");
2560    }
2561
2562    /// The same walk off a row the filter does *not* pin stays a set — there
2563    /// may be many matching rows, so there may be many values.
2564    #[test]
2565    fn test_a_single_link_walk_off_an_unpinned_row_is_still_a_set() {
2566        let schema = make_schema();
2567        let ast = parse::parse("WITH i := (SELECT Person FILTER .name = 'x') SELECT { c := i.company.name }").unwrap();
2568        let ir = super::compile(&ast, &schema).expect("compile failed");
2569        let sql = crate::sql::emit(&ir).sql;
2570        assert!(sql.contains("ARRAY(SELECT"), "a walk off many rows is a set:\n{sql}");
2571    }
2572
2573    /// And a step through a multi-link is a set however the root is pinned.
2574    #[test]
2575    fn test_a_multi_link_step_is_a_set_even_off_a_pinned_row() {
2576        let schema = make_schema();
2577        let ast =
2578            parse::parse("WITH i := (SELECT Person FILTER .id = <uuid>$0) SELECT { t := i.posts.title }").unwrap();
2579        let ir = super::compile(&ast, &schema).expect("compile failed");
2580        let sql = crate::sql::emit(&ir).sql;
2581        assert!(sql.contains("ARRAY(SELECT"), "a multi-link step is a set:\n{sql}");
2582    }
2583
2584    /// `(update T set { multi += (insert X …) }).multi { … }` — automator reads
2585    /// back the config field it just appended. Postgres shows a sibling CTE's
2586    /// inserts neither in the junction table nor in the target's own, so both
2587    /// sides of the walk have to read the CTEs that wrote them; reading the
2588    /// base tables returned no rows at all.
2589    #[test]
2590    fn test_a_plain_read_does_not_see_the_statements_own_write() {
2591        // The other side of the boundary the test below draws. One statement
2592        // is one snapshot: a read of the table sees what was there before it
2593        // ran, and only a walk rooted at the mutation sees what it wrote.
2594        // The answer for this shape is 1 and 1.
2595        let schema = make_schema();
2596        let ast =
2597            parse::parse("WITH made := (INSERT Person { name := 'a', age := 1 }) SELECT { after := count(Person) }")
2598                .unwrap();
2599        let ir = super::compile(&ast, &schema).expect("compile failed");
2600        let sql = crate::sql::emit(&ir).sql;
2601        assert!(
2602            sql.to_lowercase().contains("from \"public\".\"person\""),
2603            "the count must read the table, not the CTE that wrote to it:\n{sql}"
2604        );
2605    }
2606
2607    #[test]
2608    fn test_a_walk_off_a_mutation_sees_the_rows_it_just_wrote() {
2609        let schema = make_schema();
2610        let ast = parse::parse(
2611            "SELECT (UPDATE Person FILTER .name = 'a' SET { posts += (INSERT Post { title := 't' }) }).posts { title }",
2612        )
2613        .unwrap();
2614        let ir = super::compile(&ast, &schema).expect("compile failed");
2615        let sql = crate::sql::emit(&ir).sql;
2616        assert!(
2617            sql.contains("__ml_add_0\" AS \"") || sql.contains("JOIN \"_nested_dml_1__ml_add_0\""),
2618            "the junction must be read from the CTE that wrote it:\n{sql}"
2619        );
2620        assert!(
2621            !sql.contains("JOIN \"public\".\"Post\""),
2622            "the targets must come from their own CTE, not the base table:\n{sql}"
2623        );
2624    }
2625
2626    /// `(select …).company[is Company].name` — conduit derives a trust tier
2627    /// through a walk like this, which is legal. A `[is T]` step has no
2628    /// expression form, so the parser used to reject the whole walk; it is now
2629    /// carried as `PathStepOn` and re-rooted at a binding.
2630    #[test]
2631    fn test_a_type_intersection_may_follow_a_sub_select() {
2632        let schema = make_schema();
2633        let ast = parse::parse("SELECT (SELECT Person LIMIT 1).company[is Company].name").unwrap();
2634        let ir = super::compile(&ast, &schema).expect("compile failed");
2635        let sql = crate::sql::emit(&ir).sql;
2636        // The trailing field must be read as a column. Reading it as jsonb off
2637        // the object id compiled fine and then failed at execution with
2638        // `operator does not exist: uuid -> unknown`.
2639        assert!(
2640            !sql.contains("->'name'"),
2641            "the field must not be jsonb off an id:\n{sql}"
2642        );
2643        assert!(sql.contains("\"name\""), "the field must be read as a column:\n{sql}");
2644    }
2645
2646    /// A step that has no path, binding or sub-select to walk off is still an
2647    /// error — it just reports at compile time now rather than at parse time.
2648    #[test]
2649    fn test_a_type_intersection_on_a_value_is_rejected() {
2650        let schema = make_schema();
2651        let ast = parse::parse("SELECT (1 + 2)[is Company]").unwrap();
2652        let Err(error) = super::compile(&ast, &schema) else {
2653            panic!("a type intersection on a number is not meaningful");
2654        };
2655        assert!(error.to_string().contains("needs a path, a binding"), "got: {error}");
2656    }
2657
2658    /// `select (select (A union B) { … } limit 1) { … }` — ledger resolves a
2659    /// listing from either of two backlinks this way. The inner select
2660    /// compiles alone and the `with l := … select l { … }` spelling works;
2661    /// only wrapping it inline was rejected, because a union subject has no
2662    /// single type name for the outer select to take.
2663    #[test]
2664    fn test_a_select_may_wrap_a_nested_union_subject_select() {
2665        let schema = make_schema();
2666        let ast =
2667            parse::parse("SELECT (SELECT (Person.company UNION Person.company) { name } LIMIT 1) { name }").unwrap();
2668        let error = super::compile(&ast, &schema)
2669            .err()
2670            .map(|e| e.to_string())
2671            .unwrap_or_default();
2672        assert!(
2673            !error.contains("expected a type name as SELECT subject"),
2674            "the union subject must be hoisted, got: {error}"
2675        );
2676    }
2677
2678    /// `select (update T …).link { … }` — automator reads back the config
2679    /// field it just appended. `(select …).link { … }` already worked; only a
2680    /// walk off a *mutation* was rejected.
2681    #[test]
2682    fn test_a_select_subject_may_walk_off_a_mutation() {
2683        let schema = make_schema();
2684        let ast =
2685            parse::parse("SELECT (UPDATE Person FILTER .name = 'a' SET { name := 'b' }).company { name }").unwrap();
2686        let ir = super::compile(&ast, &schema).expect("compile failed");
2687        let sql = crate::sql::emit(&ir).sql;
2688        assert!(
2689            sql.contains("\"_nested_dml_0\" AS ("),
2690            "the mutation must run as a CTE:\n{sql}"
2691        );
2692        assert!(sql.contains("UPDATE"), "the mutation must still run:\n{sql}");
2693    }
2694
2695    /// `update (select T filter …).link set { … }` — conduit refreshes a
2696    /// connector's stored credentials this way. The equivalent
2697    /// `with s := (select …) update s.link set …` already worked; only the
2698    /// inline sub-select was rejected.
2699    #[test]
2700    fn test_an_update_subject_may_walk_off_a_sub_select() {
2701        let schema = make_schema();
2702        let inline = parse::parse("UPDATE (SELECT Person FILTER .name = 'a').company SET { name := 'b' }").unwrap();
2703        let bound =
2704            parse::parse("WITH s := (SELECT Person FILTER .name = 'a') UPDATE s.company SET { name := 'b' }").unwrap();
2705        // `make_schema`'s Company has no `id`, so both forms stop at the same
2706        // later point — which is the assertion: the inline one is no longer
2707        // rejected earlier than the binding-based one.
2708        let inline_err = super::compile(&inline, &schema).err().map(|e| e.to_string());
2709        let bound_err = super::compile(&bound, &schema).err().map(|e| e.to_string());
2710        assert_eq!(inline_err, bound_err, "the two spellings must compile alike");
2711        assert!(
2712            !inline_err
2713                .unwrap_or_default()
2714                .contains("expected a type name as SELECT subject"),
2715            "the sub-select subject must be accepted"
2716        );
2717    }
2718
2719    /// `select (for … union …)` — automator counts runs per status this way.
2720    /// The loop compiles on its own; only the wrapper was rejected.
2721    #[test]
2722    fn test_a_for_loop_may_be_a_select_subject() {
2723        let schema = make_schema();
2724        let ast = parse::parse("SELECT (FOR s IN {1, 2} UNION (SELECT { a := s }))").unwrap();
2725        let ir = super::compile(&ast, &schema).expect("compile failed");
2726        assert!(matches!(ir.stmt, super::IrStmt::For(_)), "expected the loop itself");
2727    }
2728
2729    /// A `<json>` cast as a shape element is an ordinary column of the row, so
2730    /// it must carry the pointer's name and position. Describing it with the
2731    /// root-only `JsonScalar` (which carries neither) made `pylon-client` panic
2732    /// with "shape node kind never appears as an object's own pointer".
2733    #[test]
2734    fn test_a_json_cast_in_a_shape_is_a_named_pointer() {
2735        use crate::query::ShapeNode;
2736        let schema = make_schema();
2737        let ast = parse::parse("SELECT Person { j := <json>.name }").unwrap();
2738        let ir = super::compile(&ast, &schema).expect("compile failed");
2739        let shape = crate::sql::emit(&ir).shape;
2740        let ShapeNode::Object { pointers, .. } = &shape.root else {
2741            panic!("expected an object shape, got {:?}", shape.root);
2742        };
2743        let pointer = pointers
2744            .iter()
2745            .find(|node| matches!(node, ShapeNode::Scalar { name, .. } if name == "j"))
2746            .unwrap_or_else(|| panic!("no scalar pointer named 'j' in {pointers:?}"));
2747        assert!(matches!(pointer, ShapeNode::Scalar { .. }));
2748    }
2749
2750    /// The same cast at the top level keeps `JsonScalar`: there the result
2751    /// column is the value itself rather than a field of a row tuple.
2752    #[test]
2753    fn test_a_top_level_json_cast_stays_a_root_shaped_node() {
2754        use crate::query::ShapeNode;
2755        let schema = make_schema();
2756        let ast = parse::parse("SELECT <json>'x'").unwrap();
2757        let ir = super::compile(&ast, &schema).expect("compile failed");
2758        let shape = crate::sql::emit(&ir).shape;
2759        assert!(matches!(shape.root, ShapeNode::JsonScalar), "got {:?}", shape.root);
2760    }
2761
2762    /// `std::Endian` is a stdlib enum with no PostgreSQL enum type behind it,
2763    /// so its members have to compile to a plain text literal.
2764    #[test]
2765    fn test_stdlib_enum_member_compiles_to_a_text_literal() {
2766        let schema = make_schema();
2767        let ast = parse::parse("SELECT std::Endian.Big").unwrap();
2768        let ir = super::compile(&ast, &schema).expect("compile failed");
2769        let sql = crate::sql::emit(&ir).sql;
2770        assert!(sql.contains("'Big'::text"), "expected a text literal, got: {sql}");
2771    }
2772
2773    #[test]
2774    fn test_unknown_stdlib_enum_member_is_rejected() {
2775        let schema = make_schema();
2776        let ast = parse::parse("SELECT std::Endian.Middle").unwrap();
2777        let Err(error) = super::compile(&ast, &schema) else {
2778            panic!("Middle is not a member of std::Endian");
2779        };
2780        assert!(error.to_string().contains("has no member 'Middle'"), "got: {error}");
2781    }
2782
2783    /// The single-argument form has to reach `to_bytes_uuid`, not the
2784    /// two-argument `to_bytes(str, encoding)` — picking the latter compiled
2785    /// fine and then failed at runtime with "function _pylon.to_bytes(uuid)
2786    /// does not exist".
2787    #[test]
2788    fn test_to_bytes_of_a_uuid_selects_the_uuid_overload() {
2789        let schema = make_schema();
2790        let ast = parse::parse("SELECT std::to_int32(std::to_bytes(<uuid>$0)[12:16], std::Endian.Big)").unwrap();
2791        let ir = super::compile(&ast, &schema).expect("compile failed");
2792        let sql = crate::sql::emit(&ir).sql;
2793        assert!(sql.contains("to_bytes_uuid"), "expected to_bytes_uuid, got: {sql}");
2794        assert!(sql.contains("to_int32_bytes"), "expected to_int32_bytes, got: {sql}");
2795    }
2796
2797    /// A call no overload can accept used to resolve to whichever overload
2798    /// was registered first, so `str_lower(a, b)` compiled to `lower(a, b)`
2799    /// and only failed once PostgreSQL saw a signature nobody wrote.
2800    #[test]
2801    fn a_stdlib_call_with_the_wrong_argument_count_is_rejected() {
2802        let schema = make_schema();
2803        let ast = parse::parse("SELECT std::str_lower('A', 'B')").unwrap();
2804        let Err(err) = super::compile(&ast, &schema) else {
2805            panic!("wrong arity must not compile")
2806        };
2807        let msg = err.to_string();
2808        assert!(msg.contains("std::str_lower"), "{msg}");
2809        assert!(msg.contains("takes 1 argument(s), got 2"), "{msg}");
2810    }
2811
2812    /// A near-miss name gets pointed at the real one — the shape the
2813    /// `uuid_generate_v7j` default took, one character away from a function
2814    /// that does exist.
2815    #[test]
2816    fn an_unknown_function_suggests_the_closest_real_one() {
2817        let schema = make_schema();
2818        let ast = parse::parse("SELECT std::uuid_generate_v7j()").unwrap();
2819        let Err(err) = super::compile(&ast, &schema) else {
2820            panic!("an unknown function must not compile")
2821        };
2822        let msg = err.to_string();
2823        assert!(msg.contains("does not exist"), "{msg}");
2824        assert!(msg.contains("did you mean std::uuid_generate_v7()?"), "{msg}");
2825    }
2826
2827    /// A name that is real but reached for in the wrong namespace gets told
2828    /// which one holds it, rather than fuzzy-matched against a neighbour
2829    /// that merely looks similar.
2830    #[test]
2831    fn a_function_in_another_namespace_says_where_it_lives() {
2832        let schema = make_schema();
2833        let ast = parse::parse("SELECT std::pi()").unwrap();
2834        let Err(err) = super::compile(&ast, &schema) else {
2835            panic!("pi lives in math, not std")
2836        };
2837        assert!(err.to_string().contains("it lives in math, use math::pi()"), "{err}");
2838    }
2839
2840    /// The arities are listed, not just the first overload's — `str_trim`
2841    /// takes one argument or two, and a three-argument call has to say so.
2842    #[test]
2843    fn an_arity_error_names_every_arity_the_overload_set_accepts() {
2844        let schema = make_schema();
2845        let ast = parse::parse("SELECT std::str_trim('A', 'B', 'C')").unwrap();
2846        let Err(err) = super::compile(&ast, &schema) else {
2847            panic!("wrong arity must not compile")
2848        };
2849        assert!(err.to_string().contains("takes 1 or 2 argument(s), got 3"), "{err}");
2850    }
2851
2852    /// A trailing variadic parameter absorbs any number of arguments, so the
2853    /// arity gate must not reject the calls it exists to allow.
2854    #[test]
2855    fn a_variadic_stdlib_call_accepts_extra_arguments() {
2856        let schema = make_schema();
2857        for query in [
2858            "SELECT std::json_get(<json>$0, 'a')",
2859            "SELECT std::json_get(<json>$0, 'a', 'b', 'c')",
2860        ] {
2861            let ast = parse::parse(query).unwrap();
2862            super::compile(&ast, &schema).unwrap_or_else(|e| panic!("{query} must compile: {e}"));
2863        }
2864    }
2865
2866    /// Right count, wrong types — reported as the overload mismatch it is,
2867    /// listing what the function does accept.
2868    #[test]
2869    fn a_stdlib_call_with_an_unacceptable_argument_type_is_rejected() {
2870        let schema = make_schema();
2871        let ast = parse::parse("SELECT std::str_lower(<int64>$0)").unwrap();
2872        let Err(err) = super::compile(&ast, &schema) else {
2873            panic!("wrong argument type must not compile")
2874        };
2875        let msg = err.to_string();
2876        assert!(msg.contains("no overload accepting (int8)"), "{msg}");
2877        assert!(msg.contains("(str)"), "{msg}");
2878    }
2879
2880    /// `??` and `if … else` are transparent to overload resolution: both yield
2881    /// a value of their branches' own type. While they read as untyped, every
2882    /// `str_lower(x ?? default)` — the shape a jurisdiction, a locale or any
2883    /// other optional-with-a-fallback is written in — failed to resolve.
2884    #[test]
2885    fn a_stdlib_call_over_a_coalesce_or_conditional_resolves_its_branch_type() {
2886        let schema = make_schema();
2887        for query in [
2888            "SELECT std::str_lower(<optional str>$0 ?? 'DE')",
2889            "SELECT std::str_lower('DE' ?? <optional str>$0)",
2890            "WITH j := (<optional str>$0 ?? 'DE') SELECT std::str_lower(j)",
2891            "SELECT std::str_lower(<str>$0 if <bool>$1 else 'DE')",
2892            "SELECT std::len(<optional str>$0 ?? 'DE')",
2893        ] {
2894            let ast = parse::parse(query).unwrap();
2895            super::compile(&ast, &schema).unwrap_or_else(|e| panic!("{query} must compile: {e}"));
2896        }
2897    }
2898
2899    /// Every other expression that carries a value of a type it does not spell
2900    /// out itself. Each of these reads as untyped without inference of its own,
2901    /// which under overload resolution is the difference between compiling and
2902    /// not.
2903    #[test]
2904    fn a_stdlib_call_over_a_pass_through_expression_resolves_the_value_type() {
2905        let schema = make_schema();
2906        for query in [
2907            // A loop variable holds one element of what it iterates.
2908            "SELECT (FOR code IN std::array_unpack(<array<str>>$0) UNION (SELECT std::str_lower(code)))",
2909            // A free object's field is typed by what the field binds.
2910            "WITH x := { a := 'DE' } SELECT std::str_lower(x.a)",
2911            // Negation keeps its operand's type.
2912            "SELECT math::abs(-3)",
2913            "SELECT math::abs(-(<int64>$0))",
2914            // An array subscript is one element, not the array.
2915            "SELECT std::str_lower((<array<str>>$0)[0])",
2916            // The array a stdlib call returns is named by its own overload.
2917            "SELECT std::str_title(std::str_split(<str>$0, '::')[0])",
2918            // A datetime minus a datetime is a duration.
2919            "SELECT std::duration_to_seconds(std::datetime_of_transaction() - <datetime>$0)",
2920            "SELECT std::duration_to_seconds(<duration>$0 + <duration>$0)",
2921        ] {
2922            let ast = parse::parse(query).unwrap();
2923            super::compile(&ast, &schema).unwrap_or_else(|e| panic!("{query} must compile: {e}"));
2924        }
2925    }
2926
2927    /// Looking through a `??` types the call, it does not excuse it: a branch
2928    /// whose type is known and wrong is still no overload's argument.
2929    #[test]
2930    fn a_stdlib_call_over_a_coalesce_of_the_wrong_type_is_still_rejected() {
2931        let schema = make_schema();
2932        let ast = parse::parse("SELECT std::str_lower(<optional int64>$0 ?? 3)").unwrap();
2933        let Err(err) = super::compile(&ast, &schema) else {
2934            panic!("wrong argument type must not compile")
2935        };
2936        assert!(err.to_string().contains("no overload accepting (int8)"), "{err}");
2937    }
2938
2939    /// The byte-order forms sit beside the one-argument `to_intN(str)` casts,
2940    /// so each has to resolve on arity to its own `_bytes` function rather
2941    /// than to the cast that shares its name.
2942    #[test]
2943    fn test_to_int16_and_to_int64_over_bytes_select_the_bytes_overload() {
2944        let schema = make_schema();
2945        for (query, expected) in [
2946            (
2947                "SELECT std::to_int16(std::to_bytes(<uuid>$0)[14:16], std::Endian.Big)",
2948                "to_int16_bytes",
2949            ),
2950            (
2951                "SELECT std::to_int64(std::to_bytes(<uuid>$0)[0:8], std::Endian.Little)",
2952                "to_int64_bytes",
2953            ),
2954        ] {
2955            let ast = parse::parse(query).unwrap();
2956            let ir = super::compile(&ast, &schema).expect("compile failed");
2957            let sql = crate::sql::emit(&ir).sql;
2958            assert!(sql.contains(expected), "expected {expected}, got: {sql}");
2959        }
2960    }
2961
2962    #[test]
2963    fn test_positional_param_names() {
2964        let schema = make_schema();
2965        let ast = parse::parse("SELECT Person FILTER .name = $0").unwrap();
2966        let ir = super::compile(&ast, &schema).expect("compile failed");
2967        assert_eq!(ir.params, vec!["0"]);
2968    }
2969
2970    #[test]
2971    fn test_multiple_positional_param_names_in_order() {
2972        let schema = make_schema();
2973        let ast = parse::parse("SELECT Person FILTER .name = $0 AND .age > $1").unwrap();
2974        let ir = super::compile(&ast, &schema).expect("compile failed");
2975        assert_eq!(ir.params, vec!["0", "1"]);
2976    }
2977
2978    #[test]
2979    fn test_repeated_positional_param_single_slot() {
2980        let schema = make_schema();
2981        let ast = parse::parse("SELECT Person FILTER .name = $0 OR .name = $0").unwrap();
2982        let ir = super::compile(&ast, &schema).expect("compile failed");
2983        assert_eq!(ir.params, vec!["0"], "repeated $0 must occupy a single slot");
2984    }
2985
2986    /// What a parameter's tuple cast recorded: whether it is an array, the
2987    /// nominal type name, and the member names the value is keyed by. A
2988    /// client holding `("X-Foo", "bar")` cannot know those names; the cast
2989    /// does.
2990    #[derive(Debug, PartialEq)]
2991    struct ParamTupleKeys {
2992        is_array: bool,
2993        type_name: Option<String>,
2994        keys: Vec<String>,
2995    }
2996
2997    fn param_tuple_keys(query: &str, schema: &SchemaDescriptor) -> Vec<Option<ParamTupleKeys>> {
2998        let ast = parse::parse(query).unwrap();
2999        let ir = super::compile(&ast, schema).expect("compile failed");
3000        ir.param_tuple_types
3001            .iter()
3002            .map(|plan| {
3003                plan.as_ref().map(|plan| ParamTupleKeys {
3004                    is_array: plan.is_array,
3005                    type_name: plan.type_name.clone(),
3006                    keys: plan.members.iter().map(|m| m.key.clone().unwrap_or_default()).collect(),
3007                })
3008            })
3009            .collect()
3010    }
3011
3012    #[test]
3013    fn test_a_tuple_cast_records_its_member_names_against_the_parameter() {
3014        let schema = make_schema();
3015        assert_eq!(
3016            param_tuple_keys("SELECT <tuple<street: str, zip: str>>$address", &schema),
3017            vec![Some(ParamTupleKeys {
3018                is_array: false,
3019                type_name: None,
3020                keys: vec!["street".to_string(), "zip".to_string()],
3021            })]
3022        );
3023    }
3024
3025    #[test]
3026    fn test_an_array_of_tuples_cast_records_one_element_s_member_names() {
3027        let schema = make_schema();
3028        assert_eq!(
3029            param_tuple_keys("SELECT <array<tuple<name: str, value: str>>>$headers", &schema),
3030            vec![Some(ParamTupleKeys {
3031                is_array: true,
3032                type_name: None,
3033                keys: vec!["name".to_string(), "value".to_string()],
3034            })]
3035        );
3036    }
3037
3038    #[test]
3039    fn test_a_nominal_named_tuple_cast_records_its_type_name_too() {
3040        use crate::schema::{NamedTupleDescriptor, TupleMemberDescriptor, TupleMemberKind};
3041        let mut schema = make_schema();
3042        schema.named_tuples.push(NamedTupleDescriptor {
3043            name: "Point".into(),
3044            module: "default".into(),
3045            members: vec![
3046                TupleMemberDescriptor {
3047                    name: Some("x".into()),
3048                    kind: TupleMemberKind::Scalar {
3049                        pg_type: "float8".into(),
3050                    },
3051                },
3052                TupleMemberDescriptor {
3053                    name: Some("y".into()),
3054                    kind: TupleMemberKind::Scalar {
3055                        pg_type: "float8".into(),
3056                    },
3057                },
3058            ],
3059        });
3060        assert_eq!(
3061            param_tuple_keys("SELECT <array<default::Point>>$points", &schema),
3062            vec![Some(ParamTupleKeys {
3063                is_array: true,
3064                type_name: Some("default::Point".to_string()),
3065                keys: vec!["x".to_string(), "y".to_string()],
3066            })]
3067        );
3068    }
3069
3070    #[test]
3071    fn test_a_parameter_cast_to_a_plain_scalar_records_no_tuple_plan() {
3072        let schema = make_schema();
3073        assert_eq!(param_tuple_keys("SELECT <str>$name", &schema), vec![None]);
3074    }
3075
3076    #[test]
3077    fn test_a_tuple_cast_in_expression_position_records_its_member_names() {
3078        // The write that matters — `set { headers := <array<tuple<…>>>$headers }`
3079        // — compiles its cast in expression position rather than as the free
3080        // select the cases above take.
3081        let schema = make_schema();
3082        assert_eq!(
3083            param_tuple_keys(
3084                "SELECT { name := <str>$name, address := <tuple<street: str, zip: str>>$address }",
3085                &schema
3086            ),
3087            vec![
3088                None,
3089                Some(ParamTupleKeys {
3090                    is_array: false,
3091                    type_name: None,
3092                    keys: vec!["street".to_string(), "zip".to_string()],
3093                })
3094            ]
3095        );
3096    }
3097}