Skip to main content

pylon_core/ir/
compiler.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
20use crate::error::{
21    Position, PyQLError, PyQLResolutionError, PyQLTypeError, PyQLUnknownFieldError, PyQLUnknownTypeError,
22};
23use crate::parse::ast::{self, Expr, Literal, NonesOrder, ShapeElement, ShapeOp, SortDirection, Stmt};
24use crate::schema::{
25    FunctionDescriptor, LinkDescriptor, MultiLinkDescriptor, PropertyDescriptor, SchemaDescriptor, SearchBackend,
26    TypeDescriptor,
27};
28
29use std::collections::{HashMap, HashSet};
30
31use super::{
32    IrArraySource, IrAssertedPointer, IrBinOp, IrComputedGlobalCte, IrComputedPointer, IrConflict, IrCteDef, IrDelete,
33    IrExpr, IrFor, IrForIterator, IrFreeExpr, IrFtsSearch, IrFunctionCall, IrFunctionSelect, IrGlobalCte, IrGroup,
34    IrGroupOutput, IrGroupProjection, IrIfElse, IrInsert, IrLinkProp, IrLiteral, IrLockClause, IrLockStrength,
35    IrLockWait, IrMultiLinkClear, IrMultiLinkJoin, IrMultiLinkMutation, IrMultiLinkPointer, IrMultiLinkValueSource,
36    IrMultiLinkValues, IrNulls, IrOutput, IrPathJoin, IrPathResult, IrPathSelect, IrPolyFanout, IrPolyImplementor,
37    IrRowSource, IrScalarPointer, IrScalarSetPointer, IrSelect, IrSessionGlobalCte, IrShapePointer,
38    IrSingleLinkCorrelation, IrSingleLinkPointer, IrSort, IrSortDir, IrSource, IrStmt, IrTypeCast, IrUnaryOp, IrUpdate,
39    IrVectorSearch, SearchEnqueueInfo, TupleCastShape, VectorEnqueueInfo,
40};
41
42// ── Compiled-clause tuples ──────────────────────────────────────────────────────
43//
44// The three helpers that pull a statement's trailing clauses apart all return
45// the same shape: the pieces in source order, each optional because a query
46// need not spell any of them out. Named here so the signatures read as one
47// concept rather than as an anonymous 4-tuple repeated three times.
48
49/// `(filter, order_by, offset, limit)` — a plain `select`'s trailing clauses.
50type SelectModifiers = (Option<IrExpr>, Vec<IrSort>, Option<IrExpr>, Option<IrExpr>);
51
52/// `(filter, distance/rank direction, offset, limit)` — the trailing clauses of
53/// a `vector::search`/`fts::search` select, where `order by` is constrained to
54/// the search score rather than an arbitrary sort list.
55type SearchModifiers = (Option<IrExpr>, Option<IrSortDir>, Option<IrExpr>, Option<IrExpr>);
56
57/// `(type name, shape elements, sub-statement, link-property alias)` — the
58/// parts of an expression that names a type and optionally shapes it.
59type TypeAndShape<'e> = (String, &'e [ShapeElement], Option<&'e Stmt>, Option<String>);
60
61// ── Public entry point ──────────────────────────────────────────────────────────
62
63/// Compile a parsed PyQL statement against the schema, using default session
64/// config (see `crate::ir::SessionConfig`) — used throughout this crate's own
65/// tests and schema-time compilation, which never has a live client-supplied
66/// config to honor. `compile_with_config` is the real entry a live query
67/// request uses.
68/// Returns the IR plan and the ordered list of parameter names (matching $1, $2, …).
69pub fn compile(stmt: &Stmt, schema: &SchemaDescriptor) -> Result<IrOutput, PyQLError> {
70    compile_with_config(stmt, schema, &crate::ir::SessionConfig::default())
71}
72
73/// Like `compile`, but honors a caller-supplied `SessionConfig` (e.g.
74/// `allow_user_specified_id`) for this one statement.
75pub fn compile_with_config(
76    stmt: &Stmt,
77    schema: &SchemaDescriptor,
78    config: &crate::ir::SessionConfig,
79) -> Result<IrOutput, PyQLError> {
80    let mut c = Compiler::with_config(schema, config.clone());
81
82    // Unwrap top-level WITH block: compile each CTE binding, then the main statement.
83    let (ctes, ir) = if let Stmt::With(w) = stmt {
84        // Special case: `with search := vector::search(…); select search { … } …`
85        // Merge into a single IrVectorSearch rather than going through the CTE machinery.
86        if let Some(ir) = try_compile_vs_with_pattern(&mut c, w)? {
87            return Ok(IrOutput {
88                subtype_fanouts: c.subtype_fanouts(),
89                stmt: ir,
90                params: c.params,
91                ctes: vec![],
92                global_ctes: c.global_ctes,
93                warnings: c.warnings,
94                uses_globals_arg: c.used_globals_arg,
95            });
96        }
97        // Special case: `with search := fts::search(…); select search { … } …`
98        if let Some(ir) = try_compile_fts_with_pattern(&mut c, w)? {
99            return Ok(IrOutput {
100                subtype_fanouts: c.subtype_fanouts(),
101                stmt: ir,
102                params: c.params,
103                ctes: vec![],
104                global_ctes: c.global_ctes,
105                warnings: c.warnings,
106                uses_globals_arg: c.used_globals_arg,
107            });
108        }
109
110        let mut cte_defs = vec![];
111        for alias in &w.aliases {
112            if let Some(declared) = declared_pointers_of(&alias.expr) {
113                c.cte_declared_pointers.insert(alias.name.clone(), declared);
114            }
115            if c.bind_group(&alias.name, &alias.expr)? {
116                continue;
117            }
118            let ir_stmt = compile_cte_binding(&mut c, &alias.expr)?;
119            let sql_name = c.claim_cte_sql_name(&alias.name);
120            let type_name = c.register_cte(&alias.name, &ir_stmt);
121            // What the binding hoisted (`p := existing ?? (insert …)`) goes
122            // right before it: a CTE only sees the ones ahead of it.
123            cte_defs.append(&mut c.hoisted_ctes);
124            cte_defs.push(IrCteDef {
125                name: sql_name,
126                stmt: ir_stmt,
127                type_name,
128                correlated_to: None,
129            });
130        }
131        let main = c.compile_stmt(&w.stmt)?;
132        (cte_defs, main)
133    } else {
134        (vec![], c.compile_stmt(stmt)?)
135    };
136
137    let mut ctes = ctes;
138    ctes.extend(std::mem::take(&mut c.hoisted_ctes));
139    Ok(IrOutput {
140        subtype_fanouts: c.subtype_fanouts(),
141        stmt: ir,
142        params: c.params,
143        ctes,
144        global_ctes: c.global_ctes,
145        warnings: c.warnings,
146        uses_globals_arg: c.used_globals_arg,
147    })
148}
149
150/// Detect `with <var> := vector::search(Type, $vec); select <var> { object { … }, distance }`.
151/// When matched, compile the whole thing to a single `IrVectorSearch`.
152fn try_compile_vs_with_pattern(c: &mut Compiler<'_>, w: &ast::WithStmt) -> Result<Option<IrStmt>, PyQLError> {
153    // Only handle exactly one alias that is a bare function call (not a subquery).
154    if w.aliases.len() != 1 {
155        return Ok(None);
156    }
157    let alias_def = &w.aliases[0];
158    let fc = match &alias_def.expr {
159        Expr::FunctionCall(fc) => fc,
160        _ => return Ok(None),
161    };
162    if fc.module.as_deref() != Some("vector") || fc.name != "search" {
163        return Ok(None);
164    }
165
166    // Main statement must be `select <alias_name> { … }`.
167    let select_stmt = match w.stmt.as_ref() {
168        Stmt::Select(s) => s,
169        _ => return Ok(None),
170    };
171    // Unwrap optional Shape wrapper around the result expression.
172    let (elements, result_inner): (&[ast::ShapeElement], &Expr) = match &select_stmt.result {
173        Expr::Shape(sh) => {
174            let inner = sh.expr.as_ref().unwrap_or(&select_stmt.result);
175            (sh.elements.as_slice(), inner)
176        }
177        other => (&[], other),
178    };
179    // The inner expression should be a path referencing the WITH alias.
180    match result_inner {
181        Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
182            if let ast::PathStep::Name(n) = &p.steps[0] {
183                if n != &alias_def.name {
184                    return Ok(None);
185                }
186            } else {
187                return Ok(None);
188            }
189        }
190        _ => return Ok(None),
191    }
192
193    if let Some(ir) = c.try_compile_vector_search(fc, elements, select_stmt)? {
194        Ok(Some(IrStmt::VectorSearch(ir)))
195    } else {
196        Ok(None)
197    }
198}
199
200fn try_compile_fts_with_pattern(c: &mut Compiler<'_>, w: &ast::WithStmt) -> Result<Option<IrStmt>, PyQLError> {
201    if w.aliases.len() != 1 {
202        return Ok(None);
203    }
204    let alias_def = &w.aliases[0];
205    let fc = match &alias_def.expr {
206        Expr::FunctionCall(fc) => fc,
207        _ => return Ok(None),
208    };
209    if fc.module.as_deref() != Some("fts") || fc.name != "search" {
210        return Ok(None);
211    }
212
213    let select_stmt = match w.stmt.as_ref() {
214        Stmt::Select(s) => s,
215        _ => return Ok(None),
216    };
217    let (elements, result_inner): (&[ast::ShapeElement], &Expr) = match &select_stmt.result {
218        Expr::Shape(sh) => {
219            let inner = sh.expr.as_ref().unwrap_or(&select_stmt.result);
220            (sh.elements.as_slice(), inner)
221        }
222        other => (&[], other),
223    };
224    match result_inner {
225        Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
226            if let ast::PathStep::Name(n) = &p.steps[0] {
227                if n != &alias_def.name {
228                    return Ok(None);
229                }
230            } else {
231                return Ok(None);
232            }
233        }
234        _ => return Ok(None),
235    }
236
237    if let Some(ir) = c.try_compile_fts_search(fc, elements, select_stmt)? {
238        Ok(Some(IrStmt::FtsSearch(ir)))
239    } else {
240        Ok(None)
241    }
242}
243
244/// How many times a path may expand a computed pointer into its own path
245/// before we call it a cycle. Chains are normal (a computed over a computed);
246/// a chain this long is a schema that refers to itself.
247/// AND a path traversal's own FILTER together with whatever conditions the
248/// computed pointers spliced into it contributed. Every join in a path
249/// select is inner, so a spliced computed's filter means the same thing as a
250/// WHERE condition on the whole traversal.
251/// `__subject__.p ?? (insert T { … })` — a rewrite that fills `p` with a new
252/// object when the statement gives it none. The insert has no place in an
253/// expression, so on insert it becomes `p := (insert …)` instead; `None`
254/// for any other rewrite.
255fn subject_default_insert(handler: &str, pointer: &str) -> Option<Expr> {
256    fn holds_insert(expr: &Expr) -> bool {
257        match expr {
258            Expr::SubQuery(stmt) => match stmt.as_ref() {
259                Stmt::Insert(_) => true,
260                Stmt::Select(sel) => holds_insert(&sel.result),
261                _ => false,
262            },
263            _ => false,
264        }
265    }
266    let Ok(Expr::BinOp(b)) = crate::parse::parse_pointer_expr(handler) else {
267        return None;
268    };
269    let reads_itself = matches!(&b.left, Expr::Path(p) if matches!(p.steps.as_slice(),
270        [ast::PathStep::Name(subject), ast::PathStep::Name(name)] if subject == "__subject__" && name == pointer));
271    (matches!(b.op, ast::BinOpKind::Coalesce) && reads_itself && holds_insert(&b.right)).then_some(b.right)
272}
273
274/// A sub-select's own `limit 1`: what makes it one object rather than a set.
275fn limits_to_one(sel: Option<&ast::SelectStmt>) -> bool {
276    sel.is_some_and(|sel| matches!(sel.limit, Some(Expr::Literal(ast::Literal::Int(1)))))
277}
278
279/// True when a select can yield at most one row: `limit 1`, or a filter that
280/// pins an exclusive property (`.id = …`) in one of its `and`-ed conditions.
281fn selects_at_most_one(sel: &ast::SelectStmt, td: &TypeDescriptor) -> bool {
282    fn pins_exclusive(expr: &Expr, td: &TypeDescriptor) -> bool {
283        let Expr::BinOp(b) = expr else { return false };
284        match b.op {
285            ast::BinOpKind::And => pins_exclusive(&b.left, td) || pins_exclusive(&b.right, td),
286            ast::BinOpKind::Eq => [&b.left, &b.right].into_iter().any(|side| {
287                matches!(side, Expr::Path(p) if p.partial
288                    && matches!(p.steps.as_slice(), [ast::PathStep::Name(name)]
289                        if td.properties.iter().any(|d| &d.name == name && (d.is_exclusive || d.is_pk))))
290            }),
291            _ => false,
292        }
293    }
294    matches!(sel.limit, Some(Expr::Literal(ast::Literal::Int(1))))
295        || sel.filter.as_ref().is_some_and(|f| pins_exclusive(f, td))
296}
297
298/// `(select .teams … limit 1) if cond else {}` as `(select .teams filter cond …
299/// limit 1)`, carrying along a shape written on the branch or on the whole
300/// if/else. `None` unless exactly one branch is empty and the other is a
301/// relative walk or a select over one, and `cond` reads no relative path --
302/// moved into the branch's filter, one would read the branch's rows instead.
303fn guarded_object_branch(expr: &Expr) -> Option<Expr> {
304    fn reads_relative(expr: &Expr) -> bool {
305        match expr {
306            Expr::Path(p) => p.partial,
307            Expr::Literal(_) | Expr::Parameter(_) | Expr::Global(_) => false,
308            Expr::BinOp(b) => reads_relative(&b.left) || reads_relative(&b.right),
309            Expr::UnaryOp(u) => reads_relative(&u.operand),
310            Expr::TypeCast(c) => reads_relative(&c.expr),
311            Expr::FunctionCall(f) => f.args.iter().chain(f.kwargs.iter().map(|(_, v)| v)).any(reads_relative),
312            Expr::IfElse(ie) => {
313                reads_relative(&ie.if_expr) || reads_relative(&ie.condition) || reads_relative(&ie.else_expr)
314            }
315            _ => true,
316        }
317    }
318    fn empty(expr: &Expr) -> bool {
319        match expr {
320            Expr::Set(items) => items.is_empty(),
321            Expr::TypeCast(c) => matches!(&c.expr, Expr::Set(items) if items.is_empty()),
322            _ => false,
323        }
324    }
325    let (ie, outer_shape) = match expr {
326        Expr::IfElse(ie) => (ie.as_ref(), None),
327        Expr::Shape(sh) => match sh.expr.as_ref()? {
328            Expr::IfElse(ie) => (ie.as_ref(), Some(sh.elements.clone())),
329            _ => return None,
330        },
331        _ => return None,
332    };
333    if reads_relative(&ie.condition) {
334        return None;
335    }
336    let (branch, condition) = match (empty(&ie.if_expr), empty(&ie.else_expr)) {
337        (false, true) => (&ie.if_expr, ie.condition.clone()),
338        (true, false) => (
339            &ie.else_expr,
340            Expr::UnaryOp(Box::new(ast::UnaryOp {
341                op: ast::UnaryOpKind::Not,
342                operand: ie.condition.clone(),
343            })),
344        ),
345        _ => return None,
346    };
347    let (branch, branch_shape) = match branch {
348        Expr::Shape(sh) => (sh.expr.as_ref()?, Some(sh.elements.clone())),
349        other => (other, None),
350    };
351    let mut select = match branch {
352        Expr::Path(p) if p.partial => ast::SelectStmt {
353            result: branch.clone(),
354            filter: None,
355            order_by: vec![],
356            offset: None,
357            limit: None,
358            lock: None,
359        },
360        Expr::SubQuery(stmt) => match stmt.as_ref() {
361            Stmt::Select(sel) if matches!(&sel.result, Expr::Path(p) if p.partial) => sel.clone(),
362            _ => return None,
363        },
364        _ => return None,
365    };
366    select.filter = Some(match select.filter.take() {
367        Some(filter) => Expr::BinOp(Box::new(ast::BinOp {
368            left: filter,
369            op: ast::BinOpKind::And,
370            right: condition,
371        })),
372        None => condition,
373    });
374    let guarded = Expr::SubQuery(Box::new(Stmt::Select(select)));
375    Some(match outer_shape.or(branch_shape) {
376        Some(elements) => Expr::Shape(Box::new(ast::ShapeExpr {
377            expr: Some(guarded),
378            elements,
379            marker_offset: None,
380        })),
381        None => guarded,
382    })
383}
384
385/// `.grants.account = a and not .grants.deleted` as the multi-link `grants`
386/// and the condition on one of its elements (`.account = a and not
387/// .deleted`, a bare `.grants` becoming the element's `.id`). `None` unless
388/// every relative path in the condition walks that same multi-link — one
389/// reading the outer row would rebind to the element inside the sub-select —
390/// and the condition holds no sub-statement.
391fn per_element_of_one_multilink(condition: &Expr, td: &TypeDescriptor) -> Option<(Vec<ast::PathStep>, Expr)> {
392    // The steps that reach the elements: a link name, or a backlink with the
393    // type intersection that narrows it (`.<brand[is Assignment]`).
394    fn prefix_len(steps: &[ast::PathStep]) -> Option<usize> {
395        match steps {
396            [ast::PathStep::Name(_), ..] => Some(1),
397            [ast::PathStep::Backlink(_), ast::PathStep::TypeIntersection(_), ..] => Some(2),
398            _ => None,
399        }
400    }
401    fn rewrite(expr: &Expr, link: &mut Option<Vec<ast::PathStep>>) -> Option<Expr> {
402        Some(match expr {
403            Expr::Path(p) if p.partial => {
404                let split = prefix_len(&p.steps)?;
405                let prefix = p.steps[..split].to_vec();
406                if *link.get_or_insert_with(|| prefix.clone()) != prefix {
407                    return None;
408                }
409                // A condition on the link itself (`.posts = p`, no tail) has
410                // no element to rebind; `all` over one takes the per-element
411                // route in `try_elementwise_in` instead.
412                let rest = p.steps[split..].to_vec();
413                if rest.is_empty() {
414                    return None;
415                }
416                Expr::Path(ast::Path {
417                    steps: rest,
418                    partial: true,
419                })
420            }
421            Expr::Path(_) | Expr::Literal(_) | Expr::Parameter(_) | Expr::Global(_) => expr.clone(),
422            Expr::BinOp(b) => Expr::BinOp(Box::new(ast::BinOp {
423                left: rewrite(&b.left, link)?,
424                op: b.op.clone(),
425                // A set literal on the right of `in` is the haystack the test
426                // reads, not a second set the quantifier ranges over, so it
427                // carries through whole. Rewritten as an ordinary operand it
428                // would be refused below, which is what made `all(.posts.title
429                // in {'a', 'b'})` fall back to the `any` reading.
430                right: match (&b.op, &b.right) {
431                    (ast::BinOpKind::In | ast::BinOpKind::NotIn, set @ Expr::Set(_)) => set.clone(),
432                    (_, other) => rewrite(other, link)?,
433                },
434            })),
435            Expr::UnaryOp(u) => Expr::UnaryOp(Box::new(ast::UnaryOp {
436                op: u.op.clone(),
437                operand: rewrite(&u.operand, link)?,
438            })),
439            Expr::TypeCast(c) => Expr::TypeCast(Box::new(ast::TypeCast {
440                expr: rewrite(&c.expr, link)?,
441                ty: c.ty.clone(),
442            })),
443            Expr::TypeIs { expr, ty } => Expr::TypeIs {
444                expr: Box::new(rewrite(expr, link)?),
445                ty: ty.clone(),
446            },
447            // A set-returning call (`array_unpack`) is a second set the
448            // quantifier ranges over, so turning the condition into one asked
449            // per link element would change what it asks.
450            Expr::FunctionCall(f)
451                if crate::stdlib::lookup(f.module.as_deref().unwrap_or("std"), &f.name)
452                    .iter()
453                    .any(|d| d.returns_set()) =>
454            {
455                return None;
456            }
457            Expr::FunctionCall(f) => Expr::FunctionCall(ast::FunctionCall {
458                module: f.module.clone(),
459                name: f.name.clone(),
460                args: f.args.iter().map(|a| rewrite(a, link)).collect::<Option<_>>()?,
461                kwargs: f
462                    .kwargs
463                    .iter()
464                    .map(|(k, v)| Some((k.clone(), rewrite(v, link)?)))
465                    .collect::<Option<_>>()?,
466            }),
467            _ => return None,
468        })
469    }
470    let mut link = None;
471    let element_condition = rewrite(condition, &mut link)?;
472    let link = link?;
473    let many = match link.first()? {
474        ast::PathStep::Name(name) => td.multilinks.iter().any(|m| &m.name == name),
475        ast::PathStep::Backlink(_) => true,
476        _ => false,
477    };
478    many.then_some((link, element_condition))
479}
480
481/// A shape's subject written in a longer form than it needs, rewritten to the
482/// form the select routes already take; `None` when there is nothing to
483/// rewrite.
484///
485/// - `(select E) { … }` with no clauses of its own is `E { … }`, for the
486///   subjects only a select over them spells: an if/else, a union, a shape.
487/// - `X { a := … } { a: { … } }` is one shape. The outer one says what comes
488///   back; a name it lists that the inner one computed keeps that computation
489///   and takes the outer one's nested shape and modifiers.
490fn flatten_shape_subject(expr: &Expr) -> Option<Expr> {
491    let Expr::Shape(outer) = expr else { return None };
492    match outer.expr.as_ref()? {
493        Expr::SubQuery(stmt) => {
494            let Stmt::Select(sel) = stmt.as_ref() else { return None };
495            let bare = sel.filter.is_none()
496                && sel.order_by.is_empty()
497                && sel.offset.is_none()
498                && sel.limit.is_none()
499                && sel.lock.is_none();
500            if !bare || !matches!(sel.result, Expr::IfElse(_) | Expr::Union(_, _) | Expr::Shape(_)) {
501                return None;
502            }
503            let mut flat = outer.as_ref().clone();
504            flat.expr = Some(sel.result.clone());
505            Some(Expr::Shape(Box::new(flat)))
506        }
507        Expr::Shape(inner) => {
508            let elements = outer
509                .elements
510                .iter()
511                .map(|el| {
512                    let declared = match (&el.compexpr, el.path.steps.as_slice()) {
513                        (None, [ast::PathStep::Name(name)]) => inner.elements.iter().find(|d| {
514                            d.compexpr.is_some()
515                                && matches!(d.path.steps.as_slice(), [ast::PathStep::Name(n)] if n == name)
516                        }),
517                        _ => None,
518                    };
519                    match declared {
520                        Some(d) => ShapeElement {
521                            nested: el.nested.clone().or_else(|| d.nested.clone()),
522                            filter: el.filter.clone().or_else(|| d.filter.clone()),
523                            order_by: if el.order_by.is_empty() {
524                                d.order_by.clone()
525                            } else {
526                                el.order_by.clone()
527                            },
528                            offset: el.offset.clone().or_else(|| d.offset.clone()),
529                            limit: el.limit.clone().or_else(|| d.limit.clone()),
530                            ..d.clone()
531                        },
532                        None => el.clone(),
533                    }
534                })
535                .collect();
536            Some(Expr::Shape(Box::new(ast::ShapeExpr {
537                expr: inner.expr.clone(),
538                elements,
539                marker_offset: inner.marker_offset,
540            })))
541        }
542        _ => None,
543    }
544}
545
546/// The name `.key.<name>` of a group is read through while its shape
547/// compiles — not one a query can spell, so it shadows nothing.
548fn group_key_binding(name: &str) -> String {
549    format!("<group key {name}>")
550}
551
552fn and_conditions(filter: Option<IrExpr>, extra: Vec<IrExpr>) -> Option<IrExpr> {
553    extra.into_iter().fold(filter, |acc, cond| match acc {
554        Some(existing) => Some(IrExpr::BinOp(Box::new(IrBinOp {
555            left: existing,
556            op: ast::BinOpKind::And,
557            right: cond,
558        }))),
559        None => Some(cond),
560    })
561}
562
563const MAX_COMPUTED_SPLICES: usize = 32;
564
565/// `sum`, `any`, `all` and `array_agg` over no rows: SQL gives NULL, PyQL the
566/// empty set's own value. `'{}'` rather than `ARRAY[]`, whose element type
567/// PostgreSQL cannot infer on its own — inside a `coalesce` it takes the one
568/// the aggregate already carries.
569fn aggregate_over_nothing_sql(name: &str) -> Option<&'static str> {
570    match name {
571        "sum" => Some("0"),
572        "any" => Some("false"),
573        "all" => Some("true"),
574        "array_agg" => Some("'{}'"),
575        _ => None,
576    }
577}
578
579fn aggregate_over_nothing(name: &str, aggregate: IrExpr) -> IrExpr {
580    let Some(value) = aggregate_over_nothing_sql(name) else {
581        return aggregate;
582    };
583    IrExpr::FunctionCall(IrFunctionCall {
584        return_pg_type: None,
585        schema: None,
586        name: "coalesce".to_string(),
587        args: vec![aggregate, IrExpr::RawSql(value.to_string())],
588        sql_template: None,
589    })
590}
591
592/// An expression whose value is a whole set, gathered as an array.
593/// A set-valued walk gathered as an array, read back as a scalar subquery
594/// instead — what an ordering comparison against a single value needs.
595///
596/// `(.installation.<installation[is RateLimitState].backoff_until < now) ?? true`
597/// is conduit's backoff gate, and it reads element-wise: empty set →
598/// `{}` → the `??` supplies `true`. Rendered as an array the comparison is
599/// `timestamptz[] < timestamptz`, which PostgreSQL has no operator for, so the
600/// statement failed outright. As a scalar subquery the empty case is NULL, the
601/// comparison is NULL, and `COALESCE` supplies the default — right for
602/// the nought-or-one sets these walks actually produce (an `exclusive`
603/// constraint guarantees it here). A genuinely many-valued walk now raises
604/// PostgreSQL's own "more than one row returned by a subquery" rather than
605/// comparing element-wise; that is a narrower gap than emitting SQL that
606/// cannot run at all.
607fn set_walk_as_scalar(expr: IrExpr) -> IrExpr {
608    match expr {
609        IrExpr::ArrayFromSelect(source) => match *source {
610            IrArraySource::PathSelect(ps) => IrExpr::PathSubquery(ps),
611            other => IrExpr::ArrayFromSelect(Box::new(other)),
612        },
613        other => other,
614    }
615}
616
617fn yields_array(expr: &IrExpr) -> bool {
618    match expr {
619        IrExpr::ArrayFromSelect(_) => true,
620        IrExpr::IfElse(ie) => {
621            (yields_array(&ie.if_) || matches!(ie.if_, IrExpr::Null))
622                && (yields_array(&ie.else_) || matches!(ie.else_, IrExpr::Null))
623                && !(matches!(ie.if_, IrExpr::Null) && matches!(ie.else_, IrExpr::Null))
624        }
625        _ => false,
626    }
627}
628
629/// Look through the `coalesce` an aggregate over no rows is wrapped in — see
630/// `aggregate_over_nothing`. The default only stands in for the aggregate, so
631/// the type is the aggregate's.
632fn through_coalesce(expr: &IrExpr) -> &IrExpr {
633    match expr {
634        IrExpr::FunctionCall(f) if f.schema.is_none() && f.name == "coalesce" => {
635            f.args.first().map(through_coalesce).unwrap_or(expr)
636        }
637        other => other,
638    }
639}
640
641fn ir_value_type_name(expr: &IrExpr) -> String {
642    ir_value_type(expr).unwrap_or_default()
643}
644
645/// The pg type a bound value carries, including the array types
646/// `infer_ir_type` has no spelling for — a `with` binding that loses its
647/// array-ness resolves no overload at all, so `contains(ids, .id)` reports a
648/// call nobody wrote.
649fn ir_value_type(expr: &IrExpr) -> Option<String> {
650    let expr = through_coalesce(expr);
651    match expr {
652        IrExpr::Array(elements) => {
653            let element = elements.first().and_then(infer_ir_type).unwrap_or("text");
654            Some(format!("{}[]", literal_sentinel_to_pg(element)))
655        }
656        // `ids := array_agg(r.id)` binds an array of what it aggregates, which
657        // is what lets `contains(ids, .id)` pick the array overload over `str`'s.
658        IrExpr::FunctionCall(f) if f.schema.is_none() && f.name == "array_agg" => {
659            let element = f.args.first().and_then(infer_ir_type)?;
660            Some(format!("{}[]", literal_sentinel_to_pg(element)))
661        }
662        // `array_agg((select …))` aggregates inside a correlated select, so
663        // the array is what that select projects rather than the call itself.
664        IrExpr::PathSubquery(ps) => match &ps.result {
665            IrPathResult::Scalar(inner, _) => ir_value_type(inner),
666            IrPathResult::Object { .. } => None,
667        },
668        IrExpr::ArrayFromSelect(source) => array_source_element_type(source).map(|element| format!("{element}[]")),
669        // `xs if cond else ys` is typed by whichever branch can say — the
670        // other is routinely the empty set.
671        IrExpr::IfElse(ie) => ir_value_type(&ie.if_).or_else(|| ir_value_type(&ie.else_)),
672        other => infer_ir_type(other).map(|t| t.to_string()),
673    }
674}
675
676/// The pg type of one row of an `ARRAY(SELECT …)`. `None` for a source that
677/// aggregates whole objects: those rows are composites with no scalar name.
678fn array_source_element_type(source: &IrArraySource) -> Option<String> {
679    match source {
680        IrArraySource::Select(sel) => match sel.rows.as_slice() {
681            [IrRowSource::Bound { shape, .. }] => match shape.first() {
682                // Mirrors the emitter: the first scalar pointer is the
683                // element, and a shape with none of them projects the id.
684                Some(IrShapePointer::Scalar(scalar)) => Some(scalar.pg_type.clone()),
685                _ => Some("uuid".to_string()),
686            },
687            _ => None,
688        },
689        IrArraySource::PathSelect(ps) => match &ps.result {
690            IrPathResult::Scalar(expr, _) => infer_ir_type(expr).map(|t| literal_sentinel_to_pg(t).to_string()),
691            IrPathResult::Object { .. } => None,
692        },
693        _ => None,
694    }
695}
696
697/// The computed pointers a `with` binding's own shape declares, if any.
698fn declared_pointers_of(expr: &Expr) -> Option<Vec<ShapeElement>> {
699    // `(select T { x := … }) if cond else {}` declares what its select does.
700    if let Expr::IfElse(ie) = expr {
701        return declared_pointers_of(&ie.if_expr).or_else(|| declared_pointers_of(&ie.else_expr));
702    }
703    let Expr::SubQuery(stmt) = expr else {
704        return None;
705    };
706    let shape = match innermost_select(stmt)? {
707        ast::SelectStmt {
708            result: Expr::Shape(sh),
709            ..
710        } => sh,
711        _ => return None,
712    };
713    let declared: Vec<ShapeElement> = shape
714        .elements
715        .iter()
716        .filter(|el| el.compexpr.is_some())
717        .cloned()
718        .collect();
719    (!declared.is_empty()).then_some(declared)
720}
721
722/// The select a statement ultimately is, past any `with` blocks.
723fn innermost_select(stmt: &Stmt) -> Option<&ast::SelectStmt> {
724    match stmt {
725        Stmt::Select(sel) => Some(sel),
726        Stmt::With(w) => innermost_select(&w.stmt),
727        _ => None,
728    }
729}
730
731fn cte_stmt_type(stmt: &IrStmt) -> String {
732    match stmt {
733        IrStmt::Insert(ins) => ins.target.type_name.clone(),
734        IrStmt::Update(upd) => upd.target.type_name.clone(),
735        IrStmt::Delete(del) => del.target.type_name.clone(),
736        IrStmt::Select(sel) => match sel.rows.first() {
737            Some(IrRowSource::Bound { source, .. }) => source.type_name.clone(),
738            // Infer the scalar pg_type from the first free item so the type
739            // is available for UNION mismatch error messages. Returns empty
740            // string if unknown.
741            Some(IrRowSource::Free(IrFreeExpr::Scalar(expr))) => ir_value_type_name(expr),
742            _ => String::new(),
743        },
744        // What a path select *yields*, not what it starts from: `with xs :=
745        // (select Person.name)` binds text, not `default::Person`, and the
746        // difference decides whether a reference to it reads the CTE's
747        // `result` column or its `id` (see `resolve_name_ref`).
748        IrStmt::PathSelect(ps) => match &ps.result {
749            IrPathResult::Scalar(expr, _) => ir_value_type_name(expr),
750            IrPathResult::Object { type_name, .. } => type_name.clone(),
751        },
752        IrStmt::For(f) => cte_stmt_type(&f.body),
753        IrStmt::ScalarUnion(branches) => branches.first().map(cte_stmt_type).unwrap_or_default(),
754        IrStmt::Group(g) => g.source.type_name.clone(),
755        IrStmt::FunctionSelect(fs) => fs.type_name.clone(),
756        IrStmt::VectorSearch(vs) => format!("__vs__{}", vs.source.type_name),
757        IrStmt::FtsSearch(fs) => format!("__fts__{}", fs.source.type_name),
758    }
759}
760
761/// Compile a WITH binding value: a subquery becomes its statement; any other
762/// expression is wrapped in a synthetic `select expr` so it can be used as a CTE.
763fn compile_cte_binding(c: &mut Compiler<'_>, expr: &Expr) -> Result<IrStmt, PyQLError> {
764    let stmt = compile_cte_binding_stmt(c, expr)?;
765    Ok(hoist_binding_dml(c, stmt))
766}
767
768/// `g := (insert T { … }) if cond else {}` — the binding's value compiles to a
769/// select *over* a mutation. A data-modifying statement may only sit at the top
770/// level of a `WITH`, never inside another CTE's body, so the mutation becomes a
771/// CTE of its own ahead of this binding — where `g := existing ?? (insert T { …
772/// })` already puts one — and the binding reads its rows back from there.
773/// Emitted as-is, the mutation was dropped and the binding read the whole table.
774fn hoist_binding_dml(c: &mut Compiler<'_>, stmt: IrStmt) -> IrStmt {
775    let IrStmt::Select(mut select) = stmt else {
776        return stmt;
777    };
778    let Some(dml) = select
779        .dml_source
780        .take_if(|dml| matches!(dml.as_ref(), IrStmt::Insert(_) | IrStmt::Update(_) | IrStmt::Delete(_)))
781    else {
782        return IrStmt::Select(select);
783    };
784    let cte_name = c.fresh_nested_cte_name();
785    for row in &mut select.rows {
786        if let IrRowSource::Bound { source, .. } = row {
787            source.table = format!("@cte:{cte_name}");
788            // The CTE's rows are already the concrete ones the mutation
789            // touched, each carrying its own `__type__`.
790            source.poly = None;
791        }
792    }
793    c.hoisted_ctes.push(IrCteDef {
794        name: cte_name,
795        type_name: cte_stmt_type(&dml),
796        stmt: *dml,
797        correlated_to: None,
798    });
799    IrStmt::Select(select)
800}
801
802fn compile_cte_binding_stmt(c: &mut Compiler<'_>, expr: &Expr) -> Result<IrStmt, PyQLError> {
803    if let Expr::SubQuery(s) = expr {
804        let stmt = c.compile_stmt(s)?;
805        if let IrStmt::Group(grp) = &stmt
806            && !matches!(grp.output, IrGroupOutput::Elements)
807        {
808            return Err(c.type_err("a `group` cannot be bound in a `with` except to iterate it with `for`"));
809        }
810        return Ok(stmt);
811    }
812    // `existing ?? (insert Tag { … })` — on sets `A ?? B` is `A if exists A
813    // else B`, the upsert idiom the if/else route already guards the
814    // mutation for.
815    let expr = match expr {
816        Expr::BinOp(b)
817            if b.op == ast::BinOpKind::Coalesce
818                && matches!(&b.right, Expr::SubQuery(s) if matches!(s.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_))) =>
819        {
820            &Expr::IfElse(Box::new(ast::IfElse {
821                if_expr: b.left.clone(),
822                condition: Expr::UnaryOp(Box::new(ast::UnaryOp {
823                    op: ast::UnaryOpKind::Exists,
824                    operand: b.left.clone(),
825                })),
826                else_expr: b.right.clone(),
827            }))
828        }
829        other => other,
830    };
831    // Non-statement expression (e.g. `<default::Company><uuid>'...'`):
832    // treat as `select expr`.
833    let fake_sel = ast::SelectStmt {
834        result: expr.clone(),
835        filter: None,
836        order_by: vec![],
837        offset: None,
838        limit: None,
839        lock: None,
840    };
841    c.compile_stmt(&Stmt::Select(fake_sel))
842}
843
844/// Qualified names of the functions that take `GLOBALS_ARG`.
845///
846/// A function needs it when its body reads a session global, *or* when it calls
847/// a function that needs it — so this is a fixpoint, not a single pass: the
848/// first pass finds the direct readers, later passes find their callers. A body
849/// that fails to compile is skipped; that failure is reported by validation,
850/// and guessing at its globals here would only produce a second, worse error.
851pub fn functions_needing_globals(schema: &SchemaDescriptor) -> &std::collections::HashSet<String> {
852    schema
853        .functions_needing_globals
854        .get_or_init(|| find_functions_needing_globals(schema))
855}
856
857fn find_functions_needing_globals(schema: &SchemaDescriptor) -> std::collections::HashSet<String> {
858    let mut needs: std::collections::HashSet<String> = std::collections::HashSet::new();
859    loop {
860        let mut changed = false;
861        for fd in &schema.functions {
862            let qualified = format!("{}::{}", fd.module, fd.name);
863            if needs.contains(&qualified) {
864                continue;
865            }
866            if let Ok(out) = compile_fn_body_with(fd, schema, &needs)
867                && out.uses_globals_arg
868            {
869                needs.insert(qualified);
870                changed = true;
871            }
872        }
873        if !changed {
874            return needs;
875        }
876    }
877}
878
879/// Compile the PyQL body of a user-defined function for DDL emission.
880///
881/// Sets `fn_params` on the compiler so that parameter names resolve as `FnParam`
882/// nodes rather than raising "expression is not valid in free SELECT context".
883pub fn compile_fn_body(
884    fn_desc: &crate::schema::FunctionDescriptor,
885    schema: &SchemaDescriptor,
886) -> Result<super::IrOutput, crate::error::PyQLError> {
887    compile_fn_body_with(fn_desc, schema, functions_needing_globals(schema))
888}
889
890/// `compile_fn_body` with the globals-argument set supplied, so the fixpoint in
891/// `functions_needing_globals` can call this without recursing into itself.
892pub fn compile_fn_body_with(
893    fn_desc: &crate::schema::FunctionDescriptor,
894    schema: &SchemaDescriptor,
895    fns_needing_globals: &std::collections::HashSet<String>,
896) -> Result<super::IrOutput, crate::error::PyQLError> {
897    use crate::parse;
898    use crate::parse::ast::Stmt;
899
900    let body = fn_desc.body.trim().to_string();
901    // A body that is already a statement stands on its own; only a bare
902    // expression needs the `select` that turns it into one. Wrapping a
903    // statement instead would bury it as a sub-select and lose its clauses.
904    let starts_a_stmt = ["select", "with", "for", "group", "insert", "update", "delete"]
905        .iter()
906        .any(|keyword| {
907            body.len() > keyword.len()
908                && body[..keyword.len()].eq_ignore_ascii_case(keyword)
909                && !body.as_bytes()[keyword.len()].is_ascii_alphanumeric()
910                && body.as_bytes()[keyword.len()] != b'_'
911        });
912    let body = if starts_a_stmt {
913        body
914    } else {
915        format!("select {}", body)
916    };
917
918    let ast = parse::parse(&body)?;
919    let mut c = Compiler::new(schema);
920    c.in_fn_body = true;
921    c.fns_needing_globals = Some(fns_needing_globals.clone());
922    for p in &fn_desc.params {
923        c.fn_params.insert(p.name.clone(), p.pg_type.clone());
924    }
925
926    let (ctes, ir) = if let Stmt::With(w) = &ast {
927        let mut cte_defs = vec![];
928        for alias in &w.aliases {
929            let ir_stmt = compile_cte_binding(&mut c, &alias.expr)?;
930            let type_name = cte_stmt_type(&ir_stmt);
931            c.cte_types.insert(alias.name.clone(), type_name.clone());
932            c.note_cte_cardinality(&alias.name, &ir_stmt);
933            cte_defs.push(super::IrCteDef {
934                name: alias.name.clone(),
935                stmt: ir_stmt,
936                type_name,
937                correlated_to: None,
938            });
939        }
940        let main = c.compile_stmt(&w.stmt)?;
941        (cte_defs, main)
942    } else {
943        (vec![], c.compile_stmt(&ast)?)
944    };
945
946    let mut ctes = ctes;
947    ctes.extend(std::mem::take(&mut c.hoisted_ctes));
948    Ok(super::IrOutput {
949        subtype_fanouts: c.subtype_fanouts(),
950        stmt: ir,
951        params: c.params,
952        ctes,
953        global_ctes: c.global_ctes,
954        warnings: c.warnings,
955        uses_globals_arg: c.used_globals_arg,
956    })
957}
958
959/// Compile a schema `Trigger`'s `handler` PyQL statement for DDL emission —
960/// same shape as `compile_fn_body`, but instead of `fn_params` this binds
961/// `__new__`/`__old__` as the inserted/updated/deleted row (see
962/// `Compiler::special_anchors`'s own doc comment), gated by `on_mask`
963/// (Pylon's `On` bitmask: 1=Insert, 2=Update, 4=Delete): `__old__` is bound whenever
964/// `on_mask` does *not* include Insert (so Update-only, Delete-only, and
965/// Update+Delete all get it — but never a mask that includes Insert, even
966/// combined with Update, since a shared trigger function has no old row on
967/// the Insert branch of that combination); `__new__` is bound whenever
968/// `on_mask` does *not* include Delete, by the mirror-image argument. A
969/// mask combining Insert and Delete (with no Update) legally binds
970/// neither. Referencing an anchor that isn't bound is a compile error, not
971/// a runtime NULL.
972/// One compiled rewrite: the column it writes and the value it writes there.
973pub struct RewriteAssignment {
974    pub pointer: String,
975    pub column: String,
976    pub ir: IrExpr,
977    pub sql: String,
978    /// True when the expression reads one of the owner's multi-links. Those
979    /// rows live in a junction table the statement writes *after* the row
980    /// itself, so a `BEFORE` trigger reading one sums an empty set — see
981    /// `export::rewrite_trigger_infos`.
982    pub reads_a_multi_link: bool,
983}
984
985/// A type's rewrites for one event (`on_mask`: 1 = insert, 2 = update), each
986/// compiled against the row being written (`NEW`) as `(column, SQL)`, for
987/// the `BEFORE` trigger that applies them. Evaluated on the row as the
988/// statement left it, every rewrite sees the others' inputs, not their
989/// results.
990pub fn compile_rewrite_assignments(
991    type_name: &str,
992    on_mask: u8,
993    schema: &SchemaDescriptor,
994) -> Result<Vec<RewriteAssignment>, crate::error::PyQLError> {
995    let mut c = Compiler::new(schema);
996    c.in_fn_body = true;
997    let owner_td = c.resolve_type(type_name)?;
998    c.special_anchors
999        .insert("__new__".to_string(), (owner_td, "NEW".to_string()));
1000    if on_mask & 1 == 0 {
1001        c.special_anchors
1002            .insert("__old__".to_string(), (owner_td, "OLD".to_string()));
1003    }
1004    let pointers = owner_td
1005        .properties
1006        .iter()
1007        .map(|p| (p.name.clone(), p.name.clone(), &p.rewrites))
1008        .chain(
1009            owner_td
1010                .links
1011                .iter()
1012                .filter(|l| !l.is_junction_backed())
1013                .map(|l| (l.name.clone(), format!("{}_id", l.name), &l.rewrites)),
1014        );
1015    let fanouts = c.subtype_fanouts();
1016    let mut out = Vec::new();
1017    for (name, column, rewrites) in pointers {
1018        for rw in rewrites.iter().filter(|rw| rw.on & on_mask != 0) {
1019            // Carried out by `compile_insert` as an assignment.
1020            if on_mask == 1 && subject_default_insert(&rw.handler, &name).is_some() {
1021                continue;
1022            }
1023            let expr = crate::parse::parse_pointer_expr(&rw.handler)?;
1024            let ir = c.compile_expr(&expr, owner_td, "NEW")?;
1025            if !c.params.is_empty() || !c.hoisted_ctes.is_empty() {
1026                return Err(PyQLError::Type(PyQLTypeError {
1027                    message: format!(
1028                        "the rewrite of '{type_name}.{name}' has nowhere to bind a parameter or a binding"
1029                    ),
1030                    position: Position { line: 0, col: 0 },
1031                }));
1032            }
1033            let sql = crate::sql::emit_expr_with_fanouts(&ir, &fanouts);
1034            // Read off the emitted SQL rather than the expression: a walk
1035            // reaches a junction through computeds, subtypes and `through`
1036            // types, and what it actually joins is the one answer all of
1037            // those agree on.
1038            let junctions = c.junction_tables_of(owner_td)?;
1039            let reads_a_multi_link = junctions.iter().any(|table| sql.contains(table.as_str()));
1040            out.push(RewriteAssignment {
1041                pointer: name.clone(),
1042                column: column.clone(),
1043                ir,
1044                sql,
1045                reads_a_multi_link,
1046            });
1047        }
1048    }
1049    Ok(out)
1050}
1051
1052pub fn compile_trigger_handler(
1053    handler: &str,
1054    type_name: &str,
1055    on_mask: u8,
1056    schema: &SchemaDescriptor,
1057) -> Result<super::IrOutput, crate::error::PyQLError> {
1058    use crate::parse;
1059    use crate::parse::ast::Stmt;
1060
1061    let ast = parse::parse(handler.trim())?;
1062    let mut c = Compiler::new(schema);
1063    // A trigger has no parameters to bind a session global to; it reads them
1064    // from `GLOBALS_ARG`, which its function declares from `pylon.globals`.
1065    c.in_fn_body = true;
1066    // The trigger's own type — `__new__`/`__old__` always resolve against
1067    // this, regardless of what type happens to be the ambient `td` where
1068    // the anchor is textually used (e.g. inside `insert Note { note :=
1069    // __new__.name }`, the ambient td at that point is `Note`, not this).
1070    let owner_td = c.resolve_type(type_name)?;
1071    if on_mask & 4 == 0 {
1072        c.special_anchors
1073            .insert("__new__".to_string(), (owner_td, "NEW".to_string()));
1074    }
1075    if on_mask & 1 == 0 {
1076        c.special_anchors
1077            .insert("__old__".to_string(), (owner_td, "OLD".to_string()));
1078    }
1079
1080    let (ctes, ir) = if let Stmt::With(w) = &ast {
1081        let mut cte_defs = vec![];
1082        for alias in &w.aliases {
1083            // `resolved := (__new__.status = …)` — a value read off the row
1084            // the trigger fired for, which a CTE has no way to see; it
1085            // stands in for its value wherever the name is used.
1086            if !matches!(alias.expr, ast::Expr::SubQuery(_)) {
1087                let anchor = if on_mask & 4 == 0 { "NEW" } else { "OLD" };
1088                let value = c.compile_expr(&alias.expr, owner_td, anchor)?;
1089                c.inline_bindings.insert(alias.name.clone(), value);
1090                continue;
1091            }
1092            let ir_stmt = compile_cte_binding(&mut c, &alias.expr)?;
1093            let sql_name = c.claim_cte_sql_name(&alias.name);
1094            let cte_type_name = cte_stmt_type(&ir_stmt);
1095            c.cte_types.insert(alias.name.clone(), cte_type_name.clone());
1096            c.note_cte_cardinality(&alias.name, &ir_stmt);
1097            cte_defs.push(super::IrCteDef {
1098                name: sql_name,
1099                stmt: ir_stmt,
1100                type_name: cte_type_name,
1101                correlated_to: None,
1102            });
1103        }
1104        let main = c.compile_stmt(&w.stmt)?;
1105        (cte_defs, main)
1106    } else {
1107        (vec![], c.compile_stmt(&ast)?)
1108    };
1109
1110    let recursive_kind = recursive_dml_event(&ir, type_name)
1111        .or_else(|| ctes.iter().find_map(|cte| recursive_dml_event(&cte.stmt, type_name)));
1112    if let Some(kind) = recursive_kind
1113        && kind & on_mask != 0
1114    {
1115        return Err(crate::error::PyQLError::Fragment(crate::error::PyQLFragmentError {
1116            message: format!(
1117                "trigger on {type_name} is recursive: its handler {}s its own type, \
1118                     which this trigger also fires on",
1119                dml_event_word(kind),
1120            ),
1121            context: type_name.to_string(),
1122            position: crate::error::Position { line: 0, col: 0 },
1123        }));
1124    }
1125
1126    let mut ctes = ctes;
1127    ctes.extend(std::mem::take(&mut c.hoisted_ctes));
1128    Ok(super::IrOutput {
1129        subtype_fanouts: c.subtype_fanouts(),
1130        stmt: ir,
1131        params: c.params,
1132        ctes,
1133        global_ctes: c.global_ctes,
1134        warnings: c.warnings,
1135        uses_globals_arg: c.used_globals_arg,
1136    })
1137}
1138
1139fn dml_event_word(kind: u8) -> &'static str {
1140    match kind {
1141        1 => "insert",
1142        2 => "update",
1143        4 => "delete",
1144        _ => "mutate",
1145    }
1146}
1147
1148/// Walks a compiled trigger handler's IR looking for a nested `INSERT`/
1149/// `UPDATE`/`DELETE` targeting `owner_type` — the same type the trigger
1150/// itself is declared on. Returns that DML's own event bit (1/2/4) the
1151/// first time one is found, so the caller can check it against the
1152/// trigger's own `on_mask`: a handler that inserts into its own type only
1153/// matters if this trigger *also* fires on Insert — the mask determines
1154/// what would actually refire, not just "does it touch itself at all".
1155///
1156/// Deliberately not exhaustive: covers the direct statement, a `SELECT
1157/// (INSERT/UPDATE/DELETE …) { … }` wrapper, and `for x in … union (…)`
1158/// loop bodies — the shapes every trigger handler in this codebase's own
1159/// test suite actually uses. A DML nested inside a free tuple/set literal
1160/// (`select { (insert A {...}), (insert B {...}) }`) isn't walked; a
1161/// handler written that way relies on Postgres's own runtime recursion-
1162/// depth guard instead, same fallback as before this check existed.
1163fn recursive_dml_event(stmt: &IrStmt, owner_type: &str) -> Option<u8> {
1164    match stmt {
1165        IrStmt::Insert(ins) if ins.target.type_name == owner_type => Some(1),
1166        IrStmt::Update(upd) if upd.target.type_name == owner_type => Some(2),
1167        IrStmt::Delete(del) if del.target.type_name == owner_type => Some(4),
1168        IrStmt::Select(sel) => sel
1169            .dml_source
1170            .as_deref()
1171            .and_then(|inner| recursive_dml_event(inner, owner_type)),
1172        IrStmt::For(for_stmt) => recursive_dml_event(&for_stmt.body, owner_type),
1173        _ => None,
1174    }
1175}
1176
1177/// Compile a single PyQL expression in the context of a named type.
1178/// Used for schema fragments: computed pointers, rewrite handlers, constraint exprs.
1179pub fn compile_expr_in_type(
1180    expr: &Expr,
1181    type_name: &str,
1182    schema: &SchemaDescriptor,
1183) -> Result<(IrExpr, Vec<String>), PyQLError> {
1184    let mut c = Compiler::new(schema);
1185    let td = c.resolve_type(type_name)?;
1186    let alias = c.fresh_alias();
1187    let ir = c.compile_expr(expr, td, &alias)?;
1188    Ok((ir, c.params))
1189}
1190
1191/// Compile a schema-declared computed pointer the same way a shape that
1192/// included it would, for validation.
1193///
1194/// Returns the scalar expression when the computed is scalar-valued, and
1195/// `None` when it compiles to an object (link) pointer — `(select .emails
1196/// filter .primary limit 1)` and friends, which have no scalar type for a
1197/// declared return type to be checked against.
1198pub fn compile_computed_in_type(
1199    cd: &crate::schema::ComputedDescriptor,
1200    type_name: &str,
1201    schema: &SchemaDescriptor,
1202) -> Result<Option<IrExpr>, PyQLError> {
1203    let mut c = Compiler::new(schema);
1204    let td = c.resolve_type(type_name)?;
1205    let module = td.module.clone();
1206    let alias = c.fresh_alias();
1207    match c.compile_declared_computed(cd, td, &alias, &module, None, &[])? {
1208        IrShapePointer::Computed(p) => Ok(Some(p.expr)),
1209        _ => Ok(None),
1210    }
1211}
1212
1213/// Like `compile_expr_in_type` but uses an empty table alias, so that column
1214/// references emit as bare column names (`"col"` rather than `"a1"."col"`).
1215/// Used for fill expressions in migration UPDATE SET clauses.
1216pub fn compile_expr_unaliased(
1217    expr: &Expr,
1218    type_name: &str,
1219    schema: &SchemaDescriptor,
1220) -> Result<(IrExpr, Vec<String>), PyQLError> {
1221    let mut c = Compiler::new(schema);
1222    let td = c.resolve_type(type_name)?;
1223    let ir = c.compile_expr(expr, td, "")?;
1224    Ok((ir, c.params))
1225}
1226
1227/// Compile a PyQL scalar expression to a SQL string for use as a column DEFAULT.
1228///
1229/// Wraps the expression in `SELECT <expr>`, compiles it as a free scalar, and
1230/// returns the emitted SQL expression (without the SELECT wrapper).
1231pub fn compile_scalar_default(pyql: &str, schema: &SchemaDescriptor) -> Result<String, String> {
1232    compile_scalar_default_typed(pyql, schema).map(|(sql, _ir)| sql)
1233}
1234
1235/// Like `compile_scalar_default`, but also returns the compiled `IrExpr` —
1236/// used by schema-type-consistency validation (`crate::validate`) to infer
1237/// the default's actual produced type via `infer_ir_type`.
1238/// Compile a PyQL boolean expression in a type's context into the SQL a CHECK
1239/// constraint needs.
1240///
1241/// Columns emit unqualified (via `compile_expr_unaliased`), which is what a
1242/// table-level CHECK wants — it has no alias to qualify against.
1243pub fn compile_constraint_expr(
1244    pyql: &str,
1245    type_name: &str,
1246    schema: &SchemaDescriptor,
1247) -> Result<String, crate::error::PyQLError> {
1248    use crate::parse::ast::Stmt;
1249    let ast = crate::parse::parse(&format!("SELECT {pyql}"))?;
1250    let Stmt::Select(sel) = &ast else {
1251        return Err(PyQLError::Type(PyQLTypeError {
1252            message: "constraint expression must be an expression".into(),
1253            position: Position { line: 0, col: 0 },
1254        }));
1255    };
1256    let (ir, _params) = compile_expr_unaliased(&sel.result, type_name, schema)?;
1257    Ok(crate::sql::emit_expr(&ir))
1258}
1259
1260pub fn compile_scalar_default_typed(pyql: &str, schema: &SchemaDescriptor) -> Result<(String, IrExpr), String> {
1261    use crate::parse::ast::Stmt;
1262    let full = format!("SELECT {}", pyql);
1263    let ast = crate::parse::parse(&full).map_err(|e| e.message)?;
1264    let Stmt::Select(sel) = &ast else {
1265        return Err("default expression must be a select statement".into());
1266    };
1267    let mut c = Compiler::new(schema);
1268    let ir = c.compile_free_expr(&sel.result).map_err(|e| e.to_string())?;
1269    let sql = crate::sql::emit_expr(&ir);
1270    Ok((sql, ir))
1271}
1272
1273/// What in `expr` disqualifies it from being a column DEFAULT, if anything.
1274///
1275/// PostgreSQL evaluates a column default with no row and no query in scope,
1276/// so it rejects a sub-select outright ("cannot use subquery in DEFAULT
1277/// expression") and has nothing to resolve another column's name against.
1278pub fn default_blocker(expr: &IrExpr) -> Option<&'static str> {
1279    use IrExpr as E;
1280    match expr {
1281        E::Subquery(_)
1282        | E::ObjectSubquery(_)
1283        | E::ObjectPathSubquery(_)
1284        | E::ObjectPathUnion { .. }
1285        | E::PathSubquery(_)
1286        | E::FnSubquery(_)
1287        | E::ArrayFromSelect(_)
1288        | E::ScalarSubquery(_)
1289        | E::AggOverQuery { .. }
1290        | E::AggOverCte { .. }
1291        | E::ExistsOverCte { .. }
1292        | E::SetOp { .. }
1293        | E::AggOverSet { .. }
1294        | E::CteRef { .. }
1295        | E::CteFieldRef { .. }
1296        | E::GlobalRef { .. } => Some("a sub-select"),
1297        E::ColumnRef { .. } => Some("a reference to another pointer"),
1298        E::Param { .. } | E::GlobalParam { .. } => Some("a query parameter"),
1299        E::ForVar { .. } => Some("a for-loop variable"),
1300        E::FnParam { .. } => Some("a function parameter"),
1301        E::BinOp(b) => default_blocker(&b.left).or_else(|| default_blocker(&b.right)),
1302        E::UnaryOp(u) => default_blocker(&u.operand),
1303        E::TypeCast(c) => default_blocker(&c.expr),
1304        E::IfElse(i) => default_blocker(&i.condition)
1305            .or_else(|| default_blocker(&i.if_))
1306            .or_else(|| default_blocker(&i.else_)),
1307        E::FunctionCall(f) => f.args.iter().find_map(default_blocker),
1308        E::Array(items) | E::Tuple(items) => items.iter().find_map(default_blocker),
1309        E::NamedTuple { fields, .. } => fields.iter().find_map(|(_, e)| default_blocker(e)),
1310        E::Subscript { expr, index, .. } => default_blocker(expr).or_else(|| default_blocker(index)),
1311        E::JsonbField { expr, .. } | E::JsonbIndex { expr, .. } => default_blocker(expr),
1312        E::Slice { expr, lower, upper, .. } => default_blocker(expr)
1313            .or_else(|| lower.as_deref().and_then(default_blocker))
1314            .or_else(|| upper.as_deref().and_then(default_blocker)),
1315        E::Literal(_) | E::Null | E::EnumLiteral { .. } | E::RawSql(_) => None,
1316    }
1317}
1318
1319/// The SQL for a pointer's PyQL default as a column DEFAULT clause, or `None`
1320/// when a column DEFAULT cannot hold it — it reads a session global, selects
1321/// an object, or otherwise needs the query around it. Those are applied by
1322/// `compile_insert` instead; see `inlined_pointer_defaults`.
1323pub fn column_default_sql(pyql: &str, schema: &SchemaDescriptor) -> Option<String> {
1324    let (sql, ir) = compile_scalar_default_typed(pyql, schema).ok()?;
1325    default_blocker(&ir).is_none().then_some(sql)
1326}
1327
1328/// The pointers whose PyQL default an insert has to expand into its own shape,
1329/// paired with that default, because `column_default_sql` cannot render it.
1330///
1331/// Expanding a default means adding one shape element per unspecified pointer,
1332/// which is what makes an object-returning default like
1333/// `created_by := account_of_transaction()` work at all. Here the defaults a
1334/// column DEFAULT does hold keep using it, so only the remainder needs the
1335/// query.
1336pub fn inlined_pointer_defaults(td: &TypeDescriptor, schema: &SchemaDescriptor) -> Vec<(String, String)> {
1337    let properties = td
1338        .properties
1339        .iter()
1340        .filter(|p| !p.is_pk)
1341        .filter_map(|p| p.default_pyql.as_ref().map(|d| (p.name.clone(), d.clone())));
1342    let links = td
1343        .links
1344        .iter()
1345        .filter(|l| !l.is_junction_backed())
1346        .filter_map(|l| l.default_pyql.as_ref().map(|d| (l.name.clone(), d.clone())));
1347    properties
1348        .chain(links)
1349        .filter(|(_, pyql)| column_default_sql(pyql, schema).is_none())
1350        .collect()
1351}
1352
1353/// One inlined default as the insert shape carries it: `pointer := <default>`.
1354fn default_shape_element(pointer: &str, value: Expr) -> ShapeElement {
1355    ShapeElement {
1356        path: ast::Path::relative(pointer),
1357        splat: None,
1358        nested: None,
1359        compexpr: Some(value),
1360        op: ShapeOp::Assign,
1361        filter: None,
1362        order_by: vec![],
1363        offset: None,
1364        limit: None,
1365        marker_offset: None,
1366    }
1367}
1368
1369/// One inlined default, compiled the way `compile_insert` expands it into an
1370/// insert's shape — so a default that cannot compile there is caught by schema
1371/// validation rather than by the first insert that omits the pointer.
1372///
1373/// One pointer at a time, because the caller reporting the failure has to be
1374/// able to name which declaration caused it.
1375pub fn compile_inlined_default(
1376    type_name: &str,
1377    pointer: &str,
1378    pyql: &str,
1379    schema: &SchemaDescriptor,
1380) -> Result<(String, IrExpr), PyQLError> {
1381    let mut c = Compiler::new(schema);
1382    let td = c.resolve_type(type_name)?;
1383    let alias = c.fresh_alias();
1384    let element = default_shape_element(pointer, crate::parse::parse_pointer_expr(pyql)?);
1385    let mut assignments = c.compile_assignments(&[element], td, &alias)?;
1386    Ok(assignments.remove(0))
1387}
1388
1389// ── Compiler context ────────────────────────────────────────────────────────────
1390
1391struct Compiler<'a> {
1392    schema: &'a SchemaDescriptor,
1393    /// Ordered parameter names — index + 1 is the $N position in SQL.
1394    params: Vec<String>,
1395    alias_counter: usize,
1396    /// CTE names registered in the enclosing WITH block → qualified type name.
1397    cte_types: HashMap<String, String>,
1398    /// `with g := (group …)` bindings. A group's rows are no schema object, so
1399    /// nothing can read them as a CTE; a `for` over one compiles the group
1400    /// again in its own terms instead.
1401    group_bindings: HashMap<String, IrGroup>,
1402    /// CTE names bound to a single free row (free object/scalar/tuple, not
1403    /// a schema object) → that row's `IrFreeExpr` — lets `root.field`
1404    /// resolve to `IrExpr::CteFieldRef` when `root` is such a binding,
1405    /// instead of failing as an unresolvable schema-path root.
1406    cte_free_items: HashMap<String, IrFreeExpr>,
1407    /// CTE names whose binding can hold more than one row. Comparing a value
1408    /// against one has to become set membership: `IrExpr::CteRef` reads the
1409    /// binding as a scalar subquery, which Postgres aborts on the second row
1410    /// ("more than one row returned by a subquery used as an expression").
1411    multi_row_ctes: HashSet<String>,
1412    /// FOR loop variables in scope: variable name → pg_type of the scalar iterator.
1413    for_vars: HashMap<String, String>,
1414    /// For-loop variables that iterate objects, by qualified type name. The
1415    /// variable itself holds the row's key (see `compile_for`), so a path
1416    /// rooted at one reads its table back by that key.
1417    for_var_types: HashMap<String, String>,
1418    /// For a loop over a `with` binding, the binding each variable's rows come
1419    /// from. The rows a sibling CTE has just written are not in the base table
1420    /// yet -- Postgres runs every CTE against the snapshot the statement
1421    /// started with -- so a walk off the variable has to read the binding.
1422    for_var_ctes: HashMap<String, String>,
1423    /// The name each loop variable's iterator CTE is emitted under. Two
1424    /// sibling loops may use the same variable name, and a WITH clause cannot
1425    /// hold the same CTE name twice.
1426    for_var_slots: HashMap<String, String>,
1427    /// The condition a `(insert …) if cond else {}` puts on the insert about
1428    /// to be compiled — see `IrInsert::guard`.
1429    pending_insert_guard: Option<Expr>,
1430    /// The condition a guarded `update` has to carry itself, for the same
1431    /// reason an insert does: a data-modifying CTE runs whether or not
1432    /// anything reads it, so filtering what is read back changes nothing.
1433    pending_update_guard: Option<Expr>,
1434    /// See `pending_update_guard` — the same, for a guarded delete.
1435    pending_delete_guard: Option<Expr>,
1436    /// Pointers a `with` binding declared in its own shape (`offering := (
1437    /// select Offering { publisher := … })`). They exist nowhere on the type,
1438    /// so a later `offering { publisher }` has to find them here.
1439    cte_declared_pointers: HashMap<String, Vec<ShapeElement>>,
1440    /// Those of the binding whose shape is being compiled right now.
1441    active_declared_pointers: Vec<ShapeElement>,
1442    /// Computed pointers a path is reading as a value right now, as
1443    /// `module::Type.name` — one that names itself would otherwise recurse
1444    /// without end.
1445    expanding_computeds: Vec<String>,
1446    /// The schema-bound selects currently being compiled, innermost last.
1447    ///
1448    /// Only consulted for `detached`: an absolute `TypeName.prop` inside a
1449    /// detached select means the *enclosing* select's row, so the innermost
1450    /// entry is skipped and the next matching one used.
1451    anchors: Vec<SelectAnchor>,
1452    /// CTE definitions a nested `with` contributed from somewhere the
1453    /// emitter has no WITH clause of its own — an expression, say. They are
1454    /// appended to the statement's own CTEs at the top-level boundary.
1455    hoisted_ctes: Vec<IrCteDef>,
1456    /// What each hoisted binding was defined as, so the same one arriving
1457    /// twice (one computed inlined from two places) is recognised as the same
1458    /// rather than emitted twice under one CTE name.
1459    hoisted_binding_sources: HashMap<String, Expr>,
1460    /// The WITH name a binding is actually emitted under. Two `for` bodies may
1461    /// each declare `line`; one WITH clause cannot hold that name twice, so the
1462    /// second is emitted under a suffixed one and every reference to it reads
1463    /// from here.
1464    cte_sql_names: HashMap<String, String>,
1465    /// Every WITH name this statement will carry, whoever asked for it — a
1466    /// user binding, a loop's iterator, a hoisted mutation. One set so no two
1467    /// can claim the same name and no guard has to know about the others.
1468    cte_namespace: std::collections::HashSet<String>,
1469    /// Loop slots whose variable has been read since the last time this was
1470    /// cleared — see `for_var_ref`.
1471    for_vars_read: std::collections::HashSet<String>,
1472    /// SQL CTE name → the loop slot its rows belong to one iteration of, for
1473    /// every per-iteration binding registered so far.
1474    correlated_bindings: HashMap<String, String>,
1475    /// Slots of the per-iteration bindings read since this was last cleared.
1476    /// Interior mutability because a read is noticed from `&self` name
1477    /// resolution — see `cte_sql_name`.
1478    correlated_reads: std::cell::RefCell<std::collections::HashSet<String>>,
1479    /// The loops whose bodies are being compiled, innermost last.
1480    for_scope: Vec<String>,
1481    /// `(through type, junction alias)` of the multi-link whose own
1482    /// modifiers are being compiled — what a bare `@prop` in `filter
1483    /// (@primary = true)` resolves against. `None` on the stack means a link
1484    /// with no through type, which has no link properties at all.
1485    link_prop_scope: Vec<Option<(String, String)>>,
1486    /// Set when `compile_stmt` strips a select-level `detached`, and taken by
1487    /// the `compile_path_modifiers` that compiles that select's own clauses.
1488    pending_detached: bool,
1489    /// Set while a path continues past a sub-select's own subject, e.g. the
1490    /// `.plan.tier` of `(select .licences filter not exists .ended_at limit
1491    /// 1).plan.tier`: the subject's landing row is what FILTER/ORDER BY/LIMIT
1492    /// scope to, not the type the whole path ends on.
1493    modifier_anchor: Option<(String, String)>,
1494    /// The `order by` the *outer* select wrote around such a path, which
1495    /// speaks about what the path projects rather than about the anchored
1496    /// subject — `(select Cart filter .id = $c).applied_promotions order by
1497    /// .code` orders applications, having filtered carts.
1498    tail_sorts: Vec<ast::SortExpr>,
1499    /// WITH bindings that name a path off the enclosing object. They read that
1500    /// object's alias, which a CTE emitted ahead of the FROM clause cannot
1501    /// see, so they stand in for their value wherever the name is used.
1502    inline_bindings: std::collections::HashMap<String, IrExpr>,
1503    /// True while compiling a `@pylon.function` body. A session global cannot
1504    /// be a query parameter there — a `CREATE FUNCTION` body has nothing to
1505    /// bind one to — so it is read out of the `__pylon_json_globals__`
1506    /// argument instead. See `GLOBALS_ARG`.
1507    in_fn_body: bool,
1508    /// Qualified names of functions that take the globals argument, so a call
1509    /// to one can be given it.
1510    ///
1511    /// Computed on first use rather than up front: working it out means
1512    /// compiling every function body in the schema, and the overwhelming
1513    /// majority of queries never call a user function at all. Function-body
1514    /// compilation seeds it explicitly (`compile_fn_body_with`), which is also
1515    /// what keeps the fixpoint from recursing into itself.
1516    fns_needing_globals: Option<std::collections::HashSet<String>>,
1517    /// Set when this body read a global or forwarded the argument to a callee
1518    /// — i.e. when the function being compiled needs the argument itself.
1519    used_globals_arg: bool,
1520    /// User-defined function parameters in scope (only set during body compilation).
1521    fn_params: HashMap<String, String>,
1522    /// `__new__`/`__old__` row-context bindings, only set during trigger-handler
1523    /// compilation (`compile_trigger_handler`) — maps the anchor name to
1524    /// *the trigger's own type* and the table alias its properties/links
1525    /// should resolve against (`"NEW"`/`"OLD"`). Stored explicitly (not just
1526    /// the alias string) because `__new__.x`/`__old__.x` can appear nested
1527    /// inside a sub-statement targeting a *different* type (e.g. `insert Note
1528    /// { note := __new__.name }` — the ambient `td` at that point is `Note`,
1529    /// not the trigger's own type), so resolution can't rely on whatever
1530    /// `td` happens to be in scope where the anchor is used. An anchor not
1531    /// legal for the trigger's `on` mask (e.g. `__old__` in an Insert-only
1532    /// trigger) is simply absent from this map, and fails resolution the
1533    /// same way any other unknown identifier does.
1534    special_anchors: HashMap<String, (&'a TypeDescriptor, String)>,
1535    /// Global CTEs collected during compilation (session and computed), in dependency order.
1536    global_ctes: Vec<IrGlobalCte>,
1537    /// Nested DML hoisted out of a link-assignment value currently being
1538    /// compiled (`author := (select (insert Person {...}) { id })`) — see
1539    /// `compile_link_subquery`'s doc comment. `compile_insert`/
1540    /// `compile_update` save-and-clear this on entry and drain it back into
1541    /// their own `IrInsert`/`IrUpdate::nested_ctes` on exit, so nesting
1542    /// (a nested insert whose own link value nests another insert) attaches
1543    /// each level's discoveries to the right statement.
1544    pending_nested_ctes: Vec<IrCteDef>,
1545    /// Junction tables this statement also *writes*, mapped to the CTE that
1546    /// wrote them — installed only while a walk off a hoisted mutation is
1547    /// being compiled. Postgres does not show a sibling CTE's inserts in the
1548    /// base table, so `(update T set { multi += … }).multi` has to read the
1549    /// rows back out of the append CTE or it sees none of them.
1550    junction_read_overrides: HashMap<String, JunctionReadOverride>,
1551    nested_cte_counter: usize,
1552    /// Non-fatal warnings collected during compilation.
1553    warnings: Vec<String>,
1554    /// Depth of `any()`/`all()` arguments currently being compiled. Wrapping a
1555    /// set-valued comparison in one of those *is* the explicit intent the
1556    /// multi-link FILTER warning asks for, so the warning stays quiet inside.
1557    explicit_set_depth: usize,
1558    /// Of those, the `all()` ones. `any()` is what an EXISTS already says, so
1559    /// a set-valued comparison may fold into one there; `all()` needs the
1560    /// answers one at a time to reduce them, since an EXISTS over a negated
1561    /// test is not the same question. See `try_elementwise_in`.
1562    universal_set_depth: usize,
1563    /// User-configurable session options — see `SessionConfig`. Always
1564    /// `default()` for every entry point except `compile_with_config`.
1565    config: crate::ir::SessionConfig,
1566    /// Whether a shape compiled right now gets an `id` it did not ask for —
1567    /// see `prepend_implicit_id`. True at the top level and cleared for the
1568    /// two places that cannot carry it: a mutation's own body, where a link
1569    /// value compiles to a one-column correlated subquery that a second column
1570    /// would break, and under a cast to `json`, which is an output sink whose
1571    /// text an added key would change.
1572    implicit_id_in_shapes: bool,
1573    /// Whether the expression being compiled stands where a *value* is
1574    /// expected — a computed pointer's right-hand side, say — rather than
1575    /// where a condition is. `in` asks its question once per element of its
1576    /// left operand, so over a set it yields a set of booleans; only a
1577    /// condition collapses that set to the single "any of them" answer an
1578    /// EXISTS gives. Set by `in_value_position`, cleared by `as_condition`.
1579    value_position: bool,
1580}
1581
1582/// One schema-bound select in the enclosing chain — see `Compiler::anchors`.
1583struct SelectAnchor {
1584    type_name: String,
1585    qualified: String,
1586    alias: String,
1587    detached: bool,
1588    /// The ancestor an inherited computed is declared on, set only for the
1589    /// anchor that computed is compiled under.
1590    ///
1591    /// A computed is compiled once, against the type that declares it, so
1592    /// `BrandAddon.bundle := (BrandAddon is BrandAddonBundle)` reads its own
1593    /// row there and keeps doing so through every subtype that inherits it.
1594    /// Pylon materialises an inherited computed onto each subtype and compiles
1595    /// it again there, where `BrandAddon` no longer names the subject -- and a
1596    /// bare type name that anchors nothing reads the whole table. Naming a
1597    /// supertype does *not* otherwise mean the subject
1598    /// (`select BrandAddonBundle { n := count(BrandAddon) }` is the full count,
1599    /// while the same shape naming `BrandAddonBundle` is 1), so this is scoped
1600    /// to the one anchor rather than folded into the general name match.
1601    declared_on: Option<(String, String)>,
1602}
1603
1604impl SelectAnchor {
1605    /// Whether this anchor answers to `root` as a path root.
1606    fn answers_to(&self, root: &str) -> bool {
1607        self.type_name == root
1608            || self.qualified == root
1609            || self
1610                .declared_on
1611                .as_ref()
1612                .is_some_and(|(name, qualified)| name == root || qualified == root)
1613    }
1614}
1615
1616/// The synthetic argument carrying session globals into a function body.
1617///
1618/// A session global is normally a query parameter, which a `CREATE FUNCTION`
1619/// body cannot have — it would emit a bare `$1` nothing binds. Instead the
1620/// caller packs the globals into one jsonb value and passes it as a leading
1621/// argument. One opaque argument rather than one
1622/// per global keeps the function's signature independent of which globals its
1623/// body happens to mention, so editing a body does not churn its signature
1624/// (and, with PostgreSQL overloading, leave a stale one behind).
1625pub const GLOBALS_ARG: &str = "__pylon_json_globals__";
1626
1627/// SQL reading one global out of `GLOBALS_ARG`.
1628fn globals_arg_read(qualified: &str, pg_type: &str) -> String {
1629    let key = qualified.replace('\'', "''");
1630    if let Some(element) = pg_type.strip_suffix("[]") {
1631        // `->>` would hand back the JSON array's text, not an array, so the
1632        // elements are unpacked instead. The `jsonb_typeof` guard matters: an
1633        // unset global arrives as JSON null, and unpacking that raises "cannot
1634        // extract elements from a scalar". The `coalesce` keeps an empty array
1635        // an empty array rather than letting `array_agg` turn it into NULL.
1636        format!(
1637            "(case when jsonb_typeof({GLOBALS_ARG} -> '{key}') = 'array' \
1638             then coalesce((select array_agg(value::{element}) \
1639             from jsonb_array_elements_text({GLOBALS_ARG} -> '{key}') as value), '{{}}'::{pg_type}) \
1640             else null end)"
1641        )
1642    } else {
1643        format!("(({GLOBALS_ARG} ->> '{key}')::{pg_type})")
1644    }
1645}
1646
1647impl<'a> Compiler<'a> {
1648    fn new(schema: &'a SchemaDescriptor) -> Self {
1649        Self::with_config(schema, crate::ir::SessionConfig::default())
1650    }
1651
1652    fn with_config(schema: &'a SchemaDescriptor, config: crate::ir::SessionConfig) -> Self {
1653        Compiler {
1654            schema,
1655            params: vec![],
1656            alias_counter: 0,
1657            cte_types: HashMap::new(),
1658            group_bindings: HashMap::new(),
1659            cte_free_items: HashMap::new(),
1660            multi_row_ctes: HashSet::new(),
1661            for_vars: HashMap::new(),
1662            for_var_types: HashMap::new(),
1663            for_var_ctes: HashMap::new(),
1664            for_var_slots: HashMap::new(),
1665            pending_insert_guard: None,
1666            pending_update_guard: None,
1667            pending_delete_guard: None,
1668            cte_declared_pointers: HashMap::new(),
1669            active_declared_pointers: vec![],
1670            expanding_computeds: vec![],
1671            fn_params: HashMap::new(),
1672            special_anchors: HashMap::new(),
1673            global_ctes: vec![],
1674            pending_nested_ctes: vec![],
1675            junction_read_overrides: HashMap::new(),
1676            nested_cte_counter: 0,
1677            warnings: vec![],
1678            explicit_set_depth: 0,
1679            universal_set_depth: 0,
1680            config,
1681            implicit_id_in_shapes: true,
1682            value_position: false,
1683            anchors: Vec::new(),
1684            link_prop_scope: Vec::new(),
1685            hoisted_ctes: Vec::new(),
1686            hoisted_binding_sources: HashMap::new(),
1687            cte_sql_names: HashMap::new(),
1688            cte_namespace: std::collections::HashSet::new(),
1689            for_vars_read: std::collections::HashSet::new(),
1690            correlated_bindings: HashMap::new(),
1691            correlated_reads: std::cell::RefCell::new(std::collections::HashSet::new()),
1692            for_scope: Vec::new(),
1693            pending_detached: false,
1694            modifier_anchor: None,
1695            tail_sorts: vec![],
1696            inline_bindings: std::collections::HashMap::new(),
1697            in_fn_body: false,
1698            fns_needing_globals: None,
1699            used_globals_arg: false,
1700        }
1701    }
1702
1703    /// Register a compiled WITH binding under `name`: records its type (or
1704    /// empty string for a free binding) in `cte_types`, and — when it's a
1705    /// single free row — its `IrFreeExpr` in `cte_free_items` so a later
1706    /// `name.field` reference can resolve to `IrExpr::CteFieldRef`.
1707    /// True when `name` is bound to a value rather than to an object set —
1708    /// a scalar WITH binding, or one inlined by `bind_inline_if_correlated`.
1709    fn is_value_binding(&self, name: &str) -> bool {
1710        self.inline_bindings.contains_key(name) || self.cte_types.get(name).is_some_and(|t| !t.contains("::"))
1711    }
1712
1713    /// Bind `name` to the value of a relative path off the enclosing object —
1714    /// `with handle_id := .id`. Returns false when the binding is anything
1715    /// else, which the caller hoists into a CTE as usual.
1716    /// `ctx` is the row the enclosing sub-select is correlated to, when it
1717    /// has one: a relative path in its bindings reads that row, not whichever
1718    /// select happens to enclose it.
1719    fn bind_inline_if_correlated(
1720        &mut self,
1721        name: &str,
1722        expr: &Expr,
1723        ctx: Option<(&TypeDescriptor, &str)>,
1724    ) -> Result<bool, PyQLError> {
1725        if (self.anchors.is_empty() && ctx.is_none()) || !matches!(expr, Expr::Path(p) if p.partial) {
1726            return Ok(false);
1727        }
1728        let ir = self.compile_expr_ctx(expr, ctx)?;
1729        self.inline_bindings.insert(name.to_string(), ir);
1730        Ok(true)
1731    }
1732
1733    /// The WITH name `name` is emitted under — itself, unless it collided.
1734    fn cte_sql_name(&self, name: &str) -> String {
1735        let sql_name = self
1736            .cte_sql_names
1737            .get(name)
1738            .cloned()
1739            .unwrap_or_else(|| name.to_string());
1740        if let Some(slot) = self.correlated_bindings.get(&sql_name) {
1741            self.correlated_reads.borrow_mut().insert(slot.clone());
1742        }
1743        sql_name
1744    }
1745
1746    /// The `@cte:` source sentinel for `cte`, noting the read so that a
1747    /// binding compiled around it inherits the iteration `cte` belongs to.
1748    fn cte_source_table(&self, cte: &str) -> String {
1749        if let Some(slot) = self.correlated_bindings.get(cte) {
1750            self.correlated_reads.borrow_mut().insert(slot.clone());
1751        }
1752        format!("@cte:{cte}")
1753    }
1754
1755    /// Compile a `with` binding, reporting the loop slot it reads if it reads
1756    /// one. A binding that names the loop variable holds a different value
1757    /// each iteration, which is not something a statement-level CTE can be.
1758    fn compile_binding_in_scope(&mut self, expr: &Expr) -> Result<(IrStmt, Option<String>), PyQLError> {
1759        let before = std::mem::take(&mut self.for_vars_read);
1760        let before_correlated = std::mem::take(&mut *self.correlated_reads.borrow_mut());
1761        let ir_stmt = compile_cte_binding(self, expr);
1762        let mut read = std::mem::replace(&mut self.for_vars_read, before);
1763        let correlated_read = std::mem::replace(&mut *self.correlated_reads.borrow_mut(), before_correlated);
1764        // Reading a per-iteration binding makes this one per-iteration too,
1765        // even where it never names the loop variable itself: its rows are
1766        // picked by the iteration key the one it reads carries.
1767        read.extend(correlated_read.iter().cloned());
1768        // The innermost loop read is the one it belongs to; an outer loop's
1769        // variable is reachable from there anyway.
1770        let correlated_to = self
1771            .for_scope
1772            .iter()
1773            .rev()
1774            .find(|slot| read.contains(*slot))
1775            .map(|slot| format!("_for_{slot}"));
1776        self.for_vars_read.extend(read);
1777        self.correlated_reads.borrow_mut().extend(correlated_read);
1778        Ok((ir_stmt?, correlated_to))
1779    }
1780
1781    /// Claim a WITH name, suffixing `base` until it is free. Names the SQL
1782    /// emitter derives from a claimed one — `x__ids`, `x__rows`,
1783    /// `x__ml_add_0` — are claimed with it, which is why anything under a
1784    /// taken name's `__` prefix counts as taken too.
1785    fn claim_in_cte_namespace(&mut self, base: &str) -> String {
1786        // A suffixed candidate is itself an extension of `base`, so `base` is
1787        // the one claimed name that never rules its own suffixes out.
1788        let taken = |namespace: &std::collections::HashSet<String>, candidate: &String| {
1789            namespace.contains(candidate)
1790                || namespace
1791                    .iter()
1792                    .any(|claimed| claimed != base && candidate.starts_with(&format!("{claimed}__")))
1793        };
1794        let mut candidate = base.to_string();
1795        let mut suffix = 1;
1796        while taken(&self.cte_namespace, &candidate) {
1797            candidate = format!("{base}__{suffix}");
1798            suffix += 1;
1799        }
1800        self.cte_namespace.insert(candidate.clone());
1801        candidate
1802    }
1803
1804    /// Claim a WITH name the compiler spelled itself — a loop's iterator, a
1805    /// hoisted mutation. These already live in the underscore-prefixed space
1806    /// that user names are kept out of, so they are claimed as written.
1807    fn claim_generated_cte_name(&mut self, preferred: &str) -> String {
1808        self.claim_in_cte_namespace(preferred)
1809    }
1810
1811    /// Claim a WITH name for a `with` binding, recording the name to read it
1812    /// back by when the one it asked for was already spoken for.
1813    fn claim_cte_sql_name(&mut self, name: &str) -> String {
1814        // Every generated WITH name starts with an underscore. Keeping user
1815        // names out of that space is what makes the two sets disjoint, so a
1816        // binding named `_dml` cannot collide with the wrapper of that name.
1817        let base = match name.starts_with('_') {
1818            true => format!("w{name}"),
1819            false => name.to_string(),
1820        };
1821        let claimed = self.claim_in_cte_namespace(&base);
1822        if claimed == name {
1823            self.cte_sql_names.remove(name);
1824        } else {
1825            self.cte_sql_names.insert(name.to_string(), claimed.clone());
1826        }
1827        claimed
1828    }
1829
1830    fn register_cte(&mut self, name: &str, ir_stmt: &IrStmt) -> String {
1831        let type_name = cte_stmt_type(ir_stmt);
1832        self.cte_types.insert(name.to_string(), type_name.clone());
1833        if let IrStmt::Select(sel) = ir_stmt
1834            && let [IrRowSource::Free(item)] = sel.rows.as_slice()
1835        {
1836            self.cte_free_items.insert(name.to_string(), item.clone());
1837        }
1838        self.note_cte_cardinality(name, ir_stmt);
1839        type_name
1840    }
1841
1842    /// True when `sel`'s filter pins an exclusive property to one value, which
1843    /// makes it single-valued however many rows the table holds. It is why
1844    /// `(select Installation filter .id = <uuid>$x).connector.provider.staff`
1845    /// is a value rather than a one-element set.
1846    fn pins_an_exclusive_property(&self, sel: &IrSelect) -> bool {
1847        let [IrRowSource::Bound { source, .. }] = sel.rows.as_slice() else {
1848            return false;
1849        };
1850        let Some(filter) = &sel.filter else { return false };
1851        let Ok(td) = self.resolve_type(&source.type_name) else {
1852            return false;
1853        };
1854        Self::pins_exclusive(td, filter)
1855    }
1856
1857    /// Walks the conjuncts of `filter` for an equality against an exclusive or
1858    /// primary-key column. Only `and` is descended: under `or` either side
1859    /// could match a different row, so neither pins anything.
1860    fn pins_exclusive(td: &TypeDescriptor, filter: &IrExpr) -> bool {
1861        let IrExpr::BinOp(binop) = filter else { return false };
1862        match binop.op {
1863            ast::BinOpKind::And => Self::pins_exclusive(td, &binop.left) || Self::pins_exclusive(td, &binop.right),
1864            ast::BinOpKind::Eq => {
1865                let column = match (&binop.left, &binop.right) {
1866                    (IrExpr::ColumnRef { column, .. }, _) => column,
1867                    (_, IrExpr::ColumnRef { column, .. }) => column,
1868                    _ => return false,
1869                };
1870                td.properties
1871                    .iter()
1872                    .any(|p| &p.name == column && (p.is_exclusive || p.is_pk))
1873            }
1874            _ => false,
1875        }
1876    }
1877
1878    /// Record `name` when its binding can hold more than one row, so a later
1879    /// comparison against it compiles to membership rather than a scalar
1880    /// subquery read. Anything whose row count isn't statically one counts as
1881    /// multi: a needless membership test still answers correctly, a needless
1882    /// scalar read aborts the query.
1883    fn note_cte_cardinality(&mut self, name: &str, ir_stmt: &IrStmt) {
1884        let single = match ir_stmt {
1885            IrStmt::Insert(_) => true,
1886            IrStmt::Select(sel) => {
1887                matches!(sel.limit, Some(IrExpr::Literal(IrLiteral::Int(1))))
1888                    || matches!(
1889                        sel.rows.as_slice(),
1890                        [IrRowSource::Free(
1891                            IrFreeExpr::Scalar(_)
1892                                | IrFreeExpr::FreeObject(_)
1893                                | IrFreeExpr::NamedTupleRow(_)
1894                                | IrFreeExpr::Tuple(_)
1895                        )]
1896                    )
1897                    || self.pins_an_exclusive_property(sel)
1898            }
1899            _ => false,
1900        };
1901        if !single {
1902            self.multi_row_ctes.insert(name.to_string());
1903        }
1904    }
1905
1906    /// Resolve `root.field1.field2. ... fieldN` where `root` is a WITH-bound
1907    /// free object (e.g. `with x := { a := { b := 1 } } select x.a.b`) —
1908    /// `None` when `root` isn't such a binding, so callers fall back to
1909    /// their normal path resolution. The first step reads the CTE's own
1910    /// per-field column (`IrExpr::CteFieldRef`, materialized once); any
1911    /// further steps index into that value as jsonb (`IrExpr::JsonbField`),
1912    /// since a nested free-object *field* is jsonb the moment it's not the
1913    /// top-level row itself — validated statically wherever the nesting is
1914    /// itself a free-object literal (so a typo like `x.a.typo` still gets a
1915    /// compile error instead of silently returning SQL NULL).
1916    fn resolve_cte_field_chain(&self, root: &str, steps: &[&str]) -> Option<Result<IrExpr, PyQLError>> {
1917        let (first, rest) = steps.split_first()?;
1918        let fields = match self.cte_free_items.get(root)? {
1919            IrFreeExpr::FreeObject(fields) => fields,
1920            _ => return None,
1921        };
1922        let mut current: &IrExpr = match fields.iter().find(|(n, _)| n == first) {
1923            Some((_, e)) => e,
1924            None => {
1925                return Some(Err(
1926                    self.type_err(&format!("free object '{root}' has no field '{first}'"))
1927                ));
1928            }
1929        };
1930        let mut expr = IrExpr::CteFieldRef {
1931            name: root.to_string(),
1932            field: first.to_string(),
1933            pg_type: infer_ir_type(current).map(str::to_string),
1934        };
1935        for step in rest {
1936            if let IrExpr::NamedTuple {
1937                fields: nested,
1938                is_free_object: true,
1939            } = current
1940            {
1941                match nested.iter().find(|(n, _)| n == step) {
1942                    Some((_, next)) => current = next,
1943                    None => {
1944                        return Some(Err(
1945                            self.type_err(&format!("{step} is not a member of the nested free object"))
1946                        ));
1947                    }
1948                }
1949            }
1950            expr = IrExpr::JsonbField {
1951                expr: Box::new(expr),
1952                field: step.to_string(),
1953            };
1954        }
1955        Some(Ok(expr))
1956    }
1957
1958    /// `resolve_cte_field_chain`, but taking the whole `root.f1.f2...` path
1959    /// directly — `None` when the path isn't an absolute multi-step name
1960    /// chain (so, in particular, whenever a step is anything other than a
1961    /// plain name, e.g. a type intersection or backlink).
1962    fn resolve_cte_path(&self, p: &ast::Path) -> Option<Result<IrExpr, PyQLError>> {
1963        if p.partial || p.steps.len() < 2 {
1964            return None;
1965        }
1966        let ast::PathStep::Name(root) = &p.steps[0] else {
1967            return None;
1968        };
1969        let mut steps = Vec::with_capacity(p.steps.len() - 1);
1970        for step in &p.steps[1..] {
1971            match step {
1972                ast::PathStep::Name(n) => steps.push(n.as_str()),
1973                _ => return None,
1974            }
1975        }
1976        self.resolve_cte_field_chain(root, &steps)
1977    }
1978
1979    /// Return the CTE name if `expr` is a bare identifier that matches a registered CTE.
1980    fn resolve_cte_name<'e>(&self, expr: &'e Expr) -> Option<&'e str> {
1981        if let Expr::Path(p) = expr
1982            && !p.partial
1983            && p.steps.len() == 1
1984            && let ast::PathStep::Name(n) = &p.steps[0]
1985            && self.cte_types.contains_key(n.as_str())
1986        {
1987            return Some(n.as_str());
1988        }
1989        None
1990    }
1991
1992    /// A reference to the loop variable `name`, under the CTE name its loop
1993    /// was emitted with. Recorded as read, which is what tells a `with`
1994    /// binding compiled around it that it belongs to one iteration rather
1995    /// than to the statement.
1996    fn for_var_ref(&mut self, name: &str) -> IrExpr {
1997        let slot = self
1998            .for_var_slots
1999            .get(name)
2000            .cloned()
2001            .unwrap_or_else(|| name.to_string());
2002        self.for_vars_read.insert(slot.clone());
2003        let pg_type = self.for_vars.get(name).cloned();
2004        IrExpr::ForVar { name: slot, pg_type }
2005    }
2006
2007    fn fresh_alias(&mut self) -> String {
2008        let a = format!("t{}", self.alias_counter);
2009        self.alias_counter += 1;
2010        a
2011    }
2012
2013    /// A distinct naming scheme from `fresh_alias`'s `t0`, `t1`, ... (table
2014    /// aliases) so a hoisted nested-DML CTE name can never collide with one
2015    /// — see `pending_nested_ctes`.
2016    fn fresh_nested_cte_name(&mut self) -> String {
2017        let n = self.nested_cte_counter;
2018        self.nested_cte_counter += 1;
2019        self.claim_generated_cte_name(&format!("_nested_dml_{n}"))
2020    }
2021
2022    /// Register a named parameter and return its 0-based index.
2023    fn param_index(&mut self, name: &str) -> usize {
2024        if let Some(i) = self.params.iter().position(|n| n == name) {
2025            return i;
2026        }
2027        let i = self.params.len();
2028        self.params.push(name.to_string());
2029        i
2030    }
2031
2032    // ── Global variable resolution ────────────────────────────────────────────────
2033
2034    /// `scalar_type` is a PyQL-style type-name string built by the Python
2035    /// walker's `_pyql_type_name` (e.g. `"std::str"`, `"std::uuid"`,
2036    /// `"cal::local_date"`, `"default::Gender"`, `"array<std::str>"`) — never
2037    /// a bare Python class name. Mirrors every shape `_pyql_type_name` can
2038    /// produce for a `Global[T]` annotation.
2039    fn resolve_global_pg_type(&self, scalar_type: &str) -> String {
2040        // `datetime` and `std::datetime` name the same type.
2041        let builtin = match scalar_type.strip_prefix("std::").unwrap_or(scalar_type) {
2042            "str" => Some("text"),
2043            "int16" => Some("int2"),
2044            "int32" => Some("int4"),
2045            "int64" => Some("int8"),
2046            "float32" => Some("float4"),
2047            "float64" => Some("float8"),
2048            "decimal" => Some("numeric"),
2049            "bool" => Some("boolean"),
2050            "datetime" => Some("timestamptz"),
2051            "cal::local_datetime" => Some("timestamp"),
2052            "cal::local_date" => Some("date"),
2053            "cal::local_time" => Some("time"),
2054            "uuid" => Some("uuid"),
2055            "bytes" => Some("bytea"),
2056            "json" => Some("jsonb"),
2057            "duration" => Some("interval"),
2058            _ => None,
2059        };
2060        if let Some(t) = builtin {
2061            return t.to_string();
2062        }
2063        if let Some(inner) = scalar_type.strip_prefix("array<").and_then(|s| s.strip_suffix('>')) {
2064            return format!("{}[]", self.resolve_global_pg_type(inner));
2065        }
2066        if scalar_type.starts_with("tuple<") {
2067            return "jsonb".to_string();
2068        }
2069        if let Some(ed) = self.resolve_enum(scalar_type) {
2070            return format!("{}.\"{}\"", crate::sql::pg_schema_str(&ed.module), ed.name);
2071        }
2072        if self.resolve_named_tuple(scalar_type).is_some() {
2073            return "jsonb".to_string();
2074        }
2075        // Fall back to a registered custom scalar lookup.
2076        self.schema
2077            .scalars
2078            .iter()
2079            .find(|s| s.name == scalar_type || format!("{}::{}", s.module, s.name) == scalar_type)
2080            .map(|s| s.pg_type.clone())
2081            .unwrap_or_else(|| "text".to_string())
2082    }
2083
2084    /// When `global name` (or `global name { shape }`) appears as the top-level
2085    /// SELECT subject, inline the computed expression rather than going through a
2086    /// CTE — this returns a full object, not just its id.
2087    fn try_compile_global_select(
2088        &mut self,
2089        outer: &ast::SelectStmt,
2090        result: &Expr,
2091        _distinct: bool,
2092    ) -> Result<Option<IrStmt>, PyQLError> {
2093        let (global_name, shape_elements): (&str, &[ast::ShapeElement]) = match result {
2094            Expr::Global(name) => (name.as_str(), &[]),
2095            Expr::Shape(sh) => match sh.expr.as_ref() {
2096                Some(Expr::Global(name)) => (name.as_str(), sh.elements.as_slice()),
2097                _ => return Ok(None),
2098            },
2099            _ => return Ok(None),
2100        };
2101
2102        let global = self
2103            .schema
2104            .globals
2105            .iter()
2106            .find(|g| g.name == global_name || format!("{}::{}", g.module, g.name) == global_name);
2107        let global = match global {
2108            Some(g) => g.clone(),
2109            None => return Ok(None),
2110        };
2111        let computed_expr = match global.computed_expr {
2112            Some(e) => e,
2113            None => return Ok(None), // scalar session global — fall through
2114        };
2115
2116        let inner_ast = crate::parse::parse(&computed_expr)?;
2117        let inner_sel = match inner_ast {
2118            Stmt::Select(sel) => sel,
2119            _ => return Ok(None),
2120        };
2121
2122        let merged_result = if shape_elements.is_empty() {
2123            inner_sel.result.clone()
2124        } else {
2125            Expr::Shape(Box::new(ast::ShapeExpr {
2126                expr: Some(inner_sel.result.clone()),
2127                elements: shape_elements.to_vec(),
2128                marker_offset: None,
2129            }))
2130        };
2131
2132        let merged_filter = match (&inner_sel.filter, &outer.filter) {
2133            (Some(a), Some(b)) => Some(Expr::BinOp(Box::new(ast::BinOp {
2134                left: a.clone(),
2135                op: ast::BinOpKind::And,
2136                right: b.clone(),
2137            }))),
2138            (Some(a), None) => Some(a.clone()),
2139            (None, b) => b.clone(),
2140        };
2141
2142        let merged = ast::SelectStmt {
2143            result: merged_result,
2144            filter: merged_filter,
2145            order_by: if outer.order_by.is_empty() {
2146                inner_sel.order_by.clone()
2147            } else {
2148                outer.order_by.clone()
2149            },
2150            offset: outer.offset.clone().or(inner_sel.offset.clone()),
2151            limit: outer.limit.clone().or(inner_sel.limit.clone()),
2152            lock: outer.lock.clone().or(inner_sel.lock.clone()),
2153        };
2154
2155        let ir = self.compile_stmt(&Stmt::Select(merged))?;
2156        Ok(Some(ir))
2157    }
2158
2159    /// Reading one field off a free object. A free shape is a real object
2160    /// type, so `{ device := d { id }, … }.device` is an ordinary
2161    /// path step through a pointer and yields whatever that pointer holds —
2162    /// an object stays an object. Extracting it out of the jsonb the free
2163    /// object would otherwise build flattens it back to raw JSON. Any field
2164    /// left unread still runs: a mutation among them is a data-modifying CTE,
2165    /// which Postgres executes whether or not the outer query reads it.
2166    fn project_free_object_field(expr: IrExpr, field: &str) -> IrExpr {
2167        if let IrExpr::NamedTuple {
2168            fields,
2169            is_free_object: true,
2170        } = &expr
2171            && let Some((_, value)) = fields.iter().find(|(name, _)| name == field)
2172        {
2173            return value.clone();
2174        }
2175        IrExpr::JsonbField {
2176            expr: Box::new(expr),
2177            field: field.to_string(),
2178        }
2179    }
2180
2181    /// The operands of a union written entirely of relative paths, which are
2182    /// correlated to the enclosing row and so cannot be hoisted.
2183    fn union_of_relative_paths(expr: &Expr) -> Option<Vec<ast::Path>> {
2184        fn walk(expr: &Expr, out: &mut Vec<ast::Path>) -> bool {
2185            match expr {
2186                Expr::Union(a, b) => walk(a, out) && walk(b, out),
2187                Expr::Path(p) if p.partial => {
2188                    out.push(p.clone());
2189                    true
2190                }
2191                _ => false,
2192            }
2193        }
2194        if !matches!(expr, Expr::Union(_, _)) {
2195            return None;
2196        }
2197        let mut out = vec![];
2198        walk(expr, &mut out).then_some(out)
2199    }
2200
2201    /// `([is Conditional].configs ?? [is Loop].configs)` — the operands of a
2202    /// coalesce, when every one is a path off the enclosing row. Unlike a
2203    /// union each operand but the first only contributes when the ones before
2204    /// it are empty, which is what the caller guards them with.
2205    fn coalesce_of_relative_paths(expr: &Expr) -> Option<Vec<ast::Path>> {
2206        fn walk(expr: &Expr, out: &mut Vec<ast::Path>) -> bool {
2207            match expr {
2208                Expr::BinOp(b) if b.op == ast::BinOpKind::Coalesce => walk(&b.left, out) && walk(&b.right, out),
2209                Expr::Path(p) if p.partial => {
2210                    out.push(p.clone());
2211                    true
2212                }
2213                _ => false,
2214            }
2215        }
2216        if !matches!(expr, Expr::BinOp(b) if b.op == ast::BinOpKind::Coalesce) {
2217            return None;
2218        }
2219        let mut out = vec![];
2220        walk(expr, &mut out).then_some(out)
2221    }
2222
2223    /// `(select Licence filter …) { id }` — a shape written after a
2224    /// parenthesised sub-select rather than inside it.
2225    ///
2226    /// The two mean the same thing, so the shape is pushed onto the inner
2227    /// statement's own result and the whole thing compiled as the select it
2228    /// already was. A `with` block is carried through to its inner statement.
2229    fn shape_over_subquery(&mut self, sh: &ast::ShapeExpr) -> Result<Option<IrExpr>, PyQLError> {
2230        fn push_shape(stmt: &Stmt, elements: &[ShapeElement]) -> Option<Stmt> {
2231            match stmt {
2232                Stmt::Select(sel) => {
2233                    if matches!(sel.result, Expr::Shape(_)) {
2234                        return None;
2235                    }
2236                    let mut shaped = sel.clone();
2237                    shaped.result = Expr::Shape(Box::new(ast::ShapeExpr {
2238                        expr: Some(sel.result.clone()),
2239                        elements: elements.to_vec(),
2240                        marker_offset: None,
2241                    }));
2242                    Some(Stmt::Select(shaped))
2243                }
2244                Stmt::With(w) => {
2245                    let inner = push_shape(&w.stmt, elements)?;
2246                    let mut carried = w.clone();
2247                    carried.stmt = Box::new(inner);
2248                    Some(Stmt::With(carried))
2249                }
2250                _ => None,
2251            }
2252        }
2253        let Some(Expr::SubQuery(stmt)) = sh.expr.as_ref() else {
2254            return Ok(None);
2255        };
2256        let Some(shaped) = push_shape(stmt.as_ref(), &sh.elements) else {
2257            return Ok(None);
2258        };
2259        let single = matches!(
2260            innermost_select(&shaped).and_then(|s| s.limit.as_ref()),
2261            Some(Expr::Literal(ast::Literal::Int(1)))
2262        );
2263        let IrStmt::Select(select) = self.compile_stmt(&shaped)? else {
2264            return Ok(None);
2265        };
2266        if !matches!(select.rows.as_slice(), [IrRowSource::Bound { .. }]) {
2267            return Ok(None);
2268        }
2269        Ok(Some(if single {
2270            IrExpr::ObjectSubquery(Box::new(select))
2271        } else {
2272            IrExpr::ArrayFromSelect(Box::new(IrArraySource::ObjectSelect(Box::new(select))))
2273        }))
2274    }
2275
2276    /// `(select … limit 1).account { id, name }` — a shape written on what a
2277    /// projection off a sub-select lands on. Returns the sub-statement and
2278    /// the field chain, so the shape can ride along with the splice instead
2279    /// of being rejected as a shape in expression position.
2280    fn shape_over_subquery_projection(sh: &ast::ShapeExpr) -> Option<(&Stmt, Vec<String>)> {
2281        let inner = sh.expr.as_ref()?;
2282        if !matches!(inner, Expr::FieldAccess { .. }) {
2283            return None;
2284        }
2285        let (base, fields) = Self::peel_field_access_chain(inner);
2286        match base {
2287            Expr::SubQuery(stmt) => Some((stmt.as_ref(), fields)),
2288            _ => None,
2289        }
2290    }
2291
2292    /// Peel nested `Expr::FieldAccess` layers (`X.a.b` parses as
2293    /// `FieldAccess{FieldAccess{X, "a"}, "b"}`) into the innermost root
2294    /// expression plus the ordered chain of field names.
2295    /// The junction tables `dml` writes, mapped to the CTE the emitter names
2296    /// for each append (`{cte}__ml_add_{i}`, see `emit_ml_append_cte`). A walk
2297    /// off that mutation reads them from there, because Postgres shows a
2298    /// sibling CTE's inserts nowhere else. Only appends carrying no link
2299    /// properties qualify: the append CTE returns just the two id columns, so
2300    /// a walk reading `@prop` off one would name a column it does not have.
2301    fn junction_overrides_for(dml: &IrStmt, cte_name: &str) -> HashMap<String, JunctionReadOverride> {
2302        let appends = match dml {
2303            IrStmt::Insert(ins) => &ins.multi_link_appends,
2304            IrStmt::Update(upd) => &upd.multi_link_appends,
2305            _ => return HashMap::new(),
2306        };
2307        appends
2308            .iter()
2309            .enumerate()
2310            .filter(|(_, append)| append.values.link_props.is_empty())
2311            .map(|(i, append)| {
2312                // The targets themselves may also have been inserted by this
2313                // statement, in which case they are only in their own CTE too.
2314                let targets = match &append.values.source {
2315                    IrMultiLinkValueSource::CteRef(target_cte) => Some(format!("@cte:{target_cte}")),
2316                    _ => None,
2317                };
2318                (
2319                    append.junction_table.clone(),
2320                    JunctionReadOverride {
2321                        junction: format!("@cte:{cte_name}__ml_add_{i}"),
2322                        targets,
2323                    },
2324                )
2325            })
2326            .collect()
2327    }
2328
2329    /// `(select …).provider[is Individual].staff` — a walk off a sub-select
2330    /// that includes a step with no expression form of its own. The select is
2331    /// hoisted into the statement's own WITH and the walk re-rooted at that
2332    /// binding, which is the `with i := (select …) select i.provider[is …].staff`
2333    /// spelling that already compiles.
2334    ///
2335    /// `None` when this is not that shape — in particular a walk made only of
2336    /// `.field` steps, which the `FieldAccess` arm already handles and must go
2337    /// on handling: re-rooting one of those reads the trailing field as jsonb
2338    /// off an object id instead of as a column.
2339    fn try_compile_walk_off_subquery(
2340        &mut self,
2341        expr: &Expr,
2342        ctx: Option<(&TypeDescriptor, &str)>,
2343    ) -> Result<Option<IrExpr>, PyQLError> {
2344        if !matches!(expr, Expr::PathStepOn { .. } | Expr::FieldAccess { .. }) {
2345            return Ok(None);
2346        }
2347        let (base, steps) = Self::peel_path_step_chain(expr);
2348        if !steps.iter().any(|step| !matches!(step, ast::PathStep::Name(_))) {
2349            return Ok(None);
2350        }
2351        let Expr::SubQuery(inner_stmt) = base else {
2352            return Ok(None);
2353        };
2354        // Bound exactly as a `with` alias would be, not via `compile_stmt`.
2355        let inner = compile_cte_binding(self, &Expr::SubQuery(inner_stmt.clone()))?;
2356        let cte_name = self.fresh_nested_cte_name();
2357        let overrides = Self::junction_overrides_for(&inner, &cte_name);
2358        let type_name = self.register_cte(&cte_name, &inner);
2359        self.hoisted_ctes.push(IrCteDef {
2360            name: cte_name.clone(),
2361            stmt: inner,
2362            type_name,
2363            correlated_to: None,
2364        });
2365        let mut path_steps = vec![ast::PathStep::Name(cte_name)];
2366        path_steps.extend(steps);
2367        let path = ast::Path {
2368            steps: path_steps,
2369            partial: false,
2370        };
2371        let previous = std::mem::replace(&mut self.junction_read_overrides, overrides);
2372        let ir = match ctx {
2373            Some((td, alias)) => self.compile_path(&path, td, alias),
2374            None => self.compile_free_path(&path),
2375        };
2376        self.junction_read_overrides = previous;
2377        Ok(Some(ir?))
2378    }
2379
2380    /// Peel a mixed `.field` / `[is T]` / `@prop` / `.<backlink` walk off a
2381    /// base that is not itself a path, outermost step last. The counterpart of
2382    /// `peel_field_access_chain` for a chain that contains a step with no
2383    /// expression form of its own (see `ast::Expr::PathStepOn`).
2384    fn peel_path_step_chain(expr: &Expr) -> (&Expr, Vec<ast::PathStep>) {
2385        let mut steps = Vec::new();
2386        let mut current = expr;
2387        loop {
2388            match current {
2389                Expr::FieldAccess { expr: inner, field } => {
2390                    steps.push(ast::PathStep::Name(field.clone()));
2391                    current = inner;
2392                }
2393                Expr::PathStepOn { expr: inner, step } => {
2394                    steps.push((**step).clone());
2395                    current = inner;
2396                }
2397                _ => break,
2398            }
2399        }
2400        steps.reverse();
2401        (current, steps)
2402    }
2403
2404    fn peel_field_access_chain(expr: &Expr) -> (&Expr, Vec<String>) {
2405        let mut fields = Vec::new();
2406        let mut current = expr;
2407        while let Expr::FieldAccess { expr: inner, field } = current {
2408            fields.push(field.clone());
2409            current = inner;
2410        }
2411        fields.reverse();
2412        (current, fields)
2413    }
2414
2415    /// Resolve `expr` to the inner `select Type filter ...` statement it
2416    /// stands for, if any — either a literal subquery (`(select Type filter
2417    /// ...)`) or a computed global whose defining expression is such a
2418    /// select. Only bare object-type results are recognized (`select Type
2419    /// ...`, not `select Type { shape }` or a free expression) — that's the
2420    /// only shape `.field` access after it can be spliced onto as an
2421    /// additional path step.
2422    fn resolve_field_owner_select(&self, expr: &Expr) -> Option<ast::SelectStmt> {
2423        let sel = match expr {
2424            Expr::SubQuery(stmt) => match stmt.as_ref() {
2425                Stmt::Select(sel) => sel.clone(),
2426                _ => return None,
2427            },
2428            Expr::Global(name) => {
2429                let global = self
2430                    .schema
2431                    .globals
2432                    .iter()
2433                    .find(|g| g.name == *name || format!("{}::{}", g.module, g.name) == *name)?;
2434                let computed_expr = global.computed_expr.as_ref()?;
2435                match crate::parse::parse(computed_expr).ok()? {
2436                    Stmt::Select(sel) => sel,
2437                    _ => return None,
2438                }
2439            }
2440            _ => return None,
2441        };
2442        // `(select detached T filter … limit 1).field` — at the top level the
2443        // marker says nothing a field access changes, and the path underneath
2444        // is the subject to splice the chain onto.
2445        let mut sel = sel;
2446        if let Expr::Detached(inner) = &sel.result {
2447            sel.result = inner.as_ref().clone();
2448        }
2449        match &sel.result {
2450            Expr::Path(p) if !p.partial => Some(sel),
2451            _ => None,
2452        }
2453    }
2454
2455    /// `global name.field` or `(select Type filter ...).field` (and deeper
2456    /// chains like `.link.field`) used as the top-level SELECT subject:
2457    /// rather than treating `.field` as jsonb extraction on an opaque value
2458    /// (which only makes sense for tuple-typed properties — see
2459    /// `resolve_property_tuple_shape`), splice the field chain onto the
2460    /// inner select as additional path steps and recompile as an ordinary
2461    /// path-select. Mirrors `try_compile_global_select`'s filter/modifier
2462    /// merge, generalized to a field-access result instead of a bare/shape
2463    /// global reference.
2464    /// `(select T filter … limit 1).a.b { … }` compiled as though it were
2465    /// written `with head := (select T filter … limit 1) select head.a.b { … }`:
2466    /// the head becomes a binding of its own, so its own row count stays on it
2467    /// and the walk off it keeps every row it reaches.
2468    fn compile_walk_off_bound_head(
2469        &mut self,
2470        outer: &ast::SelectStmt,
2471        inner_sel: &ast::SelectStmt,
2472        fields: &[String],
2473        trailing_shape: &[ShapeElement],
2474    ) -> Result<Option<IrStmt>, PyQLError> {
2475        let head = Expr::SubQuery(Box::new(Stmt::Select(inner_sel.clone())));
2476        let inner = compile_cte_binding(self, &head)?;
2477        let cte_name = self.fresh_nested_cte_name();
2478        let overrides = Self::junction_overrides_for(&inner, &cte_name);
2479        let type_name = self.register_cte(&cte_name, &inner);
2480        self.hoisted_ctes.push(IrCteDef {
2481            name: cte_name.clone(),
2482            stmt: inner,
2483            type_name,
2484            correlated_to: None,
2485        });
2486        let mut steps = vec![ast::PathStep::Name(cte_name)];
2487        steps.extend(fields.iter().cloned().map(ast::PathStep::Name));
2488        let walk = Expr::Path(ast::Path { steps, partial: false });
2489        let result = if trailing_shape.is_empty() {
2490            walk
2491        } else {
2492            Expr::Shape(Box::new(ast::ShapeExpr {
2493                expr: Some(walk),
2494                elements: trailing_shape.to_vec(),
2495                marker_offset: None,
2496            }))
2497        };
2498        let rerooted = ast::SelectStmt {
2499            result,
2500            filter: outer.filter.clone(),
2501            order_by: outer.order_by.clone(),
2502            offset: outer.offset.clone(),
2503            limit: outer.limit.clone(),
2504            lock: outer.lock.clone(),
2505        };
2506        let previous = std::mem::replace(&mut self.junction_read_overrides, overrides);
2507        let ir = self.compile_stmt(&Stmt::Select(rerooted));
2508        self.junction_read_overrides = previous;
2509        Ok(Some(ir?))
2510    }
2511
2512    fn try_compile_field_access_select(
2513        &mut self,
2514        outer: &ast::SelectStmt,
2515        result: &Expr,
2516    ) -> Result<Option<IrStmt>, PyQLError> {
2517        // `(select T).chapter { … }` — the parser folds a trailing shape around
2518        // the whole field access, so the chain has to be peeled out of it or
2519        // the select reads as one with no type for a subject.
2520        let (result, trailing_shape): (&Expr, &[ShapeElement]) = match result {
2521            Expr::Shape(sh) => match sh.expr.as_ref() {
2522                Some(inner @ Expr::FieldAccess { .. }) => (inner, sh.elements.as_slice()),
2523                _ => (result, &[]),
2524            },
2525            other => (other, &[]),
2526        };
2527        let (root, fields) = Self::peel_field_access_chain(result);
2528        if fields.is_empty() {
2529            return Ok(None);
2530        }
2531        let inner_sel = match self.resolve_field_owner_select(root) {
2532            Some(sel) => sel,
2533            None => return Ok(None),
2534        };
2535        let Expr::Path(type_path) = &inner_sel.result else {
2536            return Ok(None);
2537        };
2538        // A sub-select's own `limit`/`offset` counts its rows, not the walk's:
2539        // `(select Cart filter .id = $c limit 1).applied_promotions` is every
2540        // promotion of that one cart. Spliced onto the walk the count lands on
2541        // the result instead and takes one promotion, so the head is bound on
2542        // its own and walked from there — the `with` spelling, which scopes it.
2543        if inner_sel.limit.is_some() || inner_sel.offset.is_some() {
2544            return self.compile_walk_off_bound_head(outer, &inner_sel, &fields, trailing_shape);
2545        }
2546        let mut steps = type_path.steps.clone();
2547        let field_count = fields.len();
2548        steps.extend(fields.into_iter().map(ast::PathStep::Name));
2549        let merged_result = Expr::Path(ast::Path { steps, partial: false });
2550
2551        let merged_filter = match (&inner_sel.filter, &outer.filter) {
2552            (Some(a), Some(b)) => Some(Expr::BinOp(Box::new(ast::BinOp {
2553                left: a.clone(),
2554                op: ast::BinOpKind::And,
2555                right: b.clone(),
2556            }))),
2557            (Some(a), None) => Some(a.clone()),
2558            (None, b) => b.clone(),
2559        };
2560
2561        let merged = ast::SelectStmt {
2562            result: merged_result,
2563            filter: merged_filter,
2564            order_by: if outer.order_by.is_empty() {
2565                inner_sel.order_by.clone()
2566            } else {
2567                outer.order_by.clone()
2568            },
2569            offset: outer.offset.clone().or(inner_sel.offset.clone()),
2570            limit: outer.limit.clone().or(inner_sel.limit.clone()),
2571            lock: outer.lock.clone().or(inner_sel.lock.clone()),
2572        };
2573
2574        // The inner select's own filter and ordering speak about its subject,
2575        // not about what the field chain projects off it: `(select Individual
2576        // filter .id = $a).credentials.password` filters Individuals, not
2577        // Credentials. `tail` is what pins them there. Only safe when the
2578        // outer select contributed no filter of its own — the merge folds
2579        // both into one clause, and an outer filter does belong at the end.
2580        // An outer `order by` belongs at the end too, so it rides along
2581        // separately rather than costing the inner filter its subject.
2582        if outer.filter.is_none() {
2583            let merged = ast::SelectStmt {
2584                order_by: inner_sel.order_by.clone(),
2585                ..merged
2586            };
2587            let Expr::Path(merged_path) = &merged.result else {
2588                unreachable!("built as a path just above")
2589            };
2590            let ps = self.compile_path_select_with_tail(
2591                &merged,
2592                merged_path,
2593                trailing_shape,
2594                false,
2595                field_count,
2596                &outer.order_by,
2597            )?;
2598            return Ok(Some(IrStmt::PathSelect(ps)));
2599        }
2600
2601        let merged = if trailing_shape.is_empty() {
2602            merged
2603        } else {
2604            ast::SelectStmt {
2605                result: Expr::Shape(Box::new(ast::ShapeExpr {
2606                    expr: Some(merged.result.clone()),
2607                    elements: trailing_shape.to_vec(),
2608                    marker_offset: None,
2609                })),
2610                ..merged
2611            }
2612        };
2613        let ir = self.compile_stmt(&Stmt::Select(merged))?;
2614        Ok(Some(ir))
2615    }
2616
2617    fn try_compile_alias_select(
2618        &mut self,
2619        outer: &ast::SelectStmt,
2620        result: &Expr,
2621        distinct: bool,
2622    ) -> Result<Option<IrStmt>, PyQLError> {
2623        // Extract the bare path name and any outer shape elements.
2624        let (path_name, shape_elements): (&str, &[ast::ShapeElement]) = match result {
2625            Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
2626                if let ast::PathStep::Name(n) = &p.steps[0] {
2627                    (n.as_str(), &[])
2628                } else {
2629                    return Ok(None);
2630                }
2631            }
2632            Expr::Shape(sh) => match sh.expr.as_ref() {
2633                Some(Expr::Path(p)) if !p.partial && p.steps.len() == 1 => {
2634                    if let ast::PathStep::Name(n) = &p.steps[0] {
2635                        (n.as_str(), sh.elements.as_slice())
2636                    } else {
2637                        return Ok(None);
2638                    }
2639                }
2640                _ => return Ok(None),
2641            },
2642            _ => return Ok(None),
2643        };
2644
2645        // Match against schema aliases (bare name or module::name).
2646        let alias = self
2647            .schema
2648            .aliases
2649            .iter()
2650            .find(|a| a.name == path_name || format!("{}::{}", a.module, a.name) == path_name);
2651        let alias = match alias {
2652            Some(a) => a.clone(),
2653            None => return Ok(None),
2654        };
2655
2656        let inner_ast = crate::parse::parse(&alias.expr)?;
2657        let inner_sel = match inner_ast {
2658            Stmt::Select(sel) => sel,
2659            _ => return Err(self.type_err(&format!("alias '{}' expression must be a select statement", alias.name))),
2660        };
2661
2662        // Merge outer shape / filter / modifiers over the alias's select.
2663        // `inner_sel.result` is itself an `Expr::Shape` whenever the
2664        // alias's own body declares a shape (e.g. `select Type { field }
2665        // order by ... limit ...`, a legitimate, documented pattern — an
2666        // alias's own body may combine a shape with modifiers) — wrapping
2667        // it wholesale as the outer shape's own `expr` would nest a Shape
2668        // inside a Shape, which the compiler's SELECT-subject resolution
2669        // rejects ("expected a type name as SELECT subject", confirmed
2670        // live). The outer shape must bind to the same underlying *type*
2671        // reference the alias's own shape does, not to the alias's shape
2672        // node itself — so unwrap through it first.
2673        let inner_base = match &inner_sel.result {
2674            Expr::Shape(sh) => sh.expr.clone(),
2675            other => Some(other.clone()),
2676        };
2677        let merged_result = if shape_elements.is_empty() {
2678            inner_sel.result.clone()
2679        } else {
2680            Expr::Shape(Box::new(ast::ShapeExpr {
2681                expr: inner_base,
2682                elements: shape_elements.to_vec(),
2683                marker_offset: None,
2684            }))
2685        };
2686
2687        let merged_filter = match (&inner_sel.filter, &outer.filter) {
2688            (Some(a), Some(b)) => Some(Expr::BinOp(Box::new(ast::BinOp {
2689                left: a.clone(),
2690                op: ast::BinOpKind::And,
2691                right: b.clone(),
2692            }))),
2693            (Some(a), None) => Some(a.clone()),
2694            (None, b) => b.clone(),
2695        };
2696
2697        let merged = ast::SelectStmt {
2698            result: merged_result,
2699            filter: merged_filter,
2700            order_by: if outer.order_by.is_empty() {
2701                inner_sel.order_by.clone()
2702            } else {
2703                outer.order_by.clone()
2704            },
2705            offset: outer.offset.clone().or(inner_sel.offset.clone()),
2706            limit: outer.limit.clone().or(inner_sel.limit.clone()),
2707            lock: outer.lock.clone().or(inner_sel.lock.clone()),
2708        };
2709
2710        let _ = distinct; // alias selects honour the outer distinct if applied
2711        let ir = self.compile_stmt(&Stmt::Select(merged))?;
2712        Ok(Some(ir))
2713    }
2714
2715    fn compile_global(&mut self, raw_name: &str) -> Result<IrExpr, PyQLError> {
2716        let global = self
2717            .schema
2718            .globals
2719            .iter()
2720            .find(|g| g.name == raw_name || format!("{}::{}", g.module, g.name) == raw_name);
2721        let global = global
2722            .ok_or_else(|| {
2723                PyQLError::Resolution(PyQLResolutionError::UnknownField(PyQLUnknownFieldError {
2724                    message: format!("unknown global: {:?}", raw_name),
2725                    position: Position { line: 0, col: 0 },
2726                }))
2727            })?
2728            .clone();
2729
2730        let qualified = format!("{}::{}", global.module, global.name);
2731        let cte_name = format!("__global__{}", qualified);
2732
2733        if let Some(computed_expr) = global.computed_expr {
2734            // Computed global — check for duplicate before compiling
2735            if self.global_ctes.iter().any(|g| g.cte_name() == cte_name) {
2736                return Ok(IrExpr::GlobalRef { cte_name });
2737            }
2738            // Recursively compile the PyQL expression (shares params and global_ctes)
2739            let inner_ast = crate::parse::parse(&computed_expr)?;
2740            let inner_stmt = self.compile_stmt(&inner_ast)?;
2741            self.global_ctes
2742                .push(IrGlobalCte::Computed(Box::new(IrComputedGlobalCte {
2743                    cte_name: cte_name.clone(),
2744                    qualified_name: qualified,
2745                    stmt: inner_stmt,
2746                })));
2747            Ok(IrExpr::GlobalRef { cte_name })
2748        } else if self.in_fn_body {
2749            // Inside a function body there is no parameter to bind, so the
2750            // value is read out of the `__pylon_json_globals__` argument the
2751            // caller packs.
2752            let pg_type = self.resolve_global_pg_type(&global.scalar_type);
2753            self.used_globals_arg = true;
2754            // Wrapped in a cast rather than returned bare: `RawSql` carries no
2755            // type, so overload resolution fell back to text and picked the
2756            // `str` `find` for an `array<str>` global (`strpos(text[], text)
2757            // does not exist`).
2758            Ok(IrExpr::TypeCast(Box::new(super::IrTypeCast {
2759                expr: IrExpr::RawSql(globals_arg_read(&qualified, &pg_type)),
2760                pg_type,
2761                tuple_shape: None,
2762            })))
2763        } else {
2764            // Session global — allocate parameter slot
2765            let pg_type = self.resolve_global_pg_type(&global.scalar_type);
2766            let param_name = format!("__global__{}", qualified);
2767            let index = self.param_index(&param_name);
2768            // Register CTE only once
2769            if !self.global_ctes.iter().any(|g| g.cte_name() == cte_name) {
2770                self.global_ctes.push(IrGlobalCte::Session(IrSessionGlobalCte {
2771                    cte_name,
2772                    qualified_name: qualified,
2773                    param_index: index,
2774                    pg_type: pg_type.clone(),
2775                }));
2776            }
2777            Ok(IrExpr::GlobalParam { index, pg_type })
2778        }
2779    }
2780
2781    // ── Schema lookups ────────────────────────────────────────────────────────────
2782
2783    /// The object type a WITH binding names, if it binds one — object type
2784    /// strings are qualified, free/scalar bindings' are not.
2785    fn cte_object_type(&self, name: &str) -> Option<String> {
2786        self.cte_types.get(name).filter(|t| t.contains("::")).cloned()
2787    }
2788
2789    fn resolve_type(&self, name: &str) -> Result<&'a TypeDescriptor, PyQLError> {
2790        // Accept both "TypeName" and "module::TypeName"
2791        self.schema
2792            .types
2793            .iter()
2794            .find(|t| t.name == name || format!("{}::{}", t.module, t.name) == name)
2795            .ok_or_else(|| {
2796                PyQLError::Resolution(PyQLResolutionError::UnknownType(PyQLUnknownTypeError {
2797                    message: format!("unknown type '{name}'"),
2798                    position: Position { line: 0, col: 0 },
2799                }))
2800            })
2801    }
2802
2803    /// A schema enum, or failing that one the stdlib defines (`std::Endian`).
2804    /// The stdlib's are `&'static`, which coerces to `&'a` for any shorter
2805    /// `'a`, so both live behind one lookup and every site that recognizes
2806    /// enum member access picks them up without its own special case.
2807    fn resolve_enum(&self, name: &str) -> Option<&'a crate::schema::EnumDescriptor> {
2808        self.schema
2809            .enums
2810            .iter()
2811            .find(|e| e.name == name || format!("{}::{}", e.module, e.name) == name)
2812            .or_else(|| crate::stdlib::lookup_enum(name))
2813    }
2814
2815    /// Resolve a `Channel` by name (bare or `module::Name`) — used by
2816    /// `notify()` to find the declared payload shape its second argument
2817    /// must match.
2818    fn resolve_channel(&self, name: &str) -> Option<&'a crate::schema::ChannelDescriptor> {
2819        self.schema.find_channel(name)
2820    }
2821
2822    /// Resolve a registered custom scalar (`pylon.scalar(..., name=...)` or
2823    /// the `@pylon.scalar` decorator form) by name — used to recognize a
2824    /// cast target as that scalar's own PostgreSQL DOMAIN (see
2825    /// `resolve_cast_pg_type`); an anonymous (unregistered) scalar has no
2826    /// name reachable here at all, so it's never a valid cast target.
2827    fn resolve_scalar(&self, name: &str) -> Option<&'a crate::schema::ScalarDescriptor> {
2828        self.schema
2829            .scalars
2830            .iter()
2831            .find(|s| s.name == name || format!("{}::{}", s.module, s.name) == name)
2832    }
2833
2834    /// Resolve a registered (nominal) `@pylon.named_tuple` type by name — used only
2835    /// to recognize a cast target as a named tuple (member structure isn't
2836    /// validated here; the value is trusted the same way a plain `<json>` cast is).
2837    fn resolve_named_tuple(&self, name: &str) -> Option<&'a crate::schema::NamedTupleDescriptor> {
2838        self.schema
2839            .named_tuples
2840            .iter()
2841            .find(|nt| nt.name == name || format!("{}::{}", nt.module, nt.name) == name)
2842    }
2843
2844    /// Convert a schema-level `TupleMemberDescriptor` into a decode-time
2845    /// `JsonMember` — recurses for a nested tuple member, resolving a nested
2846    /// *nominal* member's own registered members too.
2847    fn tuple_member_to_json_member(&self, m: &crate::schema::TupleMemberDescriptor) -> crate::query::JsonMember {
2848        use crate::schema::TupleMemberKind;
2849        let kind = match &m.kind {
2850            TupleMemberKind::Scalar { .. } => crate::query::JsonMemberKind::Scalar,
2851            TupleMemberKind::Enum { module, name } => crate::query::JsonMemberKind::Enum {
2852                enum_type: format!("{}::{}", module, name),
2853            },
2854            TupleMemberKind::NamedTuple { module, name } => {
2855                let qname = format!("{}::{}", module, name);
2856                let nested_members = self
2857                    .resolve_named_tuple(&qname)
2858                    .map(|nt| {
2859                        nt.members
2860                            .iter()
2861                            .map(|mm| self.tuple_member_to_json_member(mm))
2862                            .collect()
2863                    })
2864                    .unwrap_or_default();
2865                crate::query::JsonMemberKind::Tuple {
2866                    type_name: Some(qname),
2867                    members: nested_members,
2868                }
2869            }
2870            TupleMemberKind::Tuple { members } => crate::query::JsonMemberKind::Tuple {
2871                type_name: None,
2872                members: members.iter().map(|mm| self.tuple_member_to_json_member(mm)).collect(),
2873            },
2874        };
2875        crate::query::JsonMember {
2876            key: m.name.clone(),
2877            kind,
2878        }
2879    }
2880
2881    /// Convert a parsed cast-target `ast::TupleTypeElement` into a decode-time
2882    /// `JsonMember` — the cast-syntax counterpart of `tuple_member_to_json_member`.
2883    fn ast_tuple_element_to_json_member(&self, elem: &ast::TupleTypeElement) -> crate::query::JsonMember {
2884        crate::query::JsonMember {
2885            key: elem.name.clone(),
2886            kind: self.ast_type_expr_to_json_member_kind(&elem.ty),
2887        }
2888    }
2889
2890    fn ast_type_expr_to_json_member_kind(&self, ty: &ast::TypeExpr) -> crate::query::JsonMemberKind {
2891        if let ast::TypeExpr::Tuple { elements } = ty {
2892            return crate::query::JsonMemberKind::Tuple {
2893                type_name: None,
2894                members: elements
2895                    .iter()
2896                    .map(|e| self.ast_tuple_element_to_json_member(e))
2897                    .collect(),
2898            };
2899        }
2900        let Some((module, name)) = ty.as_named() else {
2901            return crate::query::JsonMemberKind::Scalar;
2902        };
2903        let qname = match module {
2904            Some(m) => format!("{}::{}", m, name),
2905            None => name.to_string(),
2906        };
2907        if let Some(ed) = self.resolve_enum(&qname) {
2908            return crate::query::JsonMemberKind::Enum {
2909                enum_type: format!("{}::{}", ed.module, ed.name),
2910            };
2911        }
2912        if let Some(nt) = self.resolve_named_tuple(&qname) {
2913            return crate::query::JsonMemberKind::Tuple {
2914                type_name: Some(format!("{}::{}", nt.module, nt.name)),
2915                members: nt.members.iter().map(|m| self.tuple_member_to_json_member(m)).collect(),
2916            };
2917        }
2918        crate::query::JsonMemberKind::Scalar
2919    }
2920
2921    /// Resolve a cast's own target-type shape for decode-time `ShapeNode`
2922    /// building — a structural `tuple<...>` cast resolves its elements
2923    /// directly; a nominal `<module::Name>` cast resolves via the registered
2924    /// `NamedTupleDescriptor`'s members. `None` for a plain scalar/enum
2925    /// cast target.
2926    fn resolve_tuple_cast_shape(&self, ty: &ast::TypeExpr) -> Option<TupleCastShape> {
2927        match ty {
2928            ast::TypeExpr::Tuple { elements } => Some(TupleCastShape {
2929                type_name: None,
2930                members: elements
2931                    .iter()
2932                    .map(|e| self.ast_tuple_element_to_json_member(e))
2933                    .collect(),
2934            }),
2935            _ => {
2936                let (module, name) = ty.as_named()?;
2937                let qname = match module {
2938                    Some(m) => format!("{}::{}", m, name),
2939                    None => name.to_string(),
2940                };
2941                let nt = self.resolve_named_tuple(&qname)?;
2942                Some(TupleCastShape {
2943                    type_name: Some(format!("{}::{}", nt.module, nt.name)),
2944                    members: nt.members.iter().map(|m| self.tuple_member_to_json_member(m)).collect(),
2945                })
2946            }
2947        }
2948    }
2949
2950    /// Render a cast target `TypeExpr` for error messages, e.g.
2951    /// `tuple<std::int64, std::str>` or `default::Point` — used by the
2952    /// compile-time tuple-index bounds check.
2953    fn type_expr_to_display_str(&self, ty: &ast::TypeExpr) -> String {
2954        match ty {
2955            ast::TypeExpr::Tuple { elements } => {
2956                let inner = elements
2957                    .iter()
2958                    .map(|e| match &e.name {
2959                        Some(n) => format!("{}: {}", n, self.type_expr_to_display_str(&e.ty)),
2960                        None => self.type_expr_to_display_str(&e.ty),
2961                    })
2962                    .collect::<Vec<_>>()
2963                    .join(", ");
2964                format!("tuple<{}>", inner)
2965            }
2966            ast::TypeExpr::Array { element } => format!("array<{}>", self.type_expr_to_display_str(element)),
2967            ast::TypeExpr::Named { module, name } => match module {
2968                Some(m) => format!("{}::{}", m, name),
2969                None => type_expr_to_pg(ty)
2970                    .map(|pg| pg_type_to_pyql(&pg).to_string())
2971                    .unwrap_or_else(|_| name.clone()),
2972            },
2973        }
2974    }
2975
2976    /// Resolve a property's tuple-type shape for decode-time `ShapeNode`
2977    /// building — a nominal `__nt__:module::Name` `pg_type` marker resolves
2978    /// via the registered `NamedTupleDescriptor`'s own members; a structural
2979    /// `pylon.Tuple[...]` property carries its own `tuple_members` directly.
2980    fn resolve_property_tuple_shape(&self, prop: &PropertyDescriptor) -> Option<TupleCastShape> {
2981        if let Some(qname) = prop.pg_type.strip_prefix("__nt__:") {
2982            let nt = self.resolve_named_tuple(qname)?;
2983            return Some(TupleCastShape {
2984                type_name: Some(qname.to_string()),
2985                members: nt.members.iter().map(|m| self.tuple_member_to_json_member(m)).collect(),
2986            });
2987        }
2988        prop.tuple_members.as_ref().map(|members| TupleCastShape {
2989            type_name: None,
2990            members: members.iter().map(|m| self.tuple_member_to_json_member(m)).collect(),
2991        })
2992    }
2993
2994    /// Emit a bare `<pg_type>expr` scalar cast as a free-select statement — shared
2995    /// by the enum/named-tuple/structural-tuple cast cases in `compile_stmt`'s
2996    /// top-level `Expr::TypeCast` handling. For a structural tuple cast whose
2997    /// source is itself a tuple/named-tuple literal, applies each element's
2998    /// own cast by position (see `try_compile_tuple_literal_cast_ctx`)
2999    /// instead of jsonb-wrapping the raw uncast literal values.
3000    fn scalar_cast_free_select(
3001        &mut self,
3002        tc: &ast::TypeCast,
3003        pg_type: String,
3004        distinct: bool,
3005    ) -> Result<IrStmt, PyQLError> {
3006        let cast_expr = match &tc.ty {
3007            ast::TypeExpr::Tuple { elements } => {
3008                let tuple_shape = self.resolve_tuple_cast_shape(&tc.ty);
3009                match self.try_compile_tuple_literal_cast_ctx(elements, &tc.expr, None)? {
3010                    // The literal-decompose path already applies each element's own
3011                    // cast — still wrap in TypeCast so `tuple_shape` reaches SQL
3012                    // emission for decode-time ShapeNode building (jsonb_build_*
3013                    // already produces jsonb, so the outer `::jsonb` is a no-op).
3014                    Some(ir) => IrExpr::TypeCast(Box::new(IrTypeCast {
3015                        expr: ir,
3016                        pg_type,
3017                        tuple_shape,
3018                    })),
3019                    None => {
3020                        let inner = self.compile_expr_ctx(&tc.expr, None)?;
3021                        IrExpr::TypeCast(Box::new(IrTypeCast {
3022                            expr: inner,
3023                            pg_type,
3024                            tuple_shape,
3025                        }))
3026                    }
3027                }
3028            }
3029            _ => {
3030                let inner = self.compile_expr_ctx(&tc.expr, None)?;
3031                let tuple_shape = self.resolve_tuple_cast_shape(&tc.ty);
3032                IrExpr::TypeCast(Box::new(IrTypeCast {
3033                    expr: inner,
3034                    pg_type,
3035                    tuple_shape,
3036                }))
3037            }
3038        };
3039        Ok(IrStmt::Select(IrSelect {
3040            rows: vec![IrRowSource::Free(IrFreeExpr::Scalar(cast_expr))],
3041            filter: None,
3042            order_by: vec![],
3043            offset: None,
3044            limit: None,
3045            distinct,
3046            dml_source: None,
3047            polymorphic: false,
3048            poly_implementors: vec![],
3049            poly_columns: vec![],
3050            lock: None,
3051        }))
3052    }
3053
3054    /// Resolve a cast's target `pg_type` string — shared by both `Expr::TypeCast`
3055    /// compile sites (`compile_free_expr`/`compile_expr`). A structural tuple
3056    /// always resolves to jsonb; an array resolves to its element's own pg_type
3057    /// with a `[]` suffix — a real Postgres array, not jsonb, so it decodes
3058    /// natively (the driver already returns a Python list) with no per-member
3059    /// shape-tracking needed the way tuples require; a named type checks enum,
3060    /// then registered named tuple, then falls back to the built-in
3061    /// scalar/pgvector/cal type list.
3062    fn resolve_cast_pg_type(&self, ty: &ast::TypeExpr) -> Result<String, PyQLError> {
3063        if matches!(ty, ast::TypeExpr::Tuple { .. }) {
3064            return Ok("jsonb".to_string());
3065        }
3066        if let ast::TypeExpr::Array { element } = ty {
3067            let element_pg = self.resolve_cast_pg_type(element)?;
3068            return Ok(format!("{}[]", element_pg));
3069        }
3070        let (module, name) = ty.as_named().expect("checked above: not Tuple/Array");
3071        let qname = match module {
3072            Some(m) => format!("{}::{}", m, name),
3073            None => name.to_string(),
3074        };
3075        if let Some(ed) = self.resolve_enum(&qname) {
3076            return Ok(format!("{}.\"{}\"", crate::sql::pg_schema_str(&ed.module), ed.name));
3077        }
3078        if self.resolve_named_tuple(&qname).is_some() {
3079            return Ok("jsonb".to_string());
3080        }
3081        // A registered custom scalar casts directly to its own DOMAIN (not
3082        // just its base type) — Postgres enforces the domain's CHECK right
3083        // at cast time, the same way it would on column assignment (see
3084        // `PropertyDescriptor.column_type`'s own doc comment for why this
3085        // is safe to do everywhere else too).
3086        if let Some(sd) = self.resolve_scalar(&qname) {
3087            return Ok(format!("{}.\"{}\"", crate::sql::pg_schema_str(&sd.module), sd.name));
3088        }
3089        type_expr_to_pg(ty)
3090    }
3091
3092    /// Cast one tuple-type element's source value to `target_ty` — recurses
3093    /// via `try_compile_tuple_literal_cast_ctx` when both the element's own
3094    /// type and its source value are themselves a nested tuple/named-tuple
3095    /// literal, so nesting applies per-element casts all the way down;
3096    /// otherwise a plain scalar/enum/nominal-named-tuple cast.
3097    fn compile_tuple_element_cast_ctx(
3098        &mut self,
3099        target_ty: &ast::TypeExpr,
3100        value: &Expr,
3101        ctx: Option<(&TypeDescriptor, &str)>,
3102    ) -> Result<IrExpr, PyQLError> {
3103        if let ast::TypeExpr::Tuple { elements } = target_ty
3104            && let Some(ir) = self.try_compile_tuple_literal_cast_ctx(elements, value, ctx)?
3105        {
3106            return Ok(ir);
3107        }
3108        let inner = self.compile_expr_ctx(value, ctx)?;
3109        let pg_type = self.resolve_cast_pg_type(target_ty)?;
3110        Ok(IrExpr::TypeCast(Box::new(IrTypeCast {
3111            expr: inner,
3112            pg_type,
3113            tuple_shape: None,
3114        })))
3115    }
3116
3117    /// When casting a tuple/named-tuple *literal* to a structural tuple type,
3118    /// apply each target element's own cast to its corresponding source value
3119    /// by position — e.g. `<tuple<int64, str>>('1', 3)` must coerce '1' to
3120    /// int64 and 3 to str, not just jsonb-wrap the raw literal values
3121    /// unchanged. Returns `None` when the source isn't a literal tuple/named-
3122    /// tuple of matching arity (e.g. a `$param`) — the whole value already
3123    /// arrives pre-shaped in that case, so the caller's generic jsonb-cast
3124    /// path handles it instead.
3125    fn try_compile_tuple_literal_cast_ctx(
3126        &mut self,
3127        target_elements: &[ast::TupleTypeElement],
3128        source: &Expr,
3129        ctx: Option<(&TypeDescriptor, &str)>,
3130    ) -> Result<Option<IrExpr>, PyQLError> {
3131        let source_values: Vec<&Expr> = match source {
3132            Expr::Tuple(vals) if vals.len() == target_elements.len() => vals.iter().collect(),
3133            Expr::NamedTuple(fields) if fields.len() == target_elements.len() => {
3134                fields.iter().map(|(_, v)| v).collect()
3135            }
3136            _ => return Ok(None),
3137        };
3138        let named = target_elements.iter().all(|e| e.name.is_some());
3139        let mut casted = Vec::with_capacity(target_elements.len());
3140        for (elem, value) in target_elements.iter().zip(source_values) {
3141            casted.push(self.compile_tuple_element_cast_ctx(&elem.ty, value, ctx)?);
3142        }
3143        if named {
3144            let fields = target_elements
3145                .iter()
3146                .zip(casted)
3147                .map(|(e, v)| (e.name.clone().unwrap(), v))
3148                .collect();
3149            Ok(Some(IrExpr::NamedTuple {
3150                fields,
3151                is_free_object: false,
3152            }))
3153        } else {
3154            Ok(Some(IrExpr::Tuple(casted)))
3155        }
3156    }
3157
3158    /// When casting an array *literal* to `array<T>`, apply the element
3159    /// type's own cast to each element by position — e.g.
3160    /// `<array<int64>>['1', '3']` must coerce each string element to int64,
3161    /// not just emit a raw untyped `ARRAY[...]`. Reuses
3162    /// `compile_tuple_element_cast_ctx` for the per-element cast since
3163    /// casting "this value to this target type" is exactly the same
3164    /// operation regardless of whether the target is a tuple element or an
3165    /// array element (including decomposing a nested tuple-literal element).
3166    /// Returns `None` when the source isn't a literal array (e.g. a
3167    /// `$param` or a sub-select) — the caller's generic cast path handles
3168    /// those instead.
3169    fn try_compile_array_literal_cast_ctx(
3170        &mut self,
3171        element_ty: &ast::TypeExpr,
3172        source: &Expr,
3173        ctx: Option<(&TypeDescriptor, &str)>,
3174    ) -> Result<Option<IrExpr>, PyQLError> {
3175        let Expr::Array(elems) = source else { return Ok(None) };
3176        let casted = elems
3177            .iter()
3178            .map(|e| self.compile_tuple_element_cast_ctx(element_ty, e, ctx))
3179            .collect::<Result<Vec<_>, _>>()?;
3180        Ok(Some(IrExpr::Array(casted)))
3181    }
3182
3183    fn compile_enum_access(&self, type_ref: &str, variant: &str) -> Result<IrExpr, PyQLError> {
3184        let ed = self
3185            .resolve_enum(type_ref)
3186            .ok_or_else(|| self.type_err(&format!("unknown type '{}'", type_ref)))?;
3187        if !ed.members.iter().any(|m| m == variant) {
3188            return Err(self.type_err(&format!(
3189                "enum '{}::{}' has no member '{}'",
3190                ed.module, ed.name, variant
3191            )));
3192        }
3193        // A stdlib enum has no PostgreSQL enum type to cast to — the function
3194        // taking it switches on the label as text.
3195        let pg_type = if crate::stdlib::lookup_enum(&format!("{}::{}", ed.module, ed.name)).is_some() {
3196            "text".to_string()
3197        } else {
3198            format!("{}.\"{}\"", crate::sql::pg_schema_str(&ed.module), ed.name)
3199        };
3200        Ok(IrExpr::EnumLiteral {
3201            pg_type,
3202            variant: variant.to_string(),
3203        })
3204    }
3205
3206    /// The row source a name reads from: the binding it is bound to, if any,
3207    /// else the type's own table.
3208    fn row_source_table(&self, name: &str, td: &TypeDescriptor) -> String {
3209        if let Some(cte) = self.for_var_ctes.get(name) {
3210            return self.cte_source_table(cte);
3211        }
3212        if self.cte_object_type(name).is_some() {
3213            return self.cte_source_table(&self.cte_sql_name(name));
3214        }
3215        td.table.clone()
3216    }
3217
3218    /// The type a path's root names — a real type, or the object type a
3219    /// `with` binding stands for.
3220    ///
3221    /// A binding is a row source just like a type name, so anything that
3222    /// re-resolves a root after `compile_path_select` has already accepted it
3223    /// has to look it up the same way, or a bound alias reads as an unknown
3224    /// type.
3225    fn resolve_path_root(&self, root_name: &str) -> Result<&'a TypeDescriptor, PyQLError> {
3226        if let Some(qualified) = self.for_var_types.get(root_name) {
3227            return self.resolve_type(qualified);
3228        }
3229        match self.cte_types.get(root_name).filter(|t| t.contains("::")).cloned() {
3230            Some(bound) => self.resolve_type(&bound),
3231            None => self.resolve_type(root_name),
3232        }
3233    }
3234
3235    fn find_poly_implementors(&self, iface_qname: &str) -> Vec<IrPolyImplementor> {
3236        self.schema
3237            .types
3238            .iter()
3239            .filter(|t| !t.abstract_ && Self::is_or_implements(t, iface_qname))
3240            .map(|t| IrPolyImplementor {
3241                type_name: format!("{}::{}", t.module, t.name),
3242                table: t.table.clone(),
3243                module: t.module.clone(),
3244            })
3245            .collect()
3246    }
3247
3248    fn resolve_property<'t>(td: &'t TypeDescriptor, name: &str) -> Option<&'t PropertyDescriptor> {
3249        td.properties.iter().find(|p| p.name == name)
3250    }
3251
3252    fn resolve_link<'t>(td: &'t TypeDescriptor, name: &str) -> Option<&'t LinkDescriptor> {
3253        td.links.iter().find(|l| l.name == name)
3254    }
3255
3256    fn resolve_multilink<'t>(td: &'t TypeDescriptor, name: &str) -> Option<&'t MultiLinkDescriptor> {
3257        td.multilinks.iter().find(|m| m.name == name)
3258    }
3259
3260    /// `@prop` read off the junction row of the multi-link currently in
3261    /// scope (`link_prop_scope`). Only its own modifiers put one there —
3262    /// anywhere else there is no junction row to read.
3263    fn compile_link_prop_ref(&mut self, prop_name: &str) -> Result<IrExpr, PyQLError> {
3264        let Some(scope) = self.link_prop_scope.last().cloned() else {
3265            return Err(self.type_err(&format!(
3266                "'@{prop_name}' is a link property, so it is only valid in the modifiers or shape of the \
3267                 link it belongs to, e.g. 'locators: {{ … }} filter @{prop_name}'"
3268            )));
3269        };
3270        let Some((through_qname, junction_alias)) = scope else {
3271            return Err(self.type_err(&format!(
3272                "this link has no link properties, so '@{prop_name}' cannot be read — \
3273                 declare the link with a `Through[...]` type to give it some"
3274            )));
3275        };
3276        let through_td = self.resolve_type(&through_qname)?;
3277        let Some(prop) = Self::resolve_property(through_td, prop_name) else {
3278            return Err(self.field_err(prop_name, &through_qname));
3279        };
3280        Ok(IrExpr::ColumnRef {
3281            alias: junction_alias,
3282            column: prop.name.clone(),
3283            pg_type: prop.pg_type.clone(),
3284        })
3285    }
3286
3287    /// Whether a link declared with `target` reaches the type named
3288    /// `current_qname` — directly, or because that type implements the
3289    /// interface the link points at. A link to an interface accepts every
3290    /// implementor, so a backlink from one is exactly as valid as a backlink
3291    /// from the interface itself.
3292    fn link_target_reaches(&self, target: &str, current_qname: &str) -> bool {
3293        if target == current_qname {
3294            return true;
3295        }
3296        self.resolve_type(current_qname)
3297            .is_ok_and(|td| td.interfaces.iter().any(|i| i == target))
3298    }
3299
3300    /// A computed pointer visible on `td` — its own, or one declared on an
3301    /// interface it implements. An interface's computeds are not copied into
3302    /// its implementors the way its stored pointers are, so without the
3303    /// second lookup `.account.tier` reported that `tier` was missing while
3304    /// suggesting `tier` (the suggester already searched interfaces).
3305    fn resolve_computed(&self, td: &TypeDescriptor, name: &str) -> Option<crate::schema::ComputedDescriptor> {
3306        if let Some(cd) = td.computed.iter().find(|c| c.name == name) {
3307            return Some(cd.clone());
3308        }
3309        td.interfaces
3310            .iter()
3311            .filter_map(|iface| self.resolve_type(iface).ok())
3312            .find_map(|itd| itd.computed.iter().find(|c| c.name == name))
3313            .cloned()
3314    }
3315
3316    // ── Statement dispatch ────────────────────────────────────────────────────────
3317
3318    fn compile_stmt(&mut self, stmt: &Stmt) -> Result<IrStmt, PyQLError> {
3319        match stmt {
3320            Stmt::Select(s) => {
3321                let (distinct, result) = match &s.result {
3322                    Expr::UnaryOp(u) if u.op == ast::UnaryOpKind::Distinct => (true, &u.operand),
3323                    // `detached` marks this select as independent of the one
3324                    // enclosing it. At the top level that is already true, but
3325                    // for a select nested in a filter it is the whole point:
3326                    // it is what lets `select X filter not exists (select
3327                    // detached X filter .k = X.k)` mean "no *other* X", rather
3328                    // than comparing the inner row with itself. Stripping the
3329                    // wrapper outright made that anti-join a tautology
3330                    // (`"t1"."label" = "t1"."label"`) with no error.
3331                    Expr::Detached(inner) => {
3332                        self.pending_detached = true;
3333                        (false, inner.as_ref())
3334                    }
3335                    other => (false, other),
3336                };
3337
3338                // `select assert_distinct(f(…)) { … }` — the function's rows,
3339                // with the assert run once over all of them: the select's own
3340                // filter narrows what comes back, not what is checked.
3341                if let Expr::Shape(sh) = result
3342                    && let Some(Expr::FunctionCall(assert)) = &sh.expr
3343                    && assert.module.as_deref().is_none_or(|m| m == "std")
3344                    && matches!(assert.name.as_str(), "assert_exists" | "assert_distinct")
3345                    && let [Expr::FunctionCall(inner)] = assert.args.as_slice()
3346                    && let Some(mut fs) = self.try_compile_fn_object_select(inner, &sh.elements, s, distinct)?
3347                {
3348                    let message = self.assert_message(assert, None)?;
3349                    let unchecked = ast::SelectStmt {
3350                        result: Expr::FunctionCall(inner.clone()),
3351                        filter: None,
3352                        order_by: vec![],
3353                        offset: None,
3354                        limit: None,
3355                        lock: None,
3356                    };
3357                    let every_row = self
3358                        .try_compile_fn_object_select(inner, &[], &unchecked, false)?
3359                        .ok_or_else(|| self.type_err("assert subject is not an object-returning function"))?;
3360                    let checked = IrExpr::FunctionCall(IrFunctionCall {
3361                        return_pg_type: None,
3362                        schema: Some("_pylon".to_string()),
3363                        name: assert.name.clone(),
3364                        args: std::iter::once(IrExpr::ArrayFromSelect(Box::new(IrArraySource::StmtColumn {
3365                            stmt: Box::new(IrStmt::FunctionSelect(every_row)),
3366                            column: "id".to_string(),
3367                        })))
3368                        .chain(message)
3369                        .collect(),
3370                        sql_template: None,
3371                    });
3372                    let check = IrExpr::BinOp(Box::new(IrBinOp {
3373                        left: IrExpr::FunctionCall(IrFunctionCall {
3374                            return_pg_type: None,
3375                            schema: None,
3376                            name: "cardinality".to_string(),
3377                            args: vec![checked],
3378                            sql_template: None,
3379                        }),
3380                        op: ast::BinOpKind::Ge,
3381                        right: IrExpr::Literal(IrLiteral::Int(0)),
3382                    }));
3383                    fs.filter = and_conditions(fs.filter, vec![check]);
3384                    return Ok(IrStmt::FunctionSelect(fs));
3385                }
3386                if let Some(flattened) = flatten_shape_subject(result) {
3387                    let mut flat = s.clone();
3388                    flat.result = match distinct {
3389                        true => Expr::UnaryOp(Box::new(ast::UnaryOp {
3390                            op: ast::UnaryOpKind::Distinct,
3391                            operand: flattened,
3392                        })),
3393                        false => flattened,
3394                    };
3395                    return self.compile_stmt(&Stmt::Select(flat));
3396                }
3397                if let Expr::Shape(sh) = result
3398                    && let Some(Expr::SubQuery(inner)) = &sh.expr
3399                    && let Stmt::Group(g) = inner.as_ref()
3400                {
3401                    return self.compile_group_projection(s, g, &sh.elements).map(IrStmt::Group);
3402                }
3403
3404                // `select alias_name [{ shape }]` — inline the alias expression.
3405                if let Some(ir) = self.try_compile_alias_select(s, result, distinct)? {
3406                    return Ok(ir);
3407                }
3408
3409                // `select global name [{ shape }]` — inline the computed expression.
3410                if let Some(ir) = self.try_compile_global_select(s, result, distinct)? {
3411                    return Ok(ir);
3412                }
3413
3414                // `global name.field` / `(select Type filter ...).field` — splice the
3415                // field access onto the inner type-select as an additional path step.
3416                if let Some(ir) = self.try_compile_field_access_select(s, result)? {
3417                    return Ok(ir);
3418                }
3419
3420                // select fn() { shape } — user-defined object-returning function with shape.
3421                if let Expr::Shape(sh) = result
3422                    && let Some(Expr::FunctionCall(fc)) = sh.expr.as_ref()
3423                    && let Some(ir) = self.try_compile_fn_object_select(fc, &sh.elements, s, distinct)?
3424                {
3425                    return Ok(IrStmt::FunctionSelect(ir));
3426                }
3427
3428                // select fn() — bare user-defined object-returning function (no shape).
3429                if let Expr::FunctionCall(fc) = result
3430                    && let Some(ir) = self.try_compile_fn_object_select(fc, &[], s, distinct)?
3431                {
3432                    return Ok(IrStmt::FunctionSelect(ir));
3433                }
3434
3435                // select vector::search(Type, $vec) { object { … }, distance }
3436                if let Expr::Shape(sh) = result
3437                    && let Some(Expr::FunctionCall(fc)) = sh.expr.as_ref()
3438                {
3439                    if let Some(ir) = self.try_compile_vector_search(fc, &sh.elements, s)? {
3440                        return Ok(IrStmt::VectorSearch(ir));
3441                    }
3442                    if let Some(ir) = self.try_compile_fts_search(fc, &sh.elements, s)? {
3443                        return Ok(IrStmt::FtsSearch(ir));
3444                    }
3445                }
3446                // bare vector::search / fts::search without shape
3447                if let Expr::FunctionCall(fc) = result {
3448                    if let Some(ir) = self.try_compile_vector_search(fc, &[], s)? {
3449                        return Ok(IrStmt::VectorSearch(ir));
3450                    }
3451                    if let Some(ir) = self.try_compile_fts_search(fc, &[], s)? {
3452                        return Ok(IrStmt::FtsSearch(ir));
3453                    }
3454                }
3455
3456                // (<Module::Type>expr) { shape } — parenthesised id-lookup with shape.
3457                // The parens are stripped by the parser, leaving Shape { expr: TypeCast }.
3458                // Only meaningful for a named schema type — a structural tuple cast
3459                // never denotes an object-type lookup.
3460                if let Expr::Shape(sh) = result
3461                    && let Some(Expr::TypeCast(tc)) = sh.expr.as_ref()
3462                    && let Some((module, name)) = tc.ty.as_named()
3463                    && module
3464                        .map(|m| !["std", "cal", "math", "sys", "pgvector", "crypto", "postgis"].contains(&m))
3465                        .unwrap_or(false)
3466                {
3467                    let id_filter = Expr::BinOp(Box::new(ast::BinOp {
3468                        left: Expr::Path(ast::Path::relative("id")),
3469                        op: ast::BinOpKind::Eq,
3470                        right: tc.expr.clone(),
3471                    }));
3472                    let merged_filter = match &s.filter {
3473                        None => Some(id_filter),
3474                        Some(existing) => Some(Expr::BinOp(Box::new(ast::BinOp {
3475                            left: id_filter,
3476                            op: ast::BinOpKind::And,
3477                            right: existing.clone(),
3478                        }))),
3479                    };
3480                    // Keep the module qualification so an unknown
3481                    // target reports its full name, not a bare one.
3482                    let qname = match module {
3483                        Some(m) => format!("{}::{}", m, name),
3484                        None => name.to_string(),
3485                    };
3486                    let synthetic = ast::SelectStmt {
3487                        result: Expr::Shape(Box::new(ast::ShapeExpr {
3488                            expr: Some(Expr::Path(ast::Path::absolute(&qname))),
3489                            elements: sh.elements.clone(),
3490                            marker_offset: None,
3491                        })),
3492                        filter: merged_filter,
3493                        order_by: s.order_by.clone(),
3494                        offset: s.offset.clone(),
3495                        limit: s.limit.clone(),
3496                        lock: s.lock.clone(),
3497                    };
3498                    return self
3499                        .compile_select(&synthetic, &synthetic.result, distinct)
3500                        .map(IrStmt::Select);
3501                }
3502                // <Module::Type>expr — schema object lookup by id, or a scalar cast
3503                // (enum, registered named tuple, or a bare structural `tuple<...>`).
3504                // Stdlib modules are handled by compile_free_expr; only user schema
3505                // modules (or a structural tuple, which has no module at all) route here.
3506                const STDLIB_MODULES: &[&str] = &["std", "cal", "math", "sys", "pgvector", "crypto", "postgis"];
3507                if let Expr::TypeCast(tc) = result {
3508                    // A structural tuple cast is always a plain scalar (jsonb) cast —
3509                    // never an object-type lookup — so it's handled directly, before
3510                    // any of the qname-based (named-type-only) checks below.
3511                    if matches!(&tc.ty, ast::TypeExpr::Tuple { .. }) {
3512                        return self.scalar_cast_free_select(tc, "jsonb".to_string(), distinct);
3513                    }
3514                    if let Some((module, name)) = tc.ty.as_named() {
3515                        let qname = match module {
3516                            Some(m) => format!("{}::{}", m, name),
3517                            None => name.to_string(),
3518                        };
3519                        if let Some(ed) = self.resolve_enum(&qname) {
3520                            let pg_type = format!("{}.\"{}\"", crate::sql::pg_schema_str(&ed.module), ed.name);
3521                            return self.scalar_cast_free_select(tc, pg_type, distinct);
3522                        }
3523                        if self.resolve_named_tuple(&qname).is_some() {
3524                            return self.scalar_cast_free_select(tc, "jsonb".to_string(), distinct);
3525                        }
3526                        if let Some(sd) = self.resolve_scalar(&qname) {
3527                            let pg_type = format!("{}.\"{}\"", crate::sql::pg_schema_str(&sd.module), sd.name);
3528                            return self.scalar_cast_free_select(tc, pg_type, distinct);
3529                        }
3530                        if module.map(|m| !STDLIB_MODULES.contains(&m)).unwrap_or(false) {
3531                            return self.compile_schema_cast_select(s, tc).map(IrStmt::Select);
3532                        }
3533                    }
3534                }
3535                // Path traversal: `select TypeName.link.prop` or `select TypeName.link { shape }`.
3536                if let Expr::Path(p) = result {
3537                    if !p.partial
3538                        && p.steps.len() == 2
3539                        && let [ast::PathStep::Name(type_ref), ast::PathStep::Name(variant)] = p.steps.as_slice()
3540                        && self.resolve_enum(type_ref).is_some()
3541                    {
3542                        let expr = self.compile_enum_access(type_ref, variant)?;
3543                        return Ok(IrStmt::Select(IrSelect {
3544                            rows: vec![IrRowSource::Free(IrFreeExpr::Scalar(expr))],
3545                            filter: None,
3546                            order_by: vec![],
3547                            offset: None,
3548                            limit: None,
3549                            distinct,
3550                            dml_source: None,
3551                            polymorphic: false,
3552                            poly_implementors: vec![],
3553                            poly_columns: vec![],
3554                            lock: None,
3555                        }));
3556                    }
3557                    // `root.field1.field2...` where `root` is a WITH-bound
3558                    // free object (not a real/CTE-bound schema type) —
3559                    // resolve to a field reference (possibly chained through
3560                    // nested free objects) instead of falling into
3561                    // compile_path_select, which only knows schema paths.
3562                    if let Some(resolved) = self.resolve_cte_path(p) {
3563                        let expr = resolved?;
3564                        return Ok(IrStmt::Select(IrSelect {
3565                            rows: vec![IrRowSource::Free(IrFreeExpr::Scalar(expr))],
3566                            filter: None,
3567                            order_by: vec![],
3568                            offset: None,
3569                            limit: None,
3570                            distinct,
3571                            dml_source: None,
3572                            polymorphic: false,
3573                            poly_implementors: vec![],
3574                            poly_columns: vec![],
3575                            lock: None,
3576                        }));
3577                    }
3578                    if !p.partial && p.steps.len() > 1 {
3579                        return self.compile_path_select(s, p, &[], distinct).map(IrStmt::PathSelect);
3580                    }
3581                }
3582                if let Expr::Shape(sh) = result
3583                    && let Some(Expr::Path(p)) = sh.expr.as_ref()
3584                    && !p.partial
3585                    && p.steps.len() > 1
3586                {
3587                    return self
3588                        .compile_path_select(s, p, &sh.elements, distinct)
3589                        .map(IrStmt::PathSelect);
3590                }
3591                // assert_exists/assert_distinct with SubQuery arg → set-returning assert
3592                if let Expr::FunctionCall(f) = result
3593                    && (f.module.is_none() || f.module.as_deref() == Some("std"))
3594                    && matches!(f.name.as_str(), "assert_exists" | "assert_distinct")
3595                    && !f.args.is_empty()
3596                    && let Expr::SubQuery(inner_stmt) = &f.args[0]
3597                {
3598                    let offset = s.offset.as_ref().map(|e| self.compile_free_expr(e)).transpose()?;
3599                    let limit = s.limit.as_ref().map(|e| self.compile_free_expr(e)).transpose()?;
3600                    let message = self.assert_message(f, None)?;
3601                    // A set of objects has to come back as rows, not as the
3602                    // bare ids the array form carries — so the assert vets the
3603                    // ids and the type's own rows are selected by them.
3604                    if let Ok(type_name) = self.dml_subject_type(inner_stmt)
3605                        && let Ok(td) = self.resolve_type(&type_name)
3606                    {
3607                        let td_module = td.module.clone();
3608                        let td_name = td.name.clone();
3609                        let td_table = td.table.clone();
3610                        let pk = td
3611                            .properties
3612                            .iter()
3613                            .find(|p| p.is_pk)
3614                            .map(|p| (p.name.clone(), p.pg_type.clone()))
3615                            .unwrap_or_else(|| ("id".to_string(), "uuid".to_string()));
3616                        let inner_ir = self.compile_stmt(inner_stmt)?;
3617                        let alias = self.fresh_alias();
3618                        let vetted = IrExpr::FunctionCall(IrFunctionCall {
3619                            return_pg_type: None,
3620                            schema: Some("_pylon".to_string()),
3621                            name: f.name.clone(),
3622                            args: std::iter::once(IrExpr::ArrayFromSelect(Box::new(IrArraySource::StmtColumn {
3623                                stmt: Box::new(inner_ir),
3624                                column: pk.0.clone(),
3625                            })))
3626                            .chain(message)
3627                            .collect(),
3628                            sql_template: None,
3629                        });
3630                        let filter = IrExpr::BinOp(Box::new(IrBinOp {
3631                            left: IrExpr::ColumnRef {
3632                                alias: alias.clone(),
3633                                column: pk.0.clone(),
3634                                pg_type: pk.1.clone(),
3635                            },
3636                            op: ast::BinOpKind::In,
3637                            right: vetted,
3638                        }));
3639                        let shape = vec![IrShapePointer::Scalar(IrScalarPointer {
3640                            implicit_id: false,
3641                            marker_offset: None,
3642                            alias: "id".to_string(),
3643                            column: pk.0,
3644                            pg_type: pk.1,
3645                            tuple_shape: None,
3646                        })];
3647                        let source = IrSource {
3648                            poly: None,
3649                            type_name: format!("{td_module}::{td_name}"),
3650                            table: td_table,
3651                            alias,
3652                        };
3653                        let mut select = IrSelect::schema_bound(source, shape, Some(filter));
3654                        select.offset = offset;
3655                        select.limit = limit;
3656                        select.distinct = distinct;
3657                        return Ok(IrStmt::Select(select));
3658                    }
3659                    let inner = self.compile_subquery_to_array_source(inner_stmt)?;
3660                    return Ok(IrStmt::Select(IrSelect {
3661                        rows: vec![IrRowSource::Free(IrFreeExpr::AssertSet {
3662                            fn_name: f.name.clone(),
3663                            inner: Box::new(inner),
3664                            message,
3665                        })],
3666                        filter: None,
3667                        order_by: vec![],
3668                        offset,
3669                        limit,
3670                        distinct,
3671                        dml_source: None,
3672                        polymorphic: false,
3673                        poly_implementors: vec![],
3674                        poly_columns: vec![],
3675                        lock: None,
3676                    }));
3677                }
3678                // `array_agg(array_unpack(teams.permissions))` — the unpacking
3679                // has to happen in a row source, so the aggregate is left with
3680                // no walk of its own and the select stands free.
3681                if let Some(expr) = self.aggregate_over_unpacked(s, result, distinct)? {
3682                    return Ok(IrStmt::Select(IrSelect {
3683                        rows: vec![IrRowSource::Free(IrFreeExpr::Scalar(expr))],
3684                        filter: None,
3685                        order_by: vec![],
3686                        offset: None,
3687                        limit: None,
3688                        distinct: false,
3689                        dml_source: None,
3690                        polymorphic: false,
3691                        poly_implementors: vec![],
3692                        poly_columns: vec![],
3693                        lock: None,
3694                    }));
3695                }
3696                // `select individuals in accounts` — `in` answers once per
3697                // element of the set on its left, so that set is this
3698                // select's row source and the membership test is what each
3699                // row carries. Read as a plain value the left side collapses
3700                // into a single scalar subquery, which Postgres rejects the
3701                // moment the binding holds more than one row. The comparison
3702                // is on identity, so the walk the source needs is `.id`.
3703                if let Expr::BinOp(b) = result
3704                    && matches!(b.op, ast::BinOpKind::In | ast::BinOpKind::NotIn)
3705                    && let Some(root) = self.object_set_name(&b.left)
3706                {
3707                    let rewritten = Expr::BinOp(Box::new(ast::BinOp {
3708                        left: Expr::Path(ast::Path {
3709                            steps: vec![ast::PathStep::Name(root.clone()), ast::PathStep::Name("id".into())],
3710                            partial: false,
3711                        }),
3712                        op: b.op.clone(),
3713                        right: b.right.clone(),
3714                    }));
3715                    return self
3716                        .compile_expr_as_path_select(s, &rewritten, &root, distinct)
3717                        .map(IrStmt::PathSelect);
3718                }
3719                // Expression containing a type-rooted path: `select fn(TypeName.link.prop, ...)`.
3720                if let Some(root) = self.find_path_root_in_expr(result) {
3721                    return self
3722                        .compile_expr_as_path_select(s, result, &root, distinct)
3723                        .map(IrStmt::PathSelect);
3724                }
3725                // `select TypeName is CheckType` — iterate source type, return bool per row.
3726                if let Expr::TypeIs { expr, ty } = result
3727                    && let Expr::Path(p) = expr.as_ref()
3728                    && !p.partial
3729                    && p.steps.len() == 1
3730                    && let ast::PathStep::Name(src_name) = &p.steps[0]
3731                    && self.resolve_name_ref(src_name, true).is_none()
3732                {
3733                    let src_td = self.resolve_type(src_name)?;
3734                    {
3735                        let src_qname = format!("{}::{}", src_td.module, src_td.name);
3736                        let (ty_module, ty_name) = ty
3737                            .as_named()
3738                            .ok_or_else(|| self.type_err("cannot use IS with a tuple or array type"))?;
3739                        let check_module = ty_module.unwrap_or(&src_td.module);
3740                        let check_name = format!("{}::{}", check_module, ty_name);
3741                        self.resolve_type(&check_name)?;
3742                        let check_qname = check_name;
3743                        let src_table = src_td.table.clone();
3744                        let src_alias = self.fresh_alias();
3745                        let src_td = self.resolve_type(&src_qname)?;
3746                        let poly_implementors = if self.is_polymorphic(src_td) {
3747                            self.find_poly_implementors(&src_qname)
3748                        } else {
3749                            vec![]
3750                        };
3751                        let bool_expr = self.type_check_bool_expr(&src_qname, &check_qname, src_td, &src_alias);
3752                        let ps = IrPathSelect {
3753                            root: IrSource {
3754                                poly: None,
3755                                type_name: src_qname,
3756                                table: src_table,
3757                                alias: src_alias,
3758                            },
3759                            joins: vec![],
3760                            result: IrPathResult::Scalar(bool_expr, None),
3761                            filter: s
3762                                .filter
3763                                .as_ref()
3764                                .map(|_| Err(self.type_err("FILTER is not supported on type-is SELECT")))
3765                                .transpose()?,
3766                            order_by: vec![],
3767                            offset: None,
3768                            limit: None,
3769                            distinct,
3770                            poly_implementors,
3771                        };
3772                        return Ok(IrStmt::PathSelect(ps));
3773                    }
3774                }
3775                if let Expr::Union(a, b) = result
3776                    && !distinct
3777                    && s.filter.is_none()
3778                    && s.order_by.is_empty()
3779                    && s.offset.is_none()
3780                    && s.limit.is_none()
3781                    && (self.is_scalar_walk(a) || self.is_scalar_walk(b))
3782                {
3783                    return self.compile_scalar_union(result);
3784                }
3785                // `select (l.addon { c := … }).c` — the values, one row each.
3786                if !distinct
3787                    && s.filter.is_none()
3788                    && s.order_by.is_empty()
3789                    && s.offset.is_none()
3790                    && s.limit.is_none()
3791                    && let Some(ps) = self.compile_shape_field_select(result, None)?
3792                {
3793                    return Ok(IrStmt::PathSelect(ps));
3794                }
3795                // `select (update T … ).link { … }` — the subject walks off a
3796                // mutation. Hoist the mutation into the statement's own WITH
3797                // and re-root the walk at that binding, which is the
3798                // `with a := (update …) select a.link { … }` spelling that
3799                // already compiles. A walk off a plain sub-select needs none
3800                // of this.
3801                if let Expr::Shape(sh) = result
3802                    && let Some(subject) = sh.expr.as_ref()
3803                    && !matches!(subject, Expr::Path(_))
3804                {
3805                    let (base, fields) = Self::peel_field_access_chain(subject);
3806                    if let Expr::SubQuery(inner_stmt) = base
3807                        && !fields.is_empty()
3808                        && matches!(inner_stmt.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_))
3809                    {
3810                        let inner = self.compile_stmt(inner_stmt)?;
3811                        let cte_name = self.fresh_nested_cte_name();
3812                        // The walk may read a junction this very mutation
3813                        // appended to; those rows live only in the append CTE.
3814                        let overrides = Self::junction_overrides_for(&inner, &cte_name);
3815                        let type_name = self.register_cte(&cte_name, &inner);
3816                        self.hoisted_ctes.push(IrCteDef {
3817                            name: cte_name.clone(),
3818                            stmt: inner,
3819                            type_name,
3820                            correlated_to: None,
3821                        });
3822                        let mut steps = vec![ast::PathStep::Name(cte_name)];
3823                        steps.extend(fields.into_iter().map(ast::PathStep::Name));
3824                        let rerooted = ast::SelectStmt {
3825                            result: Expr::Shape(Box::new(ast::ShapeExpr {
3826                                expr: Some(Expr::Path(ast::Path { steps, partial: false })),
3827                                elements: sh.elements.clone(),
3828                                marker_offset: None,
3829                            })),
3830                            filter: s.filter.clone(),
3831                            order_by: s.order_by.clone(),
3832                            offset: s.offset.clone(),
3833                            limit: s.limit.clone(),
3834                            lock: s.lock.clone(),
3835                        };
3836                        let previous = std::mem::replace(&mut self.junction_read_overrides, overrides);
3837                        let compiled = self.compile_stmt(&Stmt::Select(rerooted));
3838                        self.junction_read_overrides = previous;
3839                        return compiled;
3840                    }
3841                }
3842                // `select (select (A union B) { … } limit 1) { … }` — a nested
3843                // select whose own subject is a union has no single type name
3844                // for the outer select to take. Hoisting it into a binding and
3845                // reading that back is the `with l := (select …) select l { … }`
3846                // spelling, which already compiles.
3847                if let Expr::Shape(sh) = result
3848                    && let Some(Expr::SubQuery(inner_stmt)) = sh.expr.as_ref()
3849                    && matches!(inner_stmt.as_ref(), Stmt::Select(_))
3850                    && self.dml_subject_type(inner_stmt).is_err()
3851                {
3852                    let inner = self.compile_stmt(inner_stmt)?;
3853                    let cte_name = self.fresh_nested_cte_name();
3854                    let type_name = self.register_cte(&cte_name, &inner);
3855                    self.hoisted_ctes.push(IrCteDef {
3856                        name: cte_name.clone(),
3857                        stmt: inner,
3858                        type_name,
3859                        correlated_to: None,
3860                    });
3861                    let rerooted = ast::SelectStmt {
3862                        result: Expr::Shape(Box::new(ast::ShapeExpr {
3863                            expr: Some(Expr::Path(ast::Path {
3864                                steps: vec![ast::PathStep::Name(cte_name)],
3865                                partial: false,
3866                            })),
3867                            elements: sh.elements.clone(),
3868                            marker_offset: None,
3869                        })),
3870                        filter: s.filter.clone(),
3871                        order_by: s.order_by.clone(),
3872                        offset: s.offset.clone(),
3873                        limit: s.limit.clone(),
3874                        lock: s.lock.clone(),
3875                    };
3876                    return self.compile_stmt(&Stmt::Select(rerooted));
3877                }
3878                // `select (for … union …)` — the select adds nothing the loop has
3879                // not already produced, so it *is* the loop. Written with a
3880                // shape or modifiers of its own the loop is this select's
3881                // source instead, and a mutating one has to be bound to a CTE
3882                // first — see `bind_mutating_loop_source`.
3883                if let Expr::SubQuery(inner) = result
3884                    && matches!(inner.as_ref(), Stmt::For(_))
3885                    && !distinct
3886                    && s.filter.is_none()
3887                    && s.order_by.is_empty()
3888                    && s.offset.is_none()
3889                    && s.limit.is_none()
3890                {
3891                    return self.compile_stmt(inner);
3892                }
3893                // Catch mixed object/scalar UNION before dispatching further.
3894                self.check_union_type_compat(result)?;
3895                if self.is_free_result(result) {
3896                    self.compile_free_select(s, result, distinct).map(IrStmt::Select)
3897                } else {
3898                    self.compile_select(s, result, distinct).map(IrStmt::Select)
3899                }
3900            }
3901            Stmt::Insert(s) => self.compile_insert(s).map(IrStmt::Insert),
3902            Stmt::Update(s) => self.compile_update(s).map(IrStmt::Update),
3903            Stmt::Delete(s) => self.compile_delete(s).map(IrStmt::Delete),
3904            Stmt::Group(s) => self.compile_group(s).map(IrStmt::Group),
3905            // Nested WITH blocks (e.g. inside a subquery): inline the CTE types
3906            // into the current compiler scope so references resolve correctly.
3907            // The actual CTE SQL is handled at the top-level compile() boundary.
3908            Stmt::With(w) => {
3909                for alias in &w.aliases {
3910                    if self.bind_inline_if_correlated(&alias.name, &alias.expr, None)?
3911                        || self.bind_group(&alias.name, &alias.expr)?
3912                    {
3913                        continue;
3914                    }
3915                    let (ir_inner, correlated_to) = self.compile_binding_in_scope(&alias.expr)?;
3916                    let sql_name = self.claim_cte_sql_name(&alias.name);
3917                    if let Some(slot) = correlated_to.as_deref().and_then(|i| i.strip_prefix("_for_")) {
3918                        self.correlated_bindings.insert(sql_name.clone(), slot.to_string());
3919                    }
3920                    let type_name = self.register_cte(&alias.name, &ir_inner);
3921                    self.hoisted_binding_sources
3922                        .insert(sql_name.clone(), alias.expr.clone());
3923                    self.hoisted_ctes.push(IrCteDef {
3924                        name: sql_name,
3925                        stmt: ir_inner,
3926                        type_name,
3927                        correlated_to,
3928                    });
3929                }
3930                self.compile_stmt(&w.stmt)
3931            }
3932            Stmt::For(f) => match self.compile_group_elements(f)? {
3933                Some(grp) => Ok(IrStmt::Group(grp)),
3934                None => self.compile_for(f).map(IrStmt::For),
3935            },
3936            // `analyze` only changes how the query is *executed* (see
3937            // `analyze.rs`) — the inner statement's IR is identical either
3938            // way, so this layer just unwraps and compiles it normally.
3939            Stmt::Analyze(inner) => self.compile_stmt(inner),
3940        }
3941    }
3942
3943    /// `<Module::Type>expr` in SELECT position is a schema object lookup:
3944    /// select the object whose `id` equals `expr`.  Semantically identical to
3945    /// `SELECT Type FILTER .id = expr` plus any modifiers on the outer SELECT.
3946    fn compile_schema_cast_select(&mut self, sel: &ast::SelectStmt, tc: &ast::TypeCast) -> Result<IrSelect, PyQLError> {
3947        let id_filter = Expr::BinOp(Box::new(ast::BinOp {
3948            left: Expr::Path(ast::Path::relative("id")),
3949            op: ast::BinOpKind::Eq,
3950            right: tc.expr.clone(),
3951        }));
3952        let merged_filter = match &sel.filter {
3953            None => Some(id_filter),
3954            Some(existing) => Some(Expr::BinOp(Box::new(ast::BinOp {
3955                left: id_filter,
3956                op: ast::BinOpKind::And,
3957                right: existing.clone(),
3958            }))),
3959        };
3960        // Callers only reach this function after confirming `tc.ty` is `Named`
3961        // (a structural tuple is never an object-type lookup).
3962        let (module, name) = tc
3963            .ty
3964            .as_named()
3965            .ok_or_else(|| self.type_err("cannot use a tuple or array type as a schema object cast"))?;
3966        // Keep the module qualification in the synthetic path so a genuinely
3967        // unknown target (neither an object type, enum, scalar, nor named
3968        // tuple) reports its full name via `resolve_type`'s own error,
3969        // instead of silently dropping the module and reporting a bare name.
3970        let qname = match module {
3971            Some(m) => format!("{}::{}", m, name),
3972            None => name.to_string(),
3973        };
3974        let synthetic = ast::SelectStmt {
3975            result: Expr::Path(ast::Path::absolute(&qname)),
3976            filter: merged_filter,
3977            order_by: sel.order_by.clone(),
3978            offset: sel.offset.clone(),
3979            limit: sel.limit.clone(),
3980            lock: sel.lock.clone(),
3981        };
3982        self.compile_select(&synthetic, &synthetic.result, false)
3983    }
3984
3985    // ── PATH SELECT ───────────────────────────────────────────────────────────────
3986
3987    /// `select TypeName.link.prop` / `select TypeName.link { shape }`.
3988    /// Walks the path, building JOIN steps, then projects the final pointer or object.
3989    /// `compile_shape` with the type the walk landed on anchored, so a
3990    /// relative path written inside the shape — a `with` binding's value, a
3991    /// nested sub-select's filter — has an enclosing object to resolve
3992    /// against. A plain `select` pushes the same anchor for its own shape.
3993    fn compile_shape_anchored(
3994        &mut self,
3995        elements: &[ShapeElement],
3996        td: &'a TypeDescriptor,
3997        alias: &str,
3998        module: &str,
3999    ) -> Result<Vec<IrShapePointer>, PyQLError> {
4000        self.anchors.push(SelectAnchor {
4001            type_name: td.name.clone(),
4002            qualified: format!("{}::{}", td.module, td.name),
4003            alias: alias.to_string(),
4004            detached: false,
4005            declared_on: None,
4006        });
4007        let result = self.compile_shape(elements, td, alias, module);
4008        self.anchors.pop();
4009        result
4010    }
4011
4012    fn compile_path_select(
4013        &mut self,
4014        sel: &ast::SelectStmt,
4015        path: &ast::Path,
4016        shape_elements: &[ShapeElement],
4017        distinct: bool,
4018    ) -> Result<IrPathSelect, PyQLError> {
4019        self.compile_path_select_with_tail(sel, path, shape_elements, distinct, 0, &[])
4020    }
4021
4022    /// `compile_path_select` where the last `tail` steps were appended by a
4023    /// projection off a sub-select — `(select … limit 1).plan.tier` — so
4024    /// `sel`'s own modifiers belong to the step before them, not to the end
4025    /// of the path.
4026    fn compile_path_select_with_tail(
4027        &mut self,
4028        sel: &ast::SelectStmt,
4029        path: &ast::Path,
4030        shape_elements: &[ShapeElement],
4031        distinct: bool,
4032        tail: usize,
4033        tail_sorts: &[ast::SortExpr],
4034    ) -> Result<IrPathSelect, PyQLError> {
4035        let outer_anchor = self.modifier_anchor.take();
4036        // Swapped rather than assigned, so a path select compiled inside this
4037        // one gets its own (empty) list and hands these back untouched.
4038        let outer_sorts = std::mem::replace(&mut self.tail_sorts, tail_sorts.to_vec());
4039        let mut result = self.compile_path_select_inner(sel, path, shape_elements, distinct, tail);
4040        self.tail_sorts = outer_sorts;
4041        self.modifier_anchor = outer_anchor;
4042        if let Ok(path_select) = &mut result {
4043            self.resolve_join_fanouts(path_select);
4044        }
4045        result
4046    }
4047
4048    fn compile_path_select_inner(
4049        &mut self,
4050        sel: &ast::SelectStmt,
4051        path: &ast::Path,
4052        shape_elements: &[ShapeElement],
4053        distinct: bool,
4054        mut tail: usize,
4055    ) -> Result<IrPathSelect, PyQLError> {
4056        use ast::PathStep;
4057
4058        let root_name = match &path.steps[0] {
4059            PathStep::Name(n) => n.as_str(),
4060            _ => return Err(self.type_err("path traversal must start with a type name")),
4061        };
4062        // A WITH-block CTE bound to an object type (e.g. `with user :=
4063        // (select global current_user) select user.gender`) can be
4064        // traversed just like a real type name — resolve its underlying
4065        // type and source from the `@cte:` sentinel table (the same
4066        // mechanism `compile_select`'s bare-CTE-object-select case uses)
4067        // instead of failing with "unknown type '{root_name}'".
4068        let root_td = self.resolve_path_root(root_name)?;
4069        let root_alias = self.fresh_alias();
4070        let root = IrSource {
4071            poly: None,
4072            type_name: format!("{}::{}", root_td.module, root_td.name),
4073            table: self.row_source_table(root_name, root_td),
4074            alias: root_alias.clone(),
4075        };
4076        // `for c in (select Person) union (select c.name)` — the loop variable
4077        // holds the row's key, so the walk starts from that row rather than
4078        // from the whole table.
4079        let for_var_root = self.for_var_types.contains_key(root_name).then(|| {
4080            IrExpr::BinOp(Box::new(IrBinOp {
4081                left: IrExpr::ColumnRef {
4082                    alias: root_alias.clone(),
4083                    column: "id".to_string(),
4084                    pg_type: "uuid".to_string(),
4085                },
4086                op: ast::BinOpKind::Eq,
4087                right: self.for_var_ref(root_name),
4088            }))
4089        });
4090
4091        let mut joins: Vec<IrPathJoin> = vec![];
4092        let mut current_td = root_td;
4093        let mut current_alias = root_alias;
4094
4095        // Owned rather than borrowed: traversing *through* a computed
4096        // pointer splices that computed's own path in place of the step
4097        // naming it (see the computed branch at the bottom of the loop).
4098        let mut steps: Vec<PathStep> = path.steps[1..].to_vec();
4099        // `(step index, filter)` a spliced computed contributed, to compile
4100        // against the type that step lands on once the loop reaches it.
4101        let mut pending_filters: Vec<(usize, Expr)> = vec![];
4102        let mut extra_conditions: Vec<IrExpr> = for_var_root.into_iter().collect();
4103        // The junction the traversal last crossed, for a bare `@prop` in the
4104        // select's own modifiers.
4105        let mut junction_scope: Option<(String, String)> = None;
4106        let mut splices = 0usize;
4107        let mut idx = 0;
4108        if tail > 0 && tail >= steps.len() {
4109            self.modifier_anchor = Some((
4110                format!("{}::{}", current_td.module, current_td.name),
4111                current_alias.clone(),
4112            ));
4113        }
4114        while idx < steps.len() {
4115            let owned_step = steps[idx].clone();
4116            let step = &owned_step;
4117            let n_steps = steps.len();
4118            let is_last = |extra: usize| idx + extra == n_steps - 1;
4119            if tail > 0 && idx == n_steps - tail {
4120                self.modifier_anchor = Some((
4121                    format!("{}::{}", current_td.module, current_td.name),
4122                    current_alias.clone(),
4123                ));
4124            }
4125
4126            // A spliced computed's own filter belongs to the step it landed
4127            // on, which is the one just processed.
4128            while let Some(pos) = pending_filters.iter().position(|(i, _)| *i + 1 == idx) {
4129                let (_, f) = pending_filters.remove(pos);
4130                let cond = self.as_condition(|c| c.compile_expr(&f, current_td, &current_alias))?;
4131                extra_conditions.push(cond);
4132            }
4133
4134            // Type intersection standalone (not after backlink): narrows current_td.
4135            if let PathStep::TypeIntersection(type_ref) = step {
4136                let type_name = match &type_ref.module {
4137                    Some(m) => format!("{}::{}", m, type_ref.name),
4138                    None => type_ref.name.clone(),
4139                };
4140                let narrowed = self.resolve_type(&type_name)?;
4141                // Narrowing to a type with a relation of its own moves to that
4142                // relation, joined on the shared id: an interface's view has
4143                // none of its implementors' own columns, and two unrelated
4144                // types share no ids at all, so the join finds nothing — which
4145                // is exactly what an impossible intersection yields. A mixin
4146                // has no relation; its columns are already on the current row.
4147                let narrowed_has_relation = !Self::backs_no_relation(narrowed);
4148                if narrowed_has_relation && narrowed.table != current_td.table {
4149                    let target_alias = self.fresh_alias();
4150                    let target = IrSource {
4151                        poly: None,
4152                        type_name: format!("{}::{}", narrowed.module, narrowed.name),
4153                        table: narrowed.table.clone(),
4154                        alias: target_alias.clone(),
4155                    };
4156                    joins.push(IrPathJoin::Single {
4157                        source_alias: current_alias.clone(),
4158                        fk_col: "id".to_string(),
4159                        target,
4160                    });
4161                    current_alias = target_alias;
4162                }
4163                current_td = narrowed;
4164                idx += 1;
4165                continue;
4166            }
4167
4168            // Backlink: .<link_name — optionally followed by [is OwnerType].
4169            if let PathStep::Backlink(link_name) = step {
4170                // Peek ahead: if the next step is [is Type], use it to identify the owner.
4171                let owner_hint = steps.get(idx + 1).and_then(|s| {
4172                    if let PathStep::TypeIntersection(tr) = s {
4173                        Some(tr.clone())
4174                    } else {
4175                        None
4176                    }
4177                });
4178                let consumed_extra = if owner_hint.is_some() { 1 } else { 0 };
4179
4180                let owner_td: &TypeDescriptor = if let Some(ref tr) = owner_hint {
4181                    let type_name = match &tr.module {
4182                        Some(m) => format!("{}::{}", m, tr.name),
4183                        None => tr.name.clone(),
4184                    };
4185                    let td = self.resolve_type(&type_name)?;
4186                    let current_qname = format!("{}::{}", current_td.module, current_td.name);
4187                    let link_targets_current =
4188                        td.links
4189                            .iter()
4190                            .any(|l| l.name == *link_name && self.link_target_reaches(&l.target, &current_qname))
4191                            || td.multilinks.iter().any(|ml| {
4192                                ml.name == *link_name && self.link_target_reaches(&ml.target, &current_qname)
4193                            });
4194                    if link_targets_current {
4195                        td
4196                    } else {
4197                        // `.<memberships[is account::Account]` — declared further
4198                        // down, by the one type under the narrowing that has it.
4199                        let narrowed = format!("{}::{}", td.module, td.name);
4200                        let declaring: Vec<&'a TypeDescriptor> = self
4201                            .schema
4202                            .types
4203                            .iter()
4204                            .filter(|t| !t.abstract_ && Self::is_or_implements(t, &narrowed))
4205                            .filter(|t| self.declares_backlink(t, link_name, &current_qname))
4206                            .collect();
4207                        match Self::without_inherited_owners(declaring).as_slice() {
4208                            [only] => only,
4209                            _ => {
4210                                return Err(self.type_err(&format!(
4211                                    "link '{}::{}' does not target '{}'; backlink is not valid here",
4212                                    type_name, link_name, current_qname,
4213                                )));
4214                            }
4215                        }
4216                    }
4217                } else {
4218                    // No hint — search for any type whose link/multilink targets current_td.
4219                    let current_qname = format!("{}::{}", current_td.module, current_td.name);
4220                    self.schema
4221                        .types
4222                        .iter()
4223                        // The base, not a subtype inheriting the link from it.
4224                        .filter(|t| {
4225                            t.bases.is_empty()
4226                                || !t.bases.iter().any(|base| {
4227                                    self.resolve_type(base).is_ok_and(|b| {
4228                                        b.links.iter().any(|l| l.name == *link_name)
4229                                            || b.multilinks.iter().any(|ml| ml.name == *link_name)
4230                                    })
4231                                })
4232                        })
4233                        .find(|t| {
4234                            t.links
4235                                .iter()
4236                                .any(|l| l.name == *link_name && self.link_target_reaches(&l.target, &current_qname))
4237                                || t.multilinks.iter().any(|ml| {
4238                                    ml.name == *link_name && self.link_target_reaches(&ml.target, &current_qname)
4239                                })
4240                        })
4241                        .ok_or_else(|| {
4242                            self.type_err(&format!(
4243                                "no type has a link '{}' targeting '{}'",
4244                                link_name, current_qname,
4245                            ))
4246                        })?
4247                };
4248
4249                let target_alias = self.fresh_alias();
4250                let target = IrSource {
4251                    poly: None,
4252                    type_name: format!("{}::{}", owner_td.module, owner_td.name),
4253                    table: owner_td.table.clone(),
4254                    alias: target_alias.clone(),
4255                };
4256
4257                // Determine if the link is single (FK), junction-backed
4258                // single (same shape as a backlinked multi-link), or multi
4259                // (junction).
4260                if let Some(l) = owner_td.links.iter().find(|l| l.name == *link_name) {
4261                    if l.is_junction_backed() {
4262                        let junction_alias = self.fresh_alias();
4263                        let (junction_table, module, owner_col, current_col, _) =
4264                            self.link_junction_info(owner_td, l)?;
4265                        joins.push(IrPathJoin::BacklinkMulti {
4266                            source_alias: current_alias.clone(),
4267                            junction_alias,
4268                            junction_table,
4269                            module,
4270                            owner_col,
4271                            current_col,
4272                            target,
4273                        });
4274                    } else {
4275                        joins.push(IrPathJoin::BacklinkSingle {
4276                            source_alias: current_alias.clone(),
4277                            fk_col: format!("{}_id", link_name),
4278                            target,
4279                        });
4280                    }
4281                } else {
4282                    let ml = owner_td
4283                        .multilinks
4284                        .iter()
4285                        .find(|ml| ml.name == *link_name)
4286                        .unwrap()
4287                        .clone();
4288                    let junction_alias = self.fresh_alias();
4289                    let (junction_table, module, _, _, _) = self.multilink_junction_info(owner_td, &ml)?;
4290                    joins.push(IrPathJoin::BacklinkMulti {
4291                        source_alias: current_alias.clone(),
4292                        junction_alias,
4293                        junction_table,
4294                        module,
4295                        owner_col: "source".to_string(),
4296                        current_col: "target".to_string(),
4297                        target,
4298                    });
4299                }
4300
4301                if is_last(consumed_extra) {
4302                    let shape =
4303                        self.compile_shape_anchored(shape_elements, owner_td, &target_alias, &owner_td.module.clone())?;
4304                    let result = IrPathResult::Object {
4305                        alias: target_alias.clone(),
4306                        type_name: format!("{}::{}", owner_td.module, owner_td.name),
4307                        shape,
4308                    };
4309                    let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4310                        sel,
4311                        owner_td,
4312                        &target_alias,
4313                        junction_scope.clone(),
4314                        shape_elements,
4315                    )?;
4316                    return Ok(IrPathSelect {
4317                        root,
4318                        joins,
4319                        result,
4320                        filter: and_conditions(filter, extra_conditions),
4321                        order_by,
4322                        offset,
4323                        limit,
4324                        distinct,
4325                        poly_implementors: vec![],
4326                    });
4327                }
4328                current_td = owner_td;
4329                current_alias = target_alias;
4330                idx += 1 + consumed_extra;
4331                continue;
4332            }
4333
4334            let step_name = match step {
4335                PathStep::Name(n) => n.as_str(),
4336                _ => return Err(self.type_err("only name steps are supported in path traversal")),
4337            };
4338
4339            // `__type__` is a virtual scalar property holding the
4340            // fully-qualified type name: a real discriminator column on a
4341            // polymorphic (interface) source, a constant on a concrete one.
4342            if step_name == "__type__" {
4343                if !is_last(0) {
4344                    return Err(
4345                        self.type_err("'__type__' is the type's name, not an object — it cannot be traversed further")
4346                    );
4347                }
4348                let polymorphic = self.is_polymorphic(current_td);
4349                let expr = if polymorphic {
4350                    IrExpr::ColumnRef {
4351                        alias: current_alias.clone(),
4352                        column: "__type__".to_string(),
4353                        pg_type: "text".to_string(),
4354                    }
4355                } else {
4356                    IrExpr::Literal(IrLiteral::Str(format!("{}::{}", current_td.module, current_td.name)))
4357                };
4358                // A CTE-backed root already carries the discriminator column
4359                // from the binding's own polymorphic expansion; re-expanding it
4360                // here would read the base type again and drop the binding's
4361                // filter.
4362                let poly_implementors = if polymorphic && joins.is_empty() && !root.table.starts_with("@cte:") {
4363                    self.find_poly_implementors(&root.type_name.clone())
4364                } else {
4365                    vec![]
4366                };
4367                let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4368                    sel,
4369                    current_td,
4370                    &current_alias,
4371                    junction_scope.clone(),
4372                    shape_elements,
4373                )?;
4374                return Ok(IrPathSelect {
4375                    root,
4376                    joins,
4377                    result: IrPathResult::Scalar(expr, None),
4378                    filter: and_conditions(filter, extra_conditions),
4379                    order_by,
4380                    offset,
4381                    limit,
4382                    distinct,
4383                    poly_implementors,
4384                });
4385            }
4386
4387            // Check scalar property first.
4388            if let Some(p) = current_td.properties.iter().find(|p| p.name == step_name) {
4389                if !is_last(0) {
4390                    // Named tuple properties (nominal `__nt__:` marker) and structural
4391                    // tuple properties (`tuple_members`) both allow further field
4392                    // access via jsonb operators.
4393                    if p.pg_type.starts_with("__nt__:") || p.tuple_members.is_some() {
4394                        let base = IrExpr::ColumnRef {
4395                            alias: current_alias.clone(),
4396                            column: p.name.clone(),
4397                            pg_type: p.pg_type.clone(),
4398                        };
4399                        let remaining = &steps[idx + 1..];
4400                        let mut ir: IrExpr = base;
4401                        for step in remaining {
4402                            let field = match step {
4403                                ast::PathStep::Name(n) => n.clone(),
4404                                _ => return Err(self.type_err("only field name steps are valid inside a named tuple")),
4405                            };
4406                            ir = IrExpr::JsonbField {
4407                                expr: Box::new(ir),
4408                                field,
4409                            };
4410                        }
4411                        let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4412                            sel,
4413                            current_td,
4414                            &current_alias,
4415                            junction_scope.clone(),
4416                            shape_elements,
4417                        )?;
4418                        return Ok(IrPathSelect {
4419                            root,
4420                            joins,
4421                            result: IrPathResult::Scalar(ir, None),
4422                            filter: and_conditions(filter, extra_conditions),
4423                            order_by,
4424                            offset,
4425                            limit,
4426                            distinct,
4427                            poly_implementors: vec![],
4428                        });
4429                    }
4430                    return Err(self.type_err(&format!(
4431                        "'{step_name}' is a scalar property, not a link — cannot traverse further"
4432                    )));
4433                }
4434                let tuple_shape = self.resolve_property_tuple_shape(p);
4435                let result = IrPathResult::Scalar(
4436                    IrExpr::ColumnRef {
4437                        alias: current_alias.clone(),
4438                        column: p.name.clone(),
4439                        pg_type: p.pg_type.clone(),
4440                    },
4441                    tuple_shape,
4442                );
4443                let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4444                    sel,
4445                    current_td,
4446                    &current_alias,
4447                    junction_scope.clone(),
4448                    shape_elements,
4449                )?;
4450                return Ok(IrPathSelect {
4451                    root,
4452                    joins,
4453                    result,
4454                    filter: and_conditions(filter, extra_conditions),
4455                    order_by,
4456                    offset,
4457                    limit,
4458                    distinct,
4459                    poly_implementors: vec![],
4460                });
4461            }
4462
4463            // Single link.
4464            if let Some(l) = Self::resolve_link(current_td, step_name) {
4465                let target_td = self.resolve_type(&l.target)?;
4466                let target_alias = self.fresh_alias();
4467                let target = IrSource {
4468                    poly: None,
4469                    type_name: format!("{}::{}", target_td.module, target_td.name),
4470                    table: target_td.table.clone(),
4471                    alias: target_alias.clone(),
4472                };
4473                if l.is_junction_backed() {
4474                    // Same join shape a multi-link's own path step uses
4475                    // (D1) — `PRIMARY KEY (source)` on the junction table
4476                    // already guarantees at most one matching row, so no
4477                    // extra cardinality handling is needed here.
4478                    let join = self.build_multilink_join(current_td, &l.name, &l.target, &l.through)?;
4479                    let junction_alias = self.fresh_alias();
4480                    junction_scope = l.through.clone().map(|t| (t, junction_alias.clone()));
4481                    joins.push(IrPathJoin::Multi {
4482                        source_alias: current_alias.clone(),
4483                        junction_alias,
4484                        join,
4485                        target,
4486                    });
4487                } else {
4488                    joins.push(IrPathJoin::Single {
4489                        source_alias: current_alias.clone(),
4490                        fk_col: format!("{}_id", l.name),
4491                        target,
4492                    });
4493                }
4494                if is_last(0) {
4495                    let shape = self.compile_shape_anchored(
4496                        shape_elements,
4497                        target_td,
4498                        &target_alias,
4499                        &target_td.module.clone(),
4500                    )?;
4501                    let result = IrPathResult::Object {
4502                        alias: target_alias.clone(),
4503                        type_name: format!("{}::{}", target_td.module, target_td.name),
4504                        shape,
4505                    };
4506                    let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4507                        sel,
4508                        target_td,
4509                        &target_alias,
4510                        junction_scope.clone(),
4511                        shape_elements,
4512                    )?;
4513                    return Ok(IrPathSelect {
4514                        root,
4515                        joins,
4516                        result,
4517                        filter: and_conditions(filter, extra_conditions),
4518                        order_by,
4519                        offset,
4520                        limit,
4521                        distinct,
4522                        poly_implementors: vec![],
4523                    });
4524                }
4525                current_td = target_td;
4526                current_alias = target_alias;
4527                idx += 1;
4528                continue;
4529            }
4530
4531            // Multi-link.
4532            if let Some(ml) = Self::resolve_multilink(current_td, step_name) {
4533                let target_td = self.resolve_type(&ml.target)?;
4534                let target_alias = self.fresh_alias();
4535                let junction_alias = self.fresh_alias();
4536                // Set only while walking off a mutation this statement made:
4537                // both the junction rows and, when they were inserted here
4538                // too, the targets live in CTEs rather than their tables.
4539                let override_for_step = self
4540                    .junction_read_overrides
4541                    .get(&self.owner_junction(current_td, &ml.name, None))
4542                    .cloned();
4543                let target = IrSource {
4544                    poly: None,
4545                    type_name: format!("{}::{}", target_td.module, target_td.name),
4546                    table: match override_for_step.as_ref().and_then(|o| o.targets.clone()) {
4547                        Some(cte) => cte,
4548                        None => target_td.table.clone(),
4549                    },
4550                    alias: target_alias.clone(),
4551                };
4552                let join_info = if let Some(through_qname) = &ml.through {
4553                    let through_td = self.resolve_type(through_qname)?;
4554                    if through_td.junction {
4555                        // See `junction_info_for`'s doc comment: owner-derived,
4556                        // never `through_td.table` itself.
4557                        IrMultiLinkJoin::Standard {
4558                            junction_table: self.owner_junction(current_td, &ml.name, Some(through_td)),
4559                            module: current_td.module.clone(),
4560                        }
4561                    } else {
4562                        let source_qname = format!("{}::{}", current_td.module, current_td.name);
4563                        let source_col = through_td
4564                            .links
4565                            .iter()
4566                            .find(|l| l.target == source_qname)
4567                            .ok_or_else(|| {
4568                                PyQLError::Type(PyQLTypeError {
4569                                    message: format!("through type {through_qname} has no link to {source_qname}"),
4570                                    position: Position { line: 0, col: 0 },
4571                                })
4572                            })?
4573                            .name
4574                            .clone();
4575                        let target_col = through_td
4576                            .links
4577                            .iter()
4578                            .find(|l| l.target == ml.target && l.name != source_col)
4579                            .or_else(|| through_td.links.iter().find(|l| l.target == ml.target))
4580                            .ok_or_else(|| {
4581                                PyQLError::Type(PyQLTypeError {
4582                                    message: format!(
4583                                        "through type {through_qname} has no link to target {}",
4584                                        ml.target
4585                                    ),
4586                                    position: Position { line: 0, col: 0 },
4587                                })
4588                            })?
4589                            .name
4590                            .clone();
4591                        IrMultiLinkJoin::Through {
4592                            junction_table: through_td.table.clone(),
4593                            module: through_td.module.clone(),
4594                            source_col,
4595                            target_col,
4596                        }
4597                    }
4598                } else {
4599                    let junction_table = self.owner_junction(current_td, &ml.name, None);
4600                    IrMultiLinkJoin::Standard {
4601                        junction_table: match &override_for_step {
4602                            Some(o) => o.junction.clone(),
4603                            None => junction_table,
4604                        },
4605                        module: current_td.module.clone(),
4606                    }
4607                };
4608                junction_scope = ml.through.clone().map(|t| (t, junction_alias.clone()));
4609                joins.push(IrPathJoin::Multi {
4610                    source_alias: current_alias.clone(),
4611                    junction_alias,
4612                    join: join_info,
4613                    target,
4614                });
4615                if is_last(0) {
4616                    let shape = self.compile_shape_anchored(
4617                        shape_elements,
4618                        target_td,
4619                        &target_alias,
4620                        &target_td.module.clone(),
4621                    )?;
4622                    let result = IrPathResult::Object {
4623                        alias: target_alias.clone(),
4624                        type_name: format!("{}::{}", target_td.module, target_td.name),
4625                        shape,
4626                    };
4627                    let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4628                        sel,
4629                        target_td,
4630                        &target_alias,
4631                        junction_scope.clone(),
4632                        shape_elements,
4633                    )?;
4634                    return Ok(IrPathSelect {
4635                        root,
4636                        joins,
4637                        result,
4638                        filter: and_conditions(filter, extra_conditions),
4639                        order_by,
4640                        offset,
4641                        limit,
4642                        distinct,
4643                        poly_implementors: vec![],
4644                    });
4645                }
4646                current_td = target_td;
4647                current_alias = target_alias;
4648                idx += 1;
4649                continue;
4650            }
4651
4652            // A computed pointer declared on the type reached so far (or on
4653            // an interface it implements) — or, at the root only, one the
4654            // shape in scope declared, which is on no type at all:
4655            // `r { revision := … }` read back as `.revision.created_at`.
4656            let declared = match self.resolve_computed(current_td, step_name) {
4657                Some(cd) => Some(crate::parse::parse_pointer_expr(&cd.expression).map_err(PyQLError::Syntax)?),
4658                None if idx == 0 => self
4659                    .active_declared_pointers
4660                    .iter()
4661                    .find(|d| path_leaf(&d.path).is_ok_and(|n| n == step_name))
4662                    .and_then(|d| d.compexpr.clone()),
4663                None => None,
4664            };
4665            if let Some(parsed) = declared {
4666                // `(select .parents … limit 1).parent` reads as the select over
4667                // the walk those two make together.
4668                let rewritten = Self::field_access_over_select(&parsed);
4669                let expr_ast = match &rewritten {
4670                    Some((sel, _)) => Expr::SubQuery(Box::new(Stmt::Select(sel.clone()))),
4671                    None => parsed,
4672                };
4673                let field_steps = rewritten.as_ref().map(|(_, n)| *n).unwrap_or(0);
4674
4675                // Traversing *through* it: a computed has no stored column
4676                // for a join to hang off, but if it just names a link — with
4677                // or without a filter — its own path can take the step's
4678                // place, and its filter rides along on the spliced segment.
4679                // Chaining computeds this way is routine, so the splice
4680                // re-enters the loop and expands again if it lands on
4681                // another one.
4682                // As the last step too, when what it names lands on an object:
4683                // the shape and the select's own modifiers belong to that
4684                // object, and reading it as an expression instead binds them to
4685                // the type that declares the computed.
4686                if let Some((p, _, modifiers)) = Self::pointer_subject(&expr_ast)
4687                    && p.partial
4688                    && !p.steps.is_empty()
4689                    && (!is_last(0)
4690                        || self
4691                            .walk_path_types(current_td, &p.steps, MAX_COMPUTED_SPLICES)
4692                            .1
4693                            .is_some())
4694                {
4695                    // Order/offset/limit of its own pick one row *per source
4696                    // row*, which no plain join expresses — so the computed's
4697                    // own traversal becomes a correlated LATERAL and the path
4698                    // continues from its result.
4699                    if let Some(m) = modifiers
4700                        && (m.limit.is_some() || m.offset.is_some() || !m.order_by.is_empty())
4701                    {
4702                        let mut inner_steps =
4703                            vec![PathStep::Name(format!("{}::{}", current_td.module, current_td.name))];
4704                        inner_steps.extend(p.steps.iter().cloned());
4705                        let inner_path = ast::Path {
4706                            steps: inner_steps,
4707                            partial: false,
4708                        };
4709                        let inner_sel = ast::SelectStmt {
4710                            result: Expr::Path(inner_path.clone()),
4711                            filter: m.filter.clone(),
4712                            order_by: m.order_by.clone(),
4713                            offset: m.offset.clone(),
4714                            limit: m.limit.clone(),
4715                            lock: None,
4716                        };
4717                        let mut inner =
4718                            self.compile_path_select_with_tail(&inner_sel, &inner_path, &[], false, field_steps, &[])?;
4719                        Self::correlate_path_select(&mut inner, &current_alias);
4720                        let IrPathResult::Object { type_name, .. } = &inner.result else {
4721                            return Err(self.type_err(&format!(
4722                                "computed pointer '{step_name}' is a scalar — a path cannot continue through it"
4723                            )));
4724                        };
4725                        let target_td = self.resolve_type(&type_name.clone())?;
4726                        let target_alias = self.fresh_alias();
4727                        let target = IrSource {
4728                            poly: None,
4729                            type_name: format!("{}::{}", target_td.module, target_td.name),
4730                            table: target_td.table.clone(),
4731                            alias: target_alias.clone(),
4732                        };
4733                        joins.push(IrPathJoin::Lateral {
4734                            inner: Box::new(inner),
4735                            target,
4736                        });
4737                        if is_last(0) {
4738                            let module = target_td.module.clone();
4739                            let shape =
4740                                self.compile_shape_anchored(shape_elements, target_td, &target_alias, &module)?;
4741                            let result = IrPathResult::Object {
4742                                alias: target_alias.clone(),
4743                                type_name: format!("{}::{}", target_td.module, target_td.name),
4744                                shape,
4745                            };
4746                            let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4747                                sel,
4748                                target_td,
4749                                &target_alias,
4750                                junction_scope,
4751                                shape_elements,
4752                            )?;
4753                            return Ok(IrPathSelect {
4754                                root,
4755                                joins,
4756                                result,
4757                                filter: and_conditions(filter, extra_conditions),
4758                                order_by,
4759                                offset,
4760                                limit,
4761                                distinct,
4762                                poly_implementors: vec![],
4763                            });
4764                        }
4765                        current_td = target_td;
4766                        current_alias = target_alias;
4767                        idx += 1;
4768                        continue;
4769                    }
4770                    splices += 1;
4771                    if splices > MAX_COMPUTED_SPLICES {
4772                        return Err(self.type_err(&format!(
4773                            "computed pointer '{step_name}' expands into itself — \
4774                             a path cannot be resolved through a cycle of computed pointers"
4775                        )));
4776                    }
4777                    let filter = modifiers.and_then(|m| m.filter.clone());
4778                    let spliced = p.steps.clone();
4779                    if tail > 0 && idx >= steps.len() - tail {
4780                        tail += spliced.len() - 1;
4781                    }
4782                    let landing = idx + spliced.len() - 1;
4783                    steps.splice(idx..idx + 1, spliced);
4784                    // Every filter already queued for a later step shifts
4785                    // along with it.
4786                    let shift = landing - idx;
4787                    for (i, _) in pending_filters.iter_mut() {
4788                        if *i > idx {
4789                            *i += shift;
4790                        }
4791                    }
4792                    if let Some(f) = filter {
4793                        pending_filters.push((landing, f));
4794                    }
4795                    continue;
4796                }
4797
4798                // A computed backed by an object-returning function —
4799                // `translation := latest(.id)`. There is no path to splice
4800                // in, so the call itself becomes the next row source: a
4801                // LATERAL join, since its arguments read the alias the
4802                // traversal has reached.
4803                if let Some((fc, modifiers)) = Self::function_subject(&expr_ast)
4804                    && let Some(fd) = self.resolve_object_fn(fc)
4805                {
4806                    if fd.params.len() != fc.args.len() {
4807                        return Err(self.type_err(&format!(
4808                            "function '{}::{}' expects {} argument(s), got {}",
4809                            fd.module,
4810                            fd.name,
4811                            fd.params.len(),
4812                            fc.args.len()
4813                        )));
4814                    }
4815                    let (fn_module, fn_name, return_type_name) =
4816                        (fd.module.clone(), fd.name.clone(), fd.return_pg_type.clone());
4817                    let mut args = fc
4818                        .args
4819                        .iter()
4820                        .map(|a| self.compile_expr(a, current_td, &current_alias))
4821                        .collect::<Result<Vec<_>, _>>()?;
4822                    let qualified = format!("{fn_module}::{fn_name}");
4823                    if let Some(globals) = self.globals_arg_for_call(&qualified)? {
4824                        args.insert(0, globals);
4825                    }
4826                    let target_td = self.resolve_type(&return_type_name)?;
4827                    let target_alias = self.fresh_alias();
4828                    let target = IrSource {
4829                        poly: None,
4830                        type_name: format!("{}::{}", target_td.module, target_td.name),
4831                        table: target_td.table.clone(),
4832                        alias: target_alias.clone(),
4833                    };
4834                    joins.push(IrPathJoin::Function {
4835                        fn_module,
4836                        fn_name,
4837                        args,
4838                        target,
4839                    });
4840                    if let Some(f) = modifiers.and_then(|m| m.filter.clone()) {
4841                        let cond = self.as_condition(|c| c.compile_expr(&f, target_td, &target_alias))?;
4842                        extra_conditions.push(cond);
4843                    }
4844                    if is_last(0) {
4845                        let shape = self.compile_shape_anchored(
4846                            shape_elements,
4847                            target_td,
4848                            &target_alias,
4849                            &target_td.module.clone(),
4850                        )?;
4851                        let result = IrPathResult::Object {
4852                            alias: target_alias.clone(),
4853                            type_name: format!("{}::{}", target_td.module, target_td.name),
4854                            shape,
4855                        };
4856                        let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4857                            sel,
4858                            target_td,
4859                            &target_alias,
4860                            junction_scope.clone(),
4861                            shape_elements,
4862                        )?;
4863                        return Ok(IrPathSelect {
4864                            root,
4865                            joins,
4866                            result,
4867                            filter: and_conditions(filter, extra_conditions),
4868                            order_by,
4869                            offset,
4870                            limit,
4871                            distinct,
4872                            poly_implementors: vec![],
4873                        });
4874                    }
4875                    current_td = target_td;
4876                    current_alias = target_alias;
4877                    idx += 1;
4878                    continue;
4879                }
4880
4881                if !is_last(0) {
4882                    return Err(self.type_err(&format!(
4883                        "'{step_name}' is a computed pointer — it has no stored column to traverse further through"
4884                    )));
4885                }
4886                let expanding = format!("{}::{}.{step_name}", current_td.module, current_td.name);
4887                if self.expanding_computeds.contains(&expanding) {
4888                    return Err(self.type_err(&format!(
4889                        "computed pointer '{step_name}' expands into itself — \
4890                         a path cannot be resolved through a cycle of computed pointers"
4891                    )));
4892                }
4893                self.expanding_computeds.push(expanding);
4894                let expr = self.compile_expr(&expr_ast, current_td, &current_alias);
4895                self.expanding_computeds.pop();
4896                let expr = expr?;
4897                let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4898                    sel,
4899                    current_td,
4900                    &current_alias,
4901                    junction_scope.clone(),
4902                    shape_elements,
4903                )?;
4904                return Ok(IrPathSelect {
4905                    root,
4906                    joins,
4907                    result: IrPathResult::Scalar(expr, None),
4908                    filter: and_conditions(filter, extra_conditions),
4909                    order_by,
4910                    offset,
4911                    limit,
4912                    distinct,
4913                    poly_implementors: vec![],
4914                });
4915            }
4916
4917            return Err(self.field_err(step_name, &format!("{}::{}", current_td.module, current_td.name)));
4918        }
4919
4920        // Every step consumed with nothing returned means the path *ended* on a
4921        // narrowing (`a.respondents[is GuestRespondent]`, or a bare `[is T]`).
4922        // The loop has already joined to the narrowed relation, so what it
4923        // landed on is the answer — read with the shape, like any other object
4924        // step.
4925        if matches!(steps.last(), Some(PathStep::TypeIntersection(_))) {
4926            let module = current_td.module.clone();
4927            let type_name = format!("{}::{}", current_td.module, current_td.name);
4928            let shape = self.compile_shape_anchored(shape_elements, current_td, &current_alias, &module)?;
4929            let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4930                sel,
4931                current_td,
4932                &current_alias,
4933                junction_scope.clone(),
4934                shape_elements,
4935            )?;
4936            return Ok(IrPathSelect {
4937                root,
4938                joins,
4939                result: IrPathResult::Object {
4940                    alias: current_alias,
4941                    type_name,
4942                    shape,
4943                },
4944                filter: and_conditions(filter, extra_conditions),
4945                order_by,
4946                offset,
4947                limit,
4948                distinct,
4949                poly_implementors: vec![],
4950            });
4951        }
4952
4953        // Should be unreachable: steps is non-empty (we checked len > 1 before dispatch).
4954        Err(self.type_err("empty path traversal"))
4955    }
4956
4957    /// `compile_path_modifiers` with the junction the traversal last crossed
4958    /// in scope, so a bare `@prop` in the select's own FILTER/ORDER BY can
4959    /// read it. Pushes nothing when there is no junction, leaving `@prop` to
4960    /// report that it has no link to belong to.
4961    fn compile_path_modifiers_scoped(
4962        &mut self,
4963        sel: &ast::SelectStmt,
4964        td: &TypeDescriptor,
4965        alias: &str,
4966        junction: Option<(String, String)>,
4967        shape: &[ShapeElement],
4968    ) -> Result<SelectModifiers, PyQLError> {
4969        let pushed = junction.is_some();
4970        if pushed {
4971            self.link_prop_scope.push(junction);
4972        }
4973        // `select .applied_promotions { name := .promotion.name } order by .name`
4974        // — the shape's own computeds are in scope for the clauses beside it,
4975        // the same as for a schema select. Added only now, after the shape has
4976        // been compiled, so one shape pointer still cannot read another.
4977        let outer_declared = self.active_declared_pointers.clone();
4978        self.active_declared_pointers
4979            .extend(shape.iter().filter(|el| el.compexpr.is_some()).cloned());
4980        let anchored = self.modifier_anchor.take();
4981        let tail_sorts = std::mem::take(&mut self.tail_sorts);
4982        let result = match &anchored {
4983            Some((qualified, anchor_alias)) => {
4984                let anchor_td = self.resolve_type(qualified)?;
4985                let anchor_alias = anchor_alias.clone();
4986                self.compile_path_modifiers(sel, anchor_td, &anchor_alias)
4987            }
4988            None => self.compile_path_modifiers(sel, td, alias),
4989        };
4990        let result = result.and_then(|(filter, mut order_by, offset, limit)| {
4991            if !tail_sorts.is_empty() {
4992                let sorting = ast::SelectStmt {
4993                    result: sel.result.clone(),
4994                    filter: None,
4995                    order_by: tail_sorts,
4996                    offset: None,
4997                    limit: None,
4998                    lock: None,
4999                };
5000                let (_, sorts, _, _) = self.compile_path_modifiers(&sorting, td, alias)?;
5001                order_by = sorts;
5002            }
5003            Ok((filter, order_by, offset, limit))
5004        });
5005        if pushed {
5006            self.link_prop_scope.pop();
5007        }
5008        self.active_declared_pointers = outer_declared;
5009        result
5010    }
5011
5012    fn compile_path_modifiers(
5013        &mut self,
5014        sel: &ast::SelectStmt,
5015        td: &TypeDescriptor,
5016        alias: &str,
5017    ) -> Result<SelectModifiers, PyQLError> {
5018        self.anchors.push(SelectAnchor {
5019            type_name: td.name.clone(),
5020            qualified: format!("{}::{}", td.module, td.name),
5021            alias: alias.to_string(),
5022            detached: std::mem::take(&mut self.pending_detached),
5023            declared_on: None,
5024        });
5025        let result = self.compile_path_modifiers_inner(sel, td, alias);
5026        self.anchors.pop();
5027        result
5028    }
5029
5030    fn compile_path_modifiers_inner(
5031        &mut self,
5032        sel: &ast::SelectStmt,
5033        td: &TypeDescriptor,
5034        alias: &str,
5035    ) -> Result<SelectModifiers, PyQLError> {
5036        let filter = sel
5037            .filter
5038            .as_ref()
5039            .map(|f| self.as_condition(|c| c.compile_expr(f, td, alias)))
5040            .transpose()?;
5041        let order_by = sel
5042            .order_by
5043            .iter()
5044            .map(|s| self.compile_sort(s, td, alias))
5045            .collect::<Result<Vec<_>, _>>()?;
5046        let offset = sel
5047            .offset
5048            .as_ref()
5049            .map(|e| self.compile_expr(e, td, alias))
5050            .transpose()?;
5051        let limit = sel
5052            .limit
5053            .as_ref()
5054            .map(|e| self.compile_expr(e, td, alias))
5055            .transpose()?;
5056        Ok((filter, order_by, offset, limit))
5057    }
5058
5059    // ── Expression-over-type dispatch ─────────────────────────────────────────────
5060
5061    /// The name of the set of objects `expr` stands for, when it is nothing
5062    /// but that name: a `with` binding of objects, or a type. Anything that
5063    /// walks off one (`accounts.owner`) is a path, not the set itself, and
5064    /// `find_path_root_in_expr` is what reads those.
5065    fn object_set_name(&self, expr: &Expr) -> Option<String> {
5066        let Expr::Path(p) = expr else { return None };
5067        if p.partial || p.steps.len() != 1 {
5068            return None;
5069        }
5070        let ast::PathStep::Name(name) = &p.steps[0] else {
5071            return None;
5072        };
5073        if self.cte_object_type(name).is_some() {
5074            return Some(name.clone());
5075        }
5076        // A bare name is a type only when nothing nearer claims it — the same
5077        // precedence `resolve_name_ref` gives for-vars, bindings and function
5078        // parameters over a schema type of the same name.
5079        let bound = self.for_vars.contains_key(name)
5080            || self.cte_free_items.contains_key(name)
5081            || self.inline_bindings.contains_key(name)
5082            || self.cte_types.contains_key(name)
5083            || self.fn_params.contains_key(name);
5084        if !bound && self.resolve_type(name).is_ok() {
5085            return Some(name.clone());
5086        }
5087        None
5088    }
5089
5090    /// Recursively find the first absolute path (non-partial, multi-step) in an expression
5091    /// and return its root type name if it resolves to a known schema type.
5092    fn find_path_root_in_expr(&self, expr: &Expr) -> Option<String> {
5093        match expr {
5094            Expr::Path(p) if !p.partial && p.steps.len() > 1 => {
5095                if let ast::PathStep::Name(root) = &p.steps[0]
5096                    && (self.resolve_type(root).is_ok() || self.cte_object_type(root).is_some())
5097                {
5098                    return Some(root.clone());
5099                }
5100                None
5101            }
5102            Expr::FunctionCall(f) => f.args.iter().find_map(|a| self.find_path_root_in_expr(a)),
5103            Expr::BinOp(b) => self
5104                .find_path_root_in_expr(&b.left)
5105                .or_else(|| self.find_path_root_in_expr(&b.right)),
5106            Expr::UnaryOp(u) => self.find_path_root_in_expr(&u.operand),
5107            _ => None,
5108        }
5109    }
5110
5111    /// Rewrite absolute paths rooted at `root_name` to relative (partial) paths.
5112    fn rewrite_abs_to_partial(expr: Expr, root_name: &str) -> Expr {
5113        match expr {
5114            Expr::Path(ref p) if !p.partial => {
5115                if let ast::PathStep::Name(first) = &p.steps[0]
5116                    && first == root_name
5117                    && p.steps.len() > 1
5118                {
5119                    return Expr::Path(ast::Path {
5120                        steps: p.steps[1..].to_vec(),
5121                        partial: true,
5122                    });
5123                }
5124                expr
5125            }
5126            Expr::FunctionCall(f) => Expr::FunctionCall(ast::FunctionCall {
5127                module: f.module,
5128                name: f.name,
5129                args: f
5130                    .args
5131                    .into_iter()
5132                    .map(|a| Self::rewrite_abs_to_partial(a, root_name))
5133                    .collect(),
5134                kwargs: f.kwargs,
5135            }),
5136            Expr::BinOp(b) => Expr::BinOp(Box::new(ast::BinOp {
5137                left: Self::rewrite_abs_to_partial(b.left, root_name),
5138                op: b.op,
5139                right: Self::rewrite_abs_to_partial(b.right, root_name),
5140            })),
5141            Expr::UnaryOp(u) => Expr::UnaryOp(Box::new(ast::UnaryOp {
5142                op: u.op,
5143                operand: Self::rewrite_abs_to_partial(u.operand, root_name),
5144            })),
5145            other => other,
5146        }
5147    }
5148
5149    /// The pointers a sub-select's shape declares, as the relative paths they
5150    /// stand for: `{ handle := [is Handle].handle }` → `handle` → `[is
5151    /// Handle].handle`. `None` when any element is something other than a
5152    /// named pointer defined by a relative path, which this substitution
5153    /// cannot stand in for.
5154    fn shape_alias_paths(elements: &[ShapeElement]) -> Option<Vec<(String, ast::Path)>> {
5155        let mut defs = Vec::with_capacity(elements.len());
5156        for element in elements {
5157            let [ast::PathStep::Name(name)] = element.path.steps.as_slice() else {
5158                return None;
5159            };
5160            match &element.compexpr {
5161                Some(Expr::Path(p)) if p.partial => defs.push((name.clone(), p.clone())),
5162                _ => return None,
5163            }
5164        }
5165        Some(defs)
5166    }
5167
5168    /// Replace each `.alias` a sub-select's shape declares with the path it
5169    /// stands for, so the select's own FILTER/ORDER BY read the same thing the
5170    /// shape projects.
5171    fn substitute_shape_aliases(expr: Expr, defs: &[(String, ast::Path)]) -> Expr {
5172        match expr {
5173            Expr::Path(ref p) if p.partial => {
5174                let Some(ast::PathStep::Name(first)) = p.steps.first() else {
5175                    return expr;
5176                };
5177                let Some((_, definition)) = defs.iter().find(|(name, _)| name == first) else {
5178                    return expr;
5179                };
5180                let mut steps = definition.steps.clone();
5181                steps.extend(p.steps[1..].iter().cloned());
5182                Expr::Path(ast::Path { steps, partial: true })
5183            }
5184            Expr::FunctionCall(f) => Expr::FunctionCall(ast::FunctionCall {
5185                module: f.module,
5186                name: f.name,
5187                args: f
5188                    .args
5189                    .into_iter()
5190                    .map(|a| Self::substitute_shape_aliases(a, defs))
5191                    .collect(),
5192                kwargs: f.kwargs,
5193            }),
5194            Expr::BinOp(b) => Expr::BinOp(Box::new(ast::BinOp {
5195                left: Self::substitute_shape_aliases(b.left, defs),
5196                op: b.op,
5197                right: Self::substitute_shape_aliases(b.right, defs),
5198            })),
5199            Expr::UnaryOp(u) => Expr::UnaryOp(Box::new(ast::UnaryOp {
5200                op: u.op,
5201                operand: Self::substitute_shape_aliases(u.operand, defs),
5202            })),
5203            other => other,
5204        }
5205    }
5206
5207    /// `array_agg(array_unpack(X))` — the aggregate runs over the elements the
5208    /// array unpacks to. PostgreSQL rejects a set-returning call inside an
5209    /// aggregate ("aggregate function calls cannot contain set-returning
5210    /// function calls"), so the unpacking moves to a row source of its own and
5211    /// the aggregate reads that from outside. `None` when the result is not an
5212    /// aggregate over an unpacked array, leaving the ordinary routes to it.
5213    fn aggregate_over_unpacked(
5214        &mut self,
5215        sel: &ast::SelectStmt,
5216        result: &Expr,
5217        distinct: bool,
5218    ) -> Result<Option<IrExpr>, PyQLError> {
5219        let Expr::FunctionCall(f) = result else {
5220            return Ok(None);
5221        };
5222        let [Expr::FunctionCall(unpack)] = f.args.as_slice() else {
5223            return Ok(None);
5224        };
5225        if !f.kwargs.is_empty()
5226            || !unpack.kwargs.is_empty()
5227            || unpack.module.as_deref().unwrap_or("std") != "std"
5228            || unpack.name != "array_unpack"
5229        {
5230            return Ok(None);
5231        }
5232        let [array] = unpack.args.as_slice() else {
5233            return Ok(None);
5234        };
5235        let namespace = f.module.as_deref().unwrap_or("std");
5236        let Some(sql_name) =
5237            crate::stdlib::lookup(namespace, &f.name)
5238                .into_iter()
5239                .find_map(|d| match &d.impl_strategy {
5240                    crate::stdlib::ImplStrategy::SqlBuiltin(sql_name) if d.is_aggregate() => Some(sql_name.to_string()),
5241                    _ => None,
5242                })
5243        else {
5244            return Ok(None);
5245        };
5246
5247        // A walk is unpacked one row at a time, so the unnest belongs in that
5248        // walk's own projection; anything else is a single array standing on
5249        // its own and unnests directly.
5250        let elements = match array {
5251            Expr::Path(path) if !path.partial && path.steps.len() > 1 => {
5252                let walk = ast::Path {
5253                    partial: false,
5254                    steps: path.steps.clone(),
5255                };
5256                let mut path_select = self.compile_path_select(sel, &walk, &[], distinct)?;
5257                let IrPathResult::Scalar(column, _) = path_select.result else {
5258                    return Ok(None);
5259                };
5260                path_select.result = IrPathResult::Scalar(
5261                    IrExpr::FunctionCall(IrFunctionCall {
5262                        return_pg_type: None,
5263                        schema: None,
5264                        name: "unnest".to_string(),
5265                        args: vec![column],
5266                        sql_template: None,
5267                    }),
5268                    None,
5269                );
5270                IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(path_select))))
5271            }
5272            _ if sel.filter.is_none()
5273                && sel.order_by.is_empty()
5274                && sel.offset.is_none()
5275                && sel.limit.is_none()
5276                && !distinct =>
5277            {
5278                self.compile_expr_ctx(array, None)?
5279            }
5280            _ => return Ok(None),
5281        };
5282
5283        // Over no rows SQL's aggregates give NULL where PyQL's give the empty
5284        // set's own value.
5285        let over_nothing = aggregate_over_nothing_sql(&f.name).unwrap_or("NULL");
5286        Ok(Some(IrExpr::FunctionCall(IrFunctionCall {
5287            return_pg_type: None,
5288            schema: None,
5289            name: sql_name.clone(),
5290            args: vec![elements],
5291            sql_template: Some(format!(
5292                "(SELECT coalesce({sql_name}(\"_s\".\"v\"), {over_nothing}) FROM unnest($1) AS \"_s\"(\"v\"))"
5293            )),
5294        })))
5295    }
5296
5297    /// Compile an expression that contains a type-rooted absolute path as a flat
5298    /// path select, iterating over the root type and projecting the expression as
5299    /// a scalar computed pointer.
5300    fn compile_expr_as_path_select(
5301        &mut self,
5302        sel: &ast::SelectStmt,
5303        result: &Expr,
5304        root_type_name: &str,
5305        distinct: bool,
5306    ) -> Result<IrPathSelect, PyQLError> {
5307        // `array_agg(a.sessions.id)` — a single-argument aggregate over a
5308        // path. The aggregate applies to the set the path traverses *to*, so
5309        // the traversal becomes this select's own row source and the aggregate
5310        // wraps the column it lands on. Compiling the path as an expression
5311        // instead yields the array it stands for, and aggregating that nests it
5312        // one level deep.
5313        // `array_agg(distinct X.p)` — the DISTINCT belongs *inside* the
5314        // aggregate. The unary `distinct` emits its operand unchanged (it is
5315        // normally applied where the set is built, which an aggregate argument
5316        // is not), so left here it silently keeps the duplicates.
5317        let (agg_arg, agg_distinct) = match result {
5318            Expr::FunctionCall(f) if f.args.len() == 1 => match &f.args[0] {
5319                Expr::UnaryOp(u) if matches!(u.op, ast::UnaryOpKind::Distinct) => (Some(&u.operand), true),
5320                other => (Some(other), false),
5321            },
5322            _ => (None, false),
5323        };
5324        if let Expr::FunctionCall(f) = result
5325            && f.args.len() == 1
5326            && let Some(Expr::Path(p)) = agg_arg
5327            && !p.partial
5328            && p.steps.len() > 1
5329        {
5330            let arg_path = ast::Path {
5331                partial: false,
5332                steps: p.steps.clone(),
5333            };
5334            let mut ps = self.compile_path_select(sel, &arg_path, &[], distinct)?;
5335            // A walk onto objects (`count(.<brand[is Licence])`) is counted by
5336            // its rows, which is its key. Falling through instead put the
5337            // aggregate back through the expression route that sent it here.
5338            let aggregated = match ps.result.clone() {
5339                IrPathResult::Scalar(column, _) => Some(column),
5340                IrPathResult::Object { alias, .. } => Some(IrExpr::ColumnRef {
5341                    alias,
5342                    column: "id".to_string(),
5343                    pg_type: "uuid".to_string(),
5344                }),
5345            };
5346            if let Some(column) = aggregated {
5347                let mut args = vec![column];
5348                if !f.kwargs.is_empty() {
5349                    if !matches!(f.name.as_str(), "assert_single" | "assert_exists" | "assert_distinct") {
5350                        return Err(self.type_err(&format!(
5351                            "function '{}' does not take named arguments, got '{}'",
5352                            f.name, f.kwargs[0].0
5353                        )));
5354                    }
5355                    args.extend(self.assert_message(f, None)?);
5356                }
5357                let mut call = self.resolve_fn_call(f.module.as_deref(), &f.name, args)?;
5358                if agg_distinct {
5359                    let IrExpr::FunctionCall(fc) = &mut call else {
5360                        return Err(self.type_err(&format!(
5361                            "'{}' does not take a distinct argument — it does not resolve to an aggregate",
5362                            f.name
5363                        )));
5364                    };
5365                    if fc.schema.is_some() || fc.sql_template.is_some() {
5366                        return Err(self.type_err(&format!(
5367                            "'distinct' inside '{}' is not supported — only a plain SQL aggregate can take it",
5368                            f.name
5369                        )));
5370                    }
5371                    fc.sql_template = Some(format!("{}(DISTINCT $1)", fc.name));
5372                }
5373                ps.result = IrPathResult::Scalar(aggregate_over_nothing(&f.name, call), None);
5374                return Ok(ps);
5375            }
5376        }
5377
5378        // BinOp where one side is the absolute type-rooted path:
5379        // Build the join chain for the path side, then apply the comparison
5380        // element-wise so we get one boolean per traversal element (not one EXISTS per root).
5381        if let Expr::BinOp(b) = result {
5382            let (path_expr, value_expr, flip) = match (&b.left, &b.right) {
5383                (Expr::Path(p), v) if !p.partial => (p, v, false),
5384                (v, Expr::Path(p)) if !p.partial => (p, v, true),
5385                _ => return self.compile_expr_as_path_select_fallback(sel, result, root_type_name, distinct),
5386            };
5387            // Compile value side first so we can report its type in errors
5388            let root_td = match self.cte_object_type(root_type_name) {
5389                Some(t) => self.resolve_type(&t)?,
5390                None => self.resolve_type(root_type_name)?,
5391            };
5392            // Build PathSelect for the path (builds root + joins + scalar/object result)
5393            let ast_path = ast::Path {
5394                partial: false,
5395                steps: path_expr.steps.clone(),
5396            };
5397            let mut ps = self.compile_path_select(sel, &ast_path, &[], distinct)?;
5398            let val_ir = self.compile_expr(value_expr, root_td, &ps.root.alias)?;
5399            // Extract the scalar result from the path
5400            let scalar_col = match ps.result {
5401                IrPathResult::Scalar(e, _) => e,
5402                IrPathResult::Object { type_name, .. } => {
5403                    let val_type = infer_ir_type(&val_ir).map(pg_type_to_pyql).unwrap_or("unknown");
5404                    return Err(PyQLError::Type(PyQLTypeError {
5405                        message: format!(
5406                            "operator '{}' cannot be applied to operands of type '{}' and '{}'",
5407                            b.op, type_name, val_type,
5408                        ),
5409                        position: Position { line: 0, col: 0 },
5410                    }));
5411                }
5412            };
5413            let (l, r) = if flip {
5414                (val_ir, scalar_col)
5415            } else {
5416                (scalar_col, val_ir)
5417            };
5418            ps.result = IrPathResult::Scalar(
5419                IrExpr::BinOp(Box::new(IrBinOp {
5420                    left: true_division_operand(&b.op, l, &r),
5421                    op: b.op.clone(),
5422                    right: r,
5423                })),
5424                None,
5425            );
5426            return Ok(ps);
5427        }
5428        self.compile_expr_as_path_select_fallback(sel, result, root_type_name, distinct)
5429    }
5430
5431    fn compile_expr_as_path_select_fallback(
5432        &mut self,
5433        sel: &ast::SelectStmt,
5434        result: &Expr,
5435        root_type_name: &str,
5436        distinct: bool,
5437    ) -> Result<IrPathSelect, PyQLError> {
5438        let cte_object_type = self.cte_object_type(root_type_name);
5439        let td = match &cte_object_type {
5440            Some(t) => self.resolve_type(t)?,
5441            None => self.resolve_type(root_type_name)?,
5442        };
5443        let alias = self.fresh_alias();
5444        let root = IrSource {
5445            poly: None,
5446            type_name: format!("{}::{}", td.module, td.name),
5447            table: match &cte_object_type {
5448                Some(_) => self.cte_source_table(root_type_name),
5449                None => td.table.clone(),
5450            },
5451            alias: alias.clone(),
5452        };
5453        let rewritten = Self::rewrite_abs_to_partial(result.clone(), root_type_name);
5454        let expr = self.compile_expr(&rewritten, td, &alias)?;
5455        let (filter, order_by, offset, limit) = self.compile_path_modifiers(sel, td, &alias)?;
5456        Ok(IrPathSelect {
5457            root,
5458            joins: vec![],
5459            result: IrPathResult::Scalar(expr, None),
5460            filter,
5461            order_by,
5462            offset,
5463            limit,
5464            distinct,
5465            poly_implementors: vec![],
5466        })
5467    }
5468
5469    /// Compile a SubQuery stmt to an `IrArraySource` for use in assert functions.
5470    /// The result is `ARRAY(SELECT scalar FROM compiled_inner)`.
5471    fn compile_subquery_to_array_source(&mut self, stmt: &Stmt) -> Result<IrArraySource, PyQLError> {
5472        match self.compile_stmt(stmt)? {
5473            IrStmt::Select(s) if matches!(s.rows.as_slice(), [IrRowSource::Bound { .. }]) => {
5474                Ok(IrArraySource::Select(s))
5475            }
5476            IrStmt::PathSelect(ps) => Ok(IrArraySource::PathSelect(Box::new(ps))),
5477            _ => Err(self.type_err("assert functions require a schema-bound SELECT as argument")),
5478        }
5479    }
5480
5481    /// `exists` in either schema-bound (FILTER, computed pointer, etc. —
5482    /// `ctx = Some((td, alias))`) or free (`ctx = None`) expression context.
5483    /// The `Path`-based arms (backlink / prop / link / multilink existence)
5484    /// only apply schema-bound — guarded on `ctx.is_some()` — since none of
5485    /// those concepts exist without a schema type in scope; everything else
5486    /// (`Parameter`, `TypeCast`, `SubQuery`, and the scalar-expression
5487    /// fallback) is identical either way and just threads `ctx` through.
5488    fn compile_exists_ctx(
5489        &mut self,
5490        operand: &Expr,
5491        ctx: Option<(&TypeDescriptor, &str)>,
5492    ) -> Result<IrExpr, PyQLError> {
5493        match operand {
5494            // exists $param  /  exists <type>$param → $N IS NOT NULL
5495            Expr::Parameter(name) => {
5496                let idx = self.param_index(name);
5497                Ok(ir_is_not_null(IrExpr::Param { index: idx }))
5498            }
5499            // exists <type>expr → expr IS NOT NULL (cast result is always a scalar)
5500            Expr::TypeCast(_) => {
5501                let inner = self.compile_expr_ctx(operand, ctx)?;
5502                Ok(ir_is_not_null(inner))
5503            }
5504
5505            // exists .<link[is Type] → EXISTS(SELECT 1 FROM type WHERE type.link_id = alias.id)
5506            Expr::Path(p)
5507                if ctx.is_some() && p.partial && matches!(p.steps.first(), Some(ast::PathStep::Backlink(_))) =>
5508            {
5509                let (td, alias) = ctx.unwrap();
5510                let current_qname = format!("{}::{}", td.module, td.name);
5511                let exists = self.compile_backlink_as_exists(&p.steps, None, &current_qname, alias)?;
5512                Ok(exists)
5513            }
5514
5515            // exists .prop → alias.col IS NOT NULL
5516            // exists .link → alias.link_id IS NOT NULL
5517            // exists .multilink → EXISTS(SELECT 1 FROM junction WHERE src = alias.id)
5518            Expr::Path(p) if ctx.is_some() && p.partial && p.steps.len() == 1 => {
5519                let (td, alias) = ctx.unwrap();
5520                // `exists @dismissed` — a link property is a column on the
5521                // junction row the multi-link's own modifiers are being
5522                // compiled against, so whether it is set is an ordinary null
5523                // check on it.
5524                if let ast::PathStep::LinkProp(prop_name) = &p.steps[0] {
5525                    let prop = self.compile_link_prop_ref(prop_name)?;
5526                    return Ok(ir_is_not_null(prop));
5527                }
5528                // `filter exists .n` where the shape in scope declared `n` —
5529                // the pointer is on no type, so it is read as the expression it
5530                // was written as.
5531                if let ast::PathStep::Name(n) = &p.steps[0]
5532                    && Self::resolve_property(td, n).is_none()
5533                    && Self::resolve_link(td, n).is_none()
5534                    && Self::resolve_multilink(td, n).is_none()
5535                    && self.resolve_computed(td, n).is_none()
5536                    && let Some(expr) = self
5537                        .active_declared_pointers
5538                        .iter()
5539                        .find(|d| path_leaf(&d.path).is_ok_and(|name| name == n))
5540                        .and_then(|d| d.compexpr.clone())
5541                {
5542                    let inner = self.compile_expr(&expr, td, alias)?;
5543                    return Ok(ir_is_not_null(inner));
5544                }
5545                // `exists [is contact::IndividualContact]` — a bare narrowing
5546                // is the current row seen as that type, so whether it exists is
5547                // whether the row *is* one.
5548                if let ast::PathStep::TypeIntersection(type_ref) = &p.steps[0] {
5549                    let narrowed = self.resolve_type(&type_ref.qualified_name())?;
5550                    let check = format!("{}::{}", narrowed.module, narrowed.name);
5551                    let source = format!("{}::{}", td.module, td.name);
5552                    return Ok(self.type_check_bool_expr(&source, &check, td, alias));
5553                }
5554                let pointer_name = match &p.steps[0] {
5555                    ast::PathStep::Name(n) => n.as_str(),
5556                    _ => return Err(self.type_err("exists: invalid path step")),
5557                };
5558                if let Some(prop) = Self::resolve_property(td, pointer_name) {
5559                    return Ok(ir_is_not_null(IrExpr::ColumnRef {
5560                        alias: alias.to_string(),
5561                        column: prop.name.clone(),
5562                        pg_type: prop.pg_type.clone(),
5563                    }));
5564                }
5565                if let Some(link) = Self::resolve_link(td, pointer_name) {
5566                    if link.is_junction_backed() {
5567                        return self.compile_junction_link_exists_check(link, td, alias);
5568                    }
5569                    return Ok(ir_is_not_null(IrExpr::ColumnRef {
5570                        alias: alias.to_string(),
5571                        column: format!("{}_id", link.name),
5572                        pg_type: "uuid".to_string(),
5573                    }));
5574                }
5575                if Self::resolve_multilink(td, pointer_name).is_some() {
5576                    return self.compile_multilink_exists_check(pointer_name, td, alias);
5577                }
5578                // `exists .primary_email` — a computed pointer stands for the
5579                // path it names, and whether *that* is empty is the question.
5580                // Checked last, so a stored pointer of the same name still
5581                // wins; without it the suggester could see the name the
5582                // resolver had just reported as unknown.
5583                if let Some(cd) = self.resolve_computed(td, pointer_name) {
5584                    let expr_ast = crate::parse::parse_pointer_expr(&cd.expression).map_err(PyQLError::Syntax)?;
5585                    return self.compile_exists_ctx(&expr_ast, ctx);
5586                }
5587                Err(self.field_err(pointer_name, &format!("{}::{}", td.module, td.name)))
5588            }
5589
5590            // `exists ((select .prices filter …))` — a sub-select over a
5591            // relative path is relative to the enclosing object, so it needs
5592            // the same rooting and correlation `count((select .prices))`
5593            // already gets. Compiled without them it resolves in free context
5594            // and reports the pointer as unknown.
5595            Expr::SubQuery(stmt)
5596                if ctx.is_some()
5597                    && matches!(
5598                        stmt.as_ref(),
5599                        Stmt::Select(inner) if matches!(&inner.result, Expr::Path(p) if p.partial)
5600                    ) =>
5601            {
5602                let Stmt::Select(inner) = stmt.as_ref() else {
5603                    unreachable!("checked by the guard")
5604                };
5605                let Expr::Path(path) = &inner.result else {
5606                    unreachable!("checked by the guard")
5607                };
5608                let (td, alias) = ctx.expect("checked by the guard");
5609                let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
5610                steps.extend(path.steps.iter().cloned());
5611                let full_path = ast::Path { steps, partial: false };
5612                let rooted = ast::SelectStmt {
5613                    result: Expr::Path(full_path.clone()),
5614                    filter: inner.filter.clone(),
5615                    order_by: inner.order_by.clone(),
5616                    offset: inner.offset.clone(),
5617                    limit: inner.limit.clone(),
5618                    lock: None,
5619                };
5620                let mut ps = self.compile_path_select(&rooted, &full_path, &[], false)?;
5621                Self::correlate_path_select(&mut ps, alias);
5622                Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
5623                    op: ast::UnaryOpKind::Exists,
5624                    operand: IrExpr::PathSubquery(Box::new(ps)),
5625                })))
5626            }
5627
5628            // exists (select ...) → EXISTS(SELECT 1 FROM ...)
5629            Expr::SubQuery(stmt) => self.compile_subquery_exists(stmt),
5630
5631            // `exists licenses` — a binding names a set, so it exists when it
5632            // has a row; the fallback's scalar subquery aborts on the second.
5633            operand if let Some(name) = self.resolve_cte_name(operand) => {
5634                let object = self.cte_types.get(name).is_some_and(|bound| bound.contains("::"));
5635                Ok(IrExpr::ExistsOverCte {
5636                    cte: name.to_string(),
5637                    column: (!object).then(|| "v".to_string()),
5638                })
5639            }
5640
5641            // Fallback: any scalar expression → expr IS NOT NULL. When `ctx`
5642            // is `None` and `operand` is a partial `Path` not caught above
5643            // (guards failed since ctx.is_some() was false), this recurses
5644            // into the free-context Path handling, which itself produces the
5645            // "not valid in free SELECT" error — same as before the merge.
5646            other => {
5647                let inner = self.compile_expr_ctx(other, ctx)?;
5648                // A set operation already knows how to ask whether it yields
5649                // anything; going through the array would build it first.
5650                if let IrExpr::SetOp { op, left, right, .. } = inner {
5651                    return Ok(IrExpr::SetOp {
5652                        op,
5653                        left,
5654                        right,
5655                        mode: super::SetOpMode::Exists,
5656                    });
5657                }
5658                Ok(ir_is_not_null(inner))
5659            }
5660        }
5661    }
5662
5663    /// Compile `exists (select ...)` → `EXISTS(SELECT 1 FROM ... WHERE ...)`.
5664    fn compile_subquery_exists(&mut self, stmt: &Stmt) -> Result<IrExpr, PyQLError> {
5665        match self.compile_stmt(stmt)? {
5666            IrStmt::Select(s) => {
5667                let mut rows = s.rows;
5668                if rows.len() != 1 {
5669                    return Err(self.type_err("exists requires a schema-bound SELECT expression"));
5670                }
5671                let IrRowSource::Bound { source, .. } = rows.remove(0) else {
5672                    return Err(self.type_err("exists requires a schema-bound SELECT expression"));
5673                };
5674                let inner = IrExpr::Subquery(Box::new(IrSelect::schema_bound(source, vec![], s.filter)));
5675                Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
5676                    op: ast::UnaryOpKind::Exists,
5677                    operand: inner,
5678                })))
5679            }
5680            IrStmt::PathSelect(ps) => {
5681                // EXISTS(SELECT 1 FROM root [JOINs] WHERE filter)
5682                // Reuse the path select but signal "exists" via a dedicated IR node
5683                Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
5684                    op: ast::UnaryOpKind::Exists,
5685                    operand: IrExpr::Subquery(Box::new(IrSelect::schema_bound(ps.root, vec![], ps.filter))),
5686                })))
5687            }
5688            _ => Err(self.type_err("exists requires a SELECT expression")),
5689        }
5690    }
5691
5692    /// `EXISTS(SELECT 1 FROM junction WHERE junction.source = alias.id)` for a multi-link.
5693    /// Build the `IrSelect` over the junction/FK-target rows for a multilink,
5694    /// correlated to the current row (`alias.id`) — shared by `exists
5695    /// .multilink` and `count(.multilink)`.
5696    /// `EXISTS(SELECT 1 FROM junction WHERE junction.<source_col> = alias.id)`,
5697    /// correlated to the current row — shared by a multi-link's own `exists
5698    /// .multilink`/`count(.multilink)` and a junction-backed single link's
5699    /// `exists .link` (D2: same junction-info resolution either way).
5700    fn junction_correlation_select(
5701        &mut self,
5702        td: &TypeDescriptor,
5703        alias: &str,
5704        name: &str,
5705        target: &str,
5706        through: &Option<String>,
5707    ) -> Result<IrSelect, PyQLError> {
5708        let jt_alias = self.fresh_alias();
5709        let (jt_table, jt_module, jt_src_col, _, _) = self.junction_info_for(td, name, target, through)?;
5710
5711        let filter = IrExpr::BinOp(Box::new(IrBinOp {
5712            left: IrExpr::ColumnRef {
5713                alias: jt_alias.clone(),
5714                column: jt_src_col,
5715                pg_type: "uuid".to_string(),
5716            },
5717            op: ast::BinOpKind::Eq,
5718            right: IrExpr::ColumnRef {
5719                alias: alias.to_string(),
5720                column: "id".to_string(),
5721                pg_type: "uuid".to_string(),
5722            },
5723        }));
5724        Ok(IrSelect::schema_bound(
5725            IrSource {
5726                poly: None,
5727                type_name: format!("{}::__jt__", jt_module),
5728                table: jt_table,
5729                alias: jt_alias,
5730            },
5731            vec![],
5732            Some(filter),
5733        ))
5734    }
5735
5736    fn multilink_correlation_select(
5737        &mut self,
5738        ml_name: &str,
5739        td: &TypeDescriptor,
5740        alias: &str,
5741    ) -> Result<IrSelect, PyQLError> {
5742        let ml = Self::resolve_multilink(td, ml_name).unwrap();
5743        let (name, target, through) = (ml.name.clone(), ml.target.clone(), ml.through.clone());
5744        self.junction_correlation_select(td, alias, &name, &target, &through)
5745    }
5746
5747    fn compile_multilink_exists_check(
5748        &mut self,
5749        ml_name: &str,
5750        td: &TypeDescriptor,
5751        alias: &str,
5752    ) -> Result<IrExpr, PyQLError> {
5753        let inner = self.multilink_correlation_select(ml_name, td, alias)?;
5754        Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
5755            op: ast::UnaryOpKind::Exists,
5756            operand: IrExpr::Subquery(Box::new(inner)),
5757        })))
5758    }
5759
5760    /// Same as `compile_multilink_exists_check`, for `exists .link` where
5761    /// `link` is a junction-backed single link.
5762    fn compile_junction_link_exists_check(
5763        &mut self,
5764        l: &LinkDescriptor,
5765        td: &TypeDescriptor,
5766        alias: &str,
5767    ) -> Result<IrExpr, PyQLError> {
5768        let (name, target, through) = (l.name.clone(), l.target.clone(), l.through.clone());
5769        let inner = self.junction_correlation_select(td, alias, &name, &target, &through)?;
5770        Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
5771            op: ast::UnaryOpKind::Exists,
5772            operand: IrExpr::Subquery(Box::new(inner)),
5773        })))
5774    }
5775
5776    /// A junction-backed single link's target id, as a scalar correlated
5777    /// subquery — `(SELECT jt.<target_col> FROM junction AS jt WHERE
5778    /// jt.<source_col> = alias.id)`. Stands in wherever a plain single
5779    /// link's `{name}_id` FK column would otherwise be referenced directly
5780    /// as a scalar uuid expression (bare `.link` in a filter/order-by,
5781    /// `.link.id`, or correlating `.link.<other prop>`).
5782    fn junction_target_id_expr(
5783        &mut self,
5784        td: &TypeDescriptor,
5785        l: &LinkDescriptor,
5786        alias: &str,
5787    ) -> Result<IrExpr, PyQLError> {
5788        let (name, target, through) = (l.name.clone(), l.target.clone(), l.through.clone());
5789        let (jt_table, jt_module, jt_src_col, jt_tgt_col, _) = self.junction_info_for(td, &name, &target, &through)?;
5790        let jt_alias = self.fresh_alias();
5791        let filter = IrExpr::BinOp(Box::new(IrBinOp {
5792            left: IrExpr::ColumnRef {
5793                alias: jt_alias.clone(),
5794                column: jt_src_col,
5795                pg_type: "uuid".to_string(),
5796            },
5797            op: ast::BinOpKind::Eq,
5798            right: IrExpr::ColumnRef {
5799                alias: alias.to_string(),
5800                column: "id".to_string(),
5801                pg_type: "uuid".to_string(),
5802            },
5803        }));
5804        let select = IrSelect::schema_bound(
5805            IrSource {
5806                poly: None,
5807                type_name: format!("{}::__jt__", jt_module),
5808                table: jt_table,
5809                alias: jt_alias,
5810            },
5811            vec![IrShapePointer::Scalar(IrScalarPointer {
5812                implicit_id: false,
5813                marker_offset: None,
5814                alias: "target".to_string(),
5815                column: jt_tgt_col,
5816                pg_type: "uuid".to_string(),
5817                tuple_shape: None,
5818            })],
5819            Some(filter),
5820        );
5821        Ok(IrExpr::Subquery(Box::new(select)))
5822    }
5823
5824    /// Returns true when the SELECT result expression is not a schema type reference.
5825    /// Check that a UNION expression doesn't mix object types with scalars.
5826    /// Called before dispatch so we can give a clear error instead of "expected a type name".
5827    fn check_union_type_compat(&self, expr: &Expr) -> Result<(), PyQLError> {
5828        let Expr::Union(a, b) = expr else { return Ok(()) };
5829        let a_free = self.is_free_result(a);
5830        let b_free = self.is_free_result(b);
5831        if a_free != b_free {
5832            let left = self.union_operand_type_display(a);
5833            let right = self.union_operand_type_display(b);
5834            return Err(PyQLError::Type(PyQLTypeError {
5835                message: format!(
5836                    "operator 'UNION' cannot be applied to operands of type '{}' and '{}'",
5837                    left, right,
5838                ),
5839                position: Position { line: 0, col: 0 },
5840            }));
5841        }
5842        Ok(())
5843    }
5844
5845    fn union_operand_type_display(&self, expr: &Expr) -> String {
5846        match expr {
5847            Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
5848                if let ast::PathStep::Name(n) = &p.steps[0]
5849                    && let Some(t) = self.cte_types.get(n.as_str())
5850                {
5851                    if t.contains("::") {
5852                        return t.clone(); // object CTE: "default::Person"
5853                    }
5854                    if !t.is_empty() {
5855                        return pg_type_to_pyql(t).to_string(); // scalar CTE: "std::int64"
5856                    }
5857                }
5858                // Bare type name reference
5859                if let Ok(td) = self.resolve_type(
5860                    p.steps
5861                        .first()
5862                        .and_then(|s| {
5863                            if let ast::PathStep::Name(n) = s {
5864                                Some(n.as_str())
5865                            } else {
5866                                None
5867                            }
5868                        })
5869                        .unwrap_or(""),
5870                ) {
5871                    return format!("{}::{}", td.module, td.name);
5872                }
5873            }
5874            Expr::Literal(Literal::Int(_)) => return "std::int64".to_string(),
5875            Expr::Literal(Literal::Str(_)) => return "std::str".to_string(),
5876            Expr::Literal(Literal::Float(_)) => return "std::float64".to_string(),
5877            Expr::Literal(Literal::Bool(_)) => return "std::bool".to_string(),
5878            _ => {}
5879        }
5880        "unknown".to_string()
5881    }
5882
5883    fn is_free_result(&self, expr: &Expr) -> bool {
5884        let expr = match expr {
5885            Expr::UnaryOp(u) if u.op == ast::UnaryOpKind::Distinct => &u.operand,
5886            Expr::Detached(inner) => inner.as_ref(),
5887            other => other,
5888        };
5889        match expr {
5890            Expr::Path(p) if !p.partial => {
5891                if p.steps.len() == 1
5892                    && let ast::PathStep::Name(n) = &p.steps[0]
5893                {
5894                    // A for-loop variable over values is a scalar; one over
5895                    // objects stands for a row, and `select c` is a schema-
5896                    // bound select of its type.
5897                    if self.for_vars.contains_key(n.as_str()) {
5898                        return !self.for_var_types.contains_key(n.as_str());
5899                    }
5900                    // Scalar CTE: type string has no "::" (object types always do).
5901                    if self.is_value_binding(n.as_str()) {
5902                        return true;
5903                    }
5904                    // Function parameter — only populated while compiling a
5905                    // function body (see `compile_fn_body`), so a bare `select a`
5906                    // where `a` is a param must resolve as the param, not be
5907                    // misread as a schema-type-name select.
5908                    if self.fn_params.contains_key(n.as_str()) {
5909                        return true;
5910                    }
5911                }
5912                false
5913            }
5914            Expr::Shape(s) if s.expr.is_some() => false,
5915            Expr::SubQuery(_) => false,
5916            Expr::Union(a, b) => self.is_free_result(a) && self.is_free_result(b),
5917            // `A if cond else B` picks between two *sets*; when those are
5918            // object sets the result is an object set too, not the scalar an
5919            // if/else over values gives. An empty branch says nothing about
5920            // which it is, so it does not get a vote.
5921            Expr::IfElse(ie) => {
5922                let empty = |e: &Expr| matches!(e, Expr::Set(items) if items.is_empty());
5923                match (empty(&ie.if_expr), empty(&ie.else_expr)) {
5924                    (true, true) => true,
5925                    (true, false) => self.is_free_result(&ie.else_expr),
5926                    (false, true) => self.is_free_result(&ie.if_expr),
5927                    (false, false) => self.is_free_result(&ie.if_expr) || self.is_free_result(&ie.else_expr),
5928                }
5929            }
5930            _ => true,
5931        }
5932    }
5933
5934    /// True when `expr` is a bare 1-step name bound to a free (non-object)
5935    /// WITH binding — the same "scalar CTE" check `is_free_result` uses for
5936    /// its `Expr::Path` arm, factored out so `Expr::Shape`'s handling of
5937    /// "shape applied to a non-object" can reuse it too.
5938    fn is_free_cte_ref(&self, expr: &Expr) -> bool {
5939        let Expr::Path(p) = expr else { return false };
5940        if p.partial || p.steps.len() != 1 {
5941            return false;
5942        }
5943        let ast::PathStep::Name(n) = &p.steps[0] else {
5944            return false;
5945        };
5946        self.is_value_binding(n.as_str())
5947    }
5948
5949    // ── FREE SELECT ───────────────────────────────────────────────────────────────
5950
5951    /// A mutation standing where a value is expected. Postgres cannot run DML
5952    /// in a value list, so it becomes a data-modifying CTE — which it runs to
5953    /// completion whether or not the outer query reads it — and what stands
5954    /// here reads the ids back, so the value still names the rows the
5955    /// mutation touched.
5956    fn dml_as_value(&mut self, stmt: &Stmt) -> Result<IrExpr, PyQLError> {
5957        let (cte_name, type_name) = self.hoist_dml_as_cte(stmt)?;
5958        // Read back through a subquery rather than naming the CTE's column
5959        // directly: a free select has no FROM of its own for the reference to
5960        // resolve against.
5961        let source = IrSource {
5962            poly: None,
5963            type_name,
5964            table: self.cte_source_table(&cte_name),
5965            alias: self.fresh_alias(),
5966        };
5967        Ok(IrExpr::Subquery(Box::new(IrSelect::schema_bound(
5968            source,
5969            // An empty shape would emit the `SELECT 1` an EXISTS wants, which
5970            // says nothing about which rows the value stands for.
5971            vec![IrShapePointer::Scalar(IrScalarPointer {
5972                implicit_id: false,
5973                marker_offset: None,
5974                alias: "id".to_string(),
5975                column: "id".to_string(),
5976                pg_type: "uuid".to_string(),
5977                tuple_shape: None,
5978            })],
5979            None,
5980        ))))
5981    }
5982
5983    fn collect_union_items(&mut self, expr: &Expr, items: &mut Vec<IrFreeExpr>) -> Result<(), PyQLError> {
5984        match expr {
5985            Expr::Union(a, b) => {
5986                self.collect_union_items(a, items)?;
5987                self.collect_union_items(b, items)?;
5988            }
5989            Expr::Set(exprs) => {
5990                for e in exprs {
5991                    self.collect_union_items(e, items)?;
5992                }
5993            }
5994            // `select { (update A set …), (update B set …) }` — several
5995            // mutations in one statement. Postgres cannot run DML in a value
5996            // list, so each becomes a data-modifying CTE, which it runs to
5997            // completion whether or not the outer query reads it; the item
5998            // itself reads back the id, so the set still stands for the rows
5999            // the mutations touched.
6000            Expr::SubQuery(stmt) if matches!(stmt.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)) => {
6001                items.push(IrFreeExpr::Scalar(self.dml_as_value(stmt.as_ref())?));
6002            }
6003            other => {
6004                items.push(IrFreeExpr::Scalar(self.compile_free_expr(other)?));
6005            }
6006        }
6007        Ok(())
6008    }
6009
6010    /// `metadata := metadata { author }` — a shape over a `with` binding or a
6011    /// for-loop variable. Both name a row source, so the shape reads their
6012    /// rows; a bare type name is deliberately not accepted here, because in an
6013    /// enclosing scope that prefix is factored (see `outer_anchor`) and would
6014    /// mean the outer row rather than the whole table.
6015    fn shape_over_binding(&mut self, expr: &Expr) -> Result<Option<IrExpr>, PyQLError> {
6016        let Expr::Shape(sh) = expr else {
6017            return Ok(None);
6018        };
6019        let Some(Expr::Path(p)) = sh.expr.as_ref() else {
6020            return Ok(None);
6021        };
6022        let Some(ast::PathStep::Name(root)) = p.steps.first() else {
6023            return Ok(None);
6024        };
6025        if p.partial || (self.cte_object_type(root).is_none() && !self.for_var_types.contains_key(root)) {
6026            return Ok(None);
6027        }
6028        self.free_object_link_field(expr)
6029    }
6030
6031    /// A free object's field holding an object with a shape (`{ device := d
6032    /// { id } }`). A free shape is a real object type whose fields are real
6033    /// pointers, so an object field stays an object; without
6034    /// this it would compile to the object's bare id, which is not what was
6035    /// asked for.
6036    fn free_object_link_field(&mut self, expr: &Expr) -> Result<Option<IrExpr>, PyQLError> {
6037        let Expr::Shape(sh) = expr else {
6038            return Ok(None);
6039        };
6040        let Some(Expr::Path(p)) = sh.expr.as_ref() else {
6041            return Ok(None);
6042        };
6043        if p.partial {
6044            return Ok(None);
6045        }
6046        let rest_steps = p.steps.as_slice();
6047        let Some(ast::PathStep::Name(name)) = rest_steps.first() else {
6048            return Ok(None);
6049        };
6050        let cte_name = self.cte_object_type(name).map(|_| name.clone());
6051        let Ok(td) = self.resolve_path_root(name) else {
6052            return Ok(None);
6053        };
6054        let table = self.row_source_table(name, td);
6055        // `account := resource.account { id }` — the field holds what a walk
6056        // off the binding lands on, so it is that walk with the shape on it
6057        // rather than a plain read of the binding.
6058        if rest_steps.len() > 1 {
6059            let synthetic = ast::SelectStmt {
6060                result: Expr::Path(p.clone()),
6061                filter: None,
6062                order_by: vec![],
6063                offset: None,
6064                limit: None,
6065                lock: None,
6066            };
6067            let path_select = self.compile_path_select(&synthetic, p, &sh.elements, false)?;
6068            return Ok(Some(IrExpr::ObjectPathSubquery(Box::new(path_select))));
6069        }
6070        let alias = self.fresh_alias();
6071        let source = IrSource {
6072            poly: self.poly_fanout_for(&format!("{}::{}", td.module, td.name)),
6073            type_name: format!("{}::{}", td.module, td.name),
6074            table,
6075            alias: alias.clone(),
6076        };
6077        // Same as reading the binding by name: its own shape may have
6078        // declared pointers that exist on no type.
6079        let outer_declared = std::mem::replace(
6080            &mut self.active_declared_pointers,
6081            cte_name
6082                .as_deref()
6083                .and_then(|n| self.cte_declared_pointers.get(n).cloned())
6084                .unwrap_or_default(),
6085        );
6086        let shape = self.compile_shape(&sh.elements, td, &alias, &td.module);
6087        self.active_declared_pointers = outer_declared;
6088        // A for-loop variable holds one row's key, so the select has to be
6089        // narrowed to it -- unfiltered it would read the whole table.
6090        let filter = self.for_var_types.contains_key(name).then(|| {
6091            IrExpr::BinOp(Box::new(IrBinOp {
6092                left: IrExpr::ColumnRef {
6093                    alias: alias.clone(),
6094                    column: "id".to_string(),
6095                    pg_type: "uuid".to_string(),
6096                },
6097                op: ast::BinOpKind::Eq,
6098                right: self.for_var_ref(name),
6099            }))
6100        });
6101        Ok(Some(IrExpr::ObjectSubquery(Box::new(IrSelect::schema_bound(
6102            source, shape?, filter,
6103        )))))
6104    }
6105
6106    /// One field of a free object written as a whole SELECT's result. A
6107    /// mutation is allowed here for the same reason it is allowed as an item
6108    /// of a free set — see `dml_as_value`.
6109    fn free_object_field(&mut self, expr: &Expr) -> Result<IrExpr, PyQLError> {
6110        if let Some(object) = self.free_object_link_field(expr)? {
6111            return Ok(object);
6112        }
6113        match expr {
6114            Expr::SubQuery(stmt) if matches!(stmt.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)) => {
6115                self.dml_as_value(stmt.as_ref())
6116            }
6117            other => self.compile_free_expr(other),
6118        }
6119    }
6120
6121    fn compile_free_select(
6122        &mut self,
6123        sel: &ast::SelectStmt,
6124        result_expr: &Expr,
6125        distinct: bool,
6126    ) -> Result<IrSelect, PyQLError> {
6127        // `{ a := 1 } if cond else {}` — read as a scalar `CASE` it comes back
6128        // as a bare record instead of an object, so the object is the row and
6129        // the condition decides whether there is one.
6130        if let Expr::IfElse(ie) = result_expr {
6131            let is_empty_set = |expr: &Expr| matches!(expr, Expr::Set(items) if items.is_empty());
6132            let is_free_object = |expr: &Expr| matches!(expr, Expr::Shape(sh) if sh.expr.is_none());
6133            let guarded = if is_free_object(&ie.if_expr) && is_empty_set(&ie.else_expr) {
6134                Some((&ie.if_expr, false))
6135            } else if is_empty_set(&ie.if_expr) && is_free_object(&ie.else_expr) {
6136                Some((&ie.else_expr, true))
6137            } else {
6138                None
6139            };
6140            if let Some((object, negated)) = guarded {
6141                let condition = self.compile_free_expr(&ie.condition)?;
6142                let condition = if negated {
6143                    IrExpr::UnaryOp(Box::new(IrUnaryOp {
6144                        op: ast::UnaryOpKind::Not,
6145                        operand: condition,
6146                    }))
6147                } else {
6148                    condition
6149                };
6150                let mut select = self.compile_free_select(sel, object, distinct)?;
6151                select.filter = and_conditions(select.filter, vec![condition]);
6152                return Ok(select);
6153            }
6154        }
6155        let items: Vec<IrFreeExpr> = match result_expr {
6156            Expr::Union(_, _) | Expr::Set(_) => {
6157                let mut union_items = vec![];
6158                self.collect_union_items(result_expr, &mut union_items)?;
6159                // Type-check UNION operands: all scalar branches must be in the same type family.
6160                let mut first: Option<(String, String)> = None; // (pg_type, pyql_name)
6161                for item in &union_items {
6162                    if let IrFreeExpr::Scalar(expr) = item
6163                        && let Some(t) = infer_ir_type(expr)
6164                    {
6165                        let t = t.to_string();
6166                        if let Some((ft, fq)) = &first {
6167                            if !types_compatible(ft, &t) {
6168                                return Err(PyQLError::Type(PyQLTypeError {
6169                                    message: format!(
6170                                        "operator 'UNION' cannot be applied to operands of type '{}' and '{}'",
6171                                        fq,
6172                                        pg_type_to_pyql(&t),
6173                                    ),
6174                                    position: Position { line: 0, col: 0 },
6175                                }));
6176                            }
6177                        } else {
6178                            first = Some((t.clone(), pg_type_to_pyql(&t).to_string()));
6179                        }
6180                    }
6181                }
6182                union_items
6183            }
6184            Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
6185                if let ast::PathStep::Name(n) = &p.steps[0] {
6186                    if self
6187                        .cte_types
6188                        .get(n.as_str())
6189                        .map(|t| !t.contains("::"))
6190                        .unwrap_or(false)
6191                    {
6192                        vec![IrFreeExpr::CtePassthrough(n.clone())]
6193                    } else {
6194                        vec![IrFreeExpr::Scalar(self.compile_free_expr(result_expr)?)]
6195                    }
6196                } else {
6197                    vec![IrFreeExpr::Scalar(self.compile_free_expr(result_expr)?)]
6198                }
6199            }
6200            Expr::Shape(s) if s.expr.is_none() => {
6201                let fields = s
6202                    .elements
6203                    .iter()
6204                    .map(|el| -> Result<(String, IrExpr), PyQLError> {
6205                        let name = path_leaf(&el.path)?.to_string();
6206                        let expr = el.compexpr.as_ref().ok_or_else(|| {
6207                            self.type_err("free object field must have a value expression (':= expr')")
6208                        })?;
6209                        Ok((name, self.free_object_field(expr)?))
6210                    })
6211                    .collect::<Result<Vec<_>, _>>()?;
6212                vec![IrFreeExpr::FreeObject(fields)]
6213            }
6214            Expr::Tuple(exprs) => {
6215                // An element may hold an object with a shape
6216                // (`select (offering { id }, revision { id })`), which is the
6217                // same question a free object's field asks.
6218                let ir = exprs
6219                    .iter()
6220                    .map(|e| self.free_object_field(e))
6221                    .collect::<Result<_, _>>()?;
6222                vec![IrFreeExpr::Tuple(ir)]
6223            }
6224            Expr::NamedTuple(fields) => {
6225                let ir = fields
6226                    .iter()
6227                    .map(|(name, e)| Ok((name.clone(), self.free_object_field(e)?)))
6228                    .collect::<Result<Vec<_>, PyQLError>>()?;
6229                // jsonb has no member kind for an object, so a tuple holding
6230                // one is emitted as a composite row instead; the rest stay on
6231                // the jsonb encoding they have always had.
6232                // An array of objects (`brands := array_agg((select b { * }))`)
6233                // flattens in jsonb the same way.
6234                let holds_an_object = ir.iter().any(|(_, e)| match e {
6235                    IrExpr::ObjectSubquery(_) | IrExpr::ObjectPathSubquery(_) => true,
6236                    IrExpr::ArrayFromSelect(source) => match source.as_ref() {
6237                        IrArraySource::ObjectSelect(_) | IrArraySource::ObjectFunction(_) | IrArraySource::Group(_) => {
6238                            true
6239                        }
6240                        IrArraySource::PathSelect(ps) => matches!(ps.result, IrPathResult::Object { .. }),
6241                        _ => false,
6242                    },
6243                    _ => false,
6244                });
6245                if holds_an_object {
6246                    vec![IrFreeExpr::NamedTupleRow(ir)]
6247                } else {
6248                    vec![IrFreeExpr::Scalar(IrExpr::NamedTuple {
6249                        fields: ir,
6250                        is_free_object: false,
6251                    })]
6252                }
6253            }
6254            other => vec![IrFreeExpr::Scalar(self.compile_free_expr(other)?)],
6255        };
6256
6257        let order_by = sel
6258            .order_by
6259            .iter()
6260            .map(|s| self.compile_sort_ctx(s, None))
6261            .collect::<Result<Vec<_>, _>>()?;
6262
6263        let offset = sel.offset.as_ref().map(|e| self.compile_free_expr(e)).transpose()?;
6264        let limit = sel.limit.as_ref().map(|e| self.compile_free_expr(e)).transpose()?;
6265        let filter = sel.filter.as_ref().map(|f| self.compile_free_filter(f)).transpose()?;
6266
6267        Ok(IrSelect {
6268            rows: items.into_iter().map(IrRowSource::Free).collect(),
6269            filter,
6270            order_by,
6271            offset,
6272            limit,
6273            distinct,
6274            dml_source: None,
6275            polymorphic: false,
6276            poly_implementors: vec![],
6277            poly_columns: vec![],
6278            lock: None,
6279        })
6280    }
6281
6282    /// The FILTER of a free SELECT, which has no row to apply itself to: it
6283    /// gates the result the select already produced.
6284    ///
6285    /// A condition over a type-rooted path is a *set* of booleans, one per row
6286    /// of that type, so the result survives when any of them holds — the same
6287    /// reading PyQL gives it, and the reason for the warning: a filter is
6288    /// meant to be one boolean, and `any()` says so outright.
6289    fn compile_free_filter(&mut self, filter: &Expr) -> Result<IrExpr, PyQLError> {
6290        self.as_condition(|c| c.compile_free_filter_inner(filter))
6291    }
6292
6293    fn compile_free_filter_inner(&mut self, filter: &Expr) -> Result<IrExpr, PyQLError> {
6294        let Some(root) = self.find_path_root_in_expr(filter) else {
6295            return self.compile_free_expr(filter);
6296        };
6297        // A binding roots a path here as readily as a type name does; resolved
6298        // as a type it is simply unknown.
6299        let td = self.resolve_path_root(&root)?;
6300        let table = self.row_source_table(&root, td);
6301        let alias = self.fresh_alias();
6302        let source = IrSource {
6303            poly: None,
6304            type_name: format!("{}::{}", td.module, td.name),
6305            table,
6306            alias: alias.clone(),
6307        };
6308        let condition = self.compile_expr(&Self::rewrite_abs_to_partial(filter.clone(), &root), td, &alias)?;
6309        self.warnings.push(format!(
6310            "possibly more than one element returned by an expression in a FILTER clause \
6311             (every '{root}'); wrap with any() to make intent explicit",
6312        ));
6313        Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
6314            op: ast::UnaryOpKind::Exists,
6315            operand: IrExpr::Subquery(Box::new(IrSelect::schema_bound(source, vec![], Some(condition)))),
6316        })))
6317    }
6318
6319    // ── SELECT ────────────────────────────────────────────────────────────────────
6320
6321    /// The `(qualified type, source table)` of each branch of an object-set
6322    /// union, or `None` if any branch is something other than a direct
6323    /// reference to an object set (a WITH binding or a bare type name).
6324    /// The type a union of object sets carries: the one branch type every
6325    /// other branch is or implements. `A union (insert B)` where B implements
6326    /// A is an A-set, which is how PyQL reads it; requiring the branches
6327    /// to name the same type refuses a get-or-create over an interface.
6328    fn common_union_type(&self, branches: &[(String, String)]) -> Option<String> {
6329        let covers_all = |candidate: &String| {
6330            branches.iter().all(|(t, _)| {
6331                t == candidate
6332                    || self
6333                        .resolve_type(t)
6334                        .map(|td| Self::is_or_implements(td, candidate))
6335                        .unwrap_or(false)
6336            })
6337        };
6338        if let Some(branch) = branches.iter().map(|(t, _)| t.clone()).find(&covers_all) {
6339            return Some(branch);
6340        }
6341        // None of the branches is the others' supertype, but they may still
6342        // share one — `IncomingTransformer union OutgoingTransformer` are both
6343        // `Transformer`. The first branch's ancestors are the only candidates,
6344        // since a common type has to be among them.
6345        let first = self.resolve_type(&branches.first()?.0).ok()?;
6346        first
6347            .interfaces
6348            .iter()
6349            .chain(first.parents.iter())
6350            .find(|ancestor| covers_all(ancestor))
6351            .cloned()
6352    }
6353
6354    fn object_union_branches(&self, expr: &Expr) -> Option<Vec<(String, String)>> {
6355        fn flatten<'e>(expr: &'e Expr, out: &mut Vec<&'e Expr>) {
6356            match expr {
6357                Expr::Union(a, b) => {
6358                    flatten(a, out);
6359                    flatten(b, out);
6360                }
6361                other => out.push(other),
6362            }
6363        }
6364        if !matches!(expr, Expr::Union(_, _)) {
6365            return None;
6366        }
6367        let mut operands: Vec<&Expr> = vec![];
6368        flatten(expr, &mut operands);
6369
6370        let mut branches: Vec<(String, String)> = vec![];
6371        for operand in operands {
6372            let Expr::Path(path) = operand else { return None };
6373            if path.partial || path.steps.len() != 1 {
6374                return None;
6375            }
6376            let ast::PathStep::Name(name) = &path.steps[0] else {
6377                return None;
6378            };
6379            match self.cte_object_type(name) {
6380                Some(qualified) => branches.push((qualified, self.cte_source_table(&self.cte_sql_name(name)))),
6381                None => match self.resolve_type(name) {
6382                    Ok(td) => branches.push((format!("{}::{}", td.module, td.name), td.table.clone())),
6383                    Err(_) => return None,
6384                },
6385            }
6386        }
6387        Some(branches)
6388    }
6389
6390    /// `select (a union b) { shape }` where every branch is a set of the same
6391    /// object type — a WITH binding or a bare type name. Each branch becomes
6392    /// its own bound row; the SQL layer unions them in the FROM clause, so the
6393    /// shape and modifiers are compiled once, against the shared type.
6394    ///
6395    /// `Ok(None)` when the result isn't an object union, leaving the ordinary
6396    /// single-source path (and its own errors) in charge.
6397    /// Give every operand of an object union a name to be read by.
6398    ///
6399    /// `object_union_branches` recognises operands that already name a
6400    /// relation -- a type, or a `with` binding -- because a union is emitted
6401    /// as one relation per branch. An operand written inline (a sub-select, or
6402    /// an insert in a get-or-create) names nothing yet, so it is hoisted into
6403    /// a CTE of its own and replaced by that name, leaving an ordinary union
6404    /// of names behind.
6405    fn name_union_operands(&mut self, expr: &Expr) -> Result<Option<Expr>, PyQLError> {
6406        let Expr::Union(left, right) = expr else {
6407            return Ok(None);
6408        };
6409        let mut rewritten = false;
6410        let mut name_one = |compiler: &mut Self, operand: &Expr| -> Result<Expr, PyQLError> {
6411            match operand {
6412                Expr::Union(_, _) => match compiler.name_union_operands(operand)? {
6413                    Some(inner) => {
6414                        rewritten = true;
6415                        Ok(inner)
6416                    }
6417                    None => Ok(operand.clone()),
6418                },
6419                Expr::SubQuery(stmt) => {
6420                    let (cte_name, type_name) = compiler.hoist_dml_as_cte(stmt.as_ref())?;
6421                    // An insert names its subject unqualified (`Credentials`),
6422                    // a select yields an already-qualified name; a branch that
6423                    // resolves to no object type is not one of these at all.
6424                    let Ok(td) = compiler.resolve_type(&type_name) else {
6425                        return Ok(operand.clone());
6426                    };
6427                    compiler
6428                        .cte_types
6429                        .insert(cte_name.clone(), format!("{}::{}", td.module, td.name));
6430                    rewritten = true;
6431                    Ok(Expr::Path(ast::Path {
6432                        steps: vec![ast::PathStep::Name(cte_name)],
6433                        partial: false,
6434                    }))
6435                }
6436                // `a.answers.question union a.current_question` — a walk names
6437                // no relation either, so it is hoisted the same way an inline
6438                // select is.
6439                Expr::Path(path) if !path.partial && path.steps.len() > 1 => {
6440                    let synthetic = Stmt::Select(ast::SelectStmt {
6441                        result: operand.clone(),
6442                        filter: None,
6443                        order_by: vec![],
6444                        offset: None,
6445                        limit: None,
6446                        lock: None,
6447                    });
6448                    let Ok((cte_name, type_name)) = compiler.hoist_dml_as_cte(&synthetic) else {
6449                        return Ok(operand.clone());
6450                    };
6451                    let Ok(td) = compiler.resolve_type(&type_name) else {
6452                        return Ok(operand.clone());
6453                    };
6454                    compiler
6455                        .cte_types
6456                        .insert(cte_name.clone(), format!("{}::{}", td.module, td.name));
6457                    rewritten = true;
6458                    Ok(Expr::Path(ast::Path {
6459                        steps: vec![ast::PathStep::Name(cte_name)],
6460                        partial: false,
6461                    }))
6462                }
6463                other => Ok(other.clone()),
6464            }
6465        };
6466        let left = name_one(self, left)?;
6467        let right = name_one(self, right)?;
6468        if !rewritten {
6469            return Ok(None);
6470        }
6471        Ok(Some(Expr::Union(Box::new(left), Box::new(right))))
6472    }
6473
6474    /// `A if cond else B` over object sets, as the union it is.
6475    ///
6476    /// Picking between two *sets* is not the scalar `CASE` an if/else over
6477    /// values compiles to: it stands for A's rows when the condition holds and
6478    /// B's when it does not, which is `(A filter cond) union (B filter not
6479    /// cond)`. Rewritten here rather than given its own IR, so it reaches the
6480    /// union machinery that already knows how to read one relation per branch.
6481    /// Hand a guarded mutation its condition: an insert folds it into its own
6482    /// `WHERE`, an update or a delete ANDs it onto the rows it narrows to.
6483    fn set_mutation_guard(&mut self, branch: &Expr, condition: Expr) {
6484        let stmt = match branch {
6485            Expr::SubQuery(stmt) => Some(stmt.as_ref()),
6486            Expr::Shape(sh) => match sh.expr.as_ref() {
6487                Some(Expr::SubQuery(stmt)) => Some(stmt.as_ref()),
6488                _ => None,
6489            },
6490            _ => None,
6491        };
6492        match stmt {
6493            Some(Stmt::Update(_)) => self.pending_update_guard = Some(condition),
6494            Some(Stmt::Delete(_)) => self.pending_delete_guard = Some(condition),
6495            _ => self.pending_insert_guard = Some(condition),
6496        }
6497    }
6498
6499    fn object_if_else_as_union(&mut self, expr: &Expr) -> Option<Expr> {
6500        let Expr::IfElse(ie) = expr else {
6501            return None;
6502        };
6503        fn is_empty_set(expr: &Expr) -> bool {
6504            matches!(expr, Expr::Set(items) if items.is_empty())
6505        }
6506        let yields_objects = |branch: &Expr| match branch {
6507            Expr::Path(p) if !p.partial && p.steps.len() == 1 => match &p.steps[0] {
6508                ast::PathStep::Name(n) => self.cte_object_type(n).is_some() || self.resolve_type(n).is_ok(),
6509                _ => false,
6510            },
6511            Expr::SubQuery(stmt) => {
6512                // A select over a walk (`select resource.revisions`) yields
6513                // objects too, and names no type for `dml_subject_type` to
6514                // report; what it lands on is what the branch carries.
6515                if let Some(ast::SelectStmt {
6516                    result: Expr::Path(path),
6517                    ..
6518                }) = innermost_select(stmt.as_ref())
6519                    && !path.partial
6520                    && path.steps.len() > 1
6521                    && let Some(ast::PathStep::Name(root)) = path.steps.first()
6522                    && let Ok(root_td) = self.resolve_path_root(root)
6523                {
6524                    return matches!(
6525                        self.walk_path_types(root_td, &path.steps[1..], MAX_COMPUTED_SPLICES),
6526                        (_, Some(_))
6527                    );
6528                }
6529                matches!(
6530                    self.dml_subject_type(stmt.as_ref()),
6531                    Ok(name) if self.resolve_type(&name).is_ok()
6532                )
6533            }
6534            // A branch may carry the shape the result is read with.
6535            Expr::Shape(sh) => match sh.expr.as_ref() {
6536                Some(Expr::SubQuery(stmt)) => {
6537                    matches!(self.dml_subject_type(stmt.as_ref()), Ok(name) if self.resolve_type(&name).is_ok())
6538                }
6539                Some(Expr::Path(p)) if !p.partial && p.steps.len() == 1 => match &p.steps[0] {
6540                    ast::PathStep::Name(n) => self.cte_object_type(n).is_some() || self.resolve_type(n).is_ok(),
6541                    _ => false,
6542                },
6543                _ => false,
6544            },
6545            _ => false,
6546        };
6547        // `{}` contributes no rows, so the union degenerates to the other
6548        // branch under its own guard -- which is what the full rewrite
6549        // (`SELECT A WHERE Cond UNION ALL SELECT B WHERE NOT Cond`) reduces
6550        // to when one side is empty.
6551        // A mutation under a condition is *not* handled here. Guarding the
6552        // branch only filters what is read back: the mutation is its own CTE
6553        // and Postgres runs it regardless, so `(insert …) if cond else {}`
6554        // would insert even when the condition is false -- silently, which is
6555        // far worse than the compile error it replaced. It needs the
6556        // condition folded into the mutation itself.
6557        fn mutating_stmt(branch: &Expr) -> Option<&Stmt> {
6558            match branch {
6559                Expr::SubQuery(stmt) => Some(stmt.as_ref()),
6560                Expr::Shape(sh) => match sh.expr.as_ref() {
6561                    Some(Expr::SubQuery(stmt)) => Some(stmt.as_ref()),
6562                    _ => None,
6563                },
6564                _ => None,
6565            }
6566            .filter(|stmt| matches!(stmt, Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)))
6567        }
6568        // Guarding a branch only filters what is read back, while the
6569        // mutation is its own CTE that Postgres runs regardless -- so the
6570        // condition has to go into the mutation itself, which is what
6571        // `set_mutation_guard` arranges for all three.
6572        let (if_yields_objects, else_yields_objects) = (yields_objects(&ie.if_expr), yields_objects(&ie.else_expr));
6573        let guard = |branch: &Expr, condition: Expr| {
6574            Expr::SubQuery(Box::new(Stmt::Select(ast::SelectStmt {
6575                result: branch.clone(),
6576                filter: Some(condition),
6577                order_by: vec![],
6578                offset: None,
6579                limit: None,
6580                lock: None,
6581            })))
6582        };
6583        let negated = Expr::UnaryOp(Box::new(ast::UnaryOp {
6584            op: ast::UnaryOpKind::Not,
6585            operand: ie.condition.clone(),
6586        }));
6587        let guarded_insert = |branch: &Expr, other: &Expr| {
6588            mutating_stmt(branch).is_some() && matches!(other, Expr::Set(items) if items.is_empty())
6589        };
6590        if guarded_insert(&ie.if_expr, &ie.else_expr) {
6591            self.set_mutation_guard(&ie.if_expr, ie.condition.clone());
6592            return Some(ie.if_expr.clone());
6593        }
6594        if guarded_insert(&ie.else_expr, &ie.if_expr) {
6595            let negated = Expr::UnaryOp(Box::new(ast::UnaryOp {
6596                op: ast::UnaryOpKind::Not,
6597                operand: ie.condition.clone(),
6598            }));
6599            self.set_mutation_guard(&ie.else_expr, negated);
6600            return Some(ie.else_expr.clone());
6601        }
6602        // `(insert …) if not exists existing else (update existing set …)` —
6603        // the upsert idiom. Both branches mutate, so both carry their own
6604        // condition; each statement kind has its own pending slot, so two of
6605        // different kinds can be guarded at once. Two of the *same* kind would
6606        // have one guard overwrite the other, and fall through to the refusal
6607        // below rather than compile to a mutation that runs regardless.
6608        if let (Some(if_stmt), Some(else_stmt)) = (mutating_stmt(&ie.if_expr), mutating_stmt(&ie.else_expr))
6609            && std::mem::discriminant(if_stmt) != std::mem::discriminant(else_stmt)
6610        {
6611            self.set_mutation_guard(&ie.if_expr, ie.condition.clone());
6612            self.set_mutation_guard(&ie.else_expr, negated);
6613            return Some(Expr::Union(
6614                Box::new(ie.if_expr.clone()),
6615                Box::new(ie.else_expr.clone()),
6616            ));
6617        }
6618        // `existing if exists existing else (insert …)` — the insert still has
6619        // to carry the condition itself, but the other branch is a set of its
6620        // own, so the result is the full rewrite: both branches under
6621        // opposite guards, unioned.
6622        let reads_objects = |branch: &Expr, yields: bool| mutating_stmt(branch).is_none() && yields;
6623        if mutating_stmt(&ie.if_expr).is_some() && reads_objects(&ie.else_expr, else_yields_objects) {
6624            self.set_mutation_guard(&ie.if_expr, ie.condition.clone());
6625            return Some(Expr::Union(
6626                Box::new(ie.if_expr.clone()),
6627                Box::new(guard(&ie.else_expr, negated)),
6628            ));
6629        }
6630        if mutating_stmt(&ie.else_expr).is_some() && reads_objects(&ie.if_expr, if_yields_objects) {
6631            self.set_mutation_guard(&ie.else_expr, negated);
6632            return Some(Expr::Union(
6633                Box::new(guard(&ie.if_expr, ie.condition.clone())),
6634                Box::new(ie.else_expr.clone()),
6635            ));
6636        }
6637        if mutating_stmt(&ie.if_expr).is_some() || mutating_stmt(&ie.else_expr).is_some() {
6638            return None;
6639        }
6640        let (if_empty, else_empty) = (is_empty_set(&ie.if_expr), is_empty_set(&ie.else_expr));
6641        if if_empty && else_empty {
6642            return None;
6643        }
6644        if !(if_empty || if_yields_objects) || !(else_empty || else_yields_objects) {
6645            return None;
6646        }
6647        if else_empty {
6648            return Some(guard(&ie.if_expr, ie.condition.clone()));
6649        }
6650        if if_empty {
6651            return Some(guard(&ie.else_expr, negated));
6652        }
6653        Some(Expr::Union(
6654            Box::new(guard(&ie.if_expr, ie.condition.clone())),
6655            Box::new(guard(&ie.else_expr, negated)),
6656        ))
6657    }
6658
6659    /// `granted ?? existing` over object sets is the choice `A if exists A
6660    /// else B` spells, so it reaches the same union machinery. A scalar
6661    /// coalesce rewritten this way is simply not an object union, and the
6662    /// caller falls back to compiling what was written.
6663    fn object_coalesce_as_if_else(expr: &Expr) -> Option<Expr> {
6664        fn as_if_else(b: &ast::BinOp) -> Option<Expr> {
6665            (b.op == ast::BinOpKind::Coalesce).then(|| {
6666                Expr::IfElse(Box::new(ast::IfElse {
6667                    condition: Expr::UnaryOp(Box::new(ast::UnaryOp {
6668                        op: ast::UnaryOpKind::Exists,
6669                        operand: b.left.clone(),
6670                    })),
6671                    if_expr: b.left.clone(),
6672                    else_expr: b.right.clone(),
6673                }))
6674            })
6675        }
6676        match expr {
6677            Expr::BinOp(b) => as_if_else(b),
6678            Expr::Shape(sh) => match sh.expr.as_ref() {
6679                Some(Expr::BinOp(b)) => Some(Expr::Shape(Box::new(ast::ShapeExpr {
6680                    expr: Some(as_if_else(b)?),
6681                    elements: sh.elements.clone(),
6682                    marker_offset: sh.marker_offset,
6683                }))),
6684                _ => None,
6685            },
6686            _ => None,
6687        }
6688    }
6689
6690    fn try_compile_object_union_select(
6691        &mut self,
6692        sel: &ast::SelectStmt,
6693        result_expr: &Expr,
6694        distinct: bool,
6695    ) -> Result<Option<IrSelect>, PyQLError> {
6696        let coalesced;
6697        let result_expr = match Self::object_coalesce_as_if_else(result_expr) {
6698            Some(rewritten) => {
6699                coalesced = rewritten;
6700                &coalesced
6701            }
6702            None => result_expr,
6703        };
6704        let as_union;
6705        let (union_expr, shape_elements): (&Expr, &[ShapeElement]) = match result_expr {
6706            Expr::Union(_, _) => (result_expr, &[]),
6707            Expr::IfElse(_) => match self.object_if_else_as_union(result_expr) {
6708                // One branch empty leaves a single guarded set, not a union;
6709                // that is an ordinary select over what the branch names.
6710                Some(rewritten @ Expr::SubQuery(_)) => {
6711                    return self.compile_select(sel, &rewritten, distinct).map(Some);
6712                }
6713                Some(rewritten) if !matches!(rewritten, Expr::Union(_, _)) => {
6714                    return self.compile_select(sel, &rewritten, distinct).map(Some);
6715                }
6716                Some(rewritten) => {
6717                    as_union = rewritten;
6718                    (&as_union, &[] as &[ShapeElement])
6719                }
6720                None => return Ok(None),
6721            },
6722            Expr::Shape(shape) => match &shape.expr {
6723                Some(inner @ Expr::Union(_, _)) => (inner, shape.elements.as_slice()),
6724                Some(inner @ Expr::IfElse(_)) => match self.object_if_else_as_union(inner) {
6725                    Some(Expr::SubQuery(stmt)) => {
6726                        let shaped = Expr::Shape(Box::new(ast::ShapeExpr {
6727                            expr: Some(Expr::SubQuery(stmt)),
6728                            elements: shape.elements.clone(),
6729                            marker_offset: None,
6730                        }));
6731                        return self.compile_select(sel, &shaped, distinct).map(Some);
6732                    }
6733                    Some(rewritten) if !matches!(rewritten, Expr::Union(_, _)) => {
6734                        let shaped = match rewritten {
6735                            Expr::Shape(_) => rewritten,
6736                            other => Expr::Shape(Box::new(ast::ShapeExpr {
6737                                expr: Some(other),
6738                                elements: shape.elements.clone(),
6739                                marker_offset: None,
6740                            })),
6741                        };
6742                        return self.compile_select(sel, &shaped, distinct).map(Some);
6743                    }
6744                    Some(rewritten) => {
6745                        as_union = rewritten;
6746                        (&as_union, shape.elements.as_slice())
6747                    }
6748                    None => return Ok(None),
6749                },
6750                _ => return Ok(None),
6751            },
6752            _ => return Ok(None),
6753        };
6754
6755        let named;
6756        let union_expr = match self.name_union_operands(union_expr)? {
6757            Some(rewritten) => {
6758                named = rewritten;
6759                &named
6760            }
6761            None => union_expr,
6762        };
6763        let Some(branches) = self.object_union_branches(union_expr) else {
6764            return Ok(None);
6765        };
6766
6767        let Some(first_type) = self.common_union_type(&branches) else {
6768            let (first_type, _) = &branches[0];
6769            let (other, _) = branches
6770                .iter()
6771                .find(|(t, _)| t != first_type)
6772                .expect("no common type means at least two differ");
6773            return Err(self.type_err(&format!(
6774                "operator 'UNION' cannot be applied to operands of type '{first_type}' and '{other}'"
6775            )));
6776        };
6777        let first_type = &first_type;
6778        if sel.lock.is_some() {
6779            return Err(self.type_err(
6780                "FOR UPDATE/SHARE cannot be used on a UNION — its rows come from more than one \
6781                 source, which a single locking clause can't target",
6782            ));
6783        }
6784        let td = self.resolve_type(first_type)?;
6785        let alias = self.fresh_alias();
6786
6787        self.anchors.push(SelectAnchor {
6788            type_name: td.name.clone(),
6789            qualified: format!("{}::{}", td.module, td.name),
6790            alias: alias.clone(),
6791            detached: std::mem::take(&mut self.pending_detached),
6792            declared_on: None,
6793        });
6794        let clauses = (|compiler: &mut Self| -> Result<_, PyQLError> {
6795            let shape = compiler.compile_shape(shape_elements, td, &alias, &td.module)?;
6796            let filter = sel
6797                .filter
6798                .as_ref()
6799                .map(|f| compiler.compile_expr(f, td, &alias))
6800                .transpose()?;
6801            let order_by = sel
6802                .order_by
6803                .iter()
6804                .map(|o| compiler.compile_sort(o, td, &alias))
6805                .collect::<Result<Vec<_>, _>>()?;
6806            let offset = sel
6807                .offset
6808                .as_ref()
6809                .map(|e| compiler.compile_expr(e, td, &alias))
6810                .transpose()?;
6811            let limit = sel
6812                .limit
6813                .as_ref()
6814                .map(|e| compiler.compile_expr(e, td, &alias))
6815                .transpose()?;
6816            Ok((shape, filter, order_by, offset, limit))
6817        })(self);
6818        self.anchors.pop();
6819        let (shape, mut filter, order_by, offset, limit) = clauses?;
6820        // A select whose subject is a for-loop variable reads the one row that
6821        // variable holds, not the whole table.
6822        if let Some(var) = Self::subject_name(result_expr).filter(|n| self.for_var_types.contains_key(n)) {
6823            let narrowed = IrExpr::BinOp(Box::new(IrBinOp {
6824                left: IrExpr::ColumnRef {
6825                    alias: alias.clone(),
6826                    column: "id".to_string(),
6827                    pg_type: "uuid".to_string(),
6828                },
6829                op: ast::BinOpKind::Eq,
6830                right: self.for_var_ref(&var),
6831            }));
6832            filter = and_conditions(filter, vec![narrowed]);
6833        }
6834
6835        // `brands union offerings` — types that only share an ancestor carry
6836        // different columns, so each branch is read as that ancestor: its
6837        // columns, and the concrete type each row has. An abstract branch's
6838        // rows already say which type they are.
6839        let heterogeneous = branches.iter().any(|(t, _)| t != first_type);
6840        let common_columns = if heterogeneous {
6841            self.collect_poly_info(first_type).1
6842        } else {
6843            vec![]
6844        };
6845        let rows = branches
6846            .into_iter()
6847            .map(|(type_name, table)| IrRowSource::Bound {
6848                source: IrSource {
6849                    poly: if heterogeneous {
6850                        self.poly_fanout_for(&type_name)
6851                    } else {
6852                        None
6853                    },
6854                    type_name,
6855                    table,
6856                    alias: alias.clone(),
6857                },
6858                shape: shape.clone(),
6859            })
6860            .collect();
6861
6862        Ok(Some(IrSelect {
6863            rows,
6864            filter,
6865            order_by,
6866            offset,
6867            limit,
6868            distinct,
6869            dml_source: None,
6870            polymorphic: false,
6871            poly_implementors: vec![],
6872            poly_columns: common_columns,
6873            lock: None,
6874        }))
6875    }
6876
6877    /// The bare name a select's result is written as, whether it carries a
6878    /// shape or not.
6879    fn subject_name(result_expr: &Expr) -> Option<String> {
6880        let path = match result_expr {
6881            Expr::Path(p) => p,
6882            Expr::Shape(sh) => match sh.expr.as_ref()? {
6883                Expr::Path(p) => p,
6884                _ => return None,
6885            },
6886            _ => return None,
6887        };
6888        if path.partial {
6889            return None;
6890        }
6891        match path.steps.as_slice() {
6892            [ast::PathStep::Name(n)] => Some(n.clone()),
6893            _ => None,
6894        }
6895    }
6896
6897    fn compile_select(
6898        &mut self,
6899        sel: &ast::SelectStmt,
6900        result_expr: &Expr,
6901        distinct: bool,
6902    ) -> Result<IrSelect, PyQLError> {
6903        if let Some(union_select) = self.try_compile_object_union_select(sel, result_expr, distinct)? {
6904            return Ok(union_select);
6905        }
6906        if let Some(rerooted) = self.bind_mutating_loop_source(result_expr)? {
6907            return self.compile_select(sel, &rerooted, distinct);
6908        }
6909        let (type_name, shape_elements, inner_stmt, cte_name) = self.extract_type_and_shape(result_expr)?;
6910        let td = self.resolve_type(&type_name)?;
6911        let alias = self.fresh_alias();
6912        let table = match cte_name {
6913            Some(ref cte) => self.cte_source_table(cte),
6914            None => Self::subject_name(result_expr)
6915                .map(|name| self.row_source_table(&name, td))
6916                .unwrap_or_else(|| td.table.clone()),
6917        };
6918        let source = IrSource {
6919            poly: self.poly_fanout_for(&format!("{}::{}", td.module, td.name)),
6920            type_name: format!("{}::{}", td.module, td.name),
6921            table,
6922            alias: alias.clone(),
6923        };
6924
6925        // On the stack for as long as this select's own clauses are being
6926        // compiled, so a `detached` select nested in one of them can find the
6927        // row it is being compared against. See `enclosing_anchor`.
6928        self.anchors.push(SelectAnchor {
6929            type_name: td.name.clone(),
6930            qualified: format!("{}::{}", td.module, td.name),
6931            alias: alias.clone(),
6932            detached: std::mem::take(&mut self.pending_detached),
6933            declared_on: None,
6934        });
6935        // A binding read back by name brings whatever its own shape declared
6936        // (`offering { publisher }`), which is on no type and has to be found
6937        // through the binding it was written on.
6938        let declared = match cte_name.as_deref() {
6939            Some(name) => self.cte_declared_pointers.get(name).cloned().unwrap_or_default(),
6940            // `select (select T { x := … }) filter .x…` — a nested select's
6941            // shape declares pointers on no type, just as a binding's does, and
6942            // the outer select's clauses read them the same way.
6943            None => inner_stmt
6944                .and_then(innermost_select)
6945                .and_then(|inner| match &inner.result {
6946                    Expr::Shape(sh) => Some(sh.elements.clone()),
6947                    _ => None,
6948                })
6949                .unwrap_or_default(),
6950        };
6951        // `*` on a binding takes in what the binding's shape computed, too.
6952        let splat_over_declared: Vec<ShapeElement>;
6953        let shape_elements = if shape_elements
6954            .iter()
6955            .any(|el| el.splat.is_some() && !matches!(el.path.steps.first(), Some(ast::PathStep::TypeIntersection(_))))
6956        {
6957            let named = |name: &str| {
6958                shape_elements
6959                    .iter()
6960                    .any(|el| el.splat.is_none() && path_leaf(&el.path).is_ok_and(|n| n == name))
6961            };
6962            let extra: Vec<ShapeElement> = declared
6963                .iter()
6964                .filter(|d| d.compexpr.is_some())
6965                .filter_map(|d| {
6966                    let name = path_leaf(&d.path).ok()?;
6967                    (!named(name)).then(|| ShapeElement {
6968                        path: ast::Path::relative(name),
6969                        splat: None,
6970                        nested: None,
6971                        compexpr: None,
6972                        op: ast::ShapeOp::Assign,
6973                        filter: None,
6974                        order_by: vec![],
6975                        offset: None,
6976                        limit: None,
6977                        marker_offset: None,
6978                    })
6979                })
6980                .collect();
6981            splat_over_declared = shape_elements.iter().cloned().chain(extra).collect();
6982            &splat_over_declared[..]
6983        } else {
6984            shape_elements
6985        };
6986        let outer_declared = std::mem::replace(&mut self.active_declared_pointers, declared);
6987        let clauses = (|compiler: &mut Self| -> Result<_, PyQLError> {
6988            let shape = compiler.compile_shape(shape_elements, td, &alias, &td.module)?;
6989            // This select's own shape declares pointers the same way, and its
6990            // clauses read them the same way: `select T { x := … } order by .x`
6991            // is legal, while one shape pointer reading another is not. Added
6992            // only now, after the shape is compiled, so the scope stops at the
6993            // clauses.
6994            compiler
6995                .active_declared_pointers
6996                .extend(shape_elements.iter().filter(|el| el.compexpr.is_some()).cloned());
6997            let filter = sel
6998                .filter
6999                .as_ref()
7000                .map(|f| compiler.as_condition(|c| c.compile_expr(f, td, &alias)))
7001                .transpose()?;
7002            let order_by = sel
7003                .order_by
7004                .iter()
7005                .map(|s| compiler.compile_sort(s, td, &alias))
7006                .collect::<Result<Vec<_>, _>>()?;
7007            let offset = sel
7008                .offset
7009                .as_ref()
7010                .map(|e| compiler.compile_expr(e, td, &alias))
7011                .transpose()?;
7012            let limit = sel
7013                .limit
7014                .as_ref()
7015                .map(|e| compiler.compile_expr(e, td, &alias))
7016                .transpose()?;
7017            Ok((shape, filter, order_by, offset, limit))
7018        })(self);
7019        self.anchors.pop();
7020        self.active_declared_pointers = outer_declared;
7021        let (shape, mut filter, order_by, offset, limit) = clauses?;
7022
7023        // A select whose subject is a for-loop variable reads the one row that
7024        // variable holds; unfiltered it would read the whole table.
7025        if let Some(var) = Self::subject_name(result_expr).filter(|n| self.for_var_types.contains_key(n)) {
7026            let narrowed = IrExpr::BinOp(Box::new(IrBinOp {
7027                left: IrExpr::ColumnRef {
7028                    alias: alias.clone(),
7029                    column: "id".to_string(),
7030                    pg_type: "uuid".to_string(),
7031                },
7032                op: ast::BinOpKind::Eq,
7033                right: self.for_var_ref(&var),
7034            }));
7035            filter = and_conditions(filter, vec![narrowed]);
7036        }
7037
7038        // Compile the inner DML if this is a SELECT-over-DML / SELECT-over-SELECT.
7039        let dml_source = inner_stmt.map(|s| self.compile_stmt(s).map(Box::new)).transpose()?;
7040        let mut shape = shape;
7041        if let Some(dml) = &dml_source {
7042            // `SELECT (INSERT …)` has no user-written CTE name: the emitter
7043            // wraps the DML in one of its own (`sql::DML_CTE`), and the
7044            // junction CTEs are named after it.
7045            let dml_cte = cte_name.as_deref().unwrap_or(crate::sql::DML_CTE);
7046            Self::read_nested_links_from_their_ctes(dml, Some(dml_cte), &mut shape);
7047        }
7048
7049        let polymorphic = self.is_polymorphic(td);
7050        let (poly_implementors, poly_columns) = if polymorphic {
7051            let iface_qname = format!("{}::{}", td.module, td.name);
7052            (self.find_poly_implementors(&iface_qname), Self::poly_dml_columns(td))
7053        } else {
7054            (vec![], vec![])
7055        };
7056
7057        // `FOR UPDATE`/`FOR SHARE`/... — only valid when every output row
7058        // maps 1:1 to a single physical table row, the same restriction
7059        // Postgres itself enforces (it rejects the same combinations with
7060        // its own "FOR UPDATE is not allowed with ..." errors). `DISTINCT`
7061        // and an interface (polymorphic) target both break that mapping;
7062        // `SELECT (INSERT/UPDATE/DELETE …) { ... }` has nothing left to
7063        // lock, since the DML already ran by the time this SELECT reads it.
7064        let lock = match &sel.lock {
7065            None => None,
7066            Some(lc) => {
7067                if distinct {
7068                    return Err(self.type_err(
7069                        "FOR UPDATE/SHARE cannot be combined with DISTINCT — Postgres can't \
7070                         guarantee the result rows map 1:1 to physical table rows",
7071                    ));
7072                }
7073                if polymorphic {
7074                    return Err(self.type_err(
7075                        "FOR UPDATE/SHARE cannot be used on an interface type — its rows span \
7076                         multiple underlying tables, which a single locking clause can't target",
7077                    ));
7078                }
7079                if dml_source.is_some() {
7080                    return Err(self.type_err(
7081                        "FOR UPDATE/SHARE cannot be used on SELECT (INSERT/UPDATE/DELETE …) — \
7082                         there's nothing left to lock once the DML has already run",
7083                    ));
7084                }
7085                Some(IrLockClause {
7086                    strength: match lc.strength {
7087                        ast::LockStrength::Update => IrLockStrength::Update,
7088                        ast::LockStrength::NoKeyUpdate => IrLockStrength::NoKeyUpdate,
7089                        ast::LockStrength::Share => IrLockStrength::Share,
7090                        ast::LockStrength::KeyShare => IrLockStrength::KeyShare,
7091                    },
7092                    wait: match lc.wait {
7093                        ast::LockWait::Block => IrLockWait::Block,
7094                        ast::LockWait::NoWait => IrLockWait::NoWait,
7095                        ast::LockWait::SkipLocked => IrLockWait::SkipLocked,
7096                    },
7097                })
7098            }
7099        };
7100
7101        Ok(IrSelect {
7102            rows: vec![IrRowSource::Bound { source, shape }],
7103            filter,
7104            order_by,
7105            offset,
7106            limit,
7107            distinct,
7108            dml_source,
7109            polymorphic,
7110            poly_implementors,
7111            poly_columns,
7112            lock,
7113        })
7114    }
7115
7116    /// Unwrap `Shape(expr, elements)` or bare `Path` from a SELECT result.
7117    /// Returns (type_name, shape_elements, optional_inner_stmt, optional_cte_name).
7118    /// The inner stmt is Some when the subject is `(INSERT …)` / `(SELECT …)` etc.
7119    /// The cte_name is Some when the subject is a WITH-block CTE reference.
7120    fn extract_type_and_shape<'e>(&self, expr: &'e Expr) -> Result<TypeAndShape<'e>, PyQLError> {
7121        match expr {
7122            Expr::Shape(s) => {
7123                // s.expr is Option<Expr> (not Box), so use as_ref() not as_deref()
7124                let (type_name, cte_name, inner) = match s.expr.as_ref() {
7125                    Some(Expr::SubQuery(stmt)) => (self.dml_subject_type(stmt)?, None, Some(stmt.as_ref())),
7126                    Some(Expr::Path(p)) if !p.partial && p.steps.len() == 1 => {
7127                        if let ast::PathStep::Name(n) = &p.steps[0] {
7128                            if let Some(t) = self.for_var_types.get(n.as_str()) {
7129                                (t.clone(), None, None)
7130                            } else if let Some(t) = self.cte_types.get(n.as_str()) {
7131                                if t.contains("::") {
7132                                    (t.clone(), Some(n.clone()), None)
7133                                } else {
7134                                    (self.expr_as_type_name(s.expr.as_ref().unwrap())?, None, None)
7135                                }
7136                            } else {
7137                                (self.expr_as_type_name(s.expr.as_ref().unwrap())?, None, None)
7138                            }
7139                        } else {
7140                            (self.expr_as_type_name(s.expr.as_ref().unwrap())?, None, None)
7141                        }
7142                    }
7143                    Some(inner) => (self.expr_as_type_name(inner)?, None, None),
7144                    None => {
7145                        return Err(PyQLError::Type(PyQLTypeError {
7146                            message: "shape without subject expression".into(),
7147                            position: Position { line: 0, col: 0 },
7148                        }));
7149                    }
7150                };
7151                Ok((type_name, &s.elements, inner, cte_name))
7152            }
7153            // Bare `SELECT (DML)` without an outer shape
7154            Expr::SubQuery(stmt) => Ok((self.dml_subject_type(stmt)?, &[], Some(stmt.as_ref()), None)),
7155            // Bare CTE object reference: `select cte_name`
7156            Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
7157                if let ast::PathStep::Name(n) = &p.steps[0] {
7158                    if let Some(t) = self.for_var_types.get(n.as_str()) {
7159                        return Ok((t.clone(), &[], None, None));
7160                    }
7161                    if let Some(t) = self.cte_types.get(n.as_str())
7162                        && t.contains("::")
7163                    {
7164                        return Ok((t.clone(), &[], None, Some(n.clone())));
7165                    }
7166                }
7167                Ok((self.expr_as_type_name(expr)?, &[], None, None))
7168            }
7169            _ => Ok((self.expr_as_type_name(expr)?, &[], None, None)),
7170        }
7171    }
7172
7173    /// True when `expr` is known to hold json, so `expr[…]` reaches into it
7174    /// with `->` rather than indexing a string or an array. A loop variable
7175    /// carries no type in the IR, so its `for` registration answers for it.
7176    fn is_json_expr(&self, ast: &Expr, ir: &IrExpr) -> bool {
7177        if matches!(infer_ir_type(ir), Some("jsonb")) {
7178            return true;
7179        }
7180        let Expr::Path(path) = ast else {
7181            return false;
7182        };
7183        if path.partial || path.steps.len() != 1 {
7184            return false;
7185        }
7186        let ast::PathStep::Name(name) = &path.steps[0] else {
7187            return false;
7188        };
7189        self.for_vars.get(name.as_str()).is_some_and(|t| t == "jsonb")
7190    }
7191
7192    /// The pg type one row of a set-returning call yields. An overload that
7193    /// names its element type (`json_array_unpack` yields json) answers for
7194    /// itself; one declared `set of any` (`array_unpack`) takes the type from
7195    /// the array it unpacks. `None` when the call is not set-returning, or
7196    /// when neither the overload nor the argument names a scalar type.
7197    fn set_returning_element_type(&mut self, expr: &Expr) -> Result<Option<String>, PyQLError> {
7198        if !expr_returns_set(expr) {
7199            return Ok(None);
7200        }
7201        let Expr::FunctionCall(call) = expr else {
7202            return Ok(None);
7203        };
7204        // Overloads that disagree on the element type say nothing between
7205        // them, so only one answer shared by all of them counts.
7206        let mut declared = crate::stdlib::lookup(call.module.as_deref().unwrap_or("std"), &call.name)
7207            .into_iter()
7208            .map(|descriptor| match &descriptor.return_type {
7209                crate::stdlib::PylonType::Set(element) => element.scalar_pg_type(),
7210                _ => None,
7211            });
7212        if let Some(element) = declared.next().flatten()
7213            && declared.all(|other| other == Some(element))
7214        {
7215            return Ok(Some(element.to_string()));
7216        }
7217        let [argument] = call.args.as_slice() else {
7218            return Ok(None);
7219        };
7220        let compiled = self.compile_free_expr(argument)?;
7221        let Some(array_type) = infer_ir_type(&compiled) else {
7222            return Ok(None);
7223        };
7224        Ok(array_type
7225            .strip_suffix("[]")
7226            .map(|element| literal_sentinel_to_pg(element).to_string()))
7227    }
7228
7229    fn compile_for(&mut self, f: &ast::ForStmt) -> Result<IrFor, PyQLError> {
7230        // A loop straight over a binding: the rows are that binding's, so a
7231        // walk off the variable reads it rather than the type's own table.
7232        let iterated_binding = self.resolve_cte_name(&f.iterator).map(|name| self.cte_sql_name(name));
7233        // Compile the iterator to determine what one loop variable binds to.
7234        let (iterator, pg_type, yielded_object_type) = match &f.iterator {
7235            Expr::Set(elems) => {
7236                let compiled: Result<Vec<_>, _> = elems.iter().map(|e| self.compile_free_expr(e)).collect();
7237                let exprs = compiled?;
7238                let raw = exprs.first().and_then(|e| infer_ir_type(e)).unwrap_or("text");
7239                let pg_type = literal_sentinel_to_pg(raw).to_string();
7240                (
7241                    IrForIterator::Values {
7242                        exprs,
7243                        pg_type: pg_type.clone(),
7244                    },
7245                    pg_type,
7246                    None,
7247                )
7248            }
7249            // A derived set — every row of it, not the single value a scalar
7250            // subquery in a one-row `VALUES` would collapse it to.
7251            Expr::SubQuery(stmt) => {
7252                let inner = self.compile_stmt(stmt)?;
7253                if !matches!(inner, IrStmt::Select(_) | IrStmt::PathSelect(_)) {
7254                    return Err(self.type_err(
7255                        "for-loop iterator: only a select can be iterated over — \
7256                         bind the statement in a `with` first",
7257                    ));
7258                }
7259                let yielded = cte_stmt_type(&inner);
7260                let scalar = !yielded.contains("::");
7261                let pg_type = if scalar {
7262                    let raw = if yielded.is_empty() { "text" } else { yielded.as_str() };
7263                    literal_sentinel_to_pg(raw).to_string()
7264                } else {
7265                    "uuid".to_string()
7266                };
7267                (
7268                    IrForIterator::Query {
7269                        stmt: Box::new(inner),
7270                        scalar,
7271                    },
7272                    pg_type,
7273                    (!scalar).then_some(yielded),
7274                )
7275            }
7276            // A WITH binding names a set, so the loop runs once per row of
7277            // it — not once over the single value a scalar subquery would
7278            // collapse it to.
7279            Expr::Path(p)
7280                if !p.partial
7281                    && p.steps.len() == 1
7282                    && matches!(&p.steps[0], ast::PathStep::Name(n) if self.cte_types.contains_key(n.as_str())) =>
7283            {
7284                let ast::PathStep::Name(name) = &p.steps[0] else {
7285                    unreachable!("checked by the guard")
7286                };
7287                let yielded = self.cte_types.get(name.as_str()).cloned().unwrap_or_default();
7288                let scalar = !yielded.contains("::");
7289                let pg_type = if scalar {
7290                    let raw = if yielded.is_empty() { "text" } else { yielded.as_str() };
7291                    literal_sentinel_to_pg(raw).to_string()
7292                } else {
7293                    "uuid".to_string()
7294                };
7295                let source = IrSource {
7296                    poly: None,
7297                    type_name: yielded.clone(),
7298                    table: self.cte_source_table(&self.cte_sql_name(name)),
7299                    alias: self.fresh_alias(),
7300                };
7301                (
7302                    IrForIterator::Query {
7303                        stmt: Box::new(IrStmt::Select(IrSelect::schema_bound(source, vec![], None))),
7304                        scalar,
7305                    },
7306                    pg_type,
7307                    (!scalar).then_some(yielded),
7308                )
7309            }
7310            // `for o in invitations.organizations union (…)` — a walk off a
7311            // binding names a set to iterate just as a sub-select does; it is
7312            // only written without the parentheses.
7313            Expr::Path(p) if !p.partial && p.steps.len() > 1 => {
7314                let synthetic = ast::SelectStmt {
7315                    result: Expr::Path(p.clone()),
7316                    filter: None,
7317                    order_by: vec![],
7318                    offset: None,
7319                    limit: None,
7320                    lock: None,
7321                };
7322                let inner = self.compile_stmt(&Stmt::Select(synthetic))?;
7323                let yielded = cte_stmt_type(&inner);
7324                let scalar = !yielded.contains("::");
7325                let pg_type = if scalar {
7326                    let raw = if yielded.is_empty() { "text" } else { yielded.as_str() };
7327                    literal_sentinel_to_pg(raw).to_string()
7328                } else {
7329                    "uuid".to_string()
7330                };
7331                (
7332                    IrForIterator::Query {
7333                        stmt: Box::new(inner),
7334                        scalar,
7335                    },
7336                    pg_type,
7337                    (!scalar).then_some(yielded),
7338                )
7339            }
7340            other => {
7341                let e = self.compile_free_expr(other)?;
7342                // A set-returning call is registered as `set of any`, so its
7343                // own type says nothing — the rows are the argument array's
7344                // elements, which is where the type comes from.
7345                let raw = match self.set_returning_element_type(other)? {
7346                    Some(element) => element,
7347                    None => literal_sentinel_to_pg(infer_ir_type(&e).unwrap_or("text")).to_string(),
7348                };
7349                let pg_type = raw;
7350                let iterator = if expr_returns_set(other) {
7351                    IrForIterator::SetReturning {
7352                        expr: e,
7353                        pg_type: pg_type.clone(),
7354                    }
7355                } else {
7356                    IrForIterator::Values {
7357                        exprs: vec![e],
7358                        pg_type: pg_type.clone(),
7359                    }
7360                };
7361                (iterator, pg_type, None)
7362            }
7363        };
7364
7365        // The CTE name this loop's iterator is emitted under, claimed from the
7366        // statement's one namespace so no sibling loop and no binding can end
7367        // up under it too. The emitter spells it `_for_<slot>`.
7368        let slot = self
7369            .claim_generated_cte_name(&format!("_for_{}", f.var))
7370            .strip_prefix("_for_")
7371            .unwrap_or(&f.var)
7372            .to_string();
7373        let prev_slot = self.for_var_slots.insert(f.var.clone(), slot.clone());
7374        // Register the for variable so the body can reference it.
7375        let prev = self.for_vars.insert(f.var.clone(), pg_type.clone());
7376        let prev_cte = match iterated_binding {
7377            Some(name) => self.for_var_ctes.insert(f.var.clone(), name),
7378            None => self.for_var_ctes.remove(&f.var),
7379        };
7380        let prev_type = match yielded_object_type {
7381            Some(qualified) => self.for_var_types.insert(f.var.clone(), qualified),
7382            None => self.for_var_types.remove(&f.var),
7383        };
7384        let hoisted_before = self.hoisted_ctes.len();
7385        self.for_scope.push(slot.clone());
7386        let body = self.compile_stmt(&f.body);
7387        self.for_scope.pop();
7388        let body = body?;
7389        let body_ctes: Vec<IrCteDef> = self.hoisted_ctes.split_off(hoisted_before);
7390        // Restore previous for-var (or remove if none existed).
7391        match prev {
7392            Some(old) => {
7393                self.for_vars.insert(f.var.clone(), old);
7394            }
7395            None => {
7396                self.for_vars.remove(&f.var);
7397            }
7398        }
7399        match prev_type {
7400            Some(old) => {
7401                self.for_var_types.insert(f.var.clone(), old);
7402            }
7403            None => {
7404                self.for_var_types.remove(&f.var);
7405            }
7406        }
7407        match prev_cte {
7408            Some(old) => {
7409                self.for_var_ctes.insert(f.var.clone(), old);
7410            }
7411            None => {
7412                self.for_var_ctes.remove(&f.var);
7413            }
7414        }
7415        match prev_slot {
7416            Some(old) => {
7417                self.for_var_slots.insert(f.var.clone(), old);
7418            }
7419            None => {
7420                self.for_var_slots.remove(&f.var);
7421            }
7422        }
7423
7424        // Checked here rather than left to the SQL emitter: `emit_for_stmt`
7425        // only implements these three body kinds, and reaching it with any
7426        // other one used to abort the process instead of reporting a PyQL
7427        // error the caller could act on.
7428        let body_kind = match &body {
7429            IrStmt::Insert(_) | IrStmt::Select(_) | IrStmt::PathSelect(_) => None,
7430            // An update is driven from the iteration itself (`UPDATE … FROM
7431            // <iterated set>`) rather than from a LATERAL, which cannot hold
7432            // DML. A multi-link mutation inside one would need its junction
7433            // rows driven from the iteration too, which it is not yet.
7434            // An append whose value is the loop variable itself can be driven
7435            // from the iteration (`emit_for_ml_append_cte`); anything more
7436            // would need its junction rows correlated in a way they are not.
7437            IrStmt::Update(upd)
7438                if upd.assignments.is_empty()
7439                    && upd.rewrites.is_empty()
7440                    && !upd.multi_link_appends.is_empty()
7441                    && upd
7442                        .multi_link_appends
7443                        .iter()
7444                        .all(|a| crate::sql::append_value_is_the_loop_variable(&a.values, &f.var))
7445                    && upd.multi_link_clears.is_empty()
7446                    && upd.multi_link_replaces.is_empty()
7447                    && upd.multi_link_removals.is_empty()
7448                    && upd.poly_implementors.is_empty() =>
7449            {
7450                None
7451            }
7452            // An interface target fans out into one UPDATE per implementor,
7453            // each driven from the iteration the same way.
7454            IrStmt::Update(upd)
7455                if upd.multi_link_appends.is_empty()
7456                    && upd.multi_link_clears.is_empty()
7457                    && upd.multi_link_replaces.is_empty()
7458                    && upd.multi_link_removals.is_empty() =>
7459            {
7460                None
7461            }
7462            IrStmt::Update(_) if std::env::var("PYLON_DBG_FORUPD").is_ok() => None,
7463            // `update x set { ml += (insert T { … }) }` — the insert runs once
7464            // per iteration, driven from the same rows the update is, and the
7465            // junction pairs each with the id that iteration generated. Only
7466            // appends: a clear/remove/replace has no such pairing.
7467            IrStmt::Update(upd)
7468                if upd.assignments.is_empty()
7469                    && upd.rewrites.is_empty()
7470                    && !upd.multi_link_appends.is_empty()
7471                    && upd.multi_link_clears.is_empty()
7472                    && upd.multi_link_replaces.is_empty()
7473                    && upd.multi_link_removals.is_empty()
7474                    && upd.poly_implementors.is_empty()
7475                    && upd
7476                        .multi_link_appends
7477                        .iter()
7478                        .all(|a| crate::sql::per_iteration_insert(a, &upd.nested_ctes).is_some()) =>
7479            {
7480                None
7481            }
7482            IrStmt::Update(_) => Some("update"),
7483            IrStmt::Delete(_) => Some("delete"),
7484            // `for line in … union (for component in … union (insert …))` —
7485            // one iterator CTE per loop, joined on the key the inner carries.
7486            // Only an insert innermost: anything else would need its own rows
7487            // driven from the pair, which they are not.
7488            IrStmt::For(inner) if matches!(inner.body.as_ref(), IrStmt::Insert(_)) => None,
7489            IrStmt::For(_) => Some("nested for"),
7490            IrStmt::Group(_) => Some("group"),
7491            _ => Some("this statement"),
7492        };
7493        if let Some(kind) = body_kind {
7494            return Err(self.type_err(&format!(
7495                "for-loop body: {kind} is not supported as a `for` body — use insert or select"
7496            )));
7497        }
7498
7499        Ok(IrFor {
7500            var_name: slot,
7501            iterator,
7502            body: Box::new(body),
7503            body_ctes,
7504        })
7505    }
7506
7507    fn compile_group(&mut self, g: &ast::GroupStmt) -> Result<IrGroup, PyQLError> {
7508        // Resolve the subject type — may be a schema type, a CTE alias, or a
7509        // walk. A walk (`group a.names by …`) names rows rather than a type:
7510        // the group runs over what it lands on, narrowed below to the rows it
7511        // yields, the same way `update a.preferences` narrows its own target.
7512        // The steps used to be joined with `::` and looked up as a type name,
7513        // which reported `a.names` as an unknown type `a::names`.
7514        let mut walked: Option<&ast::Path> = None;
7515        let (type_name, cte_name) = match &g.subject {
7516            Expr::Path(p) if !p.partial => match p.steps.as_slice() {
7517                [ast::PathStep::Name(n)] => {
7518                    if let Some(t) = self.cte_types.get(n.as_str()) {
7519                        (t.clone(), Some(n.clone()))
7520                    } else {
7521                        (n.clone(), None)
7522                    }
7523                }
7524                [ast::PathStep::Name(root), rest @ ..]
7525                    if !rest.is_empty()
7526                        && let Ok(root_td) = self.resolve_path_root(root)
7527                        && let (_, Some(target)) = self.walk_path_types(root_td, rest, MAX_COMPUTED_SPLICES) =>
7528                {
7529                    walked = Some(p);
7530                    (format!("{}::{}", target.module, target.name), None)
7531                }
7532                _ => {
7533                    return Err(PyQLError::Type(PyQLTypeError {
7534                        message: format!("unsupported group subject: {:?}", g.subject),
7535                        position: Position { line: 0, col: 0 },
7536                    }));
7537                }
7538            },
7539            _ => {
7540                return Err(PyQLError::Type(PyQLTypeError {
7541                    message: "group subject must be a type name".to_string(),
7542                    position: Position { line: 0, col: 0 },
7543                }));
7544            }
7545        };
7546
7547        let td = self.resolve_type(&type_name)?;
7548        let alias = self.fresh_alias();
7549        let fq_type_name = format!("{}::{}", td.module, td.name);
7550        let table = match cte_name {
7551            Some(ref cte) => self.cte_source_table(cte),
7552            None => td.table.clone(),
7553        };
7554        let source = IrSource {
7555            poly: self.poly_fanout_for(&fq_type_name),
7556            type_name: fq_type_name.clone(),
7557            table,
7558            alias: alias.clone(),
7559        };
7560        let module = td.module.clone();
7561
7562        // Compile the element shape. No explicit shape → implicit { id }.
7563        let shape = self.compile_shape(g.shape.as_deref().unwrap_or(&[]), td, &alias, &module)?;
7564
7565        // Build a map from using-alias → compiled expression.
7566        let td = self.resolve_type(&type_name)?;
7567        let mut using_map: HashMap<String, IrExpr> = HashMap::new();
7568        for (alias_name, expr) in &g.using {
7569            let ir = self.compile_expr(expr, td, &alias)?;
7570            using_map.insert(alias_name.clone(), ir);
7571        }
7572
7573        // Compile BY keys: each is either an Ident (using-alias ref) or a partial Path (.prop).
7574        let mut keys: Vec<(String, IrExpr)> = vec![];
7575        let td = self.resolve_type(&type_name)?;
7576        for by_expr in &g.by {
7577            match by_expr {
7578                Expr::Path(p) if p.partial && p.steps.len() == 1 => {
7579                    if let ast::PathStep::Name(prop) = &p.steps[0] {
7580                        // `.prop` shorthand: infer alias = prop name, expr = column ref.
7581                        let ir = self.compile_expr(by_expr, td, &alias)?;
7582                        keys.push((prop.clone(), ir));
7583                    } else {
7584                        return Err(PyQLError::Type(PyQLTypeError {
7585                            message: "group by path must be a simple property".to_string(),
7586                            position: Position { line: 0, col: 0 },
7587                        }));
7588                    }
7589                }
7590                Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
7591                    if let ast::PathStep::Name(name) = &p.steps[0] {
7592                        // Bare ident: must be a using alias.
7593                        let ir = using_map.get(name).ok_or_else(|| {
7594                            PyQLError::Type(PyQLTypeError {
7595                                message: format!("group by references unknown alias '{}'", name),
7596                                position: Position { line: 0, col: 0 },
7597                            })
7598                        })?;
7599                        keys.push((name.clone(), ir.clone()));
7600                    } else {
7601                        return Err(PyQLError::Type(PyQLTypeError {
7602                            message: "group by identifier must be a simple name".to_string(),
7603                            position: Position { line: 0, col: 0 },
7604                        }));
7605                    }
7606                }
7607                _ => {
7608                    return Err(PyQLError::Type(PyQLTypeError {
7609                        message: format!("unsupported group by expression: {:?}", by_expr),
7610                        position: Position { line: 0, col: 0 },
7611                    }));
7612                }
7613            }
7614        }
7615
7616        // `filter` restricts the grouped rows; `order by`/`offset`/`limit`
7617        // apply within each group, so they're compiled against the same
7618        // element scope the shape is.
7619        let td = self.resolve_type(&type_name)?;
7620        let synthetic = ast::SelectStmt {
7621            result: g.subject.clone(),
7622            filter: g.filter.clone(),
7623            order_by: g.order_by.clone(),
7624            offset: g.offset.clone(),
7625            limit: g.limit.clone(),
7626            lock: None,
7627        };
7628        let (filter, order_by, offset, limit) = self.compile_path_modifiers(&synthetic, td, &alias)?;
7629        // A walked subject groups only the rows the walk reaches, not every
7630        // row of the type it happens to land on.
7631        let filter = match walked {
7632            Some(subject) => {
7633                let subject = subject.clone();
7634                let rows = self.compile_subject_path_rows(&subject, &alias)?;
7635                and_conditions(filter, vec![rows])
7636            }
7637            None => filter,
7638        };
7639
7640        Ok(IrGroup {
7641            source,
7642            shape,
7643            keys,
7644            filter,
7645            order_by,
7646            offset,
7647            limit,
7648            output: IrGroupOutput::Groups,
7649        })
7650    }
7651
7652    fn bind_group(&mut self, name: &str, expr: &Expr) -> Result<bool, PyQLError> {
7653        let Expr::SubQuery(stmt) = expr else {
7654            return Ok(false);
7655        };
7656        let Stmt::Group(g) = stmt.as_ref() else {
7657            return Ok(false);
7658        };
7659        let grp = self.compile_group(g)?;
7660        self.group_bindings.insert(name.to_string(), grp);
7661        Ok(true)
7662    }
7663
7664    /// `select (group S using k := E by k) { n := count(.elements) }`. Each
7665    /// pointer is compiled over the grouped rows of `S`: `.key.k` stands for
7666    /// `E` itself, which is constant within a group, and an aggregate over
7667    /// `.elements.p` for the same aggregate over `.p`, which the `GROUP BY`
7668    /// then takes per group.
7669    fn compile_group_projection(
7670        &mut self,
7671        sel: &ast::SelectStmt,
7672        g: &ast::GroupStmt,
7673        elements: &[ShapeElement],
7674    ) -> Result<IrGroup, PyQLError> {
7675        if sel.filter.is_some() {
7676            return Err(self.type_err("a shape over a `group` does not support filter"));
7677        }
7678        if !g.order_by.is_empty() || g.offset.is_some() || g.limit.is_some() {
7679            return Err(self.type_err(
7680                "a `group` read through a shape does not support order by, offset or limit on its elements",
7681            ));
7682        }
7683        let mut grp = self.compile_group(g)?;
7684        // `.key.k` reads the key as the `GROUP BY` already compiled it:
7685        // compiling it a second time would give a walk fresh aliases, and
7686        // Postgres only accepts a grouped expression it can match verbatim.
7687        let keys: Vec<String> = grp.keys.iter().map(|(name, _)| name.clone()).collect();
7688        for (name, key) in &grp.keys {
7689            self.inline_bindings.insert(group_key_binding(name), key.clone());
7690        }
7691        let compiled = self.compile_group_projection_parts(sel, &grp, &keys, elements);
7692        for name in &keys {
7693            self.inline_bindings.remove(&group_key_binding(name));
7694        }
7695        let (pointers, order_by, offset, limit) = compiled?;
7696        grp.output = IrGroupOutput::Projection(Box::new(IrGroupProjection {
7697            pointers,
7698            order_by,
7699            offset,
7700            limit,
7701        }));
7702        Ok(grp)
7703    }
7704
7705    #[allow(clippy::type_complexity)]
7706    fn compile_group_projection_parts(
7707        &mut self,
7708        sel: &ast::SelectStmt,
7709        grp: &IrGroup,
7710        keys: &[String],
7711        elements: &[ShapeElement],
7712    ) -> Result<(Vec<IrShapePointer>, Vec<IrSort>, Option<IrExpr>, Option<IrExpr>), PyQLError> {
7713        let td = self.resolve_type(&grp.source.type_name)?;
7714        let mut rewritten = Vec::with_capacity(elements.len());
7715        for element in elements {
7716            let (Some(compexpr), [ast::PathStep::Name(_)]) = (&element.compexpr, element.path.steps.as_slice()) else {
7717                return Err(self.type_err(
7718                    "only computed pointers (`name := …`) can be read off a `group` — e.g. `k := .key.name`",
7719                ));
7720            };
7721            let mut element = element.clone();
7722            element.compexpr = Some(self.rewrite_group_refs(compexpr, keys, td)?);
7723            rewritten.push(element);
7724        }
7725        let alias = grp.source.alias.clone();
7726        let module = td.module.clone();
7727        // `td` is here so `.key`/`.elements` resolve, but the row this
7728        // projects is the group's, not one of `td`'s: an `id` added to it
7729        // reads a column no GROUP BY covers.
7730        let pointers = self.without_implicit_id(|this| this.compile_shape(&rewritten, td, &alias, &module))?;
7731        let order_by = sel
7732            .order_by
7733            .iter()
7734            .map(|sort| {
7735                Ok(ast::SortExpr {
7736                    expr: self.rewrite_group_refs(&sort.expr, keys, td)?,
7737                    direction: sort.direction.clone(),
7738                    nones: sort.nones.clone(),
7739                })
7740            })
7741            .collect::<Result<Vec<_>, PyQLError>>()?;
7742        let synthetic = ast::SelectStmt {
7743            result: Expr::Path(ast::Path::absolute(grp.source.type_name.clone())),
7744            filter: None,
7745            order_by,
7746            offset: sel.offset.clone(),
7747            limit: sel.limit.clone(),
7748            lock: None,
7749        };
7750        let (_, order_by, offset, limit) = self.compile_path_modifiers(&synthetic, td, &alias)?;
7751        Ok((pointers, order_by, offset, limit))
7752    }
7753
7754    fn rewrite_group_refs(&self, expr: &Expr, keys: &[String], td: &TypeDescriptor) -> Result<Expr, PyQLError> {
7755        const AGGREGATES: &[&str] = &["count", "sum", "min", "max", "avg", "array_agg", "all", "any"];
7756        let rewrite = |e: &Expr| self.rewrite_group_refs(e, keys, td);
7757        Ok(match expr {
7758            Expr::Path(p) if p.partial => {
7759                let name = match p.steps.first() {
7760                    Some(ast::PathStep::Name(name)) => name.as_str(),
7761                    _ => "",
7762                };
7763                match (name, p.steps.get(1..).unwrap_or_default()) {
7764                    ("key", [ast::PathStep::Name(key)]) if keys.contains(key) => {
7765                        Expr::Path(ast::Path::absolute(group_key_binding(key)))
7766                    }
7767                    ("key", [ast::PathStep::Name(key)]) => {
7768                        return Err(self.type_err(&format!("'{key}' is not a key of this group")));
7769                    }
7770                    ("elements", _) => {
7771                        return Err(self.type_err(
7772                            "`.elements` of a group can only be read through an aggregate over one \
7773                             of their properties — e.g. `count(.elements)`, `sum(.elements.amount)`",
7774                        ));
7775                    }
7776                    _ => {
7777                        return Err(self.type_err(&format!(
7778                            "a group has no pointer '{name}' — read `.key.<name>` or aggregate over `.elements`"
7779                        )));
7780                    }
7781                }
7782            }
7783            Expr::FunctionCall(f)
7784                if f.args.len() == 1
7785                    && f.kwargs.is_empty()
7786                    && f.module.as_deref().is_none_or(|m| m == "std")
7787                    && AGGREGATES.contains(&f.name.as_str())
7788                    && let Expr::Path(p) = &f.args[0]
7789                    && p.partial
7790                    && matches!(p.steps.first(), Some(ast::PathStep::Name(n)) if n == "elements") =>
7791            {
7792                let property = match p.steps.get(1..).unwrap_or_default() {
7793                    [] => "id",
7794                    [ast::PathStep::Name(prop)] if td.properties.iter().any(|d| &d.name == prop) => prop.as_str(),
7795                    _ => {
7796                        return Err(self.type_err(&format!(
7797                            "'{}' over a group's elements only reads one of their own properties",
7798                            f.name
7799                        )));
7800                    }
7801                };
7802                Expr::FunctionCall(ast::FunctionCall {
7803                    module: f.module.clone(),
7804                    name: f.name.clone(),
7805                    args: vec![Expr::Path(ast::Path::relative(property))],
7806                    kwargs: vec![],
7807                })
7808            }
7809            Expr::FunctionCall(f) => Expr::FunctionCall(ast::FunctionCall {
7810                module: f.module.clone(),
7811                name: f.name.clone(),
7812                args: f.args.iter().map(rewrite).collect::<Result<_, _>>()?,
7813                kwargs: f
7814                    .kwargs
7815                    .iter()
7816                    .map(|(k, v)| Ok((k.clone(), rewrite(v)?)))
7817                    .collect::<Result<_, PyQLError>>()?,
7818            }),
7819            Expr::BinOp(b) => Expr::BinOp(Box::new(ast::BinOp {
7820                left: rewrite(&b.left)?,
7821                op: b.op.clone(),
7822                right: rewrite(&b.right)?,
7823            })),
7824            Expr::UnaryOp(u) => Expr::UnaryOp(Box::new(ast::UnaryOp {
7825                op: u.op.clone(),
7826                operand: rewrite(&u.operand)?,
7827            })),
7828            Expr::TypeCast(c) => Expr::TypeCast(Box::new(ast::TypeCast {
7829                expr: rewrite(&c.expr)?,
7830                ty: c.ty.clone(),
7831            })),
7832            Expr::IfElse(ie) => Expr::IfElse(Box::new(ast::IfElse {
7833                if_expr: rewrite(&ie.if_expr)?,
7834                condition: rewrite(&ie.condition)?,
7835                else_expr: rewrite(&ie.else_expr)?,
7836            })),
7837            other => other.clone(),
7838        })
7839    }
7840
7841    /// `for g in (group S by k) union (select g.elements order by … limit n)`
7842    /// — the first `n` rows of `S` per key. `None` for any other `for`.
7843    fn compile_group_elements(&mut self, f: &ast::ForStmt) -> Result<Option<IrGroup>, PyQLError> {
7844        let Stmt::Select(body) = f.body.as_ref() else {
7845            return Ok(None);
7846        };
7847        let unmodified =
7848            body.filter.is_none() && body.order_by.is_empty() && body.offset.is_none() && body.limit.is_none();
7849        let (inner, shape) = match &body.result {
7850            Expr::Shape(sh) if unmodified => match &sh.expr {
7851                Some(Expr::SubQuery(stmt)) => match stmt.as_ref() {
7852                    Stmt::Select(inner) => (inner, Some(sh.elements.as_slice())),
7853                    _ => return Ok(None),
7854                },
7855                _ => return Ok(None),
7856            },
7857            _ => (body, None),
7858        };
7859        let reads_elements = matches!(&inner.result, Expr::Path(p) if !p.partial
7860            && matches!(p.steps.as_slice(), [ast::PathStep::Name(var), ast::PathStep::Name(elements)]
7861                if var == &f.var && elements == "elements"));
7862        if !reads_elements {
7863            return Ok(None);
7864        }
7865        let mut grp = match &f.iterator {
7866            Expr::SubQuery(stmt) => match stmt.as_ref() {
7867                Stmt::Group(g) => self.compile_group(g)?,
7868                _ => return Ok(None),
7869            },
7870            Expr::Path(p) if !p.partial => match p.steps.as_slice() {
7871                [ast::PathStep::Name(name)] => match self.group_bindings.get(name) {
7872                    Some(grp) => grp.clone(),
7873                    None => return Ok(None),
7874                },
7875                _ => return Ok(None),
7876            },
7877            _ => return Ok(None),
7878        };
7879        let ordered = !inner.order_by.is_empty() || inner.offset.is_some() || inner.limit.is_some();
7880        if ordered && (!grp.order_by.is_empty() || grp.offset.is_some() || grp.limit.is_some()) {
7881            return Err(self.type_err(
7882                "a group that already orders or limits its elements cannot be ordered or limited again by a `for` over it",
7883            ));
7884        }
7885        let td = self.resolve_type(&grp.source.type_name)?;
7886        let alias = grp.source.alias.clone();
7887        let synthetic = ast::SelectStmt {
7888            result: Expr::Path(ast::Path::absolute(grp.source.type_name.clone())),
7889            ..inner.clone()
7890        };
7891        let (filter, order_by, offset, limit) = self.compile_path_modifiers(&synthetic, td, &alias)?;
7892        grp.filter = and_conditions(grp.filter, filter.into_iter().collect());
7893        if ordered {
7894            grp.order_by = order_by;
7895            grp.offset = offset;
7896            grp.limit = limit;
7897        }
7898        if let Some(shape) = shape {
7899            let module = td.module.clone();
7900            grp.shape = self.compile_shape(shape, td, &alias, &module)?;
7901        }
7902        grp.output = IrGroupOutput::Elements;
7903        Ok(Some(grp))
7904    }
7905
7906    /// The key a single-link assignment stores, for the values that name a
7907    /// row rather than compute one. `None` for anything the generic
7908    /// expression route already handles.
7909    fn compile_link_key(&mut self, expr: &Expr, td: &TypeDescriptor, alias: &str) -> Result<Option<IrExpr>, PyQLError> {
7910        // A one-element set is that element — `credentials := {
7911        // (insert Credentials { … }) }` says the same thing as assigning the
7912        // insert directly.
7913        let value = match expr {
7914            Expr::Set(elements) if elements.len() == 1 => &elements[0],
7915            other => other,
7916        };
7917        match value {
7918            // `link := (select { <T>a.id, <T>b.id } limit 1)` — a set literal
7919            // of keys, read for the one value its clauses leave.
7920            Expr::SubQuery(inner_stmt)
7921                if matches!(inner_stmt.as_ref(), Stmt::Select(sel) if matches!(sel.result, Expr::Set(_))) =>
7922            {
7923                let IrStmt::Select(select) = self.compile_stmt(inner_stmt)? else {
7924                    return Ok(None);
7925                };
7926                if !select.rows.iter().all(|r| matches!(r, IrRowSource::Free(IrFreeExpr::Scalar(_)))) {
7927                    return Ok(None);
7928                }
7929                Ok(Some(IrExpr::ScalarSubquery(Box::new(select))))
7930            }
7931            // Link assignment via subquery: `company := (SELECT Company FILTER ...)`
7932            // Compile as a scalar subquery returning the target pk (the FK uuid).
7933            Expr::SubQuery(inner_stmt) => self.compile_link_subquery(inner_stmt).map(Some),
7934            // `created_by := account_of_transaction()` — an object-returning
7935            // function names the row to link to, so what is stored is its key,
7936            // the same as for a select. The schema itself writes this as a
7937            // link default.
7938            Expr::FunctionCall(fc) => self.try_compile_fn_scalar_subquery(fc, &["id".to_string()], None, None),
7939            // `credentials := (update c set { … }) if exists(c) else (insert
7940            // Credentials { … })` — each mutation carries its own condition,
7941            // so exactly one branch yields the row to link.
7942            Expr::IfElse(ie)
7943                if [&ie.if_expr, &ie.else_expr].iter().any(|b| {
7944                    matches!(b, Expr::SubQuery(s) if matches!(s.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)))
7945                }) =>
7946            {
7947                let Some(rewritten) = self.object_if_else_as_union(value) else {
7948                    return Ok(None);
7949                };
7950                self.compile_link_key(&rewritten, td, alias)
7951            }
7952            Expr::Union(a, b) => {
7953                let (Some(left), Some(right)) = (self.compile_link_key(a, td, alias)?, self.compile_link_key(b, td, alias)?)
7954                else {
7955                    return Ok(None);
7956                };
7957                // A hoisted mutation's key is read by subquery: joined into
7958                // the statement instead, the branch that wrote nothing would
7959                // leave no row to update.
7960                let hoisted = |key: IrExpr| match key {
7961                    IrExpr::ColumnRef { alias, column, .. } if column == "id" => IrExpr::CteRef {
7962                        name: alias,
7963                        scalar: false,
7964                        pg_type: None,
7965                    },
7966                    other => other,
7967                };
7968                Ok(Some(IrExpr::FunctionCall(IrFunctionCall {
7969                    return_pg_type: None,
7970                    schema: None,
7971                    name: "coalesce".to_string(),
7972                    args: vec![hoisted(left), hoisted(right)],
7973                    sql_template: None,
7974                })))
7975            }
7976            // `by := account_of_transaction() if cond else {}` — each branch
7977            // names a row (or none) the same way.
7978            Expr::IfElse(ie) => {
7979                if ![&ie.if_expr, &ie.else_expr].iter().any(|b| matches!(b, Expr::FunctionCall(_))) {
7980                    return Ok(None);
7981                }
7982                Ok(Some(IrExpr::IfElse(Box::new(IrIfElse {
7983                    condition: self.compile_expr(&ie.condition, td, alias)?,
7984                    if_: self.compile_link_branch(&ie.if_expr, td, alias)?,
7985                    else_: self.compile_link_branch(&ie.else_expr, td, alias)?,
7986                }))))
7987            }
7988            _ => Ok(None),
7989        }
7990    }
7991
7992    fn compile_link_branch(&mut self, branch: &Expr, td: &TypeDescriptor, alias: &str) -> Result<IrExpr, PyQLError> {
7993        if matches!(branch, Expr::Set(elements) if elements.is_empty()) {
7994            return Ok(IrExpr::Null);
7995        }
7996        match self.compile_link_key(branch, td, alias)? {
7997            Some(key) => Ok(key),
7998            None => self.compile_expr(branch, td, alias),
7999        }
8000    }
8001
8002    /// `.teams { owning := … }.owning` — a walk, and a pointer its shape
8003    /// declares read back off each row it lands on. `None` for anything
8004    /// else, including a field the shape does not compute.
8005    fn compile_shape_field_select(
8006        &mut self,
8007        expr: &Expr,
8008        ctx: Option<(&TypeDescriptor, &str)>,
8009    ) -> Result<Option<IrPathSelect>, PyQLError> {
8010        let Expr::FieldAccess { expr: inner, field } = expr else {
8011            return Ok(None);
8012        };
8013        let Expr::Shape(sh) = inner.as_ref() else {
8014            return Ok(None);
8015        };
8016        let Some(Expr::Path(base)) = sh.expr.as_ref() else {
8017            return Ok(None);
8018        };
8019        let Some(value) = sh
8020            .elements
8021            .iter()
8022            .find(|el| matches!(el.path.steps.as_slice(), [ast::PathStep::Name(n)] if n == field))
8023            .and_then(|el| el.compexpr.clone())
8024        else {
8025            return Ok(None);
8026        };
8027        let (rooted, correlate) = if base.partial {
8028            let Some((td, alias)) = ctx else {
8029                return Ok(None);
8030            };
8031            let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
8032            steps.extend(base.steps.iter().cloned());
8033            (ast::Path { steps, partial: false }, Some(alias.to_string()))
8034        } else if base.steps.len() > 1 {
8035            (base.clone(), None)
8036        } else if let [ast::PathStep::Name(root)] = base.steps.as_slice()
8037            && let Ok(root_td) = self.resolve_path_root(root)
8038        {
8039            // A walk needs a step: `T[is T]` lands on the same objects.
8040            let narrowing = ast::PathStep::TypeIntersection(ast::ObjectRef {
8041                module: Some(root_td.module.clone()),
8042                name: root_td.name.clone(),
8043            });
8044            (
8045                ast::Path {
8046                    steps: vec![base.steps[0].clone(), narrowing],
8047                    partial: false,
8048                },
8049                None,
8050            )
8051        } else {
8052            return Ok(None);
8053        };
8054        let synthetic = ast::SelectStmt {
8055            result: Expr::Path(rooted.clone()),
8056            filter: None,
8057            order_by: vec![],
8058            offset: None,
8059            limit: None,
8060            lock: None,
8061        };
8062        let mut ps = self.compile_path_select(&synthetic, &rooted, &[], false)?;
8063        if let Some(alias) = correlate {
8064            Self::correlate_path_select(&mut ps, &alias);
8065        }
8066        let IrPathResult::Object { alias, type_name, .. } = &ps.result else {
8067            return Err(self.type_err(&format!(
8068                "'{field}' is read off a walk that lands on a value, which has no shape"
8069            )));
8070        };
8071        let (alias, type_name) = (alias.clone(), type_name.clone());
8072        let td = self.resolve_type(&type_name)?;
8073        let value = self.compile_expr(&value, td, &alias)?;
8074        // A computed standing for a set is an array per row; the field reads
8075        // its elements, so the rows of all of them make up one set.
8076        let value = if yields_array(&value) {
8077            IrExpr::FunctionCall(IrFunctionCall {
8078                return_pg_type: None,
8079                schema: None,
8080                name: "unnest".to_string(),
8081                args: vec![value],
8082                sql_template: None,
8083            })
8084        } else {
8085            value
8086        };
8087        ps.result = IrPathResult::Scalar(value, None);
8088        Ok(Some(ps))
8089    }
8090
8091    /// `l.addon.id` — an absolute walk that lands on a property, so it yields
8092    /// values rather than objects.
8093    fn is_scalar_walk(&self, expr: &Expr) -> bool {
8094        let Expr::Path(p) = expr else { return false };
8095        let (Some(ast::PathStep::Name(root)), false) = (p.steps.first(), p.partial) else {
8096            return false;
8097        };
8098        let (Some(ast::PathStep::Name(leaf)), [_, middle @ .., _]) = (p.steps.last(), p.steps.as_slice()) else {
8099            return false;
8100        };
8101        let Ok(root_td) = self.resolve_path_root(root) else {
8102            return false;
8103        };
8104        let owner = if middle.is_empty() {
8105            Some(root_td)
8106        } else {
8107            self.walk_path_types(root_td, middle, MAX_COMPUTED_SPLICES).1
8108        };
8109        owner.is_some_and(|td| Self::resolve_property(td, leaf).is_some())
8110    }
8111
8112    /// `a.id union b.id` — each operand compiled as the select it stands
8113    /// for, the rows of all of them concatenated.
8114    fn compile_scalar_union(&mut self, expr: &Expr) -> Result<IrStmt, PyQLError> {
8115        fn operands<'e>(expr: &'e Expr, out: &mut Vec<&'e Expr>) {
8116            match expr {
8117                Expr::Union(a, b) => {
8118                    operands(a, out);
8119                    operands(b, out);
8120                }
8121                other => out.push(other),
8122            }
8123        }
8124        let mut exprs = vec![];
8125        operands(expr, &mut exprs);
8126        let mut branches = Vec::with_capacity(exprs.len());
8127        for operand in exprs {
8128            let stmt = match operand {
8129                Expr::SubQuery(stmt) => stmt.as_ref().clone(),
8130                other => Stmt::Select(ast::SelectStmt {
8131                    result: other.clone(),
8132                    filter: None,
8133                    order_by: vec![],
8134                    offset: None,
8135                    limit: None,
8136                    lock: None,
8137                }),
8138            };
8139            branches.push(self.compile_stmt(&stmt)?);
8140        }
8141        let types: Vec<String> = branches.iter().map(cte_stmt_type).collect();
8142        let first = types.iter().find(|t| !t.is_empty()).cloned().unwrap_or_default();
8143        if let Some(other) = types
8144            .iter()
8145            .find(|t| t.contains("::") || (!t.is_empty() && **t != first))
8146        {
8147            let display = |t: &str| {
8148                if t.contains("::") {
8149                    t.to_string()
8150                } else {
8151                    pg_type_to_pyql(t).to_string()
8152                }
8153            };
8154            return Err(self.type_err(&format!(
8155                "operator 'UNION' cannot be applied to operands of type '{}' and '{}'",
8156                display(&first),
8157                display(other)
8158            )));
8159        }
8160        Ok(IrStmt::ScalarUnion(branches))
8161    }
8162
8163    /// Extract the target type name from a DML or inner SELECT statement.
8164    fn dml_subject_type(&self, stmt: &Stmt) -> Result<String, PyQLError> {
8165        match stmt {
8166            Stmt::Insert(ins) => Ok(ins.subject.qualified_name()),
8167            Stmt::Update(upd) => self.subject_type_name(&upd.subject),
8168            Stmt::Delete(del) => self.subject_type_name(&del.subject),
8169            Stmt::With(w) => self.dml_subject_type(&w.stmt),
8170            Stmt::For(f) => self.dml_subject_type(&f.body),
8171            Stmt::Analyze(inner) => self.dml_subject_type(inner),
8172            Stmt::Group(g) => self.expr_as_type_name(&g.subject),
8173            Stmt::Select(sel) => {
8174                // <Module::Type>expr — type name comes from the cast target
8175                if let Expr::TypeCast(tc) = &sel.result
8176                    && let Some((module, name)) = tc.ty.as_named()
8177                    && module.map(|m| m != "std").unwrap_or(false)
8178                {
8179                    return Ok(name.to_string());
8180                }
8181                // `select resource.revisions` — a walk names no type of its
8182                // own; what it lands on is the type the statement yields.
8183                if let Expr::Path(path) = &sel.result
8184                    && !path.partial
8185                    && path.steps.len() > 1
8186                    && let Some(ast::PathStep::Name(root)) = path.steps.first()
8187                    && let Ok(root_td) = self.resolve_path_root(root)
8188                    && let (_, Some(target)) = self.walk_path_types(root_td, &path.steps[1..], MAX_COMPUTED_SPLICES)
8189                {
8190                    return Ok(format!("{}::{}", target.module, target.name));
8191                }
8192                // SELECT-over-SELECT: get the type from the inner select's result
8193                let (type_name, _, _, _) = self.extract_type_and_shape(&sel.result)?;
8194                Ok(type_name)
8195            }
8196        }
8197    }
8198
8199    /// `id in (…)` over the rows a DML subject path traverses to, so a
8200    /// mutation written against a traversal touches those rows and no others.
8201    fn compile_subject_path_rows(&mut self, subject: &ast::Path, target_alias: &str) -> Result<IrExpr, PyQLError> {
8202        // Traversing to `.id` rather than stopping at the objects: a path that
8203        // ends on a link or a type intersection has nothing to project, and the
8204        // ids are what the narrowing compares against anyway.
8205        let mut steps = subject.steps.clone();
8206        steps.push(ast::PathStep::Name("id".to_string()));
8207        let ids = ast::Path { steps, partial: false };
8208        let synthetic = ast::SelectStmt {
8209            result: Expr::Path(ids.clone()),
8210            filter: None,
8211            order_by: vec![],
8212            offset: None,
8213            limit: None,
8214            lock: None,
8215        };
8216        let rows = self.compile_path_select(&synthetic, &ids, &[], false)?;
8217        Ok(IrExpr::BinOp(Box::new(IrBinOp {
8218            left: IrExpr::ColumnRef {
8219                alias: target_alias.to_string(),
8220                column: "id".to_string(),
8221                pg_type: "uuid".to_string(),
8222            },
8223            op: ast::BinOpKind::In,
8224            right: IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(rows)))),
8225        })))
8226    }
8227
8228    /// The type a DML subject names, however it is written: a type, a `with`
8229    /// binding, or a traversal that ends on one.
8230    fn subject_type_name(&self, subject: &Expr) -> Result<String, PyQLError> {
8231        if let Expr::Path(p) = subject
8232            && p.steps.len() > 1
8233            && let Some(ast::PathStep::Name(root)) = p.steps.first()
8234            && let Ok(root_td) = self.resolve_path_root(root)
8235            && let (_, Some(target)) = self.walk_path_types(root_td, &p.steps[1..], MAX_COMPUTED_SPLICES)
8236        {
8237            return Ok(format!("{}::{}", target.module, target.name));
8238        }
8239        // A subject that is just a `with` binding names rows, not a type, so
8240        // the type is the one the binding was bound to. `compile_update`
8241        // wants the binding's own name (it narrows the update to those rows);
8242        // a reader asking what type the statement yields wants this.
8243        if let Expr::Path(p) = subject
8244            && !p.partial
8245            && let [ast::PathStep::Name(root)] = p.steps.as_slice()
8246            && let Some(bound) = self.cte_object_type(root)
8247        {
8248            return Ok(bound);
8249        }
8250        self.expr_as_type_name(subject)
8251    }
8252
8253    fn expr_as_type_name(&self, expr: &Expr) -> Result<String, PyQLError> {
8254        if std::env::var("PYLON_DBG_SUBJ").is_ok() {
8255            eprintln!(
8256                "DBG subj {:.90?}
8257{}",
8258                expr,
8259                std::backtrace::Backtrace::force_capture()
8260            );
8261        }
8262        match expr {
8263            // `detached T` names the same type; the prefix only says the set
8264            // is not correlated with the enclosing one.
8265            Expr::Detached(inner) => self.expr_as_type_name(inner),
8266            Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
8267                if let ast::PathStep::Name(n) = &p.steps[0] {
8268                    return Ok(n.clone());
8269                }
8270                Err(self.type_err("expected a type name"))
8271            }
8272            // `select (a union b)` as a SELECT-over-SELECT subject: every
8273            // branch carries the same object type, and that is the subject.
8274            Expr::Union(_, _) => {
8275                let branches = self
8276                    .object_union_branches(expr)
8277                    .ok_or_else(|| self.type_err("expected a type name as SELECT subject"))?;
8278                self.common_union_type(&branches)
8279                    .ok_or_else(|| self.type_err("expected a type name as SELECT subject"))
8280            }
8281            // `placed.line_items` — a walk names no type of its own; the type
8282            // meant is the one it lands on, which is how `dml_subject_type`
8283            // already reads the same path.
8284            Expr::Path(p)
8285                if !p.partial
8286                    && p.steps.len() > 1
8287                    && let Some(ast::PathStep::Name(root)) = p.steps.first()
8288                    && let Ok(root_td) = self.resolve_path_root(root)
8289                    && let (_, Some(target)) = self.walk_path_types(root_td, &p.steps[1..], MAX_COMPUTED_SPLICES) =>
8290            {
8291                Ok(format!("{}::{}", target.module, target.name))
8292            }
8293            _ => Err(self.type_err("expected a type name as SELECT subject")),
8294        }
8295    }
8296
8297    // ── INSERT ────────────────────────────────────────────────────────────────────
8298
8299    fn compile_insert(&mut self, ins: &ast::InsertStmt) -> Result<IrInsert, PyQLError> {
8300        // Taken before anything nested is compiled, so a nested statement
8301        // cannot pick up a guard meant for this one — `compile_update` and
8302        // `compile_delete` take theirs the same way.
8303        let pending_guard = self.pending_insert_guard.take();
8304        // Isolate this insert's own nested-DML discoveries (see
8305        // `pending_nested_ctes`'s doc comment) from whatever an enclosing
8306        // compile (e.g. this insert itself being the nested DML inside an
8307        // *outer* insert's link value) had pending, so each level attaches
8308        // only its own CTEs to its own `IrInsert`.
8309        let outer_pending_nested_ctes = std::mem::take(&mut self.pending_nested_ctes);
8310        let type_name = ins.subject.qualified_name();
8311        let td = self.resolve_type(&type_name)?;
8312        if td.abstract_ && td.materialized {
8313            return Err(self.type_err(&format!(
8314                "cannot insert into interface type '{}::{}'; insert into a concrete type instead",
8315                td.module, td.name
8316            )));
8317        }
8318        let alias = self.fresh_alias();
8319        let target = IrSource {
8320            poly: None,
8321            type_name: format!("{}::{}", td.module, td.name),
8322            table: td.table.clone(),
8323            alias: alias.clone(),
8324        };
8325
8326        // Multi-link shape elements (`tags := ...` / `tags += ...`) populate
8327        // the junction table once the row exists; everything else goes
8328        // through the normal scalar/link assignment path.
8329        let mut shape = ins.shape.clone();
8330        for link in &td.links {
8331            let unset = !shape.iter().any(|el| path_leaf(&el.path).is_ok_and(|n| n == link.name));
8332            for rw in link.rewrites.iter().filter(|rw| rw.on & 1 != 0) {
8333                if let Some(value) = subject_default_insert(&rw.handler, &link.name)
8334                    && unset
8335                {
8336                    shape.push(ShapeElement {
8337                        path: ast::Path::relative(&link.name),
8338                        splat: None,
8339                        nested: None,
8340                        compexpr: Some(value),
8341                        op: ShapeOp::Assign,
8342                        filter: None,
8343                        order_by: vec![],
8344                        offset: None,
8345                        limit: None,
8346                        marker_offset: None,
8347                    });
8348                }
8349            }
8350        }
8351        // Pointer defaults the shape leaves out. Every default is applied by
8352        // expanding it into the insert's own shape;
8353        // the ones a column DEFAULT can hold are left to it here, so only the
8354        // rest — a default reading a session global, or selecting the object to
8355        // link to — is expanded.
8356        for (pointer, pyql) in inlined_pointer_defaults(td, self.schema) {
8357            if shape.iter().any(|el| path_leaf(&el.path).is_ok_and(|n| n == pointer)) {
8358                continue;
8359            }
8360            let value = crate::parse::parse_pointer_expr(&pyql)?;
8361            shape.push(default_shape_element(&pointer, value));
8362        }
8363        let mut multi_link_appends = vec![];
8364        let mut scalar_elements: Vec<ShapeElement> = vec![];
8365        for el in &shape {
8366            let pointer_name = match path_leaf(&el.path) {
8367                Ok(n) => n,
8368                Err(_) => {
8369                    scalar_elements.push(el.clone());
8370                    continue;
8371                }
8372            };
8373            if let Some(ml) = Self::resolve_multilink(td, pointer_name) {
8374                match el.op {
8375                    ShapeOp::Remove => {
8376                        return Err(self.type_err(&format!(
8377                            "cannot use `-=` for multi-link '{pointer_name}' in an insert; \
8378                         there is nothing to remove from yet"
8379                        )));
8380                    }
8381                    ShapeOp::Assign | ShapeOp::Append => {
8382                        if let Some(expr) = &el.compexpr {
8383                            let (jt, module, src_col, tgt_col, through_td) =
8384                                self.own_multilink_junction_info(td, ml)?;
8385                            let values = self.compile_multilink_values(expr, td, &alias, through_td)?;
8386                            multi_link_appends.push(IrMultiLinkMutation {
8387                                junction_table: jt,
8388                                module,
8389                                source_col: src_col,
8390                                target_col: tgt_col,
8391                                values,
8392                                single: false,
8393                            });
8394                        }
8395                    }
8396                }
8397            } else if let Some(l) = Self::resolve_link(td, pointer_name).filter(|l| l.is_junction_backed()) {
8398                // A junction-backed single link is "a multi-link capped to
8399                // one row" (D1) — on insert there's nothing to replace yet,
8400                // so `:=` populates the junction table the same way a
8401                // multi-link's own `:=`/`+=` does above.
8402                if matches!(el.op, ShapeOp::Remove) {
8403                    return Err(self.type_err(&format!(
8404                        "cannot use `-=` for link '{pointer_name}' in an insert; \
8405                         there is nothing to remove from yet"
8406                    )));
8407                }
8408                if let Some(expr) = &el.compexpr {
8409                    // `:= {}` / `:= <Type>{}` at insert time — same as
8410                    // omitting the pointer entirely: nothing to append, no
8411                    // junction row created yet.
8412                    if !is_empty_set_expr(expr) {
8413                        let (jt, module, src_col, tgt_col, through_td) = self.own_link_junction_info(td, l)?;
8414                        let values = self.compile_multilink_values(expr, td, &alias, through_td)?;
8415                        multi_link_appends.push(IrMultiLinkMutation {
8416                            junction_table: jt,
8417                            module,
8418                            source_col: src_col,
8419                            target_col: tgt_col,
8420                            values,
8421                            single: true,
8422                        });
8423                    }
8424                }
8425            } else {
8426                scalar_elements.push(el.clone());
8427            }
8428        }
8429
8430        let assignments = self.compile_assignments(&scalar_elements, td, &alias)?;
8431        // Rewrites apply in the table's own `BEFORE` trigger — see
8432        // `export::rewrite_trigger_infos`.
8433        let rewrites = vec![];
8434        let unless_conflict = match ins.unless_conflict.as_ref() {
8435            Some(uc) => {
8436                let (conflict, else_appends) = self.compile_conflict(uc, td)?;
8437                multi_link_appends.extend(else_appends);
8438                Some(conflict)
8439            }
8440            None => None,
8441        };
8442        let returning = Self::pk_returning(td);
8443        let type_name = format!("{}::{}", td.module, td.name);
8444        let enqueue_vector = td
8445            .vector_indexes
8446            .iter()
8447            .map(|vi| VectorEnqueueInfo {
8448                type_name: type_name.clone(),
8449                index_name: vi.index_name.clone(),
8450            })
8451            .collect();
8452        let enqueue_search = collect_search_enqueue(td, &type_name, "index");
8453        let mut nested_ctes = std::mem::replace(&mut self.pending_nested_ctes, outer_pending_nested_ctes);
8454
8455        let guard = match pending_guard {
8456            Some(condition) => Some(self.compile_expr(&condition, td, &alias)?),
8457            None => None,
8458        };
8459        // A nested insert exists only to fill this row's values, so it writes
8460        // when this one does and not otherwise. Without the condition it would
8461        // leave a row behind with nothing pointing at it. Applied to any
8462        // depth: a line item's own attributes are two loops down.
8463        if let Some(condition) = &guard {
8464            fn cte_refs(appends: &[IrMultiLinkMutation], into: &mut Vec<String>) {
8465                into.extend(appends.iter().filter_map(|append| match &append.values.source {
8466                    IrMultiLinkValueSource::CteRef(name) => Some(name.clone()),
8467                    _ => None,
8468                }));
8469            }
8470            fn carry_guard(stmt: &mut IrStmt, condition: &IrExpr, pending: &mut Vec<String>) {
8471                // A loop's own bindings can mutate too — a line item's
8472                // attributes are bound inside the loop that writes the line.
8473                if let IrStmt::For(loop_) = stmt {
8474                    for def in &mut loop_.body_ctes {
8475                        carry_guard(&mut def.stmt, condition, pending);
8476                    }
8477                }
8478                let nested = match stmt {
8479                    IrStmt::Insert(nested) => Some(nested),
8480                    IrStmt::For(loop_) => match loop_.body.as_mut() {
8481                        IrStmt::Insert(nested) => Some(nested),
8482                        _ => None,
8483                    },
8484                    _ => None,
8485                };
8486                let Some(nested) = nested else { return };
8487                if nested.guard.is_none() {
8488                    nested.guard = Some(condition.clone());
8489                }
8490                cte_refs(&nested.multi_link_appends, pending);
8491                for def in &mut nested.nested_ctes {
8492                    carry_guard(&mut def.stmt, condition, pending);
8493                }
8494            }
8495            // A multi-link's rows are hoisted beside this statement rather than
8496            // into its own nested CTEs, so they are reached by name instead.
8497            let mut pending: Vec<String> = vec![];
8498            cte_refs(&multi_link_appends, &mut pending);
8499            for def in &mut nested_ctes {
8500                carry_guard(&mut def.stmt, condition, &mut pending);
8501            }
8502            let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
8503            while let Some(name) = pending.pop() {
8504                if !seen.insert(name.clone()) {
8505                    continue;
8506                }
8507                if let Some(index) = self.hoisted_ctes.iter().position(|def| def.name == name) {
8508                    carry_guard(&mut self.hoisted_ctes[index].stmt, condition, &mut pending);
8509                }
8510            }
8511        }
8512        // Only a `for` body's nested insert needs to name its own id (see
8513        // `IrInsert::id_default_sql`); carried here because the schema is in
8514        // scope, and ignored everywhere else.
8515        let id_default_sql =
8516            Self::resolve_property(td, "id").map(|p| p.default_sql.clone().unwrap_or_else(|| "uuidv7()".to_string()));
8517        Ok(IrInsert {
8518            guard,
8519            target,
8520            assignments,
8521            unless_conflict,
8522            rewrites,
8523            id_default_sql,
8524            returning,
8525            enqueue_vector,
8526            enqueue_search,
8527            multi_link_appends,
8528            nested_ctes,
8529        })
8530    }
8531
8532    fn compile_assignments(
8533        &mut self,
8534        elements: &[ShapeElement],
8535        td: &TypeDescriptor,
8536        alias: &str,
8537    ) -> Result<Vec<(String, IrExpr)>, PyQLError> {
8538        self.compile_assignments_inner(elements, td, alias, false)
8539    }
8540
8541    fn compile_assignments_for_update(
8542        &mut self,
8543        elements: &[ShapeElement],
8544        td: &TypeDescriptor,
8545        alias: &str,
8546    ) -> Result<Vec<(String, IrExpr)>, PyQLError> {
8547        self.compile_assignments_inner(elements, td, alias, true)
8548    }
8549
8550    fn compile_assignments_inner(
8551        &mut self,
8552        elements: &[ShapeElement],
8553        td: &TypeDescriptor,
8554        alias: &str,
8555        deny_readonly: bool,
8556    ) -> Result<Vec<(String, IrExpr)>, PyQLError> {
8557        // A value assigned in a mutation body is not output, and a link value
8558        // among them compiles to a one-column correlated subquery — an implicit
8559        // `id` beside the column it selects would make that subquery illegal.
8560        self.without_implicit_id(|this| this.compile_assignments_rows(elements, td, alias, deny_readonly))
8561    }
8562
8563    fn compile_assignments_rows(
8564        &mut self,
8565        elements: &[ShapeElement],
8566        td: &TypeDescriptor,
8567        alias: &str,
8568        deny_readonly: bool,
8569    ) -> Result<Vec<(String, IrExpr)>, PyQLError> {
8570        elements
8571            .iter()
8572            .map(|el| {
8573                let pointer_name = path_leaf(&el.path)?;
8574                let expr = el.compexpr.as_ref().ok_or_else(|| {
8575                    PyQLError::Type(PyQLTypeError {
8576                        message: format!("INSERT pointer '{pointer_name}' has no value expression"),
8577                        position: Position { line: 0, col: 0 },
8578                    })
8579                })?;
8580
8581                // Validate the pointer exists
8582                let column = if let Some(p) = Self::resolve_property(td, pointer_name) {
8583                    // A primary-key ("id") property: an UPDATE never allows
8584                    // reassigning it (deny_readonly is true there, regardless
8585                    // of the session config); an INSERT only allows an
8586                    // explicit value when allow_user_specified_id is set.
8587                    if p.is_pk && (deny_readonly || !self.config.allow_user_specified_id) {
8588                        return Err(PyQLError::Type(PyQLTypeError {
8589                            message: "cannot assign to property 'id'".to_string(),
8590                            position: Position { line: 0, col: 0 },
8591                        }));
8592                    }
8593                    if deny_readonly && p.is_readonly {
8594                        return Err(PyQLError::Type(PyQLTypeError {
8595                            message: format!("cannot update property '{pointer_name}': it is declared as read-only"),
8596                            position: Position { line: 0, col: 0 },
8597                        }));
8598                    }
8599                    p.name.clone()
8600                } else if let Some(l) = Self::resolve_link(td, pointer_name) {
8601                    if deny_readonly && l.is_readonly {
8602                        return Err(PyQLError::Type(PyQLTypeError {
8603                            message: format!("cannot update link '{pointer_name}': it is declared as read-only"),
8604                            position: Position { line: 0, col: 0 },
8605                        }));
8606                    }
8607                    if l.is_junction_backed() {
8608                        // Reachable only via `UNLESS CONFLICT ... ELSE (UPDATE
8609                        // ... SET { ... })` — `compile_insert`/`compile_update`'s
8610                        // own shape-classification loop intercepts a junction-
8611                        // backed link before it ever reaches this generic
8612                        // scalar-assignment path; the ELSE clause has no
8613                        // junction-mutation mechanism of its own to reuse.
8614                        return Err(PyQLError::Type(PyQLTypeError {
8615                            message: format!(
8616                                "'{pointer_name}' is a junction-backed link and cannot be \
8617                                 assigned inside an UNLESS CONFLICT ELSE clause"
8618                            ),
8619                            position: Position { line: 0, col: 0 },
8620                        }));
8621                    }
8622                    // Link assignment via subquery: `company := (SELECT Company FILTER ...)`
8623                    // Compile as a scalar subquery returning the target pk (the FK uuid).
8624                    // A one-element set is that element — `credentials := {
8625                    // (insert Credentials { … }) }` says the same thing as
8626                    // assigning the insert directly.
8627                    let fk_col = format!("{}_id", l.name);
8628                    if let Some(ir_expr) = self.compile_link_key(expr, td, alias)? {
8629                        return Ok((fk_col, ir_expr));
8630                    }
8631                    fk_col
8632                } else if Self::resolve_multilink(td, pointer_name).is_some() {
8633                    // Same reach as the junction-backed link above: the
8634                    // shape-classification loop in `compile_insert` and
8635                    // `compile_update` takes multi-links first, so one only
8636                    // arrives here from an `UNLESS CONFLICT ... ELSE` clause,
8637                    // which has no junction-mutation mechanism to reuse.
8638                    // Saying the pointer does not exist sends the reader
8639                    // looking for a typo in a name that is plainly right.
8640                    return Err(PyQLError::Type(PyQLTypeError {
8641                        message: format!(
8642                            "'{pointer_name}' is a multi-link and cannot be mutated inside an \
8643                             UNLESS CONFLICT ELSE clause"
8644                        ),
8645                        position: Position { line: 0, col: 0 },
8646                    }));
8647                } else if Self::resolve_multilink(td, pointer_name).is_some() {
8648                    return Err(self.field_err(pointer_name, &format!("{}::{}", td.module, td.name)));
8649                } else if self.resolve_computed(td, pointer_name).is_some() {
8650                    // Reported as "no link or property 'x'. Did you mean 'x'?"
8651                    // before: the suggester's name list includes computeds,
8652                    // the resolution above does not, so it handed back the very
8653                    // name it had just refused.
8654                    return Err(self.type_err(&format!(
8655                        "cannot assign to '{pointer_name}': it is a computed pointer on \
8656                         {}::{}, which has no stored column to write",
8657                        td.module, td.name
8658                    )));
8659                } else {
8660                    return Err(self.field_err(pointer_name, &format!("{}::{}", td.module, td.name)));
8661                };
8662
8663                // `{}` (empty set) in assignment position means NULL.
8664                let ir_expr = if matches!(expr, Expr::Set(v) if v.is_empty()) {
8665                    IrExpr::Null
8666                } else {
8667                    self.compile_expr(expr, td, alias)?
8668                };
8669                Ok((column, ir_expr))
8670            })
8671            .collect()
8672    }
8673
8674    // ── UPDATE ────────────────────────────────────────────────────────────────────
8675
8676    fn compile_update(&mut self, upd: &ast::UpdateStmt) -> Result<IrUpdate, PyQLError> {
8677        // Taken before anything nested is compiled, so a nested statement
8678        // cannot pick up a guard meant for this one.
8679        let pending_guard = self.pending_update_guard.take();
8680        // See `compile_insert`'s identical save/restore of `pending_nested_ctes`.
8681        let outer_pending_nested_ctes = std::mem::take(&mut self.pending_nested_ctes);
8682        // `update (select T filter …).link set { … }` — the subject walks off a
8683        // sub-select rather than off a named root. The select is hoisted into
8684        // the statement's own WITH and the walk re-rooted at that binding,
8685        // giving the `with s := (select …) update s.link set { … }` spelling
8686        // the path branch below already handles.
8687        if !matches!(&upd.subject, Expr::Path(_)) {
8688            let (base, fields) = Self::peel_field_access_chain(&upd.subject);
8689            if let Expr::SubQuery(inner_stmt) = base
8690                && !fields.is_empty()
8691                && matches!(inner_stmt.as_ref(), Stmt::Select(_))
8692            {
8693                let inner = self.compile_stmt(inner_stmt)?;
8694                let cte_name = self.fresh_nested_cte_name();
8695                let type_name = self.register_cte(&cte_name, &inner);
8696                self.hoisted_ctes.push(IrCteDef {
8697                    name: cte_name.clone(),
8698                    stmt: inner,
8699                    type_name,
8700                    correlated_to: None,
8701                });
8702                let mut steps = vec![ast::PathStep::Name(cte_name)];
8703                steps.extend(fields.into_iter().map(ast::PathStep::Name));
8704                let rerooted = ast::UpdateStmt {
8705                    subject: Expr::Path(ast::Path { steps, partial: false }),
8706                    filter: upd.filter.clone(),
8707                    shape: upd.shape.clone(),
8708                };
8709                self.pending_nested_ctes = outer_pending_nested_ctes;
8710                self.pending_update_guard = pending_guard;
8711                return self.compile_update(&rerooted);
8712            }
8713        }
8714        // `update account.preferences[is IndividualPreferences] set …` — the
8715        // subject is a traversal rather than a name, so the rows to update are
8716        // the ones it lands on: the table is the type the walk ends on, and the
8717        // update is narrowed to the ids the traversal yields.
8718        if let Expr::Path(subject) = &upd.subject
8719            && subject.steps.len() > 1
8720            && let Some(ast::PathStep::Name(root)) = subject.steps.first()
8721            && let Ok(root_td) = self.resolve_path_root(root)
8722            && let (_, Some(target_td)) = self.walk_path_types(root_td, &subject.steps[1..], MAX_COMPUTED_SPLICES)
8723        {
8724            let narrowed = ast::UpdateStmt {
8725                subject: Expr::Path(ast::Path {
8726                    steps: vec![ast::PathStep::Name(format!("{}::{}", target_td.module, target_td.name))],
8727                    partial: false,
8728                }),
8729                filter: upd.filter.clone(),
8730                shape: upd.shape.clone(),
8731            };
8732            self.pending_nested_ctes = outer_pending_nested_ctes;
8733            self.pending_update_guard = pending_guard;
8734            let mut ir = self.compile_update(&narrowed)?;
8735            // Built after the update, so the comparison names that update's own
8736            // alias: with a nested statement's CTE in the FROM, a bare `id`
8737            // could mean either relation.
8738            let rows = self.compile_subject_path_rows(subject, &ir.target.alias)?;
8739            ir.filter = Some(and_conditions(ir.filter.take(), vec![rows]).expect("row set is present"));
8740            return Ok(ir);
8741        }
8742        let type_name = self.expr_as_type_name(&upd.subject)?;
8743        // `update account set …` where `account` is a `with` binding: the
8744        // binding names the rows to update, so it decides the table *and*
8745        // narrows the update to its own rows. Without the narrowing this would
8746        // resolve to the type and rewrite every row in the table.
8747        let bound_rows = self.cte_object_type(&type_name);
8748        // `for p in … union (update p set …)` — the same for a loop variable,
8749        // which names the one row this iteration holds. Without it the UPDATE
8750        // went out with no WHERE at all and rewrote every row of the table.
8751        let iterated = self
8752            .for_var_types
8753            .contains_key(&type_name)
8754            .then(|| self.for_var_ref(&type_name));
8755        let td = self.resolve_path_root(&type_name)?;
8756        let alias = self.fresh_alias();
8757        let target = IrSource {
8758            poly: None,
8759            type_name: format!("{}::{}", td.module, td.name),
8760            table: td.table.clone(),
8761            alias: alias.clone(),
8762        };
8763
8764        let declared_filter = upd
8765            .filter
8766            .as_ref()
8767            .map(|f| self.as_condition(|c| c.compile_expr(f, td, &alias)))
8768            .transpose()?;
8769        let filter = match bound_rows {
8770            Some(_) => {
8771                let membership = IrExpr::BinOp(Box::new(IrBinOp {
8772                    left: IrExpr::ColumnRef {
8773                        alias: alias.clone(),
8774                        column: "id".to_string(),
8775                        pg_type: "uuid".to_string(),
8776                    },
8777                    op: ast::BinOpKind::In,
8778                    right: IrExpr::ArrayFromSelect(Box::new(IrArraySource::Select(IrSelect::schema_bound(
8779                        IrSource {
8780                            poly: None,
8781                            type_name: format!("{}::{}", td.module, td.name),
8782                            table: format!("@cte:{type_name}"),
8783                            alias: self.fresh_alias(),
8784                        },
8785                        vec![],
8786                        None,
8787                    )))),
8788                }));
8789                Some(and_conditions(declared_filter, vec![membership]).expect("membership is present"))
8790            }
8791            None => declared_filter,
8792        };
8793        let filter = match iterated {
8794            Some(value) => {
8795                let this_row = IrExpr::BinOp(Box::new(IrBinOp {
8796                    left: IrExpr::ColumnRef {
8797                        alias: alias.clone(),
8798                        column: "id".to_string(),
8799                        pg_type: "uuid".to_string(),
8800                    },
8801                    op: ast::BinOpKind::Eq,
8802                    right: value,
8803                }));
8804                Some(and_conditions(filter, vec![this_row]).expect("the row condition is present"))
8805            }
8806            None => filter,
8807        };
8808        // `(update cart set { … }) if not exists(existing) else {}` — the
8809        // condition narrows the rows this update touches. Left to filter what
8810        // is read back instead, the update would still run.
8811        let filter = match pending_guard {
8812            Some(condition) => {
8813                let guard = self.compile_expr(&condition, td, &alias)?;
8814                and_conditions(filter, vec![guard])
8815            }
8816            None => filter,
8817        };
8818
8819        // Classify shape elements by kind.
8820        let mut multi_link_clears = vec![];
8821        let mut multi_link_replaces = vec![];
8822        let mut multi_link_appends = vec![];
8823        let mut multi_link_removals = vec![];
8824        let mut scalar_elements: Vec<ShapeElement> = vec![];
8825
8826        for el in &upd.shape {
8827            let pointer_name = match path_leaf(&el.path) {
8828                Ok(n) => n,
8829                Err(_) => {
8830                    scalar_elements.push(el.clone());
8831                    continue;
8832                }
8833            };
8834
8835            if let Some(ml) = Self::resolve_multilink(td, pointer_name) {
8836                let (jt, module, src_col, tgt_col, through_td) = self.own_multilink_junction_info(td, ml)?;
8837
8838                match el.op {
8839                    ShapeOp::Assign => {
8840                        let is_empty = el
8841                            .compexpr
8842                            .as_ref()
8843                            .map(|e| matches!(e, Expr::Set(v) if v.is_empty()))
8844                            .unwrap_or(false);
8845                        if is_empty {
8846                            // := {} — clear all junction rows
8847                            multi_link_clears.push(IrMultiLinkClear {
8848                                junction_table: jt,
8849                                module,
8850                                source_col: src_col,
8851                            });
8852                        } else if let Some(expr) = &el.compexpr {
8853                            // := expr — replace (clear + insert)
8854                            multi_link_clears.push(IrMultiLinkClear {
8855                                junction_table: jt.clone(),
8856                                module: module.clone(),
8857                                source_col: src_col.clone(),
8858                            });
8859                            let values = self.compile_multilink_values(expr, td, &alias, through_td)?;
8860                            multi_link_replaces.push(IrMultiLinkMutation {
8861                                junction_table: jt,
8862                                module,
8863                                source_col: src_col,
8864                                target_col: tgt_col,
8865                                values,
8866                                single: false,
8867                            });
8868                        }
8869                    }
8870                    ShapeOp::Append => {
8871                        if let Some(expr) = &el.compexpr {
8872                            let values = self.compile_multilink_values(expr, td, &alias, through_td)?;
8873                            multi_link_appends.push(IrMultiLinkMutation {
8874                                junction_table: jt,
8875                                module,
8876                                source_col: src_col,
8877                                target_col: tgt_col,
8878                                values,
8879                                single: false,
8880                            });
8881                        }
8882                    }
8883                    ShapeOp::Remove => {
8884                        if let Some(expr) = &el.compexpr {
8885                            let values = self.compile_multilink_values(expr, td, &alias, through_td)?;
8886                            if has_any_link_props(&values) {
8887                                return Err(self.type_err(
8888                                    "link properties (`@prop := value`) cannot be assigned \
8889                                     when removing a link (`-=`)",
8890                                ));
8891                            }
8892                            multi_link_removals.push(IrMultiLinkMutation {
8893                                junction_table: jt,
8894                                module,
8895                                source_col: src_col,
8896                                target_col: tgt_col,
8897                                values,
8898                                single: false,
8899                            });
8900                        }
8901                    }
8902                }
8903            } else if let Some(l) = Self::resolve_link(td, pointer_name).filter(|l| l.is_junction_backed()) {
8904                // Same replace-via-clear-then-insert shape a multi-link's
8905                // own `:=` uses — only `:=` is meaningful for a single
8906                // link, whether FK-backed or junction-backed.
8907                if !matches!(el.op, ShapeOp::Assign) {
8908                    return Err(self.type_err(&format!(
8909                        "'{pointer_name}' is a single link; only `:=` is supported, not `+=`/`-=`"
8910                    )));
8911                }
8912                let (jt, module, src_col, tgt_col, through_td) = self.own_link_junction_info(td, l)?;
8913                let is_empty = el.compexpr.as_ref().map(is_empty_set_expr).unwrap_or(false);
8914                if is_empty {
8915                    // := {} — clear the junction row
8916                    multi_link_clears.push(IrMultiLinkClear {
8917                        junction_table: jt,
8918                        module,
8919                        source_col: src_col,
8920                    });
8921                } else if let Some(expr) = &el.compexpr {
8922                    multi_link_clears.push(IrMultiLinkClear {
8923                        junction_table: jt.clone(),
8924                        module: module.clone(),
8925                        source_col: src_col.clone(),
8926                    });
8927                    let values = self.compile_multilink_values(expr, td, &alias, through_td)?;
8928                    multi_link_replaces.push(IrMultiLinkMutation {
8929                        junction_table: jt,
8930                        module,
8931                        source_col: src_col,
8932                        target_col: tgt_col,
8933                        values,
8934                        single: true,
8935                    });
8936                }
8937            } else {
8938                scalar_elements.push(el.clone());
8939            }
8940        }
8941
8942        let assignments = self.compile_assignments_for_update(&scalar_elements, td, &alias)?;
8943        // See the insert's own: rewrites apply in the table's `BEFORE` trigger.
8944        let rewrites = vec![];
8945        let returning = Self::pk_returning(td);
8946
8947        let (poly_implementors, poly_columns) = if self.is_polymorphic(td) {
8948            (
8949                self.find_poly_implementors(&format!("{}::{}", td.module, td.name)),
8950                Self::poly_dml_columns(td),
8951            )
8952        } else {
8953            (vec![], vec![])
8954        };
8955
8956        // Only enqueue indexes whose source pointers are touched by this update.
8957        let written_cols: std::collections::HashSet<&str> = assignments.iter().map(|(c, _)| c.as_str()).collect();
8958        let type_name = format!("{}::{}", td.module, td.name);
8959        let enqueue_vector: Vec<VectorEnqueueInfo> = td
8960            .vector_indexes
8961            .iter()
8962            .filter(|vi| vi.pointers.iter().any(|f| written_cols.contains(f.as_str())))
8963            .map(|vi| VectorEnqueueInfo {
8964                type_name: type_name.clone(),
8965                index_name: vi.index_name.clone(),
8966            })
8967            .collect();
8968        let enqueue_search = collect_search_enqueue(td, &type_name, "index");
8969        // Nested-DML CTEs (see IrUpdate::nested_ctes) compose with every
8970        // other UPDATE shape — multi-link mutation, interface-type fan-out,
8971        // and vector/search enqueue each have their own emitter branch
8972        // (emit_update_stmt / emit_poly_update_stmt) that now prepends
8973        // these CTEs and adds the FROM clause a hoisted CTE reference needs.
8974        let nested_ctes = std::mem::replace(&mut self.pending_nested_ctes, outer_pending_nested_ctes);
8975
8976        Ok(IrUpdate {
8977            target,
8978            filter,
8979            assignments,
8980            rewrites,
8981            returning,
8982            multi_link_clears,
8983            multi_link_replaces,
8984            multi_link_appends,
8985            multi_link_removals,
8986            poly_implementors,
8987            poly_columns,
8988            enqueue_vector,
8989            enqueue_search,
8990            nested_ctes,
8991        })
8992    }
8993
8994    /// Extract junction table info for a multi-link (or a junction-backed
8995    /// single link, which shares this exact storage shape — D2):
8996    /// (junction_table, module, source_col, target_col, through_td).
8997    /// `through_td` is the junction type's own TypeDescriptor for a
8998    /// `Through[...]` link (needed to validate/compile `@prop := expr`
8999    /// link-property assignments against its real properties) — `None` for
9000    /// a Standard (implicit) junction table, which has no user-declared
9001    /// properties at all.
9002    fn junction_info_for(
9003        &mut self,
9004        td: &TypeDescriptor,
9005        name: &str,
9006        target: &str,
9007        through: &Option<String>,
9008    ) -> Result<(String, String, String, String, Option<&'a TypeDescriptor>), PyQLError> {
9009        let (junction_table, module, source_col, target_col, through_td) =
9010            self.own_junction_info_for(td, name, target, through)?;
9011        let owner_derived = through_td.is_none_or(|through_td| through_td.junction);
9012        let junction_table = if owner_derived {
9013            self.owner_junction(td, name, through_td)
9014        } else {
9015            junction_table
9016        };
9017        Ok((junction_table, module, source_col, target_col, through_td))
9018    }
9019
9020    /// Every junction table a read of `td`'s multi-links can name, quoted as
9021    /// the emitter spells it. A `through` type brings its own table; an
9022    /// owner-derived junction is named after the owner, and a subtype's
9023    /// carries that subtype's table, so each implementor contributes one.
9024    fn junction_tables_of(&mut self, td: &TypeDescriptor) -> Result<Vec<String>, PyQLError> {
9025        let mut tables = vec![];
9026        for multilink in &td.multilinks {
9027            match &multilink.through {
9028                Some(through) => {
9029                    let through_td = self.resolve_type(through)?;
9030                    tables.push(format!("\"{}\"", through_td.table));
9031                }
9032                None => {
9033                    tables.push(format!("\"{}.{}\"", td.table, multilink.name));
9034                    for implementor in self.find_poly_implementors(&format!("{}::{}", td.module, td.name)) {
9035                        tables.push(format!("\"{}.{}\"", implementor.table, multilink.name));
9036                    }
9037                }
9038            }
9039        }
9040        Ok(tables)
9041    }
9042
9043    /// The junction a read of `td`'s multi-link `name` goes through: its own
9044    /// table, or, when `td` has subtypes, the union of theirs beside it.
9045    fn owner_junction(&self, td: &TypeDescriptor, name: &str, through_td: Option<&TypeDescriptor>) -> String {
9046        let own = format!("{}.{}", td.table, name);
9047        if !self.has_subtypes(td) {
9048            return own;
9049        }
9050        let tables = self
9051            .find_poly_implementors(&format!("{}::{}", td.module, td.name))
9052            .into_iter()
9053            .map(|implementor| (implementor.module, format!("{}.{}", implementor.table, name)))
9054            .collect::<Vec<_>>();
9055        let mut columns = vec!["source".to_string(), "target".to_string()];
9056        if let Some(through_td) = through_td {
9057            columns.extend(Self::poly_dml_columns(through_td).into_iter().filter(|c| c != "id"));
9058        }
9059        super::inherited_junction(&tables, &columns)
9060    }
9061
9062    /// `junction_info_for` naming the owner's own junction table, which is
9063    /// the one a write to that owner's rows goes to.
9064    fn own_junction_info_for(
9065        &mut self,
9066        td: &TypeDescriptor,
9067        name: &str,
9068        target: &str,
9069        through: &Option<String>,
9070    ) -> Result<(String, String, String, String, Option<&'a TypeDescriptor>), PyQLError> {
9071        match through {
9072            None => Ok((
9073                format!("{}.{}", td.table, name),
9074                td.module.clone(),
9075                "source".to_string(),
9076                "target".to_string(),
9077                None,
9078            )),
9079            Some(through_qname) => {
9080                let through_td = self.resolve_type(through_qname)?;
9081                let src_type = format!("{}::{}", td.module, td.name);
9082                let source_col = through_td
9083                    .links
9084                    .iter()
9085                    .find(|l| l.target == src_type)
9086                    .map(|l| l.name.clone())
9087                    .unwrap_or_else(|| "source".to_string());
9088                // A self-referencing through-link (source type == target
9089                // type, e.g. Person.friends via a PersonFriend with two
9090                // Person-typed links) would otherwise match the same link
9091                // for both sides — prefer a differently-named one first,
9092                // matching the tie-break already used for the read-side
9093                // join resolution elsewhere in this file.
9094                let target_col = through_td
9095                    .links
9096                    .iter()
9097                    .find(|l| l.target == target && l.name != source_col)
9098                    .or_else(|| through_td.links.iter().find(|l| l.target == target))
9099                    .map(|l| l.name.clone())
9100                    .unwrap_or_else(|| "target".to_string());
9101                // A dedicated `@pylon.junction` type has no physical table
9102                // of its own — `emit_one_junction_table` always names it
9103                // `"{owner.table}.{name}"`, one per *owner* (so that e.g.
9104                // an interface-inherited junction-backed link gets one
9105                // physically separate table per concrete implementor,
9106                // never a single table shared — and thus impossibly
9107                // FK'd — across several). `through_td.table` only holds
9108                // the right name here by accident, for the common case of
9109                // exactly one owner ever referencing that junction type
9110                // (the Python walker pre-renames it for that one owner).
9111                // A non-junction "through" type, in contrast, *is* a real,
9112                // independently-queryable object with its own genuine
9113                // table — `through_td.table` is correct for that case and
9114                // must stay as-is.
9115                let (junction_table, junction_module) = if through_td.junction {
9116                    (format!("{}.{}", td.table, name), td.module.clone())
9117                } else {
9118                    (through_td.table.clone(), through_td.module.clone())
9119                };
9120                Ok((
9121                    junction_table,
9122                    junction_module,
9123                    source_col,
9124                    target_col,
9125                    Some(through_td),
9126                ))
9127            }
9128        }
9129    }
9130
9131    fn multilink_junction_info(
9132        &mut self,
9133        td: &TypeDescriptor,
9134        ml: &MultiLinkDescriptor,
9135    ) -> Result<(String, String, String, String, Option<&'a TypeDescriptor>), PyQLError> {
9136        self.junction_info_for(td, &ml.name, &ml.target, &ml.through)
9137    }
9138
9139    fn own_multilink_junction_info(
9140        &mut self,
9141        td: &TypeDescriptor,
9142        ml: &MultiLinkDescriptor,
9143    ) -> Result<(String, String, String, String, Option<&'a TypeDescriptor>), PyQLError> {
9144        self.own_junction_info_for(td, &ml.name, &ml.target, &ml.through)
9145    }
9146
9147    fn own_link_junction_info(
9148        &mut self,
9149        td: &TypeDescriptor,
9150        l: &LinkDescriptor,
9151    ) -> Result<(String, String, String, String, Option<&'a TypeDescriptor>), PyQLError> {
9152        self.own_junction_info_for(td, &l.name, &l.target, &l.through)
9153    }
9154
9155    /// Same as `multilink_junction_info`, for a junction-backed single link.
9156    fn link_junction_info(
9157        &mut self,
9158        td: &TypeDescriptor,
9159        l: &LinkDescriptor,
9160    ) -> Result<(String, String, String, String, Option<&'a TypeDescriptor>), PyQLError> {
9161        self.junction_info_for(td, &l.name, &l.target, &l.through)
9162    }
9163
9164    /// Build the `IrMultiLinkJoin` a multi-link or junction-backed single
9165    /// link's forward read-side join uses (`IrPathJoin::Multi`, a shape's
9166    /// `IrMultiLinkPointer`, or — for a junction-backed single link — the
9167    /// junction variant of `IrSingleLinkCorrelation`).
9168    fn build_multilink_join(
9169        &mut self,
9170        td: &TypeDescriptor,
9171        name: &str,
9172        target: &str,
9173        through: &Option<String>,
9174    ) -> Result<IrMultiLinkJoin, PyQLError> {
9175        if let Some(through_qname) = through {
9176            let through_td = self.resolve_type(through_qname)?;
9177            if through_td.junction {
9178                // See `junction_info_for`'s doc comment: a dedicated
9179                // junction type's physical table is always owner-derived
9180                // (one per concrete implementor), never `through_td.table`
9181                // itself.
9182                Ok(IrMultiLinkJoin::Standard {
9183                    junction_table: self.owner_junction(td, name, Some(through_td)),
9184                    module: td.module.clone(),
9185                })
9186            } else {
9187                let source_qname = format!("{}::{}", td.module, td.name);
9188                let source_col = through_td
9189                    .links
9190                    .iter()
9191                    .find(|l| l.target == source_qname)
9192                    .ok_or_else(|| {
9193                        PyQLError::Type(PyQLTypeError {
9194                            message: format!("through type {through_qname} has no link to {source_qname}"),
9195                            position: Position { line: 0, col: 0 },
9196                        })
9197                    })?
9198                    .name
9199                    .clone();
9200                let target_col = through_td
9201                    .links
9202                    .iter()
9203                    .find(|l| l.target == target && l.name != source_col)
9204                    .or_else(|| through_td.links.iter().find(|l| l.target == target))
9205                    .ok_or_else(|| {
9206                        PyQLError::Type(PyQLTypeError {
9207                            message: format!("through type {through_qname} has no link to target {target}"),
9208                            position: Position { line: 0, col: 0 },
9209                        })
9210                    })?
9211                    .name
9212                    .clone();
9213                Ok(IrMultiLinkJoin::Through {
9214                    junction_table: through_td.table.clone(),
9215                    module: through_td.module.clone(),
9216                    source_col,
9217                    target_col,
9218                })
9219            }
9220        } else {
9221            let junction_table = self.owner_junction(td, name, None);
9222            Ok(IrMultiLinkJoin::Standard {
9223                junction_table: match self.junction_read_overrides.get(&junction_table) {
9224                    Some(o) => o.junction.clone(),
9225                    None => junction_table,
9226                },
9227                module: td.module.clone(),
9228            })
9229        }
9230    }
9231
9232    /// Compile the RHS of a multilink `+=`, `-=`, or `:= expr` into an
9233    /// `IrMultiLinkValues`. `td`/`alias` are the record being updated (link-
9234    /// property value expressions like `@weight := <float64>$w` compile
9235    /// against this scope, same as any other UPDATE SET assignment — they
9236    /// cannot reference the linked target's own properties, only the outer
9237    /// record's or bound params/literals). `through_td` is the junction
9238    /// type's own TypeDescriptor for a `Through[...]` multi-link, or `None`
9239    /// for a Standard junction (which has no properties to assign).
9240    fn compile_multilink_values(
9241        &mut self,
9242        expr: &Expr,
9243        td: &'a TypeDescriptor,
9244        alias: &str,
9245        through_td: Option<&'a TypeDescriptor>,
9246    ) -> Result<IrMultiLinkValues, PyQLError> {
9247        // Same reason as `compile_assignments_inner`: these rows are the
9248        // junction's target ids, not output, and each value is read as one
9249        // column.
9250        self.without_implicit_id(|this| this.compile_multilink_values_inner(expr, td, alias, through_td))
9251    }
9252
9253    fn compile_multilink_values_inner(
9254        &mut self,
9255        expr: &Expr,
9256        td: &'a TypeDescriptor,
9257        alias: &str,
9258        through_td: Option<&'a TypeDescriptor>,
9259    ) -> Result<IrMultiLinkValues, PyQLError> {
9260        // `a union b` — combine both sides; each keeps its own link_props
9261        // (different targets in one `+=` can carry different property values).
9262        if let Expr::Union(a, b) = expr {
9263            let left = self.compile_multilink_values(a, td, alias, through_td)?;
9264            let right = self.compile_multilink_values(b, td, alias, through_td)?;
9265            return Ok(IrMultiLinkValues {
9266                source: IrMultiLinkValueSource::Union(Box::new(left), Box::new(right)),
9267                link_props: vec![],
9268            });
9269        }
9270
9271        // `emails := (insert …) if exists($email) else {}` — the same choice
9272        // between two sets the select path reads, and the same rewrite: the
9273        // insert carries the condition itself, since Postgres runs a
9274        // data-modifying CTE whether or not anything reads it.
9275        if matches!(expr, Expr::IfElse(_))
9276            && let Some(rewritten) = self.object_if_else_as_union(expr)
9277        {
9278            return self.compile_multilink_values(&rewritten, td, alias, through_td);
9279        }
9280
9281        // `notifications += (select .notifications { @read_at := … } filter …)`
9282        // — the link properties sit *inside* the select. Lifted out, the shape
9283        // branch below assigns them and the bare select goes down the
9284        // relative-path branch, which is what each already knows how to do.
9285        if let Expr::SubQuery(inner) = expr
9286            && let Stmt::Select(sel) = inner.as_ref()
9287            && let Expr::Shape(sh) = &sel.result
9288            && !sh.elements.is_empty()
9289            && sh
9290                .elements
9291                .iter()
9292                .all(|el| matches!(el.path.steps.as_slice(), [ast::PathStep::LinkProp(_)]))
9293            && let Some(base) = sh.expr.clone()
9294        {
9295            let lifted = Expr::Shape(Box::new(ast::ShapeExpr {
9296                expr: Some(Expr::SubQuery(Box::new(Stmt::Select(ast::SelectStmt {
9297                    result: base,
9298                    ..sel.clone()
9299                })))),
9300                elements: sh.elements.clone(),
9301                marker_offset: sh.marker_offset,
9302            }));
9303            return self.compile_multilink_values(&lifted, td, alias, through_td);
9304        }
9305
9306        // `expr { @prop := value, ... }` — link-property assignments layered
9307        // onto an inner target-selecting expression.
9308        if let Expr::Shape(shape) = expr {
9309            let inner_expr = shape
9310                .expr
9311                .as_ref()
9312                .ok_or_else(|| self.type_err("multilink value shape must have a base expression"))?;
9313            let mut inner = self.compile_multilink_values(inner_expr, td, alias, through_td)?;
9314            // `(select .notifications { @read_at := … @read_at })` — over a
9315            // walk of a link with properties, `@prop` reads the row's current
9316            // value off the junction that walk crosses.
9317            let walked_junction = match (&inner.source, inner_expr) {
9318                (IrMultiLinkValueSource::PathSelect(ps), Expr::SubQuery(stmt)) => {
9319                    match (stmt.as_ref(), ps.joins.last()) {
9320                        (Stmt::Select(sel), Some(IrPathJoin::Multi { junction_alias, .. })) => match &sel.result {
9321                            Expr::Path(p) if p.partial => match p.steps.as_slice() {
9322                                [ast::PathStep::Name(link)] => Self::resolve_multilink(td, link)
9323                                    .and_then(|m| m.through.clone())
9324                                    .map(|through| (through, junction_alias.clone())),
9325                                _ => None,
9326                            },
9327                            _ => None,
9328                        },
9329                        _ => None,
9330                    }
9331                }
9332                _ => None,
9333            };
9334
9335            let Some(through) = through_td else {
9336                return Err(self.type_err(
9337                    "link properties (`@prop := value`) are only valid on a multi-link \
9338                     declared with `Through[...]`",
9339                ));
9340            };
9341
9342            for el in &shape.elements {
9343                let prop_name = match el.path.steps.as_slice() {
9344                    [ast::PathStep::LinkProp(name)] => name.clone(),
9345                    _ => return Err(self.type_err("only `@prop := value` link-property assignments are valid here")),
9346                };
9347                let prop = Self::resolve_property(through, &prop_name)
9348                    .ok_or_else(|| self.field_err(&prop_name, &format!("{}::{}", through.module, through.name)))?;
9349                if prop.is_readonly {
9350                    return Err(self.type_err(&format!(
9351                        "cannot set link property '{prop_name}': it is declared as read-only"
9352                    )));
9353                }
9354                let value_expr = el
9355                    .compexpr
9356                    .as_ref()
9357                    .ok_or_else(|| self.type_err(&format!("link property '{prop_name}' must be assigned a value")))?;
9358                let scoped = walked_junction.is_some();
9359                if scoped {
9360                    self.link_prop_scope.push(walked_junction.clone());
9361                }
9362                let ir_expr = self.compile_expr(value_expr, td, alias);
9363                if scoped {
9364                    self.link_prop_scope.pop();
9365                }
9366                inner.link_props.push((prop.name.clone(), ir_expr?));
9367            }
9368            return Ok(inner);
9369        }
9370
9371        // `prices := assert_distinct(<union>)` — the assert checks the targets
9372        // before they become junction rows, and passes them through otherwise.
9373        if let Expr::FunctionCall(f) = expr
9374            && (f.module.is_none() || f.module.as_deref() == Some("std"))
9375            && matches!(f.name.as_str(), "assert_exists" | "assert_distinct")
9376            && let [arg] = f.args.as_slice()
9377        {
9378            let arg = arg.clone();
9379            let inner = self.compile_multilink_values(&arg, td, alias, through_td)?;
9380            // The ids travel through the check as a plain array and come back
9381            // unnested, which leaves no row for a per-target link property to
9382            // ride along on.
9383            let mut props = vec![];
9384            crate::sql::collect_link_prop_names(&inner, &mut props);
9385            if !props.is_empty() {
9386                return Err(self.type_err(&format!(
9387                    "'{}' cannot be applied to a link that carries link properties ({}) —                      the check reads the targets alone",
9388                    f.name,
9389                    props.join(", "),
9390                )));
9391            }
9392            let message = self.assert_message(f, Some((td, alias)))?;
9393            return Ok(IrMultiLinkValues {
9394                source: IrMultiLinkValueSource::Asserted {
9395                    fn_name: f.name.clone(),
9396                    inner: Box::new(inner),
9397                    message,
9398                },
9399                link_props: vec![],
9400            });
9401        }
9402
9403        // `translations := (select { teaser, title })` — a select with no
9404        // clauses of its own is the expression it wraps, and the forms below
9405        // already know what to do with that. Left wrapped, a free row source
9406        // (a set literal, a union) reaches the generic subquery arm and is
9407        // refused, while the identical unwrapped `{ teaser, title }` compiles.
9408        if let Expr::SubQuery(inner) = expr
9409            && let Stmt::Select(sel) = inner.as_ref()
9410            && sel.filter.is_none()
9411            && sel.order_by.is_empty()
9412            && sel.offset.is_none()
9413            && sel.limit.is_none()
9414            && matches!(sel.result, Expr::Set(_) | Expr::Union(_, _))
9415        {
9416            let result = sel.result.clone();
9417            return self.compile_multilink_values(&result, td, alias, through_td);
9418        }
9419
9420        // CTE reference: bare name matching a registered CTE
9421        if let Some(name) = self.resolve_cte_name(expr) {
9422            return Ok(IrMultiLinkValues {
9423                source: IrMultiLinkValueSource::CteRef(name.to_string()),
9424                link_props: vec![],
9425            });
9426        }
9427
9428        // `{ a, b }` — a set literal of targets, each contributing its own rows.
9429        if let Expr::Set(elements) = expr
9430            && !elements.is_empty()
9431        {
9432            let mut combined: Option<IrMultiLinkValues> = None;
9433            for element in elements {
9434                let one = self.compile_multilink_values(element, td, alias, through_td)?;
9435                combined = Some(match combined {
9436                    None => one,
9437                    Some(previous) => IrMultiLinkValues {
9438                        source: IrMultiLinkValueSource::Union(Box::new(previous), Box::new(one)),
9439                        link_props: vec![],
9440                    },
9441                });
9442            }
9443            return Ok(combined.expect("elements is non-empty"));
9444        }
9445
9446        // `social_accounts := (for p in … union (insert …))` — the loop's rows
9447        // are the link's targets, read back from the CTE it is hoisted into,
9448        // the same way a bare nested insert is.
9449        if let Expr::SubQuery(inner) = expr
9450            && let Stmt::For(_) = inner.as_ref()
9451        {
9452            let (cte_name, _) = self.hoist_dml_as_cte(inner.as_ref())?;
9453            return Ok(IrMultiLinkValues {
9454                source: IrMultiLinkValueSource::CteRef(cte_name),
9455                link_props: vec![],
9456            });
9457        }
9458
9459        // `emails := (insert Email { … })` — a nested insert whose rows become
9460        // the link's targets, read back from the CTE it is hoisted into.
9461        if let Expr::SubQuery(inner) = expr
9462            && matches!(inner.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_))
9463        {
9464            let cte_name = self.hoist_nested_dml(inner)?;
9465            return Ok(IrMultiLinkValues {
9466                source: IrMultiLinkValueSource::CteRef(cte_name),
9467                link_props: vec![],
9468            });
9469        }
9470
9471        // `emails := (select .emails filter .primary)` — a relative path here
9472        // is relative to the row being written, so it is rooted at that type
9473        // and correlated back to it; compiled bare it has no object to
9474        // resolve against and reads as a free select.
9475        if let Expr::SubQuery(inner) = expr
9476            && let Stmt::Select(sel) = inner.as_ref()
9477            && let Expr::Path(path) = &sel.result
9478            && path.partial
9479        {
9480            let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
9481            steps.extend(path.steps.iter().cloned());
9482            let rooted = ast::Path { steps, partial: false };
9483            let synthetic = ast::SelectStmt {
9484                result: Expr::Path(rooted.clone()),
9485                filter: sel.filter.clone(),
9486                order_by: sel.order_by.clone(),
9487                offset: sel.offset.clone(),
9488                limit: sel.limit.clone(),
9489                lock: None,
9490            };
9491            let mut ps = self.compile_path_select(&synthetic, &rooted, &[], false)?;
9492            Self::correlate_path_select(&mut ps, alias);
9493            return Ok(IrMultiLinkValues {
9494                source: IrMultiLinkValueSource::PathSelect(Box::new(ps)),
9495                link_props: vec![],
9496            });
9497        }
9498
9499        // Parenthesised subquery
9500        if let Expr::SubQuery(inner) = expr {
9501            return match self.compile_stmt(inner)? {
9502                IrStmt::Select(s) if matches!(s.rows.as_slice(), [IrRowSource::Bound { .. }]) => {
9503                    Ok(IrMultiLinkValues {
9504                        source: IrMultiLinkValueSource::Select(Box::new(s)),
9505                        link_props: vec![],
9506                    })
9507                }
9508                IrStmt::PathSelect(ps) => Ok(IrMultiLinkValues {
9509                    source: IrMultiLinkValueSource::PathSelect(Box::new(ps)),
9510                    link_props: vec![],
9511                }),
9512                _ => Err(self.type_err("multilink value must resolve to a SELECT or path query")),
9513            };
9514        }
9515
9516        if let Expr::FunctionCall(fc) = expr {
9517            let synthetic = ast::SelectStmt {
9518                result: expr.clone(),
9519                filter: None,
9520                order_by: vec![],
9521                offset: None,
9522                limit: None,
9523                lock: None,
9524            };
9525            if let Some(fs) = self.try_compile_fn_object_select(fc, &[], &synthetic, false)? {
9526                return Ok(IrMultiLinkValues {
9527                    source: IrMultiLinkValueSource::Function(Box::new(fs)),
9528                    link_props: vec![],
9529                });
9530            }
9531        }
9532
9533        // Absolute path expression (type reference or path traversal)
9534        if let Expr::Path(p) = expr
9535            && !p.partial
9536        {
9537            let fake_sel = ast::SelectStmt {
9538                result: expr.clone(),
9539                filter: None,
9540                order_by: vec![],
9541                offset: None,
9542                limit: None,
9543                lock: None,
9544            };
9545            return match self.compile_stmt(&Stmt::Select(fake_sel))? {
9546                IrStmt::PathSelect(ps) => Ok(IrMultiLinkValues {
9547                    source: IrMultiLinkValueSource::PathSelect(Box::new(ps)),
9548                    link_props: vec![],
9549                }),
9550                IrStmt::Select(s) if matches!(s.rows.as_slice(), [IrRowSource::Bound { .. }]) => {
9551                    Ok(IrMultiLinkValues {
9552                        source: IrMultiLinkValueSource::Select(Box::new(s)),
9553                        link_props: vec![],
9554                    })
9555                }
9556                _ => Err(self.type_err("expected a path expression for multilink value")),
9557            };
9558        }
9559
9560        Err(self.type_err("multilink value must be a CTE reference, parenthesised subquery, or type path"))
9561    }
9562
9563    // ── DELETE ────────────────────────────────────────────────────────────────────
9564
9565    fn compile_delete(&mut self, del: &ast::DeleteStmt) -> Result<IrDelete, PyQLError> {
9566        // Taken before anything nested is compiled, so a nested statement
9567        // cannot pick up a guard meant for this one.
9568        let pending_guard = self.pending_delete_guard.take();
9569        let type_name = self.expr_as_type_name(&del.subject)?;
9570        // `delete previous` where `previous` is a `with` binding: the binding
9571        // names the rows to delete, so it decides the table *and* narrows the
9572        // delete to its own rows — exactly as `compile_update` does. Without
9573        // the narrowing this would resolve to the type and empty the table.
9574        let bound_rows = self.cte_object_type(&type_name);
9575        let td = self.resolve_path_root(&type_name)?;
9576        let alias = self.fresh_alias();
9577        let target = IrSource {
9578            poly: None,
9579            type_name: format!("{}::{}", td.module, td.name),
9580            table: td.table.clone(),
9581            alias: alias.clone(),
9582        };
9583
9584        let declared_filter = del
9585            .filter
9586            .as_ref()
9587            .map(|f| self.as_condition(|c| c.compile_expr(f, td, &alias)))
9588            .transpose()?;
9589        let filter = match bound_rows {
9590            Some(_) => {
9591                let membership = IrExpr::BinOp(Box::new(IrBinOp {
9592                    left: IrExpr::ColumnRef {
9593                        alias: alias.clone(),
9594                        column: "id".to_string(),
9595                        pg_type: "uuid".to_string(),
9596                    },
9597                    op: ast::BinOpKind::In,
9598                    right: IrExpr::ArrayFromSelect(Box::new(IrArraySource::Select(IrSelect::schema_bound(
9599                        IrSource {
9600                            poly: None,
9601                            type_name: format!("{}::{}", td.module, td.name),
9602                            table: format!("@cte:{type_name}"),
9603                            alias: self.fresh_alias(),
9604                        },
9605                        vec![],
9606                        None,
9607                    )))),
9608                }));
9609                Some(and_conditions(declared_filter, vec![membership]).expect("membership is present"))
9610            }
9611            None => declared_filter,
9612        };
9613        let filter = match pending_guard {
9614            Some(condition) => {
9615                let guard = self.compile_expr(&condition, td, &alias)?;
9616                and_conditions(filter, vec![guard])
9617            }
9618            None => filter,
9619        };
9620
9621        let returning = Self::pk_returning(td);
9622
9623        let (poly_implementors, poly_columns) = if self.is_polymorphic(td) {
9624            (
9625                self.find_poly_implementors(&format!("{}::{}", td.module, td.name)),
9626                Self::poly_dml_columns(td),
9627            )
9628        } else {
9629            (vec![], vec![])
9630        };
9631        let qname = format!("{}::{}", td.module, td.name);
9632        let enqueue_search = collect_search_enqueue(td, &qname, "delete");
9633
9634        Ok(IrDelete {
9635            target,
9636            filter,
9637            returning,
9638            poly_implementors,
9639            poly_columns,
9640            enqueue_search,
9641        })
9642    }
9643
9644    // ── Shape compilation ─────────────────────────────────────────────────────────
9645
9646    fn compile_shape(
9647        &mut self,
9648        elements: &[ShapeElement],
9649        td: &TypeDescriptor,
9650        alias: &str,
9651        module: &str,
9652    ) -> Result<Vec<IrShapePointer>, PyQLError> {
9653        if elements.is_empty() {
9654            // No explicit shape: implicit { id } only.
9655            return Ok(Self::pk_returning(td));
9656        }
9657
9658        let mut pointers = vec![];
9659        for el in elements {
9660            if let Some(splat) = &el.splat {
9661                // Check if this is a type-intersection splat: [is Type].*
9662                if let Some(ast::PathStep::TypeIntersection(type_ref)) = el.path.steps.first() {
9663                    let type_ref = type_ref.clone();
9664                    pointers.extend(self.compile_type_intersection_splat(&type_ref, splat, td, alias)?);
9665                } else {
9666                    pointers.extend(self.compile_splat(splat, td, alias, module)?);
9667                }
9668            } else {
9669                pointers.push(self.compile_shape_element(el, td, alias, module)?);
9670            }
9671        }
9672        self.prepend_implicit_id(&mut pointers, td);
9673        Ok(pointers)
9674    }
9675
9676    /// Put `id` at the front of a shape that did not select one, which every
9677    /// binary-protocol query carries whether or not it asked. Without it,
9678    /// `o.id` on a shape like `options: { value }` reads as unset rather than
9679    /// as the row's id.
9680    ///
9681    /// Skipped in the two places that cannot carry it: inside a mutation's own body and under
9682    /// a cast to `json` — see `implicit_id_in_shapes`. A shape that names `id`
9683    /// itself keeps its own pointer, and its position, untouched.
9684    fn prepend_implicit_id(&self, pointers: &mut Vec<IrShapePointer>, td: &TypeDescriptor) {
9685        if !self.implicit_id_in_shapes {
9686            return;
9687        }
9688        let Some(pk) = td.properties.iter().find(|p| p.is_pk) else {
9689            return;
9690        };
9691        if pointers.iter().any(|p| p.alias() == pk.name) {
9692            return;
9693        }
9694        pointers.insert(
9695            0,
9696            IrShapePointer::Scalar(IrScalarPointer {
9697                implicit_id: true,
9698                marker_offset: None,
9699                alias: pk.name.clone(),
9700                column: pk.name.clone(),
9701                pg_type: pk.pg_type.clone(),
9702                tuple_shape: None,
9703            }),
9704        );
9705    }
9706
9707    /// Run `body` with implicit ids suppressed, restoring the previous setting
9708    /// however it ends.
9709    fn without_implicit_id<T>(&mut self, body: impl FnOnce(&mut Self) -> Result<T, PyQLError>) -> Result<T, PyQLError> {
9710        let saved = std::mem::replace(&mut self.implicit_id_in_shapes, false);
9711        let result = body(self);
9712        self.implicit_id_in_shapes = saved;
9713        result
9714    }
9715
9716    /// Expand `*` → all scalars; `**` → all scalars + all single links with implicit `{ id }`.
9717    fn compile_splat(
9718        &mut self,
9719        splat: &ast::Splat,
9720        td: &TypeDescriptor,
9721        alias: &str,
9722        module: &str,
9723    ) -> Result<Vec<IrShapePointer>, PyQLError> {
9724        let mut pointers: Vec<IrShapePointer> = td
9725            .properties
9726            .iter()
9727            .map(|p| {
9728                IrShapePointer::Scalar(IrScalarPointer {
9729                    implicit_id: false,
9730                    marker_offset: None,
9731                    alias: p.name.clone(),
9732                    column: p.name.clone(),
9733                    pg_type: p.pg_type.clone(),
9734                    tuple_shape: self.resolve_property_tuple_shape(p),
9735                })
9736            })
9737            .collect();
9738
9739        for cd in &td.computed.clone() {
9740            // `*` is properties; links arrive only with `**`. A computed
9741            // standing for objects is a link however it is written, so it
9742            // waits for the deep form too.
9743            if matches!(splat, ast::Splat::Shallow) && self.computed_is_object_valued(cd, td) {
9744                continue;
9745            }
9746            pointers.push(self.compile_declared_computed(cd, td, alias, module, None, &[])?);
9747        }
9748
9749        if matches!(splat, ast::Splat::Deep) {
9750            for l in &td.links {
9751                let target_td = self.resolve_type(&l.target)?;
9752                let sub_alias = self.fresh_alias();
9753                // `**` fetches every property (not further nested links) of
9754                // a linked object — a `Shallow` (`*`) expansion of the
9755                // target type, one level deep. Recursing with `Deep` here
9756                // instead would walk the target's own links too, which for
9757                // a two-way or cyclic link graph never terminates.
9758                let target_module = target_td.module.clone();
9759                let sub_shape = self.compile_splat(&ast::Splat::Shallow, target_td, &sub_alias, &target_module)?;
9760                let subquery = IrSelect::schema_bound(
9761                    IrSource {
9762                        poly: self.link_target_fanout(target_td),
9763                        type_name: format!("{}::{}", target_td.module, target_td.name),
9764                        table: target_td.table.clone(),
9765                        alias: sub_alias.clone(),
9766                    },
9767                    sub_shape,
9768                    None,
9769                );
9770                let correlation = if l.is_junction_backed() {
9771                    let join = self.build_multilink_join(td, &l.name, &l.target, &l.through)?;
9772                    IrSingleLinkCorrelation::Junction {
9773                        join,
9774                        target_pk: "id".to_string(),
9775                    }
9776                } else {
9777                    IrSingleLinkCorrelation::Fk {
9778                        fk_column: format!("{}_id", l.name),
9779                        target_pk: "id".to_string(),
9780                    }
9781                };
9782                pointers.push(IrShapePointer::SingleLink(IrSingleLinkPointer {
9783                    marker_offset: None,
9784                    alias: l.name.clone(),
9785                    correlation,
9786                    subquery,
9787                    link_properties: vec![],
9788                }));
9789            }
9790
9791            for ml in &td.multilinks {
9792                let sub_alias = self.fresh_alias();
9793                let target_td = self.resolve_type(&ml.target)?;
9794                // See the single-link loop above — same Shallow-not-Deep
9795                // reasoning applies to multilink targets.
9796                let target_module = target_td.module.clone();
9797                let sub_shape = self.compile_splat(&ast::Splat::Shallow, target_td, &sub_alias, &target_module)?;
9798
9799                let join = if let Some(through_qname) = &ml.through {
9800                    let through_td = self.resolve_type(through_qname)?;
9801                    if through_td.junction {
9802                        // See `junction_info_for`'s doc comment: owner-derived.
9803                        IrMultiLinkJoin::Standard {
9804                            junction_table: self.owner_junction(td, &ml.name, Some(through_td)),
9805                            module: td.module.clone(),
9806                        }
9807                    } else {
9808                        let source_qname = format!("{}::{}", td.module, td.name);
9809                        let source_col = through_td
9810                            .links
9811                            .iter()
9812                            .find(|l| l.target == source_qname)
9813                            .ok_or_else(|| {
9814                                PyQLError::Type(PyQLTypeError {
9815                                    message: format!(
9816                                        "through type {through_qname} has no link to source type {source_qname}"
9817                                    ),
9818                                    position: Position { line: 0, col: 0 },
9819                                })
9820                            })?
9821                            .name
9822                            .clone();
9823                        let target_col = through_td
9824                            .links
9825                            .iter()
9826                            .find(|l| l.target == ml.target && l.name != source_col)
9827                            .or_else(|| through_td.links.iter().find(|l| l.target == ml.target))
9828                            .ok_or_else(|| {
9829                                PyQLError::Type(PyQLTypeError {
9830                                    message: format!(
9831                                        "through type {through_qname} has no link to target type {}",
9832                                        ml.target
9833                                    ),
9834                                    position: Position { line: 0, col: 0 },
9835                                })
9836                            })?
9837                            .name
9838                            .clone();
9839                        IrMultiLinkJoin::Through {
9840                            junction_table: through_td.table.clone(),
9841                            module: through_td.module.clone(),
9842                            source_col,
9843                            target_col,
9844                        }
9845                    }
9846                } else {
9847                    // See `junction_info_for`: the junction table is named
9848                    // after the owner and lives in the owner's schema, which a
9849                    // splat reaching in from another module is not.
9850                    IrMultiLinkJoin::Standard {
9851                        junction_table: self.owner_junction(td, &ml.name, None),
9852                        module: td.module.clone(),
9853                    }
9854                };
9855
9856                let subquery = self.link_target_select(
9857                    target_td,
9858                    IrSource {
9859                        poly: None,
9860                        type_name: format!("{}::{}", target_td.module, target_td.name),
9861                        table: target_td.table.clone(),
9862                        alias: sub_alias.clone(),
9863                    },
9864                    sub_shape,
9865                );
9866
9867                let link_properties = self.splat_link_properties(ml)?;
9868                pointers.push(IrShapePointer::MultiLink(IrMultiLinkPointer {
9869                    marker_offset: None,
9870                    alias: ml.name.clone(),
9871                    join,
9872                    subquery,
9873                    link_properties,
9874                    single: false,
9875                }));
9876            }
9877        }
9878
9879        Ok(pointers)
9880    }
9881
9882    /// The link properties a splat over a multi-link's targets carries: every
9883    /// property of its `@pylon.junction` through type.
9884    fn splat_link_properties(&self, ml: &MultiLinkDescriptor) -> Result<Vec<IrLinkProp>, PyQLError> {
9885        let Some(through_qname) = &ml.through else {
9886            return Ok(vec![]);
9887        };
9888        let through_td = self.resolve_type(through_qname)?;
9889        if !through_td.junction {
9890            return Ok(vec![]);
9891        }
9892        Ok(through_td
9893            .properties
9894            .iter()
9895            .filter(|p| p.name != "id")
9896            .map(|p| IrLinkProp { name: p.name.clone() })
9897            .collect())
9898    }
9899
9900    // ── Type-intersection helpers ─────────────────────────────────────────────────
9901
9902    /// Expand `[is ConcreteType].*` → scalar subqueries for each non-inherited property.
9903    fn compile_type_intersection_splat(
9904        &mut self,
9905        type_ref: &ast::ObjectRef,
9906        splat: &ast::Splat,
9907        parent_td: &TypeDescriptor,
9908        parent_alias: &str,
9909    ) -> Result<Vec<IrShapePointer>, PyQLError> {
9910        let type_name = match &type_ref.module {
9911            Some(m) => format!("{}::{}", m, type_ref.name),
9912            None => type_ref.name.clone(),
9913        };
9914        let concrete_td = self.resolve_type(&type_name)?;
9915        let concrete_qname = format!("{}::{}", concrete_td.module, concrete_td.name);
9916        let concrete_table = concrete_td.table.clone();
9917
9918        // Interface properties: those in the parent (interface) td
9919        let interface_props: std::collections::HashSet<String> =
9920            parent_td.properties.iter().map(|p| p.name.clone()).collect();
9921
9922        // Emit scalar subquery for each property not already in the interface
9923        let props: Vec<_> = concrete_td
9924            .properties
9925            .iter()
9926            .filter(|p| !interface_props.contains(&p.name))
9927            .cloned()
9928            .collect();
9929
9930        // Same for computed pointers not already defined on the interface —
9931        // `[is Concrete].*` must include the concrete type's own computed
9932        // properties (e.g. `full_name`), not just its stored properties.
9933        let interface_computed: std::collections::HashSet<String> =
9934            parent_td.computed.iter().map(|c| c.name.clone()).collect();
9935        let computed: Vec<_> = concrete_td
9936            .computed
9937            .iter()
9938            .filter(|c| !interface_computed.contains(&c.name))
9939            .filter(|c| !matches!(splat, ast::Splat::Shallow) || !self.computed_is_object_valued(c, concrete_td))
9940            .cloned()
9941            .collect();
9942
9943        // For deep splat, also include links
9944        let links: Vec<_> = if matches!(splat, ast::Splat::Deep) {
9945            concrete_td.links.to_vec()
9946        } else {
9947            vec![]
9948        };
9949
9950        let mut pointers = vec![];
9951        for prop in props {
9952            let sub_alias = self.fresh_alias();
9953            let filter = IrExpr::BinOp(Box::new(IrBinOp {
9954                left: IrExpr::ColumnRef {
9955                    alias: sub_alias.clone(),
9956                    column: "id".to_string(),
9957                    pg_type: "uuid".to_string(),
9958                },
9959                op: ast::BinOpKind::Eq,
9960                right: IrExpr::ColumnRef {
9961                    alias: parent_alias.to_string(),
9962                    column: "id".to_string(),
9963                    pg_type: "uuid".to_string(),
9964                },
9965            }));
9966            let subquery = IrSelect::schema_bound(
9967                IrSource {
9968                    poly: None,
9969                    type_name: concrete_qname.clone(),
9970                    table: concrete_table.clone(),
9971                    alias: sub_alias,
9972                },
9973                vec![IrShapePointer::Scalar(IrScalarPointer {
9974                    implicit_id: false,
9975                    marker_offset: None,
9976                    alias: prop.name.clone(),
9977                    column: prop.name.clone(),
9978                    pg_type: prop.pg_type.clone(),
9979                    tuple_shape: self.resolve_property_tuple_shape(&prop),
9980                })],
9981                Some(filter),
9982            );
9983            pointers.push(IrShapePointer::Computed(IrComputedPointer {
9984                marker_offset: None,
9985                alias: prop.name.clone(),
9986                expr: IrExpr::Subquery(Box::new(subquery)),
9987            }));
9988        }
9989
9990        for cd in computed {
9991            let sub_alias = self.fresh_alias();
9992            let filter = IrExpr::BinOp(Box::new(IrBinOp {
9993                left: IrExpr::ColumnRef {
9994                    alias: sub_alias.clone(),
9995                    column: "id".to_string(),
9996                    pg_type: "uuid".to_string(),
9997                },
9998                op: ast::BinOpKind::Eq,
9999                right: IrExpr::ColumnRef {
10000                    alias: parent_alias.to_string(),
10001                    column: "id".to_string(),
10002                    pg_type: "uuid".to_string(),
10003                },
10004            }));
10005            let expr_ast = crate::parse::parse_pointer_expr(&cd.expression).map_err(PyQLError::Syntax)?;
10006            let inner_ir = self.compile_expr(&expr_ast, concrete_td, &sub_alias)?;
10007            let subquery = IrSelect::schema_bound(
10008                IrSource {
10009                    poly: None,
10010                    type_name: concrete_qname.clone(),
10011                    table: concrete_table.clone(),
10012                    alias: sub_alias,
10013                },
10014                vec![IrShapePointer::Computed(IrComputedPointer {
10015                    marker_offset: None,
10016                    alias: cd.name.clone(),
10017                    expr: inner_ir,
10018                })],
10019                Some(filter),
10020            );
10021            pointers.push(IrShapePointer::Computed(IrComputedPointer {
10022                marker_offset: None,
10023                alias: cd.name.clone(),
10024                expr: IrExpr::Subquery(Box::new(subquery)),
10025            }));
10026        }
10027
10028        // For deep splat, include single-link pointers as subqueries
10029        for link in links {
10030            let target_td = self.resolve_type(&link.target)?;
10031            let sub_alias = self.fresh_alias();
10032            let filter = if link.is_junction_backed() {
10033                let (jt_table, jt_module, jt_src_col, jt_tgt_col, _) =
10034                    self.junction_info_for(concrete_td, &link.name, &link.target, &link.through)?;
10035                let jt_alias = self.fresh_alias();
10036                let jt_filter = IrExpr::BinOp(Box::new(IrBinOp {
10037                    left: IrExpr::BinOp(Box::new(IrBinOp {
10038                        left: IrExpr::ColumnRef {
10039                            alias: jt_alias.clone(),
10040                            column: jt_src_col,
10041                            pg_type: "uuid".to_string(),
10042                        },
10043                        op: ast::BinOpKind::Eq,
10044                        right: IrExpr::ColumnRef {
10045                            alias: parent_alias.to_string(),
10046                            column: "id".to_string(),
10047                            pg_type: "uuid".to_string(),
10048                        },
10049                    })),
10050                    op: ast::BinOpKind::And,
10051                    right: IrExpr::BinOp(Box::new(IrBinOp {
10052                        left: IrExpr::ColumnRef {
10053                            alias: jt_alias.clone(),
10054                            column: jt_tgt_col,
10055                            pg_type: "uuid".to_string(),
10056                        },
10057                        op: ast::BinOpKind::Eq,
10058                        right: IrExpr::ColumnRef {
10059                            alias: sub_alias.clone(),
10060                            column: "id".to_string(),
10061                            pg_type: "uuid".to_string(),
10062                        },
10063                    })),
10064                }));
10065                let exists_select = IrSelect::schema_bound(
10066                    IrSource {
10067                        poly: None,
10068                        type_name: format!("{}::__jt__", jt_module),
10069                        table: jt_table,
10070                        alias: jt_alias,
10071                    },
10072                    vec![],
10073                    Some(jt_filter),
10074                );
10075                IrExpr::UnaryOp(Box::new(IrUnaryOp {
10076                    op: ast::UnaryOpKind::Exists,
10077                    operand: IrExpr::Subquery(Box::new(exists_select)),
10078                }))
10079            } else {
10080                IrExpr::BinOp(Box::new(IrBinOp {
10081                    left: IrExpr::ColumnRef {
10082                        alias: sub_alias.clone(),
10083                        column: "id".to_string(),
10084                        pg_type: "uuid".to_string(),
10085                    },
10086                    op: ast::BinOpKind::Eq,
10087                    right: IrExpr::ColumnRef {
10088                        alias: parent_alias.to_string(),
10089                        column: format!("{}_id", link.name),
10090                        pg_type: "uuid".to_string(),
10091                    },
10092                }))
10093            };
10094            let sub_shape = Self::pk_returning(target_td);
10095            let subquery = IrSelect::schema_bound(
10096                IrSource {
10097                    poly: self.link_target_fanout(target_td),
10098                    type_name: format!("{}::{}", target_td.module, target_td.name),
10099                    table: target_td.table.clone(),
10100                    alias: sub_alias,
10101                },
10102                sub_shape,
10103                Some(filter),
10104            );
10105            pointers.push(IrShapePointer::Computed(IrComputedPointer {
10106                marker_offset: None,
10107                alias: link.name.clone(),
10108                expr: IrExpr::Subquery(Box::new(subquery)),
10109            }));
10110        }
10111
10112        Ok(pointers)
10113    }
10114
10115    /// Compile `[is ConcreteType].pointer_name` → `IrShapePointer`.
10116    fn compile_type_intersection_pointer(
10117        &mut self,
10118        type_ref: &ast::ObjectRef,
10119        tail_steps: &[ast::PathStep],
10120        parent_alias: &str,
10121        marker_offset: Option<usize>,
10122    ) -> Result<IrShapePointer, PyQLError> {
10123        let expr = self.compile_type_intersection_expr_steps(type_ref, tail_steps, parent_alias)?;
10124        // Alias is the last Name step
10125        let alias = match tail_steps.last() {
10126            Some(ast::PathStep::Name(n)) => n.clone(),
10127            _ => return Err(self.type_err("type intersection must end with a pointer name")),
10128        };
10129        Ok(IrShapePointer::Computed(IrComputedPointer {
10130            alias,
10131            expr,
10132            marker_offset,
10133        }))
10134    }
10135
10136    /// Compile `[is Type].name` as an `IrExpr` (for computed pointer / expression context).
10137    fn compile_type_intersection_expr(
10138        &mut self,
10139        steps: &[ast::PathStep],
10140        td: &TypeDescriptor,
10141        parent_alias: &str,
10142    ) -> Result<IrExpr, PyQLError> {
10143        use ast::PathStep;
10144        let type_ref = match steps.first() {
10145            Some(PathStep::TypeIntersection(tr)) => tr.clone(),
10146            _ => return Err(self.type_err("expected type intersection")),
10147        };
10148        // The fast path reads one stored column off the narrowed type.
10149        // Anything else — a computed, a link, further traversal — falls
10150        // through to the general builder, rooted at the narrowed type
10151        // (whose rows share the interface row's id).
10152        match self.compile_type_intersection_expr_steps(&type_ref, &steps[1..], parent_alias) {
10153            Ok(ir) => Ok(ir),
10154            Err(fast_path_err) => {
10155                let p = ast::Path {
10156                    steps: steps.to_vec(),
10157                    partial: true,
10158                };
10159                self.compile_partial_path_as_subquery(&p, td, parent_alias)
10160                    .map_err(|_| fast_path_err)
10161            }
10162        }
10163    }
10164
10165    /// Shared: build scalar subquery for `[is ConcreteType]` + tail pointer steps.
10166    fn compile_type_intersection_expr_steps(
10167        &mut self,
10168        type_ref: &ast::ObjectRef,
10169        tail_steps: &[ast::PathStep],
10170        parent_alias: &str,
10171    ) -> Result<IrExpr, PyQLError> {
10172        use ast::PathStep;
10173        let type_name = match &type_ref.module {
10174            Some(m) => format!("{}::{}", m, type_ref.name),
10175            None => type_ref.name.clone(),
10176        };
10177        let concrete_td = self.resolve_type(&type_name)?;
10178        let concrete_qname = format!("{}::{}", concrete_td.module, concrete_td.name);
10179        let concrete_table = concrete_td.table.clone();
10180
10181        let pointer_name = match tail_steps.first() {
10182            Some(PathStep::Name(n)) => n.as_str(),
10183            _ => return Err(self.type_err("type intersection must be followed by a pointer name, e.g. [is Type].name")),
10184        };
10185
10186        let prop = concrete_td
10187            .properties
10188            .iter()
10189            .find(|p| p.name == pointer_name)
10190            .ok_or_else(|| self.field_err(pointer_name, &concrete_qname))?;
10191        let prop_name = prop.name.clone();
10192        let prop_type = prop.pg_type.clone();
10193
10194        let sub_alias = self.fresh_alias();
10195        let filter = IrExpr::BinOp(Box::new(IrBinOp {
10196            left: IrExpr::ColumnRef {
10197                alias: sub_alias.clone(),
10198                column: "id".to_string(),
10199                pg_type: "uuid".to_string(),
10200            },
10201            op: ast::BinOpKind::Eq,
10202            right: IrExpr::ColumnRef {
10203                alias: parent_alias.to_string(),
10204                column: "id".to_string(),
10205                pg_type: "uuid".to_string(),
10206            },
10207        }));
10208
10209        let poly = self.poly_fanout_for(&concrete_qname);
10210        Ok(IrExpr::Subquery(Box::new(IrSelect::schema_bound(
10211            IrSource {
10212                poly,
10213                type_name: concrete_qname,
10214                table: concrete_table,
10215                alias: sub_alias,
10216            },
10217            vec![IrShapePointer::Scalar(IrScalarPointer {
10218                implicit_id: false,
10219                marker_offset: None,
10220                alias: prop_name.clone(),
10221                column: prop_name,
10222                pg_type: prop_type,
10223                tuple_shape: self.resolve_property_tuple_shape(prop),
10224            })],
10225            Some(filter),
10226        ))))
10227    }
10228
10229    fn compile_shape_element(
10230        &mut self,
10231        el: &ShapeElement,
10232        td: &TypeDescriptor,
10233        alias: &str,
10234        module: &str,
10235    ) -> Result<IrShapePointer, PyQLError> {
10236        // Type intersection pointer: [is Type].pointer_name (without compexpr)
10237        if let Some(ast::PathStep::TypeIntersection(type_ref)) = el.path.steps.first()
10238            && el.compexpr.is_none()
10239            && el.path.steps.len() >= 2
10240        {
10241            // `[is T].configs: { … }` — a shape says the pointer is read as
10242            // objects, which the scalar route below cannot give: it resolves
10243            // a stored property and nothing else.
10244            if let Some(nested) = el.nested.as_deref()
10245                && !nested.is_empty()
10246                && let Some(ast::PathStep::Name(leaf)) = el.path.steps.last()
10247                && let Some(ptr) = self.try_compile_pointer_expr(
10248                    &leaf.clone(),
10249                    &Expr::Path(el.path.clone()),
10250                    td,
10251                    alias,
10252                    module,
10253                    el.marker_offset,
10254                    nested,
10255                )?
10256            {
10257                return Ok(ptr);
10258            }
10259            let type_ref = type_ref.clone();
10260            return self.compile_type_intersection_pointer(&type_ref, &el.path.steps[1..], alias, el.marker_offset);
10261        }
10262
10263        let pointer_name = path_leaf(&el.path)?;
10264
10265        // __type__ is a virtual property: the fully-qualified type name as a string.
10266        // It's always injected at position 0 for internal use; explicit inclusion adds
10267        // it as a regular computed pointer at a later position so Python can read it.
10268        if pointer_name == "__type__" && el.compexpr.is_none() {
10269            let expr = if self.is_polymorphic(td) {
10270                IrExpr::ColumnRef {
10271                    alias: alias.to_string(),
10272                    column: "__type__".to_string(),
10273                    pg_type: "text".to_string(),
10274                }
10275            } else {
10276                IrExpr::Literal(IrLiteral::Str(format!("{}::{}", td.module, td.name)))
10277            };
10278            return Ok(IrShapePointer::Computed(IrComputedPointer {
10279                marker_offset: el.marker_offset,
10280                alias: "__type__".to_string(),
10281                expr,
10282            }));
10283        }
10284
10285        // `p := assert_exists(.prices { … })` — the assert is a check on the
10286        // pointer's own set, not part of a value expression, so the pointer is
10287        // compiled from the argument and the check reads the rows it
10288        // aggregates. Taken as an expression instead, an object-valued
10289        // argument is "part of a larger expression", which is exactly what an
10290        // object-returning function refuses.
10291        if let Some(compexpr) = &el.compexpr
10292            && let Some((call, value_expr, check_expr)) =
10293                Self::asserted_pointer_expr(compexpr, el.nested.as_deref().unwrap_or(&[]))
10294        {
10295            let element_for = |expr: Expr| ShapeElement {
10296                compexpr: Some(expr),
10297                nested: None,
10298                ..el.clone()
10299            };
10300            // Compiled speculatively: a scalar argument's assert is an ordinary
10301            // function call and belongs on the expression path below, and an
10302            // argument that does not compile at all should report its own error
10303            // from there rather than this one.
10304            if let Ok(ptr) = self.compile_shape_element(&element_for(value_expr), td, alias, module)
10305                && ptr.is_object_pointer()
10306            {
10307                let check = match check_expr {
10308                    Some(expr) => Some(self.compile_shape_element(&element_for(expr), td, alias, module)?),
10309                    None => None,
10310                };
10311                let message = self.assert_message(&call, Some((td, alias)))?;
10312                return Ok(IrShapePointer::Asserted(Box::new(IrAssertedPointer {
10313                    fn_name: call.name,
10314                    inner: ptr,
10315                    check,
10316                    message,
10317                })));
10318            }
10319        }
10320
10321        // `t := (select .teams limit 1) if cond else {}` — one branch names
10322        // objects and the other nothing, so the pointer is that branch with
10323        // the condition folded into its own filter.
10324        if let Some(compexpr) = &el.compexpr
10325            && let Some(guarded) = guarded_object_branch(compexpr)
10326        {
10327            let element = ShapeElement {
10328                compexpr: Some(guarded),
10329                ..el.clone()
10330            };
10331            return self.compile_shape_element(&element, td, alias, module);
10332        }
10333
10334        // Computed override: `pointer := expr`
10335        if let Some(compexpr) = &el.compexpr {
10336            // A link-valued RHS (`.multilink`, `.<backlink[is T] { … }`,
10337            // `(select .link filter … limit 1)`) is a pointer in its own
10338            // right, not an expression — see `try_compile_pointer_expr`.
10339            // `(select .resource { rev := … }) { changed_at := .rev.created_at }`
10340            // — a shape written at the point of use replaces the one the
10341            // subject carries, so whatever that one declared has to stay in
10342            // scope or the replacement cannot read it.
10343            let inner_declared: Vec<ShapeElement> = Self::replaced_subject_shape(compexpr)
10344                .iter()
10345                .filter(|e| e.compexpr.is_some())
10346                .cloned()
10347                .collect();
10348            let restore = (!inner_declared.is_empty()).then(|| {
10349                let mut scope = self.active_declared_pointers.clone();
10350                scope.extend(inner_declared);
10351                std::mem::replace(&mut self.active_declared_pointers, scope)
10352            });
10353            let pointer = self.try_compile_pointer_expr(
10354                pointer_name,
10355                compexpr,
10356                td,
10357                alias,
10358                module,
10359                el.marker_offset,
10360                el.nested.as_deref().unwrap_or(&[]),
10361            );
10362            if let Some(previous) = restore {
10363                self.active_declared_pointers = previous;
10364            }
10365            if let Some(ptr) = pointer? {
10366                return Ok(ptr);
10367            }
10368            // `p := marketplace::retrieve_listing_prices(.id) { amount }` — an
10369            // object-returning call written inline. A declared computed with
10370            // the same body already takes this route; a shape written on it
10371            // belongs to the rows it yields.
10372            if let Some((fc, call_nested)) = Self::object_call_with_shape(compexpr, el.nested.as_deref())
10373                && let Some(fs) = self.compile_fn_object_source(fc, &call_nested)?
10374            {
10375                return Ok(IrShapePointer::Computed(IrComputedPointer {
10376                    marker_offset: el.marker_offset,
10377                    alias: pointer_name.to_string(),
10378                    expr: IrExpr::ArrayFromSelect(Box::new(IrArraySource::ObjectFunction(Box::new(fs)))),
10379                }));
10380            }
10381            // A `T is U` naming a type other than this scope's answers once
10382            // per row of `T`; a shape keeps all of them, so ask for the set
10383            // form rather than the single value a boolean position takes.
10384            let ir = match compexpr {
10385                Expr::TypeIs { expr, ty } => self.compile_type_is_set(expr, ty, Some((td, alias)))?,
10386                _ => self.in_value_position(|c| c.compile_expr(compexpr, td, alias))?,
10387            };
10388            // Cross-scope TypeIs: promote to set-valued shape pointer.
10389            if let IrExpr::ArrayFromSelect(src) = ir {
10390                if let IrArraySource::RawExpr {
10391                    source,
10392                    poly_implementors,
10393                    poly_columns,
10394                    expr,
10395                } = *src
10396                {
10397                    return Ok(IrShapePointer::ScalarSet(IrScalarSetPointer {
10398                        alias: pointer_name.to_string(),
10399                        source,
10400                        poly_implementors,
10401                        poly_columns,
10402                        bool_expr: expr,
10403                    }));
10404                }
10405                return Ok(IrShapePointer::Computed(IrComputedPointer {
10406                    marker_offset: el.marker_offset,
10407                    alias: pointer_name.to_string(),
10408                    expr: IrExpr::ArrayFromSelect(src),
10409                }));
10410            }
10411            return Ok(IrShapePointer::Computed(IrComputedPointer {
10412                marker_offset: el.marker_offset,
10413                alias: pointer_name.to_string(),
10414                expr: ir,
10415            }));
10416        }
10417
10418        // Scalar property
10419        if let Some(p) = Self::resolve_property(td, pointer_name) {
10420            return Ok(IrShapePointer::Scalar(IrScalarPointer {
10421                implicit_id: false,
10422                marker_offset: el.marker_offset,
10423                alias: pointer_name.to_string(),
10424                column: p.name.clone(),
10425                pg_type: p.pg_type.clone(),
10426                tuple_shape: self.resolve_property_tuple_shape(p),
10427            }));
10428        }
10429
10430        // Single link
10431        if let Some(l) = Self::resolve_link(td, pointer_name) {
10432            let target_td = self.resolve_type(&l.target)?;
10433            let sub_alias = self.fresh_alias();
10434            let nested_elements = el.nested.as_deref().unwrap_or(&[]);
10435
10436            // A junction-backed link can carry `@prop` read references
10437            // (e.g. `spouse: { name, @since }`), the same as a multi-link's
10438            // own nested shape (`compile_multilink_pointer`) — partition
10439            // those out before compiling the rest as a regular shape.
10440            let (regular_els, link_properties): (Vec<ShapeElement>, Vec<IrLinkProp>) = if l.is_junction_backed() {
10441                let mut regular = Vec::new();
10442                let mut props = Vec::new();
10443                for nel in nested_elements {
10444                    if let [ast::PathStep::LinkProp(name)] = nel.path.steps.as_slice() {
10445                        props.push(IrLinkProp { name: name.clone() });
10446                    } else {
10447                        regular.push(nel.clone());
10448                    }
10449                }
10450                (regular, props)
10451            } else {
10452                (nested_elements.to_vec(), vec![])
10453            };
10454
10455            let sub_shape = self.compile_shape(&regular_els, target_td, &sub_alias, &target_td.module.clone())?;
10456            let subquery = self.link_target_select(
10457                target_td,
10458                IrSource {
10459                    poly: None,
10460                    type_name: format!("{}::{}", target_td.module, target_td.name),
10461                    table: target_td.table.clone(),
10462                    alias: sub_alias,
10463                },
10464                sub_shape,
10465            );
10466            let correlation = if l.is_junction_backed() {
10467                let join = self.build_multilink_join(td, &l.name, &l.target, &l.through)?;
10468                IrSingleLinkCorrelation::Junction {
10469                    join,
10470                    target_pk: "id".to_string(),
10471                }
10472            } else {
10473                IrSingleLinkCorrelation::Fk {
10474                    fk_column: format!("{}_id", l.name),
10475                    target_pk: "id".to_string(),
10476                }
10477            };
10478            return Ok(IrShapePointer::SingleLink(IrSingleLinkPointer {
10479                marker_offset: el.marker_offset,
10480                alias: pointer_name.to_string(),
10481                correlation,
10482                subquery,
10483                link_properties,
10484            }));
10485        }
10486
10487        // Multi-link
10488        if Self::resolve_multilink(td, pointer_name).is_some() {
10489            return self.compile_multilink_pointer(pointer_name, pointer_name, td, alias, module, el);
10490        }
10491
10492        // Schema-defined computed pointer
10493        if let Some(cd) = self.resolve_computed(td, pointer_name) {
10494            return self.compile_declared_computed(
10495                &cd,
10496                td,
10497                alias,
10498                module,
10499                el.marker_offset,
10500                el.nested.as_deref().unwrap_or(&[]),
10501            );
10502        }
10503
10504        // Declared by the binding this shape is read off, rather than by the
10505        // type — compiled against the binding's own row, as it was written.
10506        if let Some(declared) = self
10507            .active_declared_pointers
10508            .iter()
10509            .find(|d| path_leaf(&d.path).is_ok_and(|n| n == pointer_name))
10510            .cloned()
10511            && let Some(expr) = declared.compexpr.clone()
10512        {
10513            let nested = if el.nested.as_deref().unwrap_or(&[]).is_empty() {
10514                declared.nested.clone().unwrap_or_default()
10515            } else {
10516                el.nested.clone().unwrap_or_default()
10517            };
10518            return self.compile_computed_expr(pointer_name, &expr, td, alias, module, el.marker_offset, &nested, None);
10519        }
10520
10521        Err(self.field_err(pointer_name, &format!("{}::{}", td.module, td.name)))
10522    }
10523
10524    /// A computed pointer whose path walks *through* a multi-valued step before
10525    /// landing on objects — `members := .memberships.member`.
10526    ///
10527    /// Rooted at the enclosing type and correlated back to the enclosing row,
10528    /// exactly as `compile_partial_path_as_subquery` does for a value, then
10529    /// aggregated so the rows survive as rows.
10530    #[allow(clippy::too_many_arguments)]
10531    fn compile_chained_link_pointer(
10532        &mut self,
10533        pointer_name: &str,
10534        path: &ast::Path,
10535        td: &TypeDescriptor,
10536        alias: &str,
10537        nested: &[ShapeElement],
10538        modifiers: Option<&ast::SelectStmt>,
10539        multi: bool,
10540        tail: usize,
10541    ) -> Result<IrShapePointer, PyQLError> {
10542        let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
10543        steps.extend(path.steps.iter().cloned());
10544        let full_path = ast::Path { steps, partial: false };
10545        let synthetic = ast::SelectStmt {
10546            result: Expr::Path(full_path.clone()),
10547            filter: modifiers.and_then(|m| m.filter.clone()),
10548            order_by: modifiers.map(|m| m.order_by.clone()).unwrap_or_default(),
10549            offset: modifiers.and_then(|m| m.offset.clone()),
10550            limit: modifiers.and_then(|m| m.limit.clone()),
10551            lock: None,
10552        };
10553        let mut path_select = self.compile_path_select_with_tail(&synthetic, &full_path, nested, false, tail, &[])?;
10554        Self::correlate_path_select(&mut path_select, alias);
10555        // A walk that crosses a multi step stands for a set, so it is
10556        // aggregated; one that cannot is a single object and is read as one,
10557        // rather than an array of length one.
10558        let expr = if multi {
10559            IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(path_select))))
10560        } else {
10561            IrExpr::ObjectPathSubquery(Box::new(path_select))
10562        };
10563        Ok(IrShapePointer::Computed(IrComputedPointer {
10564            marker_offset: None,
10565            alias: pointer_name.to_string(),
10566            expr,
10567        }))
10568    }
10569
10570    fn compile_multilink_pointer(
10571        &mut self,
10572        output_alias: &str,
10573        ml_name: &str,
10574        td: &TypeDescriptor,
10575        _parent_alias: &str,
10576        module: &str,
10577        el: &ShapeElement,
10578    ) -> Result<IrShapePointer, PyQLError> {
10579        let ml = Self::resolve_multilink(td, ml_name)
10580            .expect("caller verified multilink exists")
10581            .clone();
10582        let target_td = self.resolve_type(&ml.target)?;
10583        let sub_alias = self.fresh_alias();
10584        let nested_elements = el.nested.as_deref().unwrap_or(&[]);
10585
10586        // Partition @prop link-property elements from regular shape elements.
10587        let mut link_properties: Vec<IrLinkProp> = Vec::new();
10588        let mut regular_els: Vec<ShapeElement> = Vec::new();
10589        for nel in nested_elements {
10590            if let [ast::PathStep::LinkProp(name)] = nel.path.steps.as_slice() {
10591                link_properties.push(IrLinkProp { name: name.clone() });
10592            } else {
10593                regular_els.push(nel.clone());
10594            }
10595        }
10596        let has_splat = nested_elements.iter().any(|nel| {
10597            nel.splat.is_some() && !matches!(nel.path.steps.first(), Some(ast::PathStep::TypeIntersection(_)))
10598        });
10599        if has_splat {
10600            for prop in self.splat_link_properties(&ml)? {
10601                if !link_properties.iter().any(|existing| existing.name == prop.name) {
10602                    link_properties.push(prop);
10603                }
10604            }
10605        }
10606
10607        let sub_shape = self.compile_shape(&regular_els, target_td, &sub_alias, &target_td.module.clone())?;
10608
10609        let join = if let Some(through_qname) = &ml.through {
10610            let through_td = self.resolve_type(through_qname)?;
10611            if through_td.junction {
10612                // Junction type: columns are always named `source` and
10613                // `target`. See `junction_info_for`'s doc comment: the
10614                // physical table is owner-derived, never `through_td.table`.
10615                IrMultiLinkJoin::Standard {
10616                    junction_table: self.owner_junction(td, ml_name, Some(through_td)),
10617                    module: td.module.clone(),
10618                }
10619            } else {
10620                let source_qname = format!("{}::{}", td.module, td.name);
10621                let source_col = through_td
10622                    .links
10623                    .iter()
10624                    .find(|l| l.target == source_qname)
10625                    .ok_or_else(|| {
10626                        PyQLError::Type(PyQLTypeError {
10627                            message: format!("through type {through_qname} has no link to source type {source_qname}"),
10628                            position: Position { line: 0, col: 0 },
10629                        })
10630                    })?
10631                    .name
10632                    .clone();
10633                let target_col = through_td
10634                    .links
10635                    .iter()
10636                    .find(|l| l.target == ml.target && l.name != source_col)
10637                    .or_else(|| through_td.links.iter().find(|l| l.target == ml.target))
10638                    .ok_or_else(|| {
10639                        PyQLError::Type(PyQLTypeError {
10640                            message: format!("through type {through_qname} has no link to target type {}", ml.target),
10641                            position: Position { line: 0, col: 0 },
10642                        })
10643                    })?
10644                    .name
10645                    .clone();
10646                IrMultiLinkJoin::Through {
10647                    junction_table: through_td.table.clone(),
10648                    module: through_td.module.clone(),
10649                    source_col,
10650                    target_col,
10651                }
10652            }
10653        } else {
10654            IrMultiLinkJoin::Standard {
10655                junction_table: self.owner_junction(td, ml_name, None),
10656                module: module.to_string(),
10657            }
10658        };
10659
10660        // `@prop` in this link's own modifiers reads the junction row, so
10661        // the through type has to be in scope while they compile.
10662        self.link_prop_scope
10663            .push(ml.through.clone().map(|t| (t, "jt".to_string())));
10664        let modifiers = (|c: &mut Self| -> Result<SelectModifiers, PyQLError> {
10665            Ok((
10666                el.filter
10667                    .as_ref()
10668                    .map(|f| c.compile_expr(f, target_td, &sub_alias))
10669                    .transpose()?,
10670                el.order_by
10671                    .iter()
10672                    .map(|s| c.compile_sort(s, target_td, &sub_alias))
10673                    .collect::<Result<_, _>>()?,
10674                el.offset
10675                    .as_ref()
10676                    .map(|e| c.compile_expr(e, target_td, &sub_alias))
10677                    .transpose()?,
10678                el.limit
10679                    .as_ref()
10680                    .map(|e| c.compile_expr(e, target_td, &sub_alias))
10681                    .transpose()?,
10682            ))
10683        })(self);
10684        self.link_prop_scope.pop();
10685        let (filter, order_by, offset, limit) = modifiers?;
10686        let subquery = IrSelect {
10687            rows: vec![IrRowSource::Bound {
10688                source: IrSource {
10689                    poly: self.link_target_fanout(target_td),
10690                    type_name: format!("{}::{}", target_td.module, target_td.name),
10691                    table: target_td.table.clone(),
10692                    alias: sub_alias.clone(),
10693                },
10694                shape: sub_shape,
10695            }],
10696            filter,
10697            order_by,
10698            offset,
10699            limit,
10700            distinct: false,
10701            dml_source: None,
10702            polymorphic: false,
10703            poly_implementors: vec![],
10704            poly_columns: vec![],
10705            lock: None,
10706        };
10707
10708        Ok(IrShapePointer::MultiLink(IrMultiLinkPointer {
10709            marker_offset: el.marker_offset,
10710            alias: output_alias.to_string(),
10711            join,
10712            subquery,
10713            link_properties,
10714            single: false,
10715        }))
10716    }
10717
10718    /// Compile `pointer := .<backlink_name[is OwnerType] { shape }` (a
10719    /// backlink used as a computed pointer inside another type's shape —
10720    /// the missing piece that made backlinks unusable for anything beyond
10721    /// `filter exists .<...>`). Mirrors `compile_multilink_pointer`'s
10722    /// shape/subquery construction, but the owner type's rows are
10723    /// correlated in reverse: via their own FK column for a single-link
10724    /// backlink source (`IrMultiLinkJoin::BacklinkFk`), or via the same
10725    /// junction table a forward multi-link would use with the owner/current
10726    /// column roles swapped (`IrMultiLinkJoin::BacklinkJunction`).
10727    fn compile_backlink_pointer(
10728        &mut self,
10729        output_alias: &str,
10730        path: &ast::Path,
10731        current_qname: &str,
10732        nested_elements: &[ShapeElement],
10733        marker_offset: Option<usize>,
10734        modifiers: Option<&ast::SelectStmt>,
10735    ) -> Result<IrShapePointer, PyQLError> {
10736        use ast::PathStep;
10737
10738        let backlink_name = match path.steps.first() {
10739            Some(PathStep::Backlink(n)) => n.clone(),
10740            _ => return Err(self.type_err("internal: expected backlink step")),
10741        };
10742        let type_ref = match path.steps.get(1) {
10743            Some(PathStep::TypeIntersection(tr)) => tr,
10744            _ => {
10745                return Err(PyQLError::Type(PyQLTypeError {
10746                    message: format!(
10747                        "backlink '.< {backlink_name}' requires a type intersection, \
10748                     e.g.: .< {backlink_name}[is SomeType]"
10749                    ),
10750                    position: Position { line: 0, col: 0 },
10751                }));
10752            }
10753        };
10754        if path.steps.len() > 2 {
10755            return Err(self.type_err(
10756                "further path traversal after a backlink shape is not yet supported \
10757                 (e.g. '.<link[is Type].property') — attach a nested shape instead: \
10758                 '.<link[is Type] { property }'",
10759            ));
10760        }
10761
10762        let type_name = match &type_ref.module {
10763            Some(m) => format!("{}::{}", m, type_ref.name),
10764            None => type_ref.name.clone(),
10765        };
10766        let owner_td = self.backlink_owner(self.resolve_type(&type_name)?, &backlink_name, current_qname)?;
10767        let owner_qname = format!("{}::{}", owner_td.module, owner_td.name);
10768
10769        let join = if let Some(l) = owner_td
10770            .links
10771            .iter()
10772            .find(|l| l.name == backlink_name && self.link_target_reaches(&l.target, current_qname))
10773        {
10774            if l.is_junction_backed() {
10775                let (junction_table, module, owner_col, current_col, _) = self.link_junction_info(owner_td, l)?;
10776                IrMultiLinkJoin::BacklinkJunction {
10777                    junction_table,
10778                    module,
10779                    owner_col,
10780                    current_col,
10781                }
10782            } else {
10783                IrMultiLinkJoin::BacklinkFk {
10784                    fk_col: format!("{}_id", backlink_name),
10785                }
10786            }
10787        } else if let Some(ml) = owner_td
10788            .multilinks
10789            .iter()
10790            .find(|ml| ml.name == backlink_name && self.link_target_reaches(&ml.target, current_qname))
10791            .cloned()
10792        {
10793            let (junction_table, module, _, _, _) = self.multilink_junction_info(owner_td, &ml)?;
10794            IrMultiLinkJoin::BacklinkJunction {
10795                junction_table,
10796                module,
10797                owner_col: "source".to_string(),
10798                current_col: "target".to_string(),
10799            }
10800        } else {
10801            return Err(PyQLError::Type(PyQLTypeError {
10802                message: format!(
10803                    "type {} has no link or multi-link '{}' pointing to {}",
10804                    owner_qname, backlink_name, current_qname,
10805                ),
10806                position: Position { line: 0, col: 0 },
10807            }));
10808        };
10809
10810        let sub_alias = self.fresh_alias();
10811        let sub_shape = self.compile_shape(nested_elements, owner_td, &sub_alias, &owner_td.module.clone())?;
10812
10813        // The `pointer := expr` grammar (`parse_shape_element`'s `:=`
10814        // branch) never parses trailing FILTER/ORDER BY/OFFSET/LIMIT after
10815        // the RHS expression — those per-link modifiers only exist on the
10816        // separate no-`:=` "bare inclusion with nested shape" parse path
10817        // that `compile_multilink_pointer`'s other call site reads
10818        // `el.filter` etc. from. Writing the RHS as a sub-select
10819        // (`pointer := (select .<link[is T] { … } filter … limit 1)`) is the
10820        // way to get them here, and `modifiers` carries that inner select.
10821        let (filter, order_by, offset, limit) = match modifiers {
10822            Some(sel) => self.compile_path_modifiers(sel, owner_td, &sub_alias)?,
10823            None => (None, vec![], None, None),
10824        };
10825        let subquery = IrSelect {
10826            rows: vec![IrRowSource::Bound {
10827                source: IrSource {
10828                    poly: self.poly_fanout_for(&owner_qname),
10829                    type_name: owner_qname,
10830                    table: owner_td.table.clone(),
10831                    alias: sub_alias.clone(),
10832                },
10833                shape: sub_shape,
10834            }],
10835            filter,
10836            order_by,
10837            offset,
10838            limit,
10839            distinct: false,
10840            dml_source: None,
10841            polymorphic: false,
10842            poly_implementors: vec![],
10843            poly_columns: vec![],
10844            lock: None,
10845        };
10846
10847        Ok(IrShapePointer::MultiLink(IrMultiLinkPointer {
10848            alias: output_alias.to_string(),
10849            join,
10850            subquery,
10851            link_properties: vec![],
10852            marker_offset,
10853            single: self.backlink_is_single(owner_td, &backlink_name, current_qname) || limits_to_one(modifiers),
10854        }))
10855    }
10856
10857    /// Compile a pointer the *schema* declares as computed (as opposed to one
10858    /// written inline in the query). A declared computed whose expression is
10859    /// a sub-select over a link — `(select .emails filter .primary limit 1)`
10860    /// — is an object pointer, exactly as if it had been written inline, so
10861    /// it takes the same route; everything else is a scalar expression.
10862    /// An object-returning function call as a row source, with the shape the
10863    /// pointer asked for (its target's own pk when none was written).
10864    fn compile_fn_object_source(
10865        &mut self,
10866        fc: &ast::FunctionCall,
10867        nested: &[ShapeElement],
10868    ) -> Result<Option<IrFunctionSelect>, PyQLError> {
10869        let Some(fd) = self.schema.functions.iter().find(|f| {
10870            let module_matches = fc.module.as_deref().map(|m| m == f.module.as_str()).unwrap_or(true);
10871            module_matches && f.name == fc.name && f.return_is_object
10872        }) else {
10873            return Ok(None);
10874        };
10875        let (fn_module, fn_name, return_type_name) = (fd.module.clone(), fd.name.clone(), fd.return_pg_type.clone());
10876        let return_td = self.resolve_type(&return_type_name)?;
10877        let alias = self.fresh_alias();
10878        let shape = if nested.is_empty() {
10879            Self::pk_returning(return_td)
10880        } else {
10881            self.compile_shape(nested, return_td, &alias, &return_td.module)?
10882        };
10883        let mut args = fc
10884            .args
10885            .iter()
10886            .map(|a| self.compile_free_expr(a))
10887            .collect::<Result<Vec<_>, _>>()?;
10888        // A function reading a session global takes them as its first
10889        // argument — the same forwarding a select over the call gets.
10890        if let Some(globals) = self.globals_arg_for_call(&format!("{fn_module}::{fn_name}"))? {
10891            args.insert(0, globals);
10892        }
10893        Ok(Some(IrFunctionSelect {
10894            fn_module,
10895            fn_name,
10896            fn_args: args,
10897            alias,
10898            type_name: format!("{}::{}", return_td.module, return_td.name),
10899            polymorphic: false,
10900            poly_implementors: vec![],
10901            poly_columns: vec![],
10902            shape,
10903            filter: None,
10904            order_by: vec![],
10905            offset: None,
10906            limit: None,
10907            distinct: false,
10908        }))
10909    }
10910
10911    /// `assert_exists(x)`, `assert_exists(x { … })`, `assert_distinct(x) { … }`
10912    /// — the assert, the set it checks, and the shape to read that set with.
10913    ///
10914    /// Only the one-argument forms, and not `assert_single`: the message
10915    /// overload takes a second argument the wrapper would have to carry, and
10916    /// `assert_single` returns the element rather than the array, so it does
10917    /// not fit the check `IrShapePointer::Asserted` emits.
10918    fn asserted_pointer_expr(expr: &Expr, nested: &[ShapeElement]) -> Option<(ast::FunctionCall, Expr, Option<Expr>)> {
10919        // `(select assert_distinct(X) { … } limit 1)` — the assert is the
10920        // select's subject, so it sees the whole set, while the modifiers
10921        // narrow only what is read back. The two coincide unless a limit or
10922        // offset is written, and then the check needs its own copy without
10923        // them, or it would be asserting over the one row that survives.
10924        // `(select assert_exists(X)) { … }` — the shape after the select is
10925        // the one its subject passes through.
10926        if let Expr::Shape(sh) = expr
10927            && let Some(subquery @ Expr::SubQuery(_)) = sh.expr.as_ref()
10928        {
10929            return Self::asserted_pointer_expr(subquery, &sh.elements);
10930        }
10931        if let Expr::SubQuery(stmt) = expr
10932            && let Stmt::Select(sel) = stmt.as_ref()
10933        {
10934            let (call, inner, _) = Self::asserted_pointer_expr(&sel.result, nested)?;
10935            let rebuild = |offset: Option<Expr>, limit: Option<Expr>| {
10936                Expr::SubQuery(Box::new(Stmt::Select(ast::SelectStmt {
10937                    result: inner.clone(),
10938                    filter: sel.filter.clone(),
10939                    order_by: sel.order_by.clone(),
10940                    offset,
10941                    limit,
10942                    lock: sel.lock.clone(),
10943                })))
10944            };
10945            let value = rebuild(sel.offset.clone(), sel.limit.clone());
10946            let check = (sel.offset.is_some() || sel.limit.is_some()).then(|| rebuild(None, None));
10947            return Some((call, value, check));
10948        }
10949        // A shape written after the call (`assert_distinct(f(.id)) { amount }`)
10950        // belongs to the set the assert passes through, so it becomes the
10951        // inner pointer's shape.
10952        let (call, nested) = match expr {
10953            Expr::Shape(sh) => match sh.expr.as_ref() {
10954                Some(Expr::FunctionCall(f)) => (f, sh.elements.clone()),
10955                _ => return None,
10956            },
10957            Expr::FunctionCall(f) => (f, nested.to_vec()),
10958            _ => return None,
10959        };
10960        if call.module.is_some() && call.module.as_deref() != Some("std") {
10961            return None;
10962        }
10963        if !matches!(call.name.as_str(), "assert_exists" | "assert_distinct") {
10964            return None;
10965        }
10966        let [arg] = call.args.as_slice() else {
10967            return None;
10968        };
10969        // A shape written after the call belongs to the set the assert passes
10970        // through, so it is folded onto the argument rather than left as the
10971        // element's nested shape: a union only reads as objects when the shape
10972        // sits directly on it.
10973        let inner = match (&arg, nested.is_empty()) {
10974            (Expr::Shape(_), _) | (_, true) => arg.clone(),
10975            _ => Expr::Shape(Box::new(ast::ShapeExpr {
10976                expr: Some(arg.clone()),
10977                elements: nested.clone(),
10978                marker_offset: None,
10979            })),
10980        };
10981        Some((call.clone(), inner, None))
10982    }
10983
10984    /// A function call written as a pointer's value, with the shape its rows
10985    /// are read through — whether that shape sits after the call
10986    /// (`f(.id) { amount }`) or was parsed as the element's nested shape.
10987    fn object_call_with_shape<'e>(
10988        expr: &'e Expr,
10989        nested: Option<&[ShapeElement]>,
10990    ) -> Option<(&'e ast::FunctionCall, Vec<ShapeElement>)> {
10991        match expr {
10992            Expr::Shape(sh) => match sh.expr.as_ref() {
10993                Some(Expr::FunctionCall(f)) => Some((f, sh.elements.clone())),
10994                _ => None,
10995            },
10996            Expr::FunctionCall(f) => Some((f, nested.unwrap_or(&[]).to_vec())),
10997            _ => None,
10998        }
10999    }
11000
11001    fn compile_declared_computed(
11002        &mut self,
11003        cd: &crate::schema::ComputedDescriptor,
11004        td: &TypeDescriptor,
11005        alias: &str,
11006        module: &str,
11007        marker_offset: Option<usize>,
11008        nested: &[ShapeElement],
11009    ) -> Result<IrShapePointer, PyQLError> {
11010        let expr_ast = crate::parse::parse_pointer_expr(&cd.expression).map_err(PyQLError::Syntax)?;
11011        let declared_on = self.computed_declared_on(td, &cd.name);
11012        self.compile_computed_expr(
11013            &cd.name,
11014            &expr_ast,
11015            td,
11016            alias,
11017            module,
11018            marker_offset,
11019            nested,
11020            declared_on,
11021        )
11022    }
11023
11024    /// The ancestor `td` inherited computed `name` from, nearest first, or
11025    /// `None` when `td` declares it itself. See `SelectAnchor::declared_on`.
11026    fn computed_declared_on(&self, td: &TypeDescriptor, name: &str) -> Option<(String, String)> {
11027        td.bases
11028            .iter()
11029            .chain(td.parents.iter())
11030            .chain(td.interfaces.iter())
11031            .filter_map(|qualified| self.resolve_type(qualified).ok())
11032            .find(|ancestor| ancestor.computed.iter().any(|c| c.name == name))
11033            .map(|ancestor| (ancestor.name.clone(), format!("{}::{}", ancestor.module, ancestor.name)))
11034    }
11035
11036    /// The body of `compile_declared_computed`, for a pointer whose expression
11037    /// is already parsed — a `with` binding's own shape declares one that way.
11038    #[allow(clippy::too_many_arguments)]
11039    fn compile_computed_expr(
11040        &mut self,
11041        name: &str,
11042        expr_ast: &Expr,
11043        td: &TypeDescriptor,
11044        alias: &str,
11045        module: &str,
11046        marker_offset: Option<usize>,
11047        nested: &[ShapeElement],
11048        declared_on: Option<(String, String)>,
11049    ) -> Result<IrShapePointer, PyQLError> {
11050        // The object the pointer is computed on stays in scope for the whole
11051        // expression, including any part of it compiled without a type in
11052        // hand — a `with` binding's right-hand side, say.
11053        self.anchors.push(SelectAnchor {
11054            type_name: td.name.clone(),
11055            qualified: format!("{}::{}", td.module, td.name),
11056            alias: alias.to_string(),
11057            detached: false,
11058            declared_on,
11059        });
11060        let result = (|compiler: &mut Self| -> Result<IrShapePointer, PyQLError> {
11061            if let Some(ptr) =
11062                compiler.try_compile_pointer_expr(name, expr_ast, td, alias, module, marker_offset, nested)?
11063            {
11064                return Ok(ptr);
11065            }
11066            // `manifest := retrieve_connector_manifest(.id)` — a computed whose
11067            // expression is an object-returning call. The rows it yields are
11068            // the pointer's objects; read as a plain expression the call is
11069            // "part of a larger expression", which such a function refuses.
11070            if let Expr::FunctionCall(fc) = expr_ast
11071                && let Some(fs) = compiler.compile_fn_object_source(fc, nested)?
11072            {
11073                return Ok(IrShapePointer::Computed(IrComputedPointer {
11074                    marker_offset,
11075                    alias: name.to_string(),
11076                    expr: IrExpr::ArrayFromSelect(Box::new(IrArraySource::ObjectFunction(Box::new(fs)))),
11077                }));
11078            }
11079            let ir = compiler.in_value_position(|c| c.compile_expr(expr_ast, td, alias))?;
11080            Ok(IrShapePointer::Computed(IrComputedPointer {
11081                marker_offset,
11082                alias: name.to_string(),
11083                expr: ir,
11084            }))
11085        })(self);
11086        self.anchors.pop();
11087        result
11088    }
11089
11090    /// Split a sub-select's result into the path it traverses and the nested
11091    /// shape attached to it: `select .posts { title }` → `.posts` + `{ title }`,
11092    /// `select .posts` → `.posts` + no shape.
11093    fn split_path_result(result: &Expr) -> Option<(&ast::Path, &[ShapeElement])> {
11094        match result {
11095            Expr::Path(p) => Some((p, &[])),
11096            // `(select detached T filter … limit 1).field` — the marker says
11097            // the set is not correlated with the enclosing one, which is
11098            // already true of a subject named by its own type.
11099            Expr::Detached(inner) => Self::split_path_result(inner),
11100            Expr::Shape(sh) => match &sh.expr {
11101                Some(Expr::Path(p)) => Some((p, sh.elements.as_slice())),
11102                _ => None,
11103            },
11104            _ => None,
11105        }
11106    }
11107
11108    /// Peel a computed pointer's right-hand side down to the function call
11109    /// it names and the sub-select's modifiers, if that is its shape:
11110    /// `latest(.id)` / `(select latest(.id) filter …)`.
11111    fn function_subject(e: &Expr) -> Option<(&ast::FunctionCall, Option<&ast::SelectStmt>)> {
11112        match e {
11113            Expr::FunctionCall(fc) => Some((fc, None)),
11114            Expr::SubQuery(stmt) => match stmt.as_ref() {
11115                Stmt::Select(sel) => match &sel.result {
11116                    Expr::FunctionCall(fc) => Some((fc, Some(sel))),
11117                    _ => None,
11118                },
11119                _ => None,
11120            },
11121            _ => None,
11122        }
11123    }
11124
11125    /// The object-returning user function `fc` names, if any.
11126    fn resolve_object_fn(&self, fc: &ast::FunctionCall) -> Option<&'a FunctionDescriptor> {
11127        self.schema.functions.iter().find(|f| {
11128            let module_matches = fc.module.as_deref().map(|m| m == f.module.as_str()).unwrap_or(true);
11129            module_matches && f.name == fc.name && f.return_is_object
11130        })
11131    }
11132
11133    /// The shape a pointer's subject carried, when a shape written at the
11134    /// point of use replaced it: `(select .resource { rev := … }) { … }`.
11135    /// `pointer_subject` drops it, but its declarations are what the
11136    /// replacement reads `.rev` from.
11137    fn replaced_subject_shape(e: &Expr) -> &[ShapeElement] {
11138        let Expr::Shape(sh) = e else { return &[] };
11139        if sh.elements.is_empty() {
11140            return &[];
11141        }
11142        match sh.expr.as_ref() {
11143            Some(inner) => Self::pointer_subject(inner).map(|(_, nested, _)| nested).unwrap_or(&[]),
11144            None => &[],
11145        }
11146    }
11147
11148    /// Peel a computed pointer's right-hand side down to the path it names,
11149    /// the shape attached to it, and the sub-select carrying its modifiers
11150    /// (if any): `.posts` / `.posts { title }` / `(select .posts limit 1)` /
11151    /// `(select .posts { title } limit 1)` / `(select .posts limit 1) { title }`.
11152    fn pointer_subject(e: &Expr) -> Option<(&ast::Path, &[ShapeElement], Option<&ast::SelectStmt>)> {
11153        match e {
11154            Expr::Path(p) => Some((p, &[], None)),
11155            Expr::SubQuery(stmt) => match stmt.as_ref() {
11156                Stmt::Select(sel) => {
11157                    let (p, inner) = Self::split_path_result(&sel.result)?;
11158                    Some((p, inner, Some(sel)))
11159                }
11160                _ => None,
11161            },
11162            Expr::Shape(sh) => {
11163                let (path, inner, modifiers) = Self::pointer_subject(sh.expr.as_ref()?)?;
11164                let nested = if sh.elements.is_empty() {
11165                    inner
11166                } else {
11167                    sh.elements.as_slice()
11168                };
11169                Some((path, nested, modifiers))
11170            }
11171            _ => None,
11172        }
11173    }
11174
11175    /// `((select .parents filter … limit 1)).parent` — a computed whose value
11176    /// is a field access off its own sub-select. Rewritten as the select over
11177    /// the extended path, plus how many steps the field access contributed:
11178    /// the modifiers belong to the step *before* them, which is what a walk's
11179    /// `tail` pins them to.
11180    fn field_access_over_select(expr: &Expr) -> Option<(ast::SelectStmt, usize)> {
11181        let (root, fields) = Self::peel_field_access_chain(expr);
11182        if fields.is_empty() {
11183            return None;
11184        }
11185        let Expr::SubQuery(stmt) = root else { return None };
11186        let Stmt::Select(sel) = stmt.as_ref() else {
11187            return None;
11188        };
11189        let Expr::Path(path) = &sel.result else { return None };
11190        if !path.partial {
11191            return None;
11192        }
11193        let mut steps = path.steps.clone();
11194        let count = fields.len();
11195        steps.extend(fields.into_iter().map(ast::PathStep::Name));
11196        Some((
11197            ast::SelectStmt {
11198                result: Expr::Path(ast::Path { steps, partial: true }),
11199                ..sel.clone()
11200            },
11201            count,
11202        ))
11203    }
11204
11205    /// A computed pointer whose right-hand side names a *link* rather than
11206    /// computing a value — `pointer := .multilink`, `:= .multilink { … }`,
11207    /// `:= .<backlink[is T] { … }`, and any of those wrapped in a sub-select
11208    /// carrying FILTER/ORDER BY/OFFSET/LIMIT.
11209    ///
11210    /// These are pointers in their own right, so they route to the same
11211    /// builders a bare `pointer: { … } filter … limit N` inclusion uses and
11212    /// come back as real object pointers (arrays of hydrated objects) rather
11213    /// than the bare id or EXISTS boolean that compiling them as an
11214    /// expression would produce. A sub-select's modifiers are exactly the
11215    /// per-link modifiers that inclusion form already carries, which is what
11216    /// makes the re-use exact — the `:=` grammar has no trailing-modifier
11217    /// form of its own.
11218    ///
11219    /// Shared by inline `pointer := …` shape elements and schema-declared
11220    /// computeds, so both kinds behave identically.
11221    ///
11222    /// `Ok(None)` means "not a link-valued RHS": the caller compiles it as
11223    /// an ordinary expression, which is where `(select …).field` — a scalar
11224    /// — is handled.
11225    #[allow(clippy::too_many_arguments)]
11226    fn try_compile_pointer_expr(
11227        &mut self,
11228        pointer_name: &str,
11229        compexpr: &Expr,
11230        td: &TypeDescriptor,
11231        alias: &str,
11232        module: &str,
11233        marker_offset: Option<usize>,
11234        nested_override: &[ShapeElement],
11235    ) -> Result<Option<IrShapePointer>, PyQLError> {
11236        // A nested shape can sit inside the parens (`(select .posts
11237        // { title })`) or after them (`(select .posts) { title }`, `.posts
11238        // { title }`) — the parser folds a trailing `{ }` into an
11239        // `Expr::Shape` wrapping whatever precedes it, never into
11240        // `el.nested` (that field is only populated by the separate no-`:=`
11241        // "bare inclusion with nested shape" parse path).
11242        // `((select .history order by … limit 1)).release` — the field access
11243        // continues the walk, and the modifiers stay on the step they were
11244        // written on.
11245        if let Some((sel, tail)) = Self::field_access_over_select(compexpr)
11246            && let Expr::Path(path) = &sel.result
11247        {
11248            let (head_steps, tail_steps) = path.steps.split_at(path.steps.len() - tail);
11249            let (head_multi, head_target) = self.walk_path_types(td, head_steps, MAX_COMPUTED_SPLICES);
11250            let Some(head_target) = head_target else {
11251                return Ok(None);
11252            };
11253            let (tail_multi, target) = self.walk_path_types(head_target, tail_steps, MAX_COMPUTED_SPLICES);
11254            if target.is_none() {
11255                return Ok(None);
11256            }
11257            let multi = tail_multi || (head_multi && !limits_to_one(Some(&sel)));
11258            if !multi && nested_override.is_empty() {
11259                return Ok(None);
11260            }
11261            let path = path.clone();
11262            return self
11263                .compile_chained_link_pointer(pointer_name, &path, td, alias, nested_override, Some(&sel), multi, tail)
11264                .map(Some);
11265        }
11266        let Some((path, declared_nested, modifiers)) = Self::pointer_subject(compexpr) else {
11267            return Ok(None);
11268        };
11269        if !path.partial {
11270            return Ok(None);
11271        }
11272        // A shape written at the point of use (`authors { name }` on a
11273        // declared computed) wins over the one the declaration itself
11274        // carries, which acts as the default.
11275        let nested = if nested_override.is_empty() {
11276            declared_nested
11277        } else {
11278            nested_override
11279        };
11280
11281        // Only a link pointer of the current type becomes an object pointer;
11282        // a property (`x := .name`, `x := (select .name)`) is a scalar and
11283        // belongs on the expression path.
11284        let ml_name = match path.steps.as_slice() {
11285            [ast::PathStep::Name(n)] if Self::resolve_multilink(td, n).is_some() => n.clone(),
11286            // `.<link[is Owner]` — the objects on the other side of the
11287            // link. Traversing *past* the intersection (`.<link[is
11288            // Owner].name`) is a value, not an object pointer, so it goes
11289            // the expression route instead.
11290            [ast::PathStep::Backlink(_)] | [ast::PathStep::Backlink(_), ast::PathStep::TypeIntersection(_)] => {
11291                let current_qname = format!("{}::{}", td.module, td.name);
11292                let path = path.clone();
11293                let nested = nested.to_vec();
11294                return self
11295                    .compile_backlink_pointer(pointer_name, &path, &current_qname, &nested, marker_offset, modifiers)
11296                    .map(Some);
11297            }
11298            // `.memberships.member` — a chain no single-step builder can
11299            // express: the junction it would need belongs to no one link but
11300            // to the whole walk. Compiled as a path select and aggregated, so
11301            // it stays an object pointer instead of collapsing to the bare ids
11302            // an expression-position path gives — which, worse, was a scalar
11303            // subquery that failed outright the moment a second row matched.
11304            // A leading `[is T]` narrows what the walk starts from
11305            // (`brand := [is BrandOrderLineItem].brand { * }`); the walk
11306            // itself is the same one a bare name starts.
11307            steps
11308                if matches!(
11309                    steps.first(),
11310                    Some(ast::PathStep::Name(_) | ast::PathStep::TypeIntersection(_))
11311                ) =>
11312            {
11313                let (multi, target) = self.walk_path_types(td, steps, MAX_COMPUTED_SPLICES);
11314                // A single-valued walk takes this route only when a shape was
11315                // written on it (`account := resource.account { id }`), which
11316                // is what says an object was meant. Without one, a bare
11317                // `x := .link` still stands for the link's value, as it did
11318                // before there was an object route at all.
11319                if target.is_none() || (!multi && nested.is_empty()) {
11320                    return Ok(None);
11321                }
11322                let path = path.clone();
11323                let nested = nested.to_vec();
11324                return self
11325                    .compile_chained_link_pointer(
11326                        pointer_name,
11327                        &path,
11328                        td,
11329                        alias,
11330                        &nested,
11331                        modifiers,
11332                        multi && !limits_to_one(modifiers),
11333                        0,
11334                    )
11335                    .map(Some);
11336            }
11337            _ => return Ok(None),
11338        };
11339
11340        let synthetic = ShapeElement {
11341            path: path.clone(),
11342            splat: None,
11343            nested: Some(nested.to_vec()),
11344            compexpr: None,
11345            op: ast::ShapeOp::Assign,
11346            filter: modifiers.and_then(|s| s.filter.clone()),
11347            order_by: modifiers.map(|s| s.order_by.clone()).unwrap_or_default(),
11348            offset: modifiers.and_then(|s| s.offset.clone()),
11349            limit: modifiers.and_then(|s| s.limit.clone()),
11350            marker_offset,
11351        };
11352        let mut pointer = self.compile_multilink_pointer(pointer_name, &ml_name, td, alias, module, &synthetic)?;
11353        if let IrShapePointer::MultiLink(link) = &mut pointer {
11354            link.single = limits_to_one(modifiers);
11355        }
11356        Ok(Some(pointer))
11357    }
11358
11359    /// A sub-statement used as an expression: `(select .emails filter
11360    /// .primary limit 1).email`, `(select .posts order by .created desc
11361    /// limit 1).title`.
11362    ///
11363    /// Compiles the inner select as a flat path traversal
11364    /// (`compile_path_select` — the same builder a top-level `select
11365    /// Person.company.name` uses, so forward links, multi-links, backlinks,
11366    /// junction-backed links and type intersections all come along) and
11367    /// returns it as one correlated scalar subquery.
11368    ///
11369    /// A *partial* subject path (`.emails`) is relative to the enclosing
11370    /// object, so it is rooted at the enclosing type and correlated back to
11371    /// the enclosing row by primary key; an absolute one (`Person.name`) is
11372    /// independent and needs no correlation.
11373    ///
11374    /// `extra_fields` are the `.field` steps of an enclosing field-access
11375    /// chain. They are spliced onto the subject path rather than applied to
11376    /// its result, so the subquery projects the scalar column itself instead
11377    /// of an opaque object id.
11378    fn compile_subquery_expr(
11379        &mut self,
11380        stmt: &Stmt,
11381        extra_fields: &[String],
11382        ctx: Option<(&TypeDescriptor, &str)>,
11383        outer_shape: &[ShapeElement],
11384    ) -> Result<IrExpr, PyQLError> {
11385        // `(with x := … select …)` — the bindings have nowhere to live in
11386        // expression position, so they're hoisted to the enclosing
11387        // statement's own WITH clause and the inner statement takes over.
11388        if let Stmt::With(w) = stmt {
11389            for alias in &w.aliases {
11390                if self.bind_inline_if_correlated(&alias.name, &alias.expr, ctx)?
11391                    || self.bind_group(&alias.name, &alias.expr)?
11392                {
11393                    continue;
11394                }
11395                // The same computed inlined twice — `select M { * } filter … .role`
11396                // reaches `Membership.role` from both the splat and the filter —
11397                // hoists its `with` bindings once per inlining, and two CTEs of
11398                // one name is "WITH query name specified more than once". The
11399                // bindings are identical, so the first one stands for both; a
11400                // *different* binding under a name already taken is a real
11401                // collision and says so rather than silently shadowing.
11402                match self.hoisted_binding_sources.get(&alias.name) {
11403                    Some(existing) if *existing == alias.expr => continue,
11404                    Some(_) => {
11405                        return Err(self.type_err(&format!(
11406                            "two different `with` bindings named '{}' end up in one statement; \
11407                             rename one of them",
11408                            alias.name
11409                        )));
11410                    }
11411                    None => {}
11412                }
11413                let (ir_stmt, correlated_to) = self.compile_binding_in_scope(&alias.expr)?;
11414                let sql_name = self.claim_cte_sql_name(&alias.name);
11415                if let Some(slot) = correlated_to.as_deref().and_then(|i| i.strip_prefix("_for_")) {
11416                    self.correlated_bindings.insert(sql_name.clone(), slot.to_string());
11417                }
11418                let type_name = self.register_cte(&alias.name, &ir_stmt);
11419                self.hoisted_binding_sources
11420                    .insert(alias.name.clone(), alias.expr.clone());
11421                self.hoisted_ctes.push(IrCteDef {
11422                    name: sql_name,
11423                    stmt: ir_stmt,
11424                    type_name,
11425                    correlated_to,
11426                });
11427            }
11428            let inner = (*w.stmt).clone();
11429            return self.compile_subquery_expr(&inner, extra_fields, ctx, outer_shape);
11430        }
11431
11432        if extra_fields.is_empty()
11433            && let Stmt::Select(sel) = stmt
11434            && let Expr::Shape(sh) = &sel.result
11435            && let Some(Expr::SubQuery(subject)) = &sh.expr
11436            && matches!(subject.as_ref(), Stmt::Group(_))
11437            && let IrStmt::Group(grp) = self.compile_stmt(stmt)?
11438        {
11439            return Ok(IrExpr::ArrayFromSelect(Box::new(IrArraySource::Group(Box::new(grp)))));
11440        }
11441
11442        // `(select (.<a[is T] union .<b[is T]) { id } limit 1)` — the union's
11443        // operands are correlated to the enclosing row, so the whole thing is
11444        // read as one set rather than hoisted branch by branch.
11445        if let Stmt::Select(inner) = stmt
11446            && let Expr::Shape(sh) = &inner.result
11447            && let Some(subject) = sh.expr.as_ref()
11448            && let Some(operands) = Self::union_of_relative_paths(subject)
11449            && let Some((td, alias)) = ctx
11450        {
11451            let elements = sh.elements.clone();
11452            let operands_reach_many = self.relative_paths_reach_many(td, &operands);
11453            let mut branches = Vec::with_capacity(operands.len());
11454            for path in operands {
11455                let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
11456                steps.extend(path.steps.iter().cloned());
11457                let rooted = ast::Path { steps, partial: false };
11458                let synthetic = ast::SelectStmt {
11459                    result: Expr::Path(rooted.clone()),
11460                    filter: inner.filter.clone(),
11461                    order_by: vec![],
11462                    offset: None,
11463                    limit: None,
11464                    lock: None,
11465                };
11466                let mut ps = self.compile_path_select(&synthetic, &rooted, &elements, false)?;
11467                Self::correlate_path_select(&mut ps, alias);
11468                branches.push(ps);
11469            }
11470            let limit = inner
11471                .limit
11472                .as_ref()
11473                .map(|l| self.compile_free_expr(l))
11474                .transpose()?
11475                .map(Box::new);
11476            let multi = !matches!(limit.as_deref(), Some(IrExpr::Literal(IrLiteral::Int(1)))) && operands_reach_many;
11477            return Ok(IrExpr::ObjectPathUnion { branches, limit, multi });
11478        }
11479        // `(insert T { … }).id` — a mutation read through a path. The statement
11480        // itself may only run at the top level of a WITH, so it is hoisted into
11481        // one and the path is read back off that binding: exactly what the
11482        // `with x := (insert …) select x.id` spelling already compiles to. With
11483        // no path to read there is nothing for the mutation to stand in for,
11484        // which stays an error.
11485        // Only in free context. Inside a schema-bound shape element
11486        // (`select Person { x := (insert Company { … }).name }`) the mutation
11487        // would run once while every row read its result, which is not what
11488        // writing it there says — that stays rejected.
11489        if matches!(stmt, Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_))
11490            && !extra_fields.is_empty()
11491            && ctx.is_none()
11492        {
11493            let type_name = self.dml_subject_type(stmt)?;
11494            let inner = self.compile_stmt(stmt)?;
11495            let cte_name = self.fresh_nested_cte_name();
11496            // `hoisted_ctes`, not `pending_nested_ctes`: this mutation came out
11497            // of an expression in the enclosing statement, not out of another
11498            // statement's link assignment, so it belongs to that statement's
11499            // own WITH. Registering it is what lets the path below resolve
11500            // against it like any other binding.
11501            self.register_cte(&cte_name, &inner);
11502            self.hoisted_ctes.push(IrCteDef {
11503                name: cte_name.clone(),
11504                stmt: inner,
11505                type_name,
11506                correlated_to: None,
11507            });
11508            let mut steps = vec![ast::PathStep::Name(cte_name)];
11509            steps.extend(extra_fields.iter().cloned().map(ast::PathStep::Name));
11510            return self.compile_free_path(&ast::Path { steps, partial: false });
11511        }
11512
11513        let Stmt::Select(sel) = stmt else {
11514            return Err(self.subquery_expr_err(stmt));
11515        };
11516        let has_modifiers =
11517            sel.filter.is_some() || !sel.order_by.is_empty() || sel.offset.is_some() || sel.limit.is_some();
11518
11519        let Some((path, shape_els)) = Self::split_path_result(&sel.result) else {
11520            // `(select account::owner(.id) filter … limit 1).name` — the
11521            // function call is the row source the modifiers apply to.
11522            if let Expr::FunctionCall(fc) = &sel.result {
11523                let fc = fc.clone();
11524                if let Some(ir) = self.try_compile_fn_scalar_subquery(&fc, extra_fields, Some(sel), ctx)? {
11525                    return Ok(ir);
11526                }
11527            }
11528            // `(select <expression>)` with nothing to traverse is just the
11529            // expression itself. With modifiers to honour it is a statement,
11530            // not an expression — `(select count(Visit) filter …)` — so it is
11531            // hoisted into the enclosing WITH and read back by name.
11532            if has_modifiers {
11533                let inner = self.compile_stmt(stmt)?;
11534                let IrStmt::Select(select) = inner else {
11535                    return Err(self.subquery_expr_err(stmt));
11536                };
11537                if !select
11538                    .rows
11539                    .iter()
11540                    .all(|r| matches!(r, IrRowSource::Free(IrFreeExpr::Scalar(_))))
11541                {
11542                    return Err(self.subquery_expr_err(stmt));
11543                }
11544                let mut ir = IrExpr::ScalarSubquery(Box::new(select));
11545                for field in extra_fields {
11546                    ir = Self::project_free_object_field(ir, field);
11547                }
11548                return Ok(ir);
11549            }
11550            let mut ir = self.compile_expr_ctx(&sel.result, ctx)?;
11551            for field in extra_fields {
11552                ir = Self::project_free_object_field(ir, field);
11553            }
11554            return Ok(ir);
11555        };
11556        // A shape whose pointers are relative paths declares local names for
11557        // them, readable by the select's own FILTER/ORDER BY and by whatever
11558        // projects off it — `(select .locators { handle := [is Handle].handle,
11559        // latest := [is Handle].latest } filter .latest limit 1).handle`. Each
11560        // is substituted back into its readers, leaving an ordinary shapeless
11561        // sub-select behind.
11562        let rewritten_sel;
11563        let mut sel = sel;
11564        let mut shape_els = shape_els;
11565        let mut extra_steps: Vec<ast::PathStep> = extra_fields.iter().map(|f| ast::PathStep::Name(f.clone())).collect();
11566        if !shape_els.is_empty()
11567            && let Some(defs) = Self::shape_alias_paths(shape_els)
11568        {
11569            extra_steps = extra_fields
11570                .iter()
11571                .flat_map(|field| match defs.iter().find(|(name, _)| name == field) {
11572                    Some((_, definition)) => definition.steps.clone(),
11573                    None => vec![ast::PathStep::Name(field.clone())],
11574                })
11575                .collect();
11576            rewritten_sel = ast::SelectStmt {
11577                result: sel.result.clone(),
11578                filter: sel.filter.clone().map(|f| Self::substitute_shape_aliases(f, &defs)),
11579                order_by: sel
11580                    .order_by
11581                    .iter()
11582                    .map(|o| ast::SortExpr {
11583                        expr: Self::substitute_shape_aliases(o.expr.clone(), &defs),
11584                        direction: o.direction.clone(),
11585                        nones: o.nones.clone(),
11586                    })
11587                    .collect(),
11588                offset: sel.offset.clone(),
11589                limit: sel.limit.clone(),
11590                lock: sel.lock.clone(),
11591            };
11592            sel = &rewritten_sel;
11593            shape_els = &[];
11594        }
11595
11596        // A shape only matters when the sub-select's own value is the
11597        // result. `(select .emails { address } limit 1).address` projects a
11598        // column straight back out of it, so the shape says nothing the
11599        // projection doesn't — the same reading PyQL gives it.
11600        // `answer := (select brand::AssessmentAnswer { … } filter … limit 1)`
11601        // — a select over a named type, carrying the shape its rows are read
11602        // with. That is an object, not the bare key the branch below gives a
11603        // shapeless one, so the shape has somewhere to go after all.
11604        // `(select names { * })` over an object `with` binding reads the
11605        // binding's rows the same way.
11606        if !shape_els.is_empty()
11607            && extra_fields.is_empty()
11608            && !path.partial
11609            && let [ast::PathStep::Name(type_name)] = path.steps.as_slice()
11610            && let Some(root_td) = match self.cte_object_type(type_name) {
11611                Some(_) => self.resolve_path_root(type_name).ok(),
11612                None => self.resolve_type(type_name).ok(),
11613            }
11614        {
11615            let alias = self.fresh_alias();
11616            let binding = self.cte_object_type(type_name).map(|_| type_name.clone());
11617            let table = match &binding {
11618                Some(name) => self.row_source_table(name, root_td),
11619                None => root_td.table.clone(),
11620            };
11621            let outer_declared = binding.as_ref().map(|name| {
11622                let declared = self.cte_declared_pointers.get(name).cloned().unwrap_or_default();
11623                std::mem::replace(&mut self.active_declared_pointers, declared)
11624            });
11625            let modifiers = self.compile_path_modifiers(sel, root_td, &alias);
11626            let module = root_td.module.clone();
11627            let shape = self.compile_shape(shape_els, root_td, &alias, &module);
11628            if let Some(outer) = outer_declared {
11629                self.active_declared_pointers = outer;
11630            }
11631            let (filter, order_by, offset, limit) = modifiers?;
11632            let shape = shape?;
11633            let source = IrSource {
11634                poly: self.poly_fanout_for(&format!("{}::{}", root_td.module, root_td.name)),
11635                type_name: format!("{}::{}", root_td.module, root_td.name),
11636                table,
11637                alias,
11638            };
11639            let single = selects_at_most_one(sel, root_td);
11640            let mut select = IrSelect::schema_bound(source, shape, filter);
11641            select.order_by = order_by;
11642            select.offset = offset;
11643            select.limit = limit;
11644            // Read as one object, a select yielding several rows aborts the
11645            // query ("more than one row returned by a subquery").
11646            return Ok(if single {
11647                IrExpr::ObjectSubquery(Box::new(select))
11648            } else {
11649                IrExpr::ArrayFromSelect(Box::new(IrArraySource::ObjectSelect(Box::new(select))))
11650            });
11651        }
11652
11653        if !shape_els.is_empty() && extra_fields.is_empty() {
11654            return Err(self.type_err(
11655                "a sub-select with a shape is not valid in expression context — \
11656                 assign it to a computed pointer instead, or project a property \
11657                 off it, e.g. '(select .emails limit 1).address'",
11658            ));
11659        }
11660
11661        // `(with c := … select c)` — the result is the binding itself, which
11662        // is a value already, not something to traverse from.
11663        if !path.partial
11664            && let [ast::PathStep::Name(name)] = path.steps.as_slice()
11665            && self.is_value_binding(name)
11666        {
11667            let mut ir = self.compile_expr_ctx(&sel.result, ctx)?;
11668            for field in extra_fields {
11669                ir = Self::project_free_object_field(ir, field);
11670            }
11671            return Ok(ir);
11672        }
11673
11674        // `(select Company filter .name = 'Acme' limit 1)` — a bare type
11675        // select has nothing to traverse; in expression position it stands
11676        // for the object's primary key, which is what a link's value is.
11677        if !path.partial
11678            && extra_fields.is_empty()
11679            && let [ast::PathStep::Name(type_name)] = path.steps.as_slice()
11680            && let Ok(root_td) = self.resolve_type(type_name)
11681        {
11682            let alias = self.fresh_alias();
11683            let (filter, order_by, offset, limit) = self.compile_path_modifiers(sel, root_td, &alias)?;
11684            let source = IrSource {
11685                poly: None,
11686                type_name: format!("{}::{}", root_td.module, root_td.name),
11687                table: root_td.table.clone(),
11688                alias,
11689            };
11690            // Never `pk_returning` here: an empty shape is how the emitter
11691            // spells an EXISTS inner, so a type with no declared pk would
11692            // quietly become `SELECT 1` instead of a key.
11693            let pk = root_td.properties.iter().find(|p| p.is_pk);
11694            let shape = vec![IrShapePointer::Scalar(IrScalarPointer {
11695                implicit_id: false,
11696                marker_offset: None,
11697                alias: "id".to_string(),
11698                column: pk.map(|p| p.name.clone()).unwrap_or_else(|| "id".to_string()),
11699                pg_type: pk.map(|p| p.pg_type.clone()).unwrap_or_else(|| "uuid".to_string()),
11700                tuple_shape: None,
11701            })];
11702            let mut select = IrSelect::schema_bound(source, shape, filter);
11703            select.order_by = order_by;
11704            select.offset = offset;
11705            select.limit = limit;
11706            return Ok(IrExpr::Subquery(Box::new(select)));
11707        }
11708
11709        let mut steps = path.steps.clone();
11710        let correlate = if path.partial {
11711            let Some((td, alias)) = ctx else {
11712                return Err(self.type_err(
11713                    "a relative path in a sub-select needs an enclosing object — \
11714                     write the type name explicitly, e.g. '(select Person.name)'",
11715                ));
11716            };
11717            steps.insert(0, ast::PathStep::Name(format!("{}::{}", td.module, td.name)));
11718            Some(alias.to_string())
11719        } else {
11720            None
11721        };
11722        steps.extend(extra_steps.iter().cloned());
11723        let full_path = ast::Path { steps, partial: false };
11724
11725        let mut ps = self.compile_path_select_with_tail(sel, &full_path, outer_shape, false, extra_steps.len(), &[])?;
11726        if let Some(outer_alias) = correlate {
11727            Self::correlate_path_select(&mut ps, &outer_alias);
11728        }
11729        // A shape written after the projection (`(select … limit 1).account
11730        // { id, name }`) says the object was wanted, not its id, so the walk
11731        // comes back as one rather than being reduced the way a bare path in
11732        // expression position is.
11733        if !outer_shape.is_empty() && matches!(ps.result, IrPathResult::Object { .. }) {
11734            return Ok(IrExpr::ObjectPathSubquery(Box::new(ps)));
11735        }
11736        // `limit 1` is what makes a sub-select over a multi-link single-
11737        // valued — that is the whole point of `(select .emails filter
11738        // .primary limit 1).address`. Any other limit, or none, still stands
11739        // for a set, so it comes back as an array rather than a subquery
11740        // Postgres would reject the moment a second row showed up.
11741        let single = matches!(&sel.limit, Some(Expr::Literal(ast::Literal::Int(1))));
11742        let multi = match &full_path.steps[0] {
11743            ast::PathStep::Name(root) => {
11744                let root_td = self.resolve_path_root(root)?;
11745                // `(select T filter …).field` reads every `T` the filter
11746                // keeps, unless it pins an exclusive pointer.
11747                let every_row_of_a_type = !path.partial
11748                    && self.cte_object_type(root).is_none()
11749                    && self.resolve_type(root).is_ok()
11750                    && !selects_at_most_one(sel, root_td);
11751                every_row_of_a_type || self.path_crosses_multi(root_td, &full_path.steps[1..])
11752            }
11753            _ => false,
11754        };
11755        if multi && !single && matches!(ps.result, IrPathResult::Scalar(..)) {
11756            return Ok(IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(
11757                ps,
11758            )))));
11759        }
11760        Ok(IrExpr::PathSubquery(Box::new(ps)))
11761    }
11762
11763    /// `account::owner(.id).name` and `(select account::owner(.id) filter …
11764    /// limit 1).name` — an object-returning user function used inside a
11765    /// larger expression, projected down to one of its return type's
11766    /// columns.
11767    ///
11768    /// The call itself stays object-valued (there is no way to inline a
11769    /// function that returns a row set), so it becomes the FROM clause of a
11770    /// scalar subquery and `field` becomes what that subquery selects. Only
11771    /// a property of the return type can be projected — reaching further
11772    /// (`fn(x).company.name`) would need joins the function row source has
11773    /// no way to express.
11774    ///
11775    /// `Ok(None)` when `fc` doesn't name an object-returning function, so
11776    /// the caller can fall through to ordinary function-call resolution.
11777    fn try_compile_fn_scalar_subquery(
11778        &mut self,
11779        fc: &ast::FunctionCall,
11780        fields: &[String],
11781        modifiers: Option<&ast::SelectStmt>,
11782        ctx: Option<(&TypeDescriptor, &str)>,
11783    ) -> Result<Option<IrExpr>, PyQLError> {
11784        let fd = self.schema.functions.iter().find(|f| {
11785            let module_matches = fc.module.as_deref().map(|m| m == f.module.as_str()).unwrap_or(true);
11786            module_matches && f.name == fc.name && f.return_is_object
11787        });
11788        let Some(fd) = fd else { return Ok(None) };
11789        let (fn_module, fn_name, return_type_name, polymorphic, params) = (
11790            fd.module.clone(),
11791            fd.name.clone(),
11792            fd.return_pg_type.clone(),
11793            fd.return_is_polymorphic,
11794            fd.params.clone(),
11795        );
11796
11797        let qualified = format!("{fn_module}::{fn_name}");
11798        let [field] = fields else {
11799            return Err(self.type_err(&format!(
11800                "function '{qualified}' returns objects, so using it inside an expression needs \
11801                 one of its properties, e.g. '{qualified}(…).name'"
11802            )));
11803        };
11804        if params.len() != fc.args.len() {
11805            return Err(self.type_err(&format!(
11806                "function '{qualified}' expects {} argument(s), got {}",
11807                params.len(),
11808                fc.args.len()
11809            )));
11810        }
11811
11812        let mut fn_args = fc
11813            .args
11814            .iter()
11815            .map(|a| self.compile_expr_ctx(a, ctx))
11816            .collect::<Result<Vec<_>, _>>()?;
11817        if let Some(globals) = self.globals_arg_for_call(&qualified)? {
11818            fn_args.insert(0, globals);
11819        }
11820
11821        let td = self.resolve_type(&return_type_name)?;
11822        let alias = self.fresh_alias();
11823        let Some(prop) = Self::resolve_property(td, field) else {
11824            return Err(self.field_err(field, &return_type_name));
11825        };
11826        let projected = IrShapePointer::Computed(IrComputedPointer {
11827            marker_offset: None,
11828            alias: field.clone(),
11829            expr: IrExpr::ColumnRef {
11830                alias: alias.clone(),
11831                column: prop.name.clone(),
11832                pg_type: prop.pg_type.clone(),
11833            },
11834        });
11835
11836        let (poly_implementors, poly_columns) = if polymorphic {
11837            self.collect_poly_info(&return_type_name)
11838        } else {
11839            (vec![], vec![])
11840        };
11841        let (filter, order_by, offset, limit) = match modifiers {
11842            Some(sel) => {
11843                let td = self.resolve_type(&return_type_name)?;
11844                self.compile_path_modifiers(sel, td, &alias)?
11845            }
11846            None => (None, vec![], None, None),
11847        };
11848
11849        Ok(Some(IrExpr::FnSubquery(Box::new(IrFunctionSelect {
11850            fn_module,
11851            fn_name,
11852            fn_args,
11853            alias,
11854            type_name: return_type_name,
11855            polymorphic,
11856            poly_implementors,
11857            poly_columns,
11858            shape: vec![projected],
11859            filter,
11860            order_by,
11861            offset,
11862            limit,
11863            distinct: false,
11864        }))))
11865    }
11866
11867    /// Whether a declared computed pointer stands for *objects* rather than a
11868    /// value — `members := .memberships.member`, `primary_email := (select
11869    /// .emails filter .primary limit 1)`.
11870    ///
11871    /// `*` expands to properties and `**` adds links, so a computed belongs to
11872    /// whichever side its expression lands on. `ComputedDescriptor` carries no
11873    /// flag saying which, so the expression has to be walked.
11874    fn computed_is_object_valued(&self, cd: &crate::schema::ComputedDescriptor, td: &TypeDescriptor) -> bool {
11875        let Ok(expr) = crate::parse::parse_pointer_expr(&cd.expression) else {
11876            return false;
11877        };
11878        let expr = match Self::field_access_over_select(&expr) {
11879            Some((sel, _)) => Expr::SubQuery(Box::new(Stmt::Select(sel))),
11880            None => expr,
11881        };
11882        let Some((path, _, _)) = Self::pointer_subject(&expr) else {
11883            return false;
11884        };
11885        if !path.partial {
11886            return false;
11887        }
11888        match path.steps.as_slice() {
11889            // A backlink names the objects on the other side of the link.
11890            // Traversing past it (`.<author[is Post].title`) is a value again,
11891            // which the walk below works out for itself.
11892            [ast::PathStep::Backlink(_)] | [ast::PathStep::Backlink(_), ast::PathStep::TypeIntersection(_)] => true,
11893            steps => self.walk_path_types(td, steps, MAX_COMPUTED_SPLICES).1.is_some(),
11894        }
11895    }
11896
11897    /// True when traversing `steps` from `td` crosses a multi-valued step —
11898    /// a multi-link or a backlink. Such a path stands for a *set*, so in
11899    /// expression position it has to come back as an array rather than a
11900    /// scalar subquery (which Postgres would reject at run time the moment a
11901    /// second row showed up).
11902    fn path_crosses_multi(&self, td: &TypeDescriptor, steps: &[ast::PathStep]) -> bool {
11903        self.walk_path_types(td, steps, MAX_COMPUTED_SPLICES).0
11904    }
11905
11906    /// One correlated walk per operand of a coalesce of relative paths, each
11907    /// one filtered to the rows where every operand before it came up empty —
11908    /// which is what makes the arms, read together, mean what `??` means.
11909    fn coalesce_path_branches(
11910        &mut self,
11911        td: &TypeDescriptor,
11912        alias: &str,
11913        operands: &[ast::Path],
11914        elements: &[ast::ShapeElement],
11915    ) -> Result<Vec<IrPathSelect>, PyQLError> {
11916        let mut branches: Vec<IrPathSelect> = Vec::with_capacity(operands.len());
11917        for path in operands {
11918            let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
11919            steps.extend(path.steps.iter().cloned());
11920            let rooted = ast::Path { steps, partial: false };
11921            let synthetic = ast::SelectStmt {
11922                result: Expr::Path(rooted.clone()),
11923                filter: None,
11924                order_by: vec![],
11925                offset: None,
11926                limit: None,
11927                lock: None,
11928            };
11929            let mut ps = self.compile_path_select(&synthetic, &rooted, elements, false)?;
11930            Self::correlate_path_select(&mut ps, alias);
11931            let guards: Vec<IrExpr> = branches
11932                .iter()
11933                .map(|earlier| {
11934                    IrExpr::UnaryOp(Box::new(IrUnaryOp {
11935                        op: ast::UnaryOpKind::Not,
11936                        operand: IrExpr::UnaryOp(Box::new(IrUnaryOp {
11937                            op: ast::UnaryOpKind::Exists,
11938                            operand: IrExpr::PathSubquery(Box::new(earlier.clone())),
11939                        })),
11940                    }))
11941                })
11942                .collect();
11943            if !guards.is_empty() {
11944                ps.filter = and_conditions(ps.filter, guards);
11945            }
11946            branches.push(ps);
11947        }
11948        Ok(branches)
11949    }
11950
11951    /// Whether a relative path lands on objects rather than on values — its
11952    /// last named step is a link, a multi-link or a backlink. A coalesce over
11953    /// values is a choice between two sets of scalars, which the ordinary
11954    /// binary-operator path already reads as one.
11955    fn path_lands_on_objects(&self, td: &TypeDescriptor, path: &ast::Path) -> bool {
11956        let Some(last) = path
11957            .steps
11958            .iter()
11959            .rposition(|step| matches!(step, ast::PathStep::Name(_) | ast::PathStep::Backlink(_)))
11960        else {
11961            return false;
11962        };
11963        match &path.steps[last] {
11964            ast::PathStep::Backlink(_) => true,
11965            ast::PathStep::Name(name) => self
11966                .walk_path_types(td, &path.steps[..last], MAX_COMPUTED_SPLICES)
11967                .1
11968                .is_some_and(|owner| {
11969                    Self::resolve_multilink(owner, name).is_some() || Self::resolve_link(owner, name).is_some()
11970                }),
11971            _ => false,
11972        }
11973    }
11974
11975    /// Whether the operands of a union or coalesce of relative paths reach
11976    /// more than one object between them. A leading type intersection roots
11977    /// the walk at the intersected type, as `compile_partial_path_as_subquery`
11978    /// does, so `[is Loop].condition_configs` is read off `Loop`.
11979    fn relative_paths_reach_many(&self, td: &TypeDescriptor, operands: &[ast::Path]) -> bool {
11980        operands.iter().any(|path| {
11981            let (root_td, rest) = match path.steps.first() {
11982                Some(ast::PathStep::TypeIntersection(tr)) => {
11983                    let name = match &tr.module {
11984                        Some(module) => format!("{}::{}", module, tr.name),
11985                        None => tr.name.clone(),
11986                    };
11987                    match self.resolve_type(&name) {
11988                        Ok(resolved) => (resolved, &path.steps[1..]),
11989                        Err(_) => return true,
11990                    }
11991                }
11992                _ => (td, &path.steps[..]),
11993            };
11994            self.path_crosses_multi(root_td, rest)
11995        })
11996    }
11997
11998    /// Walk `steps` from `td` without compiling anything, reporting whether
11999    /// any step is multi-valued and what type the walk ends on. A computed
12000    /// pointer is expanded into the path it stands for — the same splice
12001    /// `compile_path_select` performs — so `.published.title` is recognized
12002    /// as multi-valued when `published` resolves to a multi-link.
12003    ///
12004    /// Gives up (`None` target) rather than guessing on anything it can't
12005    /// resolve; the real compile reports the error.
12006    fn walk_path_types(
12007        &self,
12008        td: &'a TypeDescriptor,
12009        steps: &[ast::PathStep],
12010        depth: usize,
12011    ) -> (bool, Option<&'a TypeDescriptor>) {
12012        let mut current = td;
12013        let mut multi = false;
12014        for (i, step) in steps.iter().enumerate() {
12015            match step {
12016                // `.<link[is Owner]` names the owner type; a bare `.<link`
12017                // spans every type declaring it, so that one lands nowhere in
12018                // particular.
12019                ast::PathStep::Backlink(backlink_name) => {
12020                    let Some(ast::PathStep::TypeIntersection(tr)) = steps.get(i + 1) else {
12021                        return (true, None);
12022                    };
12023                    let owner_name = match &tr.module {
12024                        Some(m) => format!("{}::{}", m, tr.name),
12025                        None => tr.name.clone(),
12026                    };
12027                    let current_qname = format!("{}::{}", current.module, current.name);
12028                    let single = self
12029                        .resolve_type(&owner_name)
12030                        .is_ok_and(|owner| self.backlink_is_single(owner, backlink_name, &current_qname));
12031                    multi |= !single;
12032                    continue;
12033                }
12034                ast::PathStep::TypeIntersection(tr) => {
12035                    let name = match &tr.module {
12036                        Some(m) => format!("{}::{}", m, tr.name),
12037                        None => tr.name.clone(),
12038                    };
12039                    match self.resolve_type(&name) {
12040                        Ok(t) => current = t,
12041                        Err(_) => return (multi, None),
12042                    }
12043                }
12044                ast::PathStep::Name(n) => {
12045                    if let Some(ml) = Self::resolve_multilink(current, n) {
12046                        multi = true;
12047                        match self.resolve_type(&ml.target) {
12048                            Ok(t) => current = t,
12049                            Err(_) => return (multi, None),
12050                        }
12051                        continue;
12052                    }
12053                    if let Some(target) = Self::resolve_link(current, n).map(|l| l.target.clone()) {
12054                        match self.resolve_type(&target) {
12055                            Ok(t) => current = t,
12056                            Err(_) => return (multi, None),
12057                        }
12058                        continue;
12059                    }
12060                    // A computed pointer stands for its own path, or for the
12061                    // object-returning function it calls.
12062                    if depth > 0
12063                        && let Some(cd) = self.resolve_computed(current, n)
12064                        && let Ok(expr) = crate::parse::parse_pointer_expr(&cd.expression)
12065                    {
12066                        if let Some((p, _, modifiers)) = Self::pointer_subject(&expr)
12067                            && p.partial
12068                        {
12069                            let (m, t) = self.walk_path_types(current, &p.steps, depth - 1);
12070                            // `limit 1` of its own caps the computed at one
12071                            // row however many its path crosses.
12072                            let capped = modifiers
12073                                .is_some_and(|m| matches!(&m.limit, Some(Expr::Literal(ast::Literal::Int(1)))));
12074                            multi = multi || (m && !capped);
12075                            match t {
12076                                Some(t) => current = t,
12077                                None => return (multi, None),
12078                            }
12079                            continue;
12080                        }
12081                        if let Some((fc, _)) = Self::function_subject(&expr)
12082                            && let Some(fd) = self.resolve_object_fn(fc)
12083                        {
12084                            multi = multi || fd.return_is_set;
12085                            match self.resolve_type(&fd.return_pg_type) {
12086                                Ok(t) => current = t,
12087                                Err(_) => return (multi, None),
12088                            }
12089                            continue;
12090                        }
12091                    }
12092                    // A property (or something unresolvable): the walk ends.
12093                    return (multi, None);
12094                }
12095                _ => return (multi, None),
12096            }
12097        }
12098        (multi, Some(current))
12099    }
12100
12101    /// Compile a relative path that the step-by-step expression rules can't
12102    /// resolve on their own — deeper than two steps, a computed pointer on a
12103    /// linked type, anything following a backlink — as a single correlated
12104    /// subquery over the whole traversal.
12105    ///
12106    /// `compile_path_select` already implements the general case (forward
12107    /// links, multi-links, backlinks, junction-backed links, type
12108    /// intersections, nested tuple fields, "did you mean" on a typo), so the
12109    /// work here is only to root the path at the enclosing type and
12110    /// correlate it back to the enclosing row by primary key.
12111    ///
12112    /// A leading type intersection is rooted at the *intersected* type
12113    /// instead: `[is Concrete].col` has to read from Concrete's own table,
12114    /// which shares the interface row's id.
12115    fn compile_partial_path_as_subquery(
12116        &mut self,
12117        p: &ast::Path,
12118        td: &TypeDescriptor,
12119        alias: &str,
12120    ) -> Result<IrExpr, PyQLError> {
12121        let (root_name, rest) = match p.steps.first() {
12122            Some(ast::PathStep::TypeIntersection(tr)) => {
12123                let name = match &tr.module {
12124                    Some(m) => format!("{}::{}", m, tr.name),
12125                    None => tr.name.clone(),
12126                };
12127                (name, &p.steps[1..])
12128            }
12129            _ => (format!("{}::{}", td.module, td.name), &p.steps[..]),
12130        };
12131        let root_td = self.resolve_type(&root_name)?;
12132        let multi = self.path_crosses_multi(root_td, rest);
12133
12134        let mut steps = vec![ast::PathStep::Name(root_name)];
12135        steps.extend(rest.iter().cloned());
12136        let full_path = ast::Path { steps, partial: false };
12137        let synthetic = ast::SelectStmt {
12138            result: Expr::Path(full_path.clone()),
12139            filter: None,
12140            order_by: vec![],
12141            offset: None,
12142            limit: None,
12143            lock: None,
12144        };
12145        let mut ps = self.compile_path_select(&synthetic, &full_path, &[], false)?;
12146        Self::correlate_path_select(&mut ps, alias);
12147        if multi && matches!(ps.result, IrPathResult::Scalar(..)) {
12148            Ok(IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(
12149                ps,
12150            )))))
12151        } else {
12152            Ok(IrExpr::PathSubquery(Box::new(ps)))
12153        }
12154    }
12155
12156    /// A sub-select written relative to the enclosing object (`(select
12157    /// .emails filter .primary)`, with or without a shape), as a path select
12158    /// rooted at that object and correlated back to its row.
12159    ///
12160    /// Several places compile a sub-select with no context and so lose the
12161    /// object a relative path hangs off -- it then resolves in free context
12162    /// and reports the pointer as unknown. `None` when the statement is not
12163    /// of that shape, so the caller can carry on as before.
12164    fn relative_subselect(
12165        &mut self,
12166        stmt: &Stmt,
12167        ctx: Option<(&TypeDescriptor, &str)>,
12168    ) -> Result<Option<IrPathSelect>, PyQLError> {
12169        let (Some((td, alias)), Stmt::Select(sel)) = (ctx, stmt) else {
12170            return Ok(None);
12171        };
12172        let (path, shape): (&ast::Path, &[ShapeElement]) = match &sel.result {
12173            Expr::Path(p) if p.partial => (p, &[]),
12174            Expr::Shape(sh) => match sh.expr.as_ref() {
12175                Some(Expr::Path(p)) if p.partial => (p, sh.elements.as_slice()),
12176                _ => return Ok(None),
12177            },
12178            _ => return Ok(None),
12179        };
12180        let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
12181        steps.extend(path.steps.iter().cloned());
12182        let rooted = ast::Path { steps, partial: false };
12183        let synthetic = ast::SelectStmt {
12184            result: Expr::Path(rooted.clone()),
12185            filter: sel.filter.clone(),
12186            order_by: sel.order_by.clone(),
12187            offset: sel.offset.clone(),
12188            limit: sel.limit.clone(),
12189            lock: None,
12190        };
12191        let mut ps = self.compile_path_select(&synthetic, &rooted, shape, false)?;
12192        Self::correlate_path_select(&mut ps, alias);
12193        Ok(Some(ps))
12194    }
12195
12196    /// Tie a path select's root row to the enclosing row by primary key —
12197    /// what makes a relative path's subquery see only the current object's
12198    /// side of the graph.
12199    fn correlate_path_select(ps: &mut IrPathSelect, outer_alias: &str) {
12200        // A trigger's own row may not be in its table yet (`BEFORE INSERT`):
12201        // the walk starts from the row itself.
12202        if outer_alias == "NEW" || outer_alias == "OLD" {
12203            ps.root.table = format!("@row:{outer_alias}");
12204            ps.root.poly = None;
12205        }
12206        let correlation = IrExpr::BinOp(Box::new(IrBinOp {
12207            left: IrExpr::ColumnRef {
12208                alias: ps.root.alias.clone(),
12209                column: "id".to_string(),
12210                pg_type: "uuid".to_string(),
12211            },
12212            op: ast::BinOpKind::Eq,
12213            right: IrExpr::ColumnRef {
12214                alias: outer_alias.to_string(),
12215                column: "id".to_string(),
12216                pg_type: "uuid".to_string(),
12217            },
12218        }));
12219        ps.filter = Some(match ps.filter.take() {
12220            Some(existing) => IrExpr::BinOp(Box::new(IrBinOp {
12221                left: correlation,
12222                op: ast::BinOpKind::And,
12223                right: existing,
12224            })),
12225            None => correlation,
12226        });
12227    }
12228
12229    /// Names the kind of sub-statement that can't stand in for a value, so
12230    /// the message points at the actual blocker instead of listing every
12231    /// statement keyword.
12232    fn subquery_expr_err(&self, stmt: &Stmt) -> PyQLError {
12233        let what = match stmt {
12234            Stmt::Insert(_) => "an insert",
12235            Stmt::Update(_) => "an update",
12236            Stmt::Delete(_) => "a delete",
12237            Stmt::With(_) => "a `with` block",
12238            Stmt::For(_) => "a `for` loop",
12239            Stmt::Group(_) => "a `group`",
12240            _ => "this sub-statement",
12241        };
12242        self.type_err(&format!(
12243            "{what} cannot stand in for a value — a sub-statement is only valid in expression \
12244             position as a select over a path, e.g. '(select .emails filter .primary limit 1).address'"
12245        ))
12246    }
12247
12248    // ── Expression compilation ────────────────────────────────────────────────────
12249
12250    /// The operands an aggregate reads when its argument is spelled as a set:
12251    /// the elements of a set literal, or the flattened arms of a `union` chain.
12252    /// `min({a, b})` and `min((a union b))` mean the same thing and both read
12253    /// two values, which is the shape `AggOverSet` wants either way. `None` for
12254    /// anything else, leaving the ordinary single-value routes to it.
12255    fn set_operands(expr: &Expr) -> Option<Vec<&Expr>> {
12256        match expr {
12257            // An empty set has no operands to aggregate over, and the row
12258            // source it would emit is not valid SQL -- leave it to the arms
12259            // that give `{}` its own meaning.
12260            Expr::Set(elems) if elems.is_empty() => None,
12261            Expr::Set(elems) => Some(elems.iter().collect()),
12262            Expr::Union(left, right) => {
12263                let mut operands = Self::set_operands(left).unwrap_or_else(|| vec![left.as_ref()]);
12264                operands.extend(Self::set_operands(right).unwrap_or_else(|| vec![right.as_ref()]));
12265                Some(operands)
12266            }
12267            _ => None,
12268        }
12269    }
12270
12271    /// Run `f` with `value_position` set — the expression it compiles stands
12272    /// where a value is expected, so a set-valued `in` keeps its one answer
12273    /// per element instead of collapsing to an EXISTS.
12274    fn in_value_position<R>(&mut self, f: impl FnOnce(&mut Self) -> R) -> R {
12275        let previous = std::mem::replace(&mut self.value_position, true);
12276        let result = f(self);
12277        self.value_position = previous;
12278        result
12279    }
12280
12281    /// Run `f` with `value_position` cleared — a FILTER, or any other place
12282    /// that wants a single boolean out of the whole set. A computed pointer's
12283    /// expression may carry a sub-select with a filter of its own, so the flag
12284    /// has to be taken back off on the way down, not just put on at the top.
12285    fn as_condition<R>(&mut self, f: impl FnOnce(&mut Self) -> R) -> R {
12286        let previous = std::mem::replace(&mut self.value_position, false);
12287        let result = f(self);
12288        self.value_position = previous;
12289        result
12290    }
12291
12292    /// Single expression compiler for both free and schema-bound contexts.
12293    /// `ctx = Some((td, alias))` when a schema type + SQL alias are in scope
12294    /// (enables `.property`/`.link` resolution via `compile_path`); `ctx =
12295    /// None` for free expressions (set literals, tuples, free objects,
12296    /// scalar function calls not touching a table), via `compile_free_path`
12297    /// for the `Path` variant. Most arms are identical either way and just
12298    /// thread `ctx` through recursive calls; the handful that genuinely
12299    /// diverge (`Path`, `BinOp`, `FunctionCall`, `Set`, `Detached`, `TypeIs`)
12300    /// branch internally on `ctx` — see each arm's own comment.
12301    fn compile_expr_ctx(&mut self, expr: &Expr, ctx: Option<(&TypeDescriptor, &str)>) -> Result<IrExpr, PyQLError> {
12302        match expr {
12303            // The main free/schema divergence — kept genuinely two-branched
12304            // (`compile_path` needs a schema type + alias throughout:
12305            // __type__, absolute-path rewrite, 2-step link/property
12306            // traversal, computed-pointer recursion; `compile_free_path`
12307            // only ever resolves for-vars/CTEs/fn-params/enum members).
12308            // Both share the bare-name lookup via `resolve_name_ref`.
12309            Expr::Path(p) => match ctx {
12310                Some((td, alias)) => self.compile_path(p, td, alias),
12311                None => self.compile_free_path(p),
12312            },
12313
12314            // `(select … limit 1).connector.provider[is Individual].staff` — a
12315            // walk off a sub-select including a step with no expression form.
12316            Expr::PathStepOn { .. } => match self.try_compile_walk_off_subquery(expr, ctx)? {
12317                Some(ir) => Ok(ir),
12318                None => Err(self.type_err(
12319                    "a type intersection, link property or backlink needs a path, a binding \
12320                     or a sub-select to walk off",
12321                )),
12322            },
12323
12324            Expr::Literal(lit) => Ok(IrExpr::Literal(match lit {
12325                Literal::Str(s) => IrLiteral::Str(s.clone()),
12326                Literal::Int(n) => IrLiteral::Int(*n),
12327                Literal::Float(f) => IrLiteral::Float(*f),
12328                Literal::Bool(b) => IrLiteral::Bool(*b),
12329            })),
12330
12331            Expr::Parameter(name) => {
12332                let index = self.param_index(name);
12333                Ok(IrExpr::Param { index })
12334            }
12335
12336            Expr::Global(name) => self.compile_global(name),
12337
12338            Expr::Index { expr: e, index: i } => {
12339                let ir_expr = self.compile_expr_ctx(e, ctx)?;
12340                if self.is_json_expr(e, &ir_expr) {
12341                    return match &**i {
12342                        Expr::Literal(Literal::Str(key)) => Ok(IrExpr::JsonbField {
12343                            expr: Box::new(ir_expr),
12344                            field: key.clone(),
12345                        }),
12346                        Expr::Literal(Literal::Int(n)) if *n >= 0 => Ok(IrExpr::JsonbIndex {
12347                            expr: Box::new(ir_expr),
12348                            index: *n as usize,
12349                        }),
12350                        _ => Err(self.type_err("indexing json needs a literal key or a literal position")),
12351                    };
12352                }
12353                let ir_index = self.compile_expr_ctx(i, ctx)?;
12354                let is_array = is_array_expr(&ir_expr);
12355                Ok(IrExpr::Subscript {
12356                    expr: Box::new(ir_expr),
12357                    index: Box::new(ir_index),
12358                    is_array,
12359                })
12360            }
12361
12362            Expr::Slice {
12363                expr: e,
12364                lower: lo,
12365                upper: hi,
12366            } => {
12367                let ir_expr = self.compile_expr_ctx(e, ctx)?;
12368                let is_array = is_array_expr(&ir_expr);
12369                let ir_lower = lo.as_ref().map(|x| self.compile_expr_ctx(x, ctx)).transpose()?;
12370                let ir_upper = hi.as_ref().map(|x| self.compile_expr_ctx(x, ctx)).transpose()?;
12371                Ok(IrExpr::Slice {
12372                    expr: Box::new(ir_expr),
12373                    lower: ir_lower.map(Box::new),
12374                    upper: ir_upper.map(Box::new),
12375                    is_array,
12376                })
12377            }
12378
12379            Expr::TypeCast(tc) => {
12380                // `<AnyType>{}` — an empty set cast to any type, e.g. clearing
12381                // an optional link (`<Company>{}`) — is always just NULL,
12382                // regardless of what pg_type the cast target would otherwise
12383                // resolve to (a schema object type name isn't a scalar cast
12384                // target at all, so resolve_cast_pg_type couldn't handle it
12385                // below anyway). Generalizes the same bare-`{}`-in-assignment-
12386                // position special case in compile_assignments_inner to any
12387                // expression context.
12388                if matches!(&tc.expr, Expr::Set(elems) if elems.is_empty()) {
12389                    return Ok(IrExpr::Null);
12390                }
12391                if let ast::TypeExpr::Tuple { elements } = &tc.ty
12392                    && let Some(ir) = self.try_compile_tuple_literal_cast_ctx(elements, &tc.expr, ctx)?
12393                {
12394                    let pg_type = self.resolve_cast_pg_type(&tc.ty)?;
12395                    let tuple_shape = self.resolve_tuple_cast_shape(&tc.ty);
12396                    return Ok(IrExpr::TypeCast(Box::new(IrTypeCast {
12397                        expr: ir,
12398                        pg_type,
12399                        tuple_shape,
12400                    })));
12401                }
12402                if let ast::TypeExpr::Array { element } = &tc.ty
12403                    && let Some(ir) = self.try_compile_array_literal_cast_ctx(element, &tc.expr, ctx)?
12404                {
12405                    let pg_type = self.resolve_cast_pg_type(&tc.ty)?;
12406                    return Ok(IrExpr::TypeCast(Box::new(IrTypeCast {
12407                        expr: ir,
12408                        pg_type,
12409                        tuple_shape: None,
12410                    })));
12411                }
12412                // `<marketplace::BrandAddon>line.listing.id` — casting a key to
12413                // an object type names the row that key identifies, and in
12414                // expression position (a link's value, a comparison) the row
12415                // *is* its key. There is no scalar pg type to cast to, so
12416                // `resolve_cast_pg_type` below reports the object type as
12417                // unknown.
12418                const STDLIB_MODULES: &[&str] = &["std", "cal", "math", "sys", "pgvector", "crypto", "postgis"];
12419                if let Some((module, name)) = tc.ty.as_named()
12420                    && module.map(|m| !STDLIB_MODULES.contains(&m)).unwrap_or(false)
12421                {
12422                    let qname = match module {
12423                        Some(m) => format!("{m}::{name}"),
12424                        None => name.to_string(),
12425                    };
12426                    if self.resolve_enum(&qname).is_none()
12427                        && self.resolve_scalar(&qname).is_none()
12428                        && self.resolve_named_tuple(&qname).is_none()
12429                        && self.resolve_type(&qname).is_ok()
12430                    {
12431                        return self.compile_expr_ctx(&tc.expr, ctx);
12432                    }
12433                }
12434                // A cast to `json` is an output sink: the text it produces is
12435                // the value, so an `id` nobody asked for would show up in it.
12436                // So the flag is cleared for the whole cast.
12437                let inner = if casts_to_json(&tc.ty) {
12438                    self.without_implicit_id(|this| this.compile_expr_ctx(&tc.expr, ctx))?
12439                } else {
12440                    self.compile_expr_ctx(&tc.expr, ctx)?
12441                };
12442                let pg_type = self.resolve_cast_pg_type(&tc.ty)?;
12443
12444                // PostgreSQL has no native jsonb -> {uuid, date/time family,
12445                // interval, array<T>} cast (only jsonb -> {bool, numeric
12446                // family, text} are native as of PG17+) — there's nothing
12447                // generic to defer to the way `to_jsonb(x)` covers every
12448                // scalar in the opposite direction, so extract via `#>>'{}'`
12449                // (the value's raw text form) and cast that, matching what
12450                // `to_json`'s own emitted text already round-trips (ISO 8601
12451                // for datetimes, PG's native interval text, a bare UUID
12452                // string — see `to_jsonb`'s emission for `<json>x`).
12453                if infer_ir_type(&inner) == Some("jsonb") {
12454                    if let ast::TypeExpr::Array { element } = &tc.ty {
12455                        let elem_pg = self.resolve_cast_pg_type(element)?;
12456                        let sql_template =
12457                            format!("ARRAY(SELECT (elem #>> '{{}}')::{elem_pg} FROM jsonb_array_elements($1) AS elem)");
12458                        return Ok(IrExpr::FunctionCall(super::IrFunctionCall {
12459                            return_pg_type: None,
12460                            schema: None,
12461                            name: "jsonb_array_cast".to_string(),
12462                            args: vec![inner],
12463                            sql_template: Some(sql_template),
12464                        }));
12465                    }
12466                    if matches!(
12467                        pg_type.as_str(),
12468                        "uuid" | "timestamptz" | "timestamp" | "date" | "time" | "interval"
12469                    ) {
12470                        let sql_template = format!("(($1 #>> '{{}}'))::{pg_type}");
12471                        return Ok(IrExpr::FunctionCall(super::IrFunctionCall {
12472                            return_pg_type: None,
12473                            schema: None,
12474                            name: "jsonb_scalar_cast".to_string(),
12475                            args: vec![inner],
12476                            sql_template: Some(sql_template),
12477                        }));
12478                    }
12479                }
12480
12481                // The mirror of the `<str>x` routing below: every
12482                // str → date/time cast goes through a function, and those
12483                // are stricter than PostgreSQL's own input parsers, which
12484                // accept `01/16/2026`, a zone-less datetime, or `1 month` as
12485                // a duration. Keyed on the type as written, because
12486                // `duration`, `cal::relative_duration` and
12487                // `cal::date_duration` are all `interval` and each takes a
12488                // different set of units.
12489                if infer_ir_type(&inner) == Some("text")
12490                    && let Some((module, name)) = tc.ty.as_named()
12491                {
12492                    let parser = match (module, name) {
12493                        (None | Some("std"), "datetime") => Some(("std", "to_datetime")),
12494                        (Some("cal"), "local_datetime") => Some(("cal", "to_local_datetime")),
12495                        (Some("cal"), "local_date") => Some(("cal", "to_local_date")),
12496                        (Some("cal"), "local_time") => Some(("cal", "to_local_time")),
12497                        _ => None,
12498                    };
12499                    if let Some((ns, fn_name)) = parser {
12500                        return self.resolve_fn_call(Some(ns), fn_name, vec![inner]);
12501                    }
12502                    // Neither is reachable as a function, only as a cast, so
12503                    // they are internal helpers rather than stdlib
12504                    // overloads (which would also be indistinguishable, both
12505                    // taking one `interval`).
12506                    let helper = match (module, name) {
12507                        (None | Some("std"), "duration") => Some("duration_in"),
12508                        (Some("cal"), "date_duration") => Some("date_duration_in"),
12509                        _ => None,
12510                    };
12511                    if let Some(helper) = helper {
12512                        return Ok(IrExpr::FunctionCall(super::IrFunctionCall {
12513                            return_pg_type: Some("interval".to_string()),
12514                            schema: Some("_pylon".to_string()),
12515                            name: helper.to_string(),
12516                            args: vec![inner],
12517                            sql_template: None,
12518                        }));
12519                    }
12520                }
12521
12522                // `<str>` of a date/time value is `to_str` of it, so that the
12523                // cast and the function cannot disagree. `::text`
12524                // gives a different answer for the same value — a datetime
12525                // renders as `2026-01-16 12:34:56+00` rather than ISO 8601 —
12526                // and the two spellings must not disagree. The rest of the
12527                // family is routed too, where `to_str` *is* `::text`, so the
12528                // rule is about the types rather than about which
12529                // implementations happen to differ today.
12530                if pg_type == "text"
12531                    && matches!(
12532                        infer_ir_type(&inner),
12533                        Some("timestamptz" | "timestamp" | "date" | "time" | "interval")
12534                    )
12535                {
12536                    return self.resolve_fn_call(Some("std"), "to_str", vec![inner]);
12537                }
12538
12539                // PostgreSQL casts a boolean to `int4` and to nothing else, so
12540                // the narrower and wider integers go through it.
12541                let inner = match (pg_type.as_str(), infer_ir_type(&inner)) {
12542                    ("int2" | "int8", Some("boolean")) => IrExpr::TypeCast(Box::new(IrTypeCast {
12543                        expr: inner,
12544                        pg_type: "int4".to_string(),
12545                        tuple_shape: None,
12546                    })),
12547                    _ => inner,
12548                };
12549
12550                let tuple_shape = self.resolve_tuple_cast_shape(&tc.ty);
12551                Ok(IrExpr::TypeCast(Box::new(IrTypeCast {
12552                    expr: inner,
12553                    pg_type,
12554                    tuple_shape,
12555                })))
12556            }
12557
12558            // The same coalesce with no shape after it — `(… ?? …)` standing
12559            // for the ids of the set it reaches. SQL's own COALESCE, which an
12560            // ordinary binary operator compiles to, takes one value per side,
12561            // so a walk reaching many rows has to be read as a set instead.
12562            Expr::BinOp(b)
12563                if b.op == ast::BinOpKind::Coalesce
12564                    && ctx.is_some()
12565                    && Self::coalesce_of_relative_paths(expr).is_some_and(|operands| {
12566                        let td = ctx.expect("checked").0;
12567                        self.relative_paths_reach_many(td, &operands)
12568                            && operands.iter().all(|path| self.path_lands_on_objects(td, path))
12569                    }) =>
12570            {
12571                let (td, alias) = ctx.expect("checked by the guard");
12572                let operands = Self::coalesce_of_relative_paths(expr).expect("checked by the guard");
12573                let branches = self.coalesce_path_branches(td, alias, &operands, &[])?;
12574                Ok(IrExpr::ObjectPathUnion {
12575                    branches,
12576                    limit: None,
12577                    multi: true,
12578                })
12579            }
12580
12581            Expr::BinOp(b) => {
12582                // Only a condition can become an EXISTS — `.<pins.updated_at ??
12583                // .<pins.created_at` is a set of values, not a test on each pin.
12584                let yields_values = matches!(
12585                    b.op,
12586                    ast::BinOpKind::Add
12587                        | ast::BinOpKind::Sub
12588                        | ast::BinOpKind::Mul
12589                        | ast::BinOpKind::Div
12590                        | ast::BinOpKind::FloorDiv
12591                        | ast::BinOpKind::Mod
12592                        | ast::BinOpKind::Pow
12593                        | ast::BinOpKind::Coalesce
12594                        | ast::BinOpKind::Concat
12595                );
12596                // `addons := .required_addons in licensed_addons` — `in` is
12597                // asked once per element of the set on its left, so where a
12598                // value is wanted it answers with a set of booleans, not with
12599                // the single "any of them" the EXISTS below would give. That
12600                // one is still what a FILTER means, and `all(…)`/`any(…)` are
12601                // how a query asks for it here.
12602                if let Some(elementwise) = self.try_elementwise_in(b, ctx)? {
12603                    return Ok(elementwise);
12604                }
12605                if let Some((td, alias)) = ctx
12606                    && !yields_values
12607                {
12608                    if let Some(exists) = self.try_backlink_exists(b, td, alias)? {
12609                        return Ok(exists);
12610                    }
12611                    if let Some(exists) = self.try_multilink_exists(b, td, alias)? {
12612                        return Ok(exists);
12613                    }
12614                }
12615                // `x in {a, b, c}` / `x not in {a, b, c}`: a set *literal*
12616                // specifically on the right of in/not-in compiles to a
12617                // Postgres array, not through the generic Set-literal path
12618                // below (which hard-errors on any bare set literal in
12619                // expression position) — In/NotIn's own SQL emission
12620                // (`= ANY(...)`/`<> ALL(...)`, sql/mod.rs) already expects
12621                // an array-typed right operand, so this is the one
12622                // expression position a set literal is actually meaningful
12623                // in, in either schema-bound or free context.
12624                if matches!(b.op, ast::BinOpKind::In | ast::BinOpKind::NotIn)
12625                    && let Expr::Set(elems) = &b.right
12626                {
12627                    let left = self.compile_expr_ctx(&b.left, ctx)?;
12628                    let items = elems
12629                        .iter()
12630                        .map(|e| self.compile_expr_ctx(e, ctx))
12631                        .collect::<Result<Vec<_>, _>>()?;
12632                    let right = IrExpr::Array(items);
12633                    return Ok(IrExpr::BinOp(Box::new(IrBinOp {
12634                        left,
12635                        op: b.op.clone(),
12636                        right,
12637                    })));
12638                }
12639                // `.account = account_of_transaction()` — comparing objects
12640                // compares identity, so an object-returning function on either
12641                // side stands for the id of the row it returns. Left whole it
12642                // is a function call with nowhere to go but the subject of a
12643                // select.
12644                if matches!(b.op, ast::BinOpKind::Eq | ast::BinOpKind::Ne) {
12645                    let id = ["id".to_string()];
12646                    let left_id = match &b.left {
12647                        Expr::FunctionCall(fc) => self.try_compile_fn_scalar_subquery(fc, &id, None, ctx)?,
12648                        _ => None,
12649                    };
12650                    let right_id = match &b.right {
12651                        Expr::FunctionCall(fc) => self.try_compile_fn_scalar_subquery(fc, &id, None, ctx)?,
12652                        _ => None,
12653                    };
12654                    if left_id.is_some() || right_id.is_some() {
12655                        let left = match left_id {
12656                            Some(e) => e,
12657                            None => self.compile_expr_ctx(&b.left, ctx)?,
12658                        };
12659                        let right = match right_id {
12660                            Some(e) => e,
12661                            None => self.compile_expr_ctx(&b.right, ctx)?,
12662                        };
12663                        return Ok(IrExpr::BinOp(Box::new(IrBinOp {
12664                            left,
12665                            op: b.op.clone(),
12666                            right,
12667                        })));
12668                    }
12669                }
12670                let mut left = self.compile_expr_ctx(&b.left, ctx)?;
12671                let mut right = self.compile_expr_ctx(&b.right, ctx)?;
12672                // An ordering comparison takes one value a side, so a walk
12673                // gathered as an array is read back as a scalar subquery — see
12674                // `set_walk_as_scalar`.
12675                if matches!(
12676                    b.op,
12677                    ast::BinOpKind::Lt | ast::BinOpKind::Le | ast::BinOpKind::Gt | ast::BinOpKind::Ge
12678                ) && yields_array(&left) != yields_array(&right)
12679                {
12680                    left = set_walk_as_scalar(left);
12681                    right = set_walk_as_scalar(right);
12682                }
12683                // `A ?? B` over sets is A unless A is empty. An empty set
12684                // gathered as an array is `{}`, not NULL, so `COALESCE` would
12685                // never fall through to B.
12686                if b.op == ast::BinOpKind::Coalesce && (yields_array(&left) || yields_array(&right)) {
12687                    let as_set = |value: IrExpr| {
12688                        if yields_array(&value) {
12689                            value
12690                        } else {
12691                            IrExpr::FunctionCall(IrFunctionCall {
12692                                return_pg_type: None,
12693                                schema: None,
12694                                name: "array_remove".to_string(),
12695                                args: vec![value],
12696                                sql_template: Some("array_remove(ARRAY[$1], NULL)".to_string()),
12697                            })
12698                        }
12699                    };
12700                    let left = as_set(left);
12701                    let condition = IrExpr::FunctionCall(IrFunctionCall {
12702                        return_pg_type: None,
12703                        schema: None,
12704                        name: "cardinality".to_string(),
12705                        args: vec![left.clone()],
12706                        sql_template: Some("(cardinality($1) > 0)".to_string()),
12707                    });
12708                    return Ok(IrExpr::IfElse(Box::new(IrIfElse {
12709                        condition,
12710                        if_: left,
12711                        else_: as_set(right),
12712                    })));
12713                }
12714                // Comparing a value against a *set* — a path that crosses a
12715                // multi-link or a backlink, which arrives here as an array of
12716                // its elements — holds when any element matches, which is what
12717                // the equality means in PyQL and what `= ANY` says in SQL.
12718                if matches!(b.op, ast::BinOpKind::Eq | ast::BinOpKind::Ne) {
12719                    // A `with` binding of more than one row is the same kind
12720                    // of set, reached through its CTE rather than an array.
12721                    let is_set = |e: &IrExpr| match e {
12722                        IrExpr::ArrayFromSelect(_) => true,
12723                        IrExpr::CteRef { name, .. } => self.multi_row_ctes.contains(name),
12724                        _ => false,
12725                    };
12726                    let flipped = is_set(&left) && !is_set(&right) && !is_array_expr(&right);
12727                    let straight = is_set(&right) && !is_set(&left) && !is_array_expr(&left);
12728                    if flipped || straight {
12729                        let (value, set) = if straight { (left, right) } else { (right, left) };
12730                        let membership = IrExpr::BinOp(Box::new(IrBinOp {
12731                            left: value,
12732                            op: ast::BinOpKind::In,
12733                            right: set,
12734                        }));
12735                        return Ok(if matches!(b.op, ast::BinOpKind::Ne) {
12736                            IrExpr::UnaryOp(Box::new(IrUnaryOp {
12737                                op: ast::UnaryOpKind::Not,
12738                                operand: membership,
12739                            }))
12740                        } else {
12741                            membership
12742                        });
12743                    }
12744                }
12745                if let (Some(lt), Some(rt)) = (infer_ir_type(&left), infer_ir_type(&right))
12746                    && !types_compatible(lt, rt)
12747                    && !datetime_arithmetic_compatible(&b.op, lt, rt)
12748                {
12749                    return Err(PyQLError::Type(PyQLTypeError {
12750                        message: format!(
12751                            "operator '{op}' cannot be applied to operands of type \
12752                                 '{lq}' and '{rq}'",
12753                            op = b.op,
12754                            lq = pg_type_to_pyql(lt),
12755                            rq = pg_type_to_pyql(rt),
12756                        ),
12757                        position: Position { line: 0, col: 0 },
12758                    }));
12759                }
12760                Ok(IrExpr::BinOp(Box::new(IrBinOp {
12761                    left: true_division_operand(&b.op, left, &right),
12762                    op: b.op.clone(),
12763                    right,
12764                })))
12765            }
12766
12767            Expr::FunctionCall(f) => {
12768                // notify(Channel, payload) / notify_raw(name, payload) → pg_notify(...).
12769                // Works in both free and schema-bound context (unlike sequence_next
12770                // below) since a trigger handler's payload needs __new__/__old__,
12771                // which only ever resolves schema-bound.
12772                if (f.module.is_none() || f.module.as_deref() == Some("std")) && f.name == "notify" {
12773                    return self.compile_notify(f, ctx);
12774                }
12775                if (f.module.is_none() || f.module.as_deref() == Some("std")) && f.name == "notify_raw" {
12776                    return self.compile_notify_raw(f, ctx);
12777                }
12778
12779                // sequence_next / sequence_reset: type-ref arg → nextval/setval SQL
12780                // (free-only: schema-bound context never special-cased this).
12781                if ctx.is_none()
12782                    && (f.module.is_none() || f.module.as_deref() == Some("std"))
12783                    && (f.name == "sequence_next" || f.name == "sequence_reset")
12784                {
12785                    return self.compile_sequence_fn(f);
12786                }
12787
12788                // assert_single(subquery) [free-only] / assert_single|assert_exists|
12789                // assert_distinct(subquery) [schema-bound] → _pylon.<fn>(ARRAY(subquery)).
12790                // Preserves the existing asymmetry: free context only ever recognized
12791                // "assert_single" here, not the other two.
12792                let assert_names: &[&str] = if ctx.is_some() {
12793                    &["assert_single", "assert_exists", "assert_distinct"]
12794                } else {
12795                    &["assert_single"]
12796                };
12797                if (f.module.is_none() || f.module.as_deref() == Some("std"))
12798                    && assert_names.contains(&f.name.as_str())
12799                    && !f.args.is_empty()
12800                    && let Expr::SubQuery(inner_stmt) = &f.args[0]
12801                {
12802                    let inner = match self.relative_subselect(inner_stmt, ctx)? {
12803                        Some(ps) => IrArraySource::PathSelect(Box::new(ps)),
12804                        None => self.compile_subquery_to_array_source(inner_stmt)?,
12805                    };
12806                    let fn_pg = match f.name.as_str() {
12807                        "assert_single" => "assert_single",
12808                        "assert_exists" => "assert_exists",
12809                        _ => "assert_distinct",
12810                    };
12811                    let mut args = vec![IrExpr::ArrayFromSelect(Box::new(inner))];
12812                    args.extend(self.assert_message(f, ctx)?);
12813                    return Ok(IrExpr::FunctionCall(IrFunctionCall {
12814                        return_pg_type: None,
12815                        schema: Some("_pylon".to_string()),
12816                        name: fn_pg.to_string(),
12817                        args,
12818                        sql_template: None,
12819                    }));
12820                }
12821
12822                // contains(.multilink.scalar, value) → EXISTS (set-membership
12823                // semantics; schema-bound only, needs td/alias to resolve the
12824                // multilink).
12825                if let Some((td, alias)) = ctx
12826                    && (f.module.is_none() || f.module.as_deref() == Some("std"))
12827                    && f.name == "contains"
12828                    && f.args.len() == 2
12829                    && let Expr::Path(p) = &f.args[0]
12830                    && p.partial
12831                    && p.steps.len() >= 2
12832                    && let ast::PathStep::Name(ln) = &p.steps[0]
12833                    && Self::resolve_multilink(td, ln).is_some()
12834                {
12835                    let synthetic = ast::BinOp {
12836                        left: f.args[0].clone(),
12837                        op: ast::BinOpKind::Eq,
12838                        right: f.args[1].clone(),
12839                    };
12840                    if let Some(exists) = self.try_multilink_exists(&synthetic, td, alias)? {
12841                        return Ok(exists);
12842                    }
12843                }
12844
12845                // Single-arg aggregate over a schema-shaped source →
12846                // AggOverQuery. Schema-bound: count(.multilink) correlates via
12847                // the junction/FK table (`.multilink` isn't an ordinary scalar
12848                // path). Free: count(TypeName) / count((select TypeName ...))
12849                // resolves the arg as a schema type reference or subquery.
12850                if f.args.len() == 1
12851                    && f.kwargs.is_empty()
12852                    && let Some(mut ps) = self.compile_shape_field_select(&f.args[0], ctx)?
12853                {
12854                    let IrPathResult::Scalar(column, _) = ps.result else {
12855                        return Err(self.type_err("a shape's pointer read off a walk is a value"));
12856                    };
12857                    let ns = f.module.as_deref().unwrap_or("std");
12858                    let aggregate =
12859                        crate::stdlib::lookup(ns, &f.name)
12860                            .into_iter()
12861                            .find_map(|d| match &d.impl_strategy {
12862                                crate::stdlib::ImplStrategy::SqlBuiltin(sql_name) if d.is_aggregate() => {
12863                                    Some(sql_name.to_string())
12864                                }
12865                                _ => None,
12866                            });
12867                    let Some(sql_name) = aggregate else {
12868                        return Err(self.type_err(&format!(
12869                            "'{}' over a shape's pointer read off a walk needs an aggregate",
12870                            f.name
12871                        )));
12872                    };
12873                    // Over no rows SQL's aggregates give NULL where PyQL's
12874                    // give the empty set's own value.
12875                    let over_nothing = aggregate_over_nothing_sql(&f.name);
12876                    // An unnested set cannot sit inside the aggregate call;
12877                    // the walk's rows are aggregated from outside instead.
12878                    let subquery = if matches!(&column, IrExpr::FunctionCall(f) if f.name == "unnest" && f.sql_template.is_none())
12879                    {
12880                        ps.result = IrPathResult::Scalar(column, None);
12881                        IrExpr::FunctionCall(IrFunctionCall {
12882                            return_pg_type: None,
12883                            schema: None,
12884                            name: sql_name.clone(),
12885                            args: vec![IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(
12886                                ps,
12887                            ))))],
12888                            sql_template: Some(format!(
12889                                "(SELECT {sql_name}(\"_s\".\"v\") FROM unnest($1) AS \"_s\"(\"v\"))"
12890                            )),
12891                        })
12892                    } else {
12893                        let aggregate = IrExpr::FunctionCall(IrFunctionCall {
12894                            return_pg_type: None,
12895                            schema: None,
12896                            name: sql_name,
12897                            args: vec![column],
12898                            sql_template: None,
12899                        });
12900                        ps.result = IrPathResult::Scalar(aggregate, None);
12901                        IrExpr::PathSubquery(Box::new(ps))
12902                    };
12903                    return Ok(match over_nothing {
12904                        Some(value) => IrExpr::FunctionCall(IrFunctionCall {
12905                            return_pg_type: None,
12906                            schema: None,
12907                            name: "coalesce".to_string(),
12908                            args: vec![subquery, IrExpr::RawSql(value.to_string())],
12909                            sql_template: None,
12910                        }),
12911                        None => subquery,
12912                    });
12913                }
12914                if f.args.len() == 1 {
12915                    let arg = &f.args[0];
12916                    // `count(a intersect b)` counts what the operation yields.
12917                    // Read as an array it would count the array: always one.
12918                    if matches!(arg, Expr::Intersect(_, _) | Expr::Except(_, _)) {
12919                        use crate::stdlib::{ImplStrategy, lookup};
12920                        let ns = f.module.as_deref().unwrap_or("std");
12921                        let best = lookup(ns, &f.name)
12922                            .iter()
12923                            .find(|d| d.params.len() == 1)
12924                            .map(|d| d.impl_strategy.clone());
12925                        if let Some(ImplStrategy::SqlBuiltin(sql_name)) = best
12926                            && let IrExpr::SetOp { op, left, right, .. } = self.compile_expr_ctx(arg, ctx)?
12927                        {
12928                            return Ok(IrExpr::SetOp {
12929                                op,
12930                                left,
12931                                right,
12932                                mode: super::SetOpMode::Aggregate(sql_name.to_string()),
12933                            });
12934                        }
12935                    }
12936                    // `array_agg(array_unpack(.permissions))` — the elements
12937                    // of one row's array. PostgreSQL rejects a set-returning
12938                    // call inside an aggregate, so the unpacking becomes the
12939                    // row source the aggregate reads.
12940                    if let Expr::FunctionCall(unpack) = arg
12941                        && unpack.module.as_deref().unwrap_or("std") == "std"
12942                        && unpack.name == "array_unpack"
12943                        && unpack.kwargs.is_empty()
12944                        && let [array] = unpack.args.as_slice()
12945                        && let Some(sql_name) = crate::stdlib::lookup(f.module.as_deref().unwrap_or("std"), &f.name)
12946                            .into_iter()
12947                            .find_map(|d| match &d.impl_strategy {
12948                                crate::stdlib::ImplStrategy::SqlBuiltin(sql_name) if d.is_aggregate() => {
12949                                    Some(sql_name.to_string())
12950                                }
12951                                _ => None,
12952                            })
12953                    {
12954                        let values = self.compile_expr_ctx(array, ctx)?;
12955                        // A walk gathered as an array holds one array per row,
12956                        // which `unnest` would only flatten one level; that
12957                        // shape is aggregated where the walk is built instead.
12958                        if !yields_array(&values) {
12959                            let over_nothing = aggregate_over_nothing_sql(&f.name).unwrap_or("NULL");
12960                            return Ok(IrExpr::FunctionCall(IrFunctionCall {
12961                                return_pg_type: None,
12962                                schema: None,
12963                                name: sql_name.clone(),
12964                                args: vec![values],
12965                                sql_template: Some(format!(
12966                                    "(SELECT coalesce({sql_name}(\"_s\".\"v\"), {over_nothing}) FROM unnest($1) AS \"_s\"(\"v\"))"
12967                                )),
12968                            }));
12969                        }
12970                    }
12971                    // `sum((select T filter …).amount)` — the sub-select's
12972                    // values arrive gathered as an array; the aggregate
12973                    // takes its elements.
12974                    if matches!(arg, Expr::FieldAccess { .. })
12975                        && matches!(Self::peel_field_access_chain(arg).0, Expr::SubQuery(_))
12976                    {
12977                        let ns = f.module.as_deref().unwrap_or("std");
12978                        let aggregate =
12979                            crate::stdlib::lookup(ns, &f.name)
12980                                .into_iter()
12981                                .find_map(|d| match &d.impl_strategy {
12982                                    crate::stdlib::ImplStrategy::SqlBuiltin(sql_name) if d.is_aggregate() => {
12983                                        Some(sql_name.to_string())
12984                                    }
12985                                    _ => None,
12986                                });
12987                        if let Some(sql_name) = aggregate {
12988                            let values = self.compile_expr_ctx(arg, ctx)?;
12989                            if matches!(values, IrExpr::ArrayFromSelect(_)) {
12990                                let over_nothing = aggregate_over_nothing_sql(&f.name).unwrap_or("NULL");
12991                                return Ok(IrExpr::FunctionCall(IrFunctionCall {
12992                                    return_pg_type: None,
12993                                    schema: None,
12994                                    name: sql_name.clone(),
12995                                    args: vec![values],
12996                                    sql_template: Some(format!(
12997                                        "(SELECT coalesce({sql_name}(\"_s\".\"v\"), {over_nothing}) FROM unnest($1) AS \"_s\"(\"v\"))"
12998                                    )),
12999                                }));
13000                            }
13001                            return Ok(IrExpr::FunctionCall(IrFunctionCall {
13002                                return_pg_type: None,
13003                                schema: None,
13004                                name: sql_name,
13005                                args: vec![values],
13006                                sql_template: None,
13007                            }));
13008                        }
13009                    }
13010                    // `max(.<pins.updated_at ?? .<pins.created_at)` — the
13011                    // coalesce picks one whole set, gathered as an array, and
13012                    // the aggregate takes its elements.
13013                    if let Expr::BinOp(b) = arg
13014                        && b.op == ast::BinOpKind::Coalesce
13015                    {
13016                        let values = self.compile_expr_ctx(arg, ctx)?;
13017                        let ns = f.module.as_deref().unwrap_or("std");
13018                        let aggregate =
13019                            crate::stdlib::lookup(ns, &f.name)
13020                                .into_iter()
13021                                .find_map(|d| match &d.impl_strategy {
13022                                    crate::stdlib::ImplStrategy::SqlBuiltin(sql_name) if d.is_aggregate() => {
13023                                        Some(sql_name.to_string())
13024                                    }
13025                                    _ => None,
13026                                });
13027                        if let Some(sql_name) = aggregate
13028                            && yields_array(&values)
13029                        {
13030                            let over_nothing = aggregate_over_nothing_sql(&f.name).unwrap_or("NULL");
13031                            return Ok(IrExpr::FunctionCall(IrFunctionCall {
13032                                return_pg_type: None,
13033                                schema: None,
13034                                name: sql_name.clone(),
13035                                args: vec![values],
13036                                sql_template: Some(format!(
13037                                    "(SELECT coalesce({sql_name}(\"_s\".\"v\"), {over_nothing}) FROM unnest($1) AS \"_s\"(\"v\"))"
13038                                )),
13039                            }));
13040                        }
13041                        return self.resolve_fn_call(f.module.as_deref(), &f.name, vec![values]);
13042                    }
13043                    // `count(memberships)` — a binding names a set, so the
13044                    // aggregate runs over its rows. Compiled as an ordinary
13045                    // expression it becomes a scalar subquery over the CTE,
13046                    // which any binding of more than one row aborts on.
13047                    if let Some(name) = self.resolve_cte_name(arg) {
13048                        use crate::stdlib::{ImplStrategy, lookup};
13049                        let ns = f.module.as_deref().unwrap_or("std");
13050                        let overloads = lookup(ns, &f.name);
13051                        let best = overloads
13052                            .iter()
13053                            .find(|d| d.params.len() == 1)
13054                            .or_else(|| overloads.first());
13055                        if let Some(ImplStrategy::SqlBuiltin(sql_name)) = best.map(|d| &d.impl_strategy)
13056                            && best.is_some_and(|d| d.params.first().is_some_and(|p| p.ty.is_set()))
13057                        {
13058                            let object = self.cte_types.get(name).is_some_and(|bound| bound.contains("::"));
13059                            return Ok(IrExpr::AggOverCte {
13060                                fn_name: sql_name.to_string(),
13061                                cte: name.to_string(),
13062                                column: (!object).then(|| "v".to_string()),
13063                            });
13064                        }
13065                    }
13066                    if let Some((td, alias)) = ctx {
13067                        // `count((select .<promotion filter …))` — compiled as an
13068                        // expression the sub-select is a scalar subquery, and a
13069                        // bare aggregate around it aggregates the enclosing select.
13070                        if let Expr::SubQuery(stmt) = arg
13071                            && f.name != "array_agg"
13072                            && let Some(sql_name) = crate::stdlib::lookup(f.module.as_deref().unwrap_or("std"), &f.name)
13073                                .into_iter()
13074                                .find_map(|d| match &d.impl_strategy {
13075                                    crate::stdlib::ImplStrategy::SqlBuiltin(sql_name) if d.is_aggregate() => {
13076                                        Some(sql_name.to_string())
13077                                    }
13078                                    _ => None,
13079                                })
13080                            && let Some(mut ps) = self.relative_subselect(stmt, ctx)?
13081                        {
13082                            // An object row is an anonymous record, which `unnest` cannot expand.
13083                            if let IrPathResult::Object { alias, .. } = &ps.result {
13084                                let id = IrExpr::ColumnRef {
13085                                    alias: alias.clone(),
13086                                    column: "id".to_string(),
13087                                    pg_type: "uuid".to_string(),
13088                                };
13089                                ps.result = IrPathResult::Scalar(id, None);
13090                            }
13091                            let aggregate = IrExpr::FunctionCall(IrFunctionCall {
13092                                return_pg_type: None,
13093                                schema: None,
13094                                name: sql_name.clone(),
13095                                args: vec![IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(
13096                                    ps,
13097                                ))))],
13098                                sql_template: Some(format!(
13099                                    "(SELECT {sql_name}(\"_s\".\"v\") FROM unnest($1) AS \"_s\"(\"v\"))"
13100                                )),
13101                            });
13102                            return Ok(aggregate_over_nothing(&f.name, aggregate));
13103                        }
13104                        // `max(items.created_at)` inside a FILTER: the
13105                        // aggregate takes the whole set the path names, so it
13106                        // belongs inside a subquery over that set — a bare
13107                        // `max(...)` here is an aggregate in a WHERE clause,
13108                        // which Postgres rejects outright.
13109                        if let Expr::Path(p) = arg
13110                            && !p.partial
13111                            && p.steps.len() > 1
13112                            && let Some(root) = self.find_path_root_in_expr(arg)
13113                        {
13114                            let synthetic = ast::SelectStmt {
13115                                result: Expr::FunctionCall(f.clone()),
13116                                filter: None,
13117                                order_by: vec![],
13118                                offset: None,
13119                                limit: None,
13120                                lock: None,
13121                            };
13122                            let ps = self.compile_expr_as_path_select(&synthetic, &synthetic.result, &root, false)?;
13123                            return Ok(aggregate_over_nothing(&f.name, IrExpr::PathSubquery(Box::new(ps))));
13124                        }
13125                        // `sum(.applied_promotions.amount)` — the aggregate
13126                        // takes the set the walk lands on, so the walk becomes
13127                        // the row source of a correlated subquery the aggregate
13128                        // sits inside. Compiled as an expression the walk stands
13129                        // for the array of its elements, leaving the aggregate
13130                        // with an array argument and no overload to match.
13131                        // A backlink is as many-valued: `count(.<revisions)`.
13132                        // So is a computed selecting a set
13133                        // (`Computed[MultiLink[Member], '.staff']`) — one step,
13134                        // but the junction-only count below cannot see through
13135                        // it to a junction table, so it walks the path the
13136                        // computed splices into instead.
13137                        if let Expr::Path(p) = arg
13138                            && p.partial
13139                            && (p.steps.len() > 1 && self.path_crosses_multi(td, &p.steps)
13140                                || matches!(p.steps.first(), Some(ast::PathStep::Backlink(_)))
13141                                || matches!(p.steps.as_slice(), [ast::PathStep::Name(name)]
13142                                    if Self::resolve_multilink(td, name).is_none()
13143                                        && self.path_crosses_multi(td, &p.steps)))
13144                        {
13145                            let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
13146                            steps.extend(p.steps.iter().cloned());
13147                            let rooted = ast::Path { steps, partial: false };
13148                            let mut call = f.clone();
13149                            call.args[0] = Expr::Path(rooted);
13150                            let synthetic = ast::SelectStmt {
13151                                result: Expr::FunctionCall(call),
13152                                filter: None,
13153                                order_by: vec![],
13154                                offset: None,
13155                                limit: None,
13156                                lock: None,
13157                            };
13158                            let root = format!("{}::{}", td.module, td.name);
13159                            let mut ps =
13160                                self.compile_expr_as_path_select(&synthetic, &synthetic.result, &root, false)?;
13161                            Self::correlate_path_select(&mut ps, alias);
13162                            return Ok(aggregate_over_nothing(&f.name, IrExpr::PathSubquery(Box::new(ps))));
13163                        }
13164                        if let Expr::Path(p) = arg
13165                            && p.partial
13166                            && p.steps.len() == 1
13167                            && let ast::PathStep::Name(ml_name) = &p.steps[0]
13168                            && Self::resolve_multilink(td, ml_name).is_some()
13169                        {
13170                            use crate::stdlib::{ImplStrategy, lookup};
13171                            let ns = f.module.as_deref().unwrap_or("std");
13172                            let overloads = lookup(ns, &f.name);
13173                            let best = overloads
13174                                .iter()
13175                                .find(|d| d.params.len() == 1)
13176                                .or_else(|| overloads.first());
13177                            if let Some(ImplStrategy::SqlBuiltin(sql_name)) = best.map(|d| &d.impl_strategy) {
13178                                let fn_name = sql_name.to_string();
13179                                let inner = self.multilink_correlation_select(ml_name, td, alias)?;
13180                                return Ok(IrExpr::AggOverQuery {
13181                                    fn_name,
13182                                    inner: Box::new(inner),
13183                                });
13184                            }
13185                        }
13186                    } else {
13187                        // `array_agg(a.sessions.id)` with no type in scope: the
13188                        // aggregate takes the whole set the path names, so it
13189                        // belongs in a subquery over that traversal. Compiled as
13190                        // an expression instead, a multi-valued path stands for
13191                        // the array of its elements, and aggregating *that*
13192                        // nests it one level deep — the same reason the
13193                        // schema-bound branch above routes this way.
13194                        if let Expr::Path(p) = arg
13195                            && !p.partial
13196                            && p.steps.len() > 1
13197                            && let Some(root) = self.find_path_root_in_expr(arg)
13198                        {
13199                            let synthetic = ast::SelectStmt {
13200                                result: Expr::FunctionCall(f.clone()),
13201                                filter: None,
13202                                order_by: vec![],
13203                                offset: None,
13204                                limit: None,
13205                                lock: None,
13206                            };
13207                            let ps = self.compile_expr_as_path_select(&synthetic, &synthetic.result, &root, false)?;
13208                            return Ok(aggregate_over_nothing(&f.name, IrExpr::PathSubquery(Box::new(ps))));
13209                        }
13210                        // `count((delete AuthLink filter .expired))` — the rows
13211                        // a mutation touched are countable like any other set.
13212                        // The mutation becomes its own data-modifying CTE, which
13213                        // Postgres runs regardless, and the aggregate reads it.
13214                        if let Expr::SubQuery(stmt) = arg
13215                            && matches!(stmt.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_))
13216                        {
13217                            use crate::stdlib::{ImplStrategy, lookup};
13218                            let ns = f.module.as_deref().unwrap_or("std");
13219                            let overloads = lookup(ns, &f.name);
13220                            let best = overloads
13221                                .iter()
13222                                .find(|d| d.params.len() == 1)
13223                                .or_else(|| overloads.first())
13224                                .cloned();
13225                            if let Some(d) = best
13226                                && let ImplStrategy::SqlBuiltin(sql_name) = &d.impl_strategy
13227                            {
13228                                let fn_name = sql_name.to_string();
13229                                let (cte_name, type_name) = self.hoist_dml_as_cte(stmt.as_ref())?;
13230                                let td = self.resolve_type(&type_name)?;
13231                                let source = IrSource {
13232                                    poly: None,
13233                                    type_name: format!("{}::{}", td.module, td.name),
13234                                    table: self.cte_source_table(&cte_name),
13235                                    alias: self.fresh_alias(),
13236                                };
13237                                let inner = IrSelect::schema_bound(source, Self::pk_returning(td), None);
13238                                return Ok(IrExpr::AggOverQuery {
13239                                    fn_name,
13240                                    inner: Box::new(inner),
13241                                });
13242                            }
13243                        }
13244                        let inner_sel: Option<ast::SelectStmt> = match arg {
13245                            Expr::Path(p) if !p.partial => {
13246                                // Resolve as a schema type if it matches a known type (not enum).
13247                                let qname = p
13248                                    .steps
13249                                    .iter()
13250                                    .filter_map(|s| {
13251                                        if let ast::PathStep::Name(n) = s {
13252                                            Some(n.as_str())
13253                                        } else {
13254                                            None
13255                                        }
13256                                    })
13257                                    .collect::<Vec<_>>()
13258                                    .join("::");
13259                                let is_schema_type = self
13260                                    .schema
13261                                    .types
13262                                    .iter()
13263                                    .any(|t| format!("{}::{}", t.module, t.name) == qname || t.name == qname);
13264                                if is_schema_type {
13265                                    Some(ast::SelectStmt {
13266                                        result: arg.clone(),
13267                                        filter: None,
13268                                        order_by: vec![],
13269                                        offset: None,
13270                                        limit: None,
13271                                        lock: None,
13272                                    })
13273                                } else {
13274                                    None
13275                                }
13276                            }
13277                            Expr::SubQuery(stmt) => {
13278                                if let ast::Stmt::Select(inner) = stmt.as_ref() {
13279                                    Some(inner.clone())
13280                                } else {
13281                                    None
13282                                }
13283                            }
13284                            _ => None,
13285                        };
13286                        if let Some(sel) = inner_sel {
13287                            use crate::stdlib::{ImplStrategy, lookup};
13288                            let ns = f.module.as_deref().unwrap_or("std");
13289                            let overloads = lookup(ns, &f.name);
13290                            let best = overloads
13291                                .iter()
13292                                .find(|d| d.params.len() == 1)
13293                                .or_else(|| overloads.first());
13294                            // `array_agg((select Type.prop))` — the sub-select
13295                            // walks to a scalar, which has no type name to be a
13296                            // schema select's subject. It is the same thing as
13297                            // `array_agg(Type.prop)` with the select's own
13298                            // modifiers on the walk, which is what
13299                            // `compile_expr_as_path_select` already builds.
13300                            // A `distinct` inside the sub-select means the
13301                            // same as one on the argument, and that is where the
13302                            // aggregate can act on it.
13303                            let (inner_result, inner_distinct) = match &sel.result {
13304                                Expr::UnaryOp(u) if matches!(u.op, ast::UnaryOpKind::Distinct) => (&u.operand, true),
13305                                other => (other, false),
13306                            };
13307                            if let Expr::Path(inner_path) = inner_result
13308                                && !inner_path.partial
13309                                && inner_path.steps.len() > 1
13310                                && let Some(ast::PathStep::Name(root)) = inner_path.steps.first()
13311                                && let Ok(root_td) = self.resolve_path_root(root)
13312                                && self
13313                                    .walk_path_types(root_td, &inner_path.steps[1..], MAX_COMPUTED_SPLICES)
13314                                    .1
13315                                    .is_none()
13316                            {
13317                                let root = root.clone();
13318                                let mut inner_call = f.clone();
13319                                inner_call.args = vec![if inner_distinct {
13320                                    Expr::UnaryOp(Box::new(ast::UnaryOp {
13321                                        op: ast::UnaryOpKind::Distinct,
13322                                        operand: inner_result.clone(),
13323                                    }))
13324                                } else {
13325                                    inner_result.clone()
13326                                }];
13327                                let call = ast::SelectStmt {
13328                                    result: Expr::FunctionCall(inner_call),
13329                                    ..sel.clone()
13330                                };
13331                                let ps = self.compile_expr_as_path_select(&call, &call.result, &root, false)?;
13332                                return Ok(IrExpr::PathSubquery(Box::new(ps)));
13333                            }
13334                            if let Some(d) = best
13335                                && let ImplStrategy::SqlBuiltin(sql_name) = &d.impl_strategy
13336                            {
13337                                let fn_name = sql_name.to_string();
13338                                let inner_ir = self.compile_select(&sel, &sel.result, false)?;
13339                                // `array_agg((select brands { * } filter …))` —
13340                                // an array of the objects, rows and shape
13341                                // whole; `array_agg(*)` is not an aggregate
13342                                // Postgres has.
13343                                if fn_name == "array_agg"
13344                                    && matches!(inner_ir.rows.as_slice(), [IrRowSource::Bound { .. }])
13345                                {
13346                                    return Ok(IrExpr::ArrayFromSelect(Box::new(IrArraySource::ObjectSelect(
13347                                        Box::new(inner_ir),
13348                                    ))));
13349                                }
13350                                return Ok(IrExpr::AggOverQuery {
13351                                    fn_name,
13352                                    inner: Box::new(inner_ir),
13353                                });
13354                            }
13355                        }
13356                    }
13357                }
13358
13359                // A set-valued argument → AggOverSet. Schema-bound as much as
13360                // free: an aggregate's argument is a set wherever it is written,
13361                // and each operand is compiled with `ctx` so `.started_at` and
13362                // the like still resolve. The set/shape arm below used to reject
13363                // this outright once a schema was in scope, so `set { x :=
13364                // min({cap, .y}) }` failed where the same call at the top level
13365                // compiled.
13366                if let Some(operands) = f.args.iter().find_map(Self::set_operands) {
13367                    use crate::stdlib::{ImplStrategy, lookup};
13368                    let ns = f.module.as_deref().unwrap_or("std");
13369                    let overloads = lookup(ns, &f.name);
13370                    let best = overloads
13371                        .iter()
13372                        .find(|d| d.params.len() == f.args.len())
13373                        .or_else(|| overloads.first());
13374                    let (schema, fn_name) = match best.map(|d| &d.impl_strategy) {
13375                        Some(ImplStrategy::SqlBuiltin(sql_name)) => (None, sql_name.to_string()),
13376                        Some(_) => {
13377                            return Err(self.type_err(&format!(
13378                                "function '{}::{}' cannot be called with a set literal in this context",
13379                                ns, f.name
13380                            )));
13381                        }
13382                        None => return Err(self.type_err(&format!("function '{}::{}' does not exist", ns, f.name))),
13383                    };
13384                    let elems = operands
13385                        .iter()
13386                        .map(|e| self.compile_expr_ctx(e, ctx))
13387                        .collect::<Result<Vec<_>, _>>()?;
13388                    return Ok(IrExpr::AggOverSet { fn_name, schema, elems });
13389                }
13390
13391                // `any(...)`/`all(...)` say outright that the argument is a
13392                // set, so the comparison inside must not also be warned about.
13393                // The argument is compiled before `resolve_fn_call` ever sees
13394                // which function this is, which is why the guard goes here.
13395                // `any(.grants.account = a and not .grants.deleted)` — every
13396                // `.grants` in it is the same grant, so the condition holds
13397                // per grant; compiled term by term, each walked the link on
13398                // its own.
13399                if let Some((td, _)) = ctx
13400                    && f.module.as_deref().unwrap_or("std") == "std"
13401                    && matches!(f.name.as_str(), "any" | "all")
13402                    && f.kwargs.is_empty()
13403                    && let [condition] = f.args.as_slice()
13404                    && let Some(per_element) = per_element_of_one_multilink(condition, td)
13405                {
13406                    let (link, element_condition) = per_element;
13407                    let condition = if f.name == "all" {
13408                        Expr::UnaryOp(Box::new(ast::UnaryOp {
13409                            op: ast::UnaryOpKind::Not,
13410                            operand: element_condition,
13411                        }))
13412                    } else {
13413                        element_condition
13414                    };
13415                    let exists = Expr::UnaryOp(Box::new(ast::UnaryOp {
13416                        op: ast::UnaryOpKind::Exists,
13417                        operand: Expr::SubQuery(Box::new(Stmt::Select(ast::SelectStmt {
13418                            result: Expr::Path(ast::Path {
13419                                steps: link,
13420                                partial: true,
13421                            }),
13422                            filter: Some(condition),
13423                            order_by: vec![],
13424                            offset: None,
13425                            limit: None,
13426                            lock: None,
13427                        }))),
13428                    }));
13429                    let exists = if f.name == "all" {
13430                        Expr::UnaryOp(Box::new(ast::UnaryOp {
13431                            op: ast::UnaryOpKind::Not,
13432                            operand: exists,
13433                        }))
13434                    } else {
13435                        exists
13436                    };
13437                    return self.compile_expr_ctx(&exists, ctx);
13438                }
13439                // `any(.<company.posts is Post)` — one answer per object the
13440                // path reaches, gathered as an array, so aggregate its elements.
13441                if f.module.as_deref().unwrap_or("std") == "std"
13442                    && matches!(f.name.as_str(), "any" | "all")
13443                    && f.kwargs.is_empty()
13444                    && let [Expr::TypeIs { expr: subject, ty }] = f.args.as_slice()
13445                    && let answers @ IrExpr::ArrayFromSelect(_) = self.compile_type_is_set(subject, ty, ctx)?
13446                {
13447                    let (aggregate, over_nothing) = if f.name == "all" {
13448                        ("bool_and", "true")
13449                    } else {
13450                        ("bool_or", "false")
13451                    };
13452                    return Ok(IrExpr::FunctionCall(IrFunctionCall {
13453                        return_pg_type: None,
13454                        schema: None,
13455                        name: aggregate.to_string(),
13456                        args: vec![answers],
13457                        sql_template: Some(format!(
13458                            "(SELECT coalesce({aggregate}(\"_unnested\".\"v\"), {over_nothing}) FROM unnest($1) AS \"_unnested\"(\"v\"))"
13459                        )),
13460                    }));
13461                }
13462                // `all(array_unpack($tags) in .tags.label)` — the comparison is
13463                // made once per element, then aggregated. Inlined as `unnest()`
13464                // the array is a set-returning call, which Postgres refuses in a
13465                // WHERE; over no elements `all` is true and `any` false.
13466                if f.module.as_deref().unwrap_or("std") == "std"
13467                    && matches!(f.name.as_str(), "any" | "all")
13468                    && f.kwargs.is_empty()
13469                    && let [Expr::BinOp(comparison)] = f.args.as_slice()
13470                    && let Expr::FunctionCall(unpack) = &comparison.left
13471                    && unpack.module.as_deref().unwrap_or("std") == "std"
13472                    && unpack.name == "array_unpack"
13473                    && let [array] = unpack.args.as_slice()
13474                {
13475                    let array = self.compile_expr_ctx(array, ctx)?;
13476                    let element = "<unnested element>".to_string();
13477                    self.inline_bindings
13478                        .insert(element.clone(), IrExpr::RawSql("\"_unnested\".\"v\"".to_string()));
13479                    let per_element = self.compile_expr_ctx(
13480                        &Expr::BinOp(Box::new(ast::BinOp {
13481                            left: Expr::Path(ast::Path::absolute(element.clone())),
13482                            op: comparison.op.clone(),
13483                            right: comparison.right.clone(),
13484                        })),
13485                        ctx,
13486                    );
13487                    self.inline_bindings.remove(&element);
13488                    let (aggregate, over_nothing) = if f.name == "all" {
13489                        ("bool_and", "true")
13490                    } else {
13491                        ("bool_or", "false")
13492                    };
13493                    return Ok(IrExpr::FunctionCall(IrFunctionCall {
13494                        return_pg_type: None,
13495                        schema: None,
13496                        name: aggregate.to_string(),
13497                        args: vec![per_element?, array],
13498                        sql_template: Some(format!(
13499                            "(SELECT coalesce({aggregate}($1), {over_nothing}) FROM unnest($2) AS \"_unnested\"(\"v\"))"
13500                        )),
13501                    }));
13502                }
13503                if let Some(args) = self.compile_named_call_args(f, ctx)? {
13504                    return self.resolve_fn_call(f.module.as_deref(), &f.name, args);
13505                }
13506                let is_explicit_set = f.module.as_deref().unwrap_or("std") == "std"
13507                    && matches!(f.name.as_str(), "any" | "all")
13508                    && f.args.len() == 1;
13509                let is_universal = is_explicit_set && f.name == "all";
13510                if is_explicit_set {
13511                    self.explicit_set_depth += 1;
13512                }
13513                if is_universal {
13514                    self.universal_set_depth += 1;
13515                }
13516                let args = f
13517                    .args
13518                    .iter()
13519                    .map(|a| self.compile_expr_ctx(a, ctx))
13520                    .collect::<Result<Vec<_>, _>>();
13521                if is_universal {
13522                    self.universal_set_depth -= 1;
13523                }
13524                if is_explicit_set {
13525                    self.explicit_set_depth -= 1;
13526                }
13527                let args = args?;
13528                // `all(.required_addons in licensed_addons)` — the argument is
13529                // a set of booleans, gathered as an array the way every other
13530                // multi-valued expression is, and the quantifier ranges over
13531                // its elements. Handed the array itself, `bool_and` would
13532                // aggregate the enclosing query's rows instead.
13533                if is_explicit_set
13534                    && let [values] = args.as_slice()
13535                    && yields_array(values)
13536                {
13537                    let (aggregate, over_nothing) = if f.name == "all" {
13538                        ("bool_and", "true")
13539                    } else {
13540                        ("bool_or", "false")
13541                    };
13542                    return Ok(IrExpr::FunctionCall(IrFunctionCall {
13543                        return_pg_type: None,
13544                        schema: None,
13545                        name: aggregate.to_string(),
13546                        args: vec![values.clone()],
13547                        sql_template: Some(format!(
13548                            "(SELECT coalesce({aggregate}(\"_s\".\"v\"), {over_nothing}) FROM unnest($1) AS \"_s\"(\"v\"))"
13549                        )),
13550                    }));
13551                }
13552                self.resolve_fn_call(f.module.as_deref(), &f.name, args)
13553            }
13554
13555            Expr::UnaryOp(u) if u.op == ast::UnaryOpKind::Exists => self.compile_exists_ctx(&u.operand, ctx),
13556
13557            // `distinct` in expression position: a set-producing operand
13558            // dedupes its own rows, and a single value — an array, a global, a
13559            // column — is already distinct, so it stands for itself. (A
13560            // statement-level `select distinct …` never reaches here; it is
13561            // unwrapped into the select's own `distinct` flag.)
13562            Expr::UnaryOp(u) if u.op == ast::UnaryOpKind::Distinct => {
13563                let operand = self.compile_expr_ctx(&u.operand, ctx)?;
13564                Ok(match operand {
13565                    IrExpr::PathSubquery(mut ps) => {
13566                        ps.distinct = true;
13567                        IrExpr::PathSubquery(ps)
13568                    }
13569                    IrExpr::ArrayFromSelect(src) => IrExpr::ArrayFromSelect(Box::new(match *src {
13570                        IrArraySource::Select(mut sel) => {
13571                            sel.distinct = true;
13572                            IrArraySource::Select(sel)
13573                        }
13574                        IrArraySource::PathSelect(mut ps) => {
13575                            ps.distinct = true;
13576                            IrArraySource::PathSelect(ps)
13577                        }
13578                        other => other,
13579                    })),
13580                    other => other,
13581                })
13582            }
13583
13584            Expr::UnaryOp(u) => {
13585                let operand = self.compile_expr_ctx(&u.operand, ctx)?;
13586                Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
13587                    op: u.op.clone(),
13588                    operand,
13589                })))
13590            }
13591
13592            Expr::IfElse(ie) => {
13593                let condition = self.compile_expr_ctx(&ie.condition, ctx)?;
13594                let if_ = self.compile_expr_ctx(&ie.if_expr, ctx)?;
13595                let else_ = self.compile_expr_ctx(&ie.else_expr, ctx)?;
13596                Ok(IrExpr::IfElse(Box::new(IrIfElse { condition, if_, else_ })))
13597            }
13598
13599            Expr::Array(elems) => {
13600                // Each element is one value: `[<uuid>$p, snippet.id]` is two
13601                // uuids, not a uuid beside an array of them — PostgreSQL
13602                // refuses to mix the two in one literal.
13603                let items = elems
13604                    .iter()
13605                    .map(|e| self.compile_expr_ctx(e, ctx).map(set_walk_as_scalar))
13606                    .collect::<Result<Vec<_>, _>>()?;
13607                Ok(IrExpr::Array(items))
13608            }
13609
13610            Expr::NamedTuple(fields) => {
13611                let ir = fields
13612                    .iter()
13613                    .map(|(name, e)| Ok((name.clone(), self.compile_expr_ctx(e, ctx)?)))
13614                    .collect::<Result<Vec<_>, PyQLError>>()?;
13615                Ok(IrExpr::NamedTuple {
13616                    fields: ir,
13617                    is_free_object: false,
13618                })
13619            }
13620
13621            Expr::Tuple(elems) => {
13622                let ir = elems
13623                    .iter()
13624                    .map(|e| self.compile_expr_ctx(e, ctx))
13625                    .collect::<Result<Vec<_>, PyQLError>>()?;
13626                Ok(IrExpr::Tuple(ir))
13627            }
13628
13629            Expr::FieldAccess { expr: inner, field } => {
13630                // `(select …).provider[is Individual].staff` arrives here, not
13631                // at the `PathStepOn` arm: the trailing `.staff` is outermost.
13632                if let Some(ir) = self.try_compile_walk_off_subquery(expr, ctx)? {
13633                    return Ok(ir);
13634                }
13635                if let Expr::NamedTuple(fields) = inner.as_ref() {
13636                    let (_, val) = fields.iter().find(|(k, _)| k == field).ok_or_else(|| {
13637                        self.type_err(&format!("{field} is not a member of {}", named_tuple_type_str(fields)))
13638                    })?;
13639                    return self.compile_expr_ctx(val, ctx);
13640                }
13641                if let Some(ps) = self.compile_shape_field_select(expr, ctx)? {
13642                    return Ok(IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(
13643                        ps,
13644                    )))));
13645                }
13646                // `(select .emails filter .primary limit 1).address` — the
13647                // field chain is spliced onto the sub-select's own path so
13648                // the subquery projects that column, rather than being read
13649                // as jsonb extraction off an object id.
13650                let (base, fields) = Self::peel_field_access_chain(expr);
13651                if let Expr::SubQuery(stmt) = base {
13652                    let stmt = stmt.as_ref().clone();
13653                    return self.compile_subquery_expr(&stmt, &fields, ctx, &[]);
13654                }
13655                // `account::owner(.id).name` — an object-returning function
13656                // projected to one of its columns.
13657                if let Expr::FunctionCall(fc) = base {
13658                    let fc = fc.clone();
13659                    if let Some(ir) = self.try_compile_fn_scalar_subquery(&fc, &fields, None, ctx)? {
13660                        return Ok(ir);
13661                    }
13662                }
13663                let ir = self.compile_expr_ctx(inner, ctx)?;
13664                Ok(Self::project_free_object_field(ir, field))
13665            }
13666
13667            Expr::TupleIndex { expr: inner, index } => {
13668                match inner.as_ref() {
13669                    Expr::Tuple(elems) => {
13670                        let elem = elems.get(*index).ok_or_else(|| {
13671                            self.type_err(&format!(
13672                                "{index} is not a member of {}",
13673                                positional_tuple_type_str(elems)
13674                            ))
13675                        })?;
13676                        self.compile_expr_ctx(elem, ctx)
13677                    }
13678                    Expr::NamedTuple(fields) => {
13679                        let (_, val) = fields.get(*index).ok_or_else(|| {
13680                            self.type_err(&format!("{index} is not a member of {}", named_tuple_type_str(fields)))
13681                        })?;
13682                        self.compile_expr_ctx(val, ctx)
13683                    }
13684                    // Not a literal to constant-fold — emit a generic runtime
13685                    // jsonb positional access (`$param.1`, `(<tuple<...>>expr).1`, …).
13686                    // When the source is a cast to a statically-known tuple type,
13687                    // bounds-check the index against its arity at compile time
13688                    // (e.g. `2 is not a member of tuple<std::int64, std::str>`).
13689                    _ => {
13690                        if let Expr::TypeCast(tc) = inner.as_ref()
13691                            && let Some(shape) = self.resolve_tuple_cast_shape(&tc.ty)
13692                            && *index >= shape.members.len()
13693                        {
13694                            return Err(self.type_err(&format!(
13695                                "{index} is not a member of {}",
13696                                self.type_expr_to_display_str(&tc.ty)
13697                            )));
13698                        }
13699                        let ir = self.compile_expr_ctx(inner, ctx)?;
13700                        Ok(IrExpr::JsonbIndex {
13701                            expr: Box::new(ir),
13702                            index: *index,
13703                        })
13704                    }
13705                }
13706            }
13707
13708            // `detached` bypasses the implicit root-matches-td correlation
13709            // rewrite. Schema-bound: if `inner` contains a type-rooted path,
13710            // compile it as an independent PathSubquery; otherwise (or when
13711            // already free) fall back to compiling `inner` with NO schema
13712            // binding — note this fallback is deliberately `None`, not
13713            // `ctx`, since `detached` means "evaluate independently of the
13714            // enclosing scope" and free context has no rooted path to find
13715            // in the first place (`find_path_root_in_expr` only ever matches
13716            // against a resolvable schema type name).
13717            Expr::Detached(inner) => {
13718                if ctx.is_some()
13719                    && let Some(root) = self.find_path_root_in_expr(inner)
13720                {
13721                    let synthetic = ast::SelectStmt {
13722                        result: (**inner).clone(),
13723                        filter: None,
13724                        order_by: vec![],
13725                        offset: None,
13726                        limit: None,
13727                        lock: None,
13728                    };
13729                    let ps = self.compile_expr_as_path_select(&synthetic, inner, &root, false)?;
13730                    return Ok(IrExpr::PathSubquery(Box::new(ps)));
13731                }
13732                self.compile_expr_ctx(inner, None)
13733            }
13734
13735            // Free context tolerates an empty set (-> Null) or a singleton
13736            // set (-> its one element) as a convenience; a schema-bound
13737            // expression position hard-errors on ANY set literal instead
13738            // (see the generic Shape|Set arm below) — a plausibly
13739            // intentional semantic difference, preserved exactly as-is.
13740            Expr::Set(elems) if ctx.is_none() && elems.is_empty() => Ok(IrExpr::Null),
13741
13742            Expr::Set(elems) if ctx.is_none() => {
13743                let compiled: Result<Vec<_>, _> = elems.iter().map(|e| self.compile_expr_ctx(e, ctx)).collect();
13744                let mut compiled = compiled?;
13745                if compiled.len() == 1 {
13746                    Ok(compiled.remove(0))
13747                } else {
13748                    Err(self.type_err("multi-element set literal is not supported in free SELECT context"))
13749                }
13750            }
13751
13752            // A free object literal (`{ foo := 'bar' }`, no subject type) is
13753            // valid anywhere an expression is, not just as a whole SELECT's
13754            // result — e.g. nested inside a computed shape element. Compiled
13755            // the same way `compile_free_select` treats it at the top level
13756            // (each field compiled independently, ctx propagated so a
13757            // schema-bound nested free object can still reference `.name`
13758            // etc.), just wrapped as `IrExpr::NamedTuple` (jsonb) instead of
13759            // a whole result row, since here it's a value, not a row source.
13760            // is_free_object: true — this came from curly-brace shape syntax,
13761            // not a paren tuple literal, so the value-shape-tag tree
13762            // (pylon/query.py's shape_value_tags) can tell the frontend to
13763            // render it as an expandable "Object {...}", not a `(...)`
13764            // tuple literal (see ShapeNode::NamedTuple's own doc comment).
13765            Expr::Shape(s) if s.expr.is_none() => {
13766                let fields = s
13767                    .elements
13768                    .iter()
13769                    .map(|el| -> Result<(String, IrExpr), PyQLError> {
13770                        let name = path_leaf(&el.path)?.to_string();
13771                        let expr = el.compexpr.as_ref().ok_or_else(|| {
13772                            self.type_err("free object field must have a value expression (':= expr')")
13773                        })?;
13774                        let compiled = match expr {
13775                            Expr::SubQuery(stmt)
13776                                if matches!(stmt.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)) =>
13777                            {
13778                                self.dml_as_value(stmt.as_ref())?
13779                            }
13780                            other => match self.free_object_link_field(other)? {
13781                                Some(object) => object,
13782                                None => self.compile_expr_ctx(other, ctx)?,
13783                            },
13784                        };
13785                        Ok((name, compiled))
13786                    })
13787                    .collect::<Result<Vec<_>, _>>()?;
13788                Ok(IrExpr::NamedTuple {
13789                    fields,
13790                    is_free_object: true,
13791                })
13792            }
13793
13794            // A shape applied to a WITH-bound free object (`test := test {
13795            // test2 }` where `test` isn't a schema object, just a free
13796            // binding) projects the named fields out of it — each element
13797            // either reads the underlying field (by name, via the same
13798            // per-field CTE column `.field` access uses) or, with a `:=`
13799            // override, compiles a brand new value in the current context,
13800            // exactly like a fresh free-object-literal field would.
13801            Expr::Shape(s) if matches!(&s.expr, Some(inner) if self.is_free_cte_ref(inner)) => {
13802                let Some(Expr::Path(root_path)) = &s.expr else {
13803                    unreachable!()
13804                };
13805                let ast::PathStep::Name(root) = &root_path.steps[0] else {
13806                    unreachable!()
13807                };
13808                let fields = s
13809                    .elements
13810                    .iter()
13811                    .map(|el| -> Result<(String, IrExpr), PyQLError> {
13812                        let name = path_leaf(&el.path)?.to_string();
13813                        let expr = match &el.compexpr {
13814                            Some(over) => self.compile_expr_ctx(over, ctx)?,
13815                            None => match self.resolve_cte_field_chain(root, &[name.as_str()]) {
13816                                Some(result) => result?,
13817                                None => {
13818                                    return Err(self.type_err(&format!("free object '{root}' has no field '{name}'")));
13819                                }
13820                            },
13821                        };
13822                        Ok((name, expr))
13823                    })
13824                    .collect::<Result<Vec<_>, _>>()?;
13825                Ok(IrExpr::NamedTuple {
13826                    fields,
13827                    is_free_object: true,
13828                })
13829            }
13830
13831            // `(.<prices[is Listing] union .<sale_prices[is Listing]) { id }`
13832            // — each operand hangs off the enclosing row, so none can be
13833            // hoisted into a CTE the way a standalone union's operands are.
13834            Expr::Shape(sh)
13835                if ctx.is_some()
13836                    && matches!(sh.expr.as_ref(), Some(Expr::Union(_, _)))
13837                    && Self::union_of_relative_paths(sh.expr.as_ref().expect("checked")).is_some() =>
13838            {
13839                let (td, alias) = ctx.expect("checked by the guard");
13840                let operands =
13841                    Self::union_of_relative_paths(sh.expr.as_ref().expect("checked")).expect("checked by the guard");
13842                let elements = sh.elements.clone();
13843                let multi = self.relative_paths_reach_many(td, &operands);
13844                let mut branches = Vec::with_capacity(operands.len());
13845                for path in operands {
13846                    let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
13847                    steps.extend(path.steps.iter().cloned());
13848                    let rooted = ast::Path { steps, partial: false };
13849                    let synthetic = ast::SelectStmt {
13850                        result: Expr::Path(rooted.clone()),
13851                        filter: None,
13852                        order_by: vec![],
13853                        offset: None,
13854                        limit: None,
13855                        lock: None,
13856                    };
13857                    let mut ps = self.compile_path_select(&synthetic, &rooted, &elements, false)?;
13858                    Self::correlate_path_select(&mut ps, alias);
13859                    branches.push(ps);
13860                }
13861                Ok(IrExpr::ObjectPathUnion {
13862                    branches,
13863                    limit: None,
13864                    multi,
13865                })
13866            }
13867
13868            // `([is Conditional].configs ?? [is Loop].configs) { … }` — the
13869            // same correlated walks a union of them gives, except each operand
13870            // after the first only stands in when the ones before it are empty.
13871            Expr::Shape(sh)
13872                if ctx.is_some() && sh.expr.as_ref().and_then(Self::coalesce_of_relative_paths).is_some() =>
13873            {
13874                let (td, alias) = ctx.expect("checked by the guard");
13875                let operands =
13876                    Self::coalesce_of_relative_paths(sh.expr.as_ref().expect("checked")).expect("checked by the guard");
13877                let multi = self.relative_paths_reach_many(td, &operands);
13878                let branches = self.coalesce_path_branches(td, alias, &operands, &sh.elements)?;
13879                Ok(IrExpr::ObjectPathUnion {
13880                    branches,
13881                    limit: None,
13882                    multi,
13883                })
13884            }
13885
13886            Expr::Shape(sh) if matches!(sh.expr.as_ref(), Some(Expr::SubQuery(_))) => {
13887                let sh = sh.clone();
13888                match self.shape_over_subquery(&sh)? {
13889                    Some(ir) => Ok(ir),
13890                    None => Err(PyQLError::Type(PyQLTypeError {
13891                        message: "shapes and set literals are not valid in expression context".into(),
13892                        position: Position { line: 0, col: 0 },
13893                    })),
13894                }
13895            }
13896
13897            Expr::Shape(sh) if Self::shape_over_subquery_projection(sh).is_some() => {
13898                let (stmt, fields) = Self::shape_over_subquery_projection(sh).expect("checked by the guard");
13899                let stmt = stmt.clone();
13900                let elements = sh.elements.clone();
13901                self.compile_subquery_expr(&stmt, &fields, ctx, &elements)
13902            }
13903
13904            // `account := account if cond else {}` — the empty set is a legal
13905            // expression and means "no value", which is what the
13906            // assignment paths already spell `IrExpr::Null`. Only a *non-empty*
13907            // set literal has no expression-position meaning.
13908            Expr::Set(items) if items.is_empty() => Ok(IrExpr::Null),
13909
13910            Expr::Shape(sh)
13911                if matches!(sh.expr.as_ref(), Some(Expr::Path(p))
13912                    if !p.partial
13913                        && matches!(p.steps.first(), Some(ast::PathStep::Name(n))
13914                            if self.cte_object_type(n).is_some() || self.for_var_types.contains_key(n))) =>
13915            {
13916                let expr = expr.clone();
13917                match self.shape_over_binding(&expr)? {
13918                    Some(ir) => Ok(ir),
13919                    None => Err(PyQLError::Type(PyQLTypeError {
13920                        message: "shapes and set literals are not valid in expression context".into(),
13921                        position: Position { line: 0, col: 0 },
13922                    })),
13923                }
13924            }
13925
13926            // A bare shape or set literal is never valid in expression
13927            // position, in either context — preserved exactly as the
13928            // schema-bound side always enforced (the free side's more
13929            // permissive empty/singleton-set handling above is the one
13930            // deliberate exception, handled before this arm).
13931            Expr::Shape(_) | Expr::Set(_) => Err(PyQLError::Type(PyQLTypeError {
13932                message: "shapes and set literals are not valid in expression context".into(),
13933                position: Position { line: 0, col: 0 },
13934            })),
13935
13936            // A `select` over a path compiles to a correlated subquery; DML
13937            // and everything else still has no expression-position meaning.
13938            // Union/Except previously fell through free's generic "not valid
13939            // in free SELECT context" catch-all — these explicit,
13940            // purpose-written messages (already used schema-bound) apply
13941            // equally well with no schema in scope, so they're unconditional
13942            // here rather than ctx-gated.
13943            Expr::SubQuery(stmt) => {
13944                let stmt = stmt.as_ref().clone();
13945                self.compile_subquery_expr(&stmt, &[], ctx, &[])
13946            }
13947
13948            Expr::Union(_, _) => Err(PyQLError::Type(PyQLTypeError {
13949                message: "union is not valid in expression context".into(),
13950                position: Position { line: 0, col: 0 },
13951            })),
13952
13953            // `array_unpack(a) intersect array_unpack(b)` — a set operation
13954            // between two set-valued expressions is itself a set, which in
13955            // expression position is the array of what it yields.
13956            Expr::Except(left, right) | Expr::Intersect(left, right) => {
13957                let op = if matches!(expr, Expr::Intersect(_, _)) {
13958                    super::SetOpKind::Intersect
13959                } else {
13960                    super::SetOpKind::Except
13961                };
13962                let left = self.compile_expr_ctx(left, ctx)?;
13963                let right = self.compile_expr_ctx(right, ctx)?;
13964                Ok(IrExpr::SetOp {
13965                    op,
13966                    left: Box::new(left),
13967                    right: Box::new(right),
13968                    mode: super::SetOpMode::Array,
13969                })
13970            }
13971
13972            // TypeIs (`expr is Type`) is schema-exclusive — compile_type_is
13973            // deeply needs td/alias throughout (interface checks, __type__
13974            // column, alias-scoped bool expr). No free-context equivalent
13975            // existed before the merge (fell to the generic catch-all); this
13976            // is a new, clearer explicit error for that case.
13977            Expr::TypeIs { expr, ty } => match ctx {
13978                Some((td, alias)) => self.compile_type_is(expr, ty, td, alias),
13979                None => match expr.as_ref() {
13980                    Expr::Path(p) => self.compile_path_type_is(p, ty, None),
13981                    _ => Err(self.type_err("'is' type check is not valid in free SELECT context")),
13982                },
13983            },
13984        }
13985    }
13986
13987    fn compile_expr(&mut self, expr: &Expr, td: &TypeDescriptor, alias: &str) -> Result<IrExpr, PyQLError> {
13988        self.compile_expr_ctx(expr, Some((td, alias)))
13989    }
13990
13991    fn compile_free_expr(&mut self, expr: &Expr) -> Result<IrExpr, PyQLError> {
13992        self.compile_expr_ctx(expr, None)
13993    }
13994
13995    /// `path is T`: whether the objects `path` reaches are of `T` or one of
13996    /// its subtypes, read off their own `__type__`.
13997    fn compile_path_type_is(
13998        &mut self,
13999        path: &ast::Path,
14000        ty: &ast::TypeExpr,
14001        ctx: Option<(&TypeDescriptor, &str)>,
14002    ) -> Result<IrExpr, PyQLError> {
14003        let (ty_module, ty_name) = ty
14004            .as_named()
14005            .ok_or_else(|| self.type_err("cannot use IS with a tuple or array type"))?;
14006        let check_qname = match (ty_module, ctx) {
14007            (Some(module), _) => format!("{module}::{ty_name}"),
14008            (None, Some((td, _))) => format!("{}::{}", td.module, ty_name),
14009            (None, None) => ty_name.to_string(),
14010        };
14011        let check_td = self.resolve_type(&check_qname)?;
14012        let check_qname = format!("{}::{}", check_td.module, check_td.name);
14013        let mut type_path = path.clone();
14014        type_path.steps.push(ast::PathStep::Name("__type__".to_string()));
14015        let own_type = self.compile_expr_ctx(&Expr::Path(type_path), ctx)?;
14016        let implementors: Vec<String> = self
14017            .find_poly_implementors(&check_qname)
14018            .into_iter()
14019            .map(|implementor| implementor.type_name)
14020            .collect();
14021        let is_one_of = |own_type: IrExpr| {
14022            implementors
14023                .iter()
14024                .map(|implementor| {
14025                    IrExpr::BinOp(Box::new(IrBinOp {
14026                        left: own_type.clone(),
14027                        op: ast::BinOpKind::Eq,
14028                        right: IrExpr::Literal(IrLiteral::Str(implementor.clone())),
14029                    }))
14030                })
14031                .reduce(|left, right| {
14032                    IrExpr::BinOp(Box::new(IrBinOp {
14033                        left,
14034                        op: ast::BinOpKind::Or,
14035                        right,
14036                    }))
14037                })
14038                .unwrap_or(IrExpr::Literal(IrLiteral::Bool(false)))
14039        };
14040        // A path that reaches many objects yields one answer per object.
14041        Ok(match own_type {
14042            IrExpr::ArrayFromSelect(source) => match *source {
14043                IrArraySource::PathSelect(mut path_select) => {
14044                    let IrPathResult::Scalar(element, cast) = path_select.result else {
14045                        return Err(self.type_err("'__type__' is read as a value"));
14046                    };
14047                    path_select.result = IrPathResult::Scalar(is_one_of(element), cast);
14048                    IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(path_select)))
14049                }
14050                other => is_one_of(IrExpr::ArrayFromSelect(Box::new(other))),
14051            },
14052            single => is_one_of(single),
14053        })
14054    }
14055
14056    fn compile_type_is(
14057        &mut self,
14058        expr: &Expr,
14059        ty: &ast::TypeExpr,
14060        td: &TypeDescriptor,
14061        alias: &str,
14062    ) -> Result<IrExpr, PyQLError> {
14063        self.compile_type_is_inner(expr, ty, td, alias, false)
14064    }
14065
14066    /// `expr is T` read as every answer it gives rather than as one value:
14067    /// naming a type other than the scope's asks the question once per row
14068    /// of that type. A shape pointer keeps that whole set and `any()`/`all()`
14069    /// fold it, where a boolean position — a FILTER, an `if` condition — can
14070    /// only take a single value and goes through `compile_type_is`.
14071    fn compile_type_is_set(
14072        &mut self,
14073        expr: &Expr,
14074        ty: &ast::TypeExpr,
14075        ctx: Option<(&TypeDescriptor, &str)>,
14076    ) -> Result<IrExpr, PyQLError> {
14077        match ctx {
14078            Some((td, alias)) => self.compile_type_is_inner(expr, ty, td, alias, true),
14079            None => match expr {
14080                Expr::Path(p) => self.compile_path_type_is(p, ty, None),
14081                _ => Err(self.type_err("'is' type check is not valid in free SELECT context")),
14082            },
14083        }
14084    }
14085
14086    fn compile_type_is_inner(
14087        &mut self,
14088        expr: &Expr,
14089        ty: &ast::TypeExpr,
14090        td: &TypeDescriptor,
14091        alias: &str,
14092        set_valued: bool,
14093    ) -> Result<IrExpr, PyQLError> {
14094        let self_qname = format!("{}::{}", td.module, td.name);
14095
14096        // `.listing is T`, `x is T`: the objects the path reaches are tested,
14097        // not the current row — through their own `__type__`.
14098        let reaches_other_objects = match expr {
14099            Expr::Path(p) if p.partial || p.steps.len() > 1 => {
14100                !matches!(p.steps.last(), Some(ast::PathStep::Name(n)) if n == "__type__")
14101            }
14102            Expr::Path(p) => matches!(
14103                p.steps.as_slice(),
14104                [ast::PathStep::Name(n)] if n != &td.name && n != &self_qname && self.resolve_type(n).is_err()
14105                    && self.resolve_name_ref(n, true).is_some()
14106            ),
14107            _ => false,
14108        };
14109        if reaches_other_objects && let Expr::Path(p) = expr {
14110            return self.compile_path_type_is(p, ty, Some((td, alias)));
14111        }
14112
14113        // Determine whether `expr` refers to the current scope or a different type.
14114        // A 1-step absolute path matching the current td → same scope.
14115        let (source_qname, cross_scope) = match expr {
14116            Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
14117                if let ast::PathStep::Name(n) = &p.steps[0] {
14118                    if n == &td.name || n == &self_qname || self.scope_answers_to(n, &self_qname) {
14119                        (self_qname.clone(), false)
14120                    } else {
14121                        // Attempt to resolve as another type.
14122                        match self.resolve_type(n) {
14123                            Ok(other) => (format!("{}::{}", other.module, other.name), true),
14124                            Err(_) => (self_qname.clone(), false),
14125                        }
14126                    }
14127                } else {
14128                    (self_qname.clone(), false)
14129                }
14130            }
14131            _ => (self_qname.clone(), false),
14132        };
14133
14134        let (ty_module, ty_name) = ty
14135            .as_named()
14136            .ok_or_else(|| self.type_err("cannot use IS with a tuple or array type"))?;
14137        let check_module = ty_module.unwrap_or(td.module.as_str());
14138        let check_qname = format!("{}::{}", check_module, ty_name);
14139        self.resolve_type(&check_qname)?;
14140
14141        if !cross_scope {
14142            // Scalar bool using the current alias.
14143            return Ok(self.type_check_bool_expr(&source_qname, &check_qname, td, alias));
14144        }
14145
14146        // Cross-scope: ARRAY(SELECT bool_expr FROM source_table).
14147        // Collect everything needed before calling fresh_alias (which needs &mut self).
14148        let src_alias = self.fresh_alias();
14149        let src_td = self.resolve_type(&source_qname)?;
14150        let source_table = src_td.table.clone();
14151        let (poly_implementors, poly_columns) = if self.is_polymorphic(src_td) {
14152            (
14153                self.find_poly_implementors(&source_qname),
14154                Self::poly_dml_columns(src_td),
14155            )
14156        } else {
14157            (vec![], vec![])
14158        };
14159        let bool_expr = self.type_check_bool_expr(&source_qname, &check_qname, src_td, &src_alias);
14160
14161        // Whenever the answer is the same for every row of the source,
14162        // `type_check_bool_expr` settles it with a literal. There is nothing
14163        // to iterate then, and a constant is what a boolean position can
14164        // consume: `ARRAY(SELECT FALSE FROM "Brand")` is a `boolean[]`, which
14165        // Postgres refuses outright as a CASE/WHEN condition or a WHERE
14166        // clause.
14167        if !set_valued && matches!(bool_expr, IrExpr::Literal(IrLiteral::Bool(_))) {
14168            return Ok(bool_expr);
14169        }
14170
14171        let source = IrSource {
14172            poly: None,
14173            type_name: source_qname.clone(),
14174            table: source_table,
14175            alias: src_alias,
14176        };
14177
14178        let answers = IrExpr::ArrayFromSelect(Box::new(IrArraySource::RawExpr {
14179            source,
14180            poly_implementors,
14181            poly_columns,
14182            expr: bool_expr,
14183        }));
14184        if set_valued {
14185            return Ok(answers);
14186        }
14187        // What is left really does differ per row of the source, so it still
14188        // has to collapse to one value here — empty answers nothing, more
14189        // than one row aborts. The message says which check asked and over
14190        // what, since `assert_single` appears nowhere in the query that
14191        // triggers it and its own wording sends the reader looking for a call
14192        // they never wrote.
14193        Ok(IrExpr::FunctionCall(IrFunctionCall {
14194            return_pg_type: None,
14195            schema: Some("_pylon".to_string()),
14196            name: "assert_single".to_string(),
14197            args: vec![
14198                answers,
14199                IrExpr::Literal(IrLiteral::Str(format!(
14200                    "'{source_qname} is {check_qname}' is asked once for every {source_qname} object, \
14201                     so it is not the single value a filter or an 'if' condition needs — narrow it to \
14202                     one object, or name the type being selected"
14203                ))),
14204            ],
14205            sql_template: None,
14206        }))
14207    }
14208
14209    /// Build the boolean `IrExpr` for `source_qname is check_qname` in the current row's scope.
14210    fn type_check_bool_expr(&self, source_qname: &str, check_qname: &str, td: &TypeDescriptor, alias: &str) -> IrExpr {
14211        if check_qname == source_qname || Self::is_or_implements(td, check_qname) {
14212            return IrExpr::Literal(IrLiteral::Bool(true));
14213        }
14214        if !self.is_polymorphic(td) {
14215            return IrExpr::Literal(IrLiteral::Bool(false));
14216        }
14217        // A row is of the checked type when its own type is that type or one
14218        // of its subtypes — but only the ones this source can actually hold
14219        // are worth asking about, since its rows carry one of *its* own
14220        // implementors in `__type__` and nothing else. Across two disjoint
14221        // hierarchies (`brand::Brand is brand::CustomBrief`) nothing is left
14222        // and the answer is false for every row; where nothing is ruled out
14223        // it is true for every row. Both are decided here rather than per
14224        // row, which is what keeps a check over another type out of the
14225        // per-row machinery in `compile_type_is_inner` — asking it once per
14226        // row of a three-row table is what made a filter fail the
14227        // single-value check it has to pass.
14228        let reachable: Vec<String> = self
14229            .find_poly_implementors(source_qname)
14230            .into_iter()
14231            .map(|implementor| implementor.type_name)
14232            .collect();
14233        let matching: Vec<String> = self
14234            .find_poly_implementors(check_qname)
14235            .into_iter()
14236            .map(|implementor| implementor.type_name)
14237            .filter(|type_name| reachable.contains(type_name))
14238            .collect();
14239        if matching.is_empty() {
14240            return IrExpr::Literal(IrLiteral::Bool(false));
14241        }
14242        if matching.len() == reachable.len() {
14243            return IrExpr::Literal(IrLiteral::Bool(true));
14244        }
14245        matching
14246            .into_iter()
14247            .map(|type_name| {
14248                IrExpr::BinOp(Box::new(IrBinOp {
14249                    left: IrExpr::ColumnRef {
14250                        alias: alias.to_string(),
14251                        column: "__type__".into(),
14252                        pg_type: "text".into(),
14253                    },
14254                    op: crate::parse::ast::BinOpKind::Eq,
14255                    right: IrExpr::Literal(IrLiteral::Str(type_name)),
14256                }))
14257            })
14258            .reduce(|left, right| {
14259                IrExpr::BinOp(Box::new(IrBinOp {
14260                    left,
14261                    op: crate::parse::ast::BinOpKind::Or,
14262                    right,
14263                }))
14264            })
14265            .unwrap_or(IrExpr::Literal(IrLiteral::Bool(false)))
14266    }
14267
14268    /// Shared lookup for a bare 1-step name: for-loop variable, CTE binding,
14269    /// or function parameter. Used by both `compile_path`'s schema-bound
14270    /// prefix and `compile_free_path`.
14271    ///
14272    /// `allow_fn_param` used to be `false` from `compile_path`, so a parameter
14273    /// resolved only in free context. An object-returning body like
14274    /// `select Item filter .rank = v` compiles its filter against a schema
14275    /// anchor, so it never consulted `fn_params` and rejected the bare `v` as
14276    /// "absolute paths are not valid in expression context" — while the same
14277    /// parameter in a scalar-returning body worked, which is why the gap went
14278    /// unnoticed. A bare one-step name can only be a variable, CTE binding or
14279    /// parameter in either context (a property is always written `.name`), so
14280    /// there is nothing here for a parameter to shadow.
14281    fn resolve_name_ref(&mut self, name: &str, allow_fn_param: bool) -> Option<IrExpr> {
14282        if self.for_vars.contains_key(name) {
14283            return Some(self.for_var_ref(name));
14284        }
14285        // A free-object-bound CTE (`with x := { a := 1 } select ... x ...`),
14286        // referenced bare with no shape to project through, has nothing to
14287        // expose — a free *object* needs an explicit shape to know what to
14288        // return (unlike a tuple/named tuple, it has no "default"
14289        // projection), so this collapses to an empty free object, the same
14290        // as `Expr::Shape`'s `is_free_cte_ref` case for `x { ... }`.
14291        // It also sidesteps a real problem: this CTE has no "v" column
14292        // (only `IrFreeExpr::Scalar` CTEs get one), so falling through to
14293        // the generic `IrExpr::CteRef` below would reference a column that
14294        // doesn't exist.
14295        if let Some(IrFreeExpr::FreeObject(_)) = self.cte_free_items.get(name) {
14296            return Some(IrExpr::NamedTuple {
14297                fields: vec![],
14298                is_free_object: true,
14299            });
14300        }
14301        if let Some(ir) = self.inline_bindings.get(name) {
14302            return Some(ir.clone());
14303        }
14304        if let Some(t) = self.cte_types.get(name) {
14305            // A scalar binding records its pg type here (see `cte_stmt_type`);
14306            // an object binding records a `module::Type` name, which is not a
14307            // pg type and must not be handed to type inference.
14308            let scalar = !t.contains("::");
14309            return Some(IrExpr::CteRef {
14310                name: self.cte_sql_name(name),
14311                scalar,
14312                pg_type: (scalar && !t.is_empty()).then(|| literal_sentinel_to_pg(t).to_string()),
14313            });
14314        }
14315        if allow_fn_param && let Some(pg_type) = self.fn_params.get(name) {
14316            return Some(IrExpr::FnParam {
14317                name: name.to_string(),
14318                pg_type: pg_type.clone(),
14319            });
14320        }
14321        None
14322    }
14323
14324    /// The `(qualified type, alias)` an absolute `root.prop` should resolve
14325    /// against when the innermost select is `detached`.
14326    ///
14327    /// `None` in the ordinary case — the innermost select is not detached, or
14328    /// nothing outside it binds that type, in which case naming the type still
14329    /// means the current row.
14330    fn enclosing_anchor(&self, root: &str) -> Option<(String, String)> {
14331        let innermost = self.anchors.last()?;
14332        if !innermost.detached || !innermost.answers_to(root) {
14333            return None;
14334        }
14335        self.anchors
14336            .iter()
14337            .rev()
14338            .skip(1)
14339            .find(|a| a.answers_to(root))
14340            .map(|a| (a.qualified.clone(), a.alias.clone()))
14341    }
14342
14343    /// Whether `root` names the scope `self_qname` stands for, beyond its own
14344    /// two spellings — an inherited computed names its declaring type, which
14345    /// is the subject there too. See `SelectAnchor::declared_on`.
14346    fn scope_answers_to(&self, root: &str, self_qname: &str) -> bool {
14347        self.anchors
14348            .last()
14349            .is_some_and(|anchor| anchor.qualified == self_qname && anchor.answers_to(root))
14350    }
14351
14352    /// The innermost enclosing select binding `root` as its subject.
14353    fn outer_anchor(&self, root: &str) -> Option<(String, String)> {
14354        self.anchors
14355            .iter()
14356            .rev()
14357            .find(|a| a.answers_to(root))
14358            .map(|a| (a.qualified.clone(), a.alias.clone()))
14359    }
14360
14361    fn compile_path(&mut self, p: &ast::Path, td: &TypeDescriptor, alias: &str) -> Result<IrExpr, PyQLError> {
14362        if !p.partial {
14363            if p.steps.len() == 1
14364                && let ast::PathStep::Name(n) = &p.steps[0]
14365            {
14366                if let Some(ir) = self.resolve_name_ref(n, true) {
14367                    return Ok(ir);
14368                }
14369                // __type__ without a leading dot still means the current object's type.
14370                if n == "__type__" {
14371                    return Ok(if self.is_polymorphic(td) {
14372                        IrExpr::ColumnRef {
14373                            alias: alias.to_string(),
14374                            column: "__type__".to_string(),
14375                            pg_type: "text".to_string(),
14376                        }
14377                    } else {
14378                        IrExpr::Literal(IrLiteral::Str(format!("{}::{}", td.module, td.name)))
14379                    });
14380                }
14381            }
14382            // Enum member access: `default::Gender.Female`
14383            if p.steps.len() == 2
14384                && let [ast::PathStep::Name(type_ref), ast::PathStep::Name(variant)] = p.steps.as_slice()
14385                && self.resolve_enum(type_ref).is_some()
14386            {
14387                return self.compile_enum_access(type_ref, variant);
14388            }
14389            // `root.field1.field2...` where `root` is a WITH-bound free object.
14390            if let Some(resolved) = self.resolve_cte_path(p) {
14391                return resolved;
14392            }
14393            // `root.field1.field2...` where `root` is a WITH-bound *schema
14394            // object* (`with person := (select detached Person filter ...)
14395            // select ... person.company.name ...`). `compile_path_select`
14396            // already treats a CTE-bound root exactly like a real type name
14397            // (it checks `cte_types` before falling back to `resolve_type`,
14398            // via the `@cte:` source-table sentinel — see its own doc
14399            // comment and `compile_expr_as_path_select`'s sibling case for
14400            // `Detached`), so it already implements the *entire* general
14401            // path-traversal feature set here — forward links, multilinks,
14402            // backlinks, junction-backed links, nested tuple field access,
14403            // "did you mean" on a typo'd name — not just a one-property
14404            // special case. Wrapping its result as `IrExpr::PathSubquery`
14405            // (the same vehicle `Detached` uses for a type-rooted path in
14406            // expression position) turns the whole traversal into one
14407            // correlated scalar/id expression.
14408            if p.steps.len() > 1
14409                && let ast::PathStep::Name(root) = &p.steps[0]
14410                && self
14411                    .cte_types
14412                    .get(root.as_str())
14413                    .map(|t| t.contains("::"))
14414                    .unwrap_or(false)
14415            {
14416                let full_path = ast::Path {
14417                    steps: p.steps.clone(),
14418                    partial: false,
14419                };
14420                let synthetic = ast::SelectStmt {
14421                    result: Expr::Path(full_path.clone()),
14422                    filter: None,
14423                    order_by: vec![],
14424                    offset: None,
14425                    limit: None,
14426                    lock: None,
14427                };
14428                let ps = self.compile_path_select(&synthetic, &full_path, &[], false)?;
14429                return Ok(IrExpr::PathSubquery(Box::new(ps)));
14430            }
14431            // `__new__.prop` / `__old__.prop` — the inserted/updated/deleted
14432            // row a trigger handler is compiled against (see
14433            // `compile_trigger_handler`, which populates `special_anchors`
14434            // per the trigger's declared `on` events). Same rewrite-and-
14435            // recurse trick as the `TypeName.prop` case below, just handing
14436            // `compile_path` the anchor's own alias ("NEW"/"OLD") instead
14437            // of `td`'s. Outside trigger-handler compilation (or for the
14438            // anchor the current trigger's events don't legally bind —
14439            // e.g. `__old__` in an Insert-only trigger) `special_anchors`
14440            // is empty/missing that entry, so this falls through to the
14441            // explicit error below rather than the generic "absolute
14442            // paths" message: `__old__`/`__new__` cannot be used in
14443            // this expression.
14444            if p.steps.len() > 1
14445                && let ast::PathStep::Name(root) = &p.steps[0]
14446                && (root == "__new__" || root == "__old__")
14447            {
14448                if let Some((anchor_td, anchor_alias)) = self.special_anchors.get(root).cloned() {
14449                    let relative = ast::Path {
14450                        steps: p.steps[1..].to_vec(),
14451                        partial: true,
14452                    };
14453                    return self.compile_path(&relative, anchor_td, &anchor_alias);
14454                }
14455                return Err(PyQLError::Resolution(PyQLResolutionError::UnknownField(
14456                    PyQLUnknownFieldError {
14457                        message: format!("{root} cannot be used in this expression"),
14458                        position: Position { line: 0, col: 0 },
14459                    },
14460                )));
14461            }
14462            // `__subject__` is the row a constraint is checked against, which
14463            // is the same row a relative path reads.
14464            if p.steps.len() > 1 && matches!(&p.steps[0], ast::PathStep::Name(root) if root == "__subject__") {
14465                let relative = ast::Path {
14466                    steps: p.steps[1..].to_vec(),
14467                    partial: true,
14468                };
14469                return self.compile_path(&relative, td, alias);
14470            }
14471            // Absolute path rooted at the current td: `TypeName.prop` inside a schema-bound
14472            // expression (e.g. the value side of a BinOp in compile_expr_as_path_select).
14473            // Rewrite to a relative path and compile normally.
14474            if p.steps.len() > 1
14475                && let ast::PathStep::Name(root) = &p.steps[0]
14476            {
14477                let qualified = format!("{}::{}", td.module, td.name);
14478                if *root == td.name || *root == qualified {
14479                    let relative = ast::Path {
14480                        steps: p.steps[1..].to_vec(),
14481                        partial: true,
14482                    };
14483                    // Inside a `detached` select, naming its own type means the
14484                    // enclosing select's row — that is what makes an anti-join
14485                    // compare two different rows.
14486                    if let Some((outer_qualified, outer_alias)) = self.enclosing_anchor(root) {
14487                        let outer_td = self.resolve_type(&outer_qualified)?.clone();
14488                        return self.compile_path(&relative, &outer_td, &outer_alias);
14489                    }
14490                    return self.compile_path(&relative, td, alias);
14491                }
14492                // A prefix naming a type an *enclosing* select already binds —
14493                // `select Font { styles: { font := Font.id } }`. Such a prefix
14494                // factors out to the scope that binds it, so it reads that row
14495                // rather than every row of the type.
14496                if let Some((outer_qualified, outer_alias)) = self.outer_anchor(root) {
14497                    let relative = ast::Path {
14498                        steps: p.steps[1..].to_vec(),
14499                        partial: true,
14500                    };
14501                    let outer_td = self.resolve_type(&outer_qualified)?.clone();
14502                    return self.compile_path(&relative, &outer_td, &outer_alias);
14503                }
14504            }
14505            // `membership.account.id` — a walk off a for-loop variable in
14506            // expression position. `compile_path_select` already knows to
14507            // start such a walk from the row the variable holds, so this is
14508            // that walk read as one subquery.
14509            if let Some(ast::PathStep::Name(var)) = p.steps.first()
14510                && p.steps.len() > 1
14511                && self.for_var_types.contains_key(var)
14512                && !matches!(p.steps[1], ast::PathStep::TypeIntersection(_))
14513            {
14514                let synthetic = ast::SelectStmt {
14515                    result: Expr::Path(p.clone()),
14516                    filter: None,
14517                    order_by: vec![],
14518                    offset: None,
14519                    limit: None,
14520                    lock: None,
14521                };
14522                let ps = self.compile_path_select(&synthetic, p, &[], false)?;
14523                return Ok(IrExpr::PathSubquery(Box::new(ps)));
14524            }
14525            // `o[is Organization]` on a for-loop variable — the narrowing is
14526            // a filter, not a traversal: the row is read from the narrowed
14527            // type's own table by the key the variable holds, so a variable
14528            // bound to something else yields nothing, as it should.
14529            // `line[is BrandOrderLineItem].brand` — the narrowing picks the row
14530            // out of the narrowed type's own table, and the walk continues from
14531            // there. Without the tail this is the row itself, handled below.
14532            if let [
14533                ast::PathStep::Name(var),
14534                ast::PathStep::TypeIntersection(type_ref),
14535                rest @ ..,
14536            ] = p.steps.as_slice()
14537                && !rest.is_empty()
14538                && self.for_var_types.contains_key(var)
14539            {
14540                let type_name = match &type_ref.module {
14541                    Some(m) => format!("{}::{}", m, type_ref.name),
14542                    None => type_ref.name.clone(),
14543                };
14544                let narrowed = self.resolve_type(&type_name)?;
14545                let mut steps = vec![ast::PathStep::Name(format!("{}::{}", narrowed.module, narrowed.name))];
14546                steps.extend(rest.iter().cloned());
14547                let rooted = ast::Path { steps, partial: false };
14548                let synthetic = ast::SelectStmt {
14549                    result: Expr::Path(rooted.clone()),
14550                    filter: None,
14551                    order_by: vec![],
14552                    offset: None,
14553                    limit: None,
14554                    lock: None,
14555                };
14556                let mut ps = self.compile_path_select(&synthetic, &rooted, &[], false)?;
14557                let correlation = IrExpr::BinOp(Box::new(IrBinOp {
14558                    left: IrExpr::ColumnRef {
14559                        alias: ps.root.alias.clone(),
14560                        column: "id".to_string(),
14561                        pg_type: "uuid".to_string(),
14562                    },
14563                    op: ast::BinOpKind::Eq,
14564                    right: self.for_var_ref(var),
14565                }));
14566                ps.filter = and_conditions(ps.filter, vec![correlation]);
14567                return Ok(IrExpr::PathSubquery(Box::new(ps)));
14568            }
14569            if let [ast::PathStep::Name(var), ast::PathStep::TypeIntersection(type_ref)] = p.steps.as_slice()
14570                && self.for_var_types.contains_key(var)
14571            {
14572                let type_name = match &type_ref.module {
14573                    Some(m) => format!("{}::{}", m, type_ref.name),
14574                    None => type_ref.name.clone(),
14575                };
14576                let narrowed = self.resolve_type(&type_name)?;
14577                let narrowed_alias = self.fresh_alias();
14578                let source = IrSource {
14579                    poly: self.poly_fanout_for(&format!("{}::{}", narrowed.module, narrowed.name)),
14580                    type_name: format!("{}::{}", narrowed.module, narrowed.name),
14581                    table: narrowed.table.clone(),
14582                    alias: narrowed_alias.clone(),
14583                };
14584                let filter = IrExpr::BinOp(Box::new(IrBinOp {
14585                    left: IrExpr::ColumnRef {
14586                        alias: narrowed_alias,
14587                        column: "id".to_string(),
14588                        pg_type: "uuid".to_string(),
14589                    },
14590                    op: ast::BinOpKind::Eq,
14591                    right: self.for_var_ref(var),
14592                }));
14593                return Ok(IrExpr::Subquery(Box::new(IrSelect::schema_bound(
14594                    source,
14595                    Self::pk_returning(narrowed),
14596                    Some(filter),
14597                ))));
14598            }
14599            return Err(PyQLError::Type(PyQLTypeError {
14600                message: "absolute paths are not valid in expression context; use .name".into(),
14601                position: Position { line: 0, col: 0 },
14602            }));
14603        }
14604
14605        // A bare `@prop` — the junction row of the multi-link whose own
14606        // modifiers are being compiled. Read from the junction alias the
14607        // emitter puts in scope there ("jt"), the same one a `@prop` in the
14608        // nested shape reads.
14609        if p.partial
14610            && let [ast::PathStep::LinkProp(prop_name)] = p.steps.as_slice()
14611        {
14612            return self.compile_link_prop_ref(prop_name);
14613        }
14614
14615        // Type intersection in expression: [is Type].name — scalar subquery
14616        if p.partial && matches!(p.steps.first(), Some(ast::PathStep::TypeIntersection(_))) {
14617            return self.compile_type_intersection_expr(&p.steps, td, alias);
14618        }
14619
14620        // `.<assessment[is brand::AssessmentRespondent]` — a backlink read as a
14621        // value, with or without a narrowing. No column holds it, so it is the
14622        // traversal as a correlated subquery; the builders below only know
14623        // forward links and reject the intersection outright.
14624        if p.partial && matches!(p.steps.first(), Some(ast::PathStep::Backlink(_))) {
14625            return self.compile_partial_path_as_subquery(p, td, alias);
14626        }
14627        // `.ca.name` where `ca := a if cond else b` — the condition does not
14628        // depend on which branch is taken, so the field access distributes
14629        // over both and the walk happens inside each. Left whole, the walk
14630        // builder has no column to traverse through.
14631        if p.partial
14632            && p.steps.len() > 1
14633            && let Some(ast::PathStep::Name(first)) = p.steps.first()
14634            && let Some(Expr::IfElse(ie)) = self
14635                .active_declared_pointers
14636                .iter()
14637                .find(|d| path_leaf(&d.path).is_ok_and(|name| name == first))
14638                .and_then(|d| d.compexpr.clone())
14639        {
14640            let extend = |branch: &Expr| match branch {
14641                Expr::Path(bp) => {
14642                    let mut steps = bp.steps.clone();
14643                    steps.extend(p.steps[1..].iter().cloned());
14644                    Some(Expr::Path(ast::Path {
14645                        steps,
14646                        partial: bp.partial,
14647                    }))
14648                }
14649                // `(select .<brand[is T] … limit 1).priority` — the walk's
14650                // remaining steps projected off the sub-select.
14651                Expr::SubQuery(_) => p.steps[1..].iter().try_fold(branch.clone(), |expr, step| match step {
14652                    ast::PathStep::Name(field) => Some(Expr::FieldAccess {
14653                        expr: Box::new(expr),
14654                        field: field.clone(),
14655                    }),
14656                    _ => None,
14657                }),
14658                // Nothing to walk from, so nothing is reached.
14659                Expr::Set(items) if items.is_empty() => Some(Expr::Set(vec![])),
14660                Expr::TypeCast(cast) if matches!(&cast.expr, Expr::Set(items) if items.is_empty()) => {
14661                    Some(Expr::Set(vec![]))
14662                }
14663                _ => None,
14664            };
14665            if let (Some(if_expr), Some(else_expr)) = (extend(&ie.if_expr), extend(&ie.else_expr)) {
14666                let distributed = Expr::IfElse(Box::new(ast::IfElse {
14667                    condition: ie.condition.clone(),
14668                    if_expr,
14669                    else_expr,
14670                }));
14671                return self.compile_expr(&distributed, td, alias);
14672            }
14673        }
14674
14675        // A walk that ends in a type intersection (`exists .provider[is
14676        // account::Individual]`) narrows to objects rather than reading a
14677        // pointer, so there is no trailing column for the two-step form to
14678        // read and it goes the way a longer traversal already does.
14679        let narrows_last = matches!(p.steps.last(), Some(ast::PathStep::TypeIntersection(_)));
14680
14681        if p.steps.len() == 2 && !narrows_last {
14682            return self.compile_path_2step(p, td, alias);
14683        }
14684
14685        // Three or more steps: no single column to read, so the whole
14686        // traversal becomes one correlated subquery.
14687        if p.steps.len() != 1 {
14688            return self.compile_partial_path_as_subquery(p, td, alias);
14689        }
14690
14691        let pointer_name = match &p.steps[0] {
14692            ast::PathStep::Name(n) => n.as_str(),
14693            _ => {
14694                return Err(PyQLError::Type(PyQLTypeError {
14695                    message: "type intersections are not valid in expression context".into(),
14696                    position: Position { line: 0, col: 0 },
14697                }));
14698            }
14699        };
14700
14701        // __type__ as an expression: for polymorphic (interface) types, read from the
14702        // inline union column; for concrete types, emit the static qualified name.
14703        if pointer_name == "__type__" {
14704            return Ok(if self.is_polymorphic(td) {
14705                IrExpr::ColumnRef {
14706                    alias: alias.to_string(),
14707                    column: "__type__".to_string(),
14708                    pg_type: "text".to_string(),
14709                }
14710            } else {
14711                IrExpr::Literal(IrLiteral::Str(format!("{}::{}", td.module, td.name)))
14712            });
14713        }
14714
14715        if let Some(prop) = Self::resolve_property(td, pointer_name) {
14716            return Ok(IrExpr::ColumnRef {
14717                alias: alias.to_string(),
14718                column: prop.name.clone(),
14719                pg_type: prop.pg_type.clone(),
14720            });
14721        }
14722
14723        if let Some(link) = Self::resolve_link(td, pointer_name) {
14724            if link.is_junction_backed() {
14725                return self.junction_target_id_expr(td, link, alias);
14726            }
14727            // FK column reference (uuid) — e.g. `.company` → `t0."company_id"`
14728            return Ok(IrExpr::ColumnRef {
14729                alias: alias.to_string(),
14730                column: format!("{}_id", link.name),
14731                pg_type: "uuid".to_string(),
14732            });
14733        }
14734
14735        // Schema-defined computed pointer: inline the expression in place.
14736        if let Some(cd) = self.resolve_computed(td, pointer_name) {
14737            let expr_ast = crate::parse::parse_pointer_expr(&cd.expression).map_err(PyQLError::Syntax)?;
14738            return self.compile_expr(&expr_ast, td, alias);
14739        }
14740
14741        // `o in .organizations` — a multi-link read as a value is the set of
14742        // rows on the other side, which no column holds; the same traversal a
14743        // longer walk already becomes. Only property, link and computed were
14744        // resolved here, so a bare multi-link reported itself as unknown —
14745        // while the suggester, which does know them, offered the name back.
14746        if Self::resolve_multilink(td, pointer_name).is_some() {
14747            return self.compile_partial_path_as_subquery(p, td, alias);
14748        }
14749
14750        // `select (select T { a := … }) filter .a = …` — a pointer the shape
14751        // in scope declared is on no type, so it is read as the expression it
14752        // was written as. The shape route already resolves these; a filter or
14753        // order-by naming one arrives here instead.
14754        if let Some(expr) = self
14755            .active_declared_pointers
14756            .iter()
14757            .find(|d| path_leaf(&d.path).is_ok_and(|n| n == pointer_name))
14758            .and_then(|d| d.compexpr.clone())
14759        {
14760            return self.compile_expr(&expr, td, alias);
14761        }
14762
14763        Err(self.field_err(pointer_name, &format!("{}::{}", td.module, td.name)))
14764    }
14765
14766    /// Free-context counterpart of `compile_path` — no schema type/alias in
14767    /// scope, so only for-loop variables, CTE bindings, function parameters,
14768    /// and enum member access (`default::Gender.Female`) are resolvable;
14769    /// anything property/link-shaped is a hard error.
14770    fn compile_free_path(&mut self, p: &ast::Path) -> Result<IrExpr, PyQLError> {
14771        if p.partial {
14772            // A WITH binding inside a computed pointer or a nested SELECT is
14773            // compiled without a type in scope, but `.name` there still means
14774            // the innermost enclosing set — the one the anchor stack holds.
14775            if let Some((qualified, alias)) = self.anchors.last().map(|a| (a.qualified.clone(), a.alias.clone())) {
14776                let td = self.resolve_type(&qualified)?;
14777                return self.compile_path(p, td, &alias);
14778            }
14779            return Err(self.type_err(
14780                "property reference (.name) is not valid in free SELECT; \
14781                 use a schema-bound SELECT instead",
14782            ));
14783        }
14784        if p.steps.len() == 2
14785            && let [ast::PathStep::Name(type_ref), ast::PathStep::Name(variant)] = p.steps.as_slice()
14786            && self.resolve_enum(type_ref).is_some()
14787        {
14788            return self.compile_enum_access(type_ref, variant);
14789        }
14790        // `root.field1.field2...` where `root` is a WITH-bound free object
14791        // (any length >= 2, including chains through nested free objects).
14792        if let Some(resolved) = self.resolve_cte_path(p) {
14793            return resolved;
14794        }
14795        if p.steps.len() == 1
14796            && let ast::PathStep::Name(n) = &p.steps[0]
14797            && let Some(ir) = self.resolve_name_ref(n, true)
14798        {
14799            return Ok(ir);
14800        }
14801        // `assessment.current_question` in a free shape's field or filter — a
14802        // walk off an object binding. It is the same traversal a bound select
14803        // makes, read back as one subquery; there is no enclosing row to
14804        // resolve it against, and nothing else here knows a binding can root a
14805        // path.
14806        if !p.partial
14807            && p.steps.len() > 1
14808            && let Some(ast::PathStep::Name(root)) = p.steps.first()
14809            && self.cte_object_type(root).is_some()
14810        {
14811            let synthetic = ast::SelectStmt {
14812                result: Expr::Path(p.clone()),
14813                filter: None,
14814                order_by: vec![],
14815                offset: None,
14816                limit: None,
14817                lock: None,
14818            };
14819            let ps = self.compile_path_select(&synthetic, p, &[], false)?;
14820            // A walk made only of forward single links does not multiply rows,
14821            // so its cardinality is the root binding's. Treating every join as
14822            // widening made `(select T filter .id = $x).link.prop` a
14823            // one-element set rather than the value itself.
14824            let widens = ps.joins.iter().any(|join| !matches!(join, IrPathJoin::Single { .. }));
14825            let root_is_multi = self.multi_row_ctes.contains(root.as_str());
14826            let multi = matches!(ps.result, IrPathResult::Scalar(..)) && (widens || root_is_multi);
14827            return Ok(if multi {
14828                IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(ps))))
14829            } else {
14830                IrExpr::PathSubquery(Box::new(ps))
14831            });
14832        }
14833        Err(self.type_err("expression is not valid in free SELECT context"))
14834    }
14835
14836    fn compile_path_2step(&mut self, p: &ast::Path, td: &TypeDescriptor, alias: &str) -> Result<IrExpr, PyQLError> {
14837        let link_name = match &p.steps[0] {
14838            ast::PathStep::Name(n) => n.as_str(),
14839            _ => {
14840                return Err(PyQLError::Type(PyQLTypeError {
14841                    message: "type intersections are not valid in expression context".into(),
14842                    position: Position { line: 0, col: 0 },
14843                }));
14844            }
14845        };
14846        let pointer_name = match &p.steps[1] {
14847            ast::PathStep::Name(n) => n.as_str(),
14848            _ => {
14849                return Err(PyQLError::Type(PyQLTypeError {
14850                    message: "type intersections are not valid in expression context".into(),
14851                    position: Position { line: 0, col: 0 },
14852                }));
14853            }
14854        };
14855
14856        if let Some(link) = Self::resolve_link(td, link_name) {
14857            if pointer_name == "id" {
14858                if link.is_junction_backed() {
14859                    return self.junction_target_id_expr(td, link, alias);
14860                }
14861                return Ok(IrExpr::ColumnRef {
14862                    alias: alias.to_string(),
14863                    column: format!("{}_id", link_name),
14864                    pg_type: "uuid".to_string(),
14865                });
14866            }
14867            let target_td = self.resolve_type(&link.target)?;
14868            if let Some(prop) = Self::resolve_property(target_td, pointer_name) {
14869                let ft_alias = self.fresh_alias();
14870                let target_id_expr = if link.is_junction_backed() {
14871                    self.junction_target_id_expr(td, link, alias)?
14872                } else {
14873                    IrExpr::ColumnRef {
14874                        alias: alias.to_string(),
14875                        column: format!("{}_id", link_name),
14876                        pg_type: "uuid".to_string(),
14877                    }
14878                };
14879                return Ok(IrExpr::Subquery(Box::new(IrSelect::schema_bound(
14880                    IrSource {
14881                        poly: None,
14882                        type_name: format!("{}::{}", target_td.module, target_td.name),
14883                        table: target_td.table.clone(),
14884                        alias: ft_alias.clone(),
14885                    },
14886                    vec![IrShapePointer::Scalar(IrScalarPointer {
14887                        implicit_id: false,
14888                        marker_offset: None,
14889                        alias: prop.name.clone(),
14890                        column: prop.name.clone(),
14891                        pg_type: prop.pg_type.clone(),
14892                        tuple_shape: self.resolve_property_tuple_shape(prop),
14893                    })],
14894                    Some(IrExpr::BinOp(Box::new(IrBinOp {
14895                        left: IrExpr::ColumnRef {
14896                            alias: ft_alias.clone(),
14897                            column: "id".to_string(),
14898                            pg_type: "uuid".to_string(),
14899                        },
14900                        op: ast::BinOpKind::Eq,
14901                        right: target_id_expr,
14902                    }))),
14903                ))));
14904            }
14905            // Not a stored column on the target — a computed pointer, or a
14906            // further link. The general traversal builder resolves both.
14907            return self.compile_partial_path_as_subquery(p, td, alias);
14908        }
14909
14910        // A multi-link path stands for a set of values. Inside a comparison
14911        // it has already been rewritten to an EXISTS (`try_multilink_exists`,
14912        // which runs first); everywhere else it is an array.
14913        if Self::resolve_multilink(td, link_name).is_some() {
14914            return self.compile_partial_path_as_subquery(p, td, alias);
14915        }
14916
14917        // Not a stored pointer at all — a computed one, most likely, which
14918        // the general builder can traverse through by splicing in the path
14919        // it stands for. It raises the same "no link or property" error this
14920        // used to when the name really is unknown (which is what made a
14921        // computed head read as `has no link or property 'x'. Did you mean
14922        // 'x'?` — the suggester could see it, the resolver couldn't).
14923        self.compile_partial_path_as_subquery(p, td, alias)
14924    }
14925
14926    // ── Backlink compilation ─────────────────────────────────────────────────────
14927
14928    /// Detect a backlink path on either side of a BinOp and compile as EXISTS.
14929    fn try_backlink_exists(
14930        &mut self,
14931        b: &ast::BinOp,
14932        td: &TypeDescriptor,
14933        alias: &str,
14934    ) -> Result<Option<IrExpr>, PyQLError> {
14935        use ast::PathStep;
14936        fn is_backlink(p: &ast::Path) -> bool {
14937            p.partial && matches!(p.steps.first(), Some(PathStep::Backlink(_)))
14938        }
14939        let (path_steps, value_ast, flip) = if let Expr::Path(p) = &b.left {
14940            if is_backlink(p) {
14941                (p.steps.as_slice(), &b.right, false)
14942            } else {
14943                return Ok(None);
14944            }
14945        } else if let Expr::Path(p) = &b.right {
14946            if is_backlink(p) {
14947                (p.steps.as_slice(), &b.left, true)
14948            } else {
14949                return Ok(None);
14950            }
14951        } else {
14952            return Ok(None);
14953        };
14954        let value_expr = self.compile_comparison_value(value_ast, &b.op, td, alias)?;
14955        let current_qname = format!("{}::{}", td.module, td.name);
14956        let exists = self.compile_backlink_as_exists(
14957            path_steps,
14958            Some((b.op.clone(), value_expr, flip)),
14959            &current_qname,
14960            alias,
14961        )?;
14962        Ok(Some(exists))
14963    }
14964
14965    /// Compile a path `[Backlink(name), TypeIntersect(type), ...rest]` into EXISTS.
14966    /// `comparison` is `Some((op, value_expr, flip))` when used in a comparison filter.
14967    /// `current_qname` is the fully-qualified name of the object type being filtered.
14968    fn compile_backlink_as_exists(
14969        &mut self,
14970        steps: &[ast::PathStep],
14971        comparison: Option<(ast::BinOpKind, IrExpr, bool)>,
14972        current_qname: &str,
14973        alias: &str,
14974    ) -> Result<IrExpr, PyQLError> {
14975        use ast::PathStep;
14976
14977        let backlink_name = match steps.first() {
14978            Some(PathStep::Backlink(n)) => n.clone(),
14979            _ => return Err(self.type_err("internal: expected backlink step")),
14980        };
14981        // Without a type intersection the backlink spans every type that
14982        // declares the link at this target, so the row qualifies if any one of
14983        // them points at it.
14984        let Some(PathStep::TypeIntersection(type_ref)) = steps.get(1) else {
14985            // Without a type intersection the backlink spans every type that
14986            // declares the link at this target.
14987            return self.backlink_exists_over_owners(
14988                None,
14989                &backlink_name,
14990                &steps[1..],
14991                comparison,
14992                current_qname,
14993                alias,
14994            );
14995        };
14996
14997        let type_name = match &type_ref.module {
14998            Some(m) => format!("{}::{}", m, type_ref.name),
14999            None => type_ref.name.clone(),
15000        };
15001        let target_td = self.resolve_type(&type_name)?;
15002        if self.declares_backlink(target_td, &backlink_name, current_qname) {
15003            return self.backlink_exists_for_owner(
15004                target_td,
15005                &backlink_name,
15006                &steps[2..],
15007                comparison,
15008                current_qname,
15009                alias,
15010            );
15011        }
15012        // The intersection narrows to an interface or mixin that does not
15013        // declare the link itself — its implementors do, and a row of one of
15014        // them satisfies `[is ThatType]` all the same. Qualified, because that
15015        // is how an implementor names what it implements.
15016        let narrow_to = format!("{}::{}", target_td.module, target_td.name);
15017        self.backlink_exists_over_owners(
15018            Some(&narrow_to),
15019            &backlink_name,
15020            &steps[2..],
15021            comparison,
15022            current_qname,
15023            alias,
15024        )
15025    }
15026
15027    /// The type that declares the link a backlink narrowed to `owner_td`
15028    /// reads through.
15029    fn backlink_owner(
15030        &self,
15031        owner_td: &'a TypeDescriptor,
15032        backlink_name: &str,
15033        current_qname: &str,
15034    ) -> Result<&'a TypeDescriptor, PyQLError> {
15035        // `.<passkeys[is account::Account]` — the intersection narrows what
15036        // comes back, and the link itself may be declared further down: here
15037        // `passkeys` is Individual's, and an Individual is an Account. So the
15038        // owner is the concrete type that actually declares it, the same way
15039        // `backlink_exists_over_owners` resolves one.
15040        Ok(if self.declares_backlink(owner_td, backlink_name, current_qname) {
15041            owner_td
15042        } else {
15043            // Qualified, because that is how a type records the interfaces it
15044            // implements; the query may well have written the bare name.
15045            let narrowed = format!("{}::{}", owner_td.module, owner_td.name);
15046            let declaring: Vec<&'a TypeDescriptor> = self
15047                .schema
15048                .types
15049                .iter()
15050                .filter(|t| !t.abstract_ && Self::is_or_implements(t, &narrowed))
15051                .filter(|t| self.declares_backlink(t, backlink_name, current_qname))
15052                .collect();
15053            let declaring = Self::without_inherited_owners(declaring);
15054            match declaring.as_slice() {
15055                [only] => only,
15056                [] => owner_td,
15057                several => {
15058                    return Err(self.type_err(&format!(
15059                        "'{backlink_name}' pointing to {current_qname} is declared by {} types under \
15060                         {narrowed} ({}), so a backlink narrowed to it has no single source to read \
15061                         — narrow to one of them instead",
15062                        several.len(),
15063                        several
15064                            .iter()
15065                            .map(|t| format!("{}::{}", t.module, t.name))
15066                            .collect::<Vec<_>>()
15067                            .join(", "),
15068                    )));
15069                }
15070            }
15071        })
15072    }
15073
15074    /// Whether a backlink narrowed to `owner_td` reaches at most one object,
15075    /// which it does through an exclusive link.
15076    fn backlink_is_single(&self, owner_td: &'a TypeDescriptor, backlink_name: &str, current_qname: &str) -> bool {
15077        let Ok(owner) = self.backlink_owner(owner_td, backlink_name, current_qname) else {
15078            return false;
15079        };
15080        owner
15081            .links
15082            .iter()
15083            .any(|l| l.name == backlink_name && l.is_exclusive && self.link_target_reaches(&l.target, current_qname))
15084            || owner.multilinks.iter().any(|ml| {
15085                ml.name == backlink_name && ml.is_exclusive && self.link_target_reaches(&ml.target, current_qname)
15086            })
15087    }
15088
15089    /// Does `td` declare the link a backlink names, pointing at the type the
15090    /// traversal is standing on?
15091    fn declares_backlink(&self, td: &TypeDescriptor, backlink_name: &str, current_qname: &str) -> bool {
15092        td.links
15093            .iter()
15094            .any(|l| l.name == backlink_name && self.link_target_reaches(&l.target, current_qname))
15095            || td
15096                .multilinks
15097                .iter()
15098                .any(|ml| ml.name == backlink_name && self.link_target_reaches(&ml.target, current_qname))
15099    }
15100
15101    /// EXISTS over every type that declares the backlink's link, OR-ed
15102    /// together: a row qualifies if any one of them points at it. `narrow_to`
15103    /// keeps only the types that satisfy an `[is …]` the path asked for.
15104    #[allow(clippy::too_many_arguments)]
15105    /// Drops the owners that only inherit the link from another owner in the
15106    /// list: reading the base already reads its subtypes' rows.
15107    fn without_inherited_owners(owners: Vec<&'a TypeDescriptor>) -> Vec<&'a TypeDescriptor> {
15108        let qnames: Vec<String> = owners.iter().map(|t| format!("{}::{}", t.module, t.name)).collect();
15109        owners
15110            .into_iter()
15111            .filter(|t| !t.bases.iter().any(|base| qnames.contains(base)))
15112            .collect()
15113    }
15114
15115    fn backlink_exists_over_owners(
15116        &mut self,
15117        narrow_to: Option<&str>,
15118        backlink_name: &str,
15119        rest: &[ast::PathStep],
15120        comparison: Option<(ast::BinOpKind, IrExpr, bool)>,
15121        current_qname: &str,
15122        alias: &str,
15123    ) -> Result<IrExpr, PyQLError> {
15124        let schema = self.schema;
15125        let owners: Vec<&'a TypeDescriptor> = schema
15126            .types
15127            .iter()
15128            .filter(|t| !t.abstract_)
15129            .filter(|t| narrow_to.is_none_or(|q| Self::is_or_implements(t, q)))
15130            .filter(|t| self.declares_backlink(t, backlink_name, current_qname))
15131            .collect();
15132        let owners = Self::without_inherited_owners(owners);
15133        if owners.is_empty() {
15134            return Err(self.type_err(&match narrow_to {
15135                Some(q) => format!("type {q} has no link or multi-link '{backlink_name}' pointing to {current_qname}"),
15136                None => format!("no type has a link or multi-link '{backlink_name}' pointing to {current_qname}"),
15137            }));
15138        }
15139        let mut combined: Option<IrExpr> = None;
15140        for owner_td in owners {
15141            let one = self.backlink_exists_for_owner(
15142                owner_td,
15143                backlink_name,
15144                rest,
15145                comparison.clone(),
15146                current_qname,
15147                alias,
15148            )?;
15149            combined = Some(match combined {
15150                None => one,
15151                Some(previous) => IrExpr::BinOp(Box::new(IrBinOp {
15152                    left: previous,
15153                    op: ast::BinOpKind::Or,
15154                    right: one,
15155                })),
15156            });
15157        }
15158        Ok(combined.expect("owners is non-empty"))
15159    }
15160
15161    /// Is `td` the type `qname` names, or one that implements/extends it?
15162    fn is_or_implements(td: &TypeDescriptor, qname: &str) -> bool {
15163        format!("{}::{}", td.module, td.name) == qname
15164            || td.interfaces.iter().any(|i| i == qname)
15165            || td.parents.iter().any(|p| p == qname)
15166            || td.bases.iter().any(|b| b == qname)
15167    }
15168
15169    /// A concrete type another concrete type extends (`BrandAddon`, which
15170    /// `BrandAddonBundle` extends). Its own table holds only its own rows, so
15171    /// reading the type means reading the subtypes' tables beside it.
15172    fn has_subtypes(&self, td: &TypeDescriptor) -> bool {
15173        let qname = format!("{}::{}", td.module, td.name);
15174        !td.abstract_ && self.schema.types.iter().any(|t| t.bases.contains(&qname))
15175    }
15176
15177    /// A plain `@pylon.abstract` mixin, materialised as nothing at all.
15178    /// Unlike an interface it backs no view, so naming its table reaches a
15179    /// relation that does not exist.
15180    fn backs_no_relation(td: &TypeDescriptor) -> bool {
15181        td.abstract_ && !td.materialized
15182    }
15183
15184    /// See `IrOutput::subtype_fanouts`.
15185    fn subtype_fanouts(&self) -> HashMap<(String, String), IrPolyFanout> {
15186        self.schema
15187            .types
15188            .iter()
15189            .filter(|t| self.has_subtypes(t) || Self::backs_no_relation(t))
15190            .filter_map(|t| {
15191                let fanout = self.poly_fanout_for(&format!("{}::{}", t.module, t.name))?;
15192                Some(((t.module.clone(), t.table.clone()), fanout))
15193            })
15194            .collect()
15195    }
15196
15197    /// Whether the type's rows come from more than one table — an interface,
15198    /// an abstract type (which has no table of its own), or a concrete type
15199    /// with subtypes — and so are read through the union that carries each
15200    /// row's own `__type__`.
15201    fn is_polymorphic(&self, td: &TypeDescriptor) -> bool {
15202        td.abstract_ || self.has_subtypes(td)
15203    }
15204
15205    /// One owner type's half of `compile_backlink_as_exists`: EXISTS over the
15206    /// rows of `target_td` that link back to `alias`, plus whatever the
15207    /// remaining path steps and comparison require of them.
15208    #[allow(clippy::too_many_arguments)]
15209    fn backlink_exists_for_owner(
15210        &mut self,
15211        target_td: &'a TypeDescriptor,
15212        backlink_name: &str,
15213        rest: &[ast::PathStep],
15214        comparison: Option<(ast::BinOpKind, IrExpr, bool)>,
15215        current_qname: &str,
15216        alias: &str,
15217    ) -> Result<IrExpr, PyQLError> {
15218        let backlink_name = backlink_name.to_string();
15219        let target_qname = format!("{}::{}", target_td.module, target_td.name);
15220        let target_table = target_td.table.clone();
15221        let t_alias = self.fresh_alias();
15222
15223        // Backlink source is either a single (FK) link or a multi-link
15224        // (junction table) on the target type — mirrors the equivalent
15225        // link-vs-multilink resolution the general shape-position backlink
15226        // code already does (see the `PathStep::Backlink` handling above in
15227        // `compile_path_expr`/similar), which this filter/exists-specific
15228        // path previously didn't: it only ever checked `target_td.links`,
15229        // so a self-referential-multilink backlink like `Person.friends`
15230        // failed to compile here even though it worked in a shape position.
15231        let join_cond = if let Some(l) = target_td
15232            .links
15233            .iter()
15234            .find(|l| l.name == backlink_name && self.link_target_reaches(&l.target, current_qname))
15235        {
15236            if l.is_junction_backed() {
15237                // Same junction-table EXISTS shape the multi-link branch
15238                // below uses — no direct FK column, since this link is
15239                // itself junction-backed.
15240                let (jt_table, jt_module, jt_owner_col, jt_current_col, _) = self.link_junction_info(target_td, l)?;
15241                let jt_alias = self.fresh_alias();
15242                IrExpr::UnaryOp(Box::new(IrUnaryOp {
15243                    op: ast::UnaryOpKind::Exists,
15244                    operand: IrExpr::Subquery(Box::new(IrSelect::schema_bound(
15245                        IrSource {
15246                            poly: None,
15247                            type_name: format!("{}::__jt__", jt_module),
15248                            table: jt_table,
15249                            alias: jt_alias.clone(),
15250                        },
15251                        vec![],
15252                        Some(IrExpr::BinOp(Box::new(IrBinOp {
15253                            left: IrExpr::BinOp(Box::new(IrBinOp {
15254                                left: IrExpr::ColumnRef {
15255                                    alias: jt_alias.clone(),
15256                                    column: jt_owner_col,
15257                                    pg_type: "uuid".to_string(),
15258                                },
15259                                op: ast::BinOpKind::Eq,
15260                                right: IrExpr::ColumnRef {
15261                                    alias: t_alias.clone(),
15262                                    column: "id".to_string(),
15263                                    pg_type: "uuid".to_string(),
15264                                },
15265                            })),
15266                            op: ast::BinOpKind::And,
15267                            right: IrExpr::BinOp(Box::new(IrBinOp {
15268                                left: IrExpr::ColumnRef {
15269                                    alias: jt_alias,
15270                                    column: jt_current_col,
15271                                    pg_type: "uuid".to_string(),
15272                                },
15273                                op: ast::BinOpKind::Eq,
15274                                right: IrExpr::ColumnRef {
15275                                    alias: alias.to_string(),
15276                                    column: "id".to_string(),
15277                                    pg_type: "uuid".to_string(),
15278                                },
15279                            })),
15280                        }))),
15281                    ))),
15282                }))
15283            } else {
15284                let fk_col = format!("{}_id", backlink_name);
15285                // Join condition: target.fk_col = current.id
15286                IrExpr::BinOp(Box::new(IrBinOp {
15287                    left: IrExpr::ColumnRef {
15288                        alias: t_alias.clone(),
15289                        column: fk_col,
15290                        pg_type: "uuid".to_string(),
15291                    },
15292                    op: ast::BinOpKind::Eq,
15293                    right: IrExpr::ColumnRef {
15294                        alias: alias.to_string(),
15295                        column: "id".to_string(),
15296                        pg_type: "uuid".to_string(),
15297                    },
15298                }))
15299            }
15300        } else if let Some(ml) = target_td
15301            .multilinks
15302            .iter()
15303            .find(|ml| ml.name == backlink_name && self.link_target_reaches(&ml.target, current_qname))
15304            .cloned()
15305        {
15306            // Junction row connects t_alias (as the multi-link's owner/
15307            // "source") to the current row (as its "target") — no direct FK
15308            // column on either table, so this is a nested EXISTS over the
15309            // junction table rather than a simple column comparison.
15310            let (jt_table, jt_module, _, _, _) = self.multilink_junction_info(target_td, &ml)?;
15311            let jt_alias = self.fresh_alias();
15312            IrExpr::UnaryOp(Box::new(IrUnaryOp {
15313                op: ast::UnaryOpKind::Exists,
15314                operand: IrExpr::Subquery(Box::new(IrSelect::schema_bound(
15315                    IrSource {
15316                        poly: None,
15317                        type_name: format!("{}::__jt__", jt_module),
15318                        table: jt_table,
15319                        alias: jt_alias.clone(),
15320                    },
15321                    vec![],
15322                    Some(IrExpr::BinOp(Box::new(IrBinOp {
15323                        left: IrExpr::BinOp(Box::new(IrBinOp {
15324                            left: IrExpr::ColumnRef {
15325                                alias: jt_alias.clone(),
15326                                column: "source".to_string(),
15327                                pg_type: "uuid".to_string(),
15328                            },
15329                            op: ast::BinOpKind::Eq,
15330                            right: IrExpr::ColumnRef {
15331                                alias: t_alias.clone(),
15332                                column: "id".to_string(),
15333                                pg_type: "uuid".to_string(),
15334                            },
15335                        })),
15336                        op: ast::BinOpKind::And,
15337                        right: IrExpr::BinOp(Box::new(IrBinOp {
15338                            left: IrExpr::ColumnRef {
15339                                alias: jt_alias,
15340                                column: "target".to_string(),
15341                                pg_type: "uuid".to_string(),
15342                            },
15343                            op: ast::BinOpKind::Eq,
15344                            right: IrExpr::ColumnRef {
15345                                alias: alias.to_string(),
15346                                column: "id".to_string(),
15347                                pg_type: "uuid".to_string(),
15348                            },
15349                        })),
15350                    }))),
15351                ))),
15352            }))
15353        } else {
15354            return Err(PyQLError::Type(PyQLTypeError {
15355                message: format!(
15356                    "type {} has no link or multi-link '{}' pointing to {}",
15357                    target_qname, backlink_name, current_qname,
15358                ),
15359                position: Position { line: 0, col: 0 },
15360            }));
15361        };
15362
15363        let tail_cond = self.compile_backlink_tail(rest, comparison, &target_qname, &t_alias)?;
15364
15365        let filter = match tail_cond {
15366            Some(tc) => IrExpr::BinOp(Box::new(IrBinOp {
15367                left: join_cond,
15368                op: ast::BinOpKind::And,
15369                right: tc,
15370            })),
15371            None => join_cond,
15372        };
15373
15374        Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
15375            op: ast::UnaryOpKind::Exists,
15376            operand: IrExpr::Subquery(Box::new(IrSelect::schema_bound(
15377                IrSource {
15378                    poly: None,
15379                    type_name: target_qname,
15380                    table: target_table,
15381                    alias: t_alias,
15382                },
15383                vec![],
15384                Some(filter),
15385            ))),
15386        })))
15387    }
15388
15389    /// Compile the tail steps after `[Backlink, TypeIntersect]`.
15390    fn compile_backlink_tail(
15391        &mut self,
15392        steps: &[ast::PathStep],
15393        comparison: Option<(ast::BinOpKind, IrExpr, bool)>,
15394        target_qname: &str,
15395        t_alias: &str,
15396    ) -> Result<Option<IrExpr>, PyQLError> {
15397        use ast::PathStep;
15398
15399        // `.<contacts[is Account] = account` — nothing after the backlink, so
15400        // the objects it reaches are what is compared, by identity. Returning
15401        // nothing here dropped the comparison, leaving "any account links to
15402        // this row" in its place.
15403        if steps.is_empty() {
15404            return Ok(comparison.map(|(op, value, flip)| {
15405                let key = IrExpr::ColumnRef {
15406                    alias: t_alias.to_string(),
15407                    column: "id".to_string(),
15408                    pg_type: "uuid".to_string(),
15409                };
15410                let (left, right) = if flip { (value, key) } else { (key, value) };
15411                IrExpr::BinOp(Box::new(IrBinOp { left, op, right }))
15412            }));
15413        }
15414
15415        // Chained backlink: recurse (current_qname is now target_qname of the outer backlink)
15416        if matches!(steps.first(), Some(PathStep::Backlink(_))) {
15417            let inner = self.compile_backlink_as_exists(steps, comparison, target_qname, t_alias)?;
15418            return Ok(Some(inner));
15419        }
15420
15421        let target_td = self.resolve_type(target_qname)?;
15422
15423        // Single property or link FK
15424        if let [PathStep::Name(pointer_name)] = steps {
15425            if let Some(prop) = Self::resolve_property(target_td, pointer_name) {
15426                let col = IrExpr::ColumnRef {
15427                    alias: t_alias.to_string(),
15428                    column: prop.name.clone(),
15429                    pg_type: prop.pg_type.clone(),
15430                };
15431                return Ok(Some(self.apply_comparison(col, comparison)));
15432            }
15433            if let Some(link) = Self::resolve_link(target_td, pointer_name) {
15434                let col = if link.is_junction_backed() {
15435                    self.junction_target_id_expr(target_td, link, t_alias)?
15436                } else {
15437                    IrExpr::ColumnRef {
15438                        alias: t_alias.to_string(),
15439                        column: format!("{}_id", link.name),
15440                        pg_type: "uuid".to_string(),
15441                    }
15442                };
15443                return Ok(Some(self.apply_comparison(col, comparison)));
15444            }
15445            // Not a property or a link: a multi-link has no single column to
15446            // read, so it falls through to the general tail walk below, which
15447            // reports the same error for a name that is none of the three.
15448        }
15449
15450        // Two forward steps: link then property (single FK join)
15451        if let [PathStep::Name(link_name), PathStep::Name(prop_name)] = steps
15452            && let Some(link) = Self::resolve_link(target_td, link_name)
15453        {
15454            let link_target = link.target.clone();
15455            let target_id_expr = if link.is_junction_backed() {
15456                self.junction_target_id_expr(target_td, link, t_alias)?
15457            } else {
15458                IrExpr::ColumnRef {
15459                    alias: t_alias.to_string(),
15460                    column: format!("{}_id", link_name),
15461                    pg_type: "uuid".to_string(),
15462                }
15463            };
15464            let link_target_td = self.resolve_type(&link_target)?;
15465            let link_target_qname = format!("{}::{}", link_target_td.module, link_target_td.name);
15466            let link_target_table = link_target_td.table.clone();
15467            if let Some(prop) = Self::resolve_property(link_target_td, prop_name) {
15468                let l_alias = self.fresh_alias();
15469                let id_cond = IrExpr::BinOp(Box::new(IrBinOp {
15470                    left: IrExpr::ColumnRef {
15471                        alias: l_alias.clone(),
15472                        column: "id".to_string(),
15473                        pg_type: "uuid".to_string(),
15474                    },
15475                    op: ast::BinOpKind::Eq,
15476                    right: target_id_expr,
15477                }));
15478                let col = IrExpr::ColumnRef {
15479                    alias: l_alias.clone(),
15480                    column: prop.name.clone(),
15481                    pg_type: prop.pg_type.clone(),
15482                };
15483                let prop_cond = self.apply_comparison(col, comparison);
15484                let full = IrExpr::BinOp(Box::new(IrBinOp {
15485                    left: id_cond,
15486                    op: ast::BinOpKind::And,
15487                    right: prop_cond,
15488                }));
15489                return Ok(Some(IrExpr::UnaryOp(Box::new(IrUnaryOp {
15490                    op: ast::UnaryOpKind::Exists,
15491                    operand: IrExpr::Subquery(Box::new(IrSelect::schema_bound(
15492                        IrSource {
15493                            poly: None,
15494                            type_name: link_target_qname,
15495                            table: link_target_table,
15496                            alias: l_alias,
15497                        },
15498                        vec![],
15499                        Some(full),
15500                    ))),
15501                }))));
15502            }
15503        }
15504
15505        // Anything else is an ordinary path from the backlink's target type, so
15506        // it is compiled as one rather than enumerated arity by arity. The two
15507        // cases above stay because each reads a single column; a step that
15508        // crosses a multi-link has none to read.
15509        let tail = ast::Path {
15510            steps: steps.to_vec(),
15511            partial: true,
15512        };
15513        let walked = self.compile_path(&tail, target_td, t_alias)?;
15514        Ok(Some(self.apply_comparison(walked, comparison)))
15515    }
15516
15517    /// Whether `e` stands for a *set* rather than one value: a walk gathered
15518    /// as an array, or a `with` binding of more than one row, which arrives as
15519    /// a reference to its CTE. Reading only the first of those left `filter
15520    /// .posts = ps` comparing against `(SELECT id FROM ps)` — one value, and
15521    /// an error from Postgres as soon as the binding held two.
15522    fn is_set_expr(&self, e: &IrExpr) -> bool {
15523        match e {
15524            IrExpr::ArrayFromSelect(_) => true,
15525            IrExpr::CteRef { name, .. } => self.multi_row_ctes.contains(name),
15526            _ => false,
15527        }
15528    }
15529
15530    /// Apply an optional comparison to a column ref, defaulting to IS NOT NULL.
15531    fn apply_comparison(&self, col: IrExpr, comparison: Option<(ast::BinOpKind, IrExpr, bool)>) -> IrExpr {
15532        let is_set = |e: &IrExpr| self.is_set_expr(e);
15533        match comparison {
15534            Some((op, val, flip)) => {
15535                let (l, r) = if flip { (val, col) } else { (col, val) };
15536                // A walk that crosses a multi-link stands for a *set*, which
15537                // an equality holds against when any element matches — the
15538                // same reading `compile_expr_ctx` gives one anywhere else.
15539                if matches!(op, ast::BinOpKind::Eq | ast::BinOpKind::Ne) && is_set(&l) != is_set(&r) {
15540                    let (value, set) = if is_set(&r) { (l, r) } else { (r, l) };
15541                    let membership = IrExpr::BinOp(Box::new(IrBinOp {
15542                        left: value,
15543                        op: ast::BinOpKind::In,
15544                        right: set,
15545                    }));
15546                    return if matches!(op, ast::BinOpKind::Ne) {
15547                        IrExpr::UnaryOp(Box::new(IrUnaryOp {
15548                            op: ast::UnaryOpKind::Not,
15549                            operand: membership,
15550                        }))
15551                    } else {
15552                        membership
15553                    };
15554                }
15555                IrExpr::BinOp(Box::new(IrBinOp { left: l, op, right: r }))
15556            }
15557            None => ir_is_not_null(col),
15558        }
15559    }
15560
15561    /// The other side of a comparison whose path crosses a multi-link or a
15562    /// backlink, ready to sit inside the EXISTS that walk becomes.
15563    ///
15564    /// A set literal is meaningless as an expression anywhere else, so the
15565    /// general compiler hard-errors on one — but on the right of `in` it is
15566    /// exactly the array `= ANY(…)` wants, the same reading the `In`/`NotIn`
15567    /// arm of `compile_expr_ctx` gives it. Without this, `filter .posts.title
15568    /// in {'a', 'b'}` failed to compile at all: the walk reaches this builder
15569    /// before that arm is ever tried.
15570    fn compile_comparison_value(
15571        &mut self,
15572        value: &Expr,
15573        op: &ast::BinOpKind,
15574        td: &TypeDescriptor,
15575        alias: &str,
15576    ) -> Result<IrExpr, PyQLError> {
15577        match value {
15578            Expr::Set(elems) if matches!(op, ast::BinOpKind::In | ast::BinOpKind::NotIn) => Ok(IrExpr::Array(
15579                elems
15580                    .iter()
15581                    .map(|e| self.compile_expr(e, td, alias))
15582                    .collect::<Result<Vec<_>, _>>()?,
15583            )),
15584            other => self.compile_expr(other, td, alias),
15585        }
15586    }
15587
15588    /// `X in Y` where `X` is a set and a *value* is what the position wants:
15589    /// one boolean per element of `X`, gathered as the array every other
15590    /// multi-valued expression comes back as. `Y` is the set membership is
15591    /// tested against, so it stays whole.
15592    ///
15593    /// Only the left operand's walk is rebuilt — it already compiles to
15594    /// `ARRAY(SELECT <element> FROM <walk>)`, and what this does is move the
15595    /// comparison inside that select, where it is made once per row rather
15596    /// than once for the array. `Ok(None)` for anything else, including every
15597    /// condition position, which still collapses to the EXISTS below.
15598    fn try_elementwise_in(
15599        &mut self,
15600        b: &ast::BinOp,
15601        ctx: Option<(&TypeDescriptor, &str)>,
15602    ) -> Result<Option<IrExpr>, PyQLError> {
15603        // `=`/`!=` against a *set* is the same membership question spelled
15604        // differently — the reading `apply_comparison` already gives it — so
15605        // `all(.posts = ps)` has to answer per element too. Whether the other
15606        // side really is a set is only known once compiled, which the walk arm
15607        // below checks; the bare-binding arm takes `in` alone.
15608        let membership = matches!(b.op, ast::BinOpKind::In | ast::BinOpKind::NotIn);
15609        if !membership && !matches!(b.op, ast::BinOpKind::Eq | ast::BinOpKind::Ne) {
15610            return Ok(None);
15611        }
15612        // `individuals in accounts` — the left operand is the whole set a
15613        // binding or a type names, so what answers per element is a select
15614        // over that set. Available with or without a schema type in scope,
15615        // since neither operand needs one.
15616        //
15617        // A quantifier's argument is a set position by definition, so this
15618        // holds inside one whether or not the surrounding position asked for
15619        // a value: `any(individuals in accounts)` is how those answers are
15620        // reduced back to one, and it has the same set to reduce either way.
15621        // A walk (below) has an EXISTS to fold into instead, which says the
15622        // same thing more directly, so only this arm needs the quantifier.
15623        if membership
15624            && (self.value_position || self.explicit_set_depth > 0)
15625            && let Some(root) = self.object_set_name(&b.left)
15626        {
15627            let rewritten = Expr::BinOp(Box::new(ast::BinOp {
15628                left: Expr::Path(ast::Path {
15629                    steps: vec![ast::PathStep::Name(root.clone()), ast::PathStep::Name("id".into())],
15630                    partial: false,
15631                }),
15632                op: b.op.clone(),
15633                right: b.right.clone(),
15634            }));
15635            let over_the_set = ast::SelectStmt {
15636                result: rewritten.clone(),
15637                filter: None,
15638                order_by: vec![],
15639                offset: None,
15640                limit: None,
15641                lock: None,
15642            };
15643            let ps = self.compile_expr_as_path_select(&over_the_set, &rewritten, &root, false)?;
15644            return Ok(Some(IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(
15645                Box::new(ps),
15646            )))));
15647        }
15648        // A walk answers `any` with an EXISTS over its junction, which says
15649        // the same thing more directly, so outside a value position it only
15650        // takes the per-element route for `all` — where an EXISTS is the
15651        // wrong question and the answers have to be reduced one at a time.
15652        if !self.value_position && self.universal_set_depth == 0 {
15653            return Ok(None);
15654        }
15655        let Some((td, _)) = ctx else { return Ok(None) };
15656        // Checked before compiling anything: a walk that turns out to be
15657        // single-valued has to reach the ordinary path below, and compiling it
15658        // twice would burn aliases and re-hoist whatever it bound.
15659        let Expr::Path(p) = &b.left else { return Ok(None) };
15660        if !p.partial || !self.path_crosses_multi(td, &p.steps) {
15661            return Ok(None);
15662        }
15663        // The walk is compiled speculatively — whether it can be rebuilt this
15664        // way is only visible once it has been — so anything it bound on the
15665        // way is rolled back when it turns out it cannot.
15666        let hoisted = self.hoisted_ctes.len();
15667        let left = self.compile_expr_ctx(&b.left, ctx)?;
15668        // A walk onto objects arrives as the bare correlated subquery its rows
15669        // are; one onto values arrives already gathered into an array. Either
15670        // way what comes back from here is a set of booleans, so both leave as
15671        // an array.
15672        let mut ps = match left {
15673            IrExpr::PathSubquery(ps) | IrExpr::ObjectPathSubquery(ps) => ps,
15674            IrExpr::ArrayFromSelect(source) => match *source {
15675                IrArraySource::PathSelect(ps) => ps,
15676                _ => {
15677                    self.hoisted_ctes.truncate(hoisted);
15678                    return Ok(None);
15679                }
15680            },
15681            _ => {
15682                self.hoisted_ctes.truncate(hoisted);
15683                return Ok(None);
15684            }
15685        };
15686        // A set literal on the right is the one expression position one is
15687        // meaningful in — see the `In`/`NotIn` arm of `compile_expr_ctx`,
15688        // which reads it as the array `= ANY(…)` wants.
15689        let right = match &b.right {
15690            Expr::Set(elems) => IrExpr::Array(
15691                elems
15692                    .iter()
15693                    .map(|e| self.compile_expr_ctx(e, ctx))
15694                    .collect::<Result<Vec<_>, _>>()?,
15695            ),
15696            other => self.compile_expr_ctx(other, ctx)?,
15697        };
15698        // An equality earns this route only by really being a membership
15699        // test; against a single value it is an ordinary comparison, which
15700        // the EXISTS fold below already reads correctly.
15701        if !membership && !self.is_set_expr(&right) {
15702            self.hoisted_ctes.truncate(hoisted);
15703            return Ok(None);
15704        }
15705        // Objects are compared by identity, which is the id the walk's last
15706        // step lands on — the same reading `in` gives them anywhere else.
15707        let element = match &ps.result {
15708            IrPathResult::Scalar(expr, _) => expr.clone(),
15709            IrPathResult::Object { alias, .. } => IrExpr::ColumnRef {
15710                alias: alias.clone(),
15711                column: "id".to_string(),
15712                pg_type: "uuid".to_string(),
15713            },
15714        };
15715        // `apply_comparison` turns the equality into the membership it stands
15716        // for; `in` it leaves alone.
15717        let per_element = self.apply_comparison(element, Some((b.op.clone(), right, false)));
15718        ps.result = IrPathResult::Scalar(per_element, None);
15719        Ok(Some(IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(ps)))))
15720    }
15721
15722    /// If `b` has a multi-link path (any depth) on either side, compile as EXISTS over the junction.
15723    fn try_multilink_exists(
15724        &mut self,
15725        b: &ast::BinOp,
15726        td: &TypeDescriptor,
15727        alias: &str,
15728    ) -> Result<Option<IrExpr>, PyQLError> {
15729        // A bare `.multilink` counts: comparing the link itself to an object
15730        // (`filter any(.emails = email)`) is the same set-membership question
15731        // as comparing something reached through it, just with no tail.
15732        fn ml_first_name(steps: &[ast::PathStep]) -> Option<&str> {
15733            match steps.first()? {
15734                ast::PathStep::Name(n) => Some(n.as_str()),
15735                _ => None,
15736            }
15737        }
15738
15739        let (path_steps, value_ast, flip) = if let Expr::Path(p) = &b.left {
15740            if p.partial {
15741                if let Some(ln) = ml_first_name(&p.steps) {
15742                    if Self::resolve_multilink(td, ln).is_some() {
15743                        (p.steps.as_slice(), &b.right, false)
15744                    } else {
15745                        return Ok(None);
15746                    }
15747                } else {
15748                    return Ok(None);
15749                }
15750            } else {
15751                return Ok(None);
15752            }
15753        } else if let Expr::Path(p) = &b.right {
15754            if p.partial {
15755                if let Some(ln) = ml_first_name(&p.steps) {
15756                    if Self::resolve_multilink(td, ln).is_some() {
15757                        (p.steps.as_slice(), &b.left, true)
15758                    } else {
15759                        return Ok(None);
15760                    }
15761                } else {
15762                    return Ok(None);
15763                }
15764            } else {
15765                return Ok(None);
15766            }
15767        } else {
15768            return Ok(None);
15769        };
15770
15771        let value_expr = self.compile_comparison_value(value_ast, &b.op, td, alias)?;
15772        let ml_name = match &path_steps[0] {
15773            ast::PathStep::Name(n) => n.clone(),
15774            _ => return Ok(None),
15775        };
15776        let ml = Self::resolve_multilink(td, &ml_name).unwrap();
15777
15778        // Warn: multi-link traversal in a comparison returns a set, not a single boolean.
15779        // The query works (compiled as EXISTS), but `any()` makes the intent explicit.
15780        if self.explicit_set_depth == 0 {
15781            // Rendered back the way it was written — a path the reader cannot
15782            // find in their own query is worse than no path at all.
15783            let mut pointer_path = String::new();
15784            for step in path_steps {
15785                match step {
15786                    ast::PathStep::Name(n) => {
15787                        pointer_path.push('.');
15788                        pointer_path.push_str(n);
15789                    }
15790                    ast::PathStep::Backlink(n) => {
15791                        pointer_path.push_str(".<");
15792                        pointer_path.push_str(n);
15793                    }
15794                    ast::PathStep::LinkProp(n) => {
15795                        pointer_path.push('@');
15796                        pointer_path.push_str(n);
15797                    }
15798                    ast::PathStep::TypeIntersection(t) => {
15799                        pointer_path.push_str("[is ");
15800                        pointer_path.push_str(&t.qualified_name());
15801                        pointer_path.push(']');
15802                    }
15803                }
15804            }
15805            self.warnings.push(format!(
15806                "possibly more than one element returned by an expression in a FILTER clause \
15807                 (multi-link '{pointer_path}'); wrap with any() to make intent explicit",
15808            ));
15809        }
15810
15811        // Clone what we need to avoid borrow conflicts with self below.
15812        let ml_target = ml.target.clone();
15813        let ml_through = ml.through.clone();
15814        let td_module = td.module.clone();
15815        let td_name = td.name.clone();
15816        let td_table = td.table.clone();
15817
15818        // tail steps are everything after the multi-link name (path_steps[1..])
15819        let tail_steps: Vec<ast::PathStep> = path_steps[1..].to_vec();
15820
15821        let jt_alias = self.fresh_alias();
15822
15823        // Resolve junction table columns.
15824        let (jt_table, jt_module, jt_src_col, jt_tgt_col) = if let Some(through_qname) = &ml_through {
15825            let through_td = self.resolve_type(through_qname)?;
15826            if through_td.junction {
15827                // See `junction_info_for`'s doc comment: owner-derived,
15828                // never `through_td.table` itself.
15829                (
15830                    format!("{}.{}", td_table, ml_name),
15831                    td_module.clone(),
15832                    "source".to_string(),
15833                    "target".to_string(),
15834                )
15835            } else {
15836                let source_qname = format!("{}::{}", td_module, td_name);
15837                let src_col = through_td
15838                    .links
15839                    .iter()
15840                    .find(|l| l.target == source_qname)
15841                    .ok_or_else(|| {
15842                        PyQLError::Type(PyQLTypeError {
15843                            message: format!("through type {through_qname} has no link to {source_qname}"),
15844                            position: Position { line: 0, col: 0 },
15845                        })
15846                    })?
15847                    .name
15848                    .clone();
15849                let tgt_col = through_td
15850                    .links
15851                    .iter()
15852                    .find(|l| l.target == ml_target && l.name != src_col)
15853                    .or_else(|| through_td.links.iter().find(|l| l.target == ml_target))
15854                    .ok_or_else(|| {
15855                        PyQLError::Type(PyQLTypeError {
15856                            message: format!("through type {through_qname} has no link to {ml_target}"),
15857                            position: Position { line: 0, col: 0 },
15858                        })
15859                    })?
15860                    .name
15861                    .clone();
15862                (
15863                    through_td.table.clone(),
15864                    through_td.module.clone(),
15865                    format!("{}_id", src_col),
15866                    format!("{}_id", tgt_col),
15867                )
15868            }
15869        } else {
15870            (
15871                format!("{}.{}", td_table, ml_name),
15872                td_module.clone(),
15873                "source".to_string(),
15874                "target".to_string(),
15875            )
15876        };
15877
15878        // source filter: jt.src_col = parent.id
15879        let src_filter = IrExpr::BinOp(Box::new(IrBinOp {
15880            left: IrExpr::ColumnRef {
15881                alias: jt_alias.clone(),
15882                column: jt_src_col,
15883                pg_type: "uuid".to_string(),
15884            },
15885            op: ast::BinOpKind::Eq,
15886            right: IrExpr::ColumnRef {
15887                alias: alias.to_string(),
15888                column: "id".to_string(),
15889                pg_type: "uuid".to_string(),
15890            },
15891        }));
15892
15893        // Build tail filter: what to compare inside the junction/target EXISTS
15894        let tail_filter = self.compile_path_tail_filter(
15895            &tail_steps,
15896            b.op.clone(),
15897            value_expr,
15898            flip,
15899            &ml_target,
15900            &jt_alias,
15901            &jt_tgt_col,
15902        )?;
15903
15904        let full_filter = IrExpr::BinOp(Box::new(IrBinOp {
15905            left: src_filter,
15906            op: ast::BinOpKind::And,
15907            right: tail_filter,
15908        }));
15909
15910        // EXISTS(SELECT 1 FROM junction jt WHERE ...)
15911        let jt_source = IrSource {
15912            poly: None,
15913            type_name: format!("{}::__jt__", jt_module),
15914            table: jt_table,
15915            alias: jt_alias,
15916        };
15917        let inner = IrExpr::Subquery(Box::new(IrSelect::schema_bound(jt_source, vec![], Some(full_filter))));
15918
15919        Ok(Some(IrExpr::UnaryOp(Box::new(IrUnaryOp {
15920            op: ast::UnaryOpKind::Exists,
15921            operand: inner,
15922        }))))
15923    }
15924
15925    /// Build a filter expression for the tail steps after the multi-link in an EXISTS context.
15926    ///
15927    /// `steps` = path steps after the multi-link name (e.g. ["company", "id"] for .friends.company.id)
15928    /// `jt_alias` = alias of the junction table row
15929    /// `jt_tgt_col` = column in the junction table holding the target id (e.g. "target" or "friend_id")
15930    /// `target_type` = qualified name of the multi-link's target type (e.g. "default::Person")
15931    #[allow(clippy::too_many_arguments)]
15932    fn compile_path_tail_filter(
15933        &mut self,
15934        steps: &[ast::PathStep],
15935        op: ast::BinOpKind,
15936        value_expr: IrExpr,
15937        flip: bool,
15938        target_type: &str,
15939        jt_alias: &str,
15940        jt_tgt_col: &str,
15941    ) -> Result<IrExpr, PyQLError> {
15942        // No tail at all — the multi-link itself is what is being compared,
15943        // so the junction row's target id is the whole answer.
15944        if steps.is_empty() {
15945            let col_ref = IrExpr::ColumnRef {
15946                alias: jt_alias.to_string(),
15947                column: jt_tgt_col.to_string(),
15948                pg_type: "uuid".to_string(),
15949            };
15950            // Through `apply_comparison` rather than built here, so an
15951            // equality against a *set* — a binding of more than one row —
15952            // reads as membership instead of comparing against the one value
15953            // a scalar subquery can return.
15954            return Ok(self.apply_comparison(col_ref, Some((op, value_expr, flip))));
15955        }
15956
15957        // `.emails[is account::Email].email` — an intersection narrows what the
15958        // walk continues from. The remaining steps read against the narrowed
15959        // type, and because its own table is what they join to, a row of any
15960        // other type drops out on its own: the narrowing needs no separate
15961        // check.
15962        if let Some(ast::PathStep::TypeIntersection(type_ref)) = steps.first() {
15963            let narrowed = self.resolve_type(&type_ref.qualified_name())?;
15964            let narrowed_qname = format!("{}::{}", narrowed.module, narrowed.name);
15965            return self.compile_path_tail_filter(
15966                &steps[1..],
15967                op,
15968                value_expr,
15969                flip,
15970                &narrowed_qname,
15971                jt_alias,
15972                jt_tgt_col,
15973            );
15974        }
15975
15976        let first_name = match steps.first() {
15977            Some(ast::PathStep::Name(n)) => n.clone(),
15978            _ => return Err(self.type_err("expected a property or link name in path")),
15979        };
15980
15981        let target_td = self.resolve_type(target_type)?;
15982        let target_table = target_td.table.clone();
15983
15984        if steps.len() == 1 {
15985            // Terminal step: must be a scalar property or "id"
15986            if first_name == "id" {
15987                // FK optimisation: compare jt.target directly
15988                let col_ref = IrExpr::ColumnRef {
15989                    alias: jt_alias.to_string(),
15990                    column: jt_tgt_col.to_string(),
15991                    pg_type: "uuid".to_string(),
15992                };
15993                let (l, r) = if flip {
15994                    (value_expr, col_ref)
15995                } else {
15996                    (col_ref, value_expr)
15997                };
15998                return Ok(IrExpr::BinOp(Box::new(IrBinOp { left: l, op, right: r })));
15999            }
16000            // Check if it's a link (object) rather than a scalar
16001            // A single link is compared by the foreign key it stores, the
16002            // same way `.link = obj` is one step higher up
16003            // (`any(.access_grants.account = account)`). A multi-link has no
16004            // column to compare and would need a junction of its own.
16005            let single_link = target_td
16006                .links
16007                .iter()
16008                .find(|l| l.name == first_name && !l.is_junction_backed());
16009            if single_link.is_none() && target_td.multilinks.iter().any(|l| l.name == first_name) {
16010                let target_display = target_type.replace("::", ".");
16011                return Err(PyQLError::Type(PyQLTypeError {
16012                    message: format!(
16013                        "operator '{op}' cannot be applied to operands of type '{target_display}' and the value type",
16014                        op = op,
16015                    ),
16016                    position: Position { line: 0, col: 0 },
16017                }));
16018            }
16019            let (prop_name, prop_pg) = match single_link {
16020                Some(link) => (format!("{}_id", link.name), "uuid".to_string()),
16021                None => {
16022                    let prop = target_td
16023                        .properties
16024                        .iter()
16025                        .find(|p| p.name == first_name)
16026                        .ok_or_else(|| self.field_err(&first_name, target_type))?;
16027                    (prop.name.clone(), prop.pg_type.clone())
16028                }
16029            };
16030            let tgt_alias = self.fresh_alias();
16031            // Build EXISTS(SELECT 1 FROM target WHERE target.id = jt.target AND target.prop op value)
16032            let id_filter = IrExpr::BinOp(Box::new(IrBinOp {
16033                left: IrExpr::ColumnRef {
16034                    alias: tgt_alias.clone(),
16035                    column: "id".to_string(),
16036                    pg_type: "uuid".to_string(),
16037                },
16038                op: ast::BinOpKind::Eq,
16039                right: IrExpr::ColumnRef {
16040                    alias: jt_alias.to_string(),
16041                    column: jt_tgt_col.to_string(),
16042                    pg_type: "uuid".to_string(),
16043                },
16044            }));
16045            let prop_col = IrExpr::ColumnRef {
16046                alias: tgt_alias.clone(),
16047                column: prop_name,
16048                pg_type: prop_pg,
16049            };
16050            let (pl, pr) = if flip {
16051                (value_expr, prop_col)
16052            } else {
16053                (prop_col, value_expr)
16054            };
16055            let prop_filter = IrExpr::BinOp(Box::new(IrBinOp {
16056                left: pl,
16057                op,
16058                right: pr,
16059            }));
16060            let full = IrExpr::BinOp(Box::new(IrBinOp {
16061                left: id_filter,
16062                op: ast::BinOpKind::And,
16063                right: prop_filter,
16064            }));
16065            let inner = IrExpr::Subquery(Box::new(IrSelect::schema_bound(
16066                IrSource {
16067                    poly: None,
16068                    type_name: target_type.to_string(),
16069                    table: target_table,
16070                    alias: tgt_alias,
16071                },
16072                vec![],
16073                Some(full),
16074            )));
16075            return Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
16076                op: ast::UnaryOpKind::Exists,
16077                operand: inner,
16078            })));
16079        }
16080
16081        // steps.len() >= 2: first_name must be a single link (not multi)
16082        if target_td.multilinks.iter().any(|l| l.name == first_name) {
16083            return Err(self.type_err("nested multi-link traversal in comparison is not yet supported"));
16084        }
16085        let link = target_td
16086            .links
16087            .iter()
16088            .find(|l| l.name == first_name)
16089            .ok_or_else(|| self.field_err(&first_name, target_type))?;
16090        if link.is_junction_backed() {
16091            // A junction-backed link's correlation is a subquery, not a
16092            // plain FK column, and this recursive path-tail resolver is
16093            // built entirely around passing a (alias, column) pair down to
16094            // the next level — supporting it here would need a broader
16095            // signature change. Fail loudly rather than silently reference
16096            // a `{name}_id` column that doesn't exist for this link.
16097            return Err(self.type_err(&format!(
16098                "filtering through a junction-backed single link ('{first_name}') nested inside \
16099                 a multi-link path comparison is not yet supported — filter on '.{first_name}' \
16100                 directly instead"
16101            )));
16102        }
16103        let next_target = link.target.clone();
16104        let fk_col = format!("{}_id", first_name);
16105        let tgt_alias = self.fresh_alias();
16106
16107        // FK optimisation for [single_link, "id"]:
16108        if steps.len() == 2
16109            && let Some(ast::PathStep::Name(n)) = steps.get(1)
16110            && n == "id"
16111        {
16112            // Compare tgt.{fk_col} (the FK in current target) directly
16113            // We need an EXISTS over the target to access fk_col
16114            // Actually: EXISTS(target WHERE target.id = jt.target AND target.{fk_col} op value)
16115            let id_filter = IrExpr::BinOp(Box::new(IrBinOp {
16116                left: IrExpr::ColumnRef {
16117                    alias: tgt_alias.clone(),
16118                    column: "id".to_string(),
16119                    pg_type: "uuid".to_string(),
16120                },
16121                op: ast::BinOpKind::Eq,
16122                right: IrExpr::ColumnRef {
16123                    alias: jt_alias.to_string(),
16124                    column: jt_tgt_col.to_string(),
16125                    pg_type: "uuid".to_string(),
16126                },
16127            }));
16128            let fk_ref = IrExpr::ColumnRef {
16129                alias: tgt_alias.clone(),
16130                column: fk_col,
16131                pg_type: "uuid".to_string(),
16132            };
16133            let (fl, fr) = if flip {
16134                (value_expr, fk_ref)
16135            } else {
16136                (fk_ref, value_expr)
16137            };
16138            let fk_filter = IrExpr::BinOp(Box::new(IrBinOp {
16139                left: fl,
16140                op,
16141                right: fr,
16142            }));
16143            let full = IrExpr::BinOp(Box::new(IrBinOp {
16144                left: id_filter,
16145                op: ast::BinOpKind::And,
16146                right: fk_filter,
16147            }));
16148            let inner = IrExpr::Subquery(Box::new(IrSelect::schema_bound(
16149                IrSource {
16150                    poly: None,
16151                    type_name: target_type.to_string(),
16152                    table: target_table,
16153                    alias: tgt_alias,
16154                },
16155                vec![],
16156                Some(full),
16157            )));
16158            return Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
16159                op: ast::UnaryOpKind::Exists,
16160                operand: inner,
16161            })));
16162        }
16163
16164        // General case: EXISTS(target WHERE target.id = jt.jt_tgt_col AND <tail_filter for steps[1..]>)
16165        // Recursive call uses tgt_alias.fk_col as the "pointer to the next type's id"
16166        let id_filter = IrExpr::BinOp(Box::new(IrBinOp {
16167            left: IrExpr::ColumnRef {
16168                alias: tgt_alias.clone(),
16169                column: "id".to_string(),
16170                pg_type: "uuid".to_string(),
16171            },
16172            op: ast::BinOpKind::Eq,
16173            right: IrExpr::ColumnRef {
16174                alias: jt_alias.to_string(),
16175                column: jt_tgt_col.to_string(),
16176                pg_type: "uuid".to_string(),
16177            },
16178        }));
16179        let nested_filter =
16180            self.compile_path_tail_filter(&steps[1..], op, value_expr, flip, &next_target, &tgt_alias, &fk_col)?;
16181        let full = IrExpr::BinOp(Box::new(IrBinOp {
16182            left: id_filter,
16183            op: ast::BinOpKind::And,
16184            right: nested_filter,
16185        }));
16186        let inner = IrExpr::Subquery(Box::new(IrSelect::schema_bound(
16187            IrSource {
16188                poly: None,
16189                type_name: target_type.to_string(),
16190                table: target_table,
16191                alias: tgt_alias,
16192            },
16193            vec![],
16194            Some(full),
16195        )));
16196        Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
16197            op: ast::UnaryOpKind::Exists,
16198            operand: inner,
16199        })))
16200    }
16201}
16202
16203/// Where a walk reads a multi-link this statement also wrote: the CTE holding
16204/// the junction rows, and — when the targets were inserted here too — the CTE
16205/// holding those.
16206#[derive(Clone)]
16207struct JunctionReadOverride {
16208    junction: String,
16209    targets: Option<String>,
16210}
16211
16212/// What an `UNLESS CONFLICT … ELSE (UPDATE …)` clause contributes: the
16213/// `DO UPDATE SET` assignments, and the predicate deciding whether the
16214/// conflicting row is touched at all when the ELSE UPDATE carried a filter.
16215struct ConflictElse {
16216    sets: Vec<(String, IrExpr)>,
16217    predicate: Option<IrExpr>,
16218}
16219
16220impl<'a> Compiler<'a> {
16221    /// Compile `UNLESS CONFLICT [ON expr] [ELSE (UPDATE …)]` into `IrConflict`.
16222    fn compile_conflict(
16223        &mut self,
16224        uc: &ast::UnlessConflict,
16225        td: &TypeDescriptor,
16226    ) -> Result<(IrConflict, Vec<IrMultiLinkMutation>), PyQLError> {
16227        // ON clause: compile with empty alias → bare column name (`"col"` not `"t0"."col"`)
16228        // so the emitter produces `ON CONFLICT ("name")` not `ON CONFLICT ("t0"."name")`.
16229        let on = uc.on.as_ref().map(|e| self.compile_expr(e, td, "")).transpose()?;
16230        let mut appends = vec![];
16231        let mut do_update_where = None;
16232        let do_update = match uc.else_.as_ref() {
16233            Some(e) => {
16234                let resolved = self.compile_conflict_else(e, &mut appends)?;
16235                do_update_where = resolved.predicate;
16236                Some(resolved.sets)
16237            }
16238            None => None,
16239        };
16240        Ok((
16241            IrConflict {
16242                on,
16243                do_update,
16244                do_update_where,
16245            },
16246            appends,
16247        ))
16248    }
16249
16250    /// Compile the ELSE clause of UNLESS CONFLICT, which must be `(UPDATE Type SET { … })`.
16251    ///
16252    /// Assignments are compiled with the target table's own bare name (not
16253    /// schema-qualified, and not the usual `t0`-style fresh alias) as the
16254    /// qualifying "alias" so a self-referencing RHS (`.stock` in `stock :=
16255    /// .stock + 1`) resolves unambiguously to the *existing* conflicting
16256    /// row. A genuinely bare, unqualified column reference here is
16257    /// ambiguous in Postgres between the existing row and the `excluded`
16258    /// pseudo-row (confirmed live: "column reference ... is ambiguous")
16259    /// even though only one of the two is ever actually reachable this way
16260    /// (nothing here ever compiles a reference to `excluded`) — Postgres's
16261    /// own docs describe exactly this qualification: "the existing row
16262    /// using the table's name (or an alias)," no explicit `AS` needed on
16263    /// the INSERT target for that self-reference to work.
16264    fn compile_conflict_else(
16265        &mut self,
16266        expr: &Expr,
16267        appends: &mut Vec<IrMultiLinkMutation>,
16268    ) -> Result<ConflictElse, PyQLError> {
16269        let Expr::SubQuery(stmt) = expr else {
16270            return Err(self.type_err("UNLESS CONFLICT ELSE must be an UPDATE expression, e.g. ELSE (UPDATE …)"));
16271        };
16272        // `else (select usage::Allowance)` — yield the conflicting row rather
16273        // than change it. `DO NOTHING` returns nothing at all, so the row is
16274        // read back by assigning its own key to itself, which is what makes
16275        // `RETURNING` see it.
16276        if let Stmt::Select(sel) = stmt.as_ref()
16277            && sel.filter.is_none()
16278            && sel.limit.is_none()
16279            && sel.offset.is_none()
16280            && let Ok(type_name) = self.expr_as_type_name(&sel.result)
16281            && let Ok(sel_td) = self.resolve_type(&type_name)
16282        {
16283            let table = sel_td.table.clone();
16284            let pk = sel_td
16285                .properties
16286                .iter()
16287                .find(|p| p.is_pk)
16288                .ok_or_else(|| self.type_err(&format!("type '{type_name}' has no primary key to read back")))?;
16289            return Ok(ConflictElse {
16290                sets: vec![(
16291                    pk.name.clone(),
16292                    IrExpr::ColumnRef {
16293                        alias: table,
16294                        column: pk.name.clone(),
16295                        pg_type: pk.pg_type.clone(),
16296                    },
16297                )],
16298                predicate: None,
16299            });
16300        }
16301        let Stmt::Update(upd) = stmt.as_ref() else {
16302            return Err(self.type_err(
16303                "UNLESS CONFLICT ELSE must be an UPDATE that changes the conflicting row, or a \
16304                 SELECT of its type to read it back unchanged",
16305            ));
16306        };
16307        let type_name = self.expr_as_type_name(&upd.subject)?;
16308        let upd_td = self.resolve_type(&type_name)?;
16309        let table = upd_td.table.clone();
16310        // The ON CONFLICT target says *which* row conflicts; the ELSE UPDATE's
16311        // own filter says whether to touch it at all, and it is honoured.
16312        // Dropping it silently turned `unless conflict on .key else (update T
16313        // filter .expires_at < now() set { … })` into an unconditional steal of
16314        // a live advisory lock. Compiled against the table name, which is what
16315        // refers to the *existing* row inside `DO UPDATE` (`excluded` is the
16316        // proposed one).
16317        let do_update_where = upd
16318            .filter
16319            .as_ref()
16320            .map(|f| self.as_condition(|c| c.compile_expr(f, upd_td, &table)))
16321            .transpose()?;
16322        // A multi-link cannot be written by `DO UPDATE SET` — junction rows
16323        // are separate DML. Appending them to the enclosing insert instead
16324        // applies them to whichever row comes back, inserted or conflicting,
16325        // and the junction insert is `ON CONFLICT DO NOTHING`, so the
16326        // inserted branch (which already wrote the same rows from its own
16327        // shape) is unaffected.
16328        let mut scalar_shape = vec![];
16329        for el in &upd.shape {
16330            let pointer_name = path_leaf(&el.path)?;
16331            let Some(ml) = Self::resolve_multilink(upd_td, pointer_name) else {
16332                scalar_shape.push(el.clone());
16333                continue;
16334            };
16335            if el.op == ShapeOp::Remove {
16336                return Err(self.type_err(&format!(
16337                    "cannot use `-=` for multi-link '{pointer_name}' inside an UNLESS CONFLICT \
16338                     ELSE clause; the rows to remove are not known until the conflict resolves"
16339                )));
16340            }
16341            let Some(value) = &el.compexpr else { continue };
16342            let (jt, module, src_col, tgt_col, through_td) = self.own_multilink_junction_info(upd_td, ml)?;
16343            let values = self.compile_multilink_values(value, upd_td, &table, through_td)?;
16344            appends.push(IrMultiLinkMutation {
16345                junction_table: jt,
16346                module,
16347                source_col: src_col,
16348                target_col: tgt_col,
16349                values,
16350                single: false,
16351            });
16352        }
16353        let sets = self.compile_assignments_for_update(&scalar_shape, upd_td, &table)?;
16354        Ok(ConflictElse {
16355            sets,
16356            predicate: do_update_where,
16357        })
16358    }
16359
16360    /// Compile a nested INSERT/UPDATE/DELETE into its own CTE and return that
16361    /// CTE's name. Postgres cannot run DML inside another statement's value
16362    /// list, so it is hoisted into the enclosing statement's `WITH` and read
16363    /// back from there — see `pending_nested_ctes`.
16364    /// Compile a DML statement into a CTE of the query's own `WITH`.
16365    ///
16366    /// Unlike `hoist_nested_dml`, whose CTE belongs to the enclosing
16367    /// INSERT/UPDATE it was nested inside, this one has no enclosing statement
16368    /// to attach to — the select that names it *is* the top level.
16369    fn hoist_dml_as_cte(&mut self, stmt: &Stmt) -> Result<(String, String), PyQLError> {
16370        let type_name = self.dml_subject_type(stmt)?;
16371        let before = std::mem::take(&mut self.for_vars_read);
16372        let inner = self.compile_stmt(stmt);
16373        let read = std::mem::replace(&mut self.for_vars_read, before);
16374        let correlated_to = self
16375            .for_scope
16376            .iter()
16377            .rev()
16378            .find(|slot| read.contains(*slot))
16379            .map(|slot| format!("_for_{slot}"));
16380        self.for_vars_read.extend(read);
16381        let cte_name = self.fresh_nested_cte_name();
16382        if let Some(slot) = correlated_to.as_deref().and_then(|i| i.strip_prefix("_for_")) {
16383            self.correlated_bindings.insert(cte_name.clone(), slot.to_string());
16384        }
16385        self.hoisted_ctes.push(IrCteDef {
16386            name: cte_name.clone(),
16387            stmt: inner?,
16388            type_name: type_name.clone(),
16389            correlated_to,
16390        });
16391        Ok((cte_name, type_name))
16392    }
16393
16394    /// True when a `for`'s body ultimately mutates, through however many
16395    /// nested loops. A loop over loops over a `select` mutates nothing.
16396    fn for_body_mutates(stmt: &Stmt) -> bool {
16397        match stmt {
16398            Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_) => true,
16399            Stmt::For(inner) => Self::for_body_mutates(&inner.body),
16400            _ => false,
16401        }
16402    }
16403
16404    /// `select (for x in S union (insert T { … })) { id }` — a loop whose body
16405    /// mutates cannot be a select's source as written: the source is emitted as
16406    /// a `CROSS JOIN LATERAL`, and Postgres allows a data-modifying statement
16407    /// only at the top level of a `WITH`. So the loop is bound to a CTE of its
16408    /// own and the select re-rooted at that name, which is the `with c := (for
16409    /// … union (insert …)) select c { id }` spelling the binding path already
16410    /// builds. Compiling the loop up front also puts its variable in scope
16411    /// before the body's subject is resolved, which an `update x` body needs.
16412    /// Returns the re-rooted result expression, or `None` when the select's
16413    /// source is not such a loop.
16414    fn bind_mutating_loop_source(&mut self, result_expr: &Expr) -> Result<Option<Expr>, PyQLError> {
16415        let (subject, shape) = match result_expr {
16416            Expr::SubQuery(stmt) => (stmt.as_ref(), None),
16417            Expr::Shape(sh) => match sh.expr.as_ref() {
16418                Some(Expr::SubQuery(stmt)) => (stmt.as_ref(), Some(sh.as_ref())),
16419                _ => return Ok(None),
16420            },
16421            _ => return Ok(None),
16422        };
16423        if !matches!(subject, Stmt::For(f) if Self::for_body_mutates(&f.body)) {
16424            return Ok(None);
16425        }
16426        // An enclosing loop's variable read from inside this one makes the CTE
16427        // correlated to that iteration, the same bookkeeping `hoist_dml_as_cte`
16428        // does for a mutation hoisted out of a loop.
16429        let before = std::mem::take(&mut self.for_vars_read);
16430        let inner = self.compile_stmt(subject);
16431        let read = std::mem::replace(&mut self.for_vars_read, before);
16432        let correlated_to = self
16433            .for_scope
16434            .iter()
16435            .rev()
16436            .find(|slot| read.contains(*slot))
16437            .map(|slot| format!("_for_{slot}"));
16438        self.for_vars_read.extend(read);
16439        let inner = inner?;
16440        let cte_name = self.fresh_nested_cte_name();
16441        if let Some(slot) = correlated_to.as_deref().and_then(|i| i.strip_prefix("_for_")) {
16442            self.correlated_bindings.insert(cte_name.clone(), slot.to_string());
16443        }
16444        let type_name = self.register_cte(&cte_name, &inner);
16445        self.hoisted_ctes.push(IrCteDef {
16446            name: cte_name.clone(),
16447            stmt: inner,
16448            type_name,
16449            correlated_to,
16450        });
16451        let bound = Expr::Path(ast::Path {
16452            steps: vec![ast::PathStep::Name(cte_name)],
16453            partial: false,
16454        });
16455        Ok(Some(match shape {
16456            Some(sh) => Expr::Shape(Box::new(ast::ShapeExpr {
16457                expr: Some(bound),
16458                elements: sh.elements.clone(),
16459                marker_offset: sh.marker_offset,
16460            })),
16461            None => bound,
16462        }))
16463    }
16464
16465    fn hoist_nested_dml(&mut self, stmt: &Stmt) -> Result<String, PyQLError> {
16466        let type_name = self.dml_subject_type(stmt)?;
16467        let inner = self.compile_stmt(stmt)?;
16468        let cte_name = self.fresh_nested_cte_name();
16469        self.pending_nested_ctes.push(IrCteDef {
16470            name: cte_name.clone(),
16471            stmt: inner,
16472            type_name,
16473            correlated_to: None,
16474        });
16475        Ok(cte_name)
16476    }
16477
16478    /// Compile `(SELECT TargetType FILTER …)` as a scalar subquery for use in a
16479    /// link assignment (`company := (SELECT Company FILTER .name = $co)`).
16480    /// Returns `IrExpr::Subquery` whose shape is the target pk — the SQL emitter
16481    /// renders this as `(SELECT "alias"."id" FROM … WHERE …)`.
16482    fn compile_link_subquery(&mut self, stmt: &Stmt) -> Result<IrExpr, PyQLError> {
16483        // `preferences := (insert Preferences { … })` — the nested DML used
16484        // directly as the value, which is the same hoist the wrapped
16485        // `select (insert …) { id }` form below goes through.
16486        if matches!(stmt, Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)) {
16487            let cte_name = self.hoist_nested_dml(stmt)?;
16488            return Ok(IrExpr::ColumnRef {
16489                alias: cte_name,
16490                column: "id".to_string(),
16491                pg_type: "uuid".to_string(),
16492            });
16493        }
16494        let Stmt::Select(sel) = stmt else {
16495            return Err(self.type_err(
16496                "only SELECT is valid as a link assignment value; \
16497                 use SELECT (INSERT …) { id } to assign from a DML result",
16498            ));
16499        };
16500
16501        // `SELECT (INSERT …) { id }` / `SELECT (UPDATE …) { id }` /
16502        // `SELECT (DELETE …) { id }` — the exact workaround this function's
16503        // own error message above recommends. Postgres has no way to run a
16504        // nested INSERT/UPDATE/DELETE inside another statement's value list
16505        // without hoisting it into a `WITH` CTE first, so that's what this
16506        // does: compile the inner DML as its own statement, stash it in
16507        // `self.pending_nested_ctes` under a fresh CTE name (drained by
16508        // whichever `compile_insert`/`compile_update` is compiling the
16509        // assignment this value belongs to — see those functions' own doc
16510        // comments — and prepended as a `WITH` CTE by the emitter, which
16511        // also switches the outer statement's own row source from
16512        // `VALUES (...)` / a bare `SET` to something that can actually
16513        // reference it), and return a plain reference to that CTE's `id`
16514        // column in place of the subquery.
16515        //
16516        // A prior attempt to handle this by delegating to `compile_select`
16517        // (which does know how to chain a `dml_source`, but only for a
16518        // *top-level* `SELECT (INSERT …) { ... }` statement) compiled
16519        // without error but silently emitted a subquery that dropped the
16520        // nested INSERT and selected an unrelated, arbitrary pre-existing
16521        // row instead — do not repeat that approach.
16522        let nested_dml = match &sel.result {
16523            Expr::SubQuery(inner) if matches!(inner.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)) => {
16524                Some(inner.as_ref())
16525            }
16526            Expr::Shape(s) => match s.expr.as_ref() {
16527                Some(Expr::SubQuery(inner))
16528                    if matches!(inner.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)) =>
16529                {
16530                    Some(inner.as_ref())
16531                }
16532                _ => None,
16533            },
16534            _ => None,
16535        };
16536        if let Some(inner_stmt) = nested_dml {
16537            let cte_name = self.hoist_nested_dml(inner_stmt)?;
16538            return Ok(IrExpr::ColumnRef {
16539                alias: cte_name,
16540                column: "id".to_string(),
16541                pg_type: "uuid".to_string(),
16542            });
16543        }
16544
16545        let type_name = self.expr_as_type_name(&sel.result)?;
16546        let td = self.resolve_type(&type_name)?;
16547        let alias = self.fresh_alias();
16548
16549        // `(select T filter … order by … limit 1)` — every clause decides
16550        // which row is linked, not just the filter.
16551        let (filter, order_by, offset, limit) = self.compile_path_modifiers(sel, td, &alias)?;
16552        let mut select = IrSelect::schema_bound(
16553            IrSource {
16554                poly: None,
16555                type_name: format!("{}::{}", td.module, td.name),
16556                table: td.table.clone(),
16557                alias,
16558            },
16559            Self::pk_returning(td),
16560            filter,
16561        );
16562        select.order_by = order_by;
16563        select.offset = offset;
16564        select.limit = limit;
16565        Ok(IrExpr::Subquery(Box::new(select)))
16566    }
16567
16568    /// Shared `IrSort` builder for both schema-bound (`compile_sort`) and
16569    /// free (`compile_free_select`'s order-by) contexts — direction/nulls
16570    /// translation is identical either way, only the expr compiler ctx differs.
16571    fn compile_sort_ctx(
16572        &mut self,
16573        s: &ast::SortExpr,
16574        ctx: Option<(&TypeDescriptor, &str)>,
16575    ) -> Result<IrSort, PyQLError> {
16576        Ok(IrSort {
16577            expr: self.compile_expr_ctx(&s.expr, ctx)?,
16578            direction: match s.direction {
16579                SortDirection::Asc => IrSortDir::Asc,
16580                SortDirection::Desc => IrSortDir::Desc,
16581            },
16582            nulls: match s.nones {
16583                NonesOrder::First => IrNulls::First,
16584                NonesOrder::Last => IrNulls::Last,
16585            },
16586        })
16587    }
16588
16589    fn compile_sort(&mut self, s: &ast::SortExpr, td: &TypeDescriptor, alias: &str) -> Result<IrSort, PyQLError> {
16590        self.compile_sort_ctx(s, Some((td, alias)))
16591    }
16592
16593    // ── Stdlib function resolution ────────────────────────────────────────────────
16594
16595    /// Look up `name` in the stdlib (namespace = `module` or `"std"`) and produce
16596    /// the correct `IrExpr::FunctionCall` based on the matching `ImplStrategy`.
16597    /// Falls through to a plain call if no overload is found (unknown / PG built-in).
16598    /// `message := …` of an assert handled outside the generic call route,
16599    /// which would otherwise drop it and raise the assert's own text.
16600    fn assert_message(
16601        &mut self,
16602        f: &ast::FunctionCall,
16603        ctx: Option<(&TypeDescriptor, &str)>,
16604    ) -> Result<Option<IrExpr>, PyQLError> {
16605        let mut message = None;
16606        for (name, value) in &f.kwargs {
16607            if name != "message" {
16608                return Err(self.type_err(&format!("function 'std::{}' has no parameter '{name}'", f.name)));
16609            }
16610            message = Some(self.compile_expr_ctx(value, ctx)?);
16611        }
16612        Ok(message)
16613    }
16614
16615    /// A stdlib call against its named-only parameters — `message :=` of the
16616    /// asserts, every parameter of `cal::to_relative_duration`. The arguments
16617    /// come back in parameter order, each named-only one left out standing
16618    /// at its default. `None` for a call that names nothing and needs no
16619    /// default, which the positional route handles as it always has.
16620    fn compile_named_call_args(
16621        &mut self,
16622        f: &ast::FunctionCall,
16623        ctx: Option<(&TypeDescriptor, &str)>,
16624    ) -> Result<Option<Vec<IrExpr>>, PyQLError> {
16625        use crate::stdlib::NamedDefault;
16626
16627        let ns = f.module.as_deref().unwrap_or("std");
16628        let overloads = crate::stdlib::lookup(ns, &f.name);
16629        let positional = |d: &crate::stdlib::FnDescriptor| d.params.iter().filter(|p| p.named_only.is_none()).count();
16630        let named = |d: &crate::stdlib::FnDescriptor| d.params.iter().filter(|p| p.named_only.is_some()).count();
16631        // A variadic parameter absorbs any number of the positional arguments,
16632        // so it fixes only a floor on how many a call may pass.
16633        let takes_positionally = |d: &crate::stdlib::FnDescriptor| {
16634            if d.is_variadic() {
16635                f.args.len() + 1 >= positional(d)
16636            } else {
16637                positional(d) == f.args.len()
16638            }
16639        };
16640        if f.kwargs.is_empty() {
16641            if overloads.iter().any(|d| takes_positionally(d) && named(d) == 0) {
16642                return Ok(None);
16643            }
16644            // Only when the call passed *more* positionally than an overload's
16645            // positions hold, and no more than its whole parameter list: that
16646            // is a call writing the named-only ones by position, as opposed to
16647            // one whose argument count is simply wrong.
16648            let wrote_a_named_one_positionally =
16649                |d: &crate::stdlib::FnDescriptor| positional(d) < f.args.len() && f.args.len() <= d.params.len();
16650            if !overloads.iter().any(|d| takes_positionally(d))
16651                && let Some(param) = overloads
16652                    .iter()
16653                    .filter(|d| wrote_a_named_one_positionally(d))
16654                    .flat_map(|d| d.params.iter())
16655                    .find(|p| p.named_only.is_some())
16656                    .map(|p| p.keyword())
16657            {
16658                return Err(self.type_err(&format!(
16659                    "function '{ns}::{}' takes '{param}' as a named argument only — e.g. '{param} := …'",
16660                    f.name
16661                )));
16662            }
16663        }
16664        let fits = |d: &&&crate::stdlib::FnDescriptor| {
16665            takes_positionally(d)
16666                && named(d) > 0
16667                && f.kwargs
16668                    .iter()
16669                    .all(|(name, _)| d.params.iter().any(|p| p.keyword() == name && p.named_only.is_some()))
16670        };
16671        let Some(desc) = overloads.iter().find(fits) else {
16672            if let Some((name, _)) = f.kwargs.first() {
16673                let message = if overloads.iter().all(|d| named(d) == 0) {
16674                    format!(
16675                        "function '{ns}::{}' does not take named arguments, got '{name}'",
16676                        f.name
16677                    )
16678                } else {
16679                    format!("function '{ns}::{}' has no parameter '{name}'", f.name)
16680                };
16681                return Err(self.type_err(&message));
16682            }
16683            return Ok(None);
16684        };
16685        let mut args = f
16686            .args
16687            .iter()
16688            .map(|a| self.compile_expr_ctx(a, ctx))
16689            .collect::<Result<Vec<_>, _>>()?;
16690        for param in desc.params.iter().filter(|p| p.named_only.is_some()) {
16691            let arg = match (
16692                f.kwargs.iter().find(|(name, _)| name == param.keyword()),
16693                param.named_only,
16694            ) {
16695                (Some((_, value)), _) => self.compile_expr_ctx(value, ctx)?,
16696                (None, Some(NamedDefault::Required)) => {
16697                    let keyword = param.keyword();
16698                    return Err(self.type_err(&format!(
16699                        "function '{ns}::{}' requires the named argument '{keyword}' — e.g. '{keyword} := …'",
16700                        f.name
16701                    )));
16702                }
16703                (None, Some(NamedDefault::Int(n))) => IrExpr::Literal(IrLiteral::Int(n)),
16704                (None, Some(NamedDefault::Bool(b))) => IrExpr::Literal(IrLiteral::Bool(b)),
16705                (None, Some(NamedDefault::Str(s))) => IrExpr::Literal(IrLiteral::Str(s.to_string())),
16706                (None, _) => IrExpr::Null,
16707            };
16708            args.push(arg);
16709        }
16710        Ok(Some(args))
16711    }
16712
16713    fn resolve_fn_call(&mut self, module: Option<&str>, name: &str, args: Vec<IrExpr>) -> Result<IrExpr, PyQLError> {
16714        use crate::stdlib::{ImplStrategy, lookup};
16715
16716        let ns = module.unwrap_or("std");
16717        // `any`/`all` aggregate a *set* of booleans. Everything that reaches
16718        // here is a single value — a multi-link comparison has already become
16719        // its own EXISTS — and a single boolean is its own any()/all();
16720        // `bool_or` over it would be an aggregate where SQL allows none.
16721        if ns == "std" && matches!(name, "any" | "all") && args.len() == 1 && !is_array_expr(&args[0]) {
16722            return Ok(args.into_iter().next().expect("checked by the guard"));
16723        }
16724        let overloads = lookup(ns, name);
16725
16726        // Arity is decided first and on its own, so a call no overload could
16727        // accept is never type-matched against one anyway. Named-only
16728        // parameters are already materialized into `args` by
16729        // `compile_named_call_args`, so the comparison is against the full
16730        // parameter list; a trailing variadic absorbs zero or more, matching
16731        // `pylon.stdlib._arity_matches`, the same rule the Python surface
16732        // has always applied to `std.foo(...)`.
16733        let by_arity: Vec<&crate::stdlib::FnDescriptor> = overloads
16734            .iter()
16735            .copied()
16736            .filter(|d| {
16737                if d.is_variadic() {
16738                    args.len() + 1 >= d.params.len()
16739                } else {
16740                    d.params.len() == args.len()
16741                }
16742            })
16743            .collect();
16744
16745        // Pick the overload whose parameter types best match the argument
16746        // types. An overload whose declared types are exactly what is known
16747        // of the arguments beats one that merely accepts them:
16748        // `to_str(<bytes>)` is the bytes overload, not the first one whose
16749        // parameter type no check rejects.
16750        let exact = by_arity.iter().copied().find(|d| {
16751            params_for_args(d, args.len())
16752                .zip(&args)
16753                .any(|(p, a)| p.ty.scalar_pg_type().is_some() && infer_ir_type(a).is_some())
16754                && params_for_args(d, args.len()).zip(&args).all(|(p, a)| {
16755                    match (p.ty.scalar_pg_type(), infer_ir_type(a)) {
16756                        (Some(declared), Some(known)) => declared == known,
16757                        _ => pylon_type_matches(a, &p.ty),
16758                    }
16759                })
16760        });
16761        let best = exact.or_else(|| {
16762            by_arity.iter().copied().find(|d| {
16763                params_for_args(d, args.len())
16764                    .zip(&args)
16765                    .all(|(p, a)| pylon_type_matches(a, &p.ty))
16766            })
16767        });
16768
16769        // A parameter that takes one value takes the walk's value, not the
16770        // array its rows were gathered into: `contains(ids, .<menus.id)`
16771        // compares uuids. A `set of …`/`array<…>` parameter asked for the
16772        // whole set and keeps it.
16773        let args_len = args.len();
16774        let args: Vec<IrExpr> = match best {
16775            Some(descriptor) => args
16776                .into_iter()
16777                .enumerate()
16778                .map(
16779                    |(i, arg)| match params_for_args(descriptor, args_len).nth(i).map(|p| &p.ty) {
16780                        Some(crate::stdlib::PylonType::Set(_)) | Some(crate::stdlib::PylonType::Array(_)) | None => arg,
16781                        Some(_) => set_walk_as_scalar(arg),
16782                    },
16783                )
16784                .collect(),
16785            None => args,
16786        };
16787        let args = match best {
16788            Some(descriptor) => pack_variadic_args(descriptor, args),
16789            None => args,
16790        };
16791
16792        // An `optional<…>` return still travels as its own scalar — a call
16793        // that may yield nothing (`json_get`) is typed by what it yields when
16794        // it does, or every cast and overload over it reads as untyped.
16795        let stdlib_return = best
16796            .map(|d| match &d.return_type {
16797                crate::stdlib::PylonType::Optional(inner) => inner.as_ref(),
16798                other => other,
16799            })
16800            .and_then(|ty| ty.scalar_pg_type())
16801            .map(str::to_string);
16802        let (schema, resolved_name, sql_template) = if let Some(desc) = best {
16803            match &desc.impl_strategy {
16804                ImplStrategy::SqlBuiltin(sql_name) => (None, sql_name.to_string(), None),
16805                ImplStrategy::SqlExpression(tmpl) => (None, name.to_string(), Some(tmpl.to_string())),
16806                ImplStrategy::PylonFunction(def) => (Some("_pylon".to_string()), def.name.to_string(), None),
16807                // Binary infix operator: emit as a template so sql/mod.rs's
16808                // generic FunctionCall path (which only ever calls
16809                // `schema.name(args)`) doesn't swallow the operator symbol —
16810                // previously unreachable in practice (only `std::overlaps`
16811                // used this strategy, untested, and would have silently
16812                // resolved to a nonexistent `"std".overlaps(...)` call).
16813                ImplStrategy::SqlOperator(op) if args.len() == 2 => {
16814                    (None, name.to_string(), Some(format!("($1 {op} $2)")))
16815                }
16816                ImplStrategy::TranspilerIntrinsic(intrinsic) => {
16817                    return self.compile_range_intrinsic(intrinsic, name, args);
16818                }
16819                // TranspilerIntrinsic: pass through; handled elsewhere
16820                _ => (module.map(str::to_string), name.to_string(), None),
16821            }
16822        } else {
16823            // Fall back to user-defined scalar functions. Overload
16824            // resolution is by (module, name, argument count) only — true
16825            // Postgres-style resolution by argument *type* isn't
16826            // implemented (a call whose args happen to have the right
16827            // count for the wrong-typed overload still picks that one,
16828            // relying on the forced TypeCast below rather than erroring).
16829            // Candidates are searched for one whose param count actually
16830            // matches the call — taking just the first (module, name)
16831            // match regardless of arg count used to silently pick the
16832            // wrong overload's signature (and then error on arg count)
16833            // whenever an earlier-declared overload happened to have a
16834            // different arity than the one actually being called
16835            // (confirmed live: a 2-arg call to an overload set whose
16836            // first-declared member takes 1 arg reported "expects 1
16837            // argument(s), got 2" even though a 2-arg overload existed).
16838            let candidates: Vec<&FunctionDescriptor> = self
16839                .schema
16840                .functions
16841                .iter()
16842                .filter(|f| {
16843                    let module_matches = module.map(|m| m == f.module.as_str()).unwrap_or(true);
16844                    module_matches && f.name == name && !f.return_is_object
16845                })
16846                .collect();
16847            let user_fn = candidates
16848                .iter()
16849                .find(|f| f.params.len() == args.len())
16850                .copied()
16851                .or_else(|| candidates.first().copied());
16852            if let Some(fd) = user_fn {
16853                if fd.params.len() != args.len() {
16854                    return Err(self.type_err(&format!(
16855                        "function '{}::{}' expects {} argument(s), got {}",
16856                        fd.module,
16857                        fd.name,
16858                        fd.params.len(),
16859                        args.len()
16860                    )));
16861                }
16862                let cast_args = fd
16863                    .params
16864                    .iter()
16865                    .zip(args)
16866                    .map(|(p, a)| {
16867                        IrExpr::TypeCast(Box::new(super::IrTypeCast {
16868                            expr: a,
16869                            pg_type: p.pg_type.clone(),
16870                            tuple_shape: None,
16871                        }))
16872                    })
16873                    .collect();
16874                let qualified = format!("{}::{}", fd.module, fd.name);
16875                let fn_module = fd.module.clone();
16876                let fn_name = fd.name.clone();
16877                let mut call_args: Vec<IrExpr> = cast_args;
16878                if let Some(globals) = self.globals_arg_for_call(&qualified)? {
16879                    call_args.insert(0, globals);
16880                }
16881                // A set-returning function has no single value to type, and
16882                // the declared type of an object-returning one names a type,
16883                // not a column — neither is a scalar `infer_ir_type` may
16884                // report.
16885                let return_pg_type = (!fd.return_is_set).then(|| fd.return_pg_type.clone());
16886                return Ok(IrExpr::FunctionCall(super::IrFunctionCall {
16887                    return_pg_type,
16888                    schema: Some(fn_module),
16889                    name: fn_name,
16890                    args: call_args,
16891                    sql_template: None,
16892                }));
16893            }
16894            // An object-returning function is deliberately not a candidate
16895            // above — it compiles through `try_compile_fn_object_select`, which
16896            // only runs when the call is a select's subject. Saying "does not
16897            // exist" for one that plainly does (and naming `default::` for a
16898            // call the user wrote unqualified) sent people looking for a typo
16899            // instead of at the restriction.
16900            if let Some(fd) = self.schema.functions.iter().find(|f| {
16901                let module_matches = module.map(|m| m == f.module.as_str()).unwrap_or(true);
16902                module_matches && f.name == name && f.return_is_object
16903            }) {
16904                return Err(self.type_err(&format!(
16905                    "function '{}::{}' returns objects, so it can only be the subject of a \
16906                     select (`select {}::{}(…) {{ … }}`), not part of a larger expression",
16907                    fd.module, fd.name, fd.module, fd.name
16908                )));
16909            }
16910            let qualified = match module {
16911                Some(m) => format!("{m}::{name}"),
16912                None => name.to_string(),
16913            };
16914            // The name is real and only the call is wrong — say which,
16915            // rather than resolving to whichever overload happened to be
16916            // registered first and letting PostgreSQL reject the emitted
16917            // SQL at query time with a signature the author never wrote.
16918            if !overloads.is_empty() {
16919                return Err(self.type_err(&if by_arity.is_empty() {
16920                    format!(
16921                        "function '{qualified}' takes {}, got {}",
16922                        describe_arities(&overloads),
16923                        args.len()
16924                    )
16925                } else {
16926                    format!(
16927                        "function '{qualified}' has no overload accepting ({}) — it accepts {}",
16928                        describe_args(&args),
16929                        describe_signatures(&by_arity),
16930                    )
16931                }));
16932            }
16933            return Err(self.type_err(&format!(
16934                "function '{qualified}' does not exist{}",
16935                self.suggest_function_name(ns, name)
16936            )));
16937        };
16938
16939        Ok(IrExpr::FunctionCall(super::IrFunctionCall {
16940            return_pg_type: stdlib_return,
16941            schema,
16942            name: resolved_name,
16943            args,
16944            sql_template,
16945        }))
16946    }
16947
16948    /// Resolve `std::range(...)`/`std::multirange(...)` — `TranspilerIntrinsic`
16949    /// entries with no real backing function anywhere (no `_pylon` function,
16950    /// no bare PostgreSQL builtin of that literal name). PostgreSQL has no
16951    /// single polymorphic range constructor — the concrete constructor
16952    /// (`int8range`, `numrange`, `tsrange`, `tstzrange`, `daterange`, and
16953    /// their multirange counterparts) is chosen here from the resolved
16954    /// element type of the arguments, since there's nothing generic to defer
16955    /// to at the SQL level the way `to_jsonb(x)` covers "cast to json".
16956    fn compile_range_intrinsic(&self, intrinsic: &str, name: &str, args: Vec<IrExpr>) -> Result<IrExpr, PyQLError> {
16957        match intrinsic {
16958            "range" => {
16959                // Either endpoint names the element type, since a range open
16960                // on one side carries it only on the other.
16961                let bounds = args.len().saturating_sub(3);
16962                let point_ty = args[..bounds].iter().find_map(infer_ir_type).ok_or_else(|| {
16963                    self.type_err(
16964                        "range(): cannot infer the element type from either bound — \
16965                     use an explicit cast, e.g. range(<int64>$lower, <int64>$upper)",
16966                    )
16967                })?;
16968                let ctor = range_ctor_for_pg_type(point_ty).ok_or_else(|| {
16969                    self.type_err(&format!(
16970                        "range(): unsupported element type '{point_ty}' — PostgreSQL only has native \
16971                     ranges over int64, decimal, datetime, cal::local_datetime, and cal::local_date"
16972                    ))
16973                })?;
16974                // `inc_lower`/`inc_upper`/`empty` are named-only and so always
16975                // present, defaults included: four arguments is the form that
16976                // gave only a lower bound, five the one that gave both.
16977                let upper = match args.len() {
16978                    4 => "NULL",
16979                    5 => "$2",
16980                    n => return Err(self.type_err(&format!("range(): unexpected argument count {n}"))),
16981                };
16982                let flags: Vec<Option<bool>> = args[args.len() - 3..].iter().map(bool_literal).collect();
16983                // Written out or defaulted, the three are constants at nearly
16984                // every call site, and folding them keeps a plain `range(a, b)`
16985                // emitting a plain constructor instead of a CASE over literals.
16986                let sql_template = match flags[..] {
16987                    [Some(_), Some(_), Some(true)] => format!("'empty'::{ctor}"),
16988                    [Some(true), Some(false), Some(false)] => format!("{ctor}($1, {upper})"),
16989                    [Some(inc_lower), Some(inc_upper), Some(false)] => format!(
16990                        "{ctor}($1, {upper}, '{}{}')",
16991                        if inc_lower { '[' } else { '(' },
16992                        if inc_upper { ']' } else { ')' },
16993                    ),
16994                    _ => {
16995                        let (lower_inc, upper_inc, empty) = match args.len() {
16996                            4 => ("$2", "$3", "$4"),
16997                            _ => ("$3", "$4", "$5"),
16998                        };
16999                        format!(
17000                            "(CASE WHEN {empty} THEN 'empty'::{ctor} ELSE {ctor}($1, {upper}, \
17001                             (CASE WHEN {lower_inc} THEN '[' ELSE '(' END) \
17002                             || (CASE WHEN {upper_inc} THEN ']' ELSE ')' END)) END)"
17003                        )
17004                    }
17005                };
17006                // `.name` carries the *resolved* constructor (not the
17007                // original `range`) so a wrapping `multirange([range(...)])`
17008                // call can identify the element family — emission always
17009                // goes through `sql_template` above, so this doesn't change
17010                // the SQL text.
17011                // A PostgreSQL range constructor shares its name with the
17012                // type it builds (`int8range(…)` is an `int8range`), so the
17013                // ctor doubles as the return type — which is what lets a
17014                // range reach a `range<anypoint>` parameter as itself rather
17015                // than as an untyped expression.
17016                Ok(IrExpr::FunctionCall(super::IrFunctionCall {
17017                    return_pg_type: Some(ctor.to_string()),
17018                    schema: None,
17019                    name: ctor.to_string(),
17020                    args,
17021                    sql_template: Some(sql_template),
17022                }))
17023            }
17024            "multirange" => {
17025                let Some(IrExpr::Array(elems)) = args.first() else {
17026                    return Err(self.type_err("multirange(): argument must be an array literal of ranges"));
17027                };
17028                let first_ctor = elems
17029                    .first()
17030                    .and_then(|e| match e {
17031                        IrExpr::FunctionCall(fc) => Some(fc.name.as_str()),
17032                        _ => None,
17033                    })
17034                    .ok_or_else(|| {
17035                        self.type_err(
17036                            "multirange(): cannot infer the element range type from an empty or non-range \
17037                     array — pass at least one range(...) call, e.g. multirange([range(1, 3)])",
17038                        )
17039                    })?;
17040                let ctor = multirange_ctor_for_range_ctor(first_ctor).ok_or_else(|| {
17041                    self.type_err(&format!("multirange(): unrecognized range constructor '{first_ctor}'"))
17042                })?;
17043                // PostgreSQL's multirange constructors (int8multirange, etc.)
17044                // are VARIADIC — they don't accept a plain array argument
17045                // without the VARIADIC keyword.
17046                Ok(IrExpr::FunctionCall(super::IrFunctionCall {
17047                    return_pg_type: Some(ctor.to_string()),
17048                    schema: None,
17049                    name: name.to_string(),
17050                    args,
17051                    sql_template: Some(format!("{ctor}(VARIADIC $1)")),
17052                }))
17053            }
17054            other => Err(self.type_err(&format!("internal error: unhandled TranspilerIntrinsic '{other}'"))),
17055        }
17056    }
17057
17058    // ── Sequence function helpers ──────────────────────────────────────────────────
17059
17060    /// Compile `sequence_next(SeqType)` → `nextval('"module"."Name_seq"')`
17061    /// and `sequence_reset(SeqType[, val])` → `setval(...)`.
17062    fn compile_sequence_fn(&mut self, fc: &ast::FunctionCall) -> Result<IrExpr, PyQLError> {
17063        let (module, scalar_name) = self.resolve_sequence_scalar_arg(fc)?;
17064
17065        if fc.name == "sequence_next" {
17066            if fc.args.len() != 1 {
17067                return Err(self.type_err("sequence_next takes exactly 1 argument"));
17068            }
17069            let sql = format!("nextval('\"{}\".\"{}_seq\"')", module, scalar_name);
17070            return Ok(IrExpr::FunctionCall(super::IrFunctionCall {
17071                return_pg_type: None,
17072                schema: None,
17073                name: "nextval".into(),
17074                args: vec![],
17075                sql_template: Some(sql),
17076            }));
17077        }
17078
17079        // sequence_reset
17080        match fc.args.len() {
17081            1 => {
17082                let sql = format!("setval('\"{}\".\"{}_seq\"', 1, false)", module, scalar_name);
17083                Ok(IrExpr::FunctionCall(super::IrFunctionCall {
17084                    return_pg_type: None,
17085                    schema: None,
17086                    name: "setval".into(),
17087                    args: vec![],
17088                    sql_template: Some(sql),
17089                }))
17090            }
17091            2 => {
17092                let val = self.compile_free_expr(&fc.args[1])?;
17093                let sql = format!("setval('\"{}\".\"{}_seq\"', $1, true)", module, scalar_name);
17094                Ok(IrExpr::FunctionCall(super::IrFunctionCall {
17095                    return_pg_type: None,
17096                    schema: None,
17097                    name: "setval".into(),
17098                    args: vec![val],
17099                    sql_template: Some(sql),
17100                }))
17101            }
17102            _ => Err(self.type_err("sequence_reset takes 1 or 2 arguments")),
17103        }
17104    }
17105
17106    /// Resolve the first argument of a sequence function to `(module, scalar_name)`.
17107    /// The argument must be an unqualified path that names a sequence scalar in the schema.
17108    fn resolve_sequence_scalar_arg(&self, fc: &ast::FunctionCall) -> Result<(String, String), PyQLError> {
17109        use crate::parse::ast::{Expr, Path, PathStep};
17110
17111        let arg = fc.args.first().ok_or_else(|| {
17112            self.type_err(&format!(
17113                "{}() requires a sequence scalar type as its first argument",
17114                fc.name
17115            ))
17116        })?;
17117
17118        // The parser encodes `module::Name` as a single PathStep::Name("module::Name"),
17119        // so we split on "::" here to recover the module part.
17120        let (arg_module, arg_name): (Option<&str>, &str) = match arg {
17121            Expr::Path(Path { steps, partial: false }) => match steps.as_slice() {
17122                [PathStep::Name(s)] => {
17123                    if let Some((m, n)) = s.split_once("::") {
17124                        (Some(m), n)
17125                    } else {
17126                        (None, s.as_str())
17127                    }
17128                }
17129                _ => return Err(self.type_err(&format!(
17130                    "{}(): first argument must be a sequence scalar type name (e.g. OrderNumber or default::OrderNumber)",
17131                    fc.name
17132                ))),
17133            },
17134            _ => return Err(self.type_err(&format!(
17135                "{}(): first argument must be a sequence scalar type name (e.g. OrderNumber or default::OrderNumber)",
17136                fc.name
17137            ))),
17138        };
17139
17140        let scalar = self.schema.scalars.iter().find(|s| {
17141            s.is_sequence && s.name == arg_name && arg_module.map(|m| m == s.module.as_str()).unwrap_or(true)
17142        });
17143
17144        match scalar {
17145            Some(s) => Ok((s.module.clone(), s.name.clone())),
17146            None => Err(self.type_err(&format!(
17147                "{}(): '{}' is not a known sequence scalar type",
17148                fc.name, arg_name
17149            ))),
17150        }
17151    }
17152
17153    // ── Channel notify() helpers ────────────────────────────────────────────────
17154
17155    /// PostgreSQL's hard per-NOTIFY-payload limit (`NOTIFY_PAYLOAD_MAX_LENGTH`
17156    /// in the Postgres source) — a payload at or over this is rejected by the
17157    /// server at runtime, always, for every session. Checked here only when
17158    /// the payload is a literal string (the one case actually decidable at
17159    /// compile time — see `static_min_payload_bytes` for how much of an
17160    /// arbitrary payload expression can be sized without running it.
17161    const NOTIFY_PAYLOAD_MAX_BYTES: usize = 8000;
17162
17163    /// The rejection every non-object payload for a Type channel shares.
17164    fn notify_type_payload_err(&self, channel: &str, qname: &str) -> PyQLError {
17165        self.type_err(&format!(
17166            "notify(): payload for Channel '{channel}' (a '{qname}' object channel) must name an object of that \
17167             type — either a with-block binding, or __new__/__old__ inside a trigger handler"
17168        ))
17169    }
17170
17171    /// Compile `notify(Channel, payload)` → `pg_notify('<wire_name>', (<payload>)::text)`.
17172    /// The payload's required shape depends on the Channel's declared kind:
17173    /// - `Type(qname)`: payload must be the bare `__new__`/`__old__` trigger
17174    ///   anchor for that exact type — sends its `.id`, not the whole row (see
17175    ///   this function's own restriction: a general object-typed expression
17176    ///   isn't supported yet, only the trigger-anchor case).
17177    /// - `Scalar(pg_type)`: payload is any expression, best-effort type-checked.
17178    /// - `Object(fields)`: payload must be a free object literal (`{ a := .., b := .. }`)
17179    ///   whose field names exactly match the declared shape.
17180    fn compile_notify(
17181        &mut self,
17182        fc: &ast::FunctionCall,
17183        ctx: Option<(&TypeDescriptor, &str)>,
17184    ) -> Result<IrExpr, PyQLError> {
17185        use crate::parse::ast::{Expr, Path, PathStep};
17186
17187        if fc.args.len() != 2 {
17188            return Err(self.type_err("notify() takes exactly 2 arguments: (Channel, payload)"));
17189        }
17190
17191        let (channel_module, channel_name): (Option<&str>, &str) = match &fc.args[0] {
17192            Expr::Path(Path { steps, partial: false }) => match steps.as_slice() {
17193                [PathStep::Name(s)] => {
17194                    if let Some((m, n)) = s.split_once("::") {
17195                        (Some(m), n)
17196                    } else {
17197                        (None, s.as_str())
17198                    }
17199                }
17200                _ => {
17201                    return Err(self.type_err(
17202                        "notify(): first argument must be a Channel name (e.g. OrderEvents or orders::OrderEvents)",
17203                    ));
17204                }
17205            },
17206            _ => {
17207                return Err(self.type_err(
17208                    "notify(): first argument must be a Channel name (e.g. OrderEvents or orders::OrderEvents)",
17209                ));
17210            }
17211        };
17212        let full_channel_name = match channel_module {
17213            Some(m) => format!("{m}::{channel_name}"),
17214            None => channel_name.to_string(),
17215        };
17216        let channel = self
17217            .resolve_channel(&full_channel_name)
17218            .ok_or_else(|| self.type_err(&format!("notify(): '{channel_name}' is not a known Channel")))?;
17219        let wire_name = channel.wire_name.clone();
17220        let payload_arg = &fc.args[1];
17221
17222        // A bare `select notify(...)` trigger handler has no type at its
17223        // root, so the top-level statement compiles as a *free* select
17224        // (ctx=None) even though `special_anchors` is populated — `__new__`/
17225        // `__old__` only resolve through `compile_path`, which is only ever
17226        // reached when ctx is `Some(..)` (see `Expr::Path`'s arm above).
17227        // Fall back to any bound anchor's (td, alias) as a stand-in ctx so a
17228        // Scalar/Object payload like `__new__.name` or `{ a := __new__.x }`
17229        // still resolves — `compile_path`'s own `__new__`/`__old__` branch
17230        // ignores whatever td/alias ctx carries for those two names anyway,
17231        // it only matters for a real property access on some other root.
17232        // Cloned out of `special_anchors` (rather than borrowed) so it
17233        // doesn't hold an immutable borrow of `self` across the `&mut self`
17234        // `compile_expr_ctx` calls below.
17235        let anchor_fallback: Option<(&'a TypeDescriptor, String)> = self.special_anchors.values().next().cloned();
17236        let ctx = ctx.or_else(|| anchor_fallback.as_ref().map(|(td, alias)| (*td, alias.as_str())));
17237
17238        let payload_ir = match &channel.payload {
17239            crate::schema::ChannelPayload::Type(qname) => {
17240                // The payload for an object channel is the object's `id`, in
17241                // either of the two places one can be named: the `__new__`/
17242                // `__old__` anchor a trigger handler runs against, or a
17243                // with-block binding in an ordinary query — which is what
17244                // lets a notify compose with the mutation that caused it:
17245                //
17246                //   with updated := (update User filter ... set { ... }),
17247                //   select (notify(UserUpdates, updated), updated)
17248                let Expr::Path(Path { steps, partial: false }) = payload_arg else {
17249                    return Err(self.notify_type_payload_err(&full_channel_name, qname));
17250                };
17251                let [PathStep::Name(name)] = steps.as_slice() else {
17252                    return Err(self.notify_type_payload_err(&full_channel_name, qname));
17253                };
17254
17255                if name == "__new__" || name == "__old__" {
17256                    let (anchor_td, alias) = self.special_anchors.get(name.as_str()).cloned().ok_or_else(|| {
17257                        self.type_err(&format!(
17258                            "notify(): '{name}' cannot be used here — it's only bound inside a trigger handler"
17259                        ))
17260                    })?;
17261                    let anchor_qname = format!("{}::{}", anchor_td.module, anchor_td.name);
17262                    if &anchor_qname != qname {
17263                        return Err(self.type_err(&format!(
17264                            "notify(): Channel '{full_channel_name}' expects a payload of type '{qname}', got '{anchor_qname}'"
17265                        )));
17266                    }
17267                    IrExpr::ColumnRef {
17268                        alias,
17269                        column: "id".to_string(),
17270                        pg_type: "uuid".to_string(),
17271                    }
17272                } else if let Some(cte_type) = self.cte_types.get(name.as_str()).cloned() {
17273                    if &cte_type != qname {
17274                        return Err(self.type_err(&format!(
17275                            "notify(): Channel '{full_channel_name}' expects a payload of type '{qname}', got '{cte_type}'"
17276                        )));
17277                    }
17278                    // `CteRef { scalar: false }` emits `(SELECT "id" FROM
17279                    // "<cte>")` — the same id the trigger path sends.
17280                    IrExpr::CteRef {
17281                        name: name.clone(),
17282                        scalar: false,
17283                        pg_type: None,
17284                    }
17285                } else {
17286                    return Err(self.notify_type_payload_err(&full_channel_name, qname));
17287                }
17288            }
17289            crate::schema::ChannelPayload::Scalar(pg_type) => {
17290                let ir = self.compile_expr_ctx(payload_arg, ctx)?;
17291                if let Some(actual) = infer_ir_type(&ir)
17292                    && !types_compatible(actual, pg_type)
17293                {
17294                    return Err(self.type_err(&format!(
17295                            "notify(): Channel '{full_channel_name}' expects a payload of type '{expected}', got '{actual_pyql}'",
17296                            expected = pg_type_to_pyql(pg_type),
17297                            actual_pyql = pg_type_to_pyql(actual),
17298                        )));
17299                }
17300                ir
17301            }
17302            crate::schema::ChannelPayload::Object(declared_fields) => {
17303                let Expr::Shape(sh) = payload_arg else {
17304                    return Err(self.type_err(&format!(
17305                        "notify(): payload for Channel '{full_channel_name}' (an Object channel) must be a free \
17306                         object literal, e.g. {{ {} }}",
17307                        declared_fields
17308                            .iter()
17309                            .map(|(n, _)| format!("{n} := .."))
17310                            .collect::<Vec<_>>()
17311                            .join(", ")
17312                    )));
17313                };
17314                if sh.expr.is_some() {
17315                    return Err(self.type_err(&format!(
17316                        "notify(): payload for Channel '{full_channel_name}' (an Object channel) must be a free \
17317                         object literal, not a shape over a type"
17318                    )));
17319                }
17320                let ir = self.compile_expr_ctx(payload_arg, ctx)?;
17321                let IrExpr::NamedTuple {
17322                    fields,
17323                    is_free_object: true,
17324                } = &ir
17325                else {
17326                    return Err(self.type_err(&format!(
17327                        "notify(): payload for Channel '{full_channel_name}' (an Object channel) must be a free object literal"
17328                    )));
17329                };
17330                let declared_names: std::collections::HashSet<&str> =
17331                    declared_fields.iter().map(|(n, _)| n.as_str()).collect();
17332                let actual_names: std::collections::HashSet<&str> = fields.iter().map(|(n, _)| n.as_str()).collect();
17333                if declared_names != actual_names {
17334                    let mut expected: Vec<&str> = declared_names.iter().copied().collect();
17335                    expected.sort();
17336                    let mut actual: Vec<&str> = actual_names.iter().copied().collect();
17337                    actual.sort();
17338                    return Err(self.type_err(&format!(
17339                        "notify(): payload fields for Channel '{full_channel_name}' don't match — expected {{{}}}, got {{{}}}",
17340                        expected.join(", "), actual.join(", ")
17341                    )));
17342                }
17343                for (name, expr) in fields {
17344                    let Some((_, declared_pg_type)) = declared_fields.iter().find(|(n, _)| n == name) else {
17345                        continue;
17346                    };
17347                    if let Some(actual) = infer_ir_type(expr)
17348                        && !types_compatible(actual, declared_pg_type)
17349                    {
17350                        return Err(self.type_err(&format!(
17351                                "notify(): field '{name}' of Channel '{full_channel_name}' expects type '{expected}', got '{actual_pyql}'",
17352                                expected = pg_type_to_pyql(declared_pg_type),
17353                                actual_pyql = pg_type_to_pyql(actual),
17354                            )));
17355                    }
17356                }
17357                ir
17358            }
17359        };
17360
17361        let floor = static_min_payload_bytes(payload_arg);
17362        if floor >= Self::NOTIFY_PAYLOAD_MAX_BYTES {
17363            return Err(self.type_err(&format!(
17364                "notify(): this payload is at least {floor} bytes, which is at or over PostgreSQL's {}-byte NOTIFY \
17365                 payload limit — the notification would fail at runtime, aborting the transaction that sent it",
17366                Self::NOTIFY_PAYLOAD_MAX_BYTES
17367            )));
17368        }
17369
17370        let sql = format!("pg_notify('{}', ($1)::text)", wire_name.replace('\'', "''"));
17371        Ok(IrExpr::FunctionCall(super::IrFunctionCall {
17372            return_pg_type: None,
17373            schema: None,
17374            name: "pg_notify".to_string(),
17375            args: vec![payload_ir],
17376            sql_template: Some(sql),
17377        }))
17378    }
17379
17380    /// Compile `notify_raw(channel_name, payload)` → `pg_notify($1, $2)` — the
17381    /// escape hatch that bypasses Channel resolution and payload-shape
17382    /// checking entirely: both arguments are arbitrary text expressions.
17383    fn compile_notify_raw(
17384        &mut self,
17385        fc: &ast::FunctionCall,
17386        ctx: Option<(&TypeDescriptor, &str)>,
17387    ) -> Result<IrExpr, PyQLError> {
17388        if fc.args.len() != 2 {
17389            return Err(self.type_err("notify_raw() takes exactly 2 arguments: (channel_name, payload)"));
17390        }
17391        let channel_ir = self.compile_expr_ctx(&fc.args[0], ctx)?;
17392        let payload_ir = self.compile_expr_ctx(&fc.args[1], ctx)?;
17393
17394        let floor = static_min_payload_bytes(&fc.args[1]);
17395        if floor >= Self::NOTIFY_PAYLOAD_MAX_BYTES {
17396            return Err(self.type_err(&format!(
17397                "notify_raw(): this payload is at least {floor} bytes, which is at or over PostgreSQL's {}-byte \
17398                 NOTIFY payload limit",
17399                Self::NOTIFY_PAYLOAD_MAX_BYTES
17400            )));
17401        }
17402
17403        Ok(IrExpr::FunctionCall(super::IrFunctionCall {
17404            return_pg_type: None,
17405            schema: None,
17406            name: "pg_notify".to_string(),
17407            args: vec![channel_ir, payload_ir],
17408            sql_template: None,
17409        }))
17410    }
17411
17412    /// The `GLOBALS_ARG` value to pass when calling `qualified`, or `None` if
17413    /// that function does not take one.
17414    ///
17415    /// Inside a function body the caller's own argument is forwarded verbatim;
17416    /// at the top level every session global is packed, rather than just the
17417    /// ones the callee happens to read. Packing the whole set costs one small
17418    /// jsonb per call and keeps the caller from having to know anything about
17419    /// the callee's body.
17420    fn globals_arg_for_call(&mut self, qualified: &str) -> Result<Option<IrExpr>, PyQLError> {
17421        if self.fns_needing_globals.is_none() {
17422            self.fns_needing_globals = Some(functions_needing_globals(self.schema).clone());
17423        }
17424        if !self.fns_needing_globals.as_ref().is_some_and(|s| s.contains(qualified)) {
17425            return Ok(None);
17426        }
17427        if self.in_fn_body {
17428            self.used_globals_arg = true;
17429            return Ok(Some(IrExpr::RawSql(GLOBALS_ARG.to_string())));
17430        }
17431        let session_globals: Vec<String> = self
17432            .schema
17433            .globals
17434            .iter()
17435            .filter(|g| g.computed_expr.is_none())
17436            .map(|g| format!("{}::{}", g.module, g.name))
17437            .collect();
17438        let mut args = Vec::with_capacity(session_globals.len() * 2);
17439        for name in session_globals {
17440            let value = self.compile_global(&name)?;
17441            args.push(IrExpr::Literal(IrLiteral::Str(name)));
17442            args.push(value);
17443        }
17444        Ok(Some(IrExpr::FunctionCall(super::IrFunctionCall {
17445            return_pg_type: None,
17446            schema: None,
17447            name: "jsonb_build_object".to_string(),
17448            args,
17449            sql_template: None,
17450        })))
17451    }
17452
17453    // ── User-defined function helpers ─────────────────────────────────────────────
17454
17455    /// Try to compile `fn(args) { shape }` as a `FunctionSelect` for an object-returning
17456    /// user function.  Returns `None` if no matching user function exists (so the caller
17457    /// can fall through to other dispatch paths).
17458    fn try_compile_fn_object_select(
17459        &mut self,
17460        fc: &ast::FunctionCall,
17461        elements: &[ast::ShapeElement],
17462        s: &ast::SelectStmt,
17463        distinct: bool,
17464    ) -> Result<Option<IrFunctionSelect>, PyQLError> {
17465        use crate::schema::FunctionDescriptor;
17466
17467        let fd: Option<&FunctionDescriptor> = self.schema.functions.iter().find(|f| {
17468            let module_matches = fc.module.as_deref().map(|m| m == f.module.as_str()).unwrap_or(true);
17469            module_matches && f.name == fc.name && f.return_is_object
17470        });
17471        let fd = match fd {
17472            Some(f) => f,
17473            None => return Ok(None),
17474        };
17475        if fd.params.len() != fc.args.len() {
17476            return Err(self.type_err(&format!(
17477                "function '{}::{}' expects {} argument(s), got {}",
17478                fd.module,
17479                fd.name,
17480                fd.params.len(),
17481                fc.args.len()
17482            )));
17483        }
17484
17485        let fn_module = fd.module.clone();
17486        let fn_name = fd.name.clone();
17487        let return_type_name = fd.return_pg_type.clone(); // qualified type name for object returns
17488        let polymorphic = fd.return_is_polymorphic;
17489
17490        let mut fn_args = fc
17491            .args
17492            .iter()
17493            .map(|a| self.compile_free_expr(a))
17494            .collect::<Result<Vec<_>, _>>()?;
17495        let qualified = format!("{}::{}", fn_module, fn_name);
17496        if let Some(globals) = self.globals_arg_for_call(&qualified)? {
17497            fn_args.insert(0, globals);
17498        }
17499
17500        let alias = self.fresh_alias();
17501
17502        // Resolve the return type to build the shape.
17503        let td = self.resolve_type(&return_type_name)?;
17504        let td = td.clone();
17505
17506        let (poly_implementors, poly_columns) = if polymorphic {
17507            self.collect_poly_info(&return_type_name)
17508        } else {
17509            (vec![], vec![])
17510        };
17511
17512        let td_module = td.module.clone();
17513        // Build shape against the return type (treat alias as the source alias).
17514        let shape = self.compile_shape(elements, &td, &alias, &td_module)?;
17515        // The shape's own computeds are in scope for the select's clauses,
17516        // the same as over a type -- added only now, so one pointer still
17517        // cannot read another.
17518        let outer_declared = self.active_declared_pointers.clone();
17519        self.active_declared_pointers
17520            .extend(elements.iter().filter(|el| el.compexpr.is_some()).cloned());
17521        let modifiers = self.compile_path_modifiers(s, &td, &alias);
17522        self.active_declared_pointers = outer_declared;
17523        let (filter, order_by, offset, limit) = modifiers?;
17524
17525        Ok(Some(IrFunctionSelect {
17526            fn_module,
17527            fn_name,
17528            fn_args,
17529            alias,
17530            type_name: return_type_name,
17531            polymorphic,
17532            poly_implementors,
17533            poly_columns,
17534            shape,
17535            filter,
17536            order_by,
17537            offset,
17538            limit,
17539            distinct,
17540        }))
17541    }
17542
17543    /// The interface's own physical columns (properties + `{link}_id`) — the
17544    /// subset every concrete implementor table is guaranteed to share, safe
17545    /// to RETURNING/SELECT uniformly across a poly_implementors fan-out.
17546    /// Shared by compile_select (read path) and the IrUpdate/IrDelete
17547    /// builders (write path) so a DML-as-CTE fan-out (sql/mod.rs) can expose
17548    /// the same columns a polymorphic select would.
17549    /// Point a shape's single-link sub-selects at the nested-DML CTE that
17550    /// supplied their foreign key.
17551    ///
17552    /// `insert Account { credentials := (insert Credentials { … }) } { ** }`
17553    /// runs the nested insert as a data-modifying CTE, and Postgres does not
17554    /// show one statement's CTE writes to the rest of that statement: reading
17555    /// `access."Credentials"` back finds nothing, so the link hydrated as
17556    /// `None` even though the row was there on the next statement (confirmed
17557    /// live). The CTE itself is in scope, and `RETURNING *` gives it every
17558    /// column the shape asks for, so the sub-select reads that instead.
17559    fn read_nested_links_from_their_ctes(dml: &IrStmt, dml_cte: Option<&str>, shape: &mut [IrShapePointer]) {
17560        let (assignments, nested_ctes, appends) = match dml {
17561            IrStmt::Insert(ins) => (&ins.assignments, &ins.nested_ctes, &ins.multi_link_appends),
17562            IrStmt::Update(upd) => (&upd.assignments, &upd.nested_ctes, &upd.multi_link_appends),
17563            _ => return,
17564        };
17565        // A multi-link's junction rows are written by this statement too, in
17566        // a CTE the emitter names after the DML's own — see
17567        // `emit_insert_multilink_ctes`. The targets themselves come from the
17568        // append's value source, which for a nested insert is one of
17569        // `nested_ctes`.
17570        if let Some(dml_cte) = dml_cte {
17571            for pointer in shape.iter_mut() {
17572                let IrShapePointer::MultiLink(link) = pointer else {
17573                    continue;
17574                };
17575                let IrMultiLinkJoin::Standard { junction_table, .. } = &mut link.join else {
17576                    continue;
17577                };
17578                let Some(index) = appends.iter().position(|a| a.junction_table == *junction_table) else {
17579                    continue;
17580                };
17581                *junction_table = format!("@cte:{dml_cte}__ml_add_{index}");
17582                if let IrMultiLinkValueSource::CteRef(target_cte) = &appends[index].values.source {
17583                    for row in &mut link.subquery.rows {
17584                        if let IrRowSource::Bound { source, .. } = row {
17585                            source.table = format!("@cte:{target_cte}");
17586                            source.poly = None;
17587                        }
17588                    }
17589                }
17590            }
17591        }
17592        if nested_ctes.is_empty() {
17593            return;
17594        }
17595        let from_cte: HashMap<&str, &str> = assignments
17596            .iter()
17597            .filter_map(|(column, expr)| match expr {
17598                IrExpr::ColumnRef { alias, column: c, .. }
17599                    if c == "id" && nested_ctes.iter().any(|cte| cte.name == *alias) =>
17600                {
17601                    Some((column.as_str(), alias.as_str()))
17602                }
17603                _ => None,
17604            })
17605            .collect();
17606        if from_cte.is_empty() {
17607            return;
17608        }
17609        for pointer in shape {
17610            let IrShapePointer::SingleLink(link) = pointer else {
17611                continue;
17612            };
17613            let IrSingleLinkCorrelation::Fk { fk_column, .. } = &link.correlation else {
17614                continue;
17615            };
17616            let Some(cte_name) = from_cte.get(fk_column.as_str()) else {
17617                continue;
17618            };
17619            for row in &mut link.subquery.rows {
17620                if let IrRowSource::Bound { source, .. } = row {
17621                    source.table = format!("@cte:{cte_name}");
17622                    source.poly = None;
17623                }
17624            }
17625        }
17626    }
17627
17628    fn poly_dml_columns(td: &TypeDescriptor) -> Vec<String> {
17629        td.properties
17630            .iter()
17631            .map(|p| p.name.clone())
17632            .chain(
17633                td.links
17634                    .iter()
17635                    .filter(|l| !l.is_junction_backed())
17636                    .map(|l| format!("{}_id", l.name)),
17637            )
17638            .collect()
17639    }
17640
17641    /// A nested SELECT over a link's target, expanded inline over the
17642    /// interface's implementors when the target is one.
17643    ///
17644    /// An interface is materialised as a view that carries only the
17645    /// interface's own columns -- no discriminator -- so reading a link
17646    /// through it tags every row as the interface itself and hydrates the
17647    /// interface class rather than the concrete one. Expanding the
17648    /// implementors inline is what the root of a query already does, and each
17649    /// branch supplies its own `__type__`.
17650    fn link_target_select(
17651        &self,
17652        target_td: &TypeDescriptor,
17653        mut source: IrSource,
17654        shape: Vec<IrShapePointer>,
17655    ) -> IrSelect {
17656        source.poly = self.link_target_fanout(target_td);
17657        IrSelect::schema_bound(source, shape, None)
17658    }
17659
17660    /// The fan-out a link's target needs, for the builders that assemble their
17661    /// own source rather than going through `link_target_select`.
17662    fn link_target_fanout(&self, target_td: &TypeDescriptor) -> Option<IrPolyFanout> {
17663        self.poly_fanout_for(&format!("{}::{}", target_td.module, target_td.name))
17664    }
17665
17666    /// Give every interface-typed join target its fan-out, so a traversal that
17667    /// lands on one reads the implementors and carries the real type rather
17668    /// than the interface's view, which has no discriminator to carry.
17669    ///
17670    /// The root is left alone: a path select fans its own root out through
17671    /// `poly_implementors` already, and doing it twice would nest the union.
17672    fn resolve_join_fanouts(&self, path_select: &mut IrPathSelect) {
17673        for join in &mut path_select.joins {
17674            let target = match join {
17675                IrPathJoin::Single { target, .. }
17676                | IrPathJoin::Multi { target, .. }
17677                | IrPathJoin::BacklinkSingle { target, .. }
17678                | IrPathJoin::BacklinkMulti { target, .. }
17679                | IrPathJoin::Function { target, .. }
17680                | IrPathJoin::Lateral { target, .. } => target,
17681            };
17682            if target.poly.is_none() && !target.table.starts_with("@cte:") {
17683                target.poly = self.poly_fanout_for(&target.type_name.clone());
17684            }
17685        }
17686    }
17687
17688    /// The fan-out a source of `type_name` needs, or `None` when it is not an
17689    /// interface and so reads from a table of its own. See `IrSource::poly`.
17690    fn poly_fanout_for(&self, type_name: &str) -> Option<IrPolyFanout> {
17691        let td = self
17692            .schema
17693            .types
17694            .iter()
17695            .find(|t| format!("{}::{}", t.module, t.name) == type_name)?;
17696        // A materialised interface has a view to read; a plain abstract has no
17697        // relation at all, so reading its table name finds nothing. Both are
17698        // answered by the union of the types that carry the columns.
17699        if !td.abstract_ && !self.has_subtypes(td) {
17700            return None;
17701        }
17702        let (implementors, columns) = self.collect_poly_info(type_name);
17703        Some(IrPolyFanout { implementors, columns })
17704    }
17705
17706    /// Collect poly_implementors and poly_columns for a polymorphic return type.
17707    fn collect_poly_info(&self, type_name: &str) -> (Vec<IrPolyImplementor>, Vec<String>) {
17708        let implementors = self.find_poly_implementors(type_name);
17709        let columns = if let Some(td) = self
17710            .schema
17711            .types
17712            .iter()
17713            .find(|t| format!("{}::{}", t.module, t.name) == type_name)
17714        {
17715            // The same set the DML path fans out (`poly_dml_columns`): the
17716            // interface's single-link FKs belong in it too, or reading
17717            // `.profile` off an interface-typed source finds no
17718            // `profile_id` column in the union it was fanned out into.
17719            Self::poly_dml_columns(td)
17720        } else {
17721            vec![]
17722        };
17723        (implementors, columns)
17724    }
17725
17726    // ── vector::search ────────────────────────────────────────────────────────────
17727
17728    /// Try to compile `vector::search` from a function-call AST node.
17729    ///
17730    /// Two overloads are supported:
17731    /// - `vector::search(TypeName, $vec [, index_name := '…'])` — pre-computed vector
17732    /// - `vector::search(TypeName, query := $text [, index_name := '…'])` — text overload;
17733    ///   the Python layer embeds the text and injects `__deferred_vec__` before execution.
17734    ///
17735    /// Returns `None` if the call is not `vector::search`.
17736    fn try_compile_vector_search(
17737        &mut self,
17738        fc: &ast::FunctionCall,
17739        elements: &[ast::ShapeElement],
17740        s: &ast::SelectStmt,
17741    ) -> Result<Option<IrVectorSearch>, PyQLError> {
17742        if fc.module.as_deref() != Some("vector") || fc.name != "search" {
17743            return Ok(None);
17744        }
17745
17746        // Detect text overload: `query :=` kwarg present (no positional second arg needed).
17747        let text_query_arg = fc.kwargs.iter().find(|(k, _)| k == "query").map(|(_, v)| v);
17748        let is_text_overload = text_query_arg.is_some();
17749
17750        if !is_text_overload && fc.args.len() < 2 {
17751            return Err(
17752                self.type_err("vector::search requires either a positional vector argument or `query := $text`")
17753            );
17754        }
17755
17756        // First argument: a type name or a filtered subquery narrowing the candidate set.
17757        //   - Bare name:     `Product` or `default::Product`
17758        //   - Subquery:      `(select Product filter .price < 100)`
17759        //
17760        // For the subquery form we store the raw inner filter AST and compile it below,
17761        // after the source alias is generated, so property column refs use the right alias.
17762        let (type_qname, inner_filter_ast): (String, Option<ast::Expr>) = match &fc.args[0] {
17763            ast::Expr::Path(p) if !p.partial => {
17764                let name = p.steps.iter()
17765                    .filter_map(|s| if let ast::PathStep::Name(n) = s { Some(n.as_str()) } else { None })
17766                    .collect::<Vec<_>>().join("::");
17767                let td = self.resolve_type(&name)
17768                    .map_err(|_| self.type_err(&format!("vector::search: '{}' is not a known type", name)))?;
17769                (format!("{}::{}", td.module, td.name), None)
17770            }
17771            ast::Expr::SubQuery(stmt) => {
17772                if let ast::Stmt::Select(inner_sel) = stmt.as_ref() {
17773                    let inner_type_name = match &inner_sel.result {
17774                        ast::Expr::Path(p) if !p.partial => {
17775                            p.steps.iter()
17776                                .filter_map(|s| if let ast::PathStep::Name(n) = s { Some(n.as_str()) } else { None })
17777                                .collect::<Vec<_>>().join("::")
17778                        }
17779                        _ => return Err(self.type_err(
17780                            "vector::search: subquery first argument must select a single type (e.g. select Product filter …)"
17781                        )),
17782                    };
17783                    let td = self.resolve_type(&inner_type_name)
17784                        .map_err(|_| self.type_err(&format!("vector::search: '{}' is not a known type", inner_type_name)))?;
17785                    let qname = format!("{}::{}", td.module, td.name);
17786                    (qname, inner_sel.filter.clone())
17787                } else {
17788                    return Err(self.type_err("vector::search: subquery first argument must be a SELECT"));
17789                }
17790            }
17791            _ => return Err(self.type_err(
17792                "vector::search: first argument must be a type name or a filtered subquery (e.g. select Product filter …)"
17793            )),
17794        };
17795
17796        // Optional named argument: index_name := '…'
17797        let index_name: Option<String> = fc.kwargs.iter().find(|(k, _)| k == "index_name").and_then(|(_, v)| {
17798            if let ast::Expr::Literal(ast::Literal::Str(s)) = v {
17799                Some(s.clone())
17800            } else {
17801                None
17802            }
17803        });
17804
17805        // Resolve the type and find the VectorIndex.
17806        let td = self.resolve_type(&type_qname)?.clone();
17807        let vi = td
17808            .vector_indexes
17809            .iter()
17810            .find(|vi| vi.index_name.as_deref() == index_name.as_deref())
17811            .ok_or_else(|| {
17812                let key = index_name.as_deref().unwrap_or("<default>");
17813                self.type_err(&format!("type '{}' has no vector index '{}'", type_qname, key))
17814            })?;
17815
17816        let vector_col = vi.column_name();
17817        let distance_op = match vi.metric.as_str() {
17818            "euclidean" => "<->",
17819            "inner_product" => "<#>",
17820            _ => "<=>", // cosine (default)
17821        };
17822
17823        // Build query expression and inference fields.
17824        let (
17825            query_expr,
17826            inference_query_param_name,
17827            inference_query_literal,
17828            inference_model,
17829            inference_type_name,
17830            inference_index_name,
17831        );
17832
17833        if is_text_overload {
17834            // Text overload: register __deferred_vec__ as the SQL param; Python injects
17835            // the embedding result into it before executing the query.
17836            let vec_idx = self.param_index("__deferred_vec__");
17837            let vec_param = IrExpr::Param { index: vec_idx };
17838            // Cast float8[] → vector so a Python list[float] encodes natively.
17839            let inner_cast = IrExpr::TypeCast(Box::new(IrTypeCast {
17840                expr: vec_param,
17841                pg_type: "float8[]".to_string(),
17842                tuple_shape: None,
17843            }));
17844            query_expr = IrExpr::TypeCast(Box::new(IrTypeCast {
17845                expr: inner_cast,
17846                pg_type: "vector".to_string(),
17847                tuple_shape: None,
17848            }));
17849            let query_arg = text_query_arg.unwrap();
17850            inference_query_param_name = Some(match query_arg {
17851                ast::Expr::Parameter(name) => name.clone(),
17852                _ => String::new(),
17853            });
17854            inference_query_literal = match query_arg {
17855                ast::Expr::Literal(ast::Literal::Str(s)) => Some(s.clone()),
17856                _ => None,
17857            };
17858            inference_model = Some(vi.model.clone());
17859            inference_type_name = Some(type_qname.clone());
17860            inference_index_name = Some(index_name.clone());
17861        } else {
17862            // Vector overload: second positional arg is the pre-computed vector.
17863            let raw_query_expr = self.compile_free_expr(&fc.args[1])?;
17864            query_expr = IrExpr::TypeCast(Box::new(IrTypeCast {
17865                expr: raw_query_expr,
17866                pg_type: "vector".to_string(),
17867                tuple_shape: None,
17868            }));
17869            inference_query_param_name = None;
17870            inference_query_literal = None;
17871            inference_model = None;
17872            inference_type_name = None;
17873            inference_index_name = None;
17874        }
17875
17876        let alias = self.fresh_alias();
17877        let source = IrSource {
17878            poly: None,
17879            type_name: type_qname.clone(),
17880            table: td.table.clone(),
17881            alias: alias.clone(),
17882        };
17883
17884        // Compile inner_filter_ast (from subquery first arg) now that we have the alias,
17885        // so property column refs (e.g. `.price`) use the correct table alias.
17886        let pre_filter: Option<IrExpr> = match inner_filter_ast {
17887            Some(ref f) => Some(self.as_condition(|c| c.compile_expr(f, &td, &alias))?),
17888            None => None,
17889        };
17890
17891        // Compile the object shape from `object { … }` inside the shape elements.
17892        // `elements` is the outer shape (`{ object { … }, distance }`).
17893        // We find the `object` element and take its sub-shape; everything else is ignored
17894        // at compile time (distance is always emitted; unknown pointers are an error).
17895        let mut object_shape: Vec<IrShapePointer> = vec![];
17896        for el in elements {
17897            if el.splat.is_some() {
17898                continue;
17899            } // ignore splat in outer shape
17900            let pointer_name = match el.path.steps.first() {
17901                Some(ast::PathStep::Name(n)) => n.as_str(),
17902                _ => continue,
17903            };
17904            match pointer_name {
17905                "distance" => { /* always emitted; no sub-shape */ }
17906                "object" => {
17907                    let sub_els = el.nested.as_deref().unwrap_or(&[]);
17908                    object_shape = self.compile_shape(sub_els, &td, &alias, &td.module)?;
17909                }
17910                other => {
17911                    return Err(self.type_err(&format!(
17912                        "vector::search result has no pointer '{}'; valid pointers are 'object' and 'distance'",
17913                        other
17914                    )));
17915                }
17916            }
17917        }
17918
17919        let (outer_filter, order_by_distance, offset, limit) = self.compile_vs_modifiers(s)?;
17920
17921        // Merge pre_filter (from subquery first arg) with any outer filter via AND.
17922        let filter = match (pre_filter, outer_filter) {
17923            (Some(a), Some(b)) => Some(IrExpr::BinOp(Box::new(IrBinOp {
17924                left: a,
17925                op: ast::BinOpKind::And,
17926                right: b,
17927            }))),
17928            (Some(f), None) | (None, Some(f)) => Some(f),
17929            (None, None) => None,
17930        };
17931
17932        Ok(Some(IrVectorSearch {
17933            source,
17934            vector_col,
17935            distance_op,
17936            query_expr,
17937            object_shape,
17938            filter,
17939            order_by_distance,
17940            offset,
17941            limit,
17942            inference_query_param_name,
17943            inference_query_literal,
17944            inference_model,
17945            inference_type_name,
17946            inference_index_name,
17947        }))
17948    }
17949
17950    /// Compile `order by`, `filter`, `offset`, `limit` for a VectorSearch source.
17951    /// Recognises `.distance` (relative path) as the distance expression.
17952    fn compile_vs_modifiers(&mut self, s: &ast::SelectStmt) -> Result<SearchModifiers, PyQLError> {
17953        let mut order_by_distance: Option<IrSortDir> = None;
17954        for sort in &s.order_by {
17955            let is_distance = matches!(&sort.expr,
17956                ast::Expr::Path(p) if p.partial && p.steps.len() == 1
17957                    && matches!(&p.steps[0], ast::PathStep::Name(n) if n == "distance")
17958            );
17959            if is_distance {
17960                let dir = match sort.direction {
17961                    ast::SortDirection::Desc => IrSortDir::Desc,
17962                    ast::SortDirection::Asc => IrSortDir::Asc,
17963                };
17964                order_by_distance = Some(dir);
17965            } else {
17966                return Err(self.type_err("vector::search: only 'order by .distance' is supported as a sort key"));
17967            }
17968        }
17969
17970        let filter = match &s.filter {
17971            Some(f) => Some(self.compile_free_expr(f)?),
17972            None => None,
17973        };
17974        let offset = match &s.offset {
17975            Some(o) => Some(self.compile_free_expr(o)?),
17976            None => None,
17977        };
17978        let limit = match &s.limit {
17979            Some(l) => Some(self.compile_free_expr(l)?),
17980            None => None,
17981        };
17982
17983        Ok((filter, order_by_distance, offset, limit))
17984    }
17985
17986    // ── fts::search ──────────────────────────────────────────────────────────────
17987
17988    /// Try to compile `fts::search(TypeName, $query [, index_name := '…'] [, mode := '…'])`.
17989    /// Returns `None` if the call is not `fts::search`.
17990    fn try_compile_fts_search(
17991        &mut self,
17992        fc: &ast::FunctionCall,
17993        elements: &[ast::ShapeElement],
17994        s: &ast::SelectStmt,
17995    ) -> Result<Option<IrFtsSearch>, PyQLError> {
17996        if fc.module.as_deref() != Some("fts") || fc.name != "search" {
17997            return Ok(None);
17998        }
17999        if fc.args.len() < 2 {
18000            return Err(self.type_err("fts::search requires at least 2 arguments: (TypeName, $query)"));
18001        }
18002
18003        // First argument: a bare type name reference.
18004        let type_qname = match &fc.args[0] {
18005            ast::Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
18006                if let ast::PathStep::Name(n) = &p.steps[0] {
18007                    let td = self
18008                        .resolve_type(n)
18009                        .map_err(|_| self.type_err(&format!("fts::search: '{}' is not a known type", n)))?;
18010                    format!("{}::{}", td.module, td.name)
18011                } else {
18012                    return Err(self.type_err("fts::search: first argument must be a type name"));
18013                }
18014            }
18015            _ => return Err(self.type_err("fts::search: first argument must be a bare type name")),
18016        };
18017
18018        // Optional named arguments.
18019        let index_name: Option<String> = fc.kwargs.iter().find(|(k, _)| k == "index_name").and_then(|(_, v)| {
18020            if let ast::Expr::Literal(ast::Literal::Str(s)) = v {
18021                Some(s.clone())
18022            } else {
18023                None
18024            }
18025        });
18026
18027        let mode_str = fc
18028            .kwargs
18029            .iter()
18030            .find(|(k, _)| k == "mode")
18031            .and_then(|(_, v)| {
18032                if let ast::Expr::Literal(ast::Literal::Str(s)) = v {
18033                    Some(s.as_str())
18034                } else {
18035                    None
18036                }
18037            })
18038            .unwrap_or("BestFields");
18039
18040        let tsquery_fn: &'static str = match mode_str {
18041            "Phrase" => "phraseto_tsquery",
18042            _ => "websearch_to_tsquery", // BestFields and PhrasePrefix both use websearch
18043        };
18044
18045        // Resolve the type and find the SearchIndex.
18046        let td = self.resolve_type(&type_qname)?.clone();
18047        let si = td
18048            .search_indexes
18049            .iter()
18050            .find(|si| si.index_name.as_deref() == index_name.as_deref())
18051            .ok_or_else(|| {
18052                let key = index_name.as_deref().unwrap_or("<default>");
18053                self.type_err(&format!("type '{}' has no search index '{}'", type_qname, key))
18054            })?;
18055
18056        let backend = si.backend.clone();
18057        let search_col = si.column_name();
18058        let is_deferred = backend != SearchBackend::Postgres;
18059        let deferred_index_name = if is_deferred {
18060            Some(si.deferred_index_name(&td.module, &td.name))
18061        } else {
18062            None
18063        };
18064
18065        // Second argument: the query text expression.
18066        // For deferred backends, IDs/scores are the only SQL params ($1/$2). The query
18067        // text is consumed by the Python layer before SQL execution, so we extract its
18068        // name/literal directly from the AST without registering a SQL param for it.
18069        let query_expr;
18070        let deferred_query_param_name;
18071        let deferred_query_literal;
18072        let deferred_ids_param;
18073        let deferred_scores_param;
18074
18075        if is_deferred {
18076            let ids_idx = self.param_index("__deferred_ids__");
18077            let scores_idx = self.param_index("__deferred_scores__");
18078            deferred_ids_param = Some(ids_idx);
18079            deferred_scores_param = Some(scores_idx);
18080            deferred_query_param_name = match &fc.args[1] {
18081                ast::Expr::Parameter(name) => Some(name.clone()),
18082                _ => None,
18083            };
18084            deferred_query_literal = match &fc.args[1] {
18085                ast::Expr::Literal(ast::Literal::Str(s)) => Some(s.clone()),
18086                _ => None,
18087            };
18088            // Placeholder — not used in SQL for the deferred backend.
18089            query_expr = IrExpr::Literal(crate::ir::IrLiteral::Str(String::new()));
18090        } else {
18091            query_expr = self.compile_free_expr(&fc.args[1])?;
18092            deferred_query_param_name = None;
18093            deferred_query_literal = None;
18094            deferred_ids_param = None;
18095            deferred_scores_param = None;
18096        }
18097
18098        let alias = self.fresh_alias();
18099        let source = IrSource {
18100            poly: None,
18101            type_name: type_qname.clone(),
18102            table: td.table.clone(),
18103            alias: alias.clone(),
18104        };
18105
18106        // Compile the object shape from `object { … }` in the outer shape.
18107        let mut object_shape: Vec<IrShapePointer> = vec![];
18108        for el in elements {
18109            if el.splat.is_some() {
18110                continue;
18111            }
18112            let pointer_name = match el.path.steps.first() {
18113                Some(ast::PathStep::Name(n)) => n.as_str(),
18114                _ => continue,
18115            };
18116            match pointer_name {
18117                "score" => { /* always emitted */ }
18118                "object" => {
18119                    let sub_els = el.nested.as_deref().unwrap_or(&[]);
18120                    object_shape = self.compile_shape(sub_els, &td, &alias, &td.module)?;
18121                }
18122                other => {
18123                    return Err(self.type_err(&format!(
18124                        "fts::search result has no pointer '{}'; valid pointers are 'object' and 'score'",
18125                        other
18126                    )));
18127                }
18128            }
18129        }
18130
18131        let (filter, order_by_rank, offset, limit) = self.compile_fts_modifiers(s)?;
18132
18133        Ok(Some(IrFtsSearch {
18134            source,
18135            backend,
18136            search_col,
18137            tsquery_fn,
18138            query_expr,
18139            object_shape,
18140            filter,
18141            order_by_rank,
18142            offset,
18143            limit,
18144            deferred_index_name,
18145            deferred_query_param_name,
18146            deferred_query_literal,
18147            deferred_ids_param,
18148            deferred_scores_param,
18149        }))
18150    }
18151
18152    /// Compile `order by`, `filter`, `offset`, `limit` for a FtsSearch source.
18153    /// Recognises `.score` (relative path) as the score expression.
18154    fn compile_fts_modifiers(&mut self, s: &ast::SelectStmt) -> Result<SearchModifiers, PyQLError> {
18155        let mut order_by_rank: Option<IrSortDir> = None;
18156        for sort in &s.order_by {
18157            let is_rank = matches!(&sort.expr,
18158                ast::Expr::Path(p) if p.partial && p.steps.len() == 1
18159                    && matches!(&p.steps[0], ast::PathStep::Name(n) if n == "score")
18160            );
18161            if is_rank {
18162                let dir = match sort.direction {
18163                    ast::SortDirection::Desc => IrSortDir::Desc,
18164                    ast::SortDirection::Asc => IrSortDir::Asc,
18165                };
18166                order_by_rank = Some(dir);
18167            } else {
18168                return Err(self.type_err("fts::search: only 'order by .score' is supported as a sort key"));
18169            }
18170        }
18171
18172        let filter = match &s.filter {
18173            Some(f) => Some(self.compile_free_expr(f)?),
18174            None => None,
18175        };
18176        let offset = match &s.offset {
18177            Some(o) => Some(self.compile_free_expr(o)?),
18178            None => None,
18179        };
18180        let limit = match &s.limit {
18181            Some(l) => Some(self.compile_free_expr(l)?),
18182            None => None,
18183        };
18184
18185        Ok((filter, order_by_rank, offset, limit))
18186    }
18187
18188    // ── Error helpers ─────────────────────────────────────────────────────────────
18189
18190    fn type_err(&self, msg: &str) -> PyQLError {
18191        PyQLError::Type(PyQLTypeError {
18192            message: msg.to_string(),
18193            position: Position { line: 0, col: 0 },
18194        })
18195    }
18196
18197    fn field_err(&self, field: &str, type_name: &str) -> PyQLError {
18198        if std::env::var("PYLON_DBG_FIELD_ERR").is_ok() {
18199            eprintln!(
18200                "DBG field_err {field} on {type_name}
18201{}",
18202                std::backtrace::Backtrace::force_capture()
18203            );
18204        }
18205        let suggestion = self
18206            .schema
18207            .types
18208            .iter()
18209            .find(|t| format!("{}::{}", t.module, t.name) == type_name)
18210            .and_then(|td| Self::suggest_pointer_name(td, field));
18211        let message = match suggestion {
18212            Some(s) => format!("object type '{type_name}' has no link or property '{field}'. Did you mean '{s}'?"),
18213            None => format!("object type '{type_name}' has no link or property '{field}'"),
18214        };
18215        PyQLError::Resolution(PyQLResolutionError::UnknownField(PyQLUnknownFieldError {
18216            message,
18217            position: Position { line: 0, col: 0 },
18218        }))
18219    }
18220
18221    /// A ` — did you mean …?` fragment for an unresolvable function name, or
18222    /// an empty string.
18223    ///
18224    /// Mirrors `pylon.stdlib.unknown_function_message`, which has always
18225    /// offered this for the `std.foo(...)` attribute form; a name written as
18226    /// PyQL text got nothing, which is how a schema converted from a system
18227    /// that spelled it `uuid_generate_v7j` stayed broken instead of being
18228    /// pointed one character back at `uuid_generate_v7`.
18229    ///
18230    /// The wrong-namespace check comes first and is worth its own wording:
18231    /// the namespaces overlap unevenly — `sqrt`/`abs`/`ceil` are in both
18232    /// `std` and `math`, while `pi`/`ln`/`exp` are only in `math` — so
18233    /// guessing the wrong one is routine, and naming where the function
18234    /// actually lives recovers from it in a way a fuzzy match over the
18235    /// namespace that lacks it cannot.
18236    fn suggest_function_name(&self, ns: &str, name: &str) -> String {
18237        const MIN_SIMILARITY: f64 = 0.7;
18238        if let Some(other) = ["std", "math", "cal", "sys"]
18239            .into_iter()
18240            .find(|o| *o != ns && !crate::stdlib::lookup(o, name).is_empty())
18241        {
18242            return format!(" — it lives in {other}, use {other}::{name}()");
18243        }
18244        crate::stdlib::registry()
18245            .iter()
18246            .filter(|d| d.namespace == ns)
18247            .map(|d| (format!("{ns}::{}", d.name), d.name))
18248            .chain(
18249                self.schema
18250                    .functions
18251                    .iter()
18252                    .map(|f| (format!("{}::{}", f.module, f.name), f.name.as_str())),
18253            )
18254            .map(|(qualified, candidate)| (qualified, strsim::jaro_winkler(name, candidate)))
18255            .filter(|(_, score)| *score >= MIN_SIMILARITY)
18256            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
18257            .map(|(qualified, _)| format!(" — did you mean {qualified}()?"))
18258            .unwrap_or_default()
18259    }
18260
18261    /// Fuzzy-matches `name` against every pointer (property/link/multilink/
18262    /// computed — Pylon's term for a type's own attributes; "field" is a
18263    /// Postgres-level term that doesn't apply here) on `td`, returning the
18264    /// closest candidate when it's plausibly a typo — powers a
18265    /// "Did you mean X?" suggestion for an unknown property/link.
18266    /// Jaro-Winkler (favors a shared prefix, which is where most real typos
18267    /// preserve the most characters, e.g. `nam` -> `name`) with a
18268    /// conservative similarity floor, so an unrelated pointer never gets
18269    /// suggested just because it happens to be the "closest" among an
18270    /// otherwise-dissimilar set of candidates.
18271    fn suggest_pointer_name(td: &TypeDescriptor, name: &str) -> Option<String> {
18272        const MIN_SIMILARITY: f64 = 0.7;
18273        td.properties
18274            .iter()
18275            .map(|p| p.name.as_str())
18276            .chain(td.links.iter().map(|l| l.name.as_str()))
18277            .chain(td.multilinks.iter().map(|m| m.name.as_str()))
18278            .chain(td.computed.iter().map(|c| c.name.as_str()))
18279            .map(|candidate| (candidate, strsim::jaro_winkler(name, candidate)))
18280            .filter(|(_, score)| *score >= MIN_SIMILARITY)
18281            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
18282            .map(|(name, _)| name.to_string())
18283    }
18284
18285    // ── Default returning (pk only) ────────────────────────────────────────────
18286
18287    /// For bare DML (not wrapped in SELECT) return only primary-key properties:
18288    /// `INSERT … ` returns `{ id }`, same for UPDATE/DELETE.
18289    fn pk_returning(td: &TypeDescriptor) -> Vec<IrShapePointer> {
18290        td.properties
18291            .iter()
18292            .filter(|p| p.is_pk)
18293            .map(|p| {
18294                IrShapePointer::Scalar(IrScalarPointer {
18295                    implicit_id: false,
18296                    marker_offset: None,
18297                    alias: p.name.clone(),
18298                    column: p.name.clone(),
18299                    pg_type: p.pg_type.clone(),
18300                    tuple_shape: None,
18301                })
18302            })
18303            .collect()
18304    }
18305}
18306
18307// ── Helpers ─────────────────────────────────────────────────────────────────────
18308
18309/// True for `<json>` / `<std::json>` — the one cast target that turns its
18310/// operand into output text rather than another value.
18311fn casts_to_json(ty: &ast::TypeExpr) -> bool {
18312    matches!(ty.as_named(), Some((module, "json")) if module.is_none_or(|m| m == "std"))
18313}
18314
18315/// True if any node in a multilink value tree (including both sides of any
18316/// nested `union`) carries a `@prop := value` link-property assignment.
18317fn has_any_link_props(vals: &IrMultiLinkValues) -> bool {
18318    if !vals.link_props.is_empty() {
18319        return true;
18320    }
18321    match &vals.source {
18322        IrMultiLinkValueSource::Union(a, b) => has_any_link_props(a) || has_any_link_props(b),
18323        _ => false,
18324    }
18325}
18326
18327/// True for a bare `{}` or a `<AnyType>{}` cast of one — the empty-set
18328/// literal a caller uses to clear an optional pointer, in either form. A
18329/// frontend generating PyQL typically emits the cast form (e.g.
18330/// `<Company>{}`, matching `compile_expr`'s own `Expr::TypeCast` handling of
18331/// this exact case), while hand-written PyQL more often uses the bare form.
18332fn is_empty_set_expr(expr: &Expr) -> bool {
18333    match expr {
18334        Expr::Set(elems) => elems.is_empty(),
18335        Expr::TypeCast(tc) => matches!(&tc.expr, Expr::Set(elems) if elems.is_empty()),
18336        _ => false,
18337    }
18338}
18339
18340/// Extract the single pointer name from a relative path used in a shape element.
18341fn path_leaf(p: &ast::Path) -> Result<&str, PyQLError> {
18342    match p.steps.as_slice() {
18343        [ast::PathStep::Name(n)] => Ok(n.as_str()),
18344        _ => Err(PyQLError::Type(PyQLTypeError {
18345            message: "expected a simple pointer name in shape element".into(),
18346            position: Position { line: 0, col: 0 },
18347        })),
18348    }
18349}
18350
18351/// Map a PyQL type expression to a PostgreSQL type string.
18352fn type_expr_to_pg(ty: &ast::TypeExpr) -> Result<String, PyQLError> {
18353    let Some((module, bare_name)) = ty.as_named() else {
18354        // Callers resolve a structural tuple/array directly before ever
18355        // reaching this function (see `resolve_cast_pg_type`) — reaching here
18356        // with one would be an internal bug, not a user-facing scenario.
18357        return Err(PyQLError::Type(PyQLTypeError {
18358            message: "internal error: structural tuple/array type reached type_expr_to_pg".into(),
18359            position: Position { line: 0, col: 0 },
18360        }));
18361    };
18362
18363    // pgvector:: types map directly to PostgreSQL types.
18364    if module == Some("pgvector") {
18365        return match bare_name {
18366            "vector" => Ok("vector".to_string()),
18367            other => Err(PyQLError::Type(PyQLTypeError {
18368                message: format!("unknown pgvector type '{other}'; valid types are: vector"),
18369                position: Position { line: 0, col: 0 },
18370            })),
18371        };
18372    }
18373
18374    // postgis:: types map directly to PostgreSQL types.
18375    if module == Some("postgis") {
18376        return match bare_name {
18377            "geometry" => Ok("geometry".to_string()),
18378            "geography" => Ok("geography".to_string()),
18379            "box2d" => Ok("box2d".to_string()),
18380            "box3d" => Ok("box3d".to_string()),
18381            other => Err(PyQLError::Type(PyQLTypeError {
18382                message: format!("unknown postgis type '{other}'; valid types are: geometry, geography, box2d, box3d"),
18383                position: Position { line: 0, col: 0 },
18384            })),
18385        };
18386    }
18387
18388    // cal:: types map directly to PostgreSQL types.
18389    if module == Some("cal") {
18390        let pg = match bare_name {
18391            "local_datetime" => "timestamp",
18392            "local_date" => "date",
18393            "local_time" => "time",
18394            "relative_duration" | "date_duration" => "interval",
18395            other => {
18396                return Err(PyQLError::Type(PyQLTypeError {
18397                    message: format!(
18398                        "unknown cal type '{other}'; \
18399                     valid types are: local_datetime, local_date, local_time, \
18400                     relative_duration, date_duration"
18401                    ),
18402                    position: Position { line: 0, col: 0 },
18403                }));
18404            }
18405        };
18406        return Ok(pg.to_string());
18407    }
18408
18409    let name = match module {
18410        Some("std") | None => bare_name,
18411        Some(m) => {
18412            return Err(PyQLError::Type(PyQLTypeError {
18413                message: format!("unknown type '{}::{}'", m, bare_name),
18414                position: Position { line: 0, col: 0 },
18415            }));
18416        }
18417    };
18418    Ok(match name {
18419        "str" => "text",
18420        "int16" => "int2",
18421        "int32" => "int4",
18422        "int64" => "int8",
18423        "float32" => "float4",
18424        "float64" => "float8",
18425        "bool" => "boolean",
18426        "uuid" => "uuid",
18427        "bytes" => "bytea",
18428        "json" => "jsonb",
18429        "decimal" => "numeric",
18430        // bigint and decimal are both PostgreSQL `numeric` — the distinction
18431        // is a Pylon-level scale/precision convention, not a separate PG type.
18432        "bigint" => "numeric",
18433        "datetime" => "timestamptz",
18434        "date" => "date",
18435        "time" => "time",
18436        "duration" => "interval",
18437        other => {
18438            return Err(PyQLError::Type(PyQLTypeError {
18439                message: format!("unknown type '{other}'"),
18440                position: Position { line: 0, col: 0 },
18441            }));
18442        }
18443    }
18444    .to_string())
18445}
18446
18447/// Check whether a compiled expression is compatible with a PylonType parameter.
18448/// Used for overload selection when multiple overloads share the same name.
18449/// Emit `($1 IS NOT NULL)` for a given IR expression.
18450fn ir_is_not_null(expr: IrExpr) -> IrExpr {
18451    IrExpr::FunctionCall(IrFunctionCall {
18452        return_pg_type: None,
18453        schema: None,
18454        name: String::new(),
18455        args: vec![expr],
18456        sql_template: Some("($1 IS NOT NULL)".to_string()),
18457    })
18458}
18459
18460/// "1 argument(s)" / "2 or 3 argument(s)" / "at least 1 argument(s)" — the
18461/// arities an overload set accepts, worded as `pylon.stdlib._arity_error`
18462/// words the same rejection on the Python side.
18463fn describe_arities(overloads: &[&crate::stdlib::FnDescriptor]) -> String {
18464    // Positions only: a named-only parameter is not one of the arguments the
18465    // count is about, so counting it reported `range(…)` as taking four.
18466    let mut arities: Vec<usize> = overloads
18467        .iter()
18468        .map(|d| d.params.iter().filter(|p| p.named_only.is_none()).count())
18469        .collect();
18470    arities.sort_unstable();
18471    arities.dedup();
18472    if overloads.iter().any(|d| d.is_variadic()) {
18473        return format!("at least {} argument(s)", arities[0].saturating_sub(1));
18474    }
18475    let list = arities.iter().map(usize::to_string).collect::<Vec<_>>().join(" or ");
18476    format!("{list} argument(s)")
18477}
18478
18479/// The argument types as far as they could be inferred, for an error that
18480/// has to show the caller what it actually passed.
18481fn describe_args(args: &[IrExpr]) -> String {
18482    args.iter()
18483        .map(|a| infer_ir_type(a).map_or("?", literal_sentinel_to_pg))
18484        .collect::<Vec<_>>()
18485        .join(", ")
18486}
18487
18488/// The parameter lists of the overloads that did match on arity, in PyQL
18489/// spelling — what the caller should have passed.
18490fn describe_signatures(overloads: &[&crate::stdlib::FnDescriptor]) -> String {
18491    overloads
18492        .iter()
18493        .map(|d| {
18494            let params = d
18495                .params
18496                .iter()
18497                .map(|p| match p.named_only {
18498                    // Spelled the way the call has to pass it, so a rejected
18499                    // call isn't told to write a named parameter by position.
18500                    Some(_) => format!("{} := {}", p.keyword(), p.ty.pyql_name()),
18501                    None => p.ty.pyql_name(),
18502                })
18503                .collect::<Vec<_>>()
18504                .join(", ");
18505            format!("({params})")
18506        })
18507        .collect::<Vec<_>>()
18508        .join(" or ")
18509}
18510
18511/// The value of a boolean literal argument, for the call shapes that can be
18512/// resolved at compile time rather than left to a runtime `CASE`.
18513fn bool_literal(expr: &IrExpr) -> Option<bool> {
18514    match expr {
18515        IrExpr::Literal(IrLiteral::Bool(b)) => Some(*b),
18516        _ => None,
18517    }
18518}
18519
18520/// Gather the arguments a variadic parameter absorbed into one array, for the
18521/// overloads that declare named-only parameters after it.
18522///
18523/// PostgreSQL requires `VARIADIC` to be the last parameter, so an overload
18524/// with anything after it declares a plain array instead (see
18525/// `ddl::pg_params`) and is handed the elements already gathered. An overload
18526/// whose variadic really is last keeps passing them one by one, which is what
18527/// `VARIADIC path text[]` expects.
18528fn pack_variadic_args(d: &crate::stdlib::FnDescriptor, args: Vec<IrExpr>) -> Vec<IrExpr> {
18529    let named = d.named_count();
18530    let Some(variadic_at) = d.variadic_index().filter(|v| v + 1 < d.params.len()) else {
18531        return args;
18532    };
18533    let Some(absorbed) = args.len().checked_sub(variadic_at + named) else {
18534        return args;
18535    };
18536    let mut packed: Vec<IrExpr> = Vec::with_capacity(variadic_at + 1 + named);
18537    let mut rest = args.into_iter();
18538    packed.extend(rest.by_ref().take(variadic_at));
18539    packed.push(IrExpr::Array(rest.by_ref().take(absorbed).collect()));
18540    packed.extend(rest);
18541    packed
18542}
18543
18544/// The parameter each of `argc` arguments is checked against.
18545///
18546/// One per declared parameter, except that a variadic one stands in for every
18547/// argument past the fixed ones — `json_get(j, 'a', 'b')` checks both path
18548/// elements against the single variadic `str` parameter rather than leaving
18549/// the third argument unchecked, which a plain `params.iter()` zipped against
18550/// the arguments would do.
18551///
18552/// The named-only parameters keep their own positions at the tail:
18553/// `compile_named_call_args` has already appended one argument per named-only
18554/// parameter, in declaration order, so the arguments a variadic absorbs are
18555/// only the ones between the fixed parameters and that tail.
18556fn params_for_args(d: &crate::stdlib::FnDescriptor, argc: usize) -> impl Iterator<Item = &crate::stdlib::Param> {
18557    let named = d.named_count();
18558    let variadic_at = d.is_variadic().then(|| d.variadic_index()).flatten();
18559    let absorbed = variadic_at.map_or(0, |v| argc.saturating_sub(v + named));
18560    (0..argc).filter_map(move |i| match variadic_at {
18561        Some(v) if i >= v && i < v + absorbed => d.params.get(v),
18562        Some(v) if i >= v => d.params.get(i + 1 - absorbed),
18563        _ => d.params.get(i),
18564    })
18565}
18566
18567fn pylon_type_matches(expr: &IrExpr, ty: &crate::stdlib::PylonType) -> bool {
18568    use crate::stdlib::PylonType as PT;
18569    match ty {
18570        // Wildcard params always match.
18571        PT::Any | PT::AnyOrderable | PT::AnyPoint => true,
18572        PT::Array(_) => is_array_expr(expr),
18573        // Matched on the concrete PostgreSQL type name, which for a
18574        // multirange is `int8multirange`/`tstzmultirange`/… — it carries the
18575        // element family as a prefix, so "is a multirange" is a `contains`,
18576        // not a `starts_with`, and "is a plain range" has to exclude it.
18577        PT::Range(_) => matches!(infer_ir_type(expr), Some(t) if t.ends_with("range") && !t.contains("multirange")),
18578        PT::Multirange(_) => matches!(infer_ir_type(expr), Some(t) if t.contains("multirange")),
18579        // Every parameter type that names a concrete PostgreSQL scalar is
18580        // judged by `types_compatible` — the one implicit-cast graph the
18581        // rest of the compiler already resolves operators against — rather
18582        // than by an exact type-name match. Exactness rejected an integer
18583        // literal reaching a `float64` parameter, which is how
18584        // `cal::to_relative_duration(seconds := 3)` and every other
18585        // int-for-float argument reads.
18586        //
18587        // An argument whose type cannot be inferred does *not* match a
18588        // parameter that names one: an overload is chosen only on evidence,
18589        // and `resolve_fn_call` reports the call rather than guessing.
18590        // For a parameter type with no scalar spelling (`optional<…>` and
18591        // friends) there is nothing to judge, so allow.
18592        _ => match (ty.scalar_pg_type(), infer_ir_type(expr)) {
18593            (Some(declared), Some(known)) => types_compatible(known, declared),
18594            (Some(_), None) => false,
18595            (None, _) => true,
18596        },
18597    }
18598}
18599
18600/// True for a call like `array_unpack` that yields a set rather than one value.
18601fn expr_returns_set(expr: &Expr) -> bool {
18602    let Expr::FunctionCall(call) = expr else {
18603        return false;
18604    };
18605    crate::stdlib::lookup(call.module.as_deref().unwrap_or("std"), &call.name)
18606        .iter()
18607        .any(|d| d.returns_set())
18608}
18609
18610fn literal_sentinel_to_pg(t: &str) -> &str {
18611    match t {
18612        "__int_literal" => "int8",
18613        "__float_literal" => "float8",
18614        other => other,
18615    }
18616}
18617
18618fn is_array_expr(expr: &IrExpr) -> bool {
18619    match through_coalesce(expr) {
18620        IrExpr::Array(_) | IrExpr::ArrayFromSelect(_) => true,
18621        // `str_split(…)[-1]` — a stdlib call declared to return an array,
18622        // which `infer_ir_type` cannot spell because it only names scalars.
18623        IrExpr::FunctionCall(f) if f.schema.is_none() => {
18624            let mut overloads = crate::stdlib::registry().iter().filter(|d| d.name == f.name).peekable();
18625            overloads.peek().is_some() && overloads.all(|d| matches!(d.return_type, crate::stdlib::PylonType::Array(_)))
18626        }
18627        // Everything else an array can arrive as — a column, a cast, a
18628        // global, a `with` binding, a concatenation of any of those — is
18629        // known by the type it carries.
18630        other => matches!(infer_ir_type(other), Some(t) if t.ends_with("[]")),
18631    }
18632}
18633
18634/// A lower bound, computable without running the query, on how many bytes a
18635/// `notify()` payload will serialize to.
18636///
18637/// Only the parts that are already known contribute: string literals count
18638/// their own length, a concatenation sums its sides, a free object counts the
18639/// JSON envelope it will always emit (`{}`, the quoted field names, and the
18640/// `:`/`,` separators) plus whatever its field expressions themselves floor
18641/// at. Anything runtime-valued — a column, a parameter, a function call —
18642/// contributes 0, so this never over-estimates and never rejects a payload
18643/// that could actually have fit.
18644///
18645/// Exists because Postgres's 8000-byte NOTIFY cap is enforced at *runtime*,
18646/// and blowing it aborts the transaction that sent the notification — which,
18647/// for the composed `with update ... select (notify(...), updated)` shape, is
18648/// the write itself.
18649fn static_min_payload_bytes(expr: &ast::Expr) -> usize {
18650    use crate::parse::ast::{BinOpKind, Expr, Literal};
18651    match expr {
18652        Expr::Literal(Literal::Str(s)) => s.len(),
18653        Expr::BinOp(op) if matches!(op.op, BinOpKind::Concat) => {
18654            static_min_payload_bytes(&op.left) + static_min_payload_bytes(&op.right)
18655        }
18656        Expr::Shape(sh) if sh.expr.is_none() => {
18657            // `{"a":,"b":}` — braces, one quoted name and colon per field,
18658            // and a comma between them. Every one of those bytes is emitted
18659            // regardless of what the values turn out to be.
18660            let mut total = 2;
18661            for (i, el) in sh.elements.iter().enumerate() {
18662                if i > 0 {
18663                    total += 1;
18664                }
18665                let name_len = match el.path.steps.last() {
18666                    Some(crate::parse::ast::PathStep::Name(n)) => n.len(),
18667                    _ => 0,
18668                };
18669                total += name_len + 3;
18670                if let Some(value) = &el.compexpr {
18671                    total += static_min_payload_bytes(value);
18672                }
18673            }
18674            total
18675        }
18676        _ => 0,
18677    }
18678}
18679
18680fn expr_to_std_type(expr: &ast::Expr) -> &'static str {
18681    match expr {
18682        ast::Expr::Literal(ast::Literal::Str(_)) => "std::str",
18683        ast::Expr::Literal(ast::Literal::Int(_)) => "std::int64",
18684        ast::Expr::Literal(ast::Literal::Float(_)) => "std::float64",
18685        ast::Expr::Literal(ast::Literal::Bool(_)) => "std::bool",
18686        _ => "anytype",
18687    }
18688}
18689
18690fn named_tuple_type_str(fields: &[(String, ast::Expr)]) -> String {
18691    let inner = fields
18692        .iter()
18693        .map(|(k, v)| format!("{}: {}", k, expr_to_std_type(v)))
18694        .collect::<Vec<_>>()
18695        .join(", ");
18696    format!("tuple<{}>", inner)
18697}
18698
18699fn positional_tuple_type_str(elems: &[ast::Expr]) -> String {
18700    let inner = elems.iter().map(expr_to_std_type).collect::<Vec<_>>().join(", ");
18701    format!("tuple<{}>", inner)
18702}
18703
18704pub(crate) fn infer_ir_type(expr: &IrExpr) -> Option<&str> {
18705    match expr {
18706        IrExpr::ColumnRef { pg_type, .. } => Some(pg_type.as_str()),
18707        IrExpr::TypeCast(tc) => Some(tc.pg_type.as_str()),
18708        IrExpr::FnParam { pg_type, .. } => Some(pg_type.as_str()),
18709        IrExpr::Literal(lit) => Some(match lit {
18710            IrLiteral::Str(_) => "text",
18711            IrLiteral::Int(_) => "__int_literal",
18712            IrLiteral::Float(_) => "__float_literal",
18713            IrLiteral::Bool(_) => "boolean",
18714        }),
18715        IrExpr::EnumLiteral { pg_type, .. } => Some(pg_type.as_str()),
18716        IrExpr::NamedTuple { .. } => Some("jsonb"),
18717        IrExpr::JsonbField { .. } | IrExpr::JsonbIndex { .. } => Some("jsonb"),
18718        IrExpr::GlobalParam { pg_type, .. } => Some(pg_type.as_str()),
18719        // A `with`-bound scalar is typed by what it binds, so a call over one
18720        // resolves to the same overload the bare value would.
18721        IrExpr::CteRef { pg_type, .. } => pg_type.as_deref(),
18722        // A correlated path subquery is typed by whatever it projects — the
18723        // scalar column at the end of the path. An object-valued one yields
18724        // an id, which no operator should silently compare against.
18725        IrExpr::PathSubquery(ps) => match &ps.result {
18726            IrPathResult::Scalar(e, _) => infer_ir_type(e),
18727            IrPathResult::Object { .. } => None,
18728        },
18729        // `a ++ b` and `distinct a` both yield whatever they were given, so
18730        // an array stays recognisable as one through either.
18731        IrExpr::BinOp(b) if b.op == crate::parse::ast::BinOpKind::Concat => {
18732            infer_ir_type(&b.left).or_else(|| infer_ir_type(&b.right))
18733        }
18734        IrExpr::UnaryOp(u) if u.op == crate::parse::ast::UnaryOpKind::Distinct => infer_ir_type(&u.operand),
18735        // `-x` is a number of x's own type; `not x` and `exists x` are always
18736        // boolean.
18737        IrExpr::UnaryOp(u) if u.op == crate::parse::ast::UnaryOpKind::Minus => infer_ir_type(&u.operand),
18738        IrExpr::UnaryOp(u)
18739            if matches!(
18740                u.op,
18741                crate::parse::ast::UnaryOpKind::Not | crate::parse::ast::UnaryOpKind::Exists
18742            ) =>
18743        {
18744            Some("boolean")
18745        }
18746        // `a ?? b` and `a if c else b` yield a value of their branches' own
18747        // type, so a call over either resolves the overload the bare value
18748        // would. Whichever side carries a type decides: the other is routinely
18749        // the untypeable one — an empty set, or a path known only to be
18750        // optional.
18751        IrExpr::BinOp(b) if b.op == crate::parse::ast::BinOpKind::Coalesce => {
18752            infer_ir_type(&b.left).or_else(|| infer_ir_type(&b.right))
18753        }
18754        IrExpr::IfElse(ie) => infer_ir_type(&ie.if_).or_else(|| infer_ir_type(&ie.else_)),
18755        // A slice is the same type as the thing sliced — `substr` of text is
18756        // text, of bytea is bytea, and an array slice is still that array.
18757        // Without this a `to_bytes(x)[12:16]` reaching a `bytes` parameter
18758        // reads as untyped, which is the difference between resolving the
18759        // bytes overload and resolving nothing at all.
18760        IrExpr::Slice { expr, .. } => infer_ir_type(expr),
18761        // `xs[0]` is one element of what `xs` holds — the array type with its
18762        // `[]` taken off. A string or bytes subscript stays the type it
18763        // indexes, the way the `substr` it emits does.
18764        IrExpr::CteFieldRef { pg_type, .. } | IrExpr::ForVar { pg_type, .. } => pg_type.as_deref(),
18765        IrExpr::Subscript { expr, is_array, .. } => {
18766            // `str_split(s, '::')[0]`: the array a stdlib call returns is not a
18767            // type `infer_ir_type` can name (it only names scalars), so the
18768            // element type comes from the overload's own declared return type.
18769            let Some(base) = infer_ir_type(expr) else {
18770                return stdlib_array_element_type(expr);
18771            };
18772            if *is_array { base.strip_suffix("[]") } else { Some(base) }
18773        }
18774        IrExpr::BinOp(b) => arithmetic_result_type(&b.op, infer_ir_type(&b.left)?, infer_ir_type(&b.right)?),
18775        // The `coalesce` the compiler itself builds — around an aggregate that
18776        // ran over nothing, or a `??` whose sides name rows — carries no
18777        // recorded return type, so it is typed from its arguments like the
18778        // operator it stands in for.
18779        IrExpr::FunctionCall(f) if f.schema.is_none() && f.name == "coalesce" => f.args.iter().find_map(infer_ir_type),
18780        IrExpr::FunctionCall(f) if f.schema.is_none() && matches!(f.name.as_str(), "max" | "min" | "sum") => {
18781            aggregate_result_type(&f.name, infer_ir_type(f.args.first()?)?)
18782        }
18783        IrExpr::AggOverSet {
18784            fn_name, schema: None, ..
18785        } if fn_name == "count" => Some("int8"),
18786        IrExpr::AggOverSet {
18787            fn_name,
18788            schema: None,
18789            elems,
18790        } => aggregate_result_type(fn_name, infer_ir_type(elems.first()?)?),
18791        // Any call whose resolution recorded a scalar return type — a stdlib
18792        // overload (`enc::base64_decode(…)` is bytea) or a user-defined
18793        // function, which is how a computed pointer's declared type gets
18794        // checked against the function it calls at all.
18795        IrExpr::FunctionCall(f) => f.return_pg_type.as_deref(),
18796        _ => None,
18797    }
18798}
18799
18800/// The element type of an array a stdlib call returns, for an expression whose
18801/// own type `infer_ir_type` cannot name. `None` unless every overload of the
18802/// name returns an array of the same scalar — the call would be ambiguous
18803/// otherwise, and guessing is what overload resolution exists to avoid.
18804fn stdlib_array_element_type(expr: &IrExpr) -> Option<&'static str> {
18805    let IrExpr::FunctionCall(f) = expr else {
18806        return None;
18807    };
18808    if f.schema.is_some() {
18809        return None;
18810    }
18811    let mut element: Option<&'static str> = None;
18812    for descriptor in crate::stdlib::registry().iter().filter(|d| d.name == f.name) {
18813        let crate::stdlib::PylonType::Array(inner) = &descriptor.return_type else {
18814            return None;
18815        };
18816        let scalar = inner.scalar_pg_type()?;
18817        if element.is_some_and(|seen| seen != scalar) {
18818            return None;
18819        }
18820        element = Some(scalar);
18821    }
18822    element
18823}
18824
18825/// What `+` and `-` yield over instants and durations — a datetime minus a
18826/// datetime is a duration, a datetime shifted by one is a datetime.
18827/// `duration_to_seconds(datetime_of_transaction() - .started_at)` is the shape
18828/// this exists for, and it resolves no overload while the subtraction reads as
18829/// untyped.
18830fn temporal_result_type(op: &ast::BinOpKind, left: &str, right: &str) -> Option<&'static str> {
18831    use ast::BinOpKind::*;
18832    let instant = |t: &str| matches!(t, "timestamptz" | "timestamp" | "date" | "time");
18833    match (op, left, right) {
18834        (Sub, l, r) if instant(l) && instant(r) => Some("interval"),
18835        (Sub | Add, l, "interval") if instant(l) => Some(match l {
18836            "timestamptz" => "timestamptz",
18837            "timestamp" => "timestamp",
18838            "date" => "date",
18839            _ => "time",
18840        }),
18841        (Add, "interval", r) if instant(r) => Some(match r {
18842            "timestamptz" => "timestamptz",
18843            "timestamp" => "timestamp",
18844            "date" => "date",
18845            _ => "time",
18846        }),
18847        (Add | Sub, "interval", "interval") => Some("interval"),
18848        _ => None,
18849    }
18850}
18851
18852/// The type an arithmetic operator's result carries, from its operands'.
18853/// `None` for anything but a pair of numbers.
18854fn arithmetic_result_type(op: &ast::BinOpKind, left: &str, right: &str) -> Option<&'static str> {
18855    use ast::BinOpKind::*;
18856    if !matches!(op, Add | Sub | Mul | Div | FloorDiv | Mod | Pow) {
18857        return None;
18858    }
18859    let (left, right) = (literal_sentinel_to_pg(left), literal_sentinel_to_pg(right));
18860    if let Some(temporal) = temporal_result_type(op, left, right) {
18861        return Some(temporal);
18862    }
18863    let int = |t: &str| INT_TYPES.contains(&t);
18864    let float = |t: &str| FLOAT_TYPES.contains(&t);
18865    let numeric = |t: &str| NUMERIC_TYPES.contains(&t);
18866    if int(left) && int(right) {
18867        return Some(if matches!(op, Div | Pow) {
18868            "float8"
18869        } else if left == "int8" || right == "int8" {
18870            "int8"
18871        } else if left == "int4" || right == "int4" {
18872            "int4"
18873        } else {
18874            "int2"
18875        });
18876    }
18877    if (float(left) || int(left)) && (float(right) || int(right)) {
18878        return Some(if left == "float4" && right == "float4" {
18879            "float4"
18880        } else {
18881            "float8"
18882        });
18883    }
18884    if (numeric(left) || int(left)) && (numeric(right) || int(right)) {
18885        return Some("numeric");
18886    }
18887    None
18888}
18889
18890/// What `max`, `min` and `sum` over values of `element` yield.
18891fn aggregate_result_type<'a>(name: &str, element: &'a str) -> Option<&'a str> {
18892    let element = literal_sentinel_to_pg(element);
18893    match name {
18894        "max" | "min" => Some(element),
18895        "sum" if INT_TYPES.contains(&element) => Some("int8"),
18896        "sum" if FLOAT_TYPES.contains(&element) || NUMERIC_TYPES.contains(&element) => Some(element),
18897        _ => None,
18898    }
18899}
18900
18901/// The left operand of `left op right`, cast to `float8` when `op` divides
18902/// two integers: PyQL's `/` yields a float64 there, Postgres's truncates.
18903fn true_division_operand(op: &ast::BinOpKind, left: IrExpr, right: &IrExpr) -> IrExpr {
18904    let integer = |expr: &IrExpr| infer_ir_type(expr).is_some_and(|t| INT_TYPES.contains(&literal_sentinel_to_pg(t)));
18905    if *op != ast::BinOpKind::Div || !integer(&left) || !integer(right) {
18906        return left;
18907    }
18908    IrExpr::TypeCast(Box::new(IrTypeCast {
18909        expr: left,
18910        pg_type: "float8".to_string(),
18911        tuple_shape: None,
18912    }))
18913}
18914
18915/// Maps a resolved element `pg_type` (as `infer_ir_type` reports it) to the
18916/// PostgreSQL native range constructor over that type. PG has no float4/
18917/// float8/int2/int4-native range type — only int8range, numrange, tsrange,
18918/// tstzrange, and daterange exist — so untyped int/float literals default
18919/// to the widest native family (int8/numeric) rather than erroring, matching
18920/// how those literals already default elsewhere in Pylon.
18921fn range_ctor_for_pg_type(pg_type: &str) -> Option<&'static str> {
18922    match pg_type {
18923        "int2" | "int4" | "int8" | "__int_literal" => Some("int8range"),
18924        "numeric" | "__float_literal" => Some("numrange"),
18925        "timestamp" => Some("tsrange"),
18926        "timestamptz" => Some("tstzrange"),
18927        "date" => Some("daterange"),
18928        _ => None,
18929    }
18930}
18931
18932/// The multirange counterpart of a range constructor name resolved by
18933/// `range_ctor_for_pg_type`.
18934fn multirange_ctor_for_range_ctor(range_ctor: &str) -> Option<&'static str> {
18935    match range_ctor {
18936        "int8range" => Some("int8multirange"),
18937        "numrange" => Some("nummultirange"),
18938        "tsrange" => Some("tsmultirange"),
18939        "tstzrange" => Some("tstzmultirange"),
18940        "daterange" => Some("datemultirange"),
18941        _ => None,
18942    }
18943}
18944
18945const INT_TYPES: &[&str] = &["int2", "int4", "int8", "__int_literal"];
18946const FLOAT_TYPES: &[&str] = &["float4", "float8", "__float_literal"];
18947/// Postgres `numeric` backs both Pylon's `bigint` and `decimal` (see
18948/// `pg_type_for_scalar_name`) — Pylon doesn't distinguish them at the
18949/// pg_type level, so this bucket covers both.
18950const NUMERIC_TYPES: &[&str] = &["numeric"];
18951
18952/// Pylon's implicit-cast graph for numeric operands:
18953/// `int16 → int32 → int64 → float32 → float64` on one branch and
18954/// `int64 → bigint → decimal` on another — every int width casts to every
18955/// float width and to numeric/decimal, but float and numeric/decimal don't
18956/// cast to each other (they're separate branches past `int64`). Postgres's
18957/// own operator resolution handles the actual mixed-type arithmetic once
18958/// the compile-time gate lets it through (e.g. `int2 + float4` is a native
18959/// Postgres operator) — this only needs to match which operand-type
18960/// combinations are meant to be allowed.
18961pub(crate) fn types_compatible(a: &str, b: &str) -> bool {
18962    if a == b {
18963        return true;
18964    }
18965    let a_int = INT_TYPES.contains(&a);
18966    let b_int = INT_TYPES.contains(&b);
18967    if a_int && b_int {
18968        return true;
18969    }
18970    let a_float = FLOAT_TYPES.contains(&a);
18971    let b_float = FLOAT_TYPES.contains(&b);
18972    if a_float && b_float {
18973        return true;
18974    }
18975    let a_numeric = NUMERIC_TYPES.contains(&a);
18976    let b_numeric = NUMERIC_TYPES.contains(&b);
18977    if a_numeric && b_numeric {
18978        return true;
18979    }
18980    (a_int && b_float) || (a_float && b_int) || (a_int && b_numeric) || (a_numeric && b_int)
18981}
18982
18983/// Datetime/duration `+`/`-` pairs PostgreSQL supports natively (e.g.
18984/// `timestamptz + interval`) that `types_compatible`'s bucket-matching
18985/// (same type, or both-int, or both-float) doesn't cover. Deliberately a
18986/// separate, narrower check from `types_compatible` (also used for UNION-branch
18987/// compatibility, where "a datetime and a duration are interchangeable"
18988/// would be nonsensical) rather than folded into it, so this can't leak
18989/// into a context where "arithmetic-compatible" isn't the same relation as
18990/// "interchangeable." Postgres's own operator resolution is the final
18991/// authority on any (op, operand-order) combination that doesn't actually
18992/// exist (e.g. `interval - timestamptz`) — this only needs to widen the
18993/// compile-time gate far enough to let the legitimate combinations through.
18994fn datetime_arithmetic_compatible(op: &ast::BinOpKind, a: &str, b: &str) -> bool {
18995    if !matches!(op, ast::BinOpKind::Add | ast::BinOpKind::Sub) {
18996        return false;
18997    }
18998    matches!(
18999        (a, b),
19000        ("timestamptz", "interval")
19001            | ("interval", "timestamptz")
19002            | ("timestamp", "interval")
19003            | ("interval", "timestamp")
19004            | ("date", "interval")
19005            | ("interval", "date")
19006            | ("time", "interval")
19007            | ("interval", "time")
19008    )
19009}
19010
19011/// Collect `SearchEnqueueInfo` for every OpenSearch- or Meilisearch-backed
19012/// search index on a type — `Postgres`-backed indexes are excluded since
19013/// they're maintained synchronously by a trigger-updated tsvector column,
19014/// not an async outbox worker.
19015fn collect_search_enqueue(td: &TypeDescriptor, type_name: &str, operation: &'static str) -> Vec<SearchEnqueueInfo> {
19016    td.search_indexes
19017        .iter()
19018        .filter(|si| si.backend == SearchBackend::OpenSearch || si.backend == SearchBackend::Meilisearch)
19019        .map(|si| SearchEnqueueInfo {
19020            type_name: type_name.to_string(),
19021            index_name: si.index_name.clone(),
19022            operation,
19023            backend: si.backend.clone(),
19024        })
19025        .collect()
19026}
19027
19028/// Reverse of `type_expr_to_pg`'s plain-scalar branch — renders a base
19029/// Postgres type back to its canonical PyQL display name. `pub` (not just
19030/// crate-local): reused by `pylon-server`'s `/api/schema` port
19031/// (`_scalar_type_name`'s equivalent) so that display-name logic isn't
19032/// duplicated across crates.
19033///
19034/// `"interval"`/`"date"`/`"time"`/`"timestamp"` are inherently ambiguous
19035/// from the bare pg_type alone: `interval` backs both `std::duration` and
19036/// `cal::relative_duration` (defaults to the former — the more common
19037/// case), while `date`/`time`/`timestamp` (no tz) are unambiguous (only
19038/// `cal::local_date`/`cal::local_time`/`cal::local_datetime` use them, as
19039/// opposed to `timestamptz` for `std::datetime`).
19040pub fn pg_type_to_pyql(pg: &str) -> &str {
19041    match pg {
19042        "text" | "varchar" => "std::str",
19043        "uuid" => "std::uuid",
19044        "int2" => "std::int16",
19045        "int4" => "std::int32",
19046        "int8" => "std::int64",
19047        "float4" => "std::float32",
19048        "float8" => "std::float64",
19049        "boolean" => "std::bool",
19050        "numeric" => "std::decimal",
19051        "timestamptz" => "std::datetime",
19052        "timestamp" => "cal::local_datetime",
19053        "date" => "cal::local_date",
19054        "time" => "cal::local_time",
19055        "interval" => "std::duration",
19056        "bytea" => "std::bytes",
19057        "jsonb" => "std::json",
19058        "__int_literal" => "std::int64",
19059        "__float_literal" => "std::float64",
19060        other => other,
19061    }
19062}