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