Skip to main content

pylon_core/sql/
mod.rs

1//
2// This source file is part of the Pylon open source project.
3//
4// Copyright (c) 2026 Jaldis B.V.
5//
6// Licensed under the MIT OR Apache-2.0 license (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10//     https://opensource.org/licenses/MIT
11//     https://www.apache.org/licenses/LICENSE-2.0
12//
13// Unless required by applicable law or agreed to in writing, software
14// distributed under the License is distributed on an "AS IS" BASIS,
15// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16// See the License for the specific language governing permissions and
17// limitations under the License.
18//
19
20use crate::ir::{
21    IrArraySource, IrCteDef, IrDelete, IrExpr, IrFor, IrForIterator, IrFreeExpr, IrFtsSearch, IrFunctionSelect,
22    IrGlobalCte, IrGroup, IrGroupOutput, IrInsert, IrLiteral, IrLockClause, IrLockStrength, IrLockWait,
23    IrMultiLinkJoin, IrMultiLinkMutation, IrMultiLinkPointer, IrMultiLinkValueSource, IrMultiLinkValues, IrNulls,
24    IrOutput, IrPathJoin, IrPathResult, IrPathSelect, IrPolyFanout, IrPolyImplementor, IrRewrite, IrRowSource,
25    IrScalarPointer, IrScalarSetPointer, IrSelect, IrShapePointer, IrSingleLinkCorrelation, IrSingleLinkPointer,
26    IrSort, IrSortDir, IrSource, IrStmt, IrUpdate, IrVectorSearch, SearchEnqueueInfo, VectorEnqueueInfo,
27};
28use crate::parse::ast::{BinOpKind, UnaryOpKind};
29use crate::query::{Cardinality, InferencePlan, ShapeDescriptor, ShapeNode};
30use std::collections::HashMap;
31
32pub struct SqlOutput {
33    pub sql: String,
34    pub shape: ShapeDescriptor,
35    pub inference_plan: Option<InferencePlan>,
36}
37
38/// Build an UPDATE's `SET` clause fragments (`"col" = expr`, no trailing
39/// comma) from its explicit assignments plus its rewrites, with a rewrite on
40/// a given column overriding — not appending alongside — any explicit
41/// assignment to that same column. Mirrors the INSERT-side `rewrite_cols`
42/// dedup (`ins.assignments` filtered against `ins.rewrites`'s own columns,
43/// present at every INSERT emission site) — every UPDATE emission site
44/// independently lacked the equivalent filtering, so an UPDATE that both
45/// explicitly assigned and had a rewrite declared on the same property
46/// produced two `SET` entries for one column, which Postgres rejects with
47/// "multiple assignments to same column" (confirmed live).
48fn update_set_fragments(assignments: &[(String, IrExpr)], rewrites: &[IrRewrite], indent: &str) -> Vec<String> {
49    let rewrite_cols: std::collections::HashSet<&str> = rewrites.iter().map(|r| r.column.as_str()).collect();
50    assignments
51        .iter()
52        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
53        .map(|(col, expr)| format!("{indent}{} = {}", qi(col), emit_expr(expr)))
54        .chain(
55            rewrites
56                .iter()
57                .map(|rw| format!("{indent}{} = {}", qi(&rw.column), emit_expr(&rw.expr))),
58        )
59        .collect()
60}
61
62/// Emit the SQL body for a computed global CTE — a plain scalar query with a `value` column.
63fn emit_for_global_cte(stmt: &IrStmt) -> String {
64    match stmt {
65        IrStmt::Select(sel) => match sel.rows.as_slice() {
66            [IrRowSource::Bound { source, .. }] => {
67                let alias = &source.alias;
68                let mut sql = format!(
69                    "SELECT {}.\"id\" AS \"value\"\nFROM {} AS {}",
70                    qi(alias),
71                    source_ref(source),
72                    qi(alias)
73                );
74                append_filter(&mut sql, &sel.filter);
75                sql
76            }
77            rows => match rows.first() {
78                Some(IrRowSource::Free(IrFreeExpr::Scalar(e))) => format!("SELECT {} AS \"value\"", emit_expr(e)),
79                _ => "SELECT NULL AS \"value\"".to_string(),
80            },
81        },
82        IrStmt::PathSelect(sel) => {
83            let from_sql = emit_path_joins(&sel.root, &sel.joins);
84            let scalar_expr = match &sel.result {
85                IrPathResult::Scalar(e, _) => emit_expr(e),
86                IrPathResult::Object { alias, .. } => format!("{}.\"id\"", qi(alias)),
87            };
88            let mut sql = format!("SELECT {} AS \"value\"\nFROM {}", scalar_expr, from_sql);
89            append_filter(&mut sql, &sel.filter);
90            append_order_by(&mut sql, &sel.order_by);
91            append_offset_limit(&mut sql, &sel.offset, &sel.limit);
92            sql
93        }
94        _ => "SELECT NULL AS \"value\"".to_string(),
95    }
96}
97
98fn emit_global_cte_parts(global_ctes: &[IrGlobalCte]) -> Vec<String> {
99    global_ctes
100        .iter()
101        .map(|g| match g {
102            IrGlobalCte::Session(s) => format!(
103                "\"{}\" AS (SELECT ${}::{} AS \"value\")",
104                s.cte_name,
105                s.param_index + 1,
106                s.pg_type
107            ),
108            IrGlobalCte::Computed(c) => {
109                let body = emit_for_global_cte(&c.stmt);
110                format!("\"{}\" AS (\n{}\n)", c.cte_name, body)
111            }
112        })
113        .collect()
114}
115
116pub fn emit(ir: &IrOutput) -> SqlOutput {
117    let out = with_subtype_fanouts(&ir.subtype_fanouts, || {
118        with_correlated_ctes(&ir.ctes, || emit_output(ir))
119    });
120    #[cfg(debug_assertions)]
121    if let Some(problem) = forward_cte_reference(&out.sql) {
122        panic!("{problem}\n{}", out.sql);
123    }
124    out
125}
126
127/// A WITH name is in scope only for the bindings that follow it, so a CTE
128/// that reads one defined after it fails at execution time — and does so
129/// complaining about a FROM clause, far from what actually put the two in
130/// the wrong order. Checked here rather than left to PostgreSQL because the
131/// parts are assembled in a dozen places, none of which owns the ordering.
132#[cfg(debug_assertions)]
133fn forward_cte_reference(sql: &str) -> Option<String> {
134    let definitions: Vec<(String, usize)> = sql
135        .match_indices("\" AS (")
136        .filter_map(|(end, _)| {
137            let head = &sql[..end];
138            let start = head.rfind('"')?;
139            Some((head[start + 1..].to_string(), start))
140        })
141        .collect();
142    for (name, defined_at) in &definitions {
143        // A bare quoted name is a CTE; a real relation is always schema
144        // qualified (`"marketplace"."Order"`), so these never collide.
145        let quoted = qi(name);
146        for prefix in ["FROM ", "JOIN ", "CROSS JOIN "] {
147            let needle = format!("{prefix}{quoted}");
148            if let Some(used_at) = sql.find(&needle)
149                && used_at < *defined_at
150            {
151                return Some(format!(
152                    "CTE {quoted} is read at byte {used_at} but not defined until {defined_at}"
153                ));
154            }
155        }
156    }
157    None
158}
159
160fn emit_output(ir: &IrOutput) -> SqlOutput {
161    let mut out = match &ir.stmt {
162        IrStmt::Update(upd) => emit_update_stmt(upd, &ir.ctes),
163        IrStmt::For(f) => emit_for_stmt(f, &ir.ctes),
164        stmt => {
165            let mut o = match stmt {
166                IrStmt::Select(sel) => emit_select_stmt(sel, &ir.ctes),
167                IrStmt::PathSelect(sel) => emit_path_select(sel),
168                IrStmt::Insert(ins) => emit_insert_stmt(ins),
169                IrStmt::Delete(del) => emit_delete_stmt(del),
170                IrStmt::Group(grp) => emit_group(grp),
171                IrStmt::FunctionSelect(sel) => emit_function_select(sel),
172                IrStmt::VectorSearch(vs) => emit_vector_search(vs),
173                IrStmt::FtsSearch(fs) => emit_fts_search(fs),
174                IrStmt::ScalarUnion(branches) => SqlOutput {
175                    sql: emit_scalar_union(branches),
176                    shape: ShapeDescriptor {
177                        root: ShapeNode::Scalar {
178                            name: String::new(),
179                            position: 0,
180                        },
181                    },
182                    inference_plan: None,
183                },
184                IrStmt::Update(_) | IrStmt::For(_) => unreachable!(),
185            };
186            if !ir.ctes.is_empty() {
187                // The statement emitter may already have opened a `WITH` of
188                // its own (multi-link/junction assignment, FOR-loop insert);
189                // a second one is invalid SQL, so merge into it.
190                o.sql = merge_into_existing_with(&o.sql, &emit_user_cte_parts(&ir.ctes))
191                    .unwrap_or_else(|| format!("{}{}", emit_cte_prefix(&ir.ctes), o.sql));
192            }
193            o
194        }
195    };
196
197    if !ir.global_ctes.is_empty() {
198        let global_parts = emit_global_cte_parts(&ir.global_ctes);
199        out.sql = merge_into_existing_with(&out.sql, &global_parts)
200            .unwrap_or_else(|| format!("WITH {}\n{}", global_parts.join(",\n     "), out.sql));
201    }
202
203    out
204}
205
206/// Merge CTE definitions into a statement that already opens with a top-level
207/// `WITH`, or `None` when it doesn't (the caller then builds a fresh prefix in
208/// its own format).
209///
210/// SQL allows exactly one `WITH` per statement, but CTE parts arrive from
211/// several independent places: a user-written `with` block (`ir.ctes`),
212/// globals (`ir.global_ctes`), and the CTEs some statement emitters build for
213/// themselves — a multi-link or junction-backed link assignment needs
214/// `_w__ids`/`_w__ml_add_*`, and the `FOR`-loop insert path needs its VALUES
215/// alias. Whichever runs second has to merge; blindly prefixing produces
216/// `WITH ... WITH ...`, which PostgreSQL rejects with `syntax error at or
217/// near "WITH"` — and only at execution time, since the PyQL compiles fine.
218///
219/// The merged-in parts go *first*: a non-recursive CTE may only reference
220/// siblings declared before it, and the parts already present are the ones
221/// that reference what's being added (a statement's own CTEs consume the user
222/// binding they were built from).
223///
224/// Two prefix spellings occur — `emit_cte_prefix`'s `"WITH\n"` and the inline
225/// `"WITH "` built by individual emitters. Both are 5 bytes before the parts
226/// begin, so one slice covers either.
227/// The array operand for `= ANY(...)` / `<> ALL(...)`.
228///
229/// `x in std::array_unpack(arr)` is the documented way to test membership of
230/// an array parameter, but `array_unpack` emits `unnest()`, and nesting that
231/// inside `ANY` gives `ANY(unnest(arr))` — a set-returning function in
232/// `WHERE`, which PostgreSQL rejects (`set-returning functions are not
233/// allowed in WHERE`). `ANY` already takes an array, so the `unnest` is both
234/// redundant and invalid: pass its argument straight through.
235///
236/// Falls back to the pre-rendered form for anything else, including
237/// `unnest()` reached by some route other than a direct `array_unpack` call.
238fn unwrap_unnest_for_any(right: &IrExpr, rendered: &str) -> String {
239    if let Some(array) = unnest_argument(right) {
240        return array;
241    }
242    // `<Enum>array_unpack($1)` — the cast is written over the set, so over the
243    // array the same cast is to that type's array.
244    if let IrExpr::TypeCast(cast) = right
245        && let Some(array) = unnest_argument(&cast.expr)
246    {
247        return format!("({})::{}[]", array, cast.pg_type);
248    }
249    rendered.to_string()
250}
251
252/// The array a direct `array_unpack` call unpacks, already rendered.
253fn unnest_argument(expr: &IrExpr) -> Option<String> {
254    let IrExpr::FunctionCall(call) = expr else {
255        return None;
256    };
257    (call.schema.is_none() && call.name == "unnest" && call.sql_template.is_none() && call.args.len() == 1)
258        .then(|| emit_expr(&call.args[0]))
259}
260
261fn merge_into_existing_with(sql: &str, parts: &[String]) -> Option<String> {
262    if parts.is_empty() || !(sql.starts_with("WITH\n") || sql.starts_with("WITH ")) {
263        return None;
264    }
265    Some(format!("WITH {},\n     {}", parts.join(",\n     "), &sql[5..]))
266}
267
268// ── Identifier / literal helpers ────────────────────────────────────────────
269
270fn qi(s: &str) -> String {
271    format!("\"{}\"", s.replace('"', "\"\""))
272}
273
274fn pg_schema(module: &str) -> String {
275    if module == "default" {
276        "\"public\"".into()
277    } else {
278        qi(module)
279    }
280}
281
282pub fn pg_schema_str(module: &str) -> String {
283    pg_schema(module)
284}
285
286fn qn(module: &str, name: &str) -> String {
287    format!("{}.{}", pg_schema(module), qi(name))
288}
289
290fn sql_str(s: &str) -> String {
291    format!("'{}'", s.replace('\'', "''"))
292}
293
294/// `'module::Type'::text` — always position 0 in every non-free-type tuple.
295fn type_disc(type_name: &str) -> String {
296    format!("{}::text", sql_str(type_name))
297}
298
299/// Extract the PostgreSQL schema name (module) from `"module::TypeName"`.
300fn module_of(type_name: &str) -> &str {
301    type_name.split("::").next().unwrap_or("public")
302}
303
304fn source_ref(src: &IrSource) -> String {
305    // "@cte:name" sentinel: the source is a WITH-clause CTE, not a real table.
306    if let Some(cte_name) = src.table.strip_prefix("@cte:") {
307        // A binding that reads a loop variable holds one value per iteration,
308        // all of them in the one CTE. A read of it belongs to the iteration
309        // in scope here, which is the key it carries.
310        return match correlated_cte_iterator(cte_name) {
311            Some(iterator) => format!(
312                "(SELECT * FROM {} WHERE {}.{} = {}.\"v\")",
313                qi(cte_name),
314                qi(cte_name),
315                qi(OUTER_KEY),
316                qi(&iterator),
317            ),
318            None => qi(cte_name),
319        };
320    }
321    // "@row:NEW" sentinel: a trigger's own row.
322    if let Some(row) = src.table.strip_prefix("@row:") {
323        return format!("(SELECT ({row}).*)");
324    }
325    // An interface-typed source reads from its implementors, never from the
326    // interface's own view — see `IrSource::poly`.
327    match source_fanout(src) {
328        Some(fanout) => format!("(\n{}\n)", emit_poly_union(&fanout.implementors, &fanout.columns)),
329        None => junction_ref(module_of(&src.type_name), &src.table),
330    }
331}
332
333/// The table a statement writes to: only ever the one named, never the
334/// subtypes a read of it would take in.
335fn target_ref(src: &IrSource) -> String {
336    match src.table.strip_prefix("@cte:") {
337        Some(cte_name) => qi(cte_name),
338        None => qn(module_of(&src.type_name), &src.table),
339    }
340}
341
342thread_local! {
343    /// The statement being emitted's `IrOutput::subtype_fanouts`.
344    static SUBTYPE_FANOUTS: std::cell::RefCell<HashMap<(String, String), IrPolyFanout>> =
345        std::cell::RefCell::new(HashMap::new());
346
347    /// Every CTE of the statement being emitted that holds one value per
348    /// iteration, and the iterator whose key says which.
349    static CORRELATED_CTES: std::cell::RefCell<HashMap<String, String>> =
350        std::cell::RefCell::new(HashMap::new());
351}
352
353/// The iterator a read of `cte_name` has to pin to one iteration of, if that
354/// binding is per-iteration at all.
355fn correlated_cte_iterator(cte_name: &str) -> Option<String> {
356    CORRELATED_CTES.with(|cell| cell.borrow().get(cte_name).cloned())
357}
358
359/// Installs the per-iteration bindings for the emission running inside
360/// `emit`, restoring the previous set after.
361fn with_correlated_ctes<T>(ctes: &[IrCteDef], emit: impl FnOnce() -> T) -> T {
362    fn collect(ctes: &[IrCteDef], into: &mut HashMap<String, String>) {
363        for c in ctes {
364            if let Some(iterator) = &c.correlated_to {
365                into.insert(c.name.clone(), iterator.clone());
366            }
367            match &c.stmt {
368                IrStmt::For(f) => collect(&f.body_ctes, into),
369                IrStmt::Insert(ins) => collect(&ins.nested_ctes, into),
370                IrStmt::Update(upd) => collect(&upd.nested_ctes, into),
371                _ => {}
372            }
373        }
374    }
375    let mut collected = HashMap::new();
376    collect(ctes, &mut collected);
377    let previous = CORRELATED_CTES.with(|cell| cell.replace(collected));
378    let result = emit();
379    CORRELATED_CTES.with(|cell| cell.replace(previous));
380    result
381}
382
383/// Installs `fanouts` for the emission running inside `emit`, restoring the
384/// previous set after, so a nested emission of another output cannot leak.
385fn with_subtype_fanouts<T>(fanouts: &HashMap<(String, String), IrPolyFanout>, emit: impl FnOnce() -> T) -> T {
386    let previous = SUBTYPE_FANOUTS.with(|cell| cell.replace(fanouts.clone()));
387    let result = emit();
388    SUBTYPE_FANOUTS.with(|cell| cell.replace(previous));
389    result
390}
391
392/// The fan-out a read of `src` goes through: its own, or the one a concrete
393/// type with subtypes needs.
394fn source_fanout(src: &IrSource) -> Option<IrPolyFanout> {
395    if let Some(fanout) = &src.poly {
396        return Some(fanout.clone());
397    }
398    if src.table.starts_with('@') {
399        return None;
400    }
401    let key = (module_of(&src.type_name).to_string(), src.table.clone());
402    SUBTYPE_FANOUTS.with(|cell| cell.borrow().get(&key).cloned())
403}
404
405/// A junction table, or the CTE standing in for one. Same `@cte:` sentinel
406/// `source_ref` honours: a multi-link populated by the statement's own nested
407/// DML reads the junction rows back out of the CTE that wrote them, since
408/// Postgres does not show them in the table yet.
409/// The name the emitter gives the CTE wrapping a `SELECT (INSERT/UPDATE …)`'s
410/// own mutation, and the stem of the junction CTEs beside it
411/// (`_dml__ml_add_0`). Public because the compiler has to name those same
412/// CTEs when it points a shape at them — see
413/// `read_nested_links_from_their_ctes`.
414pub const DML_CTE: &str = "_dml";
415
416fn junction_ref(module: &str, junction_table: &str) -> String {
417    if let Some(cte_name) = junction_table.strip_prefix("@cte:") {
418        return qi(cte_name);
419    }
420    let Some((tables, columns)) = crate::ir::parse_inherited_junction(junction_table) else {
421        return qn(module, junction_table);
422    };
423    let columns = columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
424    let branches = tables
425        .iter()
426        .map(|(module, table)| format!("SELECT {columns} FROM {}", qn(module, table)))
427        .collect::<Vec<_>>()
428        .join(" UNION ALL ");
429    format!("({branches})")
430}
431
432/// The discriminator for a path select's object result.
433///
434/// The terminal row source is the one the result came from, so that is the one
435/// whose fan-out decides whether the type is read off the row or fixed.
436fn result_type_disc(path: &IrPathSelect, alias: &str, type_name: &str) -> String {
437    match terminal_source(path, alias) {
438        Some(source) => source_type_disc(source),
439        None => type_disc(type_name),
440    }
441}
442
443fn terminal_source<'a>(path: &'a IrPathSelect, alias: &str) -> Option<&'a IrSource> {
444    if path.root.alias == alias {
445        return Some(&path.root);
446    }
447    path.joins
448        .iter()
449        .map(path_join_target)
450        .find(|target| target.alias == alias)
451}
452
453fn path_join_target(join: &IrPathJoin) -> &IrSource {
454    match join {
455        IrPathJoin::Single { target, .. }
456        | IrPathJoin::Multi { target, .. }
457        | IrPathJoin::BacklinkSingle { target, .. }
458        | IrPathJoin::BacklinkMulti { target, .. }
459        | IrPathJoin::Function { target, .. }
460        | IrPathJoin::Lateral { target, .. } => target,
461    }
462}
463
464/// The type discriminator for a row from `src`: the fanned-out column when the
465/// source reads from implementors, else the one type its table holds.
466fn source_type_disc(src: &IrSource) -> String {
467    match source_fanout(src) {
468        Some(_) => format!("{}.\"__type__\"", qi(&src.alias)),
469        None => type_disc(&src.type_name),
470    }
471}
472
473// ── Polymorphic UNION ALL ───────────────────────────────────────────────────
474
475fn emit_poly_union(implementors: &[IrPolyImplementor], columns: &[String]) -> String {
476    let col_list = columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
477    implementors
478        .iter()
479        .map(|imp| {
480            format!(
481                "    SELECT {}::text AS \"__type__\", {} FROM {}",
482                sql_str(&imp.type_name),
483                col_list,
484                qn(&imp.module, &imp.table),
485            )
486        })
487        .collect::<Vec<_>>()
488        .join("\n    UNION ALL\n")
489}
490
491// ── SELECT statement ────────────────────────────────────────────────────────
492
493fn emit_select_stmt(sel: &IrSelect, ctes: &[IrCteDef]) -> SqlOutput {
494    match sel.rows.as_slice() {
495        [IrRowSource::Bound { source, shape }] => emit_bound_select(sel, source, shape),
496        rows if rows.len() > 1 && rows.iter().all(|r| matches!(r, IrRowSource::Bound { .. })) => {
497            emit_bound_union_select(sel, rows)
498        }
499        rows => emit_free_rows(sel, rows, ctes),
500    }
501}
502
503/// The branches of `select (a union b)` all carry the same object type, so
504/// they share a column set: union them in the FROM clause and project, filter
505/// and order the combined set once, under the shape's single alias.
506/// `common_columns`, when set, are the columns of the ancestor branches of
507/// different types share: each branch projects those and its rows' concrete
508/// type, rather than columns the others do not have.
509fn bound_union_from_clause(rows: &[IrRowSource], common_columns: &[String]) -> String {
510    let columns = common_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
511    rows.iter()
512        .map(|row| match row {
513            IrRowSource::Bound { source, .. } if !common_columns.is_empty() => {
514                let own_type = match source_fanout(source) {
515                    Some(_) => "\"__type__\"".to_string(),
516                    None => format!("{} AS \"__type__\"", type_disc(&source.type_name)),
517                };
518                format!("    SELECT {own_type}, {columns} FROM {}", source_ref(source))
519            }
520            IrRowSource::Bound { source, .. } => format!("    SELECT * FROM {}", source_ref(source)),
521            IrRowSource::Free(_) => unreachable!("caller checked every row is bound"),
522        })
523        .collect::<Vec<_>>()
524        .join("\n    UNION ALL\n")
525}
526
527fn emit_bound_union_select(sel: &IrSelect, rows: &[IrRowSource]) -> SqlOutput {
528    let IrRowSource::Bound { source, shape } = &rows[0] else {
529        unreachable!("caller checked every row is bound")
530    };
531    let alias = &source.alias;
532    let (pointer_exprs, shape_pointers) = build_shape(shape, alias);
533    let row_type = if sel.poly_columns.is_empty() {
534        type_disc(&source.type_name)
535    } else {
536        format!("{}.\"__type__\"", qi(alias))
537    };
538    let mut parts = vec![row_type];
539    parts.extend(pointer_exprs);
540
541    let mut sql = format!(
542        "SELECT {}(\n    {}\n) AS result\nFROM (\n{}\n) AS {}",
543        if sel.distinct { "DISTINCT " } else { "" },
544        parts.join(",\n    "),
545        bound_union_from_clause(rows, &sel.poly_columns),
546        qi(alias),
547    );
548    append_filter(&mut sql, &sel.filter);
549    append_order_by(&mut sql, &sel.order_by);
550    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
551
552    SqlOutput {
553        sql,
554        shape: ShapeDescriptor {
555            root: ShapeNode::Object {
556                name: String::new(),
557                type_name: Some(source.type_name.clone()),
558                position: 0,
559                cardinality: Cardinality::Many,
560                pointers: prepend_type(shape_pointers),
561                has_implicit_id: shape_has_implicit_id(shape),
562            },
563        },
564        inference_plan: None,
565    }
566}
567
568/// Schema-bound SELECT — has a FROM clause, projected column shape, and
569/// (optionally) a DML source / polymorphic fan-out. `source`/`shape` are the
570/// single `IrRowSource::Bound` entry destructured by `emit_select_stmt`;
571/// everything else (filter/order_by/offset/limit/distinct/dml_source/
572/// polymorphic/poly_*) stays on the outer `IrSelect`.
573fn emit_bound_select(sel: &IrSelect, source: &IrSource, shape: &[IrShapePointer]) -> SqlOutput {
574    let alias = &source.alias;
575    let (pointer_exprs, shape_pointers) = build_shape(shape, alias);
576
577    let type_expr = if sel.polymorphic {
578        format!("{}.\"__type__\"", qi(alias))
579    } else {
580        type_disc(&source.type_name)
581    };
582    let mut parts = vec![type_expr];
583    parts.extend(pointer_exprs);
584    let tuple = parts.join(",\n    ");
585
586    let distinct = if sel.distinct { "DISTINCT " } else { "" };
587
588    // SELECT-over-DML: wrap inner statement in a CTE, select from it.
589    let from_clause = if let Some(dml) = &sel.dml_source {
590        let mut cte_parts = match dml.as_ref() {
591            IrStmt::Update(upd) if update_has_any_multilink(upd) => emit_update_multilink_ctes(upd, DML_CTE),
592            IrStmt::Insert(ins) if insert_has_any_multilink(ins) => emit_insert_multilink_ctes(ins, DML_CTE),
593            // Nested DML the row's values read from: hoisted ahead of the row
594            // itself, since a data-modifying WITH only works at the top level.
595            IrStmt::Insert(ins) if !ins.nested_ctes.is_empty() => {
596                let mut parts = emit_user_cte_parts(&ins.nested_ctes);
597                parts.push(format!("\"_dml\" AS (\n{}\n)", emit_dml_as_cte_source(dml)));
598                parts
599            }
600            IrStmt::Update(upd) if !upd.nested_ctes.is_empty() => {
601                let mut parts = emit_user_cte_parts(&upd.nested_ctes);
602                parts.push(format!("\"_dml\" AS (\n{}\n)", emit_dml_as_cte_source(dml)));
603                parts
604            }
605            IrStmt::Update(upd) if !upd.poly_implementors.is_empty() => emit_poly_update_dml_ctes(upd, "_dml"),
606            IrStmt::Delete(del) if !del.poly_implementors.is_empty() => emit_poly_delete_dml_ctes(del, "_dml"),
607            _ => vec![format!("\"_dml\" AS (\n{}\n)", emit_dml_as_cte_source(dml))],
608        };
609        let (enqueue_v, enqueue_s) = match dml.as_ref() {
610            IrStmt::Insert(ins) => (ins.enqueue_vector.as_slice(), ins.enqueue_search.as_slice()),
611            IrStmt::Update(upd) => (upd.enqueue_vector.as_slice(), upd.enqueue_search.as_slice()),
612            _ => (&[][..], &[][..]),
613        };
614        cte_parts.extend(enqueue_ctes(enqueue_v, "_dml"));
615        cte_parts.extend(enqueue_search_ctes(enqueue_s, "_dml", enqueue_v.len()));
616        format!(
617            "WITH\n{}\nSELECT {}(\n    {}\n) AS result\nFROM \"_dml\" AS {}",
618            cte_parts.join(",\n"),
619            distinct,
620            tuple,
621            qi(alias)
622        )
623    } else if sel.polymorphic && !source.table.starts_with("@cte:") {
624        // Polymorphic interface with no CTE indirection: fan out to implementor tables.
625        let union_sql = emit_poly_union(&sel.poly_implementors, &sel.poly_columns);
626        format!(
627            "SELECT {}(\n    {}\n) AS result\nFROM (\n{}\n) AS {}",
628            distinct,
629            tuple,
630            union_sql,
631            qi(alias)
632        )
633    } else {
634        // Concrete table or CTE (pre-filtered): query directly.
635        // For CTE-backed polymorphic sources, __type__ is already present in the CTE result.
636        format!(
637            "SELECT {}(\n    {}\n) AS result\nFROM {} AS {}",
638            distinct,
639            tuple,
640            source_ref(source),
641            qi(alias)
642        )
643    };
644
645    let mut sql = from_clause;
646    append_filter(&mut sql, &sel.filter);
647    append_order_by(&mut sql, &sel.order_by);
648    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
649    append_lock_clause(&mut sql, &sel.lock);
650
651    let root_pointers = prepend_type(shape_pointers);
652    SqlOutput {
653        sql,
654        shape: ShapeDescriptor {
655            root: ShapeNode::Object {
656                name: String::new(),
657                type_name: Some(source.type_name.clone()),
658                position: 0,
659                cardinality: Cardinality::Many,
660                pointers: root_pointers,
661                has_implicit_id: shape_has_implicit_id(shape),
662            },
663        },
664        inference_plan: None,
665    }
666}
667
668/// Emit a DML statement for use as a CTE source, using `RETURNING *` to expose
669/// all columns to the outer SELECT.  The DML's own returning shape is ignored.
670/// `FROM` over the nested-DML CTEs `reading` takes a column of, prefixed by
671/// `lead`; empty when it takes none. One read through a scalar subquery
672/// (`(SELECT "id" FROM cte)`) needs no join, and joining it anyway drops every
673/// row the moment that CTE is empty -- which a guarded branch is.
674fn nested_cte_from(ctes: &[IrCteDef], reading: &str, lead: &str) -> String {
675    let joined: Vec<String> = ctes
676        .iter()
677        .map(|c| qi(&c.name))
678        .filter(|name| reading.contains(&format!("{name}.")))
679        .collect();
680    if joined.is_empty() {
681        return String::new();
682    }
683    format!("{lead}FROM {}", joined.join(", "))
684}
685
686/// Each operand's rows read through the bare `v` every scalar statement
687/// exposes as a CTE source, so operands of different kinds line up.
688fn emit_scalar_union(branches: &[IrStmt]) -> String {
689    let operands: Vec<String> = branches
690        .iter()
691        .enumerate()
692        .map(|(i, branch)| {
693            format!(
694                "SELECT \"v\" FROM (\n{}\n) AS \"_u{i}\"",
695                emit_dml_as_cte_source(branch)
696            )
697        })
698        .collect();
699    format!(
700        "SELECT ROW(v) AS result, v FROM (\n{}\n) AS _scalar",
701        operands.join("\nUNION ALL\n")
702    )
703}
704
705fn emit_dml_as_cte_source(stmt: &IrStmt) -> String {
706    match stmt {
707        IrStmt::ScalarUnion(branches) => format!("    {}", emit_scalar_union(branches)),
708        IrStmt::Insert(ins) => {
709            let rewrite_cols: std::collections::HashSet<&str> =
710                ins.rewrites.iter().map(|r| r.column.as_str()).collect();
711            let cols: Vec<String> = ins
712                .assignments
713                .iter()
714                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
715                .map(|(c, _)| format!("    {}", qi(c)))
716                .chain(ins.rewrites.iter().map(|r| format!("    {}", qi(&r.column))))
717                .collect();
718            let vals: Vec<String> = ins
719                .assignments
720                .iter()
721                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
722                .map(|(_, e)| format!("    {}", emit_expr(e)))
723                .chain(ins.rewrites.iter().map(|r| format!("    {}", emit_expr(&r.expr))))
724                .collect();
725            let mut sql = if !ins.nested_ctes.is_empty() || ins.guard.is_some() {
726                // The row reads columns out of hoisted nested-DML CTEs, so it
727                // selects from them rather than listing literal values. Those
728                // CTEs are emitted by the caller: Postgres only allows a
729                // data-modifying WITH at the top level, never inside a CTE.
730                // A guarded insert likewise has to be a `SELECT … WHERE`, so
731                // that it writes nothing when the condition is false.
732                format!("    {}", emit_insert_row_sql(ins))
733            } else if cols.is_empty() {
734                // See `emit_insert_row_sql`: a row of nothing but defaults.
735                format!("    INSERT INTO {} DEFAULT VALUES", target_ref(&ins.target))
736            } else {
737                format!(
738                    "    INSERT INTO {} (\n{}\n    ) VALUES (\n{}\n    )",
739                    target_ref(&ins.target),
740                    cols.join(",\n"),
741                    vals.join(",\n"),
742                )
743            };
744            if let Some(conflict) = &ins.unless_conflict {
745                emit_conflict(&mut sql, conflict);
746            }
747            sql.push_str("\n    RETURNING *");
748            sql
749        }
750        IrStmt::Update(upd) => {
751            // Callers must route an update with any multi-link (junction
752            // table) mutation through emit_update_multilink_ctes instead —
753            // junction INSERT/DELETE CTEs are data-modifying and Postgres
754            // requires those to sit at the *top level* of the query, so they
755            // can't be nested inside this function's single-CTE-body return
756            // value. See emit_cte_prefix and emit_select_stmt's dml_source
757            // branch, both of which check has_any_multilink before calling
758            // this function at all.
759            let alias = &upd.target.alias;
760            let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "    ");
761            let mut sql = format!(
762                "    UPDATE {} AS {}\n    SET\n{}",
763                target_ref(&upd.target),
764                qi(alias),
765                sets.join(",\n"),
766            );
767            // The nested CTEs are emitted alongside this body (see
768            // `emit_user_cte_parts`), so the reference to them needs a FROM.
769            sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n    "));
770            append_filter(&mut sql, &upd.filter);
771            // Qualified: past a FROM, a bare `*` carries the joined relation's
772            // columns too, and a reader of this CTE then finds two `id`s.
773            sql.push_str(&format!("\n    RETURNING {}.*", qi(alias)));
774            sql
775        }
776        IrStmt::Delete(del) => {
777            let alias = &del.target.alias;
778            let mut sql = format!("    DELETE FROM {} AS {}", target_ref(&del.target), qi(alias),);
779            append_filter(&mut sql, &del.filter);
780            sql.push_str("\n    RETURNING *");
781            sql
782        }
783        IrStmt::Select(inner) => match inner.rows.as_slice() {
784            // `x := (select (select T filter …) filter …)` — the inner select is
785            // what this one reads, the same `_dml` the top-level emitter reads
786            // it from. Read from the table instead, it returned every row of
787            // the type, with neither filter applied.
788            [IrRowSource::Bound { source, .. }]
789                if let Some(read) = inner.dml_source.as_deref()
790                    && !matches!(read, IrStmt::Insert(_) | IrStmt::Update(_) | IrStmt::Delete(_)) =>
791            {
792                // A subquery, not a `WITH`: this may already sit inside one.
793                let mut sql = format!(
794                    "    SELECT {}* FROM (\n{}\n    ) AS {}",
795                    if inner.distinct { "DISTINCT " } else { "" },
796                    emit_dml_as_cte_source(read),
797                    qi(&source.alias),
798                );
799                append_filter(&mut sql, &inner.filter);
800                append_order_by(&mut sql, &inner.order_by);
801                append_offset_limit(&mut sql, &inner.offset, &inner.limit);
802                sql
803            }
804            [IrRowSource::Bound { source, .. }] => {
805                // SELECT-over-SELECT: expose raw columns so the outer SELECT can
806                // project its own shape from them, mirroring DML's RETURNING *.
807                let from = if inner.polymorphic && !source.table.starts_with("@cte:") {
808                    format!(
809                        "(\n{}\n    ) AS {}",
810                        emit_poly_union(&inner.poly_implementors, &inner.poly_columns),
811                        qi(&source.alias),
812                    )
813                } else {
814                    format!("{} AS {}", source_ref(source), qi(&source.alias))
815                };
816                let mut sql = format!(
817                    "    SELECT {}* FROM {}",
818                    if inner.distinct { "DISTINCT " } else { "" },
819                    from
820                );
821                append_filter(&mut sql, &inner.filter);
822                append_order_by(&mut sql, &inner.order_by);
823                append_offset_limit(&mut sql, &inner.offset, &inner.limit);
824                sql
825            }
826            // Object union: same FROM-clause union as `emit_bound_union_select`,
827            // but exposing raw columns like every other arm here.
828            rows if rows.len() > 1 && rows.iter().all(|r| matches!(r, IrRowSource::Bound { .. })) => {
829                let IrRowSource::Bound { source, .. } = &rows[0] else {
830                    unreachable!("checked by the guard")
831                };
832                let mut sql = format!(
833                    "    SELECT {}* FROM (\n{}\n    ) AS {}",
834                    if inner.distinct { "DISTINCT " } else { "" },
835                    bound_union_from_clause(rows, &inner.poly_columns),
836                    qi(&source.alias),
837                );
838                append_filter(&mut sql, &inner.filter);
839                append_order_by(&mut sql, &inner.order_by);
840                append_offset_limit(&mut sql, &inner.offset, &inner.limit);
841                sql
842            }
843            // Free rows: full UNION-ALL emission, same as before the merge
844            // (this arm used to be a separate IrStmt::FreeSelect match). Only
845            // `.sql` is used here, and CtePassthrough's SQL text doesn't
846            // depend on the ctes list (only its *shape* resolution does), so
847            // an empty slice is fine.
848            _ => emit_select_stmt(inner, &[]).sql,
849        },
850        IrStmt::FunctionSelect(sel) => {
851            // Expose raw columns so the outer SELECT can project its own shape,
852            // mirroring how IrStmt::Select works as a CTE source.
853            let args_sql = sel.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
854            let fn_call = format!("{}.{}({})", pg_schema(&sel.fn_module), qi(&sel.fn_name), args_sql);
855            let mut sql = format!("    SELECT * FROM {} AS {}", fn_call, qi(&sel.alias));
856            append_filter(&mut sql, &sel.filter);
857            append_order_by(&mut sql, &sel.order_by);
858            append_offset_limit(&mut sql, &sel.offset, &sel.limit);
859            sql
860        }
861        IrStmt::For(f) => {
862            // `for x in S union (...)` as a set source: iterate in the FROM
863            // clause and expose the body's raw columns, the same contract the
864            // other arms honour. An empty literal iterator yields no rows, but
865            // still has to name a column for the outer SELECT to project.
866            if matches!(&f.iterator, IrForIterator::Values { exprs, .. } if exprs.is_empty()) {
867                return "    SELECT NULL AS \"id\" WHERE FALSE".to_string();
868            }
869            let (values_from, _) = emit_for_iterator(&f.iterator, &format!("_for_{}", f.var_name));
870            let body =
871                prefix_body_ctes(emit_dml_as_cte_source(&f.body), &uncorrelated_body_ctes(f)).replace('\n', "\n    ");
872            format!(
873                "    SELECT \"_body\".*\n    FROM {}\n    CROSS JOIN LATERAL (\n    {}\n    ) AS \"_body\"",
874                values_from.replace('\n', "\n    "),
875                body,
876            )
877        }
878        IrStmt::Group(grp) if matches!(grp.output, IrGroupOutput::Elements) => emit_group_elements_source(grp),
879        IrStmt::Group(_) | IrStmt::VectorSearch(_) | IrStmt::FtsSearch(_) => {
880            unreachable!("cannot appear as a CTE source")
881        }
882        IrStmt::PathSelect(ps) => {
883            // A path traversal as a CTE body. What it projects depends on
884            // where the path ends: a scalar becomes a `result` column (which
885            // is what `IrExpr::CteRef { scalar: true }` reads back), an
886            // object exposes its raw columns so the outer query can both
887            // traverse it (`@cte:` source) and read its `id`.
888            let distinct = if ps.distinct { "DISTINCT " } else { "" };
889            let projection = match &ps.result {
890                // Same two-column convention a free scalar binding uses:
891                // `result` is the ROW() composite top-level decoding wants,
892                // `v` the bare value `IrExpr::CteRef { scalar: true }` reads.
893                IrPathResult::Scalar(e, _) => format!("{}{} AS v", distinct, emit_expr(e)),
894                IrPathResult::Object { alias, .. } => format!("{}{}.*", distinct, qi(alias)),
895            };
896            let mut sql = format!(
897                "SELECT {}\n    FROM {}",
898                projection,
899                emit_path_joins(&ps.root, &ps.joins)
900            );
901            append_filter(&mut sql, &ps.filter);
902            append_order_by(&mut sql, &ps.order_by);
903            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
904            match &ps.result {
905                IrPathResult::Scalar(e, _) => {
906                    let row_value = if enum_type_of_expr(e).is_some() { "v::text" } else { "v" };
907                    format!("    SELECT ROW({row_value}) AS result, v FROM ({sql}) AS _scalar")
908                }
909                IrPathResult::Object { .. } => format!("    {sql}"),
910            }
911        }
912    }
913}
914
915// ── Polymorphic DML-as-CTE fan-out ──────────────────────────────────────────
916//
917// A DML statement targeting an interface/abstract type can't be a single
918// `UPDATE`/`DELETE ... RETURNING *` the way emit_dml_as_cte_source handles a
919// concrete-type target — Postgres has no single physical relation backing
920// the interface (each concrete implementor has its own table), and a bare
921// `RETURNING *` off whichever one table `source_ref` happened to resolve
922// would only ever see that one implementor's rows and never carry a
923// `__type__` discriminator column at all (confirmed live: querying such a
924// CTE from an outer polymorphic SELECT raised "column t1.__type__ does not
925// exist", since the outer SELECT — see emit_select_stmt and the "CTE-backed
926// polymorphic sources" comment there — assumes any CTE tied to a polymorphic
927// type already exposes one).
928//
929// Mirrors emit_poly_update_stmt/emit_poly_delete_stmt's per-implementor
930// UNION ALL fan-out (one CTE per concrete table, each RETURNING-ing rows,
931// unioned together with a compile-time-known type literal per branch — the
932// type is known per branch even though it can vary per row across the whole
933// statement, since each branch only ever touches one implementor's table).
934// The difference here: those two functions only need `id` back (a
935// standalone DML's own default RETURNING shape), while a DML-as-CTE source
936// must expose whatever columns the *outer* SELECT might project — so this
937// returns the interface's full common column set (poly_columns: every
938// property + link FK the type declares, guaranteed present on every
939// implementor table) instead of just id.
940//
941// Not handled: a multi-link mutation (+=/-=/:=) on the interface's own
942// pointer combined with poly_implementors on the same statement — that
943// would need per-implementor junction-table CTEs too, which
944// emit_update_multilink_ctes doesn't do either today. Callers check
945// update_has_any_multilink first and keep routing that (rarer) combination
946// through the existing (equally not-poly-aware) path rather than silently
947// mishandling it here.
948
949fn emit_poly_update_dml_ctes(upd: &IrUpdate, name: &str) -> Vec<String> {
950    let alias = &upd.target.alias;
951    let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
952    let col_list = upd.poly_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
953
954    let mut cte_parts = vec![];
955    let mut union_parts = vec![];
956    for (i, imp) in upd.poly_implementors.iter().enumerate() {
957        let cte_name = format!("{}__u{}", name, i);
958        let mut upd_sql = format!(
959            "UPDATE {} AS {}\nSET {}",
960            qn(&imp.module, &imp.table),
961            qi(alias),
962            sets.join(", "),
963        );
964        append_filter(&mut upd_sql, &upd.filter);
965        upd_sql.push_str(&format!("\nRETURNING {}", col_list));
966        cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, upd_sql));
967
968        union_parts.push(format!(
969            "SELECT {}::text AS \"__type__\", {} FROM \"{}\"",
970            sql_str(&imp.type_name),
971            col_list,
972            cte_name,
973        ));
974    }
975    cte_parts.push(format!("\"{}\" AS (\n{}\n)", name, union_parts.join("\nUNION ALL\n")));
976    cte_parts
977}
978
979/// One implementor's own copy of a polymorphic update: it writes to that
980/// implementor's table, and to that implementor's own junction tables — an
981/// interface's junction is a view over them, which Postgres refuses to
982/// insert into.
983fn concrete_poly_update(upd: &IrUpdate, imp: &IrPolyImplementor) -> IrUpdate {
984    let interface_prefix = format!("{}.", upd.target.table);
985    let own_prefix = format!("{}.", imp.table);
986    let own_junction = |table: &mut String, module: &mut String| {
987        if let Some(link) = table.strip_prefix(&interface_prefix) {
988            *table = format!("{own_prefix}{link}");
989            module.clone_from(&imp.module);
990        }
991    };
992    let mut concrete = upd.clone();
993    concrete.poly_implementors = vec![];
994    concrete.target.table = imp.table.clone();
995    concrete.target.type_name = imp.type_name.clone();
996    concrete.target.poly = None;
997    concrete
998        .multi_link_clears
999        .iter_mut()
1000        .for_each(|c| own_junction(&mut c.junction_table, &mut c.module));
1001    concrete
1002        .multi_link_replaces
1003        .iter_mut()
1004        .chain(concrete.multi_link_appends.iter_mut())
1005        .chain(concrete.multi_link_removals.iter_mut())
1006        .for_each(|m| own_junction(&mut m.junction_table, &mut m.module));
1007    concrete
1008}
1009
1010/// `emit_poly_update_dml_ctes` for an update that also mutates a multi-link:
1011/// each implementor gets the whole sibling set `emit_update_multilink_ctes`
1012/// builds, against its own tables.
1013fn emit_poly_update_multilink_ctes(upd: &IrUpdate, name: &str) -> Vec<String> {
1014    let col_list = upd.poly_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
1015    let mut cte_parts = emit_user_cte_parts(&upd.nested_ctes);
1016    let mut union_parts = vec![];
1017    for (i, imp) in upd.poly_implementors.iter().enumerate() {
1018        let cte_name = format!("{}__u{}", name, i);
1019        cte_parts.extend(emit_update_multilink_cte_parts(
1020            &concrete_poly_update(upd, imp),
1021            &cte_name,
1022            false,
1023        ));
1024        union_parts.push(format!(
1025            "SELECT {}::text AS \"__type__\", {} FROM \"{}\"",
1026            sql_str(&imp.type_name),
1027            col_list,
1028            cte_name,
1029        ));
1030    }
1031    cte_parts.push(format!("\"{}\" AS (\n{}\n)", name, union_parts.join("\nUNION ALL\n")));
1032    cte_parts
1033}
1034
1035fn emit_poly_delete_dml_ctes(del: &IrDelete, name: &str) -> Vec<String> {
1036    let alias = &del.target.alias;
1037    let col_list = del.poly_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
1038
1039    let mut cte_parts = vec![];
1040    let mut union_parts = vec![];
1041    for (i, imp) in del.poly_implementors.iter().enumerate() {
1042        let cte_name = format!("{}__d{}", name, i);
1043        let mut del_sql = format!("DELETE FROM {} AS {}", qn(&imp.module, &imp.table), qi(alias),);
1044        append_filter(&mut del_sql, &del.filter);
1045        del_sql.push_str(&format!("\nRETURNING {}", col_list));
1046        cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, del_sql));
1047
1048        union_parts.push(format!(
1049            "SELECT {}::text AS \"__type__\", {} FROM \"{}\"",
1050            sql_str(&imp.type_name),
1051            col_list,
1052            cte_name,
1053        ));
1054    }
1055    cte_parts.push(format!("\"{}\" AS (\n{}\n)", name, union_parts.join("\nUNION ALL\n")));
1056    cte_parts
1057}
1058
1059// ── User CTE helpers ────────────────────────────────────────────────────────
1060
1061fn update_has_any_multilink(upd: &IrUpdate) -> bool {
1062    !upd.multi_link_clears.is_empty()
1063        || !upd.multi_link_replaces.is_empty()
1064        || !upd.multi_link_appends.is_empty()
1065        || !upd.multi_link_removals.is_empty()
1066}
1067
1068/// Extra `AND target NOT IN (...)` clause for a junction clear paired with
1069/// a replace at the same index (`friends := expr` — see `IrUpdate.
1070/// multi_link_clears`'s own doc comment on the shared-index pairing).
1071///
1072/// Without this, a target present in *both* the old and new sets gets
1073/// deleted here and then silently fails to come back: the paired INSERT
1074/// (`emit_ml_append_cte`) runs its `ON CONFLICT` check against the same
1075/// statement-wide snapshot every CTE in one `WITH` shares, which still
1076/// shows the row this DELETE is *about to* remove as present — Postgres
1077/// treats that as a real conflict and skips the insert (`ON CONFLICT DO
1078/// NOTHING`) or updates the row this DELETE is also deleting (`DO UPDATE`,
1079/// the link-properties case), not "insert the row back." Confirmed live:
1080/// reassigning a junction-backed single link to its own already-set value,
1081/// or replacing a multi-link with a set overlapping its current one,
1082/// silently dropped every overlapping member. Excluding still-wanted
1083/// targets from the DELETE instead means they're never touched at all, so
1084/// there's nothing for the INSERT to conflict with.
1085fn ml_clear_exclusion(rep: Option<&IrMultiLinkMutation>) -> String {
1086    match rep {
1087        Some(rep) => {
1088            let vals_ref = emit_multilink_values_subquery(&rep.values, &[]);
1089            format!(
1090                " AND {} NOT IN (SELECT \"_v\".\"id\" FROM {} AS \"_v\")",
1091                qi(&rep.target_col),
1092                vals_ref
1093            )
1094        }
1095        None => String::new(),
1096    }
1097}
1098
1099/// Builds the CTE chain for an UPDATE with multi-link (junction table)
1100/// mutations, bound to a single external `name` (a user WITH-binding, or the
1101/// implicit "_dml" wrapper for `SELECT (UPDATE ...)`). Junction INSERT/DELETE
1102/// statements are themselves data-modifying, and Postgres requires
1103/// data-modifying CTEs to sit at the *top level* of the query — they can't
1104/// be nested inside another CTE's own body (confirmed live: nesting them
1105/// raises "WITH clause containing a data-modifying statement must be at the
1106/// top level"). So this returns a flat list of sibling CTE parts, prefixed
1107/// by `name` to stay collision-free alongside any other bound statements in
1108/// the same WITH block, ending in a `"{name}" AS (SELECT * FROM
1109/// "{name}__ids")` passthrough — every other reference to `name` keeps
1110/// seeing the updated row's full columns exactly as `RETURNING *` would have
1111/// exposed them.
1112fn emit_update_multilink_ctes(upd: &IrUpdate, name: &str) -> Vec<String> {
1113    emit_update_multilink_cte_parts(upd, name, true)
1114}
1115
1116/// `emit_nested` is false for a caller that has already emitted
1117/// `upd.nested_ctes` beside this one: a polymorphic update emits one branch
1118/// per implementor off the same `IrUpdate`, and a `WITH` may name each
1119/// hoisted statement only once.
1120fn emit_update_multilink_cte_parts(upd: &IrUpdate, name: &str, emit_nested: bool) -> Vec<String> {
1121    let alias = &upd.target.alias;
1122    let has_scalar_changes = !upd.assignments.is_empty() || !upd.rewrites.is_empty();
1123    let ids_name = format!("{}__ids", name);
1124    // The nested statements its values read from, ahead of it.
1125    let mut parts: Vec<String> = if emit_nested {
1126        emit_user_cte_parts(&upd.nested_ctes)
1127    } else {
1128        vec![]
1129    };
1130
1131    if has_scalar_changes {
1132        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
1133        let mut upd_sql = format!(
1134            "UPDATE {} AS {}\nSET {}",
1135            target_ref(&upd.target),
1136            qi(alias),
1137            sets.join(", "),
1138        );
1139        // A nested statement's CTE sits beside this one, so reading its id
1140        // needs a FROM — the same reason the plain update path has one.
1141        upd_sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n"));
1142        append_filter(&mut upd_sql, &upd.filter);
1143        // See `emit_dml_as_cte_source`: past a FROM, a bare `*` would carry the
1144        // joined relation's columns into this CTE beside the target's.
1145        upd_sql.push_str(&format!("\nRETURNING {}.*", qi(alias)));
1146        parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, upd_sql));
1147    } else {
1148        let mut sel = format!(
1149            "SELECT {}.* FROM {} AS {}",
1150            qi(alias),
1151            target_ref(&upd.target),
1152            qi(alias),
1153        );
1154        append_filter(&mut sel, &upd.filter);
1155        parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sel));
1156    }
1157
1158    for (i, clr) in upd.multi_link_clears.iter().enumerate() {
1159        let exclude = ml_clear_exclusion(upd.multi_link_replaces.get(i));
1160        let del = format!(
1161            "DELETE FROM {} WHERE {} IN (SELECT id FROM \"{}\"){}",
1162            qn(&clr.module, &clr.junction_table),
1163            qi(&clr.source_col),
1164            ids_name,
1165            exclude,
1166        );
1167        parts.push(format!("\"{}__clr_{}\" AS (\n{}\n)", name, i, del));
1168    }
1169    for (i, app) in upd.multi_link_appends.iter().enumerate() {
1170        parts.push(emit_ml_append_cte(
1171            app,
1172            &ids_name,
1173            &upd.target.alias,
1174            &format!("{}__ml_add_{}", name, i),
1175        ));
1176    }
1177    for (i, rem) in upd.multi_link_removals.iter().enumerate() {
1178        parts.push(emit_ml_remove_cte(
1179            rem,
1180            &ids_name,
1181            &upd.target.alias,
1182            &format!("{}__ml_rm_{}", name, i),
1183        ));
1184    }
1185    for (i, rep) in upd.multi_link_replaces.iter().enumerate() {
1186        parts.push(emit_ml_append_cte(
1187            rep,
1188            &ids_name,
1189            &upd.target.alias,
1190            &format!("{}__ml_rep_{}", name, i),
1191        ));
1192    }
1193
1194    parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, ids_name));
1195    parts
1196}
1197
1198fn insert_has_any_multilink(ins: &IrInsert) -> bool {
1199    !ins.multi_link_appends.is_empty()
1200}
1201
1202/// Builds the CTE chain for an INSERT with multi-link (junction table)
1203/// assignments at creation time — same top-level-CTE constraint as
1204/// `emit_update_multilink_ctes` (junction INSERTs are themselves
1205/// data-modifying). The row itself is inserted first (`{name}__ids`, via
1206/// `RETURNING *`) so the junction CTEs can reference its freshly-generated id.
1207fn emit_insert_multilink_ctes(ins: &IrInsert, name: &str) -> Vec<String> {
1208    let ids_name = format!("{}__ids", name);
1209    let mut parts: Vec<String> = emit_user_cte_parts(&ins.nested_ctes);
1210
1211    let mut insert_sql = emit_insert_row_sql(ins);
1212    if let Some(conflict) = &ins.unless_conflict {
1213        emit_conflict(&mut insert_sql, conflict);
1214    }
1215    insert_sql.push_str("\nRETURNING *");
1216    parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, insert_sql));
1217
1218    for (i, app) in ins.multi_link_appends.iter().enumerate() {
1219        parts.push(emit_ml_append_cte(
1220            app,
1221            &ids_name,
1222            &ins.target.alias,
1223            &format!("{}__ml_add_{}", name, i),
1224        ));
1225    }
1226
1227    parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, ids_name));
1228    parts
1229}
1230
1231/// A `for` whose body mutates, bound to a WITH name: the same iteration-driven
1232/// shapes `emit_for_insert`/`emit_for_update` build at the top level, but split
1233/// into sibling CTE parts. A data-modifying statement can neither sit inside
1234/// another CTE's body nor in a LATERAL, so the loop cannot be emitted as the
1235/// binding's own subquery the way a select-bodied one is.
1236/// A `for` whose body mutates, bound to a WITH name. `outer_alias` names an
1237/// enclosing loop's iteration when this loop reads it, which makes every row
1238/// this one writes belong to one iteration of it rather than to all of them. `outer_alias` names
1239/// that loop's iteration when this loop reads it, which makes every row this
1240/// one writes belong to one iteration of it rather than to all of them.
1241fn emit_for_dml_ctes_within(f: &IrFor, name: &str, outer_alias: Option<&str>) -> Vec<String> {
1242    let iter_alias = format!("_for_{}", f.var_name);
1243    let (_, plain_iter_cte) = emit_for_iterator(&f.iterator, &iter_alias);
1244    let correlated = outer_alias;
1245    let iter_cte = match correlated {
1246        Some(outer) => emit_nested_for_iterator(&f.iterator, &iter_alias, outer),
1247        None => plain_iter_cte,
1248    };
1249    // The iterator first: a body CTE may read the loop variable, and a WITH
1250    // name is only in scope for the bindings that follow it.
1251    let mut parts: Vec<String> = vec![iter_cte];
1252    parts.extend(emit_user_cte_parts(&f.body_ctes));
1253    let ids_name = format!("{}__ids", name);
1254
1255    // Rows written under a correlated loop carry their iteration key, so the
1256    // enclosing statement can pair them with the row that iteration wrote.
1257    if correlated.is_some()
1258        && let IrStmt::Insert(ins) = f.body.as_ref()
1259        && ins.multi_link_appends.is_empty()
1260    {
1261        let rows_name = format!("{}__rows", name);
1262        let outer_source = format!("{}.{}", qi(&iter_alias), qi(OUTER_KEY));
1263        parts.push(emit_iteration_rows_cte(ins, &rows_name, &iter_alias, &outer_source));
1264        parts.push(emit_insert_from_rows_cte(ins, &ids_name, &rows_name));
1265        parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, rows_name));
1266        return parts;
1267    }
1268
1269    match f.body.as_ref() {
1270        IrStmt::Insert(ins) => {
1271            let rewrite_cols: std::collections::HashSet<&str> =
1272                ins.rewrites.iter().map(|r| r.column.as_str()).collect();
1273            let cols: Vec<String> = ins
1274                .assignments
1275                .iter()
1276                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1277                .map(|(c, _)| qi(c))
1278                .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
1279                .collect();
1280            let values: Vec<String> = ins
1281                .assignments
1282                .iter()
1283                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1284                .map(|(_, e)| emit_expr(e))
1285                .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
1286                .collect();
1287            // A nested loop that carried the iteration key back means these
1288            // rows need one too, to pair with.
1289            let rows_name = format!("{}__rows", name);
1290            let correlated_targets =
1291                correlated_append_indices(&ins.multi_link_appends, &[&f.body_ctes, &ins.nested_ctes]);
1292            if correlated_targets.is_empty() {
1293                let mut sql = format!(
1294                    "INSERT INTO {} ({})\nSELECT {} FROM {}",
1295                    target_ref(&ins.target),
1296                    cols.join(", "),
1297                    values.join(", "),
1298                    qi(&iter_alias),
1299                );
1300                if let Some(conflict) = &ins.unless_conflict {
1301                    emit_conflict(&mut sql, conflict);
1302                }
1303                sql.push_str("\nRETURNING *");
1304                parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sql));
1305            } else {
1306                let outer_source = match correlated {
1307                    Some(_) => format!("{}.{}", qi(&iter_alias), qi(OUTER_KEY)),
1308                    None => format!("{}.{}", qi(&iter_alias), qi("v")),
1309                };
1310                parts.push(emit_iteration_rows_cte(ins, &rows_name, &iter_alias, &outer_source));
1311                parts.push(emit_insert_from_rows_cte(ins, &ids_name, &rows_name));
1312            }
1313            for (i, append) in ins.multi_link_appends.iter().enumerate() {
1314                let cte_name = format!("{}__ml_add_{}", name, i);
1315                if correlated_targets.contains(&i) {
1316                    let IrMultiLinkValueSource::CteRef(target) = &append.values.source else {
1317                        unreachable!("only a CTE-backed value can carry an iteration key")
1318                    };
1319                    parts.push(emit_correlated_ml_append_cte(append, &rows_name, target, &cte_name));
1320                } else {
1321                    parts.push(emit_ml_append_cte(append, &ids_name, &ins.target.alias, &cte_name));
1322                }
1323            }
1324        }
1325        IrStmt::Update(upd) => {
1326            let alias = &upd.target.alias;
1327            if upd.assignments.is_empty() && upd.rewrites.is_empty() && !upd.multi_link_appends.is_empty() {
1328                const ITER_COL: &str = "_iter";
1329                // A nested insert gets its id generated here rather than by the
1330                // column default, so the junction below can name the row this
1331                // iteration inserted. `RETURNING` offers no order to pair on.
1332                let generated: Vec<Option<&IrInsert>> = upd
1333                    .multi_link_appends
1334                    .iter()
1335                    .map(|a| per_iteration_insert(a, &upd.nested_ctes))
1336                    .collect();
1337                let new_cols: String = generated
1338                    .iter()
1339                    .enumerate()
1340                    .filter_map(|(i, ins)| {
1341                        ins.map(|ins| {
1342                            format!(
1343                                ", {} AS {}",
1344                                ins.id_default_sql.as_deref().unwrap_or("uuidv7()"),
1345                                qi(&format!("_new_{i}"))
1346                            )
1347                        })
1348                    })
1349                    .collect();
1350                let mut ids_sql = format!(
1351                    "\"{}\" AS (\nSELECT {}.*, {}.\"v\" AS {}{} FROM {} AS {}, {}",
1352                    ids_name,
1353                    qi(alias),
1354                    qi(&iter_alias),
1355                    qi(ITER_COL),
1356                    new_cols,
1357                    target_ref(&upd.target),
1358                    qi(alias),
1359                    qi(&iter_alias),
1360                );
1361                append_filter(&mut ids_sql, &upd.filter);
1362                ids_sql.push_str("\n)");
1363                parts.push(ids_sql);
1364                for (i, append) in upd.multi_link_appends.iter().enumerate() {
1365                    match generated[i] {
1366                        Some(ins) => {
1367                            parts.push(emit_for_nested_insert_cte(
1368                                ins,
1369                                &ids_name,
1370                                &iter_alias,
1371                                &format!("{}__ml_ins_{}", name, i),
1372                                &format!("_new_{i}"),
1373                            ));
1374                            parts.push(emit_for_ml_append_cte(
1375                                append,
1376                                &ids_name,
1377                                &format!("{}__ml_add_{}", name, i),
1378                                &format!("_new_{i}"),
1379                            ));
1380                        }
1381                        None => parts.push(emit_for_ml_append_cte(
1382                            append,
1383                            &ids_name,
1384                            &format!("{}__ml_add_{}", name, i),
1385                            ITER_COL,
1386                        )),
1387                    }
1388                }
1389            } else {
1390                let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
1391                let mut sql = format!(
1392                    "UPDATE {} AS {}\nSET {}\nFROM {}",
1393                    target_ref(&upd.target),
1394                    qi(alias),
1395                    sets.join(", "),
1396                    qi(&iter_alias),
1397                );
1398                append_filter(&mut sql, &upd.filter);
1399                sql.push_str(&format!("\nRETURNING {}.*", qi(alias)));
1400                parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sql));
1401            }
1402        }
1403        // `for line in … union (for component in … union (insert …))` — one
1404        // iterator CTE per loop, the inner carrying the outer's key, and the
1405        // insert driven from the two joined on it.
1406        IrStmt::For(inner) => {
1407            let inner_alias = format!("_for_{}", inner.var_name);
1408            parts.push(emit_nested_for_iterator(&inner.iterator, &inner_alias, &iter_alias));
1409            parts.extend(emit_user_cte_parts(&inner.body_ctes));
1410            let IrStmt::Insert(ins) = inner.body.as_ref() else {
1411                unreachable!("a nested for-loop's own body is an insert: {:?}", inner.body)
1412            };
1413            let rewrite_cols: std::collections::HashSet<&str> =
1414                ins.rewrites.iter().map(|r| r.column.as_str()).collect();
1415            let cols: Vec<String> = ins
1416                .assignments
1417                .iter()
1418                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1419                .map(|(c, _)| qi(c))
1420                .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
1421                .collect();
1422            let values: Vec<String> = ins
1423                .assignments
1424                .iter()
1425                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1426                .map(|(_, e)| emit_expr(e))
1427                .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
1428                .collect();
1429            let mut sql = format!(
1430                "INSERT INTO {} ({})\nSELECT {} FROM {}",
1431                target_ref(&ins.target),
1432                cols.join(", "),
1433                values.join(", "),
1434                nested_for_from(&inner_alias, &iter_alias),
1435            );
1436            if let Some(conflict) = &ins.unless_conflict {
1437                emit_conflict(&mut sql, conflict);
1438            }
1439            sql.push_str("\nRETURNING *");
1440            parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sql));
1441            for (i, append) in ins.multi_link_appends.iter().enumerate() {
1442                parts.push(emit_ml_append_cte(
1443                    append,
1444                    &ids_name,
1445                    &ins.target.alias,
1446                    &format!("{}__ml_add_{}", name, i),
1447                ));
1448            }
1449        }
1450        // Unreachable: only a mutating body is routed here.
1451        other => unreachable!("for-loop body is not a mutation: {other:?}"),
1452    }
1453
1454    parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, ids_name));
1455    parts
1456}
1457
1458/// Expands a list of user-bound WITH names into their top-level CTE parts —
1459/// usually one part per name, except an UPDATE or INSERT with any multi-link
1460/// mutation expands into several sibling parts (see
1461/// emit_update_multilink_ctes / emit_insert_multilink_ctes), and an UPDATE or
1462/// DELETE targeting an interface/abstract type similarly fans out into one
1463/// part per concrete implementor (see emit_poly_update_dml_ctes /
1464/// emit_poly_delete_dml_ctes) — neither can be nested inside a single name's
1465/// own CTE body.
1466fn emit_user_cte_parts(ctes: &[IrCteDef]) -> Vec<String> {
1467    let mut parts: Vec<String> = vec![];
1468    for c in ctes {
1469        // A hoisted statement may have hoisted one of its own —
1470        // `credentials := (insert access::Credentials { … })` inside an update.
1471        // Those CTEs are emitted beside it rather than inside its body, which
1472        // is where its own SQL expects to read them from.
1473        // The multi-link builders emit a statement's own, ahead of it.
1474        let nested: &[IrCteDef] = match &c.stmt {
1475            IrStmt::Insert(ins) if insert_has_any_multilink(ins) => &[],
1476            IrStmt::Update(upd) if update_has_any_multilink(upd) => &[],
1477            IrStmt::Insert(ins) => &ins.nested_ctes,
1478            IrStmt::Update(upd) => &upd.nested_ctes,
1479            _ => &[],
1480        };
1481        if !nested.is_empty() {
1482            parts.extend(emit_user_cte_parts(nested));
1483        }
1484        if let IrStmt::Update(upd) = &c.stmt {
1485            if !upd.poly_implementors.is_empty() {
1486                parts.extend(if update_has_any_multilink(upd) {
1487                    emit_poly_update_multilink_ctes(upd, &c.name)
1488                } else {
1489                    emit_poly_update_dml_ctes(upd, &c.name)
1490                });
1491                continue;
1492            }
1493            if update_has_any_multilink(upd) {
1494                parts.extend(emit_update_multilink_ctes(upd, &c.name));
1495                continue;
1496            }
1497        }
1498        if let IrStmt::Insert(ins) = &c.stmt
1499            && insert_has_any_multilink(ins)
1500        {
1501            parts.extend(emit_insert_multilink_ctes(ins, &c.name));
1502            continue;
1503        }
1504        if let IrStmt::Delete(del) = &c.stmt
1505            && !del.poly_implementors.is_empty()
1506        {
1507            parts.extend(emit_poly_delete_dml_ctes(del, &c.name));
1508            continue;
1509        }
1510        if let IrStmt::For(f) = &c.stmt
1511            && matches!(f.body.as_ref(), IrStmt::Insert(_) | IrStmt::Update(_) | IrStmt::For(_))
1512        {
1513            parts.extend(emit_for_dml_ctes_within(f, &c.name, c.correlated_to.as_deref()));
1514            continue;
1515        }
1516        let body = emit_dml_as_cte_source(&c.stmt);
1517        parts.push(match &c.correlated_to {
1518            // Evaluated once per iteration and keyed by it, since a CTE is
1519            // evaluated once for the whole statement whatever it reads.
1520            Some(iterator) => format!(
1521                "\"{}\" AS (\nSELECT {}.\"v\" AS {}, \"_row\".*\nFROM {}\nCROSS JOIN LATERAL (\n{}\n) AS \"_row\"\n)",
1522                c.name,
1523                qi(iterator),
1524                qi(OUTER_KEY),
1525                qi(iterator),
1526                body,
1527            ),
1528            None => format!("\"{}\" AS (\n{}\n)", c.name, body),
1529        });
1530    }
1531    parts
1532}
1533
1534/// Emit `WITH "name" AS (...), ...` prefix (WITH keyword + trailing newline included).
1535fn emit_cte_prefix(ctes: &[IrCteDef]) -> String {
1536    format!("WITH\n{}\n", emit_user_cte_parts(ctes).join(",\n"))
1537}
1538
1539/// Collects every link-property name assigned anywhere in a multilink value
1540/// tree (both sides of any nested `union`), in first-seen order — the SQL
1541/// layer needs one consistent column list across every unioned branch, even
1542/// when different targets set different (or no) properties.
1543pub(crate) fn collect_link_prop_names(vals: &IrMultiLinkValues, names: &mut Vec<String>) {
1544    for (name, _) in &vals.link_props {
1545        if !names.contains(name) {
1546            names.push(name.clone());
1547        }
1548    }
1549    match &vals.source {
1550        IrMultiLinkValueSource::Union(a, b) => {
1551            collect_link_prop_names(a, names);
1552            collect_link_prop_names(b, names);
1553        }
1554        IrMultiLinkValueSource::Asserted { inner, .. } => collect_link_prop_names(inner, names),
1555        _ => {}
1556    }
1557}
1558
1559/// `, <expr> AS "name"` for each entry in `prop_names` — the value assigned to
1560/// *this* node if any, else a bare `NULL` (Postgres infers its type from the
1561/// other union branch's typed value in the same column position; if no
1562/// branch ever sets it, the INSERT's target column type coerces it).
1563fn emit_link_prop_cols(vals: &IrMultiLinkValues, prop_names: &[String]) -> String {
1564    prop_names
1565        .iter()
1566        .map(|name| match vals.link_props.iter().find(|(n, _)| n == name) {
1567            Some((_, expr)) => format!(", {} AS {}", emit_expr(expr), qi(name)),
1568            None => format!(", NULL AS {}", qi(name)),
1569        })
1570        .collect()
1571}
1572
1573/// Emit `(SELECT id[, prop, ...] FROM ...)` sub-expression for a multi-link
1574/// values source. `prop_names` is the full set of link-property names used
1575/// anywhere in the enclosing mutation (see `collect_link_prop_names`) — every
1576/// leaf projects all of them so a `union` of heterogeneous branches has a
1577/// consistent column list.
1578fn emit_multilink_values_subquery(vals: &IrMultiLinkValues, prop_names: &[String]) -> String {
1579    emit_multilink_values_inner(vals, prop_names, true)
1580}
1581
1582/// `bare_cte_ok` is false wherever the result has to be a SELECT in its own
1583/// right — a `UNION ALL` operand, or the body of an assert. A CTE reference
1584/// otherwise emits just its quoted name, which only works where the caller
1585/// aliases it into a FROM.
1586fn emit_multilink_values_inner(vals: &IrMultiLinkValues, prop_names: &[String], bare_cte_ok: bool) -> String {
1587    if let IrMultiLinkValueSource::Union(a, b) = &vals.source {
1588        return format!(
1589            "({}\nUNION ALL\n{})",
1590            emit_multilink_values_inner(a, prop_names, false),
1591            emit_multilink_values_inner(b, prop_names, false),
1592        );
1593    }
1594
1595    // The assert wraps the whole value, so the ids are collected once, passed
1596    // through the check, and unnested back into rows. Reading the inner value
1597    // twice — once for the check, once for the rows — would run any `insert`
1598    // inside it twice.
1599    if let IrMultiLinkValueSource::Asserted {
1600        fn_name,
1601        inner,
1602        message,
1603    } = &vals.source
1604    {
1605        let inner_sql = emit_multilink_values_inner(inner, &[], false);
1606        let prop_cols = emit_link_prop_cols(vals, prop_names);
1607        return format!(
1608            "(SELECT unnest(\"_pylon\".{}(ARRAY(SELECT \"_v\".\"id\" FROM {} AS \"_v\"){})) AS \"id\"{})",
1609            qi(fn_name),
1610            inner_sql,
1611            assert_message_arg(message),
1612            prop_cols,
1613        );
1614    }
1615
1616    let prop_cols = emit_link_prop_cols(vals, prop_names);
1617
1618    match &vals.source {
1619        IrMultiLinkValueSource::CteRef(name) => {
1620            if prop_cols.is_empty() && bare_cte_ok {
1621                // Just the CTE name; will be aliased at the call site.
1622                format!("\"{}\"", name)
1623            } else {
1624                format!("(SELECT \"_s\".\"id\"{} FROM \"{}\" AS \"_s\")", prop_cols, name)
1625            }
1626        }
1627        IrMultiLinkValueSource::Select(s) => {
1628            // Compiler only ever constructs this variant for a single
1629            // schema-bound row (compile_subquery_exists's callers reject
1630            // free rows before wrapping) — see compiler.rs's multilink-
1631            // value-source sites.
1632            let [IrRowSource::Bound { source, .. }] = s.rows.as_slice() else {
1633                unreachable!("IrMultiLinkValueSource::Select is always schema-bound")
1634            };
1635            let alias = &source.alias;
1636            let mut sql = format!(
1637                "(SELECT {}.\"id\"{} FROM {} AS {}",
1638                qi(alias),
1639                prop_cols,
1640                source_ref(source),
1641                qi(alias)
1642            );
1643            append_filter(&mut sql, &s.filter);
1644            sql.push(')');
1645            sql
1646        }
1647        IrMultiLinkValueSource::PathSelect(ps) => {
1648            let root_alias = &ps.root.alias;
1649            // The targets are the rows the walk lands on, not the one it
1650            // starts from.
1651            let target_alias = match &ps.result {
1652                IrPathResult::Object { alias, .. } => alias,
1653                IrPathResult::Scalar(..) => root_alias,
1654            };
1655            let mut sql = format!(
1656                "(SELECT {}.\"id\"{} FROM {} AS {}",
1657                qi(target_alias),
1658                prop_cols,
1659                source_ref(&ps.root),
1660                qi(root_alias)
1661            );
1662            for join in &ps.joins {
1663                sql.push_str(&emit_path_join_sql(join));
1664            }
1665            append_filter(&mut sql, &ps.filter);
1666            sql.push(')');
1667            sql
1668        }
1669        IrMultiLinkValueSource::Function(fs) => {
1670            let args = fs.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
1671            let mut sql = format!(
1672                "(SELECT {}.\"id\"{} FROM {}.{}({}) AS {}",
1673                qi(&fs.alias),
1674                prop_cols,
1675                pg_schema(&fs.fn_module),
1676                qi(&fs.fn_name),
1677                args,
1678                qi(&fs.alias),
1679            );
1680            append_filter(&mut sql, &fs.filter);
1681            sql.push(')');
1682            sql
1683        }
1684        IrMultiLinkValueSource::Union(..) | IrMultiLinkValueSource::Asserted { .. } => {
1685            unreachable!("handled above")
1686        }
1687    }
1688}
1689
1690/// SQL fragment for a single path join (used in multilink values emission).
1691fn emit_path_join_sql(join: &IrPathJoin) -> String {
1692    match join {
1693        IrPathJoin::Lateral { inner, target } => {
1694            let projected = match &inner.result {
1695                IrPathResult::Object { alias, .. } => alias.clone(),
1696                IrPathResult::Scalar(..) => unreachable!("a lateral step is only built for an object-valued computed"),
1697            };
1698            let mut body = format!(
1699                "SELECT {}.* FROM {}",
1700                qi(&projected),
1701                emit_path_joins(&inner.root, &inner.joins)
1702            );
1703            append_filter(&mut body, &inner.filter);
1704            append_order_by(&mut body, &inner.order_by);
1705            append_offset_limit(&mut body, &inner.offset, &inner.limit);
1706            format!(" JOIN LATERAL (\n{}\n) AS {} ON TRUE", body, qi(&target.alias))
1707        }
1708        IrPathJoin::Function {
1709            fn_module,
1710            fn_name,
1711            args,
1712            target,
1713        } => {
1714            let args_sql = args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
1715            format!(
1716                " CROSS JOIN LATERAL {}.{}({}) AS {}",
1717                pg_schema(fn_module),
1718                qi(fn_name),
1719                args_sql,
1720                qi(&target.alias),
1721            )
1722        }
1723        IrPathJoin::Single {
1724            source_alias,
1725            fk_col,
1726            target,
1727        } => {
1728            format!(
1729                " JOIN {} AS {} ON {}.\"id\" = {}.{}",
1730                source_ref(target),
1731                qi(&target.alias),
1732                qi(&target.alias),
1733                qi(source_alias),
1734                qi(fk_col)
1735            )
1736        }
1737        IrPathJoin::Multi {
1738            source_alias,
1739            junction_alias,
1740            join: ml_join,
1741            target,
1742        } => {
1743            let (jt_ref, src_col, tgt_col) = match ml_join {
1744                IrMultiLinkJoin::Standard { junction_table, module } => (
1745                    junction_ref(module, junction_table),
1746                    "source".to_string(),
1747                    "target".to_string(),
1748                ),
1749                IrMultiLinkJoin::Through {
1750                    junction_table,
1751                    module,
1752                    source_col,
1753                    target_col,
1754                } => (
1755                    junction_ref(module, junction_table),
1756                    source_col.clone(),
1757                    target_col.clone(),
1758                ),
1759                // A forward `IrPathJoin::Multi` step is always built from a
1760                // real multi-link (`compile_path_select`'s middle-step
1761                // handling), never a backlink — the Backlink* variants only
1762                // ever appear inside `IrShapePointer::MultiLink.join`.
1763                IrMultiLinkJoin::BacklinkFk { .. } | IrMultiLinkJoin::BacklinkJunction { .. } => {
1764                    unreachable!("a forward multi-link path step never uses a backlink join variant")
1765                }
1766            };
1767            format!(
1768                " JOIN {} AS {} ON {}.{} = {}.\"id\" JOIN {} AS {} ON {}.{} = {}.\"id\"",
1769                jt_ref,
1770                qi(junction_alias),
1771                qi(junction_alias),
1772                qi(&src_col),
1773                qi(source_alias),
1774                source_ref(target),
1775                qi(&target.alias),
1776                qi(junction_alias),
1777                qi(&tgt_col),
1778                qi(&target.alias),
1779            )
1780        }
1781        IrPathJoin::BacklinkSingle {
1782            source_alias,
1783            fk_col,
1784            target,
1785        } => {
1786            format!(
1787                " JOIN {} AS {} ON {}.{} = {}.\"id\"",
1788                source_ref(target),
1789                qi(&target.alias),
1790                qi(&target.alias),
1791                qi(fk_col),
1792                qi(source_alias),
1793            )
1794        }
1795        IrPathJoin::BacklinkMulti {
1796            source_alias,
1797            junction_alias,
1798            junction_table,
1799            module,
1800            owner_col,
1801            current_col,
1802            target,
1803        } => {
1804            format!(
1805                " JOIN {} AS {} ON {}.{} = {}.\"id\" JOIN {} AS {} ON {}.\"id\" = {}.{}",
1806                junction_ref(module, junction_table),
1807                qi(junction_alias),
1808                qi(junction_alias),
1809                qi(current_col),
1810                qi(source_alias),
1811                source_ref(target),
1812                qi(&target.alias),
1813                qi(&target.alias),
1814                qi(junction_alias),
1815                qi(owner_col),
1816            )
1817        }
1818    }
1819}
1820
1821/// Emit the CTE clause for a junction table INSERT (append / replace-insert).
1822/// `ids_name` is the row-source CTE to join against (usually "_ids", but
1823/// callers hoisting this into a shared top-level WITH block alongside other
1824/// user-bound statements pass a name prefixed for collision-safety instead).
1825/// The loop variable an append's value stands for, when the value is exactly
1826/// that variable (`memberships += membership`).
1827///
1828/// Such a value is compiled as a select over the target table narrowed to the
1829/// key the variable holds. Inside a `for` that key is already carried on the
1830/// driving rows, so the junction insert can read it from there instead of
1831/// correlating to a CTE it has no range-table entry for.
1832pub(crate) fn append_value_is_the_loop_variable(values: &IrMultiLinkValues, var: &str) -> bool {
1833    let IrMultiLinkValueSource::Select(sel) = &values.source else {
1834        return false;
1835    };
1836    let Some(IrExpr::BinOp(cmp)) = sel.filter.as_ref() else {
1837        return false;
1838    };
1839    matches!(
1840        (&cmp.left, &cmp.op, &cmp.right),
1841        (
1842            IrExpr::ColumnRef { column, .. },
1843            crate::parse::ast::BinOpKind::Eq,
1844            IrExpr::ForVar { name, .. },
1845        ) if column == "id" && name == var
1846    ) && values.link_props.is_empty()
1847}
1848
1849/// `emit_ml_append_cte` for a `for` body: the target comes from the column the
1850/// driving rows carry rather than from a correlated subquery.
1851/// The nested insert a multi-link append's value names, when it is one of this
1852/// update's own hoisted CTEs. Inside a `for` body such an insert runs once per
1853/// iteration, so it cannot stay a standalone CTE.
1854pub(crate) fn per_iteration_insert<'c>(mutation: &IrMultiLinkMutation, nested: &'c [IrCteDef]) -> Option<&'c IrInsert> {
1855    let IrMultiLinkValueSource::CteRef(name) = &mutation.values.source else {
1856        return None;
1857    };
1858    if !mutation.values.link_props.is_empty() {
1859        return None;
1860    }
1861    nested.iter().find(|c| &c.name == name).and_then(|c| match &c.stmt {
1862        IrStmt::Insert(ins) if ins.id_default_sql.is_some() && ins.multi_link_appends.is_empty() => Some(ins),
1863        _ => None,
1864    })
1865}
1866
1867/// A nested insert inside a `for` body: one row per driving row, with its id
1868/// taken from the column the driving CTE generated so the junction can pair
1869/// with it. The iterator CTE is joined in because the inserted values are
1870/// correlated subqueries that read the loop variable.
1871fn emit_for_nested_insert_cte(
1872    ins: &IrInsert,
1873    ids_name: &str,
1874    iter_alias: &str,
1875    cte_name: &str,
1876    id_col: &str,
1877) -> String {
1878    let mut cols = vec![qi("id")];
1879    let mut values = vec![format!("\"{}\".{}", ids_name, qi(id_col))];
1880    for (col, expr) in &ins.assignments {
1881        cols.push(qi(col));
1882        values.push(emit_expr(expr));
1883    }
1884    format!(
1885        "\"{}\" AS (\nINSERT INTO {} ({})\nSELECT {} FROM \"{}\", {}\nWHERE \"{}\".{} = {}.\"v\"\nRETURNING {}\n)",
1886        cte_name,
1887        target_ref(&ins.target),
1888        cols.join(", "),
1889        values.join(", "),
1890        ids_name,
1891        qi(iter_alias),
1892        ids_name,
1893        qi("_iter"),
1894        qi(iter_alias),
1895        qi("id"),
1896    )
1897}
1898
1899fn emit_for_ml_append_cte(mutation: &IrMultiLinkMutation, ids_name: &str, cte_name: &str, iter_col: &str) -> String {
1900    format!(
1901        "\"{}\" AS (\nINSERT INTO {} ({}, {})\nSELECT \"{}\".\"id\", \"{}\".{} FROM \"{}\"\nON CONFLICT DO NOTHING\nRETURNING {}, {}\n)",
1902        cte_name,
1903        qn(&mutation.module, &mutation.junction_table),
1904        qi(&mutation.source_col),
1905        qi(&mutation.target_col),
1906        ids_name,
1907        ids_name,
1908        qi(iter_col),
1909        ids_name,
1910        qi(&mutation.source_col),
1911        qi(&mutation.target_col),
1912    )
1913}
1914
1915/// `ids_alias` is the name the rows being written go by inside the value:
1916/// a walk off the row (`+= (select .notifications …)`) is correlated to it.
1917fn emit_ml_append_cte(mutation: &IrMultiLinkMutation, ids_name: &str, ids_alias: &str, cte_name: &str) -> String {
1918    let mut prop_names = vec![];
1919    collect_link_prop_names(&mutation.values, &mut prop_names);
1920    let vals_ref = emit_multilink_values_subquery(&mutation.values, &prop_names);
1921
1922    let extra_cols: String = prop_names.iter().map(|n| format!(", {}", qi(n))).collect();
1923    let extra_select: String = prop_names.iter().map(|n| format!(", \"_v\".{}", qi(n))).collect();
1924
1925    // Re-checking an already-linked target with a new property value must
1926    // update in place rather than error — a bare `DO NOTHING` (the no-
1927    // properties case) would silently keep the old value instead.
1928    let conflict_clause = if mutation.single {
1929        // A junction-backed single link's table has `PRIMARY KEY (source)`
1930        // alone (D3, cardinality-one) — the paired `_clr_i` DELETE (see
1931        // `ml_clear_exclusion`) removes any existing row for this source in
1932        // a sibling CTE that shares this whole statement's one snapshot, so
1933        // this INSERT still "sees" that old row and treats it as a
1934        // same-key conflict regardless of what the new target is — a plain
1935        // `DO NOTHING` would then silently drop a genuine reassignment.
1936        // `source` alone is always this table's conflict target, so this
1937        // must always be an upsert, not just when link properties exist.
1938        let mut sets = vec![format!(
1939            "{} = EXCLUDED.{}",
1940            qi(&mutation.target_col),
1941            qi(&mutation.target_col)
1942        )];
1943        sets.extend(prop_names.iter().map(|n| format!("{} = EXCLUDED.{}", qi(n), qi(n))));
1944        format!(
1945            "ON CONFLICT ({}) DO UPDATE SET {}",
1946            qi(&mutation.source_col),
1947            sets.join(", ")
1948        )
1949    } else if prop_names.is_empty() {
1950        "ON CONFLICT DO NOTHING".to_string()
1951    } else {
1952        let sets: Vec<String> = prop_names
1953            .iter()
1954            .map(|n| format!("{} = EXCLUDED.{}", qi(n), qi(n)))
1955            .collect();
1956        format!(
1957            "ON CONFLICT ({}, {}) DO UPDATE SET {}",
1958            qi(&mutation.source_col),
1959            qi(&mutation.target_col),
1960            sets.join(", "),
1961        )
1962    };
1963
1964    // A subquery value may read the row it is written for, so it joins
1965    // laterally; a bare CTE name cannot take LATERAL.
1966    let lateral = if vals_ref.starts_with('(') { "LATERAL " } else { "" };
1967    let ins = format!(
1968        "INSERT INTO {} ({}, {}{})\nSELECT {}.\"id\", \"_v\".\"id\"{} FROM \"{}\" AS {} CROSS JOIN {}{} AS \"_v\"\n{}\nRETURNING {}, {}",
1969        qn(&mutation.module, &mutation.junction_table),
1970        qi(&mutation.source_col),
1971        qi(&mutation.target_col),
1972        extra_cols,
1973        qi(ids_alias),
1974        extra_select,
1975        ids_name,
1976        qi(ids_alias),
1977        lateral,
1978        vals_ref,
1979        conflict_clause,
1980        qi(&mutation.source_col),
1981        qi(&mutation.target_col),
1982    );
1983    format!("\"{}\" AS (\n{}\n)", cte_name, ins)
1984}
1985
1986/// Emit the CTE clause for a junction table DELETE (remove). See
1987/// emit_ml_append_cte re: `ids_name` and `ids_alias`.
1988fn emit_ml_remove_cte(mutation: &IrMultiLinkMutation, ids_name: &str, ids_alias: &str, cte_name: &str) -> String {
1989    // Removal never carries link-property assignments (rejected at compile
1990    // time in ir/compiler.rs), so no extra columns are ever needed here.
1991    let vals_ref = emit_multilink_values_subquery(&mutation.values, &[]);
1992    // A walk off the row (`-= (select .line_items …)`) is correlated to
1993    // `ids_alias`, so the rows being written are joined in under that name
1994    // rather than read through an uncorrelated `IN`.
1995    let del = format!(
1996        "DELETE FROM {} AS \"_jn\"\nUSING \"{}\" AS {}\nWHERE \"_jn\".{} = {}.\"id\"\n  AND \"_jn\".{} IN (SELECT \"id\" FROM {})\nRETURNING \"_jn\".{}, \"_jn\".{}",
1997        qn(&mutation.module, &mutation.junction_table),
1998        ids_name,
1999        qi(ids_alias),
2000        qi(&mutation.source_col),
2001        qi(ids_alias),
2002        qi(&mutation.target_col),
2003        vals_ref,
2004        qi(&mutation.source_col),
2005        qi(&mutation.target_col),
2006    );
2007    format!("\"{}\" AS (\n{}\n)", cte_name, del)
2008}
2009
2010// ── Conflict helper ─────────────────────────────────────────────────────────
2011
2012use crate::ir::IrConflict;
2013
2014fn emit_conflict(sql: &mut String, conflict: &IrConflict) {
2015    // `unless conflict on (.email, .invitee)` names several columns, not one
2016    // composite value: Postgres infers the arbiter index from a column list,
2017    // and would look for an expression index on the tuple instead.
2018    let on_sql = conflict.on.as_ref().map(|e| match e {
2019        IrExpr::Tuple(elements) => format!("({})", elements.iter().map(emit_expr).collect::<Vec<_>>().join(", ")),
2020        other => format!("({})", emit_expr(other)),
2021    });
2022    match (&on_sql, &conflict.do_update) {
2023        (None, None) => sql.push_str(" ON CONFLICT DO NOTHING"),
2024        (Some(on), None) => sql.push_str(&format!(" ON CONFLICT {} DO NOTHING", on)),
2025        (None, Some(updates)) => {
2026            sql.push_str(&format!(" ON CONFLICT DO UPDATE SET {}", do_update_sets(updates)));
2027        }
2028        (Some(on), Some(updates)) => {
2029            sql.push_str(&format!(
2030                " ON CONFLICT {} DO UPDATE SET {}",
2031                on,
2032                do_update_sets(updates),
2033            ));
2034        }
2035    }
2036    if conflict.do_update.is_some()
2037        && let Some(predicate) = &conflict.do_update_where
2038    {
2039        sql.push_str(&format!(" WHERE {}", emit_expr(predicate)));
2040    }
2041}
2042
2043fn do_update_sets(updates: &[(String, IrExpr)]) -> String {
2044    updates
2045        .iter()
2046        .map(|(col, expr)| format!("{} = {}", qi(col), emit_expr(expr)))
2047        .collect::<Vec<_>>()
2048        .join(", ")
2049}
2050
2051// ── FREE SELECT ─────────────────────────────────────────────────────────────
2052
2053/// Types returned as plain top-level columns rather than wrapped in the
2054/// `result` ROW() composite, so each keeps its own natural top-level shape.
2055fn is_integer_expr(expr: &IrExpr) -> bool {
2056    match expr {
2057        IrExpr::ColumnRef { pg_type, .. } => matches!(
2058            pg_type.as_str(),
2059            "int2" | "int4" | "int8" | "integer" | "bigint" | "smallint"
2060        ),
2061        IrExpr::Literal(crate::ir::IrLiteral::Int(_)) => true,
2062        IrExpr::BinOp(op) => is_integer_expr(&op.left) && is_integer_expr(&op.right),
2063        _ => false,
2064    }
2065}
2066
2067fn is_raw_scalar(expr: &IrExpr) -> bool {
2068    matches!(expr, IrExpr::Array(_))
2069        || matches!(expr, IrExpr::TypeCast(c) if c.pg_type == "jsonb")
2070        || matches!(expr, IrExpr::NamedTuple { .. })
2071        || matches!(expr, IrExpr::Tuple(_))
2072        || matches!(expr, IrExpr::JsonbField { .. })
2073        || matches!(expr, IrExpr::JsonbIndex { .. })
2074}
2075
2076/// Free (non-schema-bound) SELECT rows — one or more UNION ALL branches with
2077/// a ROW(…) wrapper. `rows` is `sel.rows` with any leading `Bound` entries
2078/// already ruled out by `emit_select_stmt`'s dispatch (a schema object and a
2079/// free literal can never appear in the same UNION — `check_union_type_compat`
2080/// rejects that at compile time), so every entry here is `IrRowSource::Free`.
2081fn emit_free_rows(sel: &IrSelect, rows: &[IrRowSource], ctes: &[IrCteDef]) -> SqlOutput {
2082    use crate::query::ShapeNode;
2083
2084    let items: Vec<&IrFreeExpr> = rows
2085        .iter()
2086        .map(|r| match r {
2087            IrRowSource::Free(item) => item,
2088            IrRowSource::Bound { .. } => unreachable!("mixed Bound/Free rows rejected at compile time"),
2089        })
2090        .collect();
2091
2092    if items.is_empty() {
2093        return SqlOutput {
2094            sql: "SELECT NULL AS result WHERE FALSE".to_string(),
2095            shape: ShapeDescriptor {
2096                root: ShapeNode::Scalar {
2097                    name: String::new(),
2098                    position: 0,
2099                },
2100            },
2101            inference_plan: None,
2102        };
2103    }
2104
2105    // assert_exists / assert_distinct: set-returning — emit as unnest, not UNION ALL
2106    if items.len() == 1
2107        && let IrFreeExpr::AssertSet {
2108            fn_name,
2109            inner,
2110            message,
2111        } = items[0]
2112    {
2113        let array_sql = emit_array_source(inner) + &assert_message_arg(message);
2114        let mut sql = format!(
2115            "SELECT ROW(v) AS result FROM unnest(\"_pylon\".{}({})) AS _assert(v)",
2116            fn_name, array_sql,
2117        );
2118        if sel.distinct {
2119            sql = format!("SELECT DISTINCT * FROM ({}) AS \"_distinct\"", sql);
2120        }
2121        append_order_by(&mut sql, &sel.order_by);
2122        append_offset_limit(&mut sql, &sel.offset, &sel.limit);
2123        return SqlOutput {
2124            sql,
2125            shape: ShapeDescriptor {
2126                root: ShapeNode::Scalar {
2127                    name: String::new(),
2128                    position: 0,
2129                },
2130            },
2131            inference_plan: None,
2132        };
2133    }
2134
2135    let shape_root = free_item_shape(items.first().unwrap(), ctes);
2136
2137    let branches: Vec<String> = items
2138        .iter()
2139        .map(|item| match item {
2140            IrFreeExpr::Scalar(expr) => {
2141                // An object standing as the whole result is already the
2142                // composite row `result` is meant to be — wrapping it in
2143                // another ROW() would bury its columns one level down from
2144                // where its shape node says they are.
2145                if matches!(expr, IrExpr::ObjectSubquery(_)) {
2146                    return format!("SELECT v AS result, v FROM (SELECT {} AS v) AS _obj", emit_expr(expr));
2147                }
2148                // Arrays and jsonb are returned as plain top-level columns
2149                // rather than wrapped, so they keep their own shape node.
2150                if is_raw_scalar(expr) {
2151                    // `v` alongside it for the same reason the wrapped form
2152                    // has one: a `with` binding of an array is read by name.
2153                    format!("SELECT v AS result, v FROM (SELECT {} AS v) AS _raw", emit_expr(expr))
2154                } else {
2155                    // `result` is a ROW() composite for top-level decoding.
2156                    // `v` is the unwrapped scalar for use in CteRef expression context.
2157                    // Wrap in a subquery so volatile functions (nextval, etc.) are called once.
2158                    // Enum values also need a ::text cast inside the ROW() — same
2159                    // unregistered-OID problem as arrays/jsonb — but the bare `v`
2160                    // column stays natively typed for CteRef expression use.
2161                    let e = emit_expr(expr);
2162                    let row_value = if enum_type_of_expr(expr).is_some() {
2163                        "v::text"
2164                    } else {
2165                        "v"
2166                    };
2167                    // In a set of several, an element that is empty is not
2168                    // in the set at all (`{a.id, b.id}` with no `a` is just
2169                    // `b.id`) — where SQL would carry it as a NULL row.
2170                    let present = if items.len() > 1 { " WHERE v IS NOT NULL" } else { "" };
2171                    format!("SELECT ROW({row_value}) AS result, v FROM (SELECT {e} AS v) AS _scalar{present}")
2172                }
2173            }
2174            IrFreeExpr::FreeObject(fields) => {
2175                // Each field is computed once in an inner subquery (so a
2176                // volatile expression like nextval() isn't evaluated twice) and
2177                // exposed both packed into the `result` composite (for whole-
2178                // object passthrough/decoding) and as its own named column (for
2179                // `IrExpr::CteFieldRef` — `with x := {a := ...} select x.a`).
2180                let inner_cols: Vec<String> = fields
2181                    .iter()
2182                    .enumerate()
2183                    .map(|(i, (_, e))| format!("{} AS \"_f{}\"", emit_expr(e), i))
2184                    .collect();
2185                let row_items: Vec<String> = fields
2186                    .iter()
2187                    .enumerate()
2188                    .map(|(i, (_, e))| {
2189                        if enum_type_of_expr(e).is_some() {
2190                            format!("\"_f{}\"::text", i)
2191                        } else {
2192                            format!("\"_f{}\"", i)
2193                        }
2194                    })
2195                    .collect();
2196                let named_cols: Vec<String> = fields
2197                    .iter()
2198                    .enumerate()
2199                    .map(|(i, (name, _))| format!("\"_f{}\" AS {}", i, qi(name)))
2200                    .collect();
2201                format!(
2202                    "SELECT ROW({}) AS result, {} FROM (SELECT {}) AS _obj",
2203                    row_items.join(", "),
2204                    named_cols.join(", "),
2205                    inner_cols.join(", "),
2206                )
2207            }
2208            IrFreeExpr::Tuple(exprs) => {
2209                if exprs.len() == 1 {
2210                    format!("SELECT ROW({}) AS result", emit_free_field_expr(&exprs[0]))
2211                } else {
2212                    let parts: Vec<String> = exprs.iter().map(emit_free_field_expr).collect();
2213                    format!("SELECT ({}) AS result", parts.join(", "))
2214                }
2215            }
2216            IrFreeExpr::NamedTupleRow(fields) => {
2217                let parts: Vec<String> = fields.iter().map(|(_, e)| emit_free_field_expr(e)).collect();
2218                if parts.len() == 1 {
2219                    format!("SELECT ROW({}) AS result", parts[0])
2220                } else {
2221                    format!("SELECT ({}) AS result", parts.join(", "))
2222                }
2223            }
2224            IrFreeExpr::AssertSet { .. } => unreachable!("AssertSet is handled by early return above"),
2225            IrFreeExpr::CtePassthrough(name) => format!("SELECT \"result\" FROM {}", qi(name)),
2226        })
2227        .collect();
2228
2229    let union_sql = branches.join("\nUNION ALL\n");
2230
2231    let mut sql = if sel.distinct {
2232        // Wrap UNION ALL in an outer SELECT DISTINCT to deduplicate.
2233        format!("SELECT DISTINCT * FROM (\n{}\n) AS \"_distinct\"", union_sql)
2234    } else {
2235        union_sql
2236    };
2237    // A free SELECT's FILTER has no row of its own to sit beside, so it gates
2238    // the result the branches already produced rather than joining their own
2239    // WHERE clauses.
2240    if let Some(filter) = &sel.filter {
2241        sql = format!(
2242            "SELECT * FROM (\n{}\n) AS \"_filtered\"\nWHERE {}",
2243            sql,
2244            emit_expr(filter)
2245        );
2246    }
2247    append_order_by(&mut sql, &sel.order_by);
2248    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
2249
2250    SqlOutput {
2251        sql,
2252        shape: ShapeDescriptor { root: shape_root },
2253        inference_plan: None,
2254    }
2255}
2256
2257/// Determine whether `expr`'s runtime SQL type is a custom/enum type whose
2258/// OID is assigned by the database rather than fixed — and
2259/// if so, the Postgres-schema-qualified enum type name for `ShapeNode::Enum`
2260/// tagging (same convention `pg_quoted_to_pylon` expects). Covers both a
2261/// column reference to an enum-typed property (quoted pg_type) and a bare
2262/// enum literal (`default::Gender.Male`) — the two ways an enum value can
2263/// appear as a free scalar/object/tuple field, which (unlike a schema
2264/// object's `emit_scalar`) has no dedicated per-pointer descriptor to carry
2265/// this, so it must be recovered from the expression itself.
2266fn enum_type_of_expr(expr: &IrExpr) -> Option<QualifiedPgType> {
2267    match expr {
2268        IrExpr::ColumnRef { pg_type, .. } => QualifiedPgType::of(pg_type),
2269        IrExpr::EnumLiteral { pg_type, .. } => Some(QualifiedPgType {
2270            name: pg_quoted_to_pylon(pg_type),
2271            is_array: false,
2272        }),
2273        _ => None,
2274    }
2275}
2276
2277/// The enum type a shape position decodes as — wider than `enum_type_of_expr`,
2278/// which only answers for the values that need a `::text` cast emitting.
2279///
2280/// `[is Concrete].*` wraps each of the concrete type's properties in a
2281/// correlated subquery of its own, so the enum sits a level down and the
2282/// subquery's own emission has already cast it. Without looking through to it
2283/// the outer shape stays a plain `Scalar` and the label is handed back as a
2284/// bare string rather than the enum member.
2285fn enum_type_of_shape_expr(expr: &IrExpr) -> Option<QualifiedPgType> {
2286    if let Some(qualified) = enum_type_of_expr(expr) {
2287        return Some(qualified);
2288    }
2289    let IrExpr::Subquery(select) = expr else {
2290        return None;
2291    };
2292    let [IrRowSource::Bound { shape, .. }] = select.rows.as_slice() else {
2293        return None;
2294    };
2295    let [IrShapePointer::Scalar(scalar)] = shape.as_slice() else {
2296        return None;
2297    };
2298    QualifiedPgType::of(&scalar.pg_type)
2299}
2300
2301/// Emit `expr` for use as a free object/tuple field value — casts to
2302/// `::text` when it's enum-typed (see `enum_type_of_expr`), which keeps the
2303/// value's shape independent of the enum's database-assigned type OID.
2304/// (`pylon-pgcon` also discovers enum OIDs at connect time now, so this cast
2305/// is belt-and-braces rather than load-bearing for decoding.)
2306fn emit_free_field_expr(expr: &IrExpr) -> String {
2307    match enum_type_of_expr(expr) {
2308        Some(qualified) => format!("{}{}", emit_expr(expr), qualified.text_cast()),
2309        None => emit_expr(expr),
2310    }
2311}
2312
2313/// Shape node for one free scalar/object/tuple field — `Enum` when the
2314/// value is enum-typed (see `enum_type_of_expr`), else a plain `Scalar`.
2315fn free_field_shape_node(name: &str, position: usize, expr: &IrExpr) -> crate::query::ShapeNode {
2316    use crate::query::{Cardinality, ShapeNode};
2317    if let IrExpr::ObjectSubquery(sel) = expr {
2318        let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
2319            unreachable!("an object subquery is always schema-bound")
2320        };
2321        let (_, nodes) = build_shape(shape, &source.alias);
2322        return ShapeNode::Object {
2323            name: name.to_string(),
2324            type_name: Some(source.type_name.clone()),
2325            position,
2326            cardinality: Cardinality::Optional,
2327            pointers: prepend_type(nodes),
2328            has_implicit_id: shape_has_implicit_id(shape),
2329        };
2330    }
2331    // A walk landing on one object: `(select … limit 1).latest_data { … }`.
2332    if matches!(expr, IrExpr::ObjectPathSubquery(_) | IrExpr::ObjectPathUnion { .. }) {
2333        return expr_shape_node(name, position, expr);
2334    }
2335    // A set of objects keeps its rows' own shape, as it does on a schema
2336    // shape; described as a scalar, each row hydrates as a bare tuple.
2337    if let IrExpr::ArrayFromSelect(source) = expr
2338        && matches!(
2339            source.as_ref(),
2340            IrArraySource::ObjectSelect(_)
2341                | IrArraySource::ObjectFunction(_)
2342                | IrArraySource::PathSelect(_)
2343                | IrArraySource::Group(_)
2344        )
2345    {
2346        return expr_shape_node(name, position, expr);
2347    }
2348    match enum_type_of_shape_expr(expr) {
2349        Some(qualified) => qualified.shape_node(name.to_string(), position),
2350        None => ShapeNode::Scalar {
2351            name: name.to_string(),
2352            position,
2353        },
2354    }
2355}
2356
2357/// Shape node for an arbitrary compiled expression appearing at a shape
2358/// position (a computed pointer, or a free scalar/tuple/object field) —
2359/// `NamedTuple`/`JsonScalar` when the expression produces jsonb (tuple
2360/// literals, nested free objects, tuple-typed casts), `RawScalar` when
2361/// it is returned as a bare top-level column, `Enum` when
2362/// enum-typed, else a plain `Scalar`.
2363fn expr_shape_node(name: &str, position: usize, expr: &IrExpr) -> crate::query::ShapeNode {
2364    use crate::query::{Cardinality, ShapeNode};
2365    match expr {
2366        IrExpr::ObjectPathUnion { branches, multi, .. } => {
2367            let first = branches.first().expect("a union has at least one branch");
2368            let IrPathResult::Object {
2369                alias,
2370                type_name,
2371                shape,
2372            } = &first.result
2373            else {
2374                unreachable!("an object path union's branches always land on objects")
2375            };
2376            let (_, pointer_nodes) = build_shape(shape, alias);
2377            let object = ShapeNode::Object {
2378                name: if *multi { String::new() } else { name.to_string() },
2379                type_name: Some(type_name.clone()),
2380                position: if *multi { 0 } else { position },
2381                cardinality: if *multi {
2382                    Cardinality::Many
2383                } else {
2384                    Cardinality::Optional
2385                },
2386                pointers: prepend_type(pointer_nodes),
2387                has_implicit_id: shape_has_implicit_id(shape),
2388            };
2389            if *multi {
2390                ShapeNode::Array {
2391                    name: name.to_string(),
2392                    position,
2393                    element: Box::new(object),
2394                }
2395            } else {
2396                object
2397            }
2398        }
2399        IrExpr::ObjectPathSubquery(ps) => {
2400            let IrPathResult::Object {
2401                alias,
2402                type_name,
2403                shape,
2404            } = &ps.result
2405            else {
2406                unreachable!("an object path subquery always lands on an object")
2407            };
2408            let (_, pointer_nodes) = build_shape(shape, alias);
2409            ShapeNode::Object {
2410                name: name.to_string(),
2411                type_name: Some(type_name.clone()),
2412                position,
2413                cardinality: Cardinality::Optional,
2414                pointers: prepend_type(pointer_nodes),
2415                has_implicit_id: shape_has_implicit_id(shape),
2416            }
2417        }
2418        IrExpr::TypeCast(c) if c.tuple_shape.is_some() => {
2419            let shape = c.tuple_shape.as_ref().unwrap();
2420            ShapeNode::NamedTuple {
2421                name: name.to_string(),
2422                position,
2423                type_name: shape.type_name.clone(),
2424                members: Some(shape.members.clone()),
2425                is_free_object: false,
2426            }
2427        }
2428        // A `<json>` cast at a shape position is an ordinary column of the row
2429        // tuple, so it needs the pointer's own name and position like any
2430        // other leaf. `JsonScalar` is the *root-only* form, where the result
2431        // column itself is the value and there is no pointer to name — it
2432        // carries neither field, and `free_item_shape` is what selects it.
2433        IrExpr::TypeCast(c) if c.pg_type == "jsonb" => ShapeNode::Scalar {
2434            name: name.to_string(),
2435            position,
2436        },
2437        IrExpr::NamedTuple { is_free_object, .. } => ShapeNode::NamedTuple {
2438            name: name.to_string(),
2439            position,
2440            type_name: None,
2441            members: None,
2442            is_free_object: *is_free_object,
2443        },
2444        // A path select aggregated into an array keeps its rows' own shape, so
2445        // the elements hydrate as objects rather than as opaque scalars.
2446        IrExpr::ArrayFromSelect(source) => match source.as_ref() {
2447            IrArraySource::ObjectFunction(fs) => {
2448                let (_, nodes) = build_shape(&fs.shape, &fs.alias);
2449                ShapeNode::Array {
2450                    name: name.to_string(),
2451                    position,
2452                    element: Box::new(ShapeNode::Object {
2453                        name: String::new(),
2454                        type_name: Some(fs.type_name.clone()),
2455                        position: 0,
2456                        cardinality: Cardinality::Many,
2457                        pointers: prepend_type(nodes),
2458                        has_implicit_id: shape_has_implicit_id(&fs.shape),
2459                    }),
2460                }
2461            }
2462            IrArraySource::Group(grp) => ShapeNode::Array {
2463                name: name.to_string(),
2464                position,
2465                element: Box::new(emit_group(grp).shape.root),
2466            },
2467            IrArraySource::ObjectSelect(sel) => {
2468                let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
2469                    unreachable!("IrArraySource::ObjectSelect is always schema-bound")
2470                };
2471                let (_, nodes) = build_shape(shape, &source.alias);
2472                ShapeNode::Array {
2473                    name: name.to_string(),
2474                    position,
2475                    element: Box::new(ShapeNode::Object {
2476                        name: String::new(),
2477                        type_name: Some(source.type_name.clone()),
2478                        position: 0,
2479                        cardinality: Cardinality::Many,
2480                        pointers: prepend_type(nodes),
2481                        has_implicit_id: shape_has_implicit_id(shape),
2482                    }),
2483                }
2484            }
2485            IrArraySource::PathSelect(ps) => match &ps.result {
2486                IrPathResult::Object {
2487                    alias,
2488                    type_name,
2489                    shape,
2490                } => {
2491                    let (_, pointer_nodes) = build_shape(shape, alias);
2492                    ShapeNode::Array {
2493                        name: name.to_string(),
2494                        position,
2495                        element: Box::new(ShapeNode::Object {
2496                            name: String::new(),
2497                            type_name: Some(type_name.clone()),
2498                            position: 0,
2499                            cardinality: Cardinality::Many,
2500                            pointers: prepend_type(pointer_nodes),
2501                            has_implicit_id: shape_has_implicit_id(shape),
2502                        }),
2503                    }
2504                }
2505                IrPathResult::Scalar(..) => ShapeNode::Scalar {
2506                    name: name.to_string(),
2507                    position,
2508                },
2509            },
2510            _ => ShapeNode::Scalar {
2511                name: name.to_string(),
2512                position,
2513            },
2514        },
2515        e if is_raw_scalar(e) => ShapeNode::RawScalar,
2516        e => free_field_shape_node(name, position, e),
2517    }
2518}
2519
2520fn free_item_shape(item: &IrFreeExpr, ctes: &[IrCteDef]) -> crate::query::ShapeNode {
2521    use crate::query::{Cardinality, ShapeNode};
2522    match item {
2523        IrFreeExpr::Scalar(e) => match e {
2524            // Top level: the result column *is* the jsonb value rather than a
2525            // field inside a row tuple. A tuple-shaped cast is excluded because
2526            // `expr_shape_node` decodes that as a `NamedTuple`.
2527            IrExpr::TypeCast(c) if c.pg_type == "jsonb" && c.tuple_shape.is_none() => ShapeNode::JsonScalar,
2528            _ => expr_shape_node("", 0, e),
2529        },
2530        IrFreeExpr::FreeObject(fields) => ShapeNode::Object {
2531            name: String::new(),
2532            type_name: None,
2533            position: 0,
2534            cardinality: Cardinality::Many,
2535            pointers: fields
2536                .iter()
2537                .enumerate()
2538                .map(|(i, (name, e))| free_field_shape_node(name, i, e))
2539                .collect(),
2540            has_implicit_id: false,
2541        },
2542        IrFreeExpr::Tuple(exprs) => ShapeNode::Tuple {
2543            position: 0,
2544            elements: exprs
2545                .iter()
2546                .enumerate()
2547                .map(|(i, e)| free_field_shape_node("", i, e))
2548                .collect(),
2549            names: None,
2550        },
2551        IrFreeExpr::NamedTupleRow(fields) => ShapeNode::Tuple {
2552            position: 0,
2553            elements: fields
2554                .iter()
2555                .enumerate()
2556                .map(|(i, (name, e))| free_field_shape_node(name, i, e))
2557                .collect(),
2558            names: Some(fields.iter().map(|(name, _)| name.clone()).collect()),
2559        },
2560        IrFreeExpr::AssertSet { .. } => ShapeNode::Scalar {
2561            name: String::new(),
2562            position: 0,
2563        },
2564        // The referenced CTE's own `result` column carries whatever shape its
2565        // defining free row has (a bare scalar, a multi-field free object, a
2566        // tuple, or even another passthrough) — resolve it by looking the CTE
2567        // up rather than assuming Scalar, otherwise a multi-field free object
2568        // bound in a `WITH` clause decodes as just its first field's value.
2569        IrFreeExpr::CtePassthrough(name) => ctes
2570            .iter()
2571            .find(|c| &c.name == name)
2572            .and_then(|c| match &c.stmt {
2573                IrStmt::Select(sel) => match sel.rows.first() {
2574                    Some(IrRowSource::Free(inner)) => Some(free_item_shape(inner, ctes)),
2575                    _ => None,
2576                },
2577                _ => None,
2578            })
2579            .unwrap_or(ShapeNode::Scalar {
2580                name: String::new(),
2581                position: 0,
2582            }),
2583    }
2584}
2585
2586// ── PATH SELECT ─────────────────────────────────────────────────────────────
2587
2588fn emit_path_joins(root: &IrSource, joins: &[IrPathJoin]) -> String {
2589    let mut parts = vec![format!("{} AS {}", source_ref(root), qi(&root.alias))];
2590    for join in joins {
2591        match join {
2592            IrPathJoin::Single {
2593                source_alias,
2594                fk_col,
2595                target,
2596            } => {
2597                parts.push(format!(
2598                    "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2599                    source_ref(target),
2600                    qi(&target.alias),
2601                    qi(source_alias),
2602                    qi(fk_col),
2603                    qi(&target.alias),
2604                ));
2605            }
2606            IrPathJoin::Multi {
2607                source_alias,
2608                junction_alias,
2609                join,
2610                target,
2611            } => {
2612                match join {
2613                    IrMultiLinkJoin::Standard { junction_table, module } => {
2614                        parts.push(format!(
2615                            "JOIN {} AS {} ON {}.\"source\" = {}.\"id\"",
2616                            junction_ref(module, junction_table),
2617                            qi(junction_alias),
2618                            qi(junction_alias),
2619                            qi(source_alias),
2620                        ));
2621                        parts.push(format!(
2622                            "JOIN {} AS {} ON {}.\"id\" = {}.\"target\"",
2623                            source_ref(target),
2624                            qi(&target.alias),
2625                            qi(&target.alias),
2626                            qi(junction_alias),
2627                        ));
2628                    }
2629                    IrMultiLinkJoin::Through {
2630                        junction_table,
2631                        module,
2632                        source_col,
2633                        target_col,
2634                    } => {
2635                        parts.push(format!(
2636                            "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2637                            junction_ref(module, junction_table),
2638                            qi(junction_alias),
2639                            qi(junction_alias),
2640                            qi(source_col),
2641                            qi(source_alias),
2642                        ));
2643                        parts.push(format!(
2644                            "JOIN {} AS {} ON {}.\"id\" = {}.{}",
2645                            source_ref(target),
2646                            qi(&target.alias),
2647                            qi(&target.alias),
2648                            qi(junction_alias),
2649                            qi(target_col),
2650                        ));
2651                    }
2652                    // See the identical comment in `emit_path_join_sql` above.
2653                    IrMultiLinkJoin::BacklinkFk { .. } | IrMultiLinkJoin::BacklinkJunction { .. } => {
2654                        unreachable!("a forward multi-link path step never uses a backlink join variant")
2655                    }
2656                }
2657            }
2658            IrPathJoin::BacklinkSingle {
2659                source_alias,
2660                fk_col,
2661                target,
2662            } => {
2663                parts.push(format!(
2664                    "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2665                    source_ref(target),
2666                    qi(&target.alias),
2667                    qi(&target.alias),
2668                    qi(fk_col),
2669                    qi(source_alias),
2670                ));
2671            }
2672            // LATERAL, not a plain join: the arguments read from the alias
2673            // the traversal has reached, which a non-lateral function call
2674            // in FROM cannot see.
2675            IrPathJoin::Function {
2676                fn_module,
2677                fn_name,
2678                args,
2679                target,
2680            } => {
2681                let args_sql = args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
2682                parts.push(format!(
2683                    "CROSS JOIN LATERAL {}.{}({}) AS {}",
2684                    pg_schema(fn_module),
2685                    qi(fn_name),
2686                    args_sql,
2687                    qi(&target.alias),
2688                ));
2689            }
2690            IrPathJoin::Lateral { inner, target } => {
2691                let projected = match &inner.result {
2692                    IrPathResult::Object { alias, .. } => alias.clone(),
2693                    IrPathResult::Scalar(..) => {
2694                        unreachable!("a lateral step is only built for an object-valued computed")
2695                    }
2696                };
2697                let mut body = format!(
2698                    "SELECT {}.* FROM {}",
2699                    qi(&projected),
2700                    emit_path_joins(&inner.root, &inner.joins)
2701                );
2702                append_filter(&mut body, &inner.filter);
2703                append_order_by(&mut body, &inner.order_by);
2704                append_offset_limit(&mut body, &inner.offset, &inner.limit);
2705                parts.push(format!("JOIN LATERAL (\n{}\n) AS {} ON TRUE", body, qi(&target.alias)));
2706            }
2707            IrPathJoin::BacklinkMulti {
2708                source_alias,
2709                junction_alias,
2710                junction_table,
2711                module,
2712                owner_col,
2713                current_col,
2714                target,
2715            } => {
2716                parts.push(format!(
2717                    "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2718                    junction_ref(module, junction_table),
2719                    qi(junction_alias),
2720                    qi(junction_alias),
2721                    qi(current_col),
2722                    qi(source_alias),
2723                ));
2724                parts.push(format!(
2725                    "JOIN {} AS {} ON {}.\"id\" = {}.{}",
2726                    source_ref(target),
2727                    qi(&target.alias),
2728                    qi(&target.alias),
2729                    qi(junction_alias),
2730                    qi(owner_col),
2731                ));
2732            }
2733        }
2734    }
2735    parts.join("\n")
2736}
2737
2738/// Emit `ARRAY(SELECT scalar FROM source [JOINs] [WHERE filter])`.
2739fn emit_array_source(src: &IrArraySource) -> String {
2740    match src {
2741        IrArraySource::Select(s) => {
2742            // compile_subquery_to_array_source only ever constructs this
2743            // variant for a single schema-bound row.
2744            let [IrRowSource::Bound { source, shape }] = s.rows.as_slice() else {
2745                unreachable!("IrArraySource::Select is always schema-bound")
2746            };
2747            let scalar = match shape.first() {
2748                Some(IrShapePointer::Scalar(sf)) => format!("{}.{}", qi(&source.alias), qi(&sf.column)),
2749                _ => format!("{}.\"id\"", qi(&source.alias)),
2750            };
2751            let mut sql = format!("SELECT {} FROM {} AS {}", scalar, source_ref(source), qi(&source.alias));
2752            append_filter(&mut sql, &s.filter);
2753            format!("ARRAY({})", sql)
2754        }
2755        IrArraySource::ObjectFunction(fs) => {
2756            let (exprs, _) = build_shape(&fs.shape, &fs.alias);
2757            let mut parts = vec![sql_str(&fs.type_name) + "::text"];
2758            parts.extend(exprs);
2759            let args_sql = fs.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
2760            let mut sql = format!(
2761                "SELECT (\n    {}\n) FROM {}.{}({}) AS {}",
2762                parts.join(",\n    "),
2763                pg_schema(&fs.fn_module),
2764                qi(&fs.fn_name),
2765                args_sql,
2766                qi(&fs.alias),
2767            );
2768            append_filter(&mut sql, &fs.filter);
2769            append_order_by(&mut sql, &fs.order_by);
2770            append_offset_limit(&mut sql, &fs.offset, &fs.limit);
2771            format!("ARRAY({})", sql)
2772        }
2773        IrArraySource::ObjectSelect(s) => {
2774            let [IrRowSource::Bound { source, shape }] = s.rows.as_slice() else {
2775                unreachable!("IrArraySource::ObjectSelect is always schema-bound")
2776            };
2777            let (exprs, _) = build_shape(shape, &source.alias);
2778            let mut parts = vec![source_type_disc(source)];
2779            parts.extend(exprs);
2780            let mut sql = format!(
2781                "SELECT (\n    {}\n) FROM {} AS {}",
2782                parts.join(",\n    "),
2783                source_ref(source),
2784                qi(&source.alias)
2785            );
2786            append_filter(&mut sql, &s.filter);
2787            append_order_by(&mut sql, &s.order_by);
2788            append_offset_limit(&mut sql, &s.offset, &s.limit);
2789            format!("ARRAY({})", sql)
2790        }
2791        IrArraySource::Group(grp) => format!("ARRAY({})", emit_group(grp).sql),
2792        IrArraySource::StmtColumn { stmt, column } => format!(
2793            "ARRAY(SELECT {} FROM (\n{}\n) AS \"_rows\")",
2794            qi(column),
2795            emit_dml_as_cte_source(stmt),
2796        ),
2797        IrArraySource::PathSelect(ps) => {
2798            let scalar = match &ps.result {
2799                IrPathResult::Scalar(e, _) => emit_expr(e),
2800                // Aggregating objects keeps the whole row, not the id: this is
2801                // how a computed pointer that walks through a multi-link comes
2802                // back as hydrated rows rather than a list of uuids.
2803                IrPathResult::Object {
2804                    alias,
2805                    type_name,
2806                    shape,
2807                } => {
2808                    let (pointer_exprs, _) = build_shape(shape, alias);
2809                    let mut parts = vec![result_type_disc(ps, alias, type_name)];
2810                    parts.extend(pointer_exprs);
2811                    format!("(\n    {}\n)", parts.join(",\n    "))
2812                }
2813            };
2814            let from_sql = emit_path_joins(&ps.root, &ps.joins);
2815            let mut sql = format!(
2816                "SELECT {}{} FROM {}",
2817                if ps.distinct { "DISTINCT " } else { "" },
2818                scalar,
2819                from_sql
2820            );
2821            append_filter(&mut sql, &ps.filter);
2822            append_order_by(&mut sql, &ps.order_by);
2823            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
2824            format!("ARRAY({})", sql)
2825        }
2826        IrArraySource::RawExpr {
2827            source,
2828            poly_implementors,
2829            poly_columns,
2830            expr,
2831        } => {
2832            let from_sql = if !poly_implementors.is_empty() {
2833                format!(
2834                    "(\n{}\n) AS {}",
2835                    emit_poly_union(poly_implementors, poly_columns),
2836                    qi(&source.alias)
2837                )
2838            } else {
2839                format!("{} AS {}", source_ref(source), qi(&source.alias))
2840            };
2841            format!("ARRAY(SELECT {} FROM {})", emit_expr(expr), from_sql)
2842        }
2843    }
2844}
2845
2846/// Emit a group-key expression, casting schema-qualified enum ColumnRefs to `::text`
2847/// so they decode outside of a typed composite.
2848fn emit_key_expr(expr: &IrExpr) -> String {
2849    if let IrExpr::ColumnRef { alias, column, pg_type } = expr
2850        && let Some(qualified) = QualifiedPgType::of(pg_type)
2851    {
2852        let col_ref = if alias.is_empty() {
2853            qi(column)
2854        } else {
2855            format!("{}.{}", qi(alias), qi(column))
2856        };
2857        return format!("{}{}", col_ref, qualified.text_cast());
2858    }
2859    emit_expr(expr)
2860}
2861
2862/// The rows a group reads, as `(FROM, WHERE)`. A per-group OFFSET/LIMIT
2863/// can't be a plain LIMIT — that would cut whole groups — so the rows are
2864/// ranked within each key first and only the wanted slice kept.
2865fn group_rows(grp: &IrGroup) -> (String, Option<String>) {
2866    let alias = &grp.source.alias;
2867    if grp.limit.is_none() && grp.offset.is_none() {
2868        return (
2869            format!("{} AS {}", source_ref(&grp.source), qi(alias)),
2870            grp.filter.as_ref().map(emit_expr),
2871        );
2872    }
2873    let ranked = format!(
2874        "(SELECT {}.*, row_number() OVER (PARTITION BY {}{}) AS \"__rk\"\n    FROM {} AS {}{}) AS {}",
2875        qi(alias),
2876        group_by_sql(grp),
2877        group_element_order(grp),
2878        source_ref(&grp.source),
2879        qi(alias),
2880        grp.filter
2881            .as_ref()
2882            .map(|f| format!("\n    WHERE {}", emit_expr(f)))
2883            .unwrap_or_default(),
2884        qi(alias),
2885    );
2886    let lower = grp.offset.as_ref().map(emit_expr).unwrap_or_else(|| "0".to_string());
2887    let mut conds = vec![format!("\"__rk\" > {}", lower)];
2888    if let Some(l) = &grp.limit {
2889        conds.push(format!("\"__rk\" <= {} + {}", lower, emit_expr(l)));
2890    }
2891    (ranked, Some(conds.join(" AND ")))
2892}
2893
2894fn group_by_sql(grp: &IrGroup) -> String {
2895    grp.keys
2896        .iter()
2897        .map(|(_, key_expr)| emit_expr(key_expr))
2898        .collect::<Vec<_>>()
2899        .join(", ")
2900}
2901
2902fn group_element_order(grp: &IrGroup) -> String {
2903    if grp.order_by.is_empty() {
2904        return String::new();
2905    }
2906    let s: Vec<_> = grp.order_by.iter().map(emit_sort_clause).collect();
2907    format!(" ORDER BY {}", s.join(", "))
2908}
2909
2910/// A `for` over a group's elements used as a CTE body: the rows themselves,
2911/// so the outer query can traverse them like any object binding.
2912fn emit_group_elements_source(grp: &IrGroup) -> String {
2913    let (from_sql, cond) = group_rows(grp);
2914    let mut sql = format!("SELECT {}.*\n    FROM {}", qi(&grp.source.alias), from_sql);
2915    if let Some(cond) = cond {
2916        sql.push_str(&format!("\n    WHERE {}", cond));
2917    }
2918    sql
2919}
2920
2921fn emit_group(grp: &IrGroup) -> SqlOutput {
2922    match &grp.output {
2923        IrGroupOutput::Groups => emit_group_rows(grp),
2924        IrGroupOutput::Projection(projection) => emit_group_projection(
2925            grp,
2926            &projection.pointers,
2927            &projection.order_by,
2928            &projection.offset,
2929            &projection.limit,
2930        ),
2931        IrGroupOutput::Elements => emit_group_elements(grp),
2932    }
2933}
2934
2935fn emit_group_projection(
2936    grp: &IrGroup,
2937    pointers: &[IrShapePointer],
2938    order_by: &[IrSort],
2939    offset: &Option<IrExpr>,
2940    limit: &Option<IrExpr>,
2941) -> SqlOutput {
2942    let (exprs, nodes) = build_shape(pointers, &grp.source.alias);
2943    let mut parts = vec!["NULL::text".to_string()];
2944    parts.extend(exprs);
2945    let (from_sql, cond) = group_rows(grp);
2946    let mut sql = format!(
2947        "SELECT (\n    {}\n) AS \"result\"\nFROM {}",
2948        parts.join(",\n    "),
2949        from_sql
2950    );
2951    if let Some(cond) = cond {
2952        sql.push_str(&format!("\nWHERE {}", cond));
2953    }
2954    sql.push_str(&format!("\nGROUP BY {}", group_by_sql(grp)));
2955    if !order_by.is_empty() {
2956        let sorts: Vec<_> = order_by.iter().map(emit_sort_clause).collect();
2957        sql.push_str(&format!("\nORDER BY {}", sorts.join(", ")));
2958    }
2959    append_offset_limit(&mut sql, offset, limit);
2960    SqlOutput {
2961        sql,
2962        shape: ShapeDescriptor {
2963            root: ShapeNode::Object {
2964                name: String::new(),
2965                type_name: None,
2966                position: 0,
2967                cardinality: Cardinality::Many,
2968                pointers: nodes,
2969                has_implicit_id: false,
2970            },
2971        },
2972        inference_plan: None,
2973    }
2974}
2975
2976fn emit_group_elements(grp: &IrGroup) -> SqlOutput {
2977    let (exprs, nodes) = build_shape(&grp.shape, &grp.source.alias);
2978    let mut parts = vec![source_type_disc(&grp.source)];
2979    parts.extend(exprs);
2980    let (from_sql, cond) = group_rows(grp);
2981    let mut sql = format!(
2982        "SELECT (\n    {}\n) AS \"result\"\nFROM {}",
2983        parts.join(",\n    "),
2984        from_sql
2985    );
2986    if let Some(cond) = cond {
2987        sql.push_str(&format!("\nWHERE {}", cond));
2988    }
2989    SqlOutput {
2990        sql,
2991        shape: ShapeDescriptor {
2992            root: ShapeNode::Object {
2993                name: String::new(),
2994                type_name: Some(grp.source.type_name.clone()),
2995                position: 0,
2996                cardinality: Cardinality::Many,
2997                pointers: prepend_type(nodes),
2998                has_implicit_id: shape_has_implicit_id(&grp.shape),
2999            },
3000        },
3001        inference_plan: None,
3002    }
3003}
3004
3005fn emit_group_rows(grp: &IrGroup) -> SqlOutput {
3006    let alias = &grp.source.alias;
3007    let (shape_exprs, shape_nodes) = build_shape(&grp.shape, alias);
3008
3009    // Build the elements ROW: type discriminator at pos 0, then shape pointers.
3010    let mut elem_row_parts = vec![source_type_disc(&grp.source)];
3011    elem_row_parts.extend(shape_exprs);
3012    let elem_row = elem_row_parts.join(",\n            ");
3013
3014    // Key positions: NULL at pos 0 (type slot), keys at 1..=N, grouping at N+1, elements at N+2.
3015    let n_keys = grp.keys.len();
3016    let grouping_pos = n_keys + 1;
3017    let elements_pos = n_keys + 2;
3018
3019    // Build key SQL expressions and ShapeNodes.
3020    let mut key_exprs_sql: Vec<String> = vec![];
3021    let mut key_nodes: Vec<ShapeNode> = vec![];
3022    for (i, (key_name, key_expr)) in grp.keys.iter().enumerate() {
3023        let pos = i + 1;
3024        if let IrExpr::ColumnRef { pg_type, .. } = key_expr
3025            && let Some(qualified) = QualifiedPgType::of(pg_type)
3026        {
3027            key_exprs_sql.push(emit_key_expr(key_expr));
3028            key_nodes.push(qualified.shape_node(key_name.clone(), pos));
3029            continue;
3030        }
3031        key_exprs_sql.push(emit_expr(key_expr));
3032        key_nodes.push(ShapeNode::Scalar {
3033            name: key_name.clone(),
3034            position: pos,
3035        });
3036    }
3037
3038    // Build the outer SELECT tuple.
3039    let mut outer_parts = vec!["NULL::text".to_string()];
3040    outer_parts.extend(key_exprs_sql.clone());
3041    let key_names_sql = grp
3042        .keys
3043        .iter()
3044        .map(|(name, _)| format!("'{}'", name))
3045        .collect::<Vec<_>>()
3046        .join(", ");
3047    outer_parts.push(format!("ARRAY[{}]::text[]", key_names_sql));
3048    outer_parts.push(format!(
3049        "array_agg(ROW(\n            {}\n        )::record{})",
3050        elem_row,
3051        group_element_order(grp)
3052    ));
3053
3054    let outer_tuple = outer_parts.join(",\n    ");
3055    let (from_sql, cond) = group_rows(grp);
3056    let mut sql = format!("SELECT (\n    {}\n) AS \"result\"\nFROM {}", outer_tuple, from_sql,);
3057    if let Some(cond) = cond {
3058        sql.push_str(&format!("\nWHERE {}", cond));
3059    }
3060    sql.push_str(&format!("\nGROUP BY {}", group_by_sql(grp)));
3061
3062    // ShapeNode for each element (Object with the selected pointers).
3063    let element_node = ShapeNode::Object {
3064        name: String::new(),
3065        type_name: Some(grp.source.type_name.clone()),
3066        position: 0,
3067        cardinality: Cardinality::Many,
3068        pointers: prepend_type(shape_nodes),
3069        has_implicit_id: shape_has_implicit_id(&grp.shape),
3070    };
3071
3072    let root = ShapeNode::Group {
3073        key_nodes,
3074        grouping_position: grouping_pos,
3075        elements_position: elements_pos,
3076        element: Box::new(element_node),
3077    };
3078
3079    SqlOutput {
3080        sql,
3081        shape: ShapeDescriptor { root },
3082        inference_plan: None,
3083    }
3084}
3085
3086fn emit_poly_union_type_only(implementors: &[IrPolyImplementor]) -> String {
3087    implementors
3088        .iter()
3089        .map(|imp| {
3090            format!(
3091                "    SELECT {}::text AS \"__type__\" FROM {}",
3092                sql_str(&imp.type_name),
3093                qn(&imp.module, &imp.table),
3094            )
3095        })
3096        .collect::<Vec<_>>()
3097        .join("\n    UNION ALL\n")
3098}
3099
3100fn emit_path_select(sel: &IrPathSelect) -> SqlOutput {
3101    let distinct = if sel.distinct { "DISTINCT " } else { "" };
3102    let from_sql = if !sel.poly_implementors.is_empty() {
3103        format!(
3104            "(\n{}\n) AS {}",
3105            emit_poly_union_type_only(&sel.poly_implementors),
3106            qi(&sel.root.alias),
3107        )
3108    } else {
3109        emit_path_joins(&sel.root, &sel.joins)
3110    };
3111
3112    let (result_expr, shape_root) = match &sel.result {
3113        IrPathResult::Scalar(ir_expr, tuple_shape) => {
3114            // Named tuples / jsonb field accesses can't be decoded inside ROW() — emit raw.
3115            let is_nt = matches!(ir_expr, IrExpr::NamedTuple { .. })
3116                || matches!(ir_expr, IrExpr::Tuple(_))
3117                || matches!(ir_expr, IrExpr::JsonbField { .. })
3118                || matches!(ir_expr, IrExpr::JsonbIndex { .. })
3119                || matches!(ir_expr, IrExpr::ColumnRef { pg_type, .. } if pg_type.starts_with("__nt__:"))
3120                || tuple_shape.is_some();
3121            if is_nt {
3122                let expr_sql = format!("{} AS result", emit_expr(ir_expr));
3123                let shape = if matches!(
3124                    ir_expr,
3125                    IrExpr::JsonbField { .. } | IrExpr::JsonbIndex { .. } | IrExpr::Tuple(_)
3126                ) {
3127                    ShapeNode::RawScalar
3128                } else if let Some(shape) = tuple_shape {
3129                    // A bare tuple-typed property reference (nominal or
3130                    // structural) — real member shape already resolved at
3131                    // compile time (see resolve_property_tuple_shape), same
3132                    // as a `Type { tuple_property }` shape query gets via
3133                    // emit_scalar, instead of falling back to `members: None`.
3134                    ShapeNode::NamedTuple {
3135                        name: String::new(),
3136                        position: 0,
3137                        type_name: shape.type_name.clone(),
3138                        members: Some(shape.members.clone()),
3139                        is_free_object: false,
3140                    }
3141                } else {
3142                    let type_name = match ir_expr {
3143                        IrExpr::ColumnRef { pg_type, .. } => pg_type.strip_prefix("__nt__:").map(|s| s.to_string()),
3144                        _ => None,
3145                    };
3146                    ShapeNode::NamedTuple {
3147                        name: String::new(),
3148                        position: 0,
3149                        type_name,
3150                        members: None,
3151                        is_free_object: false,
3152                    }
3153                };
3154                (expr_sql, shape)
3155            } else {
3156                // Schema-qualified types (enums, domains) have unknown OIDs inside ROW() —
3157                // cast to text so the value's shape doesn't depend on a runtime OID.
3158                if let IrExpr::ColumnRef { pg_type, .. } = ir_expr {
3159                    if let Some(qualified) = QualifiedPgType::of(pg_type) {
3160                        let expr = format!("ROW({}{}) AS result", emit_expr(ir_expr), qualified.text_cast());
3161                        let shape = qualified.shape_node(String::new(), 0);
3162                        (expr, shape)
3163                    } else {
3164                        let expr = format!("ROW({}) AS result", emit_expr(ir_expr));
3165                        (
3166                            expr,
3167                            ShapeNode::Scalar {
3168                                name: String::new(),
3169                                position: 0,
3170                            },
3171                        )
3172                    }
3173                } else {
3174                    let expr = format!("ROW({}) AS result", emit_expr(ir_expr));
3175                    (
3176                        expr,
3177                        ShapeNode::Scalar {
3178                            name: String::new(),
3179                            position: 0,
3180                        },
3181                    )
3182                }
3183            }
3184        }
3185        IrPathResult::Object {
3186            alias,
3187            type_name,
3188            shape,
3189        } => {
3190            let (pointer_exprs, pointer_nodes) = build_shape(shape, alias);
3191            let mut parts = vec![result_type_disc(sel, alias, type_name)];
3192            parts.extend(pointer_exprs);
3193            let expr = format!("(\n    {}\n) AS result", parts.join(",\n    "));
3194            let shape_root = ShapeNode::Object {
3195                name: String::new(),
3196                type_name: Some(type_name.clone()),
3197                position: 0,
3198                cardinality: Cardinality::Many,
3199                pointers: prepend_type(pointer_nodes),
3200                has_implicit_id: shape_has_implicit_id(shape),
3201            };
3202            (expr, shape_root)
3203        }
3204    };
3205
3206    let mut sql = format!("SELECT {}{}\nFROM {}", distinct, result_expr, from_sql);
3207    append_filter(&mut sql, &sel.filter);
3208    append_order_by(&mut sql, &sel.order_by);
3209    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
3210
3211    SqlOutput {
3212        sql,
3213        shape: ShapeDescriptor { root: shape_root },
3214        inference_plan: None,
3215    }
3216}
3217
3218// ── FOR LOOP ─────────────────────────────────────────────────────────────────
3219
3220/// The loop's iterator as a relation with a single column named `v`, in both
3221/// the forms the two body kinds need: one for a `FROM` clause, one for a CTE.
3222/// True for an expression PostgreSQL hands back as `jsonb`, whose value a
3223/// scalar cast has to reach into rather than render.
3224fn yields_jsonb(expr: &IrExpr) -> bool {
3225    match expr {
3226        IrExpr::JsonbField { .. } | IrExpr::JsonbIndex { .. } | IrExpr::NamedTuple { .. } => true,
3227        IrExpr::TypeCast(c) => c.pg_type == "jsonb",
3228        IrExpr::ColumnRef { pg_type, .. } | IrExpr::FnParam { pg_type, .. } => pg_type == "jsonb",
3229        IrExpr::CteRef { pg_type, .. } | IrExpr::ForVar { pg_type, .. } => pg_type.as_deref() == Some("jsonb"),
3230        // A call that recorded a return type answers from it, which is the
3231        // only thing that speaks for one reached through `_pylon` or a schema
3232        // module — the registry scan below only recognises an inline builtin.
3233        IrExpr::FunctionCall(f) if f.return_pg_type.is_some() => f.return_pg_type.as_deref() == Some("jsonb"),
3234        IrExpr::FunctionCall(f) if f.schema.is_none() => {
3235            let mut overloads = crate::stdlib::registry().iter().filter(|d| d.name == f.name).peekable();
3236            overloads.peek().is_some() && overloads.all(|d| matches!(d.return_type, crate::stdlib::PylonType::Json))
3237        }
3238        _ => false,
3239    }
3240}
3241
3242fn emit_for_iterator(it: &IrForIterator, iter_alias: &str) -> (String, String) {
3243    match it {
3244        IrForIterator::Values { exprs, pg_type } => {
3245            let rows: Vec<String> = exprs
3246                .iter()
3247                .map(|e| format!("({}::{})", emit_expr(e), pg_type))
3248                .collect();
3249            (
3250                format!("(VALUES {}) AS {}(\"v\")", rows.join(", "), qi(iter_alias)),
3251                format!("{}(\"v\") AS (VALUES {})", qi(iter_alias), rows.join(", ")),
3252            )
3253        }
3254        IrForIterator::Query { stmt, scalar } => {
3255            let inner = emit_dml_as_cte_source(stmt);
3256            // A scalar-yielding select already exposes `v` (the same
3257            // convention a scalar `with` binding uses); an object-yielding
3258            // one exposes its raw columns, so the loop variable binds `id`.
3259            let body = if *scalar {
3260                inner
3261            } else {
3262                format!("    SELECT \"id\" AS v FROM (\n{}\n    ) AS _src", inner)
3263            };
3264            (
3265                format!("(\n{}\n) AS {}", body, qi(iter_alias)),
3266                format!("{} AS (\n{}\n)", qi(iter_alias), body),
3267            )
3268        }
3269        IrForIterator::SetReturning { expr, pg_type } => {
3270            let body = format!("    SELECT {}::{} AS v", emit_expr(expr), pg_type);
3271            (
3272                format!("(\n{}\n) AS {}", body, qi(iter_alias)),
3273                format!("{} AS (\n{}\n)", qi(iter_alias), body),
3274            )
3275        }
3276    }
3277}
3278
3279/// The iterator CTE of a `for` nested in another one. The enclosing iterator
3280/// goes in its FROM, so the walk it iterates can read the outer loop variable,
3281/// and its key rides along as `_outer` for the body to join back on — the bond
3282/// that holds a chain of iterator CTEs together.
3283fn emit_nested_for_iterator(it: &IrForIterator, iter_alias: &str, outer_alias: &str) -> String {
3284    const OUTER: &str = "_outer";
3285    match it {
3286        IrForIterator::Values { exprs, pg_type } => {
3287            let rows: Vec<String> = exprs
3288                .iter()
3289                .map(|e| format!("({}::{})", emit_expr(e), pg_type))
3290                .collect();
3291            format!(
3292                "{} AS (\nSELECT {}.\"v\" AS {}, \"_vals\".\"v\" AS v\nFROM {}, (VALUES {}) AS \"_vals\"(\"v\")\n)",
3293                qi(iter_alias),
3294                qi(outer_alias),
3295                qi(OUTER),
3296                qi(outer_alias),
3297                rows.join(", "),
3298            )
3299        }
3300        IrForIterator::Query { stmt, scalar } => {
3301            let inner = emit_dml_as_cte_source(stmt);
3302            let projected = if *scalar { "\"_src\".\"v\"" } else { "\"_src\".\"id\"" };
3303            format!(
3304                "{} AS (\nSELECT {}.\"v\" AS {}, {} AS v\nFROM {}\nCROSS JOIN LATERAL (\n{}\n) AS \"_src\"\n)",
3305                qi(iter_alias),
3306                qi(outer_alias),
3307                qi(OUTER),
3308                projected,
3309                qi(outer_alias),
3310                inner,
3311            )
3312        }
3313        IrForIterator::SetReturning { expr, pg_type } => format!(
3314            "{} AS (\nSELECT {}.\"v\" AS {}, \"_vals\".\"v\" AS v\nFROM {}, LATERAL (SELECT {}::{} AS v) AS \"_vals\"\n)",
3315            qi(iter_alias),
3316            qi(outer_alias),
3317            qi(OUTER),
3318            qi(outer_alias),
3319            emit_expr(expr),
3320            pg_type,
3321        ),
3322    }
3323}
3324
3325/// `FROM <inner iterator> JOIN <outer iterator> ON …` — both loop variables in
3326/// scope for the body, each still read as `"_for_<name>"."v"`.
3327fn nested_for_from(inner_alias: &str, outer_alias: &str) -> String {
3328    format!(
3329        "{} JOIN {} ON {}.\"v\" = {}.\"_outer\"",
3330        qi(inner_alias),
3331        qi(outer_alias),
3332        qi(outer_alias),
3333        qi(inner_alias),
3334    )
3335}
3336
3337/// Put the bindings a `for` body declared in front of the body itself: they may
3338/// read the loop variable, so they cannot sit in the enclosing WITH clause.
3339/// A `for` body's CTEs as they must read inside the LATERAL that runs the body.
3340///
3341/// `emit_user_cte_parts` keys a binding that reads the loop variable by the
3342/// iteration, joining `_for_<slot>` to carry its key — right where the binding
3343/// sits in the statement's own WITH beside the iterator CTE, as the DML forms
3344/// emit it. Inside the LATERAL there is no such relation: `_for_<slot>` is the
3345/// outer FROM item's alias, so `FROM "_for_<slot>"` is SQL PostgreSQL rejects
3346/// ("relation does not exist"), and spelling it legally would widen the binding
3347/// to every iteration's rows at once. The LATERAL has already pinned the
3348/// iteration to one row, and the correlated column reference the body carries
3349/// reads it, so the key is neither needed nor correct here.
3350fn uncorrelated_body_ctes(f: &IrFor) -> Vec<IrCteDef> {
3351    f.body_ctes
3352        .iter()
3353        .map(|cte| IrCteDef {
3354            correlated_to: None,
3355            ..cte.clone()
3356        })
3357        .collect()
3358}
3359
3360fn prefix_body_ctes(sql: String, body_ctes: &[IrCteDef]) -> String {
3361    if body_ctes.is_empty() {
3362        return sql;
3363    }
3364    merge_into_existing_with(&sql, &emit_user_cte_parts(body_ctes))
3365        .unwrap_or_else(|| format!("{}{}", emit_cte_prefix(body_ctes), sql))
3366}
3367
3368fn emit_for_stmt(f: &IrFor, user_ctes: &[IrCteDef]) -> SqlOutput {
3369    let iter_alias = format!("_for_{}", f.var_name);
3370
3371    if matches!(&f.iterator, IrForIterator::Values { exprs, .. } if exprs.is_empty()) {
3372        let empty = SqlOutput {
3373            sql: "SELECT NULL AS result WHERE FALSE".to_string(),
3374            shape: ShapeDescriptor {
3375                root: ShapeNode::Scalar {
3376                    name: String::new(),
3377                    position: 0,
3378                },
3379            },
3380            inference_plan: None,
3381        };
3382        return empty;
3383    }
3384
3385    let (values_from, iter_cte) = emit_for_iterator(&f.iterator, &iter_alias);
3386
3387    match f.body.as_ref() {
3388        IrStmt::Insert(ins) => emit_for_insert(ins, &iter_alias, &iter_cte, user_ctes, &f.body_ctes),
3389        IrStmt::Update(upd) => emit_for_update(upd, &iter_alias, &iter_cte, user_ctes, &f.body_ctes),
3390        body => {
3391            let body_out = match body {
3392                IrStmt::Select(sel) => emit_select_stmt(sel, user_ctes),
3393                IrStmt::PathSelect(sel) => emit_path_select(sel),
3394                // Unreachable: `Compiler::compile_for` rejects every other
3395                // body kind with a PyQL error before an `IrFor` is built.
3396                other => unreachable!("for-loop body should have been rejected at compile time: {other:?}"),
3397            };
3398            let indent_body = prefix_body_ctes(body_out.sql, &uncorrelated_body_ctes(f)).replace('\n', "\n    ");
3399            let cte_prefix = if !user_ctes.is_empty() {
3400                emit_cte_prefix(user_ctes)
3401            } else {
3402                String::new()
3403            };
3404            let sql = format!(
3405                "{}SELECT \"_body\".result\nFROM {}\nCROSS JOIN LATERAL (\n    {}\n) AS \"_body\"",
3406                cte_prefix, values_from, indent_body,
3407            );
3408            SqlOutput {
3409                sql,
3410                shape: body_out.shape,
3411                inference_plan: None,
3412            }
3413        }
3414    }
3415}
3416
3417/// A `for` whose body updates: driven from the iteration with `UPDATE … FROM`,
3418/// since a LATERAL -- what the select-bodied form uses -- cannot hold DML.
3419/// Both the SET clause and the filter may read the loop variable, and both are
3420/// already compiled against the iteration alias.
3421fn emit_for_update(
3422    upd: &IrUpdate,
3423    iter_alias: &str,
3424    iter_cte: &str,
3425    user_ctes: &[IrCteDef],
3426    body_ctes: &[IrCteDef],
3427) -> SqlOutput {
3428    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
3429    cte_parts.extend(emit_user_cte_parts(body_ctes));
3430    cte_parts.push(iter_cte.to_string());
3431
3432    let alias = &upd.target.alias;
3433    // With only junction rows to write there is nothing to SET, so the driving
3434    // rows are selected rather than updated -- the same shape a multi-link-only
3435    // update takes outside a loop.
3436    if upd.assignments.is_empty() && upd.rewrites.is_empty() && !upd.multi_link_appends.is_empty() {
3437        const ITER_COL: &str = "_iter";
3438        let ids_name = "_ids";
3439        // A nested insert gets its id generated here rather than by the column
3440        // default, so the junction below can name the row *this* iteration
3441        // inserted — `RETURNING` offers no order to pair on.
3442        let generated: Vec<Option<&IrInsert>> = upd
3443            .multi_link_appends
3444            .iter()
3445            .map(|a| per_iteration_insert(a, &upd.nested_ctes))
3446            .collect();
3447        let new_cols: String = generated
3448            .iter()
3449            .enumerate()
3450            .filter_map(|(i, ins)| {
3451                ins.map(|ins| {
3452                    format!(
3453                        ", {} AS {}",
3454                        ins.id_default_sql.as_deref().unwrap_or("uuidv7()"),
3455                        qi(&format!("_new_{i}"))
3456                    )
3457                })
3458            })
3459            .collect();
3460        let mut ids_sql = format!(
3461            "\"{}\" AS (\nSELECT {}.*, {}.\"v\" AS {}{} FROM {} AS {}, {}",
3462            ids_name,
3463            qi(alias),
3464            qi(iter_alias),
3465            qi(ITER_COL),
3466            new_cols,
3467            target_ref(&upd.target),
3468            qi(alias),
3469            qi(iter_alias),
3470        );
3471        append_filter(&mut ids_sql, &upd.filter);
3472        ids_sql.push_str("\n)");
3473        cte_parts.push(ids_sql);
3474        for (i, append) in upd.multi_link_appends.iter().enumerate() {
3475            match generated[i] {
3476                Some(ins) => {
3477                    cte_parts.push(emit_for_nested_insert_cte(
3478                        ins,
3479                        ids_name,
3480                        iter_alias,
3481                        &format!("_ml_ins_{i}"),
3482                        &format!("_new_{i}"),
3483                    ));
3484                    cte_parts.push(emit_for_ml_append_cte(
3485                        append,
3486                        ids_name,
3487                        &format!("_ml_add_{i}"),
3488                        &format!("_new_{i}"),
3489                    ));
3490                }
3491                None => cte_parts.push(emit_for_ml_append_cte(
3492                    append,
3493                    ids_name,
3494                    &format!("_ml_add_{i}"),
3495                    ITER_COL,
3496                )),
3497            }
3498        }
3499        let (pointer_exprs, shape_nodes) = build_shape(&upd.returning, ids_name);
3500        let mut parts = vec![type_disc(&upd.target.type_name)];
3501        parts.extend(pointer_exprs);
3502        let sql = format!(
3503            "WITH {}\nSELECT (\n    {}\n) AS result\nFROM \"{}\"",
3504            cte_parts.join(",\n"),
3505            parts.join(",\n    "),
3506            ids_name,
3507        );
3508        return SqlOutput {
3509            sql,
3510            shape: crate::query::ShapeDescriptor {
3511                root: ShapeNode::Object {
3512                    name: String::new(),
3513                    type_name: Some(upd.target.type_name.clone()),
3514                    position: 0,
3515                    cardinality: Cardinality::Many,
3516                    pointers: prepend_type(shape_nodes),
3517                    has_implicit_id: shape_has_implicit_id(&upd.returning),
3518                },
3519            },
3520            inference_plan: None,
3521        };
3522    }
3523    let sets: Vec<String> = upd
3524        .assignments
3525        .iter()
3526        .map(|(col, expr)| format!("{} = {}", qi(col), emit_expr(expr)))
3527        .chain(
3528            upd.rewrites
3529                .iter()
3530                .map(|rw| format!("{} = {}", qi(&rw.column), emit_expr(&rw.expr))),
3531        )
3532        .collect();
3533    let mut sql = format!(
3534        "WITH {}\nUPDATE {} AS {}\nSET {}\nFROM {}",
3535        cte_parts.join(",\n"),
3536        target_ref(&upd.target),
3537        qi(alias),
3538        sets.join(", "),
3539        qi(iter_alias),
3540    );
3541    append_filter(&mut sql, &upd.filter);
3542    let mut parts = vec![type_disc(&upd.target.type_name)];
3543    let (pointer_exprs, shape_nodes) = build_shape(&upd.returning, alias);
3544    parts.extend(pointer_exprs);
3545    sql.push_str(&format!("\nRETURNING (\n    {}\n) AS result", parts.join(",\n    ")));
3546    SqlOutput {
3547        sql,
3548        shape: crate::query::ShapeDescriptor {
3549            root: ShapeNode::Object {
3550                name: String::new(),
3551                type_name: Some(upd.target.type_name.clone()),
3552                position: 0,
3553                cardinality: Cardinality::Many,
3554                pointers: prepend_type(shape_nodes),
3555                has_implicit_id: shape_has_implicit_id(&upd.returning),
3556            },
3557        },
3558        inference_plan: None,
3559    }
3560}
3561
3562/// The iteration key a correlated nested CTE carries, so rows written per
3563/// iteration can be paired back with the row that iteration wrote. `RETURNING`
3564/// can only name the inserted row's own columns, so the pairing is computed in
3565/// a staging CTE beside the insert rather than read back out of it.
3566const OUTER_KEY: &str = "_outer";
3567
3568/// `SELECT <id> AS "id", <iter>."v" AS "_outer", <value> AS "<column>", …` —
3569/// the rows one iteration-driven insert will write, with their ids generated
3570/// here rather than by the column default so a junction can name them before
3571/// the insert has run.
3572fn emit_iteration_rows_cte(ins: &IrInsert, cte_name: &str, iter_alias: &str, outer_source: &str) -> String {
3573    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
3574    let mut selected = vec![
3575        format!(
3576            "{} AS {}",
3577            ins.id_default_sql.as_deref().unwrap_or("uuidv7()"),
3578            qi("id")
3579        ),
3580        format!("{} AS {}", outer_source, qi(OUTER_KEY)),
3581    ];
3582    selected.extend(
3583        ins.assignments
3584            .iter()
3585            .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3586            .map(|(c, e)| format!("{} AS {}", emit_expr(e), qi(c)))
3587            .chain(
3588                ins.rewrites
3589                    .iter()
3590                    .map(|r| format!("{} AS {}", emit_expr(&r.expr), qi(&r.column))),
3591            ),
3592    );
3593    format!(
3594        "\"{}\" AS (\nSELECT {}\nFROM {}\n)",
3595        cte_name,
3596        selected.join(", "),
3597        qi(iter_alias),
3598    )
3599}
3600
3601/// The insert that reads its rows back out of the staging CTE above.
3602fn emit_insert_from_rows_cte(ins: &IrInsert, cte_name: &str, rows_name: &str) -> String {
3603    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
3604    let cols: Vec<String> = std::iter::once(qi("id"))
3605        .chain(
3606            ins.assignments
3607                .iter()
3608                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3609                .map(|(c, _)| qi(c)),
3610        )
3611        .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
3612        .collect();
3613    let mut sql = format!(
3614        "INSERT INTO {} ({})\nSELECT {} FROM \"{}\"",
3615        target_ref(&ins.target),
3616        cols.join(", "),
3617        cols.join(", "),
3618        rows_name,
3619    );
3620    if let Some(conflict) = &ins.unless_conflict {
3621        emit_conflict(&mut sql, conflict);
3622    }
3623    sql.push_str("\nRETURNING *");
3624    format!("\"{}\" AS (\n{}\n)", cte_name, sql)
3625}
3626
3627/// A junction append whose targets were written per iteration: pair the two
3628/// staging CTEs on the iteration key rather than cross-joining every row
3629/// written against every row that wrote one.
3630fn emit_correlated_ml_append_cte(
3631    mutation: &IrMultiLinkMutation,
3632    rows_name: &str,
3633    targets_name: &str,
3634    cte_name: &str,
3635) -> String {
3636    format!(
3637        "\"{}\" AS (\nINSERT INTO {} ({}, {})\nSELECT \"_src\".{}, \"_tgt\".{} FROM \"{}\" AS \"_src\" JOIN \"{}\" AS \"_tgt\" ON \"_tgt\".{} = \"_src\".{}\nON CONFLICT DO NOTHING\nRETURNING {}, {}\n)",
3638        cte_name,
3639        qn(&mutation.module, &mutation.junction_table),
3640        qi(&mutation.source_col),
3641        qi(&mutation.target_col),
3642        qi("id"),
3643        qi("id"),
3644        rows_name,
3645        targets_name,
3646        qi(OUTER_KEY),
3647        qi(OUTER_KEY),
3648        qi(&mutation.source_col),
3649        qi(&mutation.target_col),
3650    )
3651}
3652
3653/// Which of `appends` name a CTE that wrote its rows per iteration, and so
3654/// pair on the iteration key instead of cross-joining.
3655fn correlated_append_indices(appends: &[IrMultiLinkMutation], defs: &[&[IrCteDef]]) -> Vec<usize> {
3656    appends
3657        .iter()
3658        .enumerate()
3659        .filter(|(_, append)| match &append.values.source {
3660            IrMultiLinkValueSource::CteRef(target) => defs
3661                .iter()
3662                .flat_map(|group| group.iter())
3663                .any(|c| &c.name == target && c.correlated_to.is_some()),
3664            _ => false,
3665        })
3666        .map(|(i, _)| i)
3667        .collect()
3668}
3669
3670fn emit_for_insert(
3671    ins: &IrInsert,
3672    iter_alias: &str,
3673    iter_cte: &str,
3674    user_ctes: &[IrCteDef],
3675    body_ctes: &[IrCteDef],
3676) -> SqlOutput {
3677    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
3678
3679    let cols: Vec<String> = ins
3680        .assignments
3681        .iter()
3682        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3683        .map(|(c, _)| qi(c))
3684        .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
3685        .collect();
3686    let sel_exprs: Vec<String> = ins
3687        .assignments
3688        .iter()
3689        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3690        .map(|(_, e)| emit_expr(e))
3691        .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
3692        .collect();
3693
3694    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
3695    // The iterator first: a body CTE, and any DML nested in the row's values,
3696    // may read the loop variable, and a WITH name is only in scope for the
3697    // bindings that follow it.
3698    cte_parts.push(iter_cte.to_string());
3699    cte_parts.extend(emit_user_cte_parts(body_ctes));
3700    cte_parts.extend(emit_user_cte_parts(&ins.nested_ctes));
3701    // A nested loop that carried the iteration key back means this insert's
3702    // own rows need one too, to pair with.
3703    let correlated_targets = correlated_append_indices(&ins.multi_link_appends, &[body_ctes, &ins.nested_ctes]);
3704
3705    let mut insert_sql = format!(
3706        "INSERT INTO {} ({})\nSELECT {} FROM {}",
3707        target_ref(&ins.target),
3708        cols.join(", "),
3709        sel_exprs.join(", "),
3710        qi(iter_alias),
3711    );
3712    if let Some(conflict) = &ins.unless_conflict {
3713        emit_conflict(&mut insert_sql, conflict);
3714    }
3715    let (shape, returning_sql) = emit_returning_shape(&ins.target, &ins.returning, false);
3716
3717    if ins.multi_link_appends.is_empty() {
3718        let mut sql = format!("WITH {}\n{}", cte_parts.join(",\n"), insert_sql);
3719        if let Some(r) = returning_sql {
3720            sql.push_str(&r);
3721        }
3722        return SqlOutput {
3723            sql,
3724            shape,
3725            inference_plan: None,
3726        };
3727    }
3728
3729    // A junction row needs the id of the row this iteration just wrote, so
3730    // the insert becomes a CTE the junction inserts read back from.
3731    const IDS: &str = "_for_dml__ids";
3732    const ROWS: &str = "_for_dml__rows";
3733    if correlated_targets.is_empty() {
3734        insert_sql.push_str("\nRETURNING *");
3735        cte_parts.push(format!("\"{}\" AS (\n{}\n)", IDS, insert_sql));
3736    } else {
3737        let outer_source = format!("{}.{}", qi(iter_alias), qi("v"));
3738        cte_parts.push(emit_iteration_rows_cte(ins, ROWS, iter_alias, &outer_source));
3739        cte_parts.push(emit_insert_from_rows_cte(ins, IDS, ROWS));
3740    }
3741    for (i, append) in ins.multi_link_appends.iter().enumerate() {
3742        let cte_name = format!("_for_dml__ml_add_{}", i);
3743        if correlated_targets.contains(&i) {
3744            let IrMultiLinkValueSource::CteRef(target) = &append.values.source else {
3745                unreachable!("only a CTE-backed value can carry an iteration key")
3746            };
3747            cte_parts.push(emit_correlated_ml_append_cte(append, ROWS, target, &cte_name));
3748        } else {
3749            cte_parts.push(emit_ml_append_cte(append, IDS, &ins.target.alias, &cte_name));
3750        }
3751    }
3752
3753    let projection = match &returning_sql {
3754        // `emit_returning_shape` spells the projection as a RETURNING clause;
3755        // read off a CTE it is the same tuple in a SELECT list.
3756        Some(r) => r.trim_start_matches('\n').replacen("RETURNING ", "SELECT ", 1),
3757        None => "SELECT 1".to_string(),
3758    };
3759    SqlOutput {
3760        sql: format!("WITH {}\n{}\nFROM \"{}\"", cte_parts.join(",\n"), projection, IDS),
3761        shape,
3762        inference_plan: None,
3763    }
3764}
3765
3766// ── INSERT ──────────────────────────────────────────────────────────────────
3767
3768// ── Vector index enqueue helpers ─────────────────────────────────────────────
3769
3770/// Build one `"_eqN" AS (INSERT INTO _pylon."IndexOutbox" ...)` CTE string.
3771fn enqueue_cte_sql(eq: &VectorEnqueueInfo, source_cte: &str, cte_name: &str) -> String {
3772    let index_name_sql = match &eq.index_name {
3773        None => "NULL".to_string(),
3774        Some(name) => sql_str(name),
3775    };
3776    format!(
3777        concat!(
3778            "\"{}\" AS (\n",
3779            "    INSERT INTO _pylon.\"IndexOutbox\"\n",
3780            "        (object_id, type_name, index_kind, index_name)\n",
3781            "    SELECT \"id\", {}, 'Vector'::_pylon.\"IndexKind\", {}\n",
3782            "    FROM \"{}\"\n",
3783            "    ON CONFLICT (object_id, index_kind, index_name)\n",
3784            "    DO UPDATE SET status = 'Pending', enqueued_at = now()\n",
3785            ")",
3786        ),
3787        cte_name,
3788        sql_str(&eq.type_name),
3789        index_name_sql,
3790        source_cte,
3791    )
3792}
3793
3794/// Build the full list of enqueue CTE strings for a set of vector indexes.
3795fn enqueue_ctes(enqueue: &[VectorEnqueueInfo], source_cte: &str) -> Vec<String> {
3796    enqueue
3797        .iter()
3798        .enumerate()
3799        .map(|(i, eq)| enqueue_cte_sql(eq, source_cte, &format!("_eq{}", i)))
3800        .collect()
3801}
3802
3803/// The `_pylon."IndexOutbox".index_kind` enum label for a search backend —
3804/// `Postgres`-backed indexes never reach this (see `collect_search_enqueue`),
3805/// so there's no corresponding `IndexKind` value for that variant.
3806fn search_backend_index_kind(backend: &crate::schema::SearchBackend) -> &'static str {
3807    match backend {
3808        crate::schema::SearchBackend::OpenSearch => "OpenSearch",
3809        crate::schema::SearchBackend::Meilisearch => "Meilisearch",
3810        crate::schema::SearchBackend::Postgres => {
3811            unreachable!("Postgres-backed search indexes are never collected into SearchEnqueueInfo")
3812        }
3813    }
3814}
3815
3816/// Build one OpenSearch/Meilisearch outbox CTE string.
3817fn enqueue_search_cte_sql(eq: &SearchEnqueueInfo, source_cte: &str, cte_name: &str) -> String {
3818    let index_name_sql = match &eq.index_name {
3819        None => "NULL".to_string(),
3820        Some(name) => sql_str(name),
3821    };
3822    format!(
3823        concat!(
3824            "\"{}\" AS (\n",
3825            "    INSERT INTO _pylon.\"IndexOutbox\"\n",
3826            "        (object_id, type_name, index_kind, index_name, operation)\n",
3827            "    SELECT \"id\", {}, '{}'::_pylon.\"IndexKind\", {}, {}\n",
3828            "    FROM \"{}\"\n",
3829            "    ON CONFLICT (object_id, index_kind, index_name)\n",
3830            "    DO UPDATE SET status = 'Pending', operation = EXCLUDED.operation, enqueued_at = now()\n",
3831            ")",
3832        ),
3833        cte_name,
3834        sql_str(&eq.type_name),
3835        search_backend_index_kind(&eq.backend),
3836        index_name_sql,
3837        sql_str(eq.operation),
3838        source_cte,
3839    )
3840}
3841
3842/// Build the full list of OpenSearch/Meilisearch enqueue CTE strings.
3843fn enqueue_search_ctes(enqueue: &[SearchEnqueueInfo], source_cte: &str, offset: usize) -> Vec<String> {
3844    enqueue
3845        .iter()
3846        .enumerate()
3847        .map(|(i, eq)| enqueue_search_cte_sql(eq, source_cte, &format!("_es{}", offset + i)))
3848        .collect()
3849}
3850
3851/// Build `SELECT (...) AS result FROM "cte_name"` plus its ShapeDescriptor,
3852/// mirroring `emit_returning_shape` but for the CTE-wrapper SELECT path.
3853fn shape_select_from_cte(
3854    target: &IrSource,
3855    returning: &[IrShapePointer],
3856    cte_name: &str,
3857) -> (ShapeDescriptor, Option<String>) {
3858    if returning.is_empty() {
3859        return (
3860            ShapeDescriptor {
3861                root: ShapeNode::Scalar {
3862                    name: String::new(),
3863                    position: 0,
3864                },
3865            },
3866            None,
3867        );
3868    }
3869    let (pointer_exprs, shape_pointers) = build_shape(returning, "");
3870    let mut parts = vec![type_disc(&target.type_name)];
3871    parts.extend(pointer_exprs);
3872    let tuple = parts.join(",\n    ");
3873    let sql = format!("SELECT (\n    {}\n) AS result\nFROM {}", tuple, qi(cte_name));
3874    let root_pointers = prepend_type(shape_pointers);
3875    let shape = ShapeDescriptor {
3876        root: ShapeNode::Object {
3877            name: String::new(),
3878            type_name: Some(target.type_name.clone()),
3879            position: 0,
3880            cardinality: Cardinality::Required,
3881            pointers: root_pointers,
3882            has_implicit_id: shape_has_implicit_id(returning),
3883        },
3884    };
3885    (shape, Some(sql))
3886}
3887
3888/// Builds the base `INSERT INTO t (cols) VALUES (...)` row source — or,
3889/// when `ins.nested_ctes` is non-empty, `INSERT INTO t (cols) SELECT ...
3890/// FROM cte1, cte2, ...` instead, since a VALUES list can't reference a
3891/// CTE's columns (needed when a link's value is sourced from a hoisted
3892/// nested INSERT/UPDATE/DELETE — see `IrInsert::nested_ctes`). Each nested
3893/// CTE is guaranteed exactly one row, so an implicit cross join is safe.
3894/// Shared by the plain wrap path and the multi-link junction wrap path
3895/// below, both of which need this same VALUES/SELECT choice.
3896fn emit_insert_row_sql(ins: &IrInsert) -> String {
3897    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
3898    let cols: Vec<String> = ins
3899        .assignments
3900        .iter()
3901        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3902        .map(|(c, _)| qi(c))
3903        .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
3904        .collect();
3905    let vals: Vec<String> = ins
3906        .assignments
3907        .iter()
3908        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3909        .map(|(_, e)| emit_expr(e))
3910        .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
3911        .collect();
3912    // A guarded insert writes nothing when its condition is false, so the row
3913    // comes from a `SELECT` that returns none -- `VALUES` always yields one.
3914    if let Some(guard) = &ins.guard {
3915        let from_ctes = nested_cte_from(&ins.nested_ctes, &vals.join(","), " ");
3916        let projection = if cols.is_empty() {
3917            String::new()
3918        } else {
3919            format!(" {}", vals.join(", "))
3920        };
3921        let column_list = if cols.is_empty() {
3922            String::new()
3923        } else {
3924            format!(" ({})", cols.join(", "))
3925        };
3926        return format!(
3927            "INSERT INTO {}{} SELECT{}{} WHERE {}",
3928            target_ref(&ins.target),
3929            column_list,
3930            projection,
3931            from_ctes,
3932            emit_expr(guard),
3933        );
3934    }
3935    if cols.is_empty() && ins.nested_ctes.is_empty() {
3936        // `insert Preferences {}` — a row made entirely of its own defaults.
3937        // An empty column list is not SQL; `DEFAULT VALUES` is how it is said.
3938        format!("INSERT INTO {} DEFAULT VALUES", target_ref(&ins.target))
3939    } else if ins.nested_ctes.is_empty() {
3940        format!(
3941            "INSERT INTO {} ({}) VALUES ({})",
3942            target_ref(&ins.target),
3943            cols.join(", "),
3944            vals.join(", ")
3945        )
3946    } else {
3947        format!(
3948            "INSERT INTO {} ({}) SELECT {}{}",
3949            target_ref(&ins.target),
3950            cols.join(", "),
3951            vals.join(", "),
3952            nested_cte_from(&ins.nested_ctes, &vals.join(","), " "),
3953        )
3954    }
3955}
3956
3957fn emit_insert_stmt(ins: &IrInsert) -> SqlOutput {
3958    if ins.enqueue_vector.is_empty()
3959        && ins.enqueue_search.is_empty()
3960        && !insert_has_any_multilink(ins)
3961        && ins.nested_ctes.is_empty()
3962    {
3963        let mut sql = emit_insert_row_sql(ins);
3964        if let Some(conflict) = &ins.unless_conflict {
3965            emit_conflict(&mut sql, conflict);
3966        }
3967        let (shape, returning_sql) = emit_returning_shape(&ins.target, &ins.returning, false);
3968        if let Some(r) = returning_sql {
3969            sql.push_str(&r);
3970        }
3971        return SqlOutput {
3972            sql,
3973            shape,
3974            inference_plan: None,
3975        };
3976    }
3977
3978    // Wrap path: needed for outbox enqueue CTEs, junction-table population
3979    // (see emit_insert_multilink_ctes), and/or hoisted nested-DML CTEs (see
3980    // IrInsert::nested_ctes) — all of which require either the row's own id
3981    // or a WITH prefix, so the plain single-statement INSERT above can't be
3982    // used.
3983    let mut cte_parts = if insert_has_any_multilink(ins) {
3984        emit_insert_multilink_ctes(ins, "_w")
3985    } else {
3986        let mut cte_parts = emit_user_cte_parts(&ins.nested_ctes);
3987        let mut insert_sql = emit_insert_row_sql(ins);
3988        if let Some(conflict) = &ins.unless_conflict {
3989            emit_conflict(&mut insert_sql, conflict);
3990        }
3991        insert_sql.push_str("\nRETURNING \"id\"");
3992        cte_parts.push(format!("\"_w\" AS (\n{}\n)", insert_sql));
3993        cte_parts
3994    };
3995    cte_parts.extend(enqueue_ctes(&ins.enqueue_vector, "_w"));
3996    cte_parts.extend(enqueue_search_ctes(&ins.enqueue_search, "_w", ins.enqueue_vector.len()));
3997
3998    let (shape, select_sql) = shape_select_from_cte(&ins.target, &ins.returning, "_w");
3999    let sql = format!(
4000        "WITH\n{}\n{}",
4001        cte_parts.join(",\n"),
4002        select_sql.unwrap_or_else(|| "SELECT * FROM \"_w\"".to_string()),
4003    );
4004    SqlOutput {
4005        sql,
4006        shape,
4007        inference_plan: None,
4008    }
4009}
4010
4011// ── UPDATE ──────────────────────────────────────────────────────────────────
4012
4013fn emit_poly_update_stmt(upd: &IrUpdate, user_ctes: &[IrCteDef]) -> SqlOutput {
4014    let alias = &upd.target.alias;
4015    let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4016
4017    // Nested-DML CTEs first — a single hoisted CTE (e.g. a nested INSERT)
4018    // is materialized once and safely referenced from every per-implementor
4019    // branch below via its own FROM clause; at most one branch's filter
4020    // will ever actually match a row, since each row belongs to exactly
4021    // one concrete implementor table.
4022    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
4023    cte_parts.extend(emit_user_cte_parts(&upd.nested_ctes));
4024    let mut union_parts = vec![];
4025    let from_ctes = nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n");
4026
4027    let has_any_multilink = !upd.multi_link_clears.is_empty()
4028        || !upd.multi_link_replaces.is_empty()
4029        || !upd.multi_link_appends.is_empty()
4030        || !upd.multi_link_removals.is_empty();
4031    for (i, imp) in upd.poly_implementors.iter().enumerate() {
4032        let cte_name = format!("_u{}", i);
4033        // Junction rows live in each implementor's own junction table, so
4034        // with any to write each implementor is updated as the concrete type
4035        // it is.
4036        if has_any_multilink {
4037            cte_parts.extend(emit_update_multilink_cte_parts(
4038                &concrete_poly_update(upd, imp),
4039                &cte_name,
4040                false,
4041            ));
4042        } else {
4043            let mut upd_sql = format!(
4044                "UPDATE {} AS {}\nSET {}{}",
4045                qn(&imp.module, &imp.table),
4046                qi(alias),
4047                sets.join(", "),
4048                from_ctes,
4049            );
4050            append_filter(&mut upd_sql, &upd.filter);
4051            upd_sql.push_str(&format!("\nRETURNING {}.\"id\"", qi(alias)));
4052            cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, upd_sql));
4053        }
4054
4055        let r_alias = format!("_r{}", i);
4056        union_parts.push(format!(
4057            "SELECT ROW({}::text, {}.\"id\") AS result FROM \"{}\" AS {}",
4058            sql_str(&imp.type_name),
4059            qi(&r_alias),
4060            cte_name,
4061            qi(&r_alias),
4062        ));
4063    }
4064
4065    let sql = format!("WITH\n{}\n{}", cte_parts.join(",\n"), union_parts.join("\nUNION ALL\n"),);
4066
4067    let (shape, _) = emit_returning_shape(&upd.target, &upd.returning, true);
4068    SqlOutput {
4069        sql,
4070        shape,
4071        inference_plan: None,
4072    }
4073}
4074
4075fn emit_update_stmt(upd: &IrUpdate, user_ctes: &[IrCteDef]) -> SqlOutput {
4076    if !upd.poly_implementors.is_empty() {
4077        return emit_poly_update_stmt(upd, user_ctes);
4078    }
4079    let alias = &upd.target.alias;
4080    let (shape, returning_sql) = emit_returning_shape(&upd.target, &upd.returning, true);
4081
4082    let has_any_multilink = !upd.multi_link_clears.is_empty()
4083        || !upd.multi_link_replaces.is_empty()
4084        || !upd.multi_link_appends.is_empty()
4085        || !upd.multi_link_removals.is_empty();
4086
4087    if !has_any_multilink && upd.enqueue_vector.is_empty() && upd.enqueue_search.is_empty() {
4088        // No junction changes, no enqueue — plain UPDATE (possibly with a
4089        // user CTE prefix and/or hoisted nested-DML CTEs from a link value
4090        // like `author := (select (insert Person {...}) { id })` — see
4091        // IrUpdate::nested_ctes). A nested CTE's `id` column is referenced
4092        // from the SET clause, which needs a `FROM` clause to see it —
4093        // ordinary `UPDATE ... SET ...` has no FROM of its own otherwise.
4094        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4095        let mut sql = format!(
4096            "UPDATE {} AS {}\nSET {}",
4097            target_ref(&upd.target),
4098            qi(alias),
4099            sets.join(", "),
4100        );
4101        sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n"));
4102        append_filter(&mut sql, &upd.filter);
4103        if let Some(r) = returning_sql {
4104            sql.push_str(&r);
4105        }
4106        // The user's bindings first: a nested statement may read them, and a
4107        // CTE only sees the ones before it.
4108        let combined_ctes: Vec<IrCteDef> = user_ctes
4109            .iter()
4110            .cloned()
4111            .chain(upd.nested_ctes.iter().cloned())
4112            .collect();
4113        if !combined_ctes.is_empty() {
4114            sql = format!("{}{}", emit_cte_prefix(&combined_ctes), sql);
4115        }
4116        return SqlOutput {
4117            sql,
4118            shape,
4119            inference_plan: None,
4120        };
4121    }
4122
4123    if !has_any_multilink && (!upd.enqueue_vector.is_empty() || !upd.enqueue_search.is_empty()) {
4124        // No junction changes but need to enqueue — wrap UPDATE in a CTE.
4125        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4126        let mut upd_sql = format!(
4127            "    UPDATE {} AS {}\n    SET {}",
4128            target_ref(&upd.target),
4129            qi(alias),
4130            sets.join(", "),
4131        );
4132        upd_sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n    "));
4133        append_filter(&mut upd_sql, &upd.filter);
4134        upd_sql.push_str("\n    RETURNING \"id\"");
4135
4136        let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
4137        cte_parts.extend(emit_user_cte_parts(&upd.nested_ctes));
4138        cte_parts.push(format!("\"_w\" AS (\n{}\n)", upd_sql));
4139        cte_parts.extend(enqueue_ctes(&upd.enqueue_vector, "_w"));
4140        cte_parts.extend(enqueue_search_ctes(&upd.enqueue_search, "_w", upd.enqueue_vector.len()));
4141
4142        let (shape2, select_sql) = shape_select_from_cte(&upd.target, &upd.returning, "_w");
4143        let sql = format!(
4144            "WITH\n{}\n{}",
4145            cte_parts.join(",\n"),
4146            select_sql.unwrap_or_else(|| "SELECT * FROM \"_w\"".to_string()),
4147        );
4148        return SqlOutput {
4149            sql,
4150            shape: shape2,
4151            inference_plan: None,
4152        };
4153    }
4154
4155    // CTE-based UPDATE for junction table mutations.
4156    let result_expr = if !upd.returning.is_empty() {
4157        let (pointer_exprs, _) = build_shape(&upd.returning, alias);
4158        let mut parts = vec![type_disc(&upd.target.type_name)];
4159        parts.extend(pointer_exprs);
4160        parts.join(",\n    ")
4161    } else {
4162        format!("{}.id", qi(alias))
4163    };
4164
4165    let has_scalar_changes = !upd.assignments.is_empty() || !upd.rewrites.is_empty();
4166
4167    // Nested-DML CTEs first (see IrUpdate::nested_ctes) — only relevant when
4168    // has_scalar_changes, since nested_ctes is only ever populated while
4169    // compiling scalar assignments, which is exactly the condition below.
4170    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
4171    cte_parts.extend(emit_user_cte_parts(&upd.nested_ctes));
4172
4173    // _ids: the target rows (updated or selected).
4174    if has_scalar_changes {
4175        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4176        let mut upd_sql = format!(
4177            "UPDATE {} AS {}\nSET {}",
4178            target_ref(&upd.target),
4179            qi(alias),
4180            sets.join(", "),
4181        );
4182        upd_sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n"));
4183        append_filter(&mut upd_sql, &upd.filter);
4184        upd_sql.push_str("\nRETURNING *");
4185        cte_parts.push(format!("\"_ids\" AS (\n{}\n)", upd_sql));
4186    } else {
4187        let mut sel = format!(
4188            "SELECT {}.* FROM {} AS {}",
4189            qi(alias),
4190            target_ref(&upd.target),
4191            qi(alias),
4192        );
4193        append_filter(&mut sel, &upd.filter);
4194        cte_parts.push(format!("\"_ids\" AS (\n{}\n)", sel));
4195    }
4196
4197    // Junction clears (`:= {}` and `:= expr` — the clear part of replace).
4198    for (i, clr) in upd.multi_link_clears.iter().enumerate() {
4199        let exclude = ml_clear_exclusion(upd.multi_link_replaces.get(i));
4200        let del = format!(
4201            "DELETE FROM {} WHERE {} IN (SELECT id FROM \"_ids\"){}",
4202            qn(&clr.module, &clr.junction_table),
4203            qi(&clr.source_col),
4204            exclude,
4205        );
4206        cte_parts.push(format!("\"_clr_{}\" AS (\n{}\n)", i, del));
4207    }
4208
4209    // Junction appends (`+=`).
4210    for (i, app) in upd.multi_link_appends.iter().enumerate() {
4211        cte_parts.push(emit_ml_append_cte(
4212            app,
4213            "_ids",
4214            &upd.target.alias,
4215            &format!("_ml_add_{}", i),
4216        ));
4217    }
4218
4219    // Junction removals (`-=`).
4220    for (i, rem) in upd.multi_link_removals.iter().enumerate() {
4221        cte_parts.push(emit_ml_remove_cte(
4222            rem,
4223            "_ids",
4224            &upd.target.alias,
4225            &format!("_ml_rm_{}", i),
4226        ));
4227    }
4228
4229    // Junction inserts for replace (`:= expr` — insert after the clear).
4230    for (i, rep) in upd.multi_link_replaces.iter().enumerate() {
4231        cte_parts.push(emit_ml_append_cte(
4232            rep,
4233            "_ids",
4234            &upd.target.alias,
4235            &format!("_ml_rep_{}", i),
4236        ));
4237    }
4238
4239    // Enqueue CTEs (source is _ids which has all columns including id).
4240    cte_parts.extend(enqueue_ctes(&upd.enqueue_vector, "_ids"));
4241    cte_parts.extend(enqueue_search_ctes(
4242        &upd.enqueue_search,
4243        "_ids",
4244        upd.enqueue_vector.len(),
4245    ));
4246
4247    let sql = format!(
4248        "WITH\n{}\nSELECT (\n    {}\n) AS result\nFROM \"_ids\" AS {}",
4249        cte_parts.join(",\n"),
4250        result_expr,
4251        qi(alias),
4252    );
4253    SqlOutput {
4254        sql,
4255        shape,
4256        inference_plan: None,
4257    }
4258}
4259
4260// ── DELETE ──────────────────────────────────────────────────────────────────
4261
4262fn emit_delete_stmt(del: &IrDelete) -> SqlOutput {
4263    if !del.poly_implementors.is_empty() {
4264        return emit_poly_delete_stmt(del);
4265    }
4266    let alias = &del.target.alias;
4267
4268    if del.enqueue_search.is_empty() {
4269        let mut sql = format!("DELETE FROM {} AS {}", target_ref(&del.target), qi(alias),);
4270        append_filter(&mut sql, &del.filter);
4271        let (shape, returning_sql) = emit_returning_shape(&del.target, &del.returning, true);
4272        if let Some(r) = returning_sql {
4273            sql.push_str(&r);
4274        }
4275        return SqlOutput {
4276            sql,
4277            shape,
4278            inference_plan: None,
4279        };
4280    }
4281
4282    // Wrap DELETE in a CTE to enqueue OpenSearch delete jobs.
4283    let mut del_sql = format!("    DELETE FROM {} AS {}", target_ref(&del.target), qi(alias),);
4284    append_filter(&mut del_sql, &del.filter);
4285    del_sql.push_str("\n    RETURNING \"id\"");
4286
4287    let mut cte_parts = vec![format!("\"_del\" AS (\n{}\n)", del_sql)];
4288    cte_parts.extend(enqueue_search_ctes(&del.enqueue_search, "_del", 0));
4289
4290    let (shape, select_sql) = shape_select_from_cte(&del.target, &del.returning, "_del");
4291    let sql = format!(
4292        "WITH\n{}\n{}",
4293        cte_parts.join(",\n"),
4294        select_sql.unwrap_or_else(|| "SELECT * FROM \"_del\"".to_string()),
4295    );
4296    SqlOutput {
4297        sql,
4298        shape,
4299        inference_plan: None,
4300    }
4301}
4302
4303fn emit_poly_delete_stmt(del: &IrDelete) -> SqlOutput {
4304    let alias = &del.target.alias;
4305    let mut cte_parts = vec![];
4306    let mut union_parts = vec![];
4307
4308    for (i, imp) in del.poly_implementors.iter().enumerate() {
4309        let cte_name = format!("_d{}", i);
4310        let mut del_sql = format!("DELETE FROM {} AS {}", qn(&imp.module, &imp.table), qi(alias),);
4311        append_filter(&mut del_sql, &del.filter);
4312        del_sql.push_str(&format!("\nRETURNING {}.\"id\"", qi(alias)));
4313        cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, del_sql));
4314
4315        let r_alias = format!("_r{}", i);
4316        union_parts.push(format!(
4317            "SELECT ROW({}::text, {}.\"id\") AS result FROM \"{}\" AS {}",
4318            sql_str(&imp.type_name),
4319            qi(&r_alias),
4320            cte_name,
4321            qi(&r_alias),
4322        ));
4323    }
4324
4325    let sql = format!("WITH\n{}\n{}", cte_parts.join(",\n"), union_parts.join("\nUNION ALL\n"),);
4326
4327    let (shape, _) = emit_returning_shape(&del.target, &del.returning, true);
4328    SqlOutput {
4329        sql,
4330        shape,
4331        inference_plan: None,
4332    }
4333}
4334
4335// ── RETURNING helper ─────────────────────────────────────────────────────────
4336
4337/// Builds the RETURNING clause and ShapeDescriptor for DML.
4338/// `with_alias`: UPDATE/DELETE can use the table alias; INSERT cannot.
4339fn emit_returning_shape(
4340    target: &IrSource,
4341    returning: &[IrShapePointer],
4342    with_alias: bool,
4343) -> (ShapeDescriptor, Option<String>) {
4344    if returning.is_empty() {
4345        return (
4346            ShapeDescriptor {
4347                root: ShapeNode::Scalar {
4348                    name: String::new(),
4349                    position: 0,
4350                },
4351            },
4352            None,
4353        );
4354    }
4355
4356    let alias = if with_alias { target.alias.as_str() } else { "" };
4357    let (pointer_exprs, shape_pointers) = build_shape(returning, alias);
4358
4359    let mut parts = vec![type_disc(&target.type_name)];
4360    parts.extend(pointer_exprs);
4361    let tuple = parts.join(",\n    ");
4362    let sql = format!("\nRETURNING (\n    {}\n) AS result", tuple);
4363
4364    let root_pointers = prepend_type(shape_pointers);
4365    let shape = ShapeDescriptor {
4366        root: ShapeNode::Object {
4367            name: String::new(),
4368            type_name: Some(target.type_name.clone()),
4369            position: 0,
4370            cardinality: Cardinality::Required,
4371            pointers: root_pointers,
4372            has_implicit_id: shape_has_implicit_id(returning),
4373        },
4374    };
4375    (shape, Some(sql))
4376}
4377
4378// ── Shape emission ───────────────────────────────────────────────────────────
4379
4380fn emit_scalar_set(f: &IrScalarSetPointer, pos: usize) -> (String, ShapeNode) {
4381    let from_sql = if !f.poly_implementors.is_empty() {
4382        format!(
4383            "(\n{}\n) AS {}",
4384            emit_poly_union(&f.poly_implementors, &f.poly_columns),
4385            qi(&f.source.alias)
4386        )
4387    } else {
4388        format!("{} AS {}", source_ref(&f.source), qi(&f.source.alias))
4389    };
4390    let sql = format!(
4391        "(SELECT COALESCE(array_agg(ROW({})::record), ARRAY[]::record[]) FROM {})",
4392        emit_expr(&f.bool_expr),
4393        from_sql,
4394    );
4395    let node = ShapeNode::Array {
4396        name: f.alias.clone(),
4397        position: pos,
4398        element: Box::new(ShapeNode::Scalar {
4399            name: String::new(),
4400            position: 0,
4401        }),
4402    };
4403    (sql, node)
4404}
4405
4406/// Build SQL expressions and ShapeNodes for `pointers`, starting at position 1
4407/// (position 0 is always the type discriminator, added by the caller).
4408/// True when the compiler put an `id` at the front of this shape that the
4409/// query never asked for — see `IrScalarPointer::implicit_id`.
4410fn shape_has_implicit_id(pointers: &[IrShapePointer]) -> bool {
4411    matches!(pointers.first(), Some(IrShapePointer::Scalar(p)) if p.implicit_id)
4412}
4413
4414fn build_shape(pointers: &[IrShapePointer], table_alias: &str) -> (Vec<String>, Vec<ShapeNode>) {
4415    let mut exprs = Vec::new();
4416    let mut nodes = Vec::new();
4417
4418    for (i, pointer) in pointers.iter().enumerate() {
4419        let (sql, node) = emit_shape_pointer(pointer, table_alias, i + 1);
4420        exprs.push(sql);
4421        nodes.push(node);
4422    }
4423
4424    (exprs, nodes)
4425}
4426
4427/// A `$1`, `$2`, … template with each placeholder replaced by its argument's
4428/// SQL, in one pass over the template alone. Replacing one number at a time
4429/// over the growing string would rewrite a query parameter an argument
4430/// carries (`$5` inside the first argument, replaced again as the fifth), and
4431/// `$1` also matches the start of `$10`.
4432fn fill_template(template: &str, args: &[String]) -> String {
4433    let mut out = String::with_capacity(template.len());
4434    let mut chars = template.char_indices().peekable();
4435    while let Some((start, c)) = chars.next() {
4436        if c != '$' {
4437            out.push(c);
4438            continue;
4439        }
4440        let mut end = start + 1;
4441        while let Some((i, d)) = chars.peek().copied()
4442            && d.is_ascii_digit()
4443        {
4444            end = i + d.len_utf8();
4445            chars.next();
4446        }
4447        match template[start + 1..end].parse::<usize>() {
4448            Ok(n) if (1..=args.len()).contains(&n) => out.push_str(&args[n - 1]),
4449            _ => out.push_str(&template[start..end]),
4450        }
4451    }
4452    out
4453}
4454
4455/// `, message` for an assert given `message := …`, else nothing.
4456fn assert_message_arg(message: &Option<IrExpr>) -> String {
4457    message
4458        .as_ref()
4459        .map(|m| format!(", ({})::text", emit_expr(m)))
4460        .unwrap_or_default()
4461}
4462
4463fn emit_shape_pointer(pointer: &IrShapePointer, table_alias: &str, pos: usize) -> (String, ShapeNode) {
4464    match pointer {
4465        IrShapePointer::Scalar(f) => emit_scalar(f, table_alias, pos),
4466        IrShapePointer::SingleLink(f) => emit_single_link(f, table_alias, pos),
4467        IrShapePointer::MultiLink(f) => emit_multi_link(f, table_alias, pos),
4468        IrShapePointer::Computed(f) => (emit_expr(&f.expr), expr_shape_node(&f.alias, pos, &f.expr)),
4469        IrShapePointer::ScalarSet(f) => emit_scalar_set(f, pos),
4470        IrShapePointer::Asserted(a) => {
4471            let (sql, node) = emit_shape_pointer(&a.inner, table_alias, pos);
4472            // The inner pointer aggregates rows to `record[]`, which a PL/pgSQL
4473            // function cannot take as a parameter at all -- hence the cast to
4474            // `text[]`, whose rendering compares element-for-element the same
4475            // way. Bound in a sub-select so the pointer's own subquery is
4476            // evaluated once rather than in both the check and the result, and
4477            // read through `cardinality` because the assert hands back the
4478            // array it was given.
4479            // A pointer landing on one object reads as a lone record, not an
4480            // array, so its set is re-read through `ARRAY(…)`; the unlimited
4481            // check form would otherwise fail on more than one row.
4482            let checked_set = match &a.check {
4483                Some(check) if emits_one_object(check) => {
4484                    format!("ARRAY{}", emit_shape_pointer(check, table_alias, pos).0)
4485                }
4486                Some(check) => emit_shape_pointer(check, table_alias, pos).0,
4487                None if emits_one_object(&a.inner) => format!("ARRAY{sql}"),
4488                None => "\"_a\".\"v\"".to_string(),
4489            };
4490            let checked = format!(
4491                "(SELECT \"_a\".\"v\" FROM (SELECT {sql} AS \"v\") AS \"_a\"\n                     WHERE cardinality(\"_pylon\".{}({checked_set}::text[]{})) >= 0)",
4492                qi(&a.fn_name),
4493                assert_message_arg(&a.message),
4494            );
4495            (checked, node)
4496        }
4497    }
4498}
4499
4500/// True when the pointer is emitted as a single `(SELECT …)` record rather
4501/// than an aggregated array of them.
4502fn emits_one_object(pointer: &IrShapePointer) -> bool {
4503    match pointer {
4504        IrShapePointer::SingleLink(_) => true,
4505        IrShapePointer::Computed(c) => matches!(
4506            c.expr,
4507            IrExpr::ObjectPathSubquery(_) | IrExpr::ObjectPathUnion { multi: false, .. }
4508        ),
4509        IrShapePointer::Asserted(a) => emits_one_object(&a.inner),
4510        _ => false,
4511    }
4512}
4513
4514/// Convert a PostgreSQL schema-qualified type name (`"module"."TypeName"`) to
4515/// a Pylon-qualified name (`module::TypeName`).
4516fn pg_quoted_to_pylon(pg_type: &str) -> String {
4517    let inner = pg_type.trim_start_matches('"');
4518    if let Some(idx) = inner.find(r#""."#) {
4519        let module = &inner[..idx];
4520        let type_name = inner[idx + 3..].trim_end_matches('"');
4521        format!("{}::{}", module, type_name)
4522    } else {
4523        pg_type.to_string()
4524    }
4525}
4526
4527/// A schema-qualified custom type (an enum or a domain) named in a property's
4528/// `pg_type`, together with whether the column holds an array of it.
4529///
4530/// Both forms are emitted as text -- `::text` for a scalar, `::text[]` for an
4531/// array -- so a value's shape never depends on an OID the database assigns
4532/// when the migration creating the type runs. The array case has to be told
4533/// apart from the scalar one: `"access"."AuthenticationMethod"[]` starts with a
4534/// quote just as `"account"."InterfaceAppearance"` does, and reading it as a
4535/// scalar enum casts a whole array to a single `text` (`'{MagicLink}'`) and
4536/// shapes it as one enum value.
4537struct QualifiedPgType {
4538    /// Pylon-qualified name of the element type, e.g. `access::AuthenticationMethod`.
4539    name: String,
4540    is_array: bool,
4541}
4542
4543impl QualifiedPgType {
4544    fn of(pg_type: &str) -> Option<Self> {
4545        let (element, is_array) = match pg_type.strip_suffix("[]") {
4546            Some(element) => (element, true),
4547            None => (pg_type, false),
4548        };
4549        element.starts_with('"').then(|| Self {
4550            name: pg_quoted_to_pylon(element),
4551            is_array,
4552        })
4553    }
4554
4555    /// The cast that carries this value back as text.
4556    fn text_cast(&self) -> &'static str {
4557        if self.is_array { "::text[]" } else { "::text" }
4558    }
4559
4560    /// How the decoded value is shaped: a single enum, or a list of them.
4561    fn shape_node(&self, name: String, position: usize) -> ShapeNode {
4562        let element = ShapeNode::Enum {
4563            name: name.clone(),
4564            position,
4565            enum_type: self.name.clone(),
4566        };
4567        if !self.is_array {
4568            return element;
4569        }
4570        ShapeNode::Array {
4571            name,
4572            // Each element arrives as the label itself rather than as a field
4573            // of a record, which the decoders read at position 0.
4574            element: Box::new(ShapeNode::Enum {
4575                name: String::new(),
4576                position: 0,
4577                enum_type: self.name.clone(),
4578            }),
4579            position,
4580        }
4581    }
4582}
4583
4584fn emit_scalar(f: &IrScalarPointer, table_alias: &str, pos: usize) -> (String, ShapeNode) {
4585    if let Some(nt_name) = f.pg_type.strip_prefix("__nt__:") {
4586        let sql = if table_alias.is_empty() {
4587            format!("{}::jsonb", qi(&f.column))
4588        } else {
4589            format!("{}.{}::jsonb", qi(table_alias), qi(&f.column))
4590        };
4591        return (
4592            sql,
4593            ShapeNode::NamedTuple {
4594                name: f.alias.clone(),
4595                position: pos,
4596                type_name: Some(nt_name.to_string()),
4597                members: f.tuple_shape.as_ref().map(|s| s.members.clone()),
4598                is_free_object: false,
4599            },
4600        );
4601    }
4602    // Schema-qualified custom types (enums, domains) have runtime OIDs unknown to a static
4603    // anonymous_record_decode. Cast to text — the string label is all the decoder needs.
4604    if let Some(qualified) = QualifiedPgType::of(&f.pg_type) {
4605        let cast = qualified.text_cast();
4606        let sql = if table_alias.is_empty() {
4607            format!("{}{}", qi(&f.column), cast)
4608        } else {
4609            format!("{}.{}{}", qi(table_alias), qi(&f.column), cast)
4610        };
4611        return (sql, qualified.shape_node(f.alias.clone(), pos));
4612    }
4613    // A structural pylon.Tuple[...]-typed property — same jsonb column shape
4614    // as the nominal `__nt__:` case above, just with no registered dataclass
4615    // to hydrate (type_name stays None).
4616    if let Some(shape) = &f.tuple_shape {
4617        let sql = if table_alias.is_empty() {
4618            format!("{}::jsonb", qi(&f.column))
4619        } else {
4620            format!("{}.{}::jsonb", qi(table_alias), qi(&f.column))
4621        };
4622        return (
4623            sql,
4624            ShapeNode::NamedTuple {
4625                name: f.alias.clone(),
4626                position: pos,
4627                type_name: shape.type_name.clone(),
4628                members: Some(shape.members.clone()),
4629                is_free_object: false,
4630            },
4631        );
4632    }
4633    let sql = if table_alias.is_empty() {
4634        format!("{}::{}", qi(&f.column), f.pg_type)
4635    } else {
4636        format!("{}.{}::{}", qi(table_alias), qi(&f.column), f.pg_type)
4637    };
4638    (
4639        sql,
4640        ShapeNode::Scalar {
4641            name: f.alias.clone(),
4642            position: pos,
4643        },
4644    )
4645}
4646
4647fn emit_single_link(f: &IrSingleLinkPointer, parent_alias: &str, pos: usize) -> (String, ShapeNode) {
4648    let sub = &f.subquery;
4649    let [IrRowSource::Bound { source, shape }] = sub.rows.as_slice() else {
4650        unreachable!("single-link subquery is always schema-bound")
4651    };
4652    let sub_alias = &source.alias;
4653
4654    let (sub_exprs, mut sub_nodes) = build_shape(shape, sub_alias);
4655    let mut parts = vec![source_type_disc(source)];
4656    parts.extend(sub_exprs);
4657
4658    // Link properties: read from the junction table alias "jt" — only ever
4659    // populated for a junction-backed link (see `IrSingleLinkPointer`'s doc
4660    // comment), which is exactly when `f.correlation` below puts "jt" in
4661    // scope.
4662    for lp in &f.link_properties {
4663        parts.push(format!("\"jt\".{}", qi(&lp.name)));
4664        let pos = sub_nodes.len() + 1;
4665        sub_nodes.push(ShapeNode::Scalar {
4666            name: format!("@{}", lp.name),
4667            position: pos,
4668        });
4669    }
4670
4671    let tuple = parts.join(",\n        ");
4672
4673    let (from_sql, mut where_parts) = match &f.correlation {
4674        IrSingleLinkCorrelation::Fk { fk_column, target_pk } => {
4675            let from = format!("FROM {} AS {}", source_ref(source), qi(sub_alias));
4676            let cond = format!(
4677                "{}.{} = {}.{}",
4678                qi(parent_alias),
4679                qi(fk_column),
4680                qi(sub_alias),
4681                qi(target_pk),
4682            );
4683            (from, vec![cond])
4684        }
4685        // Junction-backed — same join shape a multi-link's correlated
4686        // subquery uses; cardinality-one is a schema-level invariant on the
4687        // junction table, not something this query needs to enforce itself.
4688        IrSingleLinkCorrelation::Junction { join, target_pk: _ } => match join {
4689            IrMultiLinkJoin::Standard { junction_table, module } => {
4690                let from = format!(
4691                    "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".target",
4692                    junction_ref(module, junction_table),
4693                    source_ref(source),
4694                    qi(sub_alias),
4695                    qi(sub_alias),
4696                );
4697                let cond = format!("\"jt\".source = {}.id", qi(parent_alias));
4698                (from, vec![cond])
4699            }
4700            IrMultiLinkJoin::Through {
4701                junction_table,
4702                module,
4703                source_col,
4704                target_col,
4705            } => {
4706                let from = format!(
4707                    "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".{}",
4708                    junction_ref(module, junction_table),
4709                    source_ref(source),
4710                    qi(sub_alias),
4711                    qi(sub_alias),
4712                    qi(target_col),
4713                );
4714                let cond = format!("\"jt\".{} = {}.id", qi(source_col), qi(parent_alias));
4715                (from, vec![cond])
4716            }
4717            IrMultiLinkJoin::BacklinkFk { .. } | IrMultiLinkJoin::BacklinkJunction { .. } => {
4718                unreachable!("a junction-backed single link's own forward join is always Standard or Through")
4719            }
4720        },
4721    };
4722    if let Some(filter) = &sub.filter {
4723        where_parts.push(emit_expr(filter));
4724    }
4725
4726    let mut sql = format!(
4727        "(SELECT (\n        {}\n    )\n    {}\n    WHERE {}",
4728        tuple,
4729        from_sql,
4730        where_parts.join(" AND "),
4731    );
4732    if !sub.order_by.is_empty() {
4733        let s: Vec<_> = sub.order_by.iter().map(emit_sort_clause).collect();
4734        sql.push_str(&format!("\n    ORDER BY {}", s.join(", ")));
4735    }
4736    sql.push(')');
4737
4738    let node = ShapeNode::Object {
4739        name: f.alias.clone(),
4740        type_name: Some(source.type_name.clone()),
4741        position: pos,
4742        cardinality: Cardinality::Optional,
4743        pointers: prepend_type(sub_nodes),
4744        has_implicit_id: shape_has_implicit_id(shape),
4745    };
4746    (sql, node)
4747}
4748
4749fn emit_multi_link(f: &IrMultiLinkPointer, parent_alias: &str, pos: usize) -> (String, ShapeNode) {
4750    let sub = &f.subquery;
4751    let [IrRowSource::Bound { source, shape }] = sub.rows.as_slice() else {
4752        unreachable!("multi-link subquery is always schema-bound")
4753    };
4754    let sub_alias = &source.alias;
4755
4756    let (sub_exprs, mut sub_nodes) = build_shape(shape, sub_alias);
4757    let mut row_parts = vec![source_type_disc(source)];
4758    row_parts.extend(sub_exprs);
4759
4760    // Link properties: read from the junction table alias "jt".
4761    // The ShapeNode name carries the `@` prefix so hydration stores it as
4762    // `@prop` in the object's __dict__ and the REPL displays it with `@`.
4763    for lp in &f.link_properties {
4764        row_parts.push(format!("\"jt\".{}", qi(&lp.name)));
4765        // Positions are 1-based (0 = type discriminator). sub_nodes.len() gives
4766        // the count of already-assigned positions, so the next position is len+1.
4767        let pos = sub_nodes.len() + 1;
4768        sub_nodes.push(ShapeNode::Scalar {
4769            name: format!("@{}", lp.name),
4770            position: pos,
4771        });
4772    }
4773
4774    let row = row_parts.join(",\n            ");
4775
4776    // ORDER BY inside array_agg
4777    let order_sql = if !sub.order_by.is_empty() {
4778        let s: Vec<_> = sub.order_by.iter().map(emit_sort_clause).collect();
4779        format!(" ORDER BY {}", s.join(", "))
4780    } else {
4781        String::new()
4782    };
4783
4784    let (from_sql, source_cond) = match &f.join {
4785        IrMultiLinkJoin::Standard { junction_table, module } => {
4786            let from = format!(
4787                "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".target",
4788                junction_ref(module, junction_table),
4789                source_ref(source),
4790                qi(sub_alias),
4791                qi(sub_alias),
4792            );
4793            let cond = format!("\"jt\".source = {}.id", qi(parent_alias));
4794            (from, cond)
4795        }
4796        IrMultiLinkJoin::Through {
4797            junction_table,
4798            module,
4799            source_col,
4800            target_col,
4801        } => {
4802            let from = format!(
4803                "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".{}",
4804                junction_ref(module, junction_table),
4805                source_ref(source),
4806                qi(sub_alias),
4807                qi(sub_alias),
4808                qi(target_col),
4809            );
4810            let cond = format!("\"jt\".{} = {}.id", qi(source_col), qi(parent_alias));
4811            (from, cond)
4812        }
4813        // Reverse of a single link: no junction table, the owner
4814        // (sub-select) rows are correlated directly by their own FK column.
4815        IrMultiLinkJoin::BacklinkFk { fk_col } => {
4816            let from = format!("FROM {} AS {}", source_ref(source), qi(sub_alias));
4817            let cond = format!("{}.{} = {}.id", qi(sub_alias), qi(fk_col), qi(parent_alias));
4818            (from, cond)
4819        }
4820        // Reverse of a multi-link: same junction table shape as `Standard`/
4821        // `Through`, with the owner/current column roles swapped — the
4822        // sub-select's own rows join via `owner_col`, the outer (current)
4823        // row correlates via `current_col`.
4824        IrMultiLinkJoin::BacklinkJunction {
4825            junction_table,
4826            module,
4827            owner_col,
4828            current_col,
4829        } => {
4830            let from = format!(
4831                "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".{}",
4832                junction_ref(module, junction_table),
4833                source_ref(source),
4834                qi(sub_alias),
4835                qi(sub_alias),
4836                qi(owner_col),
4837            );
4838            let cond = format!("\"jt\".{} = {}.id", qi(current_col), qi(parent_alias));
4839            (from, cond)
4840        }
4841    };
4842
4843    let mut where_parts = vec![source_cond];
4844    if let Some(filter) = &sub.filter {
4845        where_parts.push(emit_expr(filter));
4846    }
4847
4848    if f.single {
4849        let mut sql = format!(
4850            "(SELECT (\n            {}\n        )\n    {}\n    WHERE {}",
4851            row,
4852            from_sql,
4853            where_parts.join(" AND "),
4854        );
4855        append_order_by(&mut sql, &sub.order_by);
4856        append_offset_limit(&mut sql, &sub.offset, &sub.limit);
4857        sql.push(')');
4858        let node = ShapeNode::Object {
4859            name: f.alias.clone(),
4860            type_name: Some(source.type_name.clone()),
4861            position: pos,
4862            cardinality: Cardinality::Optional,
4863            pointers: prepend_type(sub_nodes),
4864            has_implicit_id: shape_has_implicit_id(shape),
4865        };
4866        return (sql, node);
4867    }
4868
4869    if f.single {
4870        let mut sql = format!(
4871            "(SELECT (\n            {}\n        )\n    {}\n    WHERE {}",
4872            row,
4873            from_sql,
4874            where_parts.join(" AND "),
4875        );
4876        append_order_by(&mut sql, &sub.order_by);
4877        append_offset_limit(&mut sql, &sub.offset, &sub.limit);
4878        sql.push(')');
4879        let node = ShapeNode::Object {
4880            name: f.alias.clone(),
4881            type_name: Some(source.type_name.clone()),
4882            position: pos,
4883            cardinality: Cardinality::Optional,
4884            pointers: prepend_type(sub_nodes),
4885            has_implicit_id: shape_has_implicit_id(shape),
4886        };
4887        return (sql, node);
4888    }
4889
4890    let sql = if sub.limit.is_some() || sub.offset.is_some() {
4891        // OFFSET/LIMIT cut the *rows* that go into the array, so they can't
4892        // sit next to the aggregate — the aggregate collapses them to one
4893        // row first. Select the row's parts (and any sort keys) in a derived
4894        // table that carries the modifiers, then aggregate over that.
4895        let mut cols: Vec<String> = row_parts
4896            .iter()
4897            .enumerate()
4898            .map(|(i, part)| format!("{} AS \"c{}\"", part, i))
4899            .collect();
4900        let agg_row: Vec<String> = (0..row_parts.len()).map(|i| format!("\"__lim\".\"c{}\"", i)).collect();
4901        let mut agg_order: Vec<String> = vec![];
4902        for (i, s) in sub.order_by.iter().enumerate() {
4903            cols.push(format!("{} AS \"s{}\"", emit_expr(&s.expr), i));
4904            agg_order.push(emit_sort_clause(&IrSort {
4905                expr: IrExpr::ColumnRef {
4906                    alias: "__lim".to_string(),
4907                    column: format!("s{}", i),
4908                    pg_type: String::new(),
4909                },
4910                direction: s.direction.clone(),
4911                nulls: s.nulls.clone(),
4912            }));
4913        }
4914        let mut inner = format!(
4915            "SELECT {}\n    {}\n    WHERE {}",
4916            cols.join(",\n        "),
4917            from_sql,
4918            where_parts.join(" AND "),
4919        );
4920        append_order_by(&mut inner, &sub.order_by);
4921        append_offset_limit(&mut inner, &sub.offset, &sub.limit);
4922        let agg_order_sql = if agg_order.is_empty() {
4923            String::new()
4924        } else {
4925            format!(" ORDER BY {}", agg_order.join(", "))
4926        };
4927        format!(
4928            "(SELECT COALESCE(\n        array_agg(ROW(\n            {}\n        )::record{}),\n        ARRAY[]::record[]\n    )\n    FROM ({}) AS \"__lim\")",
4929            agg_row.join(",\n            "),
4930            agg_order_sql,
4931            inner,
4932        )
4933    } else {
4934        format!(
4935            "(SELECT COALESCE(\n        array_agg(ROW(\n            {}\n        )::record{}),\n        ARRAY[]::record[]\n    )\n    {}\n    WHERE {})",
4936            row,
4937            order_sql,
4938            from_sql,
4939            where_parts.join(" AND "),
4940        )
4941    };
4942
4943    let node = ShapeNode::Array {
4944        name: f.alias.clone(),
4945        position: pos,
4946        element: Box::new(ShapeNode::Object {
4947            name: String::new(),
4948            type_name: Some(source.type_name.clone()),
4949            position: 0,
4950            cardinality: Cardinality::Required,
4951            pointers: prepend_type(sub_nodes),
4952            has_implicit_id: shape_has_implicit_id(shape),
4953        }),
4954    };
4955    (sql, node)
4956}
4957
4958/// Prepend `ShapeNode::Scalar { name: "__type__", position: 0 }` and shift
4959/// existing nodes' positions by 1.
4960fn prepend_type(nodes: Vec<ShapeNode>) -> Vec<ShapeNode> {
4961    let mut out = vec![ShapeNode::Scalar {
4962        name: "__type__".into(),
4963        position: 0,
4964    }];
4965    out.extend(nodes);
4966    out
4967}
4968
4969// ── SQL clause helpers ──────────────────────────────────────────────────────
4970
4971fn append_filter(sql: &mut String, filter: &Option<IrExpr>) {
4972    if let Some(f) = filter {
4973        sql.push_str(&format!("\nWHERE {}", emit_expr(f)));
4974    }
4975}
4976
4977fn append_order_by(sql: &mut String, order_by: &[IrSort]) {
4978    if !order_by.is_empty() {
4979        let s: Vec<_> = order_by.iter().map(emit_sort_clause).collect();
4980        sql.push_str(&format!("\nORDER BY {}", s.join(", ")));
4981    }
4982}
4983
4984fn append_offset_limit(sql: &mut String, offset: &Option<IrExpr>, limit: &Option<IrExpr>) {
4985    if let Some(o) = offset {
4986        sql.push_str(&format!("\nOFFSET {}", emit_expr(o)));
4987    }
4988    if let Some(l) = limit {
4989        sql.push_str(&format!("\nLIMIT {}", emit_expr(l)));
4990    }
4991}
4992
4993/// `FOR UPDATE`/`FOR SHARE`/... — Postgres's own grammar places this last,
4994/// after `ORDER BY`/`LIMIT`/`OFFSET`, so this must be the final thing
4995/// appended to a bound SELECT's SQL. No `OF table_name` — the outer
4996/// SELECT's own aliased table is always the only lockable target (nested
4997/// shape pointers are correlated subqueries, never part of the outer FROM),
4998/// so a bare `FOR UPDATE` already targets exactly the right row(s).
4999fn append_lock_clause(sql: &mut String, lock: &Option<IrLockClause>) {
5000    let Some(lock) = lock else { return };
5001    let strength = match lock.strength {
5002        IrLockStrength::Update => "UPDATE",
5003        IrLockStrength::NoKeyUpdate => "NO KEY UPDATE",
5004        IrLockStrength::Share => "SHARE",
5005        IrLockStrength::KeyShare => "KEY SHARE",
5006    };
5007    sql.push_str(&format!("\nFOR {}", strength));
5008    match lock.wait {
5009        IrLockWait::Block => {}
5010        IrLockWait::NoWait => sql.push_str(" NOWAIT"),
5011        IrLockWait::SkipLocked => sql.push_str(" SKIP LOCKED"),
5012    }
5013}
5014
5015fn emit_sort_clause(s: &IrSort) -> String {
5016    let dir = match s.direction {
5017        IrSortDir::Asc => "ASC",
5018        IrSortDir::Desc => "DESC",
5019    };
5020    let nulls = match s.nulls {
5021        IrNulls::First => "NULLS FIRST",
5022        IrNulls::Last => "NULLS LAST",
5023    };
5024    format!("{} {} {}", emit_expr(&s.expr), dir, nulls)
5025}
5026
5027// ── Expression emission ─────────────────────────────────────────────────────
5028
5029/// One side of `a intersect b` as the rows of the set it stands for: an
5030/// array's elements, a binding's rows, else the single value.
5031fn set_operand(expr: &IrExpr) -> String {
5032    match expr {
5033        IrExpr::ArrayFromSelect(_) => format!("SELECT unnest({})", emit_expr(expr)),
5034        IrExpr::CteRef { name, scalar, .. } => {
5035            format!("SELECT {} FROM {}", if *scalar { "\"v\"" } else { "\"id\"" }, qi(name))
5036        }
5037        _ => format!("SELECT {}", emit_expr(expr)),
5038    }
5039}
5040
5041/// `emit_expr`, reading the types with subtypes through `fanouts` the way
5042/// `emit` does — see `IrOutput::subtype_fanouts`.
5043pub fn emit_expr_with_fanouts(expr: &IrExpr, fanouts: &HashMap<(String, String), IrPolyFanout>) -> String {
5044    with_subtype_fanouts(fanouts, || emit_expr(expr))
5045}
5046
5047pub fn emit_expr(expr: &IrExpr) -> String {
5048    match expr {
5049        IrExpr::ColumnRef { alias, column, .. } => {
5050            if alias.is_empty() {
5051                qi(column)
5052            } else if alias == "NEW" || alias == "OLD" {
5053                // plpgsql's trigger row variables (`ir::compile_trigger_handler`'s
5054                // `__new__`/`__old__` anchors) are recognized by the plpgsql
5055                // parser as bare, unquoted identifiers — quoting them like an
5056                // ordinary table alias (`"NEW"."col"`) turns this into an
5057                // *ordinary* SQL column reference instead, which Postgres then
5058                // rejects with "missing FROM-clause entry for table NEW" the
5059                // moment it appears inside a nested SQL command (e.g. the
5060                // VALUES list of an `INSERT` the trigger handler runs) — there's
5061                // no real `NEW` table to resolve it against there, only
5062                // plpgsql's own row-variable substitution recognizes it.
5063                format!("{alias}.{}", qi(column))
5064            } else {
5065                format!("{}.{}", qi(alias), qi(column))
5066            }
5067        }
5068        IrExpr::Param { index } => format!("${}", index + 1),
5069        IrExpr::Literal(lit) => emit_literal(lit),
5070        IrExpr::BinOp(op) => {
5071            let l = emit_expr(&op.left);
5072            let r = emit_expr(&op.right);
5073            match op.op {
5074                BinOpKind::Add => format!("({} + {})", l, r),
5075                BinOpKind::Sub => format!("({} - {})", l, r),
5076                BinOpKind::Mul => format!("({} * {})", l, r),
5077                BinOpKind::Div => format!("({} / {})", l, r),
5078                BinOpKind::FloorDiv => {
5079                    if is_integer_expr(&op.left) && is_integer_expr(&op.right) {
5080                        format!("({} / {})", l, r)
5081                    } else {
5082                        format!("floor(({}) / ({}))", l, r)
5083                    }
5084                }
5085                BinOpKind::Mod => format!("({} % {})", l, r),
5086                BinOpKind::Pow => format!("power({}, {})", l, r),
5087                BinOpKind::Eq => format!("({} = {})", l, r),
5088                BinOpKind::Ne => format!("({} <> {})", l, r),
5089                BinOpKind::Lt => format!("({} < {})", l, r),
5090                BinOpKind::Le => format!("({} <= {})", l, r),
5091                BinOpKind::Gt => format!("({} > {})", l, r),
5092                BinOpKind::Ge => format!("({} >= {})", l, r),
5093                BinOpKind::And => format!("({} AND {})", l, r),
5094                BinOpKind::Or => format!("({} OR {})", l, r),
5095                BinOpKind::Like => format!("({} LIKE {})", l, r),
5096                BinOpKind::Ilike => format!("({} ILIKE {})", l, r),
5097                BinOpKind::NotLike => format!("({} NOT LIKE {})", l, r),
5098                BinOpKind::NotIlike => format!("({} NOT ILIKE {})", l, r),
5099                // `ANY`/`ALL` take the array directly, so unwrap a
5100                // right-hand `std::array_unpack` rather than emitting its
5101                // `unnest()` — a set-returning function is not allowed in
5102                // `WHERE`, and PostgreSQL rejects it at execution time even
5103                // though the PyQL and the SQL both look fine.
5104                BinOpKind::In => format!("({} = ANY({}))", l, unwrap_unnest_for_any(&op.right, &r)),
5105                BinOpKind::NotIn => format!("({} <> ALL({}))", l, unwrap_unnest_for_any(&op.right, &r)),
5106                BinOpKind::Coalesce => format!("COALESCE({}, {})", l, r),
5107                BinOpKind::CoalesceEq => format!("({} IS NOT DISTINCT FROM {})", l, r),
5108                BinOpKind::CoalesceNe => format!("({} IS DISTINCT FROM {})", l, r),
5109                BinOpKind::Concat => format!("({} || {})", l, r),
5110            }
5111        }
5112        IrExpr::UnaryOp(op) => {
5113            let inner = emit_expr(&op.operand);
5114            match op.op {
5115                UnaryOpKind::Not => format!("(NOT {})", inner),
5116                UnaryOpKind::Minus => format!("(-{})", inner),
5117                UnaryOpKind::Exists => format!("EXISTS({})", inner),
5118                // A value that reached expression position is a single one,
5119                // and a single value is already distinct — `DISTINCT x` is not
5120                // an SQL expression. Set-valued operands carry their own
5121                // DISTINCT, applied where the set is built.
5122                UnaryOpKind::Distinct => inner,
5123            }
5124        }
5125        IrExpr::FunctionCall(f) => {
5126            let args: Vec<_> = f.args.iter().map(emit_expr).collect();
5127            if let Some(tmpl) = &f.sql_template {
5128                return fill_template(tmpl, &args);
5129            }
5130            let name = match &f.schema {
5131                Some(s) => format!("{}.{}", pg_schema(s), qi(&f.name)),
5132                None => f.name.clone(),
5133            };
5134            format!("{}({})", name, args.join(", "))
5135        }
5136        IrExpr::TypeCast(c) => {
5137            // PostgreSQL doesn't support arbitrary_type::jsonb; to_jsonb() accepts any input.
5138            // String literals have type "unknown" in PG, so cast to text first.
5139            if c.pg_type == "jsonb" {
5140                match &c.expr {
5141                    // A bare `$N` parameter has no PG type at all until told
5142                    // otherwise. to_jsonb($N) can't resolve it — it's a
5143                    // polymorphic function, which needs a *concrete* input
5144                    // type to dispatch on, and an unknown-typed placeholder
5145                    // gives it nothing to work with ("could not determine
5146                    // polymorphic type because input has type unknown").
5147                    // A direct ($N)::jsonb cast works because PG specially
5148                    // resolves an unknown-typed parameter against an
5149                    // explicit cast — the same reason `$1::uuid` works fine
5150                    // elsewhere in this file.
5151                    IrExpr::Param { .. } => format!("({})::jsonb", emit_expr(&c.expr)),
5152                    IrExpr::Literal(IrLiteral::Str(_)) => {
5153                        format!("to_jsonb({}::text)", emit_expr(&c.expr))
5154                    }
5155                    _ => format!("to_jsonb({})", emit_expr(&c.expr)),
5156                }
5157            } else if yields_jsonb(&c.expr) {
5158                // Casting json to a scalar yields the value, not its JSON
5159                // spelling — `<str>` of a json string is the string, with no
5160                // quotes around it. `::text` would keep them.
5161                format!("(({}) #>> '{{}}')::{}", emit_expr(&c.expr), c.pg_type)
5162            } else {
5163                format!("({})::{}", emit_expr(&c.expr), c.pg_type)
5164            }
5165        }
5166        IrExpr::IfElse(ie) => format!(
5167            "CASE WHEN {} THEN {} ELSE {} END",
5168            emit_expr(&ie.condition),
5169            emit_expr(&ie.if_),
5170            emit_expr(&ie.else_),
5171        ),
5172        IrExpr::Array(elems) => {
5173            if elems.is_empty() {
5174                "ARRAY[]::text[]".to_string()
5175            } else {
5176                let parts: Vec<String> = elems.iter().map(emit_expr).collect();
5177                format!("ARRAY[{}]", parts.join(", "))
5178            }
5179        }
5180        IrExpr::Null => "NULL".to_string(),
5181        IrExpr::AggOverSet {
5182            fn_name,
5183            schema: _,
5184            elems,
5185        } => {
5186            let union_all = elems
5187                .iter()
5188                .map(|e| format!("SELECT {}", emit_expr(e)))
5189                .collect::<Vec<_>>()
5190                .join(" UNION ALL ");
5191            format!("(SELECT {}(v) FROM ({}) AS _set(v))", fn_name, union_all)
5192        }
5193        IrExpr::AggOverCte { fn_name, cte, column } => format!(
5194            "(SELECT {}({}) FROM {})",
5195            fn_name,
5196            column.as_deref().map(qi).unwrap_or_else(|| "*".to_string()),
5197            qi(cte),
5198        ),
5199        IrExpr::ExistsOverCte { cte, column } => match column {
5200            Some(column) => format!("EXISTS(SELECT 1 FROM {} WHERE {} IS NOT NULL)", qi(cte), qi(column)),
5201            None => format!("EXISTS(SELECT 1 FROM {})", qi(cte)),
5202        },
5203        IrExpr::AggOverQuery { fn_name, inner } => {
5204            let inner_sql = emit_select_stmt(inner, &[]).sql;
5205            format!("(SELECT {}(*) FROM ({}) _agg)", fn_name, inner_sql)
5206        }
5207        IrExpr::ArrayFromSelect(src) => emit_array_source(src),
5208        IrExpr::ScalarSubquery(sel) => {
5209            format!(
5210                "(SELECT \"v\" FROM (\n{}\n) AS \"_scalar_sub\")",
5211                emit_select_stmt(sel, &[]).sql
5212            )
5213        }
5214
5215        IrExpr::SetOp { op, left, right, mode } => {
5216            let set = format!("({}) {} ({})", set_operand(left), op.sql(), set_operand(right));
5217            match mode {
5218                crate::ir::SetOpMode::Exists => format!("EXISTS({set})"),
5219                crate::ir::SetOpMode::Array => format!("ARRAY({set})"),
5220                crate::ir::SetOpMode::Aggregate(f) => {
5221                    format!("(SELECT {f}(\"_s\".\"v\") FROM ({set}) AS \"_s\"(\"v\"))")
5222                }
5223            }
5224        }
5225
5226        IrExpr::CteRef { name, scalar, .. } => {
5227            // scalar CTEs emit `ROW(expr) AS result, expr AS v`; use `v` for
5228            // expression context so we get the plain scalar type, not record.
5229            let col = if *scalar { "v" } else { "id" };
5230            format!("(SELECT \"{}\" FROM \"{}\")", col, name)
5231        }
5232
5233        IrExpr::CteFieldRef { name, field, .. } => {
5234            format!("(SELECT {} FROM {})", qi(field), qi(name))
5235        }
5236
5237        IrExpr::ForVar { name, .. } => format!("\"_for_{}\".\"v\"", name),
5238
5239        IrExpr::EnumLiteral { pg_type, variant } => {
5240            format!("'{}'::{}", variant.replace('\'', "''"), pg_type)
5241        }
5242
5243        IrExpr::GlobalParam { index, pg_type } => {
5244            format!("(${}::{})", index + 1, pg_type)
5245        }
5246
5247        IrExpr::GlobalRef { cte_name } => {
5248            format!("(SELECT \"value\" FROM \"{}\")", cte_name)
5249        }
5250
5251        IrExpr::NamedTuple { fields, .. } => {
5252            let pairs: Vec<String> = fields
5253                .iter()
5254                .flat_map(|(k, v)| [format!("'{}'", k.replace('\'', "''")), emit_expr(v)])
5255                .collect();
5256            format!("jsonb_build_object({})", pairs.join(", "))
5257        }
5258
5259        IrExpr::Tuple(elems) => {
5260            let items: Vec<String> = elems.iter().map(emit_expr).collect();
5261            format!("jsonb_build_array({})", items.join(", "))
5262        }
5263
5264        IrExpr::Subscript { expr, index, is_array } => {
5265            let e = emit_expr(expr);
5266            let i = emit_expr(index);
5267            if *is_array {
5268                format!("_pylon.array_subscript({}, ({})::bigint)", e, i)
5269            } else {
5270                format!("_pylon.str_subscript({}, ({})::bigint)", e, i)
5271            }
5272        }
5273
5274        IrExpr::Slice {
5275            expr,
5276            lower,
5277            upper,
5278            is_array,
5279        } => {
5280            let e = emit_expr(expr);
5281            if *is_array {
5282                let lo = lower
5283                    .as_deref()
5284                    .map(|x| format!("({}) + 1", emit_expr(x)))
5285                    .unwrap_or_else(|| "1".to_string());
5286                let hi = upper.as_deref().map(emit_expr).unwrap_or_default();
5287                if hi.is_empty() {
5288                    format!("({})[{}:]", e, lo)
5289                } else {
5290                    format!("({})[{}:{}]", e, lo, hi)
5291                }
5292            } else {
5293                // substr(expr, start, length) for text/bytea.
5294                let start = lower
5295                    .as_deref()
5296                    .map(|x| format!("({}) + 1", emit_expr(x)))
5297                    .unwrap_or_else(|| "1".to_string());
5298                match upper.as_deref() {
5299                    Some(hi_expr) => {
5300                        let lo_val = lower.as_deref().map(emit_expr).unwrap_or_else(|| "0".to_string());
5301                        // GREATEST(0, ...) so reversed bounds yield '' instead of a PG error.
5302                        format!(
5303                            "substr({}, {}, GREATEST(0, ({}) - ({})))",
5304                            e,
5305                            start,
5306                            emit_expr(hi_expr),
5307                            lo_val
5308                        )
5309                    }
5310                    None => format!("substr({}, {})", e, start),
5311                }
5312            }
5313        }
5314
5315        IrExpr::JsonbField { expr, field } => {
5316            format!("({}->{})", emit_expr(expr), sql_str(field))
5317        }
5318
5319        IrExpr::JsonbIndex { expr, index } => {
5320            format!("({}->{})", emit_expr(expr), index)
5321        }
5322
5323        IrExpr::FnParam { name, .. } => qi(name),
5324
5325        IrExpr::FnSubquery(fs) => {
5326            let scalar = match fs.shape.as_slice() {
5327                [IrShapePointer::Computed(c)] => emit_expr(&c.expr),
5328                _ => unreachable!("a function scalar subquery always projects exactly one computed pointer"),
5329            };
5330            let args_sql = fs.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
5331            let mut sql = format!(
5332                "(SELECT {}{}\nFROM {}.{}({}) AS {}",
5333                if fs.distinct { "DISTINCT " } else { "" },
5334                scalar,
5335                pg_schema(&fs.fn_module),
5336                qi(&fs.fn_name),
5337                args_sql,
5338                qi(&fs.alias),
5339            );
5340            append_filter(&mut sql, &fs.filter);
5341            append_order_by(&mut sql, &fs.order_by);
5342            append_offset_limit(&mut sql, &fs.offset, &fs.limit);
5343            sql.push(')');
5344            sql
5345        }
5346
5347        IrExpr::PathSubquery(ps) => {
5348            let scalar = match &ps.result {
5349                IrPathResult::Scalar(e, _) => emit_expr(e),
5350                IrPathResult::Object { alias, .. } => format!("{}.\"id\"", qi(alias)),
5351            };
5352            let from_sql = emit_path_joins(&ps.root, &ps.joins);
5353            let mut sql = format!(
5354                "(SELECT {}{}\nFROM {}",
5355                if ps.distinct { "DISTINCT " } else { "" },
5356                scalar,
5357                from_sql
5358            );
5359            append_filter(&mut sql, &ps.filter);
5360            append_order_by(&mut sql, &ps.order_by);
5361            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
5362            sql.push(')');
5363            sql
5364        }
5365
5366        IrExpr::ObjectPathUnion { branches, limit, multi } => {
5367            let arms: Vec<String> = branches
5368                .iter()
5369                .map(|ps| {
5370                    let IrPathResult::Object {
5371                        alias,
5372                        type_name,
5373                        shape,
5374                    } = &ps.result
5375                    else {
5376                        unreachable!("an object path union's branches always land on objects")
5377                    };
5378                    let (pointer_exprs, _) = build_shape(shape, alias);
5379                    let mut parts = vec![result_type_disc(ps, alias, type_name)];
5380                    parts.extend(pointer_exprs);
5381                    let mut sql = format!(
5382                        "SELECT (\n    {}\n) AS \"r\"\nFROM {}",
5383                        parts.join(",\n    "),
5384                        emit_path_joins(&ps.root, &ps.joins),
5385                    );
5386                    append_filter(&mut sql, &ps.filter);
5387                    sql
5388                })
5389                .collect();
5390            let mut sql = format!(
5391                "{}(SELECT \"r\" FROM (\n{}\n) AS \"_u\"",
5392                if *multi { "ARRAY" } else { "" },
5393                arms.join("\nUNION ALL\n")
5394            );
5395            if let Some(limit) = limit {
5396                sql.push_str(&format!("\nLIMIT {}", emit_expr(limit)));
5397            }
5398            sql.push(')');
5399            sql
5400        }
5401
5402        IrExpr::ObjectPathSubquery(ps) => {
5403            let IrPathResult::Object {
5404                alias,
5405                type_name,
5406                shape,
5407            } = &ps.result
5408            else {
5409                unreachable!("an object path subquery always lands on an object")
5410            };
5411            let (pointer_exprs, _) = build_shape(shape, alias);
5412            let mut parts = vec![result_type_disc(ps, alias, type_name)];
5413            parts.extend(pointer_exprs);
5414            let mut sql = format!(
5415                "(SELECT (\n    {}\n)\nFROM {}",
5416                parts.join(",\n    "),
5417                emit_path_joins(&ps.root, &ps.joins),
5418            );
5419            append_filter(&mut sql, &ps.filter);
5420            append_order_by(&mut sql, &ps.order_by);
5421            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
5422            sql.push(')');
5423            sql
5424        }
5425
5426        IrExpr::ObjectSubquery(sel) => {
5427            let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
5428                unreachable!("an object subquery is always schema-bound")
5429            };
5430            let alias = &source.alias;
5431            let (sub_exprs, _) = build_shape(shape, alias);
5432            let mut row_parts = vec![source_type_disc(source)];
5433            row_parts.extend(sub_exprs);
5434            let mut sql = format!(
5435                "(SELECT (\n        {}\n    )\n    FROM {} AS {}",
5436                row_parts.join(",\n        "),
5437                source_ref(source),
5438                qi(alias),
5439            );
5440            append_filter(&mut sql, &sel.filter);
5441            append_order_by(&mut sql, &sel.order_by);
5442            append_offset_limit(&mut sql, &sel.offset, &sel.limit);
5443            sql.push(')');
5444            sql
5445        }
5446
5447        IrExpr::Subquery(sel) => {
5448            let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
5449                unreachable!("scalar/exists subquery is always schema-bound")
5450            };
5451            let alias = &source.alias;
5452            let mut sql = if shape.is_empty() {
5453                // EXISTS inner: SELECT 1 FROM …
5454                format!("(SELECT 1\nFROM {} AS {}", source_ref(source), qi(alias))
5455            } else if let Some(c) = shape.iter().find_map(|f| {
5456                if let IrShapePointer::Computed(c) = f {
5457                    Some(c)
5458                } else {
5459                    None
5460                }
5461            }) {
5462                // Computed subquery (e.g. a type-intersection splat's
5463                // computed pointer): select the compiled expression itself,
5464                // not a bare column — the expression already references
5465                // `alias`'s own columns via ColumnRef.
5466                format!(
5467                    "(SELECT {}\nFROM {} AS {}",
5468                    emit_expr(&c.expr),
5469                    source_ref(source),
5470                    qi(alias)
5471                )
5472            } else {
5473                // Scalar subquery: SELECT alias.col FROM …
5474                let pk_col = shape
5475                    .iter()
5476                    .find_map(|f| {
5477                        if let IrShapePointer::Scalar(s) = f {
5478                            Some(s.column.as_str())
5479                        } else {
5480                            None
5481                        }
5482                    })
5483                    .unwrap_or("id");
5484                format!(
5485                    "(SELECT {}.{}\nFROM {} AS {}",
5486                    qi(alias),
5487                    qi(pk_col),
5488                    source_ref(source),
5489                    qi(alias),
5490                )
5491            };
5492            append_filter(&mut sql, &sel.filter);
5493            append_order_by(&mut sql, &sel.order_by);
5494            append_offset_limit(&mut sql, &sel.offset, &sel.limit);
5495            sql.push(')');
5496            sql
5497        }
5498        IrExpr::RawSql(s) => format!("({s})"),
5499    }
5500}
5501
5502// ── Vector search ────────────────────────────────────────────────────────────
5503
5504fn emit_vector_search(vs: &IrVectorSearch) -> SqlOutput {
5505    let alias = &vs.source.alias;
5506    let dist_sql = format!(
5507        "{}.{} {} {}",
5508        qi(alias),
5509        qi(&vs.vector_col),
5510        vs.distance_op,
5511        emit_expr(&vs.query_expr),
5512    );
5513
5514    // Build the object sub-tuple.  If object_shape is empty, include all properties
5515    // (type disc + id implicitly come from build_shape when no elements provided;
5516    //  with no shape elements the shape is empty, so we fall back to "just id").
5517    // Use build_shape when there are explicit shape pointers; otherwise emit a minimal tuple.
5518    let (obj_tuple, object_shape_nodes) = if vs.object_shape.is_empty() {
5519        // No explicit shape: produce (type_disc, id) as minimum.
5520        let type_expr = type_disc(&vs.source.type_name);
5521        let id_expr = format!("{}.\"id\"", qi(alias));
5522        let tuple = format!("{},\n    {}", type_expr, id_expr);
5523        let id_node = ShapeNode::Scalar {
5524            name: "id".to_string(),
5525            position: 1,
5526        };
5527        (tuple, vec![id_node])
5528    } else {
5529        let (pointer_exprs, shape_pointers) = build_shape(&vs.object_shape, alias);
5530        let mut parts = vec![type_disc(&vs.source.type_name)];
5531        parts.extend(pointer_exprs);
5532        (parts.join(",\n    "), prepend_type(shape_pointers))
5533    };
5534
5535    // Outer tuple: NULL (type slot), object sub-tuple at pos 1, distance at pos 2.
5536    let outer = format!(
5537        "NULL::text,\n    ROW(\n    {}\n    )::record,\n    {}",
5538        obj_tuple, dist_sql,
5539    );
5540    let mut sql = format!(
5541        "SELECT (\n    {}\n) AS result\nFROM {} AS {}",
5542        outer,
5543        source_ref(&vs.source),
5544        qi(alias),
5545    );
5546    append_filter(&mut sql, &vs.filter);
5547
5548    // ORDER BY distance if requested.
5549    if let Some(dir) = &vs.order_by_distance {
5550        let dir_sql = match dir {
5551            IrSortDir::Asc => "ASC",
5552            IrSortDir::Desc => "DESC",
5553        };
5554        sql.push_str(&format!("\nORDER BY {} {}", dist_sql, dir_sql));
5555    }
5556    append_offset_limit(&mut sql, &vs.offset, &vs.limit);
5557
5558    let object_node = ShapeNode::Object {
5559        name: "object".to_string(),
5560        type_name: Some(vs.source.type_name.clone()),
5561        position: 1,
5562        cardinality: Cardinality::Many,
5563        pointers: object_shape_nodes,
5564        has_implicit_id: shape_has_implicit_id(&vs.object_shape),
5565    };
5566    let shape = ShapeDescriptor {
5567        root: ShapeNode::VectorSearch {
5568            object_position: 1,
5569            distance_position: 2,
5570            object_node: Box::new(object_node),
5571        },
5572    };
5573    let inference_plan = vs.inference_model.as_ref().map(|model_name| InferencePlan::Embedding {
5574        model_name: model_name.clone(),
5575        type_name: vs.inference_type_name.clone().unwrap_or_default(),
5576        index_name: vs.inference_index_name.clone().unwrap_or(None),
5577        query_param_name: vs.inference_query_param_name.clone().unwrap_or_default(),
5578        query_literal: vs.inference_query_literal.clone(),
5579    });
5580    SqlOutput {
5581        sql,
5582        shape,
5583        inference_plan,
5584    }
5585}
5586
5587// ── FTS search ───────────────────────────────────────────────────────────────
5588
5589fn emit_fts_search(fs: &IrFtsSearch) -> SqlOutput {
5590    use crate::schema::SearchBackend;
5591    if fs.backend != SearchBackend::Postgres {
5592        return emit_fts_search_deferred(fs);
5593    }
5594
5595    let alias = &fs.source.alias;
5596    let search_col = format!("{}.{}", qi(alias), qi(&fs.search_col));
5597    let query_sql = emit_expr(&fs.query_expr);
5598    let tsquery = format!("{}('english', {})", fs.tsquery_fn, query_sql);
5599    let rank_sql = format!("ts_rank({}, {})", search_col, tsquery);
5600
5601    let (obj_tuple, object_shape_nodes) = if fs.object_shape.is_empty() {
5602        let type_expr = type_disc(&fs.source.type_name);
5603        let id_expr = format!("{}.\"id\"", qi(alias));
5604        let tuple = format!("{},\n    {}", type_expr, id_expr);
5605        let id_node = ShapeNode::Scalar {
5606            name: "id".to_string(),
5607            position: 1,
5608        };
5609        (tuple, vec![id_node])
5610    } else {
5611        let (pointer_exprs, shape_pointers) = build_shape(&fs.object_shape, alias);
5612        let mut parts = vec![type_disc(&fs.source.type_name)];
5613        parts.extend(pointer_exprs);
5614        (parts.join(",\n    "), prepend_type(shape_pointers))
5615    };
5616
5617    let outer = format!(
5618        "NULL::text,\n    ROW(\n    {}\n    )::record,\n    {}",
5619        obj_tuple, rank_sql,
5620    );
5621    let mut sql = format!(
5622        "SELECT (\n    {}\n) AS result\nFROM {} AS {}\nWHERE {} @@ {}",
5623        outer,
5624        source_ref(&fs.source),
5625        qi(alias),
5626        search_col,
5627        tsquery,
5628    );
5629    if let Some(f) = &fs.filter {
5630        sql.push_str(&format!(" AND ({})", emit_expr(f)));
5631    }
5632    if let Some(dir) = &fs.order_by_rank {
5633        let dir_sql = match dir {
5634            IrSortDir::Asc => "ASC",
5635            IrSortDir::Desc => "DESC",
5636        };
5637        sql.push_str(&format!("\nORDER BY {} {}", rank_sql, dir_sql));
5638    }
5639    append_offset_limit(&mut sql, &fs.offset, &fs.limit);
5640
5641    let object_node = ShapeNode::Object {
5642        name: "object".to_string(),
5643        type_name: Some(fs.source.type_name.clone()),
5644        position: 1,
5645        cardinality: Cardinality::Many,
5646        pointers: object_shape_nodes,
5647        has_implicit_id: shape_has_implicit_id(&fs.object_shape),
5648    };
5649    let shape = ShapeDescriptor {
5650        root: ShapeNode::FtsSearch {
5651            object_position: 1,
5652            rank_position: 2,
5653            object_node: Box::new(object_node),
5654        },
5655    };
5656    SqlOutput {
5657        sql,
5658        shape,
5659        inference_plan: None,
5660    }
5661}
5662
5663fn emit_fts_search_deferred(fs: &IrFtsSearch) -> SqlOutput {
5664    let alias = &fs.source.alias;
5665    let ids_idx = fs
5666        .deferred_ids_param
5667        .expect("deferred_ids_param must be set for deferred backend");
5668    let scores_idx = fs
5669        .deferred_scores_param
5670        .expect("deferred_scores_param must be set for deferred backend");
5671    let ids_param = format!("${}", ids_idx + 1);
5672    let scores_param = format!("${}", scores_idx + 1);
5673
5674    let (obj_tuple, object_shape_nodes) = if fs.object_shape.is_empty() {
5675        let type_expr = type_disc(&fs.source.type_name);
5676        let id_expr = format!("{}.\"id\"", qi(alias));
5677        let tuple = format!("{},\n    {}", type_expr, id_expr);
5678        let id_node = ShapeNode::Scalar {
5679            name: "id".to_string(),
5680            position: 1,
5681        };
5682        (tuple, vec![id_node])
5683    } else {
5684        let (pointer_exprs, shape_pointers) = build_shape(&fs.object_shape, alias);
5685        let mut parts = vec![type_disc(&fs.source.type_name)];
5686        parts.extend(pointer_exprs);
5687        (parts.join(",\n    "), prepend_type(shape_pointers))
5688    };
5689
5690    let outer = format!(
5691        "NULL::text,\n    ROW(\n    {}\n    )::record,\n    \"_os\".\"score\"",
5692        obj_tuple,
5693    );
5694    let mut sql = format!(
5695        concat!(
5696            "SELECT (\n    {}\n) AS result\n",
5697            "FROM {} AS {}\n",
5698            "JOIN UNNEST({}::uuid[], {}::float8[]) AS \"_os\"(\"id\", \"score\")\n",
5699            "    ON \"_os\".\"id\" = {}.\"id\"",
5700        ),
5701        outer,
5702        source_ref(&fs.source),
5703        qi(alias),
5704        ids_param,
5705        scores_param,
5706        qi(alias),
5707    );
5708    if let Some(f) = &fs.filter {
5709        sql.push_str(&format!("\nWHERE ({})", emit_expr(f)));
5710    }
5711    if let Some(dir) = &fs.order_by_rank {
5712        let dir_sql = match dir {
5713            IrSortDir::Asc => "ASC",
5714            IrSortDir::Desc => "DESC",
5715        };
5716        sql.push_str(&format!("\nORDER BY \"_os\".\"score\" {}", dir_sql));
5717    }
5718    // limit/offset are passed to OpenSearch as size/from, not emitted in Postgres SQL.
5719    let size = fs.limit.as_ref().and_then(|lim| {
5720        if let IrExpr::Literal(IrLiteral::Int(n)) = lim {
5721            Some(*n as usize)
5722        } else {
5723            None
5724        }
5725    });
5726
5727    let object_node = ShapeNode::Object {
5728        name: "object".to_string(),
5729        type_name: Some(fs.source.type_name.clone()),
5730        position: 1,
5731        cardinality: Cardinality::Many,
5732        pointers: object_shape_nodes,
5733        has_implicit_id: shape_has_implicit_id(&fs.object_shape),
5734    };
5735    let shape = ShapeDescriptor {
5736        root: ShapeNode::FtsSearch {
5737            object_position: 1,
5738            rank_position: 2,
5739            object_node: Box::new(object_node),
5740        },
5741    };
5742    let backend_str = match fs.backend {
5743        crate::schema::SearchBackend::Meilisearch => "meilisearch",
5744        _ => "opensearch",
5745    };
5746    let inference_plan = Some(InferencePlan::Search {
5747        backend: backend_str.to_string(),
5748        index_name: fs.deferred_index_name.clone().unwrap_or_default(),
5749        query_param_name: fs.deferred_query_param_name.clone().unwrap_or_default(),
5750        query_literal: fs.deferred_query_literal.clone(),
5751        size,
5752    });
5753    SqlOutput {
5754        sql,
5755        shape,
5756        inference_plan,
5757    }
5758}
5759
5760// ── Function select ──────────────────────────────────────────────────────────
5761
5762fn emit_function_select(sel: &IrFunctionSelect) -> SqlOutput {
5763    let alias = &sel.alias;
5764    let (pointer_exprs, shape_pointers) = build_shape(&sel.shape, alias);
5765
5766    let type_expr = if sel.polymorphic {
5767        format!("{}.\"__type__\"", qi(alias))
5768    } else {
5769        type_disc(&sel.type_name)
5770    };
5771    let mut parts = vec![type_expr];
5772    parts.extend(pointer_exprs);
5773    let tuple = parts.join(",\n    ");
5774    let distinct = if sel.distinct { "DISTINCT " } else { "" };
5775
5776    let args_sql = sel.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
5777    let fn_call = format!("{}.{}({})", pg_schema(&sel.fn_module), qi(&sel.fn_name), args_sql);
5778
5779    let from_clause = if sel.polymorphic {
5780        // Polymorphic: we can't peek inside the function — assume it returns
5781        // a __type__ column since we control the DDL.  Use the fn call directly.
5782        format!("{} AS {}", fn_call, qi(alias))
5783    } else {
5784        format!("{} AS {}", fn_call, qi(alias))
5785    };
5786
5787    let mut sql = format!("SELECT {}(\n    {}\n) AS result\nFROM {}", distinct, tuple, from_clause,);
5788    append_filter(&mut sql, &sel.filter);
5789    append_order_by(&mut sql, &sel.order_by);
5790    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
5791
5792    let root_pointers = prepend_type(shape_pointers);
5793    SqlOutput {
5794        sql,
5795        shape: ShapeDescriptor {
5796            root: ShapeNode::Object {
5797                name: String::new(),
5798                type_name: Some(sel.type_name.clone()),
5799                position: 0,
5800                cardinality: Cardinality::Many,
5801                pointers: root_pointers,
5802                has_implicit_id: shape_has_implicit_id(&sel.shape),
5803            },
5804        },
5805        inference_plan: None,
5806    }
5807}
5808
5809/// Emit the SQL body expression for a user-defined function DDL.
5810///
5811/// For a bare scalar free select emits just the expression — e.g. `"a" + "b"`.
5812/// For everything else (object functions, non-scalar free selects) emits a
5813/// full `SELECT … FROM …` via `emit_dml_as_cte_source`.
5814pub fn emit_fn_body(ir: &crate::ir::IrOutput) -> String {
5815    with_subtype_fanouts(&ir.subtype_fanouts, || emit_output_fn_body(ir))
5816}
5817
5818fn emit_output_fn_body(ir: &crate::ir::IrOutput) -> String {
5819    let body = match &ir.stmt {
5820        IrStmt::Select(sel) if matches!(sel.rows.as_slice(), [IrRowSource::Free(IrFreeExpr::Scalar(_))]) => {
5821            let IrRowSource::Free(IrFreeExpr::Scalar(e)) = &sel.rows[0] else {
5822                unreachable!()
5823            };
5824            format!("SELECT {}", emit_expr(e))
5825        }
5826        other => emit_dml_as_cte_source(other),
5827    };
5828    if ir.ctes.is_empty() {
5829        body
5830    } else {
5831        let cte_prefix = emit_cte_prefix(&ir.ctes);
5832        format!("{}{}", cte_prefix, body)
5833    }
5834}
5835
5836fn emit_literal(lit: &IrLiteral) -> String {
5837    match lit {
5838        IrLiteral::Str(s) => sql_str(s),
5839        IrLiteral::Int(i) => i.to_string(),
5840        IrLiteral::Float(f) => {
5841            // Explicit ::float8 cast — a bare untyped numeral like `1.0`
5842            // defaults to Postgres `numeric`, but an un-cast PyQL float
5843            // literal means `float64`; only an explicit `<decimal>`/`123n`
5844            // literal should ever produce a real decimal. Without this,
5845            // `numeric`'s different wire OID
5846            // also broke decoding inside ROW() composites (see
5847            // _pg_decode_numeric in pylon/client.py, still needed for
5848            // genuine decimal casts).
5849            let s = f.to_string();
5850            let s = if s.contains('.') || s.contains('e') {
5851                s
5852            } else {
5853                format!("{}.0", s)
5854            };
5855            format!("({}::float8)", s)
5856        }
5857        IrLiteral::Bool(b) => {
5858            if *b {
5859                "TRUE".into()
5860            } else {
5861                "FALSE".into()
5862            }
5863        }
5864    }
5865}
5866
5867// ── Tests ────────────────────────────────────────────────────────────────────
5868
5869#[cfg(test)]
5870mod tests {
5871    use super::*;
5872    use crate::ir;
5873    use crate::parse;
5874    use crate::schema::{
5875        FunctionDescriptor, FunctionParamDescriptor, GlobalDescriptor, LinkDescriptor, MultiLinkDescriptor,
5876        NamedTupleDescriptor, PropertyDescriptor, SchemaDescriptor, TypeDescriptor,
5877    };
5878
5879    #[test]
5880    fn a_template_argument_keeps_the_parameters_it_carries() {
5881        // The first argument carries query parameter `$2`; filled one number
5882        // at a time, it would be rewritten into the second argument.
5883        let filled = fill_template("f($1, $2, $10)", &["($2)::int".to_string(), "b".to_string()]);
5884        assert_eq!(filled, "f(($2)::int, b, $10)");
5885    }
5886
5887    fn make_schema() -> SchemaDescriptor {
5888        SchemaDescriptor {
5889            types: vec![
5890                TypeDescriptor {
5891                    name: "Person".into(),
5892                    module: "default".into(),
5893                    table: "Person".into(),
5894                    abstract_: false,
5895                    materialized: false,
5896                    description: None,
5897                    parents: vec![],
5898                    interfaces: vec![],
5899                    bases: vec![],
5900                    properties: vec![
5901                        PropertyDescriptor {
5902                            name: "id".into(),
5903                            pg_type: "uuid".into(),
5904                            nullable: false,
5905                            default_sql: Some("uuidv7()".into()),
5906                            default_pyql: None,
5907                            description: None,
5908                            check_constraints: vec![],
5909                            is_exclusive: true,
5910                            is_pk: true,
5911                            is_readonly: true,
5912                            rewrites: vec![],
5913                            tuple_members: None,
5914                            column_type: None,
5915                        },
5916                        PropertyDescriptor {
5917                            name: "name".into(),
5918                            pg_type: "text".into(),
5919                            nullable: false,
5920                            default_sql: None,
5921                            default_pyql: None,
5922                            description: None,
5923                            check_constraints: vec![],
5924                            is_exclusive: false,
5925                            is_pk: false,
5926                            is_readonly: false,
5927                            rewrites: vec![],
5928                            tuple_members: None,
5929                            column_type: None,
5930                        },
5931                        PropertyDescriptor {
5932                            name: "age".into(),
5933                            pg_type: "int8".into(),
5934                            nullable: true,
5935                            default_sql: None,
5936                            default_pyql: None,
5937                            description: None,
5938                            check_constraints: vec![],
5939                            is_exclusive: false,
5940                            is_pk: false,
5941                            is_readonly: false,
5942                            rewrites: vec![],
5943                            tuple_members: None,
5944                            column_type: None,
5945                        },
5946                    ],
5947                    links: vec![LinkDescriptor {
5948                        name: "company".into(),
5949                        target: "default::Company".into(),
5950                        nullable: true,
5951                        through: None,
5952                        description: None,
5953                        default_pyql: None,
5954                        is_exclusive: false,
5955                        is_readonly: false,
5956                        rewrites: vec![],
5957                        on_delete: vec![],
5958                    }],
5959                    multilinks: vec![MultiLinkDescriptor {
5960                        name: "posts".into(),
5961                        target: "default::Post".into(),
5962                        through: None,
5963                        nullable: false,
5964                        description: None,
5965                        default_pyql: None,
5966                        on_delete: vec![],
5967                        is_exclusive: false,
5968                    }],
5969                    computed: vec![],
5970                    constraints: vec![],
5971                    indexes: vec![],
5972                    partition: None,
5973                    vector_indexes: vec![],
5974                    search_indexes: vec![],
5975                    triggers: vec![],
5976                    junction: false,
5977                    signals: vec![],
5978                },
5979                TypeDescriptor {
5980                    name: "Company".into(),
5981                    module: "default".into(),
5982                    table: "Company".into(),
5983                    abstract_: false,
5984                    materialized: false,
5985                    description: None,
5986                    parents: vec![],
5987                    interfaces: vec![],
5988                    bases: vec![],
5989                    properties: vec![PropertyDescriptor {
5990                        name: "name".into(),
5991                        pg_type: "text".into(),
5992                        nullable: false,
5993                        default_sql: None,
5994                        default_pyql: None,
5995                        description: None,
5996                        check_constraints: vec![],
5997                        is_exclusive: false,
5998                        is_pk: false,
5999                        is_readonly: false,
6000                        rewrites: vec![],
6001                        tuple_members: None,
6002                        column_type: None,
6003                    }],
6004                    links: vec![],
6005                    multilinks: vec![],
6006                    computed: vec![],
6007                    constraints: vec![],
6008                    indexes: vec![],
6009                    partition: None,
6010                    vector_indexes: vec![],
6011                    search_indexes: vec![],
6012                    triggers: vec![],
6013                    junction: false,
6014                    signals: vec![],
6015                },
6016                TypeDescriptor {
6017                    name: "Post".into(),
6018                    module: "default".into(),
6019                    table: "Post".into(),
6020                    abstract_: false,
6021                    materialized: false,
6022                    description: None,
6023                    parents: vec![],
6024                    interfaces: vec![],
6025                    bases: vec![],
6026                    properties: vec![PropertyDescriptor {
6027                        name: "title".into(),
6028                        pg_type: "text".into(),
6029                        nullable: false,
6030                        default_sql: None,
6031                        default_pyql: None,
6032                        description: None,
6033                        check_constraints: vec![],
6034                        is_exclusive: false,
6035                        is_pk: false,
6036                        is_readonly: false,
6037                        rewrites: vec![],
6038                        tuple_members: None,
6039                        column_type: None,
6040                    }],
6041                    links: vec![],
6042                    multilinks: vec![],
6043                    computed: vec![],
6044                    constraints: vec![],
6045                    indexes: vec![],
6046                    partition: None,
6047                    vector_indexes: vec![],
6048                    search_indexes: vec![],
6049                    triggers: vec![],
6050                    junction: false,
6051                    signals: vec![],
6052                },
6053            ],
6054            scalars: vec![],
6055            enums: vec![],
6056            named_tuples: vec![],
6057            globals: vec![],
6058            functions: vec![],
6059            aliases: vec![],
6060            channels: vec![],
6061            ..Default::default()
6062        }
6063    }
6064
6065    fn compile_and_emit(query: &str) -> SqlOutput {
6066        let schema = make_schema();
6067        compile_and_emit_with(query, &schema)
6068    }
6069
6070    fn compile_and_emit_with(query: &str, schema: &SchemaDescriptor) -> SqlOutput {
6071        let ast = parse::parse(query).expect("parse failed");
6072        let ir = ir::compile(&ast, schema).expect("IR compile failed");
6073        emit(&ir)
6074    }
6075
6076    #[test]
6077    fn a_nested_read_inside_a_with_opens_no_second_with() {
6078        let out = compile_and_emit_with(
6079            "WITH a := (SELECT Person FILTER .age > 1), b := (SELECT Person LIMIT 1) SELECT (SELECT (a UNION b))",
6080            &make_schema(),
6081        );
6082        assert_eq!(out.sql.matches("WITH").count(), 1, "{}", out.sql);
6083    }
6084
6085    #[test]
6086    fn a_comparison_against_a_multi_row_with_binding_tests_membership() {
6087        // Read as a scalar subquery, the binding aborts the query on its
6088        // second row: "more than one row returned by a subquery used as an
6089        // expression".
6090        let out = compile_and_emit_with(
6091            "WITH companies := (SELECT Company FILTER .name = 'x') SELECT Person FILTER .company = companies",
6092            &make_schema(),
6093        );
6094        assert!(
6095            out.sql.contains("= ANY((SELECT \"id\" FROM \"companies\"))"),
6096            "{}",
6097            out.sql
6098        );
6099
6100        let out = compile_and_emit_with(
6101            "WITH companies := (SELECT Company) SELECT Person FILTER any(.company = companies)",
6102            &make_schema(),
6103        );
6104        assert!(
6105            out.sql.contains("= ANY((SELECT \"id\" FROM \"companies\"))"),
6106            "{}",
6107            out.sql
6108        );
6109
6110        let out = compile_and_emit_with(
6111            "WITH companies := (SELECT Company) SELECT Person FILTER .company != companies",
6112            &make_schema(),
6113        );
6114        assert!(out.sql.contains("NOT (\"t1\".\"company_id\" = ANY("), "{}", out.sql);
6115    }
6116
6117    #[test]
6118    fn a_comparison_against_a_single_row_with_binding_stays_a_scalar_read() {
6119        let out = compile_and_emit_with(
6120            "WITH company := (SELECT Company LIMIT 1) SELECT Person FILTER .company = company",
6121            &make_schema(),
6122        );
6123        assert!(out.sql.contains("= (SELECT \"id\" FROM \"company\")"), "{}", out.sql);
6124    }
6125
6126    #[test]
6127    fn an_aggregate_over_a_backlink_counts_inside_a_subquery() {
6128        let out = compile_and_emit_with("SELECT Post { n := count(.<posts) }", &make_schema());
6129        assert!(
6130            !out.sql.contains("count((SELECT"),
6131            "an aggregate over a scalar subquery:\n{}",
6132            out.sql
6133        );
6134        assert!(out.sql.contains("count("), "{}", out.sql);
6135    }
6136
6137    #[test]
6138    fn an_aggregate_over_a_relative_subselect_counts_inside_a_subquery() {
6139        let out = compile_and_emit_with(
6140            "SELECT Post { n := count((SELECT .<posts FILTER .age > 1)) }",
6141            &make_schema(),
6142        );
6143        assert!(
6144            !out.sql.contains("count((SELECT"),
6145            "an aggregate over a scalar subquery:\n{}",
6146            out.sql
6147        );
6148        assert!(
6149            out.sql.contains(r#"(SELECT count("_s"."v") FROM unnest(ARRAY("#),
6150            "{}",
6151            out.sql
6152        );
6153    }
6154
6155    #[test]
6156    fn any_over_a_link_type_check_tests_each_element() {
6157        let out = compile_and_emit_with(
6158            "SELECT Company { name } FILTER any(.<company.posts IS Post)",
6159            &make_schema(),
6160        );
6161        assert!(
6162            !out.sql.contains("bool_or(ARRAY"),
6163            "an aggregate over outer rows:\n{}",
6164            out.sql
6165        );
6166        assert!(
6167            out.sql
6168                .contains("FROM unnest(ARRAY(SELECT ('default::Post' = 'default::Post')"),
6169            "{}",
6170            out.sql
6171        );
6172    }
6173
6174    #[test]
6175    fn a_link_type_check_reads_the_linked_object() {
6176        let out = compile_and_emit_with("SELECT Person { name } FILTER .company IS Company", &make_schema());
6177        assert!(!out.sql.contains("WHERE FALSE"), "{}", out.sql);
6178        assert!(out.sql.contains("'default::Company'"), "{}", out.sql);
6179    }
6180
6181    /// `make_schema` with an `Admin` extending `Person`, in a table of its own.
6182    fn schema_with_a_subtype() -> SchemaDescriptor {
6183        let mut schema = make_schema();
6184        let mut admin = schema.types[0].clone();
6185        admin.name = "Admin".into();
6186        admin.table = "Admin".into();
6187        admin.bases = vec!["default::Person".into()];
6188        schema.types.push(admin);
6189        schema
6190    }
6191
6192    #[test]
6193    fn a_type_with_subtypes_reads_their_rows_too() {
6194        let out = compile_and_emit_with("SELECT Person { name }", &schema_with_a_subtype());
6195        assert!(
6196            out.sql.contains("'default::Admin'::text AS \"__type__\"") && out.sql.contains("FROM \"public\".\"Admin\""),
6197            "expected the subtype's table in the read:\n{}",
6198            out.sql
6199        );
6200    }
6201
6202    #[test]
6203    fn a_path_from_a_type_with_subtypes_reads_their_rows_too() {
6204        let out = compile_and_emit_with("SELECT Person.name", &schema_with_a_subtype());
6205        assert!(out.sql.contains("FROM \"public\".\"Admin\""), "{}", out.sql);
6206    }
6207
6208    #[test]
6209    fn a_multilink_of_a_type_with_subtypes_reads_their_junctions_too() {
6210        let out = compile_and_emit_with("SELECT Person { posts: { title } }", &schema_with_a_subtype());
6211        assert!(
6212            out.sql.contains(
6213                "(SELECT \"source\", \"target\" FROM \"public\".\"Person.posts\" UNION ALL \
6214                 SELECT \"source\", \"target\" FROM \"public\".\"Admin.posts\")"
6215            ),
6216            "expected the junctions unioned:\n{}",
6217            out.sql
6218        );
6219    }
6220
6221    #[test]
6222    fn is_a_supertype_matches_its_subtypes() {
6223        let out = compile_and_emit_with(
6224            "SELECT Person { name } FILTER Person IS Admin",
6225            &schema_with_a_subtype(),
6226        );
6227        assert!(out.sql.contains("\"__type__\" = 'default::Admin'"), "{}", out.sql);
6228    }
6229
6230    #[test]
6231    fn a_write_to_a_type_with_subtypes_reaches_their_tables() {
6232        let out = compile_and_emit_with("UPDATE Person SET { age := 1 }", &schema_with_a_subtype());
6233        assert!(
6234            out.sql.contains("UPDATE \"public\".\"Person\"") && out.sql.contains("UPDATE \"public\".\"Admin\""),
6235            "expected both tables updated:\n{}",
6236            out.sql
6237        );
6238        assert!(
6239            !out.sql.contains("UPDATE (\n"),
6240            "a write never targets the union:\n{}",
6241            out.sql
6242        );
6243    }
6244
6245    #[test]
6246    fn test_comparing_a_multilink_itself_to_an_object() {
6247        // `filter any(.posts = post)` — the multi-link with no tail step. It
6248        // used to reach `compile_path`, which only knows properties, links
6249        // and computeds, and reported "has no link or property 'posts'".
6250        let out = compile_and_emit_with(
6251            "WITH p := (SELECT Post LIMIT 1) SELECT Person { id } FILTER any(.posts = p)",
6252            &make_schema(),
6253        );
6254        assert!(
6255            out.sql.contains("EXISTS((SELECT 1\nFROM \"public\".\"Person.posts\""),
6256            "expected a junction EXISTS:\n{}",
6257            out.sql
6258        );
6259        assert!(
6260            out.sql.contains("\"target\" = (SELECT \"id\" FROM \"p\")"),
6261            "expected the junction target compared to the bound object's id:\n{}",
6262            out.sql
6263        );
6264    }
6265
6266    #[test]
6267    fn test_comparing_a_multilink_itself_warns_without_any() {
6268        let ast = parse::parse("WITH p := (SELECT Post LIMIT 1) SELECT Person { id } FILTER .posts = p").unwrap();
6269        let ir = ir::compile(&ast, &make_schema()).unwrap();
6270        assert!(
6271            ir.warnings.iter().any(|w| w.contains("multi-link '.posts'")),
6272            "expected the set-valued FILTER warning, got: {:?}",
6273            ir.warnings
6274        );
6275    }
6276
6277    #[test]
6278    fn test_detached_names_the_type_of_a_nested_select() {
6279        // `detached T` as an insert's link value: the prefix used to hide the
6280        // type name from the nested select's subject resolution, which then
6281        // reported "expected a type name as SELECT subject".
6282        let out = compile_and_emit_with(
6283            "INSERT Person { name := $n, company := (SELECT detached Company FILTER .name = $c LIMIT 1) }",
6284            &make_schema(),
6285        );
6286        assert!(
6287            out.sql.contains("INSERT INTO \"public\".\"Person\""),
6288            "expected the insert to compile:\n{}",
6289            out.sql
6290        );
6291        assert!(
6292            out.sql.contains("FROM \"public\".\"Company\""),
6293            "expected the detached select to read the Company table:\n{}",
6294            out.sql
6295        );
6296    }
6297
6298    #[test]
6299    fn test_select_over_an_update_on_a_with_binding() {
6300        // A bare `with` binding as an update's subject names rows, not a
6301        // type. The top-level form already knew that; wrapping it in a select
6302        // asked what type the statement yields and got the binding's name,
6303        // reported as "unknown type 'p'".
6304        let out = compile_and_emit_with(
6305            "WITH p := (SELECT Person FILTER .id = $a LIMIT 1) SELECT (UPDATE p SET { name := $n }) { id }",
6306            &make_schema(),
6307        );
6308        assert!(
6309            out.sql.contains("UPDATE \"public\".\"Person\""),
6310            "expected the update to resolve to the bound type's table:\n{}",
6311            out.sql
6312        );
6313    }
6314
6315    #[test]
6316    fn test_a_mutation_as_a_free_object_field() {
6317        // Already allowed as an item of a free *set*; a named field of a free
6318        // object is the same question and used to answer "an update cannot
6319        // stand in for a value".
6320        let out = compile_and_emit_with(
6321            "SELECT { renamed := (UPDATE Person FILTER .id = $a SET { name := $n }), n := 1 }",
6322            &make_schema(),
6323        );
6324        assert!(
6325            out.sql.contains("UPDATE \"public\".\"Person\""),
6326            "expected the mutation to become a data-modifying CTE:\n{}",
6327            out.sql
6328        );
6329        assert!(
6330            out.sql
6331                .contains("(SELECT \"t1\".\"id\"\nFROM \"_nested_dml_0\" AS \"t1\") AS \"_f0\""),
6332            "expected the field to carry the rows the mutation touched:\n{}",
6333            out.sql
6334        );
6335    }
6336
6337    #[test]
6338    fn test_a_sub_selects_filter_stays_on_its_own_subject_when_a_field_chain_projects_off_it() {
6339        // `(select Person filter .id = $a).company.name` filters People, not
6340        // Companies. The merge that splices the field chain onto the inner
6341        // select used to leave the filter to bind against the last step.
6342        let out = compile_and_emit_with("SELECT (SELECT Person FILTER .name = $n).company.name", &make_schema());
6343        assert!(
6344            out.sql.contains("WHERE (\"t0\".\"name\" = $1)"),
6345            "the inner filter belongs to the Person alias:\n{}",
6346            out.sql
6347        );
6348    }
6349
6350    #[test]
6351    fn test_reading_back_a_nested_insert_through_its_own_statement() {
6352        let out = compile_and_emit_with(
6353            "SELECT (INSERT Person { name := $n, posts := (INSERT Post { title := $t }) }) { id, posts: { title } }",
6354            &make_schema(),
6355        );
6356        assert!(out.sql.contains("__ml_add_0"), "got:\n{}", out.sql);
6357    }
6358
6359    #[test]
6360    fn test_reading_back_a_nested_insert_within_its_own_statement() {
6361        // Postgres does not show one statement's CTE writes to the rest of
6362        // that statement, so reading the link back off the base tables found
6363        // nothing and it hydrated as empty/None (confirmed live).
6364        let out = compile_and_emit_with(
6365            "SELECT (INSERT Person { name := $n, posts := (INSERT Post { title := $t }) }) \
6366             { id, posts: { title } }",
6367            &make_schema(),
6368        );
6369        assert!(
6370            out.sql.contains("FROM \"_dml__ml_add_0\" AS \"jt\""),
6371            "the junction rows must come from the CTE that wrote them:\n{}",
6372            out.sql
6373        );
6374        assert!(
6375            out.sql.contains("\"_nested_dml_0\""),
6376            "and the targets from the nested insert's own CTE:\n{}",
6377            out.sql
6378        );
6379    }
6380
6381    #[test]
6382    fn test_a_nested_single_link_insert_reads_back_from_its_cte() {
6383        let out = compile_and_emit_with(
6384            "SELECT (INSERT Person { name := $n, company := (INSERT Company { name := $c }) }) \
6385             { id, company: { name } }",
6386            &make_schema(),
6387        );
6388        assert!(
6389            out.sql.contains("FROM \"_nested_dml_0\""),
6390            "the linked row must be read from the CTE that inserted it:\n{}",
6391            out.sql
6392        );
6393    }
6394
6395    #[test]
6396    fn test_a_free_object_field_holding_an_object_keeps_it_an_object() {
6397        // A free shape is a real object type whose fields are real pointers,
6398        // so an object field stays an object. Pylon used to build the free
6399        // object as jsonb, which flattened it to the bare id.
6400        let out = compile_and_emit_with(
6401            "WITH c := (SELECT Company LIMIT 1) SELECT { co := c { name }, n := 1 }",
6402            &make_schema(),
6403        );
6404        assert!(
6405            out.sql.contains("'default::Company'::text"),
6406            "the field must carry the object's own row:\n{}",
6407            out.sql
6408        );
6409    }
6410
6411    #[test]
6412    fn test_projecting_an_object_off_a_free_object() {
6413        // `.co` is a path step through a pointer, not jsonb extraction, so
6414        // what comes back is the object with its shape.
6415        let out = compile_and_emit_with(
6416            "WITH c := (SELECT Company LIMIT 1) SELECT { co := c { name }, n := 1 }.co",
6417            &make_schema(),
6418        );
6419        assert!(
6420            !out.sql.contains("jsonb_build_object"),
6421            "projecting a pointer must not go through jsonb:\n{}",
6422            out.sql
6423        );
6424        assert!(
6425            out.sql.contains("SELECT v AS result, v FROM"),
6426            "the object row is already the result row:\n{}",
6427            out.sql
6428        );
6429    }
6430
6431    #[test]
6432    fn test_a_shape_on_a_single_valued_object_path() {
6433        // `c := .company { name }` — an object route existed only for walks
6434        // that crossed a multi step, so a single-valued one fell through to
6435        // expression position and reported "shapes and set literals are not
6436        // valid in expression context".
6437        let out = compile_and_emit_with("SELECT Person { c := .company { name } }", &make_schema());
6438        assert!(
6439            out.sql.contains("'default::Company'::text"),
6440            "the pointer must carry the object's own row:\n{}",
6441            out.sql
6442        );
6443        assert!(
6444            !out.sql.contains("ARRAY(SELECT"),
6445            "a single-valued walk is one object, not an array of one:\n{}",
6446            out.sql
6447        );
6448    }
6449
6450    #[test]
6451    fn test_a_shapeless_single_link_still_reads_as_its_value() {
6452        // The object route is taken only when a shape says an object was
6453        // meant; without one this stays what it always was.
6454        let out = compile_and_emit_with("SELECT Person { c := .company }", &make_schema());
6455        assert!(
6456            !out.sql.contains("'default::Company'::text"),
6457            "a bare link reference must not grow an object row:\n{}",
6458            out.sql
6459        );
6460    }
6461
6462    #[test]
6463    fn test_a_tuple_element_holding_a_shaped_object() {
6464        // `select (c { name }, 1)` — an element is the same question a free
6465        // object's field asks, and used to report "shapes and set literals
6466        // are not valid in expression context".
6467        let out = compile_and_emit_with(
6468            "WITH c := (SELECT Company LIMIT 1) SELECT (c { name }, 1)",
6469            &make_schema(),
6470        );
6471        assert!(
6472            out.sql.contains("'default::Company'::text"),
6473            "the element must carry the object's own row:\n{}",
6474            out.sql
6475        );
6476    }
6477
6478    #[test]
6479    fn test_an_object_returning_function_as_a_link_value() {
6480        // `created_by := account_of_transaction()` — the function names the
6481        // row to link to, so its key is what gets stored. The schema itself
6482        // writes this as a link default; written out in a query it used to be
6483        // refused as "part of a larger expression".
6484        let mut schema = make_schema();
6485        // make_schema's Company carries only a name; a link's value is its key.
6486        let company = schema
6487            .types
6488            .iter_mut()
6489            .find(|t| t.name == "Company")
6490            .expect("make_schema declares Company");
6491        company.properties.push(PropertyDescriptor {
6492            name: "id".into(),
6493            pg_type: "uuid".into(),
6494            nullable: false,
6495            default_sql: None,
6496            default_pyql: None,
6497            description: None,
6498            check_constraints: vec![],
6499            is_exclusive: true,
6500            is_pk: true,
6501            is_readonly: true,
6502            rewrites: vec![],
6503            tuple_members: None,
6504            column_type: None,
6505        });
6506        schema.functions.push(crate::schema::FunctionDescriptor {
6507            name: "current_company".into(),
6508            module: "default".into(),
6509            params: vec![],
6510            return_pg_type: "default::Company".into(),
6511            return_is_object: true,
6512            return_is_set: false,
6513            return_is_polymorphic: false,
6514            volatility: "stable".into(),
6515            body: "select Company limit 1".into(),
6516        });
6517        let out = compile_and_emit_with("INSERT Person { name := $n, company := current_company() }", &schema);
6518        assert!(
6519            out.sql.contains("FROM \"public\".\"current_company\"()"),
6520            "the function must supply the foreign key:\n{}",
6521            out.sql
6522        );
6523    }
6524
6525    /// `make_schema` plus a `Company`-returning function and a `Person.company`
6526    /// link defaulting to it — jaldis's `Creatable.created_by :=
6527    /// account_of_transaction()` in miniature.
6528    fn make_schema_with_an_object_returning_default() -> SchemaDescriptor {
6529        let mut schema = make_schema();
6530        let company = schema
6531            .types
6532            .iter_mut()
6533            .find(|t| t.name == "Company")
6534            .expect("make_schema declares Company");
6535        company.properties.push(PropertyDescriptor {
6536            name: "id".into(),
6537            pg_type: "uuid".into(),
6538            nullable: false,
6539            default_sql: None,
6540            default_pyql: None,
6541            description: None,
6542            check_constraints: vec![],
6543            is_exclusive: true,
6544            is_pk: true,
6545            is_readonly: true,
6546            rewrites: vec![],
6547            tuple_members: None,
6548            column_type: None,
6549        });
6550        schema.functions.push(crate::schema::FunctionDescriptor {
6551            name: "current_company".into(),
6552            module: "default".into(),
6553            params: vec![],
6554            return_pg_type: "default::Company".into(),
6555            return_is_object: true,
6556            return_is_set: false,
6557            return_is_polymorphic: false,
6558            volatility: "stable".into(),
6559            body: "select Company limit 1".into(),
6560        });
6561        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
6562        let company_link = person.links.iter_mut().find(|l| l.name == "company").unwrap();
6563        company_link.default_pyql = Some("current_company()".into());
6564        schema
6565    }
6566
6567    #[test]
6568    fn an_object_returning_link_default_is_applied_by_the_insert() {
6569        // A column DEFAULT cannot hold this one — PostgreSQL evaluates it with
6570        // no query in scope — so the insert has to carry it, expanded into
6571        // the shape.
6572        let schema = make_schema_with_an_object_returning_default();
6573        let out = compile_and_emit_with("INSERT Person { name := $n }", &schema);
6574        assert!(
6575            out.sql.contains("\"company_id\"") && out.sql.contains("current_company"),
6576            "the default must supply the foreign key:\n{}",
6577            out.sql
6578        );
6579    }
6580
6581    #[test]
6582    fn an_explicit_value_wins_over_an_inlined_default() {
6583        let schema = make_schema_with_an_object_returning_default();
6584        let out = compile_and_emit_with("INSERT Person { name := $n, company := {} }", &schema);
6585        assert!(
6586            !out.sql.contains("current_company"),
6587            "the shape named the pointer, so the default does not apply:\n{}",
6588            out.sql
6589        );
6590    }
6591
6592    #[test]
6593    fn an_object_returning_link_default_gets_no_column_default() {
6594        // Emitting one produces DDL PostgreSQL refuses to run; it used to be
6595        // dropped silently here, leaving the pointer with no default at all.
6596        let schema = make_schema_with_an_object_returning_default();
6597        let ddl = crate::export::export_schema(&schema).unwrap();
6598        assert!(
6599            !ddl.contains("\"company_id\" uuid DEFAULT"),
6600            "the column must carry no default:\n{ddl}"
6601        );
6602        crate::validate::validate_schema_types(&schema).expect("an inlined default is not an error");
6603    }
6604
6605    #[test]
6606    fn a_column_expressible_default_stays_in_the_ddl() {
6607        // Only what a column DEFAULT cannot hold moves into the insert.
6608        let mut schema = make_schema();
6609        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
6610        let age = person.properties.iter_mut().find(|p| p.name == "age").unwrap();
6611        age.default_pyql = Some("21".into());
6612        assert!(
6613            crate::ir::inlined_pointer_defaults(schema.types.iter().find(|t| t.name == "Person").unwrap(), &schema)
6614                .is_empty()
6615        );
6616        let ddl = crate::export::export_schema(&schema).unwrap();
6617        assert!(ddl.contains("DEFAULT 21"), "{ddl}");
6618        let out = compile_and_emit_with("INSERT Person { name := $n }", &schema);
6619        assert!(
6620            !out.sql.contains("21"),
6621            "the column DEFAULT still applies it:\n{}",
6622            out.sql
6623        );
6624    }
6625
6626    /// `make_schema` plus an `Employee` extending `Person`, where `Person`
6627    /// declares a computed naming itself — jaldis's
6628    /// `BrandAddon.bundle := (BrandAddon is BrandAddonBundle)` in miniature.
6629    fn make_schema_with_an_inherited_self_naming_computed() -> SchemaDescriptor {
6630        let mut schema = make_schema();
6631        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
6632        person.computed.push(crate::schema::ComputedDescriptor {
6633            name: "own_name".into(),
6634            expression: "default::Person.name".into(),
6635            return_type: Some("text".into()),
6636            link_target: None,
6637            link_multi: false,
6638        });
6639        let mut employee = person.clone();
6640        employee.name = "Employee".into();
6641        employee.table = "Employee".into();
6642        employee.bases = vec!["default::Person".into()];
6643        schema.types.push(employee);
6644        schema
6645    }
6646
6647    #[test]
6648    fn an_inherited_computed_naming_its_declaring_type_still_means_the_row() {
6649        // A computed is compiled once, against the type that declares it, so
6650        // the name reaches the subject on every subtype that inherits it.
6651        // Pylon compiles it again per subtype, where the name matched nothing
6652        // and the reference read the whole table instead.
6653        let schema = make_schema_with_an_inherited_self_naming_computed();
6654        let out = compile_and_emit_with("SELECT Employee { own_name }", &schema);
6655        assert!(
6656            !out.sql.contains("\"Person\""),
6657            "the computed must read the Employee row, not Person's table:\n{}",
6658            out.sql
6659        );
6660        crate::validate::validate_schema_types(&schema).expect("an inherited computed is single-valued");
6661    }
6662
6663    #[test]
6664    fn an_inherited_type_check_computed_tests_the_row_it_is_read_on() {
6665        // jaldis's own: `BrandAddon.bundle := (BrandAddon is BrandAddonBundle)`,
6666        // read on `BrandAddonBundle`. `is` had a same-scope check of its own
6667        // that only knew the two spellings of the type being compiled, so on
6668        // the subtype it read `BrandAddon` as a separate set — many values
6669        // behind a declared `bool`.
6670        let mut schema = make_schema();
6671        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
6672        person.computed.push(crate::schema::ComputedDescriptor {
6673            name: "employed".into(),
6674            expression: "(default::Person is default::Employee)".into(),
6675            return_type: Some("boolean".into()),
6676            link_target: None,
6677            link_multi: false,
6678        });
6679        let mut employee = person.clone();
6680        employee.name = "Employee".into();
6681        employee.table = "Employee".into();
6682        employee.bases = vec!["default::Person".into()];
6683        schema.types.push(employee);
6684
6685        crate::validate::validate_schema_types(&schema).expect("the check is single-valued on both types");
6686        let out = compile_and_emit_with("SELECT Employee { employed }", &schema);
6687        assert!(
6688            !out.sql.contains("\"Person\""),
6689            "the check must read the Employee row's own type:\n{}",
6690            out.sql
6691        );
6692    }
6693
6694    #[test]
6695    fn a_shape_writing_the_computed_out_itself_does_not_get_the_anchor() {
6696        // The rule is narrow: only a *declared* computed is compiled against
6697        // its declaring type. Naming a supertype anywhere else is that
6698        // supertype's own set —
6699        // `select BrandAddonBundle { n := count(BrandAddon) }` is the full
6700        // count while the same shape naming `BrandAddonBundle` is 1. So the
6701        // same expression written out in a shape keeps resolving on its own,
6702        // even where it shadows the inherited computed's name.
6703        let schema = make_schema_with_an_inherited_self_naming_computed();
6704        let ast = parse::parse("SELECT Employee { own_name := default::Person.name }").expect("parse failed");
6705        let Err(error) = ir::compile(&ast, &schema) else {
6706            panic!("an absolute path has nothing to anchor to here")
6707        };
6708        assert!(
6709            format!("{error:?}").contains("absolute paths are not valid"),
6710            "{error:?}"
6711        );
6712    }
6713
6714    #[test]
6715    fn a_default_that_compiles_nowhere_is_still_an_error() {
6716        // jaldis carried `sequence_next(INTROSPECT marketplace::OrderNo)`,
6717        // which PyQL cannot parse. Inlining must not turn that into silence.
6718        let mut schema = make_schema();
6719        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
6720        let age = person.properties.iter_mut().find(|p| p.name == "age").unwrap();
6721        age.default_pyql = Some("sequence_next(INTROSPECT default::AgeSeq)".into());
6722        let errs = crate::validate::validate_schema_types(&schema).unwrap_err();
6723        let (_, msg, _) = errs[0].class_name_message_position();
6724        assert!(msg.contains("default"), "{msg}");
6725    }
6726
6727    #[test]
6728    fn test_comparing_a_link_with_an_object_returning_function() {
6729        // Comparing objects compares identity, so the call stands for the id
6730        // of the row it returns. Left whole it is a function call with nowhere
6731        // to go but the subject of a select.
6732        let mut schema = make_schema();
6733        // make_schema's Company carries only a name; identity needs its key.
6734        let company = schema
6735            .types
6736            .iter_mut()
6737            .find(|t| t.name == "Company")
6738            .expect("make_schema declares Company");
6739        company.properties.push(PropertyDescriptor {
6740            name: "id".into(),
6741            pg_type: "uuid".into(),
6742            nullable: false,
6743            default_sql: None,
6744            default_pyql: None,
6745            description: None,
6746            check_constraints: vec![],
6747            is_exclusive: true,
6748            is_pk: true,
6749            is_readonly: true,
6750            rewrites: vec![],
6751            tuple_members: None,
6752            column_type: None,
6753        });
6754        schema.functions.push(crate::schema::FunctionDescriptor {
6755            name: "current_company".into(),
6756            module: "default".into(),
6757            params: vec![],
6758            return_pg_type: "default::Company".into(),
6759            return_is_object: true,
6760            return_is_set: false,
6761            return_is_polymorphic: false,
6762            volatility: "stable".into(),
6763            body: "select Company limit 1".into(),
6764        });
6765        let out = compile_and_emit_with("SELECT Person FILTER .company = current_company()", &schema);
6766        assert!(
6767            out.sql.contains("\"company_id\"") && out.sql.contains("current_company"),
6768            "the link's key against the function's row:\n{}",
6769            out.sql
6770        );
6771    }
6772
6773    #[test]
6774    fn test_choosing_between_two_object_sets() {
6775        // `A if cond else B` over objects is not the scalar CASE an if/else
6776        // over values gives: it stands for A's rows when the condition holds
6777        // and B's when it does not.
6778        let out = compile_and_emit_with(
6779            "WITH a := (SELECT Person FILTER .name = $n), b := (SELECT Person) \
6780             SELECT (a IF EXISTS a ELSE b) { name }",
6781            &make_schema(),
6782        );
6783        assert!(
6784            out.sql.contains("UNION ALL"),
6785            "expected one branch per side:\n{}",
6786            out.sql
6787        );
6788    }
6789
6790    #[test]
6791    fn test_a_union_operand_written_inline() {
6792        // A union reads one relation per branch, so an operand that names no
6793        // relation yet -- a sub-select, or an insert in a get-or-create -- is
6794        // hoisted into a CTE of its own first.
6795        let out = compile_and_emit_with(
6796            "SELECT ((SELECT Person FILTER .name = $n) UNION (SELECT Person)) { name }",
6797            &make_schema(),
6798        );
6799        assert!(
6800            out.sql.starts_with("WITH"),
6801            "expected the operands hoisted:\n{}",
6802            out.sql
6803        );
6804        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
6805    }
6806
6807    #[test]
6808    fn test_a_for_loop_variable_over_objects() {
6809        // The variable holds the row's key, so a path off it reads that row
6810        // back. It used to be treated as a scalar throughout, and any path
6811        // rooted at one reported "unknown type".
6812        let out = compile_and_emit_with("FOR p IN (SELECT Person) UNION (SELECT p.name)", &make_schema());
6813        assert!(
6814            out.sql.contains("FROM \"public\".\"Person\"") && out.sql.contains("\"_for_p\".\"v\""),
6815            "the body must read the row the variable names:\n{}",
6816            out.sql
6817        );
6818    }
6819
6820    #[test]
6821    fn test_a_shape_on_a_for_loop_variable() {
6822        let out = compile_and_emit_with("FOR p IN (SELECT Person) UNION (SELECT p { name })", &make_schema());
6823        assert!(
6824            out.sql.contains("'default::Person'::text"),
6825            "a shape on the variable yields its object:\n{}",
6826            out.sql
6827        );
6828    }
6829
6830    #[test]
6831    fn test_a_for_loop_variable_over_values_is_still_a_scalar() {
6832        let out = compile_and_emit_with("FOR n IN {1, 2} UNION (SELECT n)", &make_schema());
6833        assert!(out.sql.contains("VALUES"), "{}", out.sql);
6834    }
6835
6836    #[test]
6837    fn test_exists_over_a_sub_select_on_a_relative_path() {
6838        // `exists ((select .posts filter …))` — the sub-select is relative to
6839        // the enclosing object, so it needs the same rooting and correlation
6840        // `count((select .posts))` gets. Compiled without them it resolved in
6841        // free context and called the pointer unknown.
6842        let out = compile_and_emit_with(
6843            "SELECT Person { id } FILTER EXISTS ((SELECT .posts FILTER .title = $t))",
6844            &make_schema(),
6845        );
6846        assert!(out.sql.contains("EXISTS("), "{}", out.sql);
6847        assert!(
6848            out.sql.contains("\"Person.posts\""),
6849            "the junction join must survive into the EXISTS:\n{}",
6850            out.sql
6851        );
6852        assert!(
6853            out.sql.contains("\"t0\".\"id\""),
6854            "and it must correlate to the enclosing row:\n{}",
6855            out.sql
6856        );
6857    }
6858
6859    #[test]
6860    fn test_counting_the_rows_a_mutation_touched() {
6861        // `count((delete X filter …))` — the mutation runs as its own
6862        // data-modifying CTE and the aggregate reads it back, rather than
6863        // being refused as "a delete cannot stand in for a value".
6864        let out = compile_and_emit_with("SELECT count((DELETE Person FILTER .name = $n))", &make_schema());
6865        assert!(out.sql.starts_with("WITH"), "{}", out.sql);
6866        assert!(out.sql.contains("DELETE FROM \"public\".\"Person\""), "{}", out.sql);
6867        assert!(out.sql.contains("count(*)"), "{}", out.sql);
6868    }
6869
6870    #[test]
6871    fn test_a_conflict_target_naming_several_columns() {
6872        // `unless conflict on (.a, .b)` names columns for Postgres to infer
6873        // the arbiter index from; emitted as one composite value it would
6874        // look for an expression index on the tuple instead.
6875        let out = compile_and_emit_with(
6876            "INSERT Person { name := $n } UNLESS CONFLICT ON (.name, .id) ELSE (UPDATE Person SET { name := $n })",
6877            &make_schema(),
6878        );
6879        assert!(
6880            out.sql.contains("ON CONFLICT (\"name\", \"id\")"),
6881            "expected a column list:\n{}",
6882            out.sql
6883        );
6884    }
6885
6886    #[test]
6887    fn test_a_for_loop_over_a_path() {
6888        // `for post in p.posts union (…)` — a walk off a binding names a set
6889        // to iterate just as a sub-select does, only written without the
6890        // parentheses.
6891        let out = compile_and_emit_with(
6892            "WITH p := (SELECT Person LIMIT 1) FOR post IN p.posts UNION (SELECT post.title)",
6893            &make_schema(),
6894        );
6895        assert!(out.sql.contains("CROSS JOIN LATERAL"), "{}", out.sql);
6896    }
6897
6898    #[test]
6899    fn test_a_relative_sub_select_keeps_its_enclosing_object() {
6900        // `assert_exists((select .posts { title }))` and `posts := (select
6901        // .posts filter …)` in an update both compiled their sub-select with
6902        // no enclosing object, so the relative path resolved in free context
6903        // and the pointer came back unknown.
6904        let out = compile_and_emit_with(
6905            "SELECT Person { ps := assert_exists((SELECT .posts { title })) }",
6906            &make_schema(),
6907        );
6908        assert!(out.sql.contains("assert_exists"), "{}", out.sql);
6909        assert!(
6910            out.sql.contains("\"Person.posts\""),
6911            "the walk must reach the junction:\n{}",
6912            out.sql
6913        );
6914    }
6915
6916    #[test]
6917    fn test_a_relative_sub_select_as_a_multi_link_value() {
6918        let out = compile_and_emit_with(
6919            "UPDATE Person FILTER .name = $n SET { posts := (SELECT .posts FILTER .title = $t) }",
6920            &make_schema(),
6921        );
6922        assert!(
6923            out.sql.contains("\"Person.posts\""),
6924            "the value must read the row's own posts:\n{}",
6925            out.sql
6926        );
6927    }
6928
6929    #[test]
6930    fn test_a_shape_written_after_a_sub_select() {
6931        // `(select Post filter …) { title }` means the same as putting the
6932        // shape inside the parentheses, so the shape is pushed onto the inner
6933        // statement's own result rather than refused.
6934        let out = compile_and_emit_with(
6935            "SELECT Person { ps := (SELECT Post FILTER .title = $t) { title } }",
6936            &make_schema(),
6937        );
6938        assert!(
6939            out.sql.contains("'default::Post'::text"),
6940            "the pointer must carry the object rows:\n{}",
6941            out.sql
6942        );
6943    }
6944
6945    #[test]
6946    fn test_a_shape_after_a_sub_select_with_a_with_block() {
6947        let out = compile_and_emit_with(
6948            "SELECT Person { ps := (WITH t := $t SELECT Post FILTER .title = t) { title } }",
6949            &make_schema(),
6950        );
6951        assert!(out.sql.contains("'default::Post'::text"), "{}", out.sql);
6952    }
6953
6954    #[test]
6955    fn test_a_computed_declared_as_an_object_returning_call() {
6956        // `recent := recent()` — the rows the function yields are the
6957        // pointer's objects. Read as a plain expression the call was "part of
6958        // a larger expression", which such a function refuses, so a splat over
6959        // a type carrying one could not compile at all.
6960        let mut schema = make_schema();
6961        schema.functions.push(crate::schema::FunctionDescriptor {
6962            name: "recent".into(),
6963            module: "default".into(),
6964            params: vec![],
6965            return_pg_type: "default::Post".into(),
6966            return_is_object: true,
6967            return_is_set: true,
6968            return_is_polymorphic: false,
6969            volatility: "stable".into(),
6970            body: "select Post".into(),
6971        });
6972        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
6973            name: "recent".into(),
6974            expression: "default::recent()".into(),
6975            return_type: None,
6976            link_target: None,
6977            link_multi: false,
6978        });
6979        let out = compile_and_emit_with("SELECT Person { recent }", &schema);
6980        assert!(
6981            out.sql.contains("ARRAY(SELECT") && out.sql.contains("'default::Post'::text"),
6982            "the pointer must carry the function's rows:\n{}",
6983            out.sql
6984        );
6985    }
6986
6987    #[test]
6988    fn test_one_branch_of_an_object_if_else_is_empty() {
6989        // `{}` contributes no rows, so the full rewrite (`SELECT A WHERE
6990        // Cond UNION ALL SELECT B WHERE NOT Cond`) reduces to the other
6991        // branch under its guard.
6992        let out = compile_and_emit_with(
6993            "WITH p := (SELECT Person LIMIT 1) SELECT p { name } IF EXISTS p ELSE {}",
6994            &make_schema(),
6995        );
6996        assert!(!out.sql.contains("UNION ALL"), "nothing to union with:\n{}", out.sql);
6997        assert!(out.sql.contains("'default::Person'::text"), "{}", out.sql);
6998    }
6999
7000    #[test]
7001    fn test_a_conditional_insert_is_guarded_by_its_condition() {
7002        // `(insert …) if cond else {}` — guarding a reader would not do: the
7003        // insert is a data-modifying CTE that Postgres runs regardless, so
7004        // the condition goes into the insert itself and it writes nothing
7005        // when false. Verified live: running it twice leaves one row.
7006        let out = compile_and_emit_with("SELECT (INSERT Person { name := $n }) IF FALSE ELSE {}", &make_schema());
7007        assert!(
7008            out.sql.contains("SELECT") && out.sql.contains("WHERE"),
7009            "a guarded insert selects its row rather than listing values:\n{}",
7010            out.sql
7011        );
7012        assert!(!out.sql.contains("VALUES"), "VALUES always yields a row:\n{}", out.sql);
7013    }
7014
7015    #[test]
7016    fn test_a_conditional_update_carries_its_condition() {
7017        // Guarding the branch only filters what is read back -- the mutation
7018        // is its own CTE and Postgres runs it regardless. An update has a
7019        // WHERE of its own to fold the condition into, which is what keeps it
7020        // from running when the condition is false.
7021        let out = compile_and_emit(
7022            "SELECT (UPDATE Person FILTER .name = $n SET { name := $m }) IF EXISTS (SELECT Company) ELSE {}",
7023        );
7024        let update = out.sql.find("UPDATE").expect("an update is emitted");
7025        let where_clause = out.sql[update..].find("WHERE").expect("the update is filtered");
7026        assert!(
7027            out.sql[update + where_clause..].contains("EXISTS"),
7028            "the condition narrows the rows the update touches:\n{}",
7029            out.sql
7030        );
7031    }
7032
7033    #[test]
7034    fn test_a_conditional_delete_carries_its_condition() {
7035        // Guarding the branch would only filter what is read back — Postgres
7036        // runs a data-modifying CTE regardless — so the condition has to be
7037        // ANDed into the DELETE's own WHERE, as it already is for an update.
7038        let out = compile_and_emit("SELECT (DELETE Person FILTER .name = $n) IF FALSE ELSE {}");
7039        let delete = out
7040            .sql
7041            .split("DELETE FROM")
7042            .nth(1)
7043            .expect("the delete should still be emitted");
7044        let where_clause = delete.split("WHERE").nth(1).expect("the delete should be filtered");
7045        assert!(
7046            where_clause.contains("FALSE") || where_clause.contains("false"),
7047            "the condition must narrow the delete itself:\n{}",
7048            out.sql
7049        );
7050    }
7051
7052    #[test]
7053    fn test_a_pointer_a_binding_declared_in_its_own_shape() {
7054        // `p := (select Person { boss := … })` then `p { boss }` — the
7055        // pointer is on no type, so reading it back has to find it through
7056        // the binding it was written on, and compile it against that row.
7057        let out = compile_and_emit_with(
7058            "WITH p := (SELECT Person { co := .company { name } } LIMIT 1) SELECT p { name, co: { name } }",
7059            &make_schema(),
7060        );
7061        assert!(
7062            out.sql.contains("'default::Company'::text"),
7063            "the declared pointer must be inlined:\n{}",
7064            out.sql
7065        );
7066    }
7067
7068    #[test]
7069    fn test_a_binding_declared_pointer_read_from_a_tuple_element() {
7070        let out = compile_and_emit_with(
7071            "WITH p := (SELECT Person { co := .company { name } } LIMIT 1) SELECT (p { co: { name } }, 1)",
7072            &make_schema(),
7073        );
7074        assert!(out.sql.contains("'default::Company'::text"), "{}", out.sql);
7075    }
7076
7077    #[test]
7078    fn test_a_for_loop_variable_shaped_inside_a_tuple() {
7079        // The variable holds one row's key, so the element has to be narrowed
7080        // to it. Unfiltered it read the whole table and answered with rows
7081        // the loop never named -- wrong, and silently so.
7082        let out = compile_and_emit_with(
7083            "WITH p := (SELECT Person LIMIT 1) FOR q IN p.posts UNION (SELECT (a := q { title }, b := 1))",
7084            &make_schema(),
7085        );
7086        assert!(
7087            out.sql.contains("\"_for_q\".\"v\""),
7088            "the element must be narrowed to the loop's row:\n{}",
7089            out.sql
7090        );
7091    }
7092
7093    #[test]
7094    fn test_choosing_between_two_walks() {
7095        // `(select p.posts …) if cond else (select p.posts …)` — a walk names
7096        // no type of its own, so neither branch could be resolved.
7097        let out = compile_and_emit_with(
7098            "WITH p := (SELECT Person LIMIT 1), q := (SELECT p.posts LIMIT 1) IF TRUE ELSE (SELECT p.posts) \
7099             SELECT q { title }",
7100            &make_schema(),
7101        );
7102        assert!(out.sql.contains("UNION ALL"), "one branch per side:\n{}", out.sql);
7103    }
7104
7105    #[test]
7106    fn test_a_bare_coalesce_of_correlated_walks_reaching_many_objects() {
7107        // Without a shape the walks stand for the ids they reach, which is
7108        // still a set -- SQL's COALESCE takes one value per side.
7109        let out = compile_and_emit_with(
7110            "SELECT Post { owners := .<posts[is Person] ?? .<posts[is Person] }",
7111            &make_schema(),
7112        );
7113        assert!(
7114            !out.sql.contains("COALESCE("),
7115            "not a value-by-value choice:\n{}",
7116            out.sql
7117        );
7118        assert!(
7119            out.sql.contains("ARRAY(SELECT \"r\" FROM ("),
7120            "the arms are aggregated:\n{}",
7121            out.sql
7122        );
7123    }
7124
7125    #[test]
7126    fn test_a_union_of_correlated_walks_reaching_many() {
7127        // Without the LIMIT the union stands for every object both walks
7128        // reach, so its arms have to be aggregated. Read as a lone record it
7129        // compiled fine and failed in PostgreSQL on the second row, with
7130        // "more than one row returned by a subquery used as an expression".
7131        let out = compile_and_emit_with(
7132            "SELECT Post { owners := (.<posts[is Person] UNION .<posts[is Person]) { name } }",
7133            &make_schema(),
7134        );
7135        assert!(
7136            out.sql.contains("ARRAY(SELECT \"r\" FROM ("),
7137            "the arms are aggregated:\n{}",
7138            out.sql
7139        );
7140    }
7141
7142    #[test]
7143    fn test_a_coalesce_of_correlated_walks_reaching_many() {
7144        // `([is A].links ?? [is B].links) { … }` — the same set a union of
7145        // them gives, so it aggregates the same way.
7146        let out = compile_and_emit_with(
7147            "SELECT Post { owners := (.<posts[is Person] ?? .<posts[is Person]) { name } }",
7148            &make_schema(),
7149        );
7150        assert!(
7151            out.sql.contains("ARRAY(SELECT \"r\" FROM ("),
7152            "the arms are aggregated:\n{}",
7153            out.sql
7154        );
7155    }
7156
7157    #[test]
7158    fn test_a_union_of_correlated_walks() {
7159        // `(.<a[is T] union .<b[is T]) { id }` — each operand hangs off the
7160        // enclosing row, so none can be hoisted into a CTE the way a
7161        // standalone union's operands are; they are read as one set instead.
7162        let out = compile_and_emit_with(
7163            "SELECT Post { owners := (SELECT (.<posts[is Person] UNION .<posts[is Person]) { name } LIMIT 1) }",
7164            &make_schema(),
7165        );
7166        assert!(out.sql.contains("UNION ALL"), "one arm per operand:\n{}", out.sql);
7167        assert!(
7168            out.sql.contains("AS \"_u\""),
7169            "the arms are read as one set:\n{}",
7170            out.sql
7171        );
7172    }
7173
7174    #[test]
7175    fn test_a_for_loop_whose_body_updates() {
7176        // Driven from the iteration with `UPDATE … FROM`: the select-bodied
7177        // form uses a LATERAL, which cannot hold DML. Verified live -- three
7178        // rows iterated, three rows updated.
7179        let out = compile_and_emit_with(
7180            "FOR p IN (SELECT Person) UNION (UPDATE Person FILTER .id = p.id SET { name := $n })",
7181            &make_schema(),
7182        );
7183        assert!(out.sql.contains("UPDATE \"public\".\"Person\""), "{}", out.sql);
7184        assert!(
7185            out.sql.contains("FROM \"_for_p\""),
7186            "driven from the iteration:\n{}",
7187            out.sql
7188        );
7189        assert!(
7190            !out.sql.contains("LATERAL"),
7191            "DML cannot sit in a LATERAL:\n{}",
7192            out.sql
7193        );
7194    }
7195
7196    #[test]
7197    fn test_a_for_loop_body_appends_the_row_it_is_iterating() {
7198        let out = compile_and_emit(
7199            "WITH t := (SELECT Post) FOR q IN t UNION (UPDATE Person FILTER .id = $i SET { posts += q })",
7200        );
7201        // One junction row per iteration, taken from the iteration itself --
7202        // not every Post, which an uncorrelated value subquery would append.
7203        assert!(
7204            out.sql.contains("\"_iter\"") && out.sql.contains("FROM \"_ids\""),
7205            "junction rows driven from the iteration:\n{}",
7206            out.sql
7207        );
7208        assert!(
7209            !out.sql.contains("LATERAL"),
7210            "DML cannot sit in a LATERAL:\n{}",
7211            out.sql
7212        );
7213    }
7214
7215    #[test]
7216    fn test_select_on_a_for_loop_variable_reads_only_its_row() {
7217        let out = compile_and_emit("FOR p IN (SELECT Person) UNION (SELECT (SELECT p) { name })");
7218        assert!(
7219            out.sql.contains("= \"_for_p\".\"v\""),
7220            "narrowed to the row the variable holds:\n{}",
7221            out.sql
7222        );
7223    }
7224
7225    #[test]
7226    fn test_a_walk_off_a_for_loop_variable_reads_the_binding() {
7227        // The rows a sibling CTE just wrote are not in the base table yet, so
7228        // a walk that re-reads the table finds nothing.
7229        let out = compile_and_emit("WITH ps := (SELECT Post) FOR q IN ps UNION (SELECT Person FILTER .name = q.title)");
7230        assert!(
7231            out.sql.contains("FROM \"ps\""),
7232            "the walk reads the binding:\n{}",
7233            out.sql
7234        );
7235    }
7236
7237    #[test]
7238    fn test_a_mutating_for_loop_binding_becomes_sibling_ctes() {
7239        let out = compile_and_emit(
7240            "WITH made := (FOR n IN {'a', 'b'} UNION (INSERT Post { title := n })) SELECT count(made)",
7241        );
7242        assert!(
7243            out.sql.contains("\"made__ids\" AS (\nINSERT INTO"),
7244            "the insert is its own top-level CTE:\n{}",
7245            out.sql
7246        );
7247        assert!(
7248            !out.sql.contains("LATERAL"),
7249            "DML cannot sit in a LATERAL:\n{}",
7250            out.sql
7251        );
7252    }
7253
7254    #[test]
7255    fn test_count_of_a_binding_counts_its_rows() {
7256        // Read as an ordinary expression a binding becomes a scalar subquery,
7257        // which aborts on the second row.
7258        let out = compile_and_emit("WITH people := (SELECT Person) SELECT count(people)");
7259        assert!(out.sql.contains("(SELECT count(*) FROM \"people\")"), "{}", out.sql);
7260    }
7261
7262    #[test]
7263    fn test_dividing_integers_yields_a_float() {
7264        for (query, expected) in [
7265            ("SELECT 100 / 133", "((100)::float8 / 133)"),
7266            ("SELECT Person { a := .age / 2 }", "::float8 / 2)"),
7267            ("SELECT Person.age / 2", "::float8 / 2)"),
7268            (
7269                "WITH total := max({133}), current := 38 - 1 SELECT 100 / total * current",
7270                "(((100)::float8 / (SELECT \"v\" FROM \"total\")) * (SELECT \"v\" FROM \"current\"))",
7271            ),
7272            (
7273                "WITH p := (SELECT Person LIMIT 1), index := p.age - 1, total := max(Person.age) SELECT 100 / total * index",
7274                "((100)::float8 / (SELECT \"v\" FROM \"total\"))",
7275            ),
7276        ] {
7277            let out = compile_and_emit(query);
7278            assert!(out.sql.contains(expected), "{query}:\n{}", out.sql);
7279        }
7280    }
7281
7282    #[test]
7283    fn test_dividing_anything_but_two_integers_is_left_to_postgres() {
7284        for query in [
7285            "SELECT 100 // 133",
7286            "SELECT 100 / 1.5",
7287            "SELECT <decimal>100 / <decimal>3",
7288            "SELECT 100 / <decimal>3",
7289        ] {
7290            let out = compile_and_emit(query);
7291            assert!(!out.sql.contains("::float8 /"), "{query}:\n{}", out.sql);
7292        }
7293    }
7294
7295    #[test]
7296    fn test_a_free_object_under_a_condition_stays_an_object() {
7297        for query in [
7298            "SELECT { a := 1 } IF true ELSE {}",
7299            "SELECT {} IF true ELSE { a := 1 }",
7300            "SELECT { a := 1 } IF true ELSE {} LIMIT 1",
7301        ] {
7302            let out = compile_and_emit(query);
7303            assert!(
7304                matches!(&out.shape.root, ShapeNode::Object { type_name: None, pointers, .. } if pointers.len() == 1),
7305                "{query}: {:?}\n{}",
7306                out.shape.root,
7307                out.sql
7308            );
7309        }
7310    }
7311
7312    #[test]
7313    fn test_exists_of_a_binding_asks_for_any_row() {
7314        let out = compile_and_emit("WITH people := (SELECT Person) SELECT exists people");
7315        assert!(out.sql.contains("EXISTS(SELECT 1 FROM \"people\")"), "{}", out.sql);
7316
7317        let out = compile_and_emit("WITH names := {'a', 'b'} SELECT exists names");
7318        assert!(
7319            out.sql
7320                .contains("EXISTS(SELECT 1 FROM \"names\" WHERE \"v\" IS NOT NULL)"),
7321            "{}",
7322            out.sql
7323        );
7324    }
7325
7326    #[test]
7327    fn test_a_conditional_insert_beside_a_read_branch_unions_both() {
7328        let out = compile_and_emit(
7329            "WITH existing := (SELECT Person FILTER .name = $n LIMIT 1) \
7330             SELECT (existing IF EXISTS existing ELSE (INSERT Person { name := $n })) { name }",
7331        );
7332        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
7333        assert!(
7334            out.sql.contains("INSERT INTO") && out.sql.contains("WHERE (NOT EXISTS(SELECT 1 FROM \"existing\"))"),
7335            "the insert carries the condition itself:\n{}",
7336            out.sql
7337        );
7338    }
7339
7340    /// `@pylon.abstract` — unlike an interface it is materialised as nothing
7341    /// at all, so nothing can read a relation under its name. Modelled on
7342    /// `Person`, the one test type carrying the `id` every implementor of a
7343    /// real abstract has.
7344    fn make_schema_with_a_plain_abstract() -> SchemaDescriptor {
7345        let mut schema = make_schema();
7346        let mut archivable = schema
7347            .types
7348            .iter()
7349            .find(|t| t.name == "Person")
7350            .expect("Person is in the test schema")
7351            .clone();
7352        archivable.name = "Archivable".into();
7353        archivable.table = "Archivable".into();
7354        archivable.abstract_ = true;
7355        archivable.materialized = false;
7356        archivable.links = vec![];
7357        archivable.multilinks = vec![];
7358        schema.types.push(archivable);
7359        let person = schema
7360            .types
7361            .iter_mut()
7362            .find(|t| t.name == "Person")
7363            .expect("just found");
7364        person.parents.push("default::Archivable".into());
7365        schema
7366    }
7367
7368    #[test]
7369    fn test_an_intersection_with_a_plain_abstract_fans_out() {
7370        let schema = make_schema_with_a_plain_abstract();
7371        let out = compile_and_emit_with("SELECT Post { who := [is default::Archivable].name }", &schema);
7372        assert!(
7373            !out.sql.contains("\"Archivable\""),
7374            "a plain abstract backs no relation:\n{}",
7375            out.sql
7376        );
7377        assert!(
7378            out.sql.contains("\"public\".\"Person\""),
7379            "read from the type that carries the column:\n{}",
7380            out.sql
7381        );
7382    }
7383
7384    /// The same plain abstract, but keeping `Person`'s `company` link so a
7385    /// backlink can be narrowed to it the way jaldis narrows one to
7386    /// `default::Creatable`.
7387    fn make_schema_with_a_linking_plain_abstract() -> SchemaDescriptor {
7388        let mut schema = make_schema_with_a_plain_abstract();
7389        let person_links = schema
7390            .types
7391            .iter()
7392            .find(|t| t.name == "Person")
7393            .expect("Person is in the test schema")
7394            .links
7395            .clone();
7396        let archivable = schema
7397            .types
7398            .iter_mut()
7399            .find(|t| t.name == "Archivable")
7400            .expect("just added");
7401        archivable.links = person_links;
7402        schema
7403    }
7404
7405    #[test]
7406    fn test_a_plain_abstract_in_a_subquery_fans_out() {
7407        let schema = make_schema_with_a_plain_abstract();
7408        let out = compile_and_emit_with(
7409            "SELECT Person FILTER .id IN (SELECT default::Archivable FILTER .age > 1).id",
7410            &schema,
7411        );
7412        assert!(
7413            !out.sql.contains("\"Archivable\""),
7414            "a plain abstract backs no relation:\n{}",
7415            out.sql
7416        );
7417    }
7418
7419    #[test]
7420    fn test_a_backlink_narrowed_to_a_plain_abstract_fans_out() {
7421        let schema = make_schema_with_a_linking_plain_abstract();
7422        let out = compile_and_emit_with(
7423            "SELECT Company FILTER .<company[is default::Archivable].name = 'a'",
7424            &schema,
7425        );
7426        assert!(
7427            !out.sql.contains("\"Archivable\""),
7428            "a plain abstract backs no relation:\n{}",
7429            out.sql
7430        );
7431    }
7432
7433    /// `Account` as a real interface (a view), with a plain abstract only its
7434    /// implementor carries — jaldis reads `[is default::Auditable].*` off
7435    /// `marketplace::IdentifiableListing` exactly this way.
7436    fn make_interface_schema_with_a_plain_abstract() -> SchemaDescriptor {
7437        let mut schema = make_interface_schema();
7438        let mut archivable = schema
7439            .types
7440            .iter()
7441            .find(|t| t.name == "Individual")
7442            .expect("Individual is in the interface schema")
7443            .clone();
7444        archivable.name = "Archivable".into();
7445        archivable.table = "Archivable".into();
7446        archivable.abstract_ = true;
7447        archivable.materialized = false;
7448        archivable.interfaces = vec![];
7449        archivable.links = vec![];
7450        archivable.multilinks = vec![];
7451        archivable.computed = vec![];
7452        schema.types.push(archivable);
7453        for t in schema.types.iter_mut() {
7454            match t.name.as_str() {
7455                // A view to read, so the splat cannot fall back to it.
7456                "Account" => t.materialized = true,
7457                "Individual" => t.parents.push("default::Archivable".into()),
7458                _ => {}
7459            }
7460        }
7461        schema
7462    }
7463
7464    #[test]
7465    fn test_a_splat_over_a_plain_abstract_fans_out() {
7466        let schema = make_interface_schema_with_a_plain_abstract();
7467        let out = compile_and_emit_with("SELECT Account { id, [is default::Archivable].* }", &schema);
7468        assert!(
7469            !out.sql.contains("\"Archivable\""),
7470            "a plain abstract backs no relation:\n{}",
7471            out.sql
7472        );
7473        assert!(
7474            out.sql.contains("first_name"),
7475            "the splat still reaches the mixin's own pointers:\n{}",
7476            out.sql
7477        );
7478    }
7479
7480    #[test]
7481    fn test_an_aggregate_over_a_relative_walk_runs_inside_the_subquery() {
7482        // Compiled as an expression the walk stands for the array of its
7483        // elements, leaving the aggregate with an array argument.
7484        let out = compile_and_emit("SELECT Person { latest := max(.posts.title) }");
7485        assert!(out.sql.contains("(SELECT max("), "{}", out.sql);
7486        assert!(!out.sql.contains("max(ARRAY("), "{}", out.sql);
7487    }
7488
7489    /// `Company.staff` — a computed standing for a narrowed backlink, the
7490    /// shape jaldis reaches for wherever one type owns rows of another.
7491    fn make_schema_with_a_computed_backlink() -> SchemaDescriptor {
7492        use crate::schema::ComputedDescriptor;
7493        let mut schema = make_schema();
7494        let company = schema
7495            .types
7496            .iter_mut()
7497            .find(|t| t.name == "Company")
7498            .expect("Company is in the test schema");
7499        company.computed = vec![ComputedDescriptor {
7500            name: "staff".into(),
7501            expression: ".<company[is default::Person]".into(),
7502            return_type: None,
7503            link_target: None,
7504            link_multi: false,
7505        }];
7506        schema
7507    }
7508
7509    #[test]
7510    fn test_a_computed_backlink_reads_the_objects_it_lands_on() {
7511        let schema = make_schema_with_a_computed_backlink();
7512        let out = compile_and_emit_with("SELECT Company { staff: { name } }", &schema);
7513        assert!(
7514            out.sql.contains("\"public\".\"Person\""),
7515            "the walk reaches Person:\n{}",
7516            out.sql
7517        );
7518    }
7519
7520    #[test]
7521    fn test_a_computed_backlinks_modifiers_belong_to_what_it_lands_on() {
7522        // Bound to the declaring type instead, `.name` is a field Company has
7523        // not got and the query is rejected outright.
7524        let schema = make_schema_with_a_computed_backlink();
7525        let out = compile_and_emit_with("SELECT Company.staff { name } FILTER .name = 'ada'", &schema);
7526        assert!(out.sql.contains("'ada'"), "{}", out.sql);
7527        assert!(
7528            out.sql.contains("\"public\".\"Person\""),
7529            "filtered on the Person the walk lands on:\n{}",
7530            out.sql
7531        );
7532    }
7533
7534    #[test]
7535    fn test_an_unreserved_keyword_names_a_free_shape_field() {
7536        // `last`, `first` and `order` are unreserved keywords, so a shape can
7537        // carry them. Read as a set literal, the `:=` has nowhere to go.
7538        let out = compile_and_emit("SELECT { last := 2, first := 1, order := 3 }");
7539        assert!(out.sql.contains("\"last\""), "{}", out.sql);
7540        assert!(out.sql.contains("\"first\""), "{}", out.sql);
7541        assert!(out.sql.contains("\"order\""), "{}", out.sql);
7542    }
7543
7544    #[test]
7545    fn test_a_shaped_sub_select_over_a_type_is_an_object() {
7546        // Shapeless it stands for the row's key; with a shape it is the object
7547        // the shape reads, which used to be refused outright.
7548        let out = compile_and_emit("SELECT Person { latest := (SELECT Post { title } LIMIT 1) }");
7549        assert!(out.sql.contains("\"title\""), "{}", out.sql);
7550    }
7551
7552    #[test]
7553    fn test_a_walk_inside_a_path_selects_shape_finds_its_row() {
7554        // The shape is compiled against the type the walk landed on, so a
7555        // relative path inside it has that row to resolve against.
7556        let out = compile_and_emit("SELECT Person.posts { mine := (SELECT Post FILTER .title = .title LIMIT 1) }");
7557        assert!(out.sql.contains("\"public\".\"Post\""), "{}", out.sql);
7558    }
7559
7560    #[test]
7561    fn test_a_walk_off_a_binding_in_a_free_select() {
7562        // A free select has no row of its own, so the walk is read back as one
7563        // subquery; resolved as a type name the binding is simply unknown.
7564        let out = compile_and_emit("WITH p := (SELECT Person) SELECT { n := p.company.name }");
7565        assert!(out.sql.contains("\"p\""), "{}", out.sql);
7566        assert!(out.sql.contains("\"public\".\"Company\""), "{}", out.sql);
7567    }
7568
7569    #[test]
7570    fn test_a_free_selects_filter_can_root_at_a_binding() {
7571        let out = compile_and_emit("WITH p := (SELECT Person) SELECT { n := 1 } FILTER p.company.name = 'Acme'");
7572        assert!(out.sql.contains("'Acme'"), "{}", out.sql);
7573    }
7574
7575    #[test]
7576    fn test_a_trailing_shape_on_a_field_access_select() {
7577        let out = compile_and_emit("SELECT (SELECT Person).company { name }");
7578        assert!(out.sql.contains("\"name\""), "{}", out.sql);
7579        assert!(out.sql.contains("\"public\".\"Company\""), "{}", out.sql);
7580    }
7581
7582    #[test]
7583    fn test_a_union_of_walks_names_each_branch() {
7584        // A walk names no relation, and a union is emitted one relation per
7585        // branch — so each is hoisted into a CTE of its own first.
7586        let out = compile_and_emit(
7587            "WITH p := (SELECT Person FILTER .name = $a), q := (SELECT Person FILTER .name = $b), \
7588             t := (SELECT p.posts UNION q.posts) SELECT t { title }",
7589        );
7590        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
7591        assert!(out.sql.contains("\"title\""), "{}", out.sql);
7592    }
7593
7594    #[test]
7595    fn test_sibling_for_loops_each_get_their_own_iterator() {
7596        // One WITH clause cannot hold `_for_p` twice, and two loops written
7597        // over the same variable name are ordinary.
7598        let out = compile_and_emit(
7599            "WITH a := (SELECT Post), b := (SELECT Post), \
7600             x := (FOR p IN a UNION (INSERT Person { name := p.title, age := 1 })), \
7601             y := (FOR p IN b UNION (INSERT Person { name := p.title, age := 2 })) \
7602             SELECT x",
7603        );
7604        assert!(out.sql.contains("\"_for_p\" AS"), "{}", out.sql);
7605        assert!(out.sql.contains("\"_for_p__1\" AS"), "{}", out.sql);
7606    }
7607
7608    #[test]
7609    fn test_a_nested_for_carries_the_outer_loops_key() {
7610        // The inner iterator has the outer one in its FROM, and the insert
7611        // joins the two back on the key it carries.
7612        let out = compile_and_emit(
7613            "WITH made := (FOR p IN (SELECT Person) UNION ( \
7614               FOR q IN p.posts UNION (INSERT Company { name := q.title }) \
7615             )) SELECT count(made)",
7616        );
7617        assert!(
7618            out.sql.contains("\"_outer\""),
7619            "the inner iterator carries the bond:\n{}",
7620            out.sql
7621        );
7622        assert!(
7623            out.sql.contains("JOIN \"_for_p\" ON"),
7624            "the insert reads both loop variables:\n{}",
7625            out.sql
7626        );
7627        assert!(
7628            !out.sql.contains("LATERAL (\nINSERT"),
7629            "DML cannot sit in a LATERAL:\n{}",
7630            out.sql
7631        );
7632    }
7633
7634    #[test]
7635    fn test_a_link_value_can_be_a_walk_off_a_binding() {
7636        // `(select placed.line_items limit 1)` — the walk names no type of its
7637        // own, so reading one off it used to report the path as no type at all.
7638        let mut schema = make_schema();
7639        let company = schema
7640            .types
7641            .iter_mut()
7642            .find(|t| t.name == "Company")
7643            .expect("make_schema declares Company");
7644        company.properties.push(PropertyDescriptor {
7645            name: "id".into(),
7646            pg_type: "uuid".into(),
7647            nullable: false,
7648            default_sql: None,
7649            default_pyql: None,
7650            description: None,
7651            check_constraints: vec![],
7652            is_exclusive: true,
7653            is_pk: true,
7654            is_readonly: true,
7655            rewrites: vec![],
7656            tuple_members: None,
7657            column_type: None,
7658        });
7659        let out = compile_and_emit_with(
7660            "WITH others := (SELECT Person) \
7661             SELECT (INSERT Person { name := $n, age := 1, company := (SELECT others.company LIMIT 1) })",
7662            &schema,
7663        );
7664        assert!(out.sql.contains("\"company_id\""), "{}", out.sql);
7665    }
7666
7667    #[test]
7668    fn test_a_shape_over_a_coalesce_of_walks() {
7669        // Each operand is a walk off the enclosing row, and every one after the
7670        // first only stands in when the ones before it are empty.
7671        let schema = make_schema_with_computed_links();
7672        let out = compile_and_emit_with("SELECT Person { x := (.published ?? .plain) { title } }", &schema);
7673        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
7674        assert!(
7675            out.sql.contains("NOT EXISTS"),
7676            "the later branch only stands in when the first is empty:\n{}",
7677            out.sql
7678        );
7679    }
7680
7681    #[test]
7682    fn test_an_aggregate_over_a_walk_onto_objects_counts_its_rows() {
7683        // Falling through instead put the aggregate back through the very
7684        // expression route that sent it here, and the recursion took the
7685        // process down with it.
7686        let out = compile_and_emit("WITH c := (SELECT Company) SELECT c { n := count(.<company[is default::Person]) }");
7687        assert!(out.sql.contains("count("), "{}", out.sql);
7688        assert!(out.sql.contains("\"public\".\"Person\""), "{}", out.sql);
7689    }
7690
7691    #[test]
7692    fn an_aggregate_over_a_computed_set_counts_inside_a_subquery() {
7693        // `count(.plain)` is one step, so the junction-only count a stored
7694        // multi-link gets cannot apply — it has no junction to read. Taking
7695        // the walk as a value instead made the whole select an aggregate
7696        // query, which Postgres rejects ("column must appear in the GROUP BY
7697        // clause").
7698        let schema = make_schema_with_computed_links();
7699        let out = compile_and_emit_with("SELECT Person { n := count(.plain) }", &schema);
7700        assert!(
7701            !out.sql.contains("count((SELECT"),
7702            "the aggregate belongs inside the walk's subquery, not around it:\n{}",
7703            out.sql
7704        );
7705        assert!(
7706            out.sql.contains("(SELECT count(") && out.sql.contains("\"public\".\"Post\""),
7707            "it counts the rows the computed's own path lands on:\n{}",
7708            out.sql
7709        );
7710    }
7711
7712    /// The specialized count for a stored multi-link reads its junction table
7713    /// alone — the computed case above must not have pulled it off that route.
7714    #[test]
7715    fn an_aggregate_over_a_stored_multi_link_still_counts_its_junction_rows() {
7716        let schema = make_schema_with_computed_links();
7717        let out = compile_and_emit_with("SELECT Person { n := count(.posts) }", &schema);
7718        assert!(out.sql.contains("\"public\".\"Person.posts\""), "{}", out.sql);
7719        assert!(
7720            !out.sql.contains("JOIN \"public\".\"Post\""),
7721            "counting junction rows needs no join onto the targets:\n{}",
7722            out.sql
7723        );
7724    }
7725
7726    #[test]
7727    fn test_a_select_can_name_its_own_result() {
7728        // A select may name its own result: the alias names it and the
7729        // select's own clauses may read it by that name.
7730        let out = compile_and_emit("SELECT oldest := max(Person.age)");
7731        assert!(out.sql.contains("max("), "{}", out.sql);
7732        let filtered = compile_and_emit("SELECT n := Person.age FILTER n > 18");
7733        assert!(
7734            filtered.sql.contains("18"),
7735            "the alias resolves to the result:\n{}",
7736            filtered.sql
7737        );
7738    }
7739
7740    #[test]
7741    fn test_exists_sees_a_computed_pointer() {
7742        // The suggester could see the name the resolver had just reported as
7743        // unknown — `has no link or property 'published'. Did you mean
7744        // 'published'?`
7745        let schema = make_schema_with_computed_links();
7746        let out = compile_and_emit_with("SELECT Person FILTER EXISTS .published", &schema);
7747        assert!(out.sql.contains("EXISTS"), "{}", out.sql);
7748        assert!(
7749            out.sql.contains("\"public\".\"Post\""),
7750            "the computed's own path:\n{}",
7751            out.sql
7752        );
7753    }
7754
7755    #[test]
7756    fn test_walking_through_a_computed_field_access() {
7757        use crate::schema::ComputedDescriptor;
7758        // `parent := (select .parents … limit 1).parent` — the modifiers belong
7759        // to the step before the field access, not to what it lands on.
7760        let mut schema = make_schema_with_computed_links();
7761        let person = schema.types.iter_mut().find(|t| t.name == "Person").expect("Person");
7762        person.computed.push(ComputedDescriptor {
7763            name: "latest_title".into(),
7764            expression: "(select .posts order by .title desc limit 1).title".into(),
7765            return_type: None,
7766            link_target: None,
7767            link_multi: false,
7768        });
7769        let out = compile_and_emit_with("SELECT Person { x := .latest_title }", &schema);
7770        assert!(out.sql.contains("\"title\""), "{}", out.sql);
7771        assert!(
7772            out.sql.contains("LIMIT"),
7773            "the computed's own limit survives:\n{}",
7774            out.sql
7775        );
7776    }
7777
7778    #[test]
7779    fn test_a_computed_field_access_onto_an_object_takes_a_shape() {
7780        use crate::schema::ComputedDescriptor;
7781        // Compiled as an expression, the pointer came back as the bare id of
7782        // the object it lands on, whatever shape was written on it.
7783        let mut schema = make_schema_with_computed_links();
7784        let post = schema.types.iter_mut().find(|t| t.name == "Post").expect("Post");
7785        post.links.push(LinkDescriptor {
7786            name: "author".into(),
7787            target: "default::Person".into(),
7788            nullable: true,
7789            through: None,
7790            description: None,
7791            default_pyql: None,
7792            is_exclusive: false,
7793            is_readonly: false,
7794            rewrites: vec![],
7795            on_delete: vec![],
7796        });
7797        let person = schema.types.iter_mut().find(|t| t.name == "Person").expect("Person");
7798        person.computed.retain(|c| c.name != "looper");
7799        person.computed.push(ComputedDescriptor {
7800            name: "latest_author".into(),
7801            expression: "((select .posts order by .title desc limit 1)).author".into(),
7802            return_type: None,
7803            link_target: None,
7804            link_multi: false,
7805        });
7806        let out = compile_and_emit_with("SELECT Person { latest_author: { name } }", &schema);
7807        assert!(out.sql.contains("\"name\""), "{}", out.sql);
7808        assert!(
7809            out.sql.contains("\"title\" DESC"),
7810            "the order stays on the posts:\n{}",
7811            out.sql
7812        );
7813        assert!(
7814            out.sql.contains("LIMIT"),
7815            "the computed's own limit survives:\n{}",
7816            out.sql
7817        );
7818        let splat = compile_and_emit_with("SELECT Person { * }", &schema);
7819        assert!(
7820            !splat.sql.contains("latest_author"),
7821            "`*` leaves links out:\n{}",
7822            splat.sql
7823        );
7824    }
7825
7826    #[test]
7827    fn test_a_narrowed_pointer_can_carry_a_shape() {
7828        // `[is T].posts: { title }` — the parser stopped at the `:`, and the
7829        // route behind it resolved a stored property and nothing else, so a
7830        // link reported itself as the name it had just been asked for.
7831        let out = compile_and_emit("SELECT Company { x := 1, [is default::Person].posts: { title } }");
7832        assert!(out.sql.contains("\"title\""), "{}", out.sql);
7833        assert!(out.sql.contains("\"public\".\"Post\""), "{}", out.sql);
7834    }
7835
7836    #[test]
7837    fn test_select_type_name_as_a_path_step() {
7838        let out = compile_and_emit("SELECT Person.__type__");
7839        assert!(out.sql.contains("ROW('default::Person')"), "{}", out.sql);
7840        assert!(out.sql.contains("\"Person\" AS \"t0\""), "{}", out.sql);
7841    }
7842
7843    #[test]
7844    fn test_aggregate_over_a_with_bound_object_set() {
7845        let out = compile_and_emit("WITH people := (SELECT Person) SELECT max(people.age)");
7846        assert!(out.sql.contains("\"people\" AS ("));
7847        assert!(out.sql.contains("max("));
7848        // Reads the binding, not the base table again.
7849        assert!(!out.sql.contains("FROM \"default\".\"Person\" AS \"t1\""));
7850    }
7851
7852    #[test]
7853    fn test_select_union_of_two_object_bindings() {
7854        let out = compile_and_emit("WITH a := (SELECT Person LIMIT 1), b := (SELECT Person) SELECT (a UNION b)");
7855        assert!(out.sql.contains("SELECT * FROM \"a\""));
7856        assert!(out.sql.contains("UNION ALL"));
7857        assert!(out.sql.contains("SELECT * FROM \"b\""));
7858    }
7859
7860    #[test]
7861    fn test_select_union_of_different_object_types_is_rejected() {
7862        let schema = make_schema();
7863        let ast = parse::parse("WITH a := (SELECT Person), b := (SELECT Company) SELECT (a UNION b)").unwrap();
7864        let Err(err) = ir::compile(&ast, &schema) else {
7865            panic!("union of unrelated types must be rejected")
7866        };
7867        assert!(format!("{err}").contains("UNION"));
7868    }
7869
7870    #[test]
7871    fn test_for_union_as_a_function_body() {
7872        let schema = make_schema();
7873        let descriptor = FunctionDescriptor {
7874            name: "recent".into(),
7875            module: "default".into(),
7876            params: vec![],
7877            return_pg_type: "default::Person".into(),
7878            return_is_object: true,
7879            return_is_set: true,
7880            return_is_polymorphic: false,
7881            volatility: "stable".into(),
7882            body: "for n in {1, 2} union (select Person filter .age = n)".into(),
7883        };
7884        let ir = ir::compile_fn_body(&descriptor, &schema).expect("function body must compile");
7885        let body = emit_fn_body(&ir);
7886        assert!(body.contains("VALUES"), "{body}");
7887        assert!(body.contains("CROSS JOIN LATERAL"), "{body}");
7888        assert!(body.contains("\"_body\".*"), "{body}");
7889    }
7890
7891    #[test]
7892    fn test_with_binding_in_a_computed_reads_the_enclosing_object() {
7893        let out = compile_and_emit("SELECT Person { n := (WITH own := .name SELECT own) }");
7894        assert!(out.sql.contains("\"name\""), "{}", out.sql);
7895    }
7896
7897    #[test]
7898    fn test_subject_path_update_names_its_own_alias() {
7899        let mut schema = make_schema();
7900        let company = schema.types.iter_mut().find(|t| t.name == "Company").unwrap();
7901        company.properties.insert(
7902            0,
7903            PropertyDescriptor {
7904                name: "id".into(),
7905                pg_type: "uuid".into(),
7906                nullable: false,
7907                default_sql: Some("uuidv7()".into()),
7908                default_pyql: None,
7909                description: None,
7910                check_constraints: vec![],
7911                is_exclusive: true,
7912                is_pk: true,
7913                is_readonly: true,
7914                rewrites: vec![],
7915                tuple_members: None,
7916                column_type: None,
7917            },
7918        );
7919        // The row set narrowing the update compares against *that update's*
7920        // alias; a bare `id` would be ambiguous once a nested statement's CTE
7921        // joins the FROM.
7922        let out = compile_and_emit_with("SELECT (UPDATE Person.company SET { name := 'x' }) { name }", &schema);
7923        assert!(out.sql.contains("\"t1\".\"id\" = ANY("), "{}", out.sql);
7924    }
7925
7926    #[test]
7927    fn test_an_update_past_a_from_returns_only_its_own_columns() {
7928        // `UPDATE … FROM cte … RETURNING *` returns the joined relation's
7929        // columns as well, so a CTE built that way carries two `id`s and any
7930        // later reference to one is ambiguous.
7931        let out = compile_and_emit(
7932            "SELECT { (UPDATE Person FILTER .name = 'a' SET { company := (INSERT Company { name := 'c' }) }) }",
7933        );
7934        assert!(out.sql.contains("FROM \"_nested_dml_0\""), "{}", out.sql);
7935        assert!(
7936            out.sql.contains("RETURNING \"t0\".*"),
7937            "the update names its own target:\n{}",
7938            out.sql
7939        );
7940    }
7941
7942    #[test]
7943    fn test_nested_insert_in_a_mutation_written_as_a_free_set() {
7944        let out = compile_and_emit(
7945            "SELECT { (UPDATE Person FILTER .name = 'a' SET { company := (INSERT Company { name := 'c' }) }) }",
7946        );
7947        assert!(
7948            out.sql.contains("INSERT INTO \"public\".\"Company\""),
7949            "the hoisted insert is defined, not just referenced:\n{}",
7950            out.sql
7951        );
7952    }
7953
7954    #[test]
7955    fn test_nested_insert_as_a_link_value_in_an_update() {
7956        // The hoisted insert sits beside the update rather than inside it, so
7957        // the update needs a FROM to read its id, and its own `id` has to name
7958        // the update's alias — a bare one could mean either relation.
7959        let out = compile_and_emit(
7960            "SELECT (UPDATE Person FILTER .name = 'a' SET { company := (INSERT Company { name := 'c' }) }) { name }",
7961        );
7962        assert!(out.sql.contains("INSERT INTO \"public\".\"Company\""), "{}", out.sql);
7963        assert!(out.sql.contains("_nested_dml_0"), "{}", out.sql);
7964        assert!(
7965            out.sql.contains("FROM \"_nested_dml_0\""),
7966            "the update reads the hoisted CTE:\n{}",
7967            out.sql
7968        );
7969    }
7970
7971    #[test]
7972    fn test_nested_insert_as_a_link_value() {
7973        let out = compile_and_emit(
7974            "SELECT (INSERT Person { name := 'a', company := (INSERT Company { name := 'c' }) }) { name }",
7975        );
7976        assert!(out.sql.contains("_nested_dml_0"), "{}", out.sql);
7977        assert!(out.sql.contains("INSERT INTO \"public\".\"Company\""), "{}", out.sql);
7978    }
7979
7980    #[test]
7981    fn test_nested_insert_in_a_one_element_set_is_that_insert() {
7982        let out = compile_and_emit(
7983            "SELECT (INSERT Person { name := 'a', company := { (INSERT Company { name := 'c' }) } }) { name }",
7984        );
7985        assert!(out.sql.contains("INSERT INTO \"public\".\"Company\""), "{}", out.sql);
7986    }
7987
7988    #[test]
7989    fn test_insert_with_no_assignments_uses_default_values() {
7990        let out = compile_and_emit("SELECT (INSERT Person { name := 'a', company := (INSERT Company {}) }) { name }");
7991        assert!(out.sql.contains("DEFAULT VALUES"), "{}", out.sql);
7992    }
7993
7994    #[test]
7995    fn test_backlink_narrowed_to_an_interface_its_implementor_declares() {
7996        // `notes` is declared on Individual; `[is Account]` is satisfied by an
7997        // Individual row, so the backlink resolves through the implementor.
7998        use crate::schema::{LinkDescriptor, TypeDescriptor};
7999        let mut schema = make_interface_schema();
8000        let individual = schema.types.iter().find(|t| t.name == "Individual").unwrap().clone();
8001        schema.types.push(TypeDescriptor {
8002            name: "Note".into(),
8003            module: "default".into(),
8004            table: "Note".into(),
8005            abstract_: false,
8006            materialized: true,
8007            description: None,
8008            parents: vec![],
8009            interfaces: vec![],
8010            bases: vec![],
8011            properties: individual.properties[..1].to_vec(),
8012            links: vec![],
8013            multilinks: vec![],
8014            computed: vec![],
8015            constraints: vec![],
8016            indexes: vec![],
8017            partition: None,
8018            vector_indexes: vec![],
8019            search_indexes: vec![],
8020            triggers: vec![],
8021            junction: false,
8022            signals: vec![],
8023        });
8024        schema
8025            .types
8026            .iter_mut()
8027            .find(|t| t.name == "Individual")
8028            .unwrap()
8029            .links
8030            .push(LinkDescriptor {
8031                name: "note".into(),
8032                target: "default::Note".into(),
8033                nullable: true,
8034                description: None,
8035                default_pyql: None,
8036                is_exclusive: false,
8037                is_readonly: false,
8038                rewrites: vec![],
8039                on_delete: vec![],
8040                through: None,
8041            });
8042        let out = compile_and_emit_with("SELECT Note FILTER EXISTS .<note[is Account]", &schema);
8043        assert!(out.sql.contains("\"Individual\""), "{}", out.sql);
8044    }
8045
8046    #[test]
8047    fn test_subject_reads_the_row_a_constraint_checks() {
8048        let schema = make_schema();
8049        let sql = ir::compile_constraint_expr("__subject__.age > 18", "default::Person", &schema)
8050            .expect("__subject__ names the row under check");
8051        assert!(sql.contains("\"age\""), "{sql}");
8052    }
8053
8054    #[test]
8055    fn test_free_select_filter_gates_the_result_and_warns() {
8056        let schema = make_schema();
8057        let ast = parse::parse("SELECT count(Person) FILTER (Person.age > 18)").unwrap();
8058        let ir = ir::compile(&ast, &schema).unwrap();
8059        let out = emit(&ir);
8060        assert!(
8061            out.sql.contains("EXISTS("),
8062            "the condition gates the count:\n{}",
8063            out.sql
8064        );
8065        assert!(
8066            ir.warnings.iter().any(|w| w.contains("FILTER clause")),
8067            "a set-valued filter warns: {:?}",
8068            ir.warnings
8069        );
8070    }
8071
8072    #[test]
8073    fn test_free_sub_select_with_modifiers_reads_inline() {
8074        // The inner statement reads the enclosing row, so it cannot become a
8075        // CTE ahead of the FROM clause that defines it.
8076        let out = compile_and_emit(
8077            "SELECT Person { n := (WITH mine := .name SELECT count(Post) FILTER (Post.title = mine)) }",
8078        );
8079        assert!(out.sql.contains("_scalar_sub"), "{}", out.sql);
8080        assert!(!out.sql.contains("WITH"), "nothing is hoisted:\n{}", out.sql);
8081    }
8082
8083    #[test]
8084    fn test_correlated_with_binding_is_not_hoisted() {
8085        let out = compile_and_emit("SELECT Person { n := (WITH own := .name SELECT own) }");
8086        assert!(
8087            !out.sql.contains("\"own\" AS ("),
8088            "the binding is inlined:\n{}",
8089            out.sql
8090        );
8091        assert!(out.sql.contains("\"name\""), "{}", out.sql);
8092    }
8093
8094    #[test]
8095    fn test_array_literal_binding_is_readable_and_typed() {
8096        let out = compile_and_emit("WITH order := ['a', 'b'] SELECT std::find(order, 'b')");
8097        assert!(out.sql.contains("array_position"), "its type is known:\n{}", out.sql);
8098        assert!(
8099            out.sql.contains("AS v"),
8100            "the binding exposes a value column:\n{}",
8101            out.sql
8102        );
8103    }
8104
8105    #[test]
8106    fn test_any_over_a_single_boolean_is_that_boolean() {
8107        let out = compile_and_emit("SELECT Person FILTER std::any(.age > 18)");
8108        assert!(!out.sql.contains("bool_or"), "no aggregate in WHERE:\n{}", out.sql);
8109        assert!(out.sql.contains("> 18"), "{}", out.sql);
8110    }
8111
8112    #[test]
8113    fn test_comparing_a_value_to_a_set_is_membership() {
8114        let schema = make_schema_with_computed_links();
8115        let out = compile_and_emit_with("SELECT Person FILTER .name = Person.posts.title", &schema);
8116        assert!(out.sql.contains("= ANY("), "{}", out.sql);
8117    }
8118
8119    #[test]
8120    fn test_for_over_a_with_binding_iterates_every_row() {
8121        let out = compile_and_emit(
8122            "WITH names := (SELECT Person.name) FOR n IN names UNION (SELECT Person FILTER .name = n)",
8123        );
8124        assert!(out.sql.contains("FROM \"names\""), "{}", out.sql);
8125        assert!(!out.sql.contains("VALUES"), "the whole set is iterated:\n{}", out.sql);
8126    }
8127
8128    #[test]
8129    fn test_for_body_bindings_stay_inside_the_body() {
8130        // `matching` reads the loop variable, so it cannot be hoisted to the
8131        // statement's own WITH — nothing there can see `_for_n`.
8132        let out = compile_and_emit(
8133            "WITH names := (SELECT Person.name) \
8134             FOR n IN names UNION (WITH matching := (SELECT Person FILTER .name = n) SELECT matching)",
8135        );
8136        let lateral = out.sql.find("CROSS JOIN LATERAL").expect("a lateral body");
8137        let binding = out.sql.find("\"matching\" AS (").expect("the body's binding");
8138        assert!(binding > lateral, "the binding sits inside the body:\n{}", out.sql);
8139    }
8140
8141    #[test]
8142    fn test_array_valued_binding_picks_the_array_overload() {
8143        // The binding carries `text[]`, so the call resolves to the array
8144        // overload even though the value itself is no longer an array literal.
8145        let out = compile_and_emit("WITH l := <array<std::str>>$0 SELECT std::find(l, 'a')");
8146        assert!(out.sql.contains("array_position"), "{}", out.sql);
8147        assert!(!out.sql.contains("strpos"), "{}", out.sql);
8148    }
8149
8150    #[test]
8151    fn test_aggregate_over_a_path_in_a_filter_is_a_subquery() {
8152        let out = compile_and_emit("SELECT Person FILTER .age = std::max(Person.age)");
8153        assert!(
8154            out.sql.contains("(SELECT max("),
8155            "an aggregate in WHERE needs its own query:\n{}",
8156            out.sql
8157        );
8158    }
8159
8160    #[test]
8161    fn test_distinct_over_a_single_value_is_that_value() {
8162        let out = compile_and_emit("SELECT Person { n := (DISTINCT .name) }");
8163        assert!(out.sql.contains("\"name\""), "{}", out.sql);
8164        assert!(!out.sql.contains("DISTINCT \""), "{}", out.sql);
8165    }
8166
8167    #[test]
8168    fn test_narrowing_an_interface_joins_the_implementor_table() {
8169        // `first_name` lives on Individual's own table; the Account view has
8170        // only the columns every implementor shares.
8171        let mut schema = make_interface_schema();
8172        let account = schema.types.iter_mut().find(|t| t.name == "Account").unwrap();
8173        account.materialized = true;
8174        let out = compile_and_emit_with("SELECT Account[is Individual].first_name", &schema);
8175        assert!(
8176            out.sql.contains("JOIN \"public\".\"Individual\""),
8177            "the implementor's own table is joined:\n{}",
8178            out.sql
8179        );
8180        assert!(out.sql.contains("\"first_name\""), "{}", out.sql);
8181    }
8182
8183    #[test]
8184    fn test_sub_select_shape_declares_names_its_filter_and_projection_read() {
8185        let out = compile_and_emit("SELECT Person { n := (SELECT .posts { t := .title } FILTER .t = 'x' LIMIT 1).t }");
8186        assert!(out.sql.contains("'x'"), "{}", out.sql);
8187        assert!(out.sql.contains("\"title\""), "{}", out.sql);
8188    }
8189
8190    #[test]
8191    fn test_assert_over_an_object_set_returns_rows() {
8192        let out = compile_and_emit("SELECT std::assert_distinct((SELECT Person))");
8193        assert!(
8194            out.sql.contains("\"assert_distinct\"(ARRAY(SELECT \"id\""),
8195            "{}",
8196            out.sql
8197        );
8198        assert!(out.sql.contains("= ANY("), "{}", out.sql);
8199        assert!(out.sql.contains("\"Person\" AS \"t1\""), "{}", out.sql);
8200        assert!(!out.sql.contains("unnest("), "{}", out.sql);
8201    }
8202
8203    #[test]
8204    fn test_assert_over_a_for_union_reads_its_rows() {
8205        let out =
8206            compile_and_emit("SELECT std::assert_distinct((FOR n IN {1, 2} UNION (SELECT Person FILTER .age = n)))");
8207        assert!(out.sql.contains("CROSS JOIN LATERAL"), "{}", out.sql);
8208        assert!(out.sql.contains("= ANY("), "{}", out.sql);
8209    }
8210
8211    #[test]
8212    fn test_declared_computed_with_binding_reads_its_own_object() {
8213        // Validation compiles a declared computed on its own, with no
8214        // enclosing SELECT — `.name` in the binding still means this object's.
8215        use crate::schema::ComputedDescriptor;
8216        let schema = make_schema();
8217        let cd = ComputedDescriptor {
8218            name: "shouted".into(),
8219            expression: "(with own := .name select std::str_upper(own))".into(),
8220            return_type: None,
8221            link_target: None,
8222            link_multi: false,
8223        };
8224        let ir = ir::compile_computed_in_type(&cd, "default::Person", &schema)
8225            .expect("a computed may read the object it is declared on");
8226        assert!(ir.is_some());
8227    }
8228
8229    #[test]
8230    fn test_sub_select_modifiers_scope_to_its_own_subject() {
8231        // `.title` belongs to Post, the sub-select's subject — not to Person,
8232        // where the projection `.author.name` lands.
8233        let mut schema = make_schema();
8234        let post = schema
8235            .types
8236            .iter_mut()
8237            .find(|t| t.name == "Post")
8238            .expect("Post is in the test schema");
8239        post.links.push(LinkDescriptor {
8240            name: "author".into(),
8241            target: "default::Person".into(),
8242            nullable: true,
8243            description: None,
8244            default_pyql: None,
8245            is_exclusive: false,
8246            is_readonly: false,
8247            rewrites: vec![],
8248            on_delete: vec![],
8249            through: None,
8250        });
8251        let out = compile_and_emit_with(
8252            "SELECT Person { a := (SELECT .posts FILTER .title = 'x' LIMIT 1).author.name }",
8253            &schema,
8254        );
8255        assert!(out.sql.contains("'x'"), "{}", out.sql);
8256    }
8257
8258    #[test]
8259    fn test_free_select_set_literal() {
8260        let schema = make_schema();
8261        let ast = parse::parse("SELECT {1, 2, 3}").unwrap();
8262        let ir = ir::compile(&ast, &schema).unwrap();
8263        let out = emit(&ir);
8264        // Three UNION ALL branches
8265        assert_eq!(out.sql.matches("UNION ALL").count(), 2);
8266        assert!(out.sql.contains("1 AS v"));
8267        assert!(out.sql.contains("2 AS v"));
8268        assert!(out.sql.contains("3 AS v"));
8269        assert!(out.sql.contains("ROW(v) AS result"));
8270        assert!(matches!(out.shape.root, crate::query::ShapeNode::Scalar { .. }));
8271    }
8272
8273    #[test]
8274    fn test_free_select_free_object() {
8275        let schema = make_schema();
8276        let ast = parse::parse("SELECT { foo := 'bar', n := 42 }").unwrap();
8277        let ir = ir::compile(&ast, &schema).unwrap();
8278        let out = emit(&ir);
8279        assert!(out.sql.contains("'bar'"));
8280        assert!(out.sql.contains("42"));
8281        assert!(out.sql.contains("AS result"));
8282        // Shape should describe an object with pointers foo and n
8283        let crate::query::ShapeNode::Object {
8284            pointers, type_name, ..
8285        } = &out.shape.root
8286        else {
8287            panic!("expected Object shape")
8288        };
8289        assert!(type_name.is_none());
8290        assert_eq!(pointers.len(), 2);
8291        assert!(matches!(&pointers[0], crate::query::ShapeNode::Scalar { name, position: 0 } if name == "foo"));
8292        assert!(matches!(&pointers[1], crate::query::ShapeNode::Scalar { name, position: 1 } if name == "n"));
8293    }
8294
8295    #[test]
8296    fn test_free_select_object_with_enum_field_casts_to_text_and_tags_shape() {
8297        // Regression: `select { gender := default::Gender.Male }` failed at
8298        // runtime with "no decoder for composite type element ... " —
8299        // A custom enum's OID is database-assigned, so inside an anonymous ROW()
8300        // composite. free_item_shape/emit_free_select always treated every
8301        // free-object field as a plain untyped Scalar, never casting an
8302        // enum-valued field to ::text (unlike a schema object's emit_scalar,
8303        // which already does this for a real enum-typed property column).
8304        let mut schema = make_schema();
8305        schema.enums.push(crate::schema::EnumDescriptor {
8306            name: "Gender".into(),
8307            module: "default".into(),
8308            members: vec!["Male".into(), "Female".into()],
8309        });
8310        let out = compile_and_emit_with("select { gender := default::Gender.Male }", &schema);
8311        // The enum value is computed once (`'Male'::"public"."Gender" AS
8312        // "_f0"`) and reused for both the `result` composite (cast to
8313        // `::text` there, since an enum's OID is database-assigned inside an
8314        // anonymous ROW()) and the plain per-field column exposed for
8315        // `IrExpr::CteFieldRef` access — so the `::text` cast now applies
8316        // to that computed column, not inline on the enum literal itself.
8317        assert!(
8318            out.sql.contains("'Male'::\"public\".\"Gender\""),
8319            "expected the enum literal, got:\n{}",
8320            out.sql
8321        );
8322        assert!(
8323            out.sql.contains("ROW(\"_f0\"::text) AS result"),
8324            "expected the ROW composite to cast the enum field to text, got:\n{}",
8325            out.sql
8326        );
8327        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
8328            panic!("expected Object shape")
8329        };
8330        assert_eq!(pointers.len(), 1);
8331        assert!(
8332            matches!(
8333                &pointers[0],
8334                crate::query::ShapeNode::Enum { name, position: 0, enum_type }
8335                    if name == "gender" && enum_type == "public::Gender"
8336            ),
8337            "expected Enum-tagged shape, got: {:?}",
8338            pointers[0]
8339        );
8340    }
8341
8342    #[test]
8343    fn test_free_select_bare_enum_literal_casts_to_text_inside_row() {
8344        // Same bug, bare scalar form: `select default::Gender.Male;` (no
8345        // shape/object wrapper) also wraps the value in ROW() for top-level
8346        // top-level decoding.
8347        let mut schema = make_schema();
8348        schema.enums.push(crate::schema::EnumDescriptor {
8349            name: "Gender".into(),
8350            module: "default".into(),
8351            members: vec!["Male".into(), "Female".into()],
8352        });
8353        let out = compile_and_emit_with("select default::Gender.Male", &schema);
8354        assert!(
8355            out.sql.contains("ROW(v::text) AS result"),
8356            "expected ROW(v::text), got:\n{}",
8357            out.sql
8358        );
8359        assert!(
8360            matches!(
8361                &out.shape.root,
8362                crate::query::ShapeNode::Enum { enum_type, .. } if enum_type == "public::Gender"
8363            ),
8364            "expected Enum-tagged shape, got: {:?}",
8365            out.shape.root
8366        );
8367    }
8368
8369    // ── FOR UPDATE / FOR SHARE row-locking clause ──────────────────────────────
8370
8371    #[test]
8372    fn test_for_update_defaults_to_blocking() {
8373        let out = compile_and_emit("SELECT Person FOR UPDATE");
8374        assert!(out.sql.trim_end().ends_with("FOR UPDATE"), "got:\n{}", out.sql);
8375    }
8376
8377    #[test]
8378    fn test_for_update_skip_locked() {
8379        let out = compile_and_emit("SELECT Person FOR UPDATE SKIP LOCKED");
8380        assert!(
8381            out.sql.trim_end().ends_with("FOR UPDATE SKIP LOCKED"),
8382            "got:\n{}",
8383            out.sql
8384        );
8385    }
8386
8387    #[test]
8388    fn test_for_update_nowait() {
8389        let out = compile_and_emit("SELECT Person FOR UPDATE NOWAIT");
8390        assert!(out.sql.trim_end().ends_with("FOR UPDATE NOWAIT"), "got:\n{}", out.sql);
8391    }
8392
8393    #[test]
8394    fn test_for_no_key_update_skip_locked() {
8395        let out = compile_and_emit("SELECT Person FOR NO KEY UPDATE SKIP LOCKED");
8396        assert!(
8397            out.sql.trim_end().ends_with("FOR NO KEY UPDATE SKIP LOCKED"),
8398            "got:\n{}",
8399            out.sql
8400        );
8401    }
8402
8403    #[test]
8404    fn test_for_share() {
8405        let out = compile_and_emit("SELECT Person FOR SHARE");
8406        assert!(out.sql.trim_end().ends_with("FOR SHARE"), "got:\n{}", out.sql);
8407    }
8408
8409    #[test]
8410    fn test_for_key_share_nowait() {
8411        let out = compile_and_emit("SELECT Person FOR KEY SHARE NOWAIT");
8412        assert!(
8413            out.sql.trim_end().ends_with("FOR KEY SHARE NOWAIT"),
8414            "got:\n{}",
8415            out.sql
8416        );
8417    }
8418
8419    #[test]
8420    fn test_for_update_comes_after_order_by_limit_offset_in_emitted_sql() {
8421        // Postgres's own grammar places the locking clause last — confirm
8422        // the emitter matches, not just that all the pieces are present.
8423        let out = compile_and_emit("SELECT Person { name } ORDER BY .name OFFSET 1 LIMIT 5 FOR UPDATE SKIP LOCKED");
8424        let order_pos = out.sql.find("ORDER BY").unwrap();
8425        let offset_pos = out.sql.find("OFFSET").unwrap();
8426        let limit_pos = out.sql.find("LIMIT").unwrap();
8427        let for_pos = out.sql.find("FOR UPDATE").unwrap();
8428        assert!(
8429            order_pos < offset_pos && offset_pos < limit_pos && limit_pos < for_pos,
8430            "got:\n{}",
8431            out.sql
8432        );
8433    }
8434
8435    #[test]
8436    fn test_for_update_combined_with_distinct_is_rejected() {
8437        let ast = parse::parse("SELECT DISTINCT Person FOR UPDATE").expect("parse failed");
8438        let err = ir::compile(&ast, &make_schema())
8439            .err()
8440            .expect("expected a compile error");
8441        assert!(err.to_string().contains("DISTINCT"), "unexpected: {err}");
8442    }
8443
8444    #[test]
8445    fn test_for_update_combined_with_select_over_insert_is_rejected() {
8446        let ast = parse::parse("SELECT (INSERT Person { name := 'Alice' }) { name } FOR UPDATE").expect("parse failed");
8447        let err = ir::compile(&ast, &make_schema())
8448            .err()
8449            .expect("expected a compile error");
8450        assert!(err.to_string().contains("INSERT"), "unexpected: {err}");
8451    }
8452
8453    #[test]
8454    fn test_for_update_on_an_interface_type_is_rejected() {
8455        fn id_prop() -> PropertyDescriptor {
8456            PropertyDescriptor {
8457                name: "id".into(),
8458                pg_type: "uuid".into(),
8459                nullable: false,
8460                default_sql: Some("uuidv7()".into()),
8461                default_pyql: None,
8462                description: None,
8463                check_constraints: vec![],
8464                is_exclusive: true,
8465                is_pk: true,
8466                is_readonly: true,
8467                rewrites: vec![],
8468                tuple_members: None,
8469                column_type: None,
8470            }
8471        }
8472        let schema = SchemaDescriptor {
8473            types: vec![
8474                TypeDescriptor {
8475                    name: "Account".into(),
8476                    module: "default".into(),
8477                    table: "Account".into(),
8478                    abstract_: true,
8479                    materialized: true,
8480                    description: None,
8481                    parents: vec![],
8482                    interfaces: vec![],
8483                    bases: vec![],
8484                    properties: vec![id_prop()],
8485                    links: vec![],
8486                    multilinks: vec![],
8487                    computed: vec![],
8488                    constraints: vec![],
8489                    indexes: vec![],
8490                    partition: None,
8491                    vector_indexes: vec![],
8492                    search_indexes: vec![],
8493                    triggers: vec![],
8494                    junction: false,
8495                    signals: vec![],
8496                },
8497                TypeDescriptor {
8498                    name: "Individual".into(),
8499                    module: "default".into(),
8500                    table: "Individual".into(),
8501                    abstract_: false,
8502                    materialized: true,
8503                    description: None,
8504                    parents: vec![],
8505                    interfaces: vec!["default::Account".into()],
8506                    bases: vec![],
8507                    properties: vec![id_prop()],
8508                    links: vec![],
8509                    multilinks: vec![],
8510                    computed: vec![],
8511                    constraints: vec![],
8512                    indexes: vec![],
8513                    partition: None,
8514                    vector_indexes: vec![],
8515                    search_indexes: vec![],
8516                    triggers: vec![],
8517                    junction: false,
8518                    signals: vec![],
8519                },
8520            ],
8521            scalars: vec![],
8522            enums: vec![],
8523            named_tuples: vec![],
8524            globals: vec![],
8525            functions: vec![],
8526            aliases: vec![],
8527            channels: vec![],
8528            ..Default::default()
8529        };
8530        let ast = parse::parse("SELECT Account FOR UPDATE").expect("parse failed");
8531        let err = ir::compile(&ast, &schema).err().expect("expected a compile error");
8532        assert!(err.to_string().contains("interface"), "unexpected: {err}");
8533    }
8534
8535    #[test]
8536    fn test_schema_select_distinct_emits_distinct_keyword() {
8537        // Regression: compile_stmt's Distinct/Detached unwrap and
8538        // compile_select's own re-derivation of the same unwrap were merged
8539        // into a single pass-through (Phase 2 of the compile_expr merge) —
8540        // confirm `select distinct` still compiles and emits DISTINCT.
8541        let out = compile_and_emit("SELECT DISTINCT Person { name }");
8542        assert!(out.sql.contains("DISTINCT"), "expected DISTINCT in SQL:\n{}", out.sql);
8543    }
8544
8545    #[test]
8546    fn test_schema_select_detached_compiles_as_ordinary_select() {
8547        // Same merge — `detached` at the top level of a schema select is a
8548        // no-op (already independent); confirm it still compiles cleanly
8549        // instead of erroring or double-unwrapping.
8550        let out = compile_and_emit("SELECT DETACHED Person { name }");
8551        assert!(
8552            out.sql.contains("\"name\""),
8553            "expected name column in SQL:\n{}",
8554            out.sql
8555        );
8556    }
8557
8558    #[test]
8559    fn test_detached_subquery_correlates_against_the_outer_row() {
8560        // The anti-join idiom: "the Person with no other Person of the same
8561        // name that is younger". `detached` is what makes the inner `Person` a
8562        // different row from the outer one, so the bare `Person.name` inside
8563        // has to resolve to the *outer* alias. It used to be stripped as a
8564        // no-op, which compiled to `t1.name = t1.name` — a tautology that
8565        // quietly matched every row instead of erroring.
8566        let out = compile_and_emit(
8567            "SELECT Person { name } FILTER NOT EXISTS (\
8568             SELECT DETACHED Person FILTER .name = Person.name AND .age > Person.age)",
8569        );
8570        assert!(
8571            out.sql.contains("\"t1\".\"name\" = \"t0\".\"name\""),
8572            "the inner row should be compared against the outer one:\n{}",
8573            out.sql
8574        );
8575        assert!(
8576            !out.sql.contains("\"t1\".\"name\" = \"t1\".\"name\""),
8577            "self-comparison means the correlation was lost:\n{}",
8578            out.sql
8579        );
8580    }
8581
8582    #[test]
8583    fn test_an_assert_on_a_pointer_checks_its_rows() {
8584        // The pointer's own subquery is bound once and read back through the
8585        // check, rather than appearing in both — and the cast is what makes
8586        // the call legal at all, since PL/pgSQL cannot take a `record[]`.
8587        let out = compile_and_emit("SELECT Person { name, p := assert_exists(.posts { title }) }");
8588        assert!(
8589            out.sql
8590                .contains(r#"cardinality("_pylon"."assert_exists"("_a"."v"::text[]))"#),
8591            "the assert should read the aggregated rows:\n{}",
8592            out.sql
8593        );
8594        assert_eq!(
8595            out.sql.matches("array_agg(ROW(").count(),
8596            1,
8597            "the pointer's subquery should be evaluated once, not once per use:\n{}",
8598            out.sql
8599        );
8600    }
8601
8602    #[test]
8603    fn test_an_assert_on_a_scalar_stays_an_ordinary_call() {
8604        // Only an object pointer takes the wrapper; `assert_exists(.name)` is
8605        // a value expression and must keep compiling as the function call it is.
8606        let out = compile_and_emit("SELECT Person { n := assert_exists(.name) }");
8607        assert!(
8608            !out.sql.contains(r#""_a"."v""#),
8609            "a scalar assert should not be wrapped as a pointer check:\n{}",
8610            out.sql
8611        );
8612    }
8613
8614    #[test]
8615    fn test_a_backlink_tail_walks_past_the_first_step() {
8616        // `.<link[is T].multilink.prop` — the tail after a backlink was read
8617        // one arity at a time (property, or link-then-property), so anything
8618        // crossing a multi-link had no single column to read and was refused.
8619        // Counted against the migrated data the answer is 4 and 4.
8620        let out = compile_and_emit("SELECT Post FILTER any(.<posts[is Person].posts.title = $t)");
8621        assert!(
8622            out.sql.contains(r#""public"."Person.posts""#),
8623            "the tail should walk the multi-link's junction:\n{}",
8624            out.sql
8625        );
8626    }
8627
8628    #[test]
8629    fn test_an_intersection_narrows_the_rest_of_a_multilink_walk() {
8630        // `.posts[is Post].title` — the narrowed type's own table is what the
8631        // remaining steps join to, so a row of any other type drops out
8632        // without a separate check. On real data the answer is 1 and 1.
8633        let out = compile_and_emit("SELECT Person FILTER any(.posts[is Post].title = $t)");
8634        assert!(
8635            out.sql.contains(r#""public"."Post""#),
8636            "the walk should continue from the narrowed type:\n{}",
8637            out.sql
8638        );
8639    }
8640
8641    #[test]
8642    fn test_a_union_of_bindings_emits_selects_not_bare_names() {
8643        // A CTE reference normally emits just its quoted name, which works
8644        // where the caller aliases it into a FROM. As a `UNION ALL` operand it
8645        // has to be a SELECT of its own — `("a" UNION ALL "b")` is not SQL.
8646        let out = compile_and_emit_with(
8647            "WITH a := (INSERT Post { title := $t1 }), b := (INSERT Post { title := $t2 }) \
8648             SELECT (INSERT Person { name := $n, posts := (a UNION b) })",
8649            &make_schema(),
8650        );
8651        assert!(
8652            out.sql.contains(r#"(SELECT "_s"."id" FROM "a" AS "_s")"#),
8653            "each union operand should be a select:\n{}",
8654            out.sql
8655        );
8656    }
8657
8658    #[test]
8659    fn test_a_bare_narrowing_reads_as_a_type_check_and_as_an_object() {
8660        // `exists [is Sub]` is whether the row *is* one; `x := [is Sub] { … }`
8661        // is that row read as it. Both used to fall off the end of the path
8662        // walk ("invalid path step" / "empty path traversal").
8663        for query in [
8664            "SELECT Person FILTER EXISTS [is Person]",
8665            "SELECT Person { p := [is Person] { name } }",
8666        ] {
8667            let ast = parse::parse(query).unwrap_or_else(|e| panic!("{query}: {e}"));
8668            assert!(ir::compile(&ast, &make_schema()).is_ok(), "{query} should compile");
8669        }
8670    }
8671
8672    #[test]
8673    fn test_a_multilink_takes_a_set_wrapped_in_a_select() {
8674        // `translations := (select { a, b })` — a select with no clauses of its
8675        // own is the expression it wraps. Left wrapped, a free row source (a
8676        // set literal, a union) reached the generic subquery arm and was
8677        // refused, while the identical unwrapped `{ a, b }` compiled.
8678        let out = compile_and_emit_with(
8679            "WITH a := (INSERT Post { title := $t1 }), b := (INSERT Post { title := $t2 }) \
8680             SELECT (INSERT Person { name := $n, posts := (SELECT { a, b }) })",
8681            &make_schema(),
8682        );
8683        assert!(
8684            out.sql.contains(r#""public"."Person.posts""#),
8685            "both targets should reach the junction:\n{}",
8686            out.sql
8687        );
8688    }
8689
8690    #[test]
8691    fn test_an_aggregate_over_a_subselected_path() {
8692        // `array_agg((select Person.name))` — the sub-select walks to a scalar,
8693        // which has no type name to be a schema select's subject. It means the
8694        // same as `array_agg(Person.name)` with the select's own modifiers on
8695        // the walk, which the path-select builder already handles. Checked
8696        // element for element against the unwrapped spelling.
8697        let out = compile_and_emit("SELECT array_agg((SELECT Person.name))");
8698        assert!(
8699            out.sql.contains("array_agg") && out.sql.contains(r#""public"."Person""#),
8700            "the aggregate should wrap the walk's column:\n{}",
8701            out.sql
8702        );
8703    }
8704
8705    #[test]
8706    fn test_a_distinct_aggregate_argument_reaches_the_aggregate() {
8707        // `array_agg(distinct x)` used to emit `array_agg(x)`: the unary
8708        // `distinct` emits its operand unchanged, because it is normally
8709        // applied where the set is built — which an aggregate argument is not.
8710        // Both spellings mean the same thing, and both have to return one
8711        // element where the old output returned two.
8712        for query in [
8713            "SELECT array_agg(DISTINCT Person.name)",
8714            "SELECT array_agg((SELECT DISTINCT Person.name))",
8715        ] {
8716            let out = compile_and_emit(query);
8717            assert!(
8718                out.sql.contains("array_agg(DISTINCT "),
8719                "{query} should deduplicate inside the aggregate:\n{}",
8720                out.sql
8721            );
8722        }
8723    }
8724
8725    #[test]
8726    fn test_is_not_negates_the_type_check() {
8727        // `x IS NOT T` is `Expr IS NOT TypeExpr` — the negation of the check,
8728        // not a comparison against some type `not T`. Counted against the
8729        // migrated data it came out as 3 of 3.
8730        let positive = compile_and_emit("SELECT Person FILTER Person IS Person");
8731        let negative = compile_and_emit("SELECT Person FILTER Person IS NOT Person");
8732        assert!(
8733            negative.sql.contains("NOT") && !positive.sql.contains("NOT"),
8734            "the negation should reach the SQL:\npositive:\n{}\nnegative:\n{}",
8735            positive.sql,
8736            negative.sql
8737        );
8738    }
8739
8740    #[test]
8741    fn test_a_cast_takes_a_prefix_operator_as_its_operand() {
8742        // `<bool>exists x` is legal — a cast takes a whole `Expr` at CAST
8743        // precedence. Pylon's cast went straight to a postfix expression, so
8744        // any prefix operator after one failed to parse.
8745        for query in ["SELECT <bool>EXISTS (SELECT Person LIMIT 1)", "SELECT <int64>-1"] {
8746            let ast = parse::parse(query).unwrap_or_else(|e| panic!("{query} should parse: {e}"));
8747            assert!(ir::compile(&ast, &make_schema()).is_ok(), "{query} should compile");
8748        }
8749    }
8750
8751    #[test]
8752    fn test_a_bare_multilink_reads_as_a_set() {
8753        // `p in .posts` — a multi-link read as a value, not traversed through.
8754        // The single-step resolver knew properties, links and computeds but
8755        // never multi-links, so the pointer reported itself as unknown while
8756        // the suggester, which does know them, offered the name straight back.
8757        let out =
8758            compile_and_emit("WITH p := (SELECT DETACHED Post LIMIT 1) SELECT Person { name } FILTER p IN .posts");
8759        assert!(
8760            out.sql.contains(r#""public"."Person.posts""#),
8761            "the membership test should read the link's junction:\n{}",
8762            out.sql
8763        );
8764    }
8765
8766    #[test]
8767    fn test_a_select_reads_its_own_shape_computed_in_its_clauses() {
8768        // `select T { x := … } order by .x` — a shape's computeds are in scope
8769        // for the select's own clauses, but not for a sibling pointer, so the
8770        // scope stops at the clauses.
8771        let out = compile_and_emit("SELECT Person { n := .name } ORDER BY .n ASC");
8772        assert!(
8773            out.sql.contains("ORDER BY"),
8774            "the order by should compile against the declared pointer:\n{}",
8775            out.sql
8776        );
8777    }
8778
8779    #[test]
8780    fn test_a_declared_pointer_can_be_walked_through() {
8781        // `p { boss := … }` read back as `.boss.name` — the walk builder
8782        // resolved a *schema* computed as a step but not one a shape in scope
8783        // declared, so anything past the first step reported it as unknown.
8784        let out = compile_and_emit_with(
8785            "WITH p := (SELECT Person { boss := .company } LIMIT 1) SELECT p { name, boss_name := .boss.name }",
8786            &make_schema(),
8787        );
8788        assert!(
8789            out.sql.contains(r#""public"."Company""#),
8790            "the walk should reach the declared pointer's own target table:\n{}",
8791            out.sql
8792        );
8793    }
8794
8795    #[test]
8796    fn test_a_replaced_subject_shape_stays_in_scope() {
8797        // `(select .company { n := .name }) { n }` — a shape at the point of
8798        // use replaces the subject's own, so what that one declared has to
8799        // stay readable or the replacement cannot see it. Before, this was an
8800        // outright compile error.
8801        let out = compile_and_emit("SELECT Person { c := (SELECT .company { n := .name }) { n } }");
8802        assert!(
8803            out.sql.contains(r#"'default::Company'::text"#) && out.sql.contains(r#""t2"."name""#),
8804            "`n` should resolve to the company's own column:\n{}",
8805            out.sql
8806        );
8807    }
8808
8809    #[test]
8810    fn test_a_sibling_shape_pointer_is_not_in_scope() {
8811        // One shape pointer reading another is refused, which is what keeps
8812        // four graph queries reported as the application bugs they are.
8813        let schema = make_schema();
8814        let ast = parse::parse("SELECT Person { n := .name, copy := .n }").unwrap();
8815        assert!(ir::compile(&ast, &schema).is_err());
8816    }
8817
8818    #[test]
8819    fn test_shape_over_a_with_binding_reads_the_binding() {
8820        // `metadata := metadata { … }` — a binding names a row source, so a
8821        // shape on it projects that source. The pointer route only took
8822        // relative paths, so this fell through to the expression route and was
8823        // rejected as "shapes are not valid in expression context".
8824        let out = compile_and_emit("WITH c := (SELECT Company LIMIT 1) SELECT Person { name, employer := c { name } }");
8825        assert!(
8826            out.sql.contains("\"c\""),
8827            "the pointer should read the binding's CTE:\n{}",
8828            out.sql
8829        );
8830    }
8831
8832    #[test]
8833    fn test_shape_over_a_bare_type_name_is_still_rejected() {
8834        // Unlike a binding, a type name in an enclosing scope is a factored
8835        // prefix, so a shape on one does not mean "every row of the table".
8836        let schema = make_schema();
8837        let ast = parse::parse("SELECT Person { name, other := Company { name } }").unwrap();
8838        assert!(ir::compile(&ast, &schema).is_err());
8839    }
8840
8841    #[test]
8842    fn test_nested_shape_reads_a_prefix_bound_by_the_outer_select() {
8843        // `Person.name` inside the `posts` shape names a type the enclosing
8844        // select binds, so it reads the outer row — not every Person. The
8845        // prefix used to be rejected outright because only the *innermost*
8846        // subject was checked.
8847        let out = compile_and_emit("SELECT Person { name, posts: { title, who := Person.name } }");
8848        assert!(
8849            out.sql.contains(r#""t0"."name""#),
8850            "the nested computed should read the outer Person alias:\n{}",
8851            out.sql
8852        );
8853        assert!(
8854            !out.sql.contains(r#"FROM "public"."Person" "t2""#),
8855            "the prefix should not open a second Person source:\n{}",
8856            out.sql
8857        );
8858    }
8859
8860    #[test]
8861    fn test_prefix_naming_a_type_out_of_scope_is_rejected() {
8862        // Nothing binds `Company` here, so the prefix stays an absolute path —
8863        // Pylon must not silently read the whole table.
8864        let schema = make_schema();
8865        let ast = parse::parse("SELECT Person { name, who := Company.name }").unwrap();
8866        assert!(ir::compile(&ast, &schema).is_err());
8867    }
8868
8869    #[test]
8870    fn test_free_select_tuple() {
8871        let schema = make_schema();
8872        let ast = parse::parse("SELECT (1, 2)").unwrap();
8873        let ir = ir::compile(&ast, &schema).unwrap();
8874        let out = emit(&ir);
8875        assert!(out.sql.contains("1"));
8876        assert!(out.sql.contains("2"));
8877        assert!(out.sql.contains("AS result"));
8878        assert!(matches!(out.shape.root, crate::query::ShapeNode::Tuple { .. }));
8879    }
8880
8881    #[test]
8882    fn test_free_select_scalar_literal() {
8883        let schema = make_schema();
8884        let ast = parse::parse("SELECT 'hello'").unwrap();
8885        let ir = ir::compile(&ast, &schema).unwrap();
8886        let out = emit(&ir);
8887        assert!(out.sql.contains("SELECT 'hello' AS v"));
8888        assert!(out.sql.contains("ROW(v) AS result"));
8889        assert!(matches!(out.shape.root, crate::query::ShapeNode::Scalar { .. }));
8890    }
8891
8892    #[test]
8893    fn test_float_literal_casts_to_float8() {
8894        // Regression: an un-cast float literal like `1.0` is untyped in
8895        // Postgres and defaults to `numeric`, not `float64` — silently
8896        // changing PyQL's semantics (only an explicit `<decimal>`/`123n`
8897        // literal should ever produce a real decimal) and breaking decode
8898        // inside ROW() composites (numeric's wire format differs from
8899        // float8's). An explicit ::float8 cast keeps it a real float.
8900        let out = compile_and_emit("SELECT 1.0");
8901        assert!(
8902            out.sql.contains("(1.0::float8)"),
8903            "expected explicit float8 cast, got:\n{}",
8904            out.sql
8905        );
8906    }
8907
8908    #[test]
8909    fn test_free_select_array_literal() {
8910        let schema = make_schema();
8911        let ast = parse::parse("SELECT [1, 2, 3]").unwrap();
8912        let ir = ir::compile(&ast, &schema).unwrap();
8913        let out = emit(&ir);
8914        assert!(out.sql.contains("SELECT ARRAY[1, 2, 3] AS v"), "{}", out.sql);
8915        assert!(out.sql.contains("SELECT v AS result, v FROM"), "{}", out.sql);
8916        assert!(matches!(out.shape.root, crate::query::ShapeNode::RawScalar));
8917    }
8918
8919    #[test]
8920    fn test_select_scalars() {
8921        let out = compile_and_emit("SELECT Person { name, age }");
8922        assert!(out.sql.contains("'default::Person'::text"));
8923        assert!(out.sql.contains("\"name\"::text"));
8924        assert!(out.sql.contains("\"age\"::int8"));
8925        assert!(out.sql.contains("FROM \"public\".\"Person\""));
8926        assert!(out.sql.contains(") AS result"));
8927    }
8928
8929    #[test]
8930    fn test_select_filter_param() {
8931        let out = compile_and_emit("SELECT Person { name } FILTER .name = $name");
8932        assert!(out.sql.contains("WHERE"));
8933        assert!(out.sql.contains("$1"));
8934    }
8935
8936    #[test]
8937    fn test_filter_in_set_literal_compiles_to_any_array() {
8938        let out = compile_and_emit("SELECT Person { name } FILTER .name IN {'Carol'}");
8939        assert!(out.sql.contains("= ANY(ARRAY['Carol'])"), "got:\n{}", out.sql);
8940    }
8941
8942    #[test]
8943    fn test_filter_in_multi_element_set_literal_compiles_to_any_array() {
8944        let out = compile_and_emit("SELECT Person { name } FILTER .name IN {'Carol', 'Bob'}");
8945        assert!(out.sql.contains("= ANY(ARRAY['Carol', 'Bob'])"), "got:\n{}", out.sql);
8946    }
8947
8948    /// `x IN std::array_unpack(arr)` is the documented way to test membership
8949    /// of an array parameter. It used to emit `= ANY(unnest(arr))` — a
8950    /// set-returning function inside `WHERE`, which PostgreSQL rejects at
8951    /// execution time (`set-returning functions are not allowed in WHERE`)
8952    /// even though both the PyQL and the SQL parse cleanly. `ANY` takes the
8953    /// array directly, so the `unnest` has to go.
8954    #[test]
8955    fn test_filter_in_array_unpack_passes_the_array_straight_to_any() {
8956        let out = compile_and_emit("SELECT Person { name } FILTER .name IN std::array_unpack(<array<str>>$names)");
8957        assert!(
8958            !out.sql.contains("ANY(unnest("),
8959            "unnest inside ANY is invalid in WHERE:\n{}",
8960            out.sql
8961        );
8962        assert!(out.sql.contains("= ANY("), "got:\n{}", out.sql);
8963    }
8964
8965    #[test]
8966    fn test_filter_not_in_array_unpack_passes_the_array_straight_to_all() {
8967        let out = compile_and_emit("SELECT Person { name } FILTER .name NOT IN std::array_unpack(<array<str>>$names)");
8968        assert!(
8969            !out.sql.contains("ALL(unnest("),
8970            "unnest inside ALL is invalid in WHERE:\n{}",
8971            out.sql
8972        );
8973        assert!(out.sql.contains("<> ALL("), "got:\n{}", out.sql);
8974    }
8975
8976    /// `all(array_unpack($t) in .posts.title)` quantifies over the unpacked
8977    /// array, not over `.posts`. Read as a condition on one `.posts` element
8978    /// it would both ask the opposite question and leave the `unnest()` in a
8979    /// `WHERE`, which PostgreSQL rejects at execution time.
8980    #[test]
8981    fn test_all_over_unpacked_array_aggregates_the_array_not_the_multilink() {
8982        let out = compile_and_emit(
8983            "SELECT Person { name } FILTER all(std::array_unpack(<array<str>>$titles) IN .posts.title)",
8984        );
8985        assert!(out.sql.contains("bool_and("), "got:\n{}", out.sql);
8986        assert!(out.sql.contains("_unnested"), "got:\n{}", out.sql);
8987    }
8988
8989    /// The mirror of the above: the multilink is what is quantified over and
8990    /// the array is only the haystack, so `IN` still folds into `= ANY(array)`
8991    /// and nothing unnests.
8992    #[test]
8993    fn test_any_over_a_multilink_against_an_unpacked_array_still_folds_into_any() {
8994        let out = compile_and_emit(
8995            "SELECT Person { name } FILTER any(.posts.title IN std::array_unpack(<array<str>>$titles))",
8996        );
8997        assert!(!out.sql.contains("unnest("), "got:\n{}", out.sql);
8998        assert!(out.sql.contains("= ANY("), "got:\n{}", out.sql);
8999    }
9000
9001    #[test]
9002    fn test_any_over_unpacked_array_aggregates_the_array_not_the_multilink() {
9003        let out = compile_and_emit(
9004            "SELECT Person { name } FILTER any(std::array_unpack(<array<str>>$titles) IN .posts.title)",
9005        );
9006        assert!(out.sql.contains("bool_or("), "got:\n{}", out.sql);
9007        assert!(out.sql.contains("_unnested"), "got:\n{}", out.sql);
9008    }
9009
9010    /// A schema with a `text[]` property, which `make_schema` has none of.
9011    fn schema_with_array_property() -> SchemaDescriptor {
9012        let mut schema = make_schema();
9013        schema.types[0].properties.push(crate::schema::PropertyDescriptor {
9014            name: "perms".into(),
9015            pg_type: "text[]".into(),
9016            nullable: true,
9017            default_sql: None,
9018            default_pyql: None,
9019            description: None,
9020            check_constraints: vec![],
9021            is_exclusive: false,
9022            is_pk: false,
9023            is_readonly: false,
9024            rewrites: vec![],
9025            tuple_members: None,
9026            column_type: None,
9027        });
9028        schema
9029    }
9030
9031    /// `array_agg(array_unpack(teams.perms))` flattens every row's array into
9032    /// one. Compiled as written it emitted `array_agg(unnest(...))`, which
9033    /// PostgreSQL rejects at execution time ("aggregate function calls cannot
9034    /// contain set-returning function calls"), so the unpacking has to happen
9035    /// in a row source the aggregate reads from outside.
9036    #[test]
9037    fn test_aggregate_over_an_unpacked_walk_unnests_in_a_row_source() {
9038        let out = compile_and_emit_with(
9039            "WITH teams := (SELECT Person { perms } FILTER .age > 1) SELECT std::array_agg(std::array_unpack(teams.perms))",
9040            &schema_with_array_property(),
9041        );
9042        assert!(!out.sql.contains("array_agg(unnest("), "got:\n{}", out.sql);
9043        assert!(
9044            out.sql.contains(
9045                r#"coalesce(array_agg("_s"."v"), '{}') FROM unnest(ARRAY(SELECT unnest("t1"."perms") FROM "teams" AS "t1"))"#
9046            ),
9047            "got:\n{}",
9048            out.sql
9049        );
9050    }
9051
9052    #[test]
9053    fn test_aggregate_over_an_unpacked_type_rooted_path_unnests_in_a_row_source() {
9054        let out = compile_and_emit_with(
9055            "SELECT std::count(std::array_unpack(Person.perms))",
9056            &schema_with_array_property(),
9057        );
9058        assert!(!out.sql.contains("count(unnest("), "got:\n{}", out.sql);
9059        assert!(
9060            out.sql
9061                .contains(r#"FROM unnest(ARRAY(SELECT unnest("t0"."perms") FROM "public"."Person" AS "t0"))"#),
9062            "got:\n{}",
9063            out.sql
9064        );
9065    }
9066
9067    /// One array standing on its own — a parameter, or one row's property —
9068    /// is already the row source, so it needs no walk gathered around it.
9069    #[test]
9070    fn test_aggregate_over_an_unpacked_parameter_unnests_the_array_itself() {
9071        let out = compile_and_emit("SELECT std::array_agg(std::array_unpack(<array<str>>$names))");
9072        assert!(!out.sql.contains("array_agg(unnest("), "got:\n{}", out.sql);
9073        assert!(
9074            out.sql.contains(r#"FROM unnest(($1)::text[]) AS "_s"("v")"#),
9075            "got:\n{}",
9076            out.sql
9077        );
9078    }
9079
9080    #[test]
9081    fn test_aggregate_over_an_unpacked_property_unnests_that_row_s_array() {
9082        let out = compile_and_emit_with(
9083            "SELECT Person { n := std::array_agg(std::array_unpack(.perms)) }",
9084            &schema_with_array_property(),
9085        );
9086        assert!(!out.sql.contains("array_agg(unnest("), "got:\n{}", out.sql);
9087        assert!(
9088            out.sql.contains(r#"FROM unnest("t0"."perms") AS "_s"("v")"#),
9089            "got:\n{}",
9090            out.sql
9091        );
9092    }
9093
9094    /// `array_agg` over no rows is `[]` in PyQL and NULL in SQL, which reaches
9095    /// a caller as a missing value where a list was promised.
9096    #[test]
9097    fn test_array_agg_over_nothing_is_an_empty_array_not_null() {
9098        for query in [
9099            "SELECT std::array_agg(Person.name)",
9100            "SELECT std::array_agg((SELECT Person).name)",
9101        ] {
9102            let out = compile_and_emit(query);
9103            assert!(
9104                out.sql.contains("coalesce(") && out.sql.contains("'{}'"),
9105                "{query} got:\n{}",
9106                out.sql
9107            );
9108        }
9109    }
9110
9111    /// The `coalesce` that empty array wears must not hide what the binding
9112    /// holds: `contains(ids, .id)` picks its overload off the binding's type,
9113    /// and an array read as text silently becomes a substring search.
9114    #[test]
9115    fn test_an_array_agg_binding_stays_an_array_through_its_coalesce() {
9116        let out = compile_and_emit(
9117            "WITH ids := std::array_agg(Person.id) SELECT Person { name } FILTER std::contains(ids, .id)",
9118        );
9119        assert!(
9120            out.sql.contains("@> ARRAY["),
9121            "expected the array overload, got:\n{}",
9122            out.sql
9123        );
9124        assert!(
9125            !out.sql.contains("strpos("),
9126            "the string overload must not win, got:\n{}",
9127            out.sql
9128        );
9129    }
9130
9131    /// An aggregate over a walk that is *not* unpacked keeps its own row
9132    /// source — the rewrite above must not reach it.
9133    #[test]
9134    fn test_aggregate_over_a_plain_walk_keeps_its_row_source() {
9135        let out = compile_and_emit("SELECT std::array_agg(Person.name)");
9136        assert!(
9137            out.sql.contains(r#"array_agg("t0"."name")"#) && out.sql.contains(r#"FROM "public"."Person""#),
9138            "got:\n{}",
9139            out.sql
9140        );
9141    }
9142
9143    /// The order check is a safety net for every emitter that assembles a
9144    /// WITH clause, so it has to actually catch one.
9145    #[test]
9146    fn test_the_cte_order_check_catches_a_forward_reference() {
9147        let bad = "WITH \"b\" AS (\n SELECT * FROM \"a\"\n),\n\"a\" AS (\n SELECT 1\n)\nSELECT 1";
9148        assert!(
9149            super::forward_cte_reference(bad).is_some(),
9150            "expected a forward reference"
9151        );
9152        let good = "WITH \"a\" AS (\n SELECT 1\n),\n\"b\" AS (\n SELECT * FROM \"a\"\n)\nSELECT 1";
9153        assert_eq!(super::forward_cte_reference(good), None, "ordered CTEs must pass");
9154    }
9155
9156    /// `.status IN <Enum>array_unpack($1)` — the cast sits between `IN` and
9157    /// the unpack, and used to hide it, leaving a set-returning function in a
9158    /// `WHERE` that PostgreSQL rejects at execution time.
9159    #[test]
9160    fn test_a_cast_unpacked_array_still_folds_into_any() {
9161        let out = compile_and_emit("SELECT Person { name } FILTER .name IN <str>std::array_unpack(<array<str>>$names)");
9162        assert!(
9163            !out.sql.contains("unnest("),
9164            "the unpack must fold into ANY, got:\n{}",
9165            out.sql
9166        );
9167        assert!(out.sql.contains("= ANY("), "got:\n{}", out.sql);
9168    }
9169
9170    /// A bare `array_unpack` outside `IN` still has to unnest — the unwrap is
9171    /// specific to `ANY`/`ALL`, which take an array rather than a set.
9172    #[test]
9173    fn test_array_unpack_outside_in_still_unnests() {
9174        let out = compile_and_emit("SELECT std::array_unpack(<array<str>>$names)");
9175        assert!(out.sql.contains("unnest("), "got:\n{}", out.sql);
9176    }
9177
9178    #[test]
9179    fn test_filter_not_in_set_literal_compiles_to_all_array() {
9180        let out = compile_and_emit("SELECT Person { name } FILTER .name NOT IN {'Carol'}");
9181        assert!(out.sql.contains("<> ALL(ARRAY['Carol'])"), "got:\n{}", out.sql);
9182    }
9183
9184    #[test]
9185    fn test_deep_splat_fetches_single_link_target_properties_not_just_id() {
9186        // `**` on a single link must expand to the target type's own
9187        // properties (one level of `*`, not `**` again — recursing into
9188        // the target's own links would never terminate for a cyclic link
9189        // graph), not just an implicit `{ id }`.
9190        let out = compile_and_emit("SELECT Person { ** }");
9191        assert!(
9192            out.sql.contains("\"name\""),
9193            "expected Company.name pulled in via .company's ** expansion, got:\n{}",
9194            out.sql
9195        );
9196    }
9197
9198    #[test]
9199    fn test_deep_splat_fetches_multilink_target_properties_not_just_id() {
9200        let out = compile_and_emit("SELECT Person { ** }");
9201        assert!(
9202            out.sql.contains("\"title\""),
9203            "expected Post.title pulled in via .posts' ** expansion, got:\n{}",
9204            out.sql
9205        );
9206    }
9207
9208    #[test]
9209    fn test_schema_type_cast_select() {
9210        let out = compile_and_emit("SELECT <default::Person><uuid>'019ef1bb-0d42-7a9f-8f6b-b38d028a49ba'");
9211        assert!(out.sql.contains("FROM \"public\".\"Person\""));
9212        assert!(out.sql.contains("WHERE"));
9213        assert!(out.sql.contains("'019ef1bb-0d42-7a9f-8f6b-b38d028a49ba'"));
9214    }
9215
9216    #[test]
9217    fn test_select_single_link() {
9218        let out = compile_and_emit("SELECT Person { name, company { name } }");
9219        assert!(out.sql.contains("'default::Company'::text"));
9220        assert!(out.sql.contains("FROM \"public\".\"Company\""));
9221        // join condition: parent FK column = target PK
9222        assert!(out.sql.contains("\"company_id\" = "));
9223    }
9224
9225    #[test]
9226    fn test_select_multi_link() {
9227        let out = compile_and_emit("SELECT Person { name, posts { title } }");
9228        assert!(out.sql.contains("array_agg(ROW("));
9229        assert!(out.sql.contains("ARRAY[]::record[]"));
9230        assert!(out.sql.contains("'default::Post'::text"));
9231        assert!(out.sql.contains("\"Person.posts\""));
9232    }
9233
9234    fn compile_err(query: &str) -> String {
9235        let schema = make_schema();
9236        let ast = parse::parse(query).expect("parse failed");
9237        match ir::compile(&ast, &schema) {
9238            Ok(_) => panic!("expected a compile error"),
9239            Err(e) => format!("{e}"),
9240        }
9241    }
9242
9243    #[test]
9244    fn test_multi_link_limit_cuts_rows_before_aggregating() {
9245        let out = compile_and_emit("SELECT Person { posts: { title } filter .title = 'x' limit 1 }");
9246        // The LIMIT has to sit on the row source, not next to array_agg —
9247        // the aggregate has already collapsed the rows by then.
9248        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9249        assert!(out.sql.contains("AS \"__lim\""), "{}", out.sql);
9250        let agg = out.sql.find("array_agg").unwrap();
9251        let limit = out.sql.find("LIMIT 1").unwrap();
9252        assert!(agg < limit, "{}", out.sql);
9253        assert!(out.sql.contains("\"t1\".\"title\"::text AS \"c1\""), "{}", out.sql);
9254    }
9255
9256    #[test]
9257    fn test_multi_link_order_by_with_limit_orders_the_aggregate_too() {
9258        let out = compile_and_emit("SELECT Person { posts: { title } order by .title desc offset 1 limit 2 }");
9259        // The sort key rides along as its own column so the array keeps the
9260        // derived table's order rather than relying on aggregation order.
9261        assert!(out.sql.contains("\"t1\".\"title\" AS \"s0\""), "{}", out.sql);
9262        assert!(
9263            out.sql.contains("ORDER BY \"__lim\".\"s0\" DESC NULLS LAST"),
9264            "{}",
9265            out.sql
9266        );
9267        assert!(out.sql.contains("OFFSET 1"), "{}", out.sql);
9268        assert!(out.sql.contains("LIMIT 2"), "{}", out.sql);
9269    }
9270
9271    #[test]
9272    fn test_multi_link_without_limit_keeps_the_flat_aggregate() {
9273        let out = compile_and_emit("SELECT Person { posts: { title } filter .title = 'x' }");
9274        assert!(!out.sql.contains("__lim"), "{}", out.sql);
9275        assert!(out.sql.contains("array_agg(ROW("), "{}", out.sql);
9276    }
9277
9278    #[test]
9279    fn test_sub_select_as_computed_pointer_is_a_link_pointer() {
9280        let out = compile_and_emit("SELECT Person { recent := (select .posts filter .title = 'x' limit 1) { title } }");
9281        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
9282            panic!()
9283        };
9284        // Named after the pointer, not after the link it selects from, and
9285        // one object: `limit 1` makes the sub-select at most one row.
9286        let ShapeNode::Object {
9287            name,
9288            pointers: elem_pointers,
9289            ..
9290        } = &pointers[2]
9291        else {
9292            panic!("{:?}", pointers[2])
9293        };
9294        assert_eq!(name, "recent");
9295        assert!(matches!(&elem_pointers[1], ShapeNode::Scalar { name, .. } if name == "title"));
9296        assert!(out.sql.contains("\"jt\".source = \"t0\".id"), "{}", out.sql);
9297        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9298    }
9299
9300    #[test]
9301    fn test_sub_select_shape_inside_the_parens_is_equivalent() {
9302        let outer = compile_and_emit("SELECT Person { recent := (select .posts limit 1) { title } }");
9303        let inner = compile_and_emit("SELECT Person { recent := (select .posts { title } limit 1) }");
9304        assert_eq!(outer.sql, inner.sql);
9305    }
9306
9307    #[test]
9308    fn test_sub_select_field_access_is_a_correlated_scalar_subquery() {
9309        let out = compile_and_emit("SELECT Person { t := (select .posts filter .title = 'x' limit 1).title }");
9310        // Rooted at the enclosing type and correlated back to its row, so
9311        // the subquery only ever sees this Person's posts.
9312        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
9313        assert!(out.sql.contains("\"t2\".\"title\""), "{}", out.sql);
9314        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9315        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
9316            panic!()
9317        };
9318        assert!(matches!(&pointers[2], ShapeNode::Scalar { name, .. } if name == "t"));
9319    }
9320
9321    #[test]
9322    fn test_sub_select_field_access_over_a_single_link() {
9323        let out = compile_and_emit("SELECT Person { c := (select .company).name }");
9324        assert!(out.sql.contains("\"t1\".\"company_id\" = \"t2\".\"id\""), "{}", out.sql);
9325        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
9326    }
9327
9328    #[test]
9329    fn test_sub_select_field_access_in_a_filter() {
9330        let out = compile_and_emit("SELECT Person { name } filter (select .posts limit 1).title = 'x'");
9331        assert!(out.sql.contains("WHERE ((SELECT \"t2\".\"title\""), "{}", out.sql);
9332        assert!(out.sql.contains("LIMIT 1) = 'x')"), "{}", out.sql);
9333    }
9334
9335    #[test]
9336    fn test_sub_select_over_an_absolute_path_is_not_correlated() {
9337        let out = compile_and_emit("SELECT Person { n := (select Company.name limit 1) }");
9338        assert!(out.sql.contains("FROM \"public\".\"Company\""), "{}", out.sql);
9339        assert!(!out.sql.contains("= \"t0\".\"id\""), "{}", out.sql);
9340    }
9341
9342    #[test]
9343    fn test_backlink_sub_select_carries_its_modifiers() {
9344        let out = compile_and_emit("SELECT Post { authors := (select .<posts[is Person] { name } limit 2) }");
9345        assert!(out.sql.contains("LIMIT 2"), "{}", out.sql);
9346        assert!(out.sql.contains("AS \"__lim\""), "{}", out.sql);
9347        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
9348            panic!()
9349        };
9350        assert!(matches!(&pointers[1], ShapeNode::Array { name, .. } if name == "authors"));
9351    }
9352
9353    #[test]
9354    fn test_with_bound_scalar_is_typed_for_overload_resolution() {
9355        // A `with`-bound value had no inferred type, so a call over one fell
9356        // back to the first registered overload and needed an explicit cast.
9357        let out = compile_and_emit("WITH xs := (select Person.name) SELECT contains(xs, 'a')");
9358        assert!(
9359            out.sql.contains("strpos((SELECT \"v\" FROM \"xs\"), 'a')"),
9360            "{}",
9361            out.sql
9362        );
9363    }
9364
9365    /// `Person` with a translation-style computed model: one computed
9366    /// resolves a filtered subset of a multi-link, others read through it.
9367    fn make_schema_with_computed_links() -> SchemaDescriptor {
9368        use crate::schema::ComputedDescriptor;
9369        let mut schema = make_schema();
9370        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
9371        person.computed = vec![
9372            ComputedDescriptor {
9373                name: "published".into(),
9374                expression: "(select .posts filter .title != '')".into(),
9375                return_type: None,
9376                link_target: None,
9377                link_multi: false,
9378            },
9379            ComputedDescriptor {
9380                name: "plain".into(),
9381                expression: ".posts".into(),
9382                return_type: None,
9383                link_target: None,
9384                link_multi: false,
9385            },
9386            ComputedDescriptor {
9387                name: "featured".into(),
9388                expression: "(select .published filter .title != 'draft')".into(),
9389                return_type: None,
9390                link_target: None,
9391                link_multi: false,
9392            },
9393            ComputedDescriptor {
9394                name: "looper".into(),
9395                expression: "(select .looper)".into(),
9396                return_type: None,
9397                link_target: None,
9398                link_multi: false,
9399            },
9400            ComputedDescriptor {
9401                name: "capped".into(),
9402                expression: "(select .posts limit 1)".into(),
9403                return_type: None,
9404                link_target: None,
9405                link_multi: false,
9406            },
9407        ];
9408        schema
9409    }
9410
9411    /// `Person.recent` computed from an object-returning function call, the
9412    /// shape jaldis's translation model uses.
9413    fn make_schema_with_object_fn_computed() -> SchemaDescriptor {
9414        use crate::schema::{ComputedDescriptor, FunctionDescriptor, FunctionParamDescriptor};
9415        let mut schema = make_schema();
9416        schema.functions = vec![FunctionDescriptor {
9417            name: "latest".into(),
9418            module: "default".into(),
9419            params: vec![FunctionParamDescriptor {
9420                name: "owner".into(),
9421                pg_type: "uuid".into(),
9422            }],
9423            return_pg_type: "default::Post".into(),
9424            return_is_object: true,
9425            return_is_set: true,
9426            return_is_polymorphic: false,
9427            volatility: "stable".into(),
9428            body: String::new(),
9429        }];
9430        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
9431        person.computed = vec![
9432            ComputedDescriptor {
9433                name: "recent".into(),
9434                expression: "latest(.id)".into(),
9435                return_type: None,
9436                link_target: None,
9437                link_multi: false,
9438            },
9439            ComputedDescriptor {
9440                name: "vetted".into(),
9441                expression: "(select latest(.id) filter .title != '')".into(),
9442                return_type: None,
9443                link_target: None,
9444                link_multi: false,
9445            },
9446        ];
9447        schema
9448    }
9449
9450    #[test]
9451    fn test_path_traverses_through_a_function_backed_computed() {
9452        // `recent := latest(.id)` has no path to splice in, so the call
9453        // itself becomes the next row source — LATERAL, because its
9454        // arguments read the alias the traversal has reached.
9455        let schema = make_schema_with_object_fn_computed();
9456        let out = compile_and_emit_with("SELECT Person { t := .recent.title }", &schema);
9457        assert!(
9458            out.sql
9459                .contains("CROSS JOIN LATERAL \"public\".\"latest\"(\"t1\".\"id\") AS \"t2\""),
9460            "{}",
9461            out.sql
9462        );
9463        // The function returns a set, so the traversal is multi-valued.
9464        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"title\""), "{}", out.sql);
9465    }
9466
9467    #[test]
9468    fn test_sub_select_over_a_function_backed_computed() {
9469        // The jaldis translation model, minimal: a function-backed computed
9470        // resolves the set, a scalar computed picks an attribute out of it.
9471        let schema = make_schema_with_object_fn_computed();
9472        let out = compile_and_emit_with(
9473            "SELECT Person { t := (select .recent filter .title = 'x' limit 1).title }",
9474            &schema,
9475        );
9476        assert!(
9477            out.sql.contains("CROSS JOIN LATERAL \"public\".\"latest\""),
9478            "{}",
9479            out.sql
9480        );
9481        assert!(out.sql.contains("\"t2\".\"title\" = 'x'"), "{}", out.sql);
9482        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9483        assert!(!out.sql.contains("ARRAY("), "{}", out.sql);
9484    }
9485
9486    #[test]
9487    fn test_function_backed_computed_carries_its_own_filter() {
9488        let schema = make_schema_with_object_fn_computed();
9489        let out = compile_and_emit_with(
9490            "SELECT Person { t := (select .vetted filter .title = 'x' limit 1).title }",
9491            &schema,
9492        );
9493        assert!(
9494            out.sql
9495                .contains("(\"t2\".\"title\" = 'x') AND (\"t2\".\"title\" <> '')"),
9496            "{}",
9497            out.sql
9498        );
9499    }
9500
9501    #[test]
9502    fn test_function_backed_computed_checks_its_argument_count() {
9503        use crate::schema::ComputedDescriptor;
9504        let mut schema = make_schema_with_object_fn_computed();
9505        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
9506        person.computed = vec![ComputedDescriptor {
9507            name: "recent".into(),
9508            expression: "latest()".into(),
9509            return_type: None,
9510            link_target: None,
9511            link_multi: false,
9512        }];
9513        let ast = parse::parse("SELECT Person { t := .recent.title }").unwrap();
9514        let err = match ir::compile(&ast, &schema) {
9515            Ok(_) => panic!("expected a compile error"),
9516            Err(e) => format!("{e}"),
9517        };
9518        assert!(err.contains("expects 1 argument(s), got 0"), "{err}");
9519    }
9520
9521    #[test]
9522    fn test_path_traverses_through_a_computed_pointer() {
9523        // `.published` is itself computed, so it has no column to join on —
9524        // its own path takes the step's place and its filter rides along.
9525        let schema = make_schema_with_computed_links();
9526        let out = compile_and_emit_with("SELECT Person { t := .published.title }", &schema);
9527        assert!(out.sql.contains("\"public\".\"Person.posts\""), "{}", out.sql);
9528        assert!(
9529            out.sql
9530                .contains("(\"t1\".\"id\" = \"t0\".\"id\") AND (\"t2\".\"title\" <> '')"),
9531            "{}",
9532            out.sql
9533        );
9534    }
9535
9536    #[test]
9537    fn test_sub_select_over_a_computed_pointer_projects_a_property() {
9538        // The shape the jaldis translation model is built on:
9539        // `(select .translation filter .attribute = X limit 1).value`.
9540        let schema = make_schema_with_computed_links();
9541        let out = compile_and_emit_with(
9542            "SELECT Person { t := (select .published filter .title = 'a' limit 1).title }",
9543            &schema,
9544        );
9545        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9546        // Both the computed's own filter and the use-site one apply.
9547        assert!(out.sql.contains("\"t2\".\"title\" = 'a'"), "{}", out.sql);
9548        assert!(out.sql.contains("\"t2\".\"title\" <> ''"), "{}", out.sql);
9549    }
9550
9551    #[test]
9552    fn test_chained_computed_pointers_each_contribute_their_filter() {
9553        let schema = make_schema_with_computed_links();
9554        let out = compile_and_emit_with("SELECT Person { t := .featured.title }", &schema);
9555        assert_eq!(out.sql.matches("\"public\".\"Person.posts\"").count(), 1, "{}", out.sql);
9556        assert!(out.sql.contains("\"t2\".\"title\" <> 'draft'"), "{}", out.sql);
9557        assert!(out.sql.contains("\"t2\".\"title\" <> ''"), "{}", out.sql);
9558    }
9559
9560    #[test]
9561    fn test_computed_pointer_naming_a_bare_link_is_traversable() {
9562        let schema = make_schema_with_computed_links();
9563        let out = compile_and_emit_with("SELECT Person { t := .plain.title }", &schema);
9564        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"title\""), "{}", out.sql);
9565    }
9566
9567    #[test]
9568    fn test_a_cycle_of_computed_pointers_is_reported_not_hung_on() {
9569        let schema = make_schema_with_computed_links();
9570        let ast = parse::parse("SELECT Person { t := .looper.title }").unwrap();
9571        let err = match ir::compile(&ast, &schema) {
9572            Ok(_) => panic!("expected a compile error"),
9573            Err(e) => format!("{e}"),
9574        };
9575        assert!(err.contains("expands into itself"), "{err}");
9576    }
9577
9578    #[test]
9579    fn test_a_cycle_of_computed_pointers_read_as_a_value_is_reported_not_hung_on() {
9580        // Read as the last step, the computed was compiled as an expression,
9581        // which walked the same path again with no count of how often.
9582        let schema = make_schema_with_computed_links();
9583        for query in ["SELECT Person { looper }", "SELECT Person { * }"] {
9584            let ast = parse::parse(query).unwrap();
9585            let err = match ir::compile(&ast, &schema) {
9586                Ok(_) => panic!("expected a compile error for {query}"),
9587                Err(e) => format!("{e}"),
9588            };
9589            assert!(err.contains("expands into itself"), "{query}: {err}");
9590        }
9591    }
9592
9593    #[test]
9594    fn test_traversing_through_a_limited_computed_uses_a_lateral() {
9595        // `capped` picks one row per Person, so the traversal correlates
9596        // through a LATERAL rather than a plain join — and its `limit 1`
9597        // means the result is one title, not an array of them.
9598        let schema = make_schema_with_computed_links();
9599        let out = compile_and_emit_with("SELECT Person { t := .capped.title }", &schema);
9600        assert!(out.sql.contains("JOIN LATERAL ("), "{}", out.sql);
9601        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9602        assert!(!out.sql.contains("ARRAY(SELECT"), "{}", out.sql);
9603    }
9604
9605    #[test]
9606    fn test_backlink_from_a_type_whose_interface_the_link_targets() {
9607        use crate::schema::{LinkDescriptor, TypeDescriptor};
9608        let mut schema = make_interface_schema();
9609        let individual = schema.types.iter().find(|t| t.name == "Individual").unwrap().clone();
9610        schema.types.push(TypeDescriptor {
9611            name: "Note".into(),
9612            module: "default".into(),
9613            table: "Note".into(),
9614            abstract_: false,
9615            materialized: true,
9616            description: None,
9617            parents: vec![],
9618            interfaces: vec![],
9619            bases: vec![],
9620            properties: individual.properties[..1].to_vec(),
9621            // Targets the *interface*, which `Individual` implements.
9622            links: vec![LinkDescriptor {
9623                name: "owner".into(),
9624                target: "default::Account".into(),
9625                nullable: true,
9626                through: None,
9627                description: None,
9628                default_pyql: None,
9629                is_exclusive: false,
9630                is_readonly: false,
9631                rewrites: vec![],
9632                on_delete: vec![],
9633            }],
9634            multilinks: vec![],
9635            computed: vec![],
9636            constraints: vec![],
9637            indexes: vec![],
9638            partition: None,
9639            vector_indexes: vec![],
9640            search_indexes: vec![],
9641            triggers: vec![],
9642            junction: false,
9643            signals: vec![],
9644        });
9645        // Reported "link 'Note::owner' does not target 'Individual'" — but a
9646        // link to an interface accepts every implementor, and they share ids.
9647        let out = compile_and_emit_with("SELECT Individual { notes := .<owner[is Note] }", &schema);
9648        assert!(out.sql.contains("\"public\".\"Note\""), "{}", out.sql);
9649        assert!(out.sql.contains("\"owner_id\" = \"t0\".id"), "{}", out.sql);
9650    }
9651
9652    #[test]
9653    fn test_with_block_in_expression_position_hoists_its_bindings() {
9654        // A `with` has nowhere to put its CTEs mid-expression, so they move
9655        // to the enclosing statement's own WITH clause.
9656        let out = compile_and_emit("SELECT Person { t := (with c := (select Company.name limit 1) select c) }");
9657        assert!(out.sql.starts_with("WITH\n\"c\" AS ("), "{}", out.sql);
9658        assert!(out.sql.contains("(SELECT \"v\" FROM \"c\")"), "{}", out.sql);
9659    }
9660
9661    #[test]
9662    fn test_hoisted_binding_is_usable_in_the_expression_around_it() {
9663        let out =
9664            compile_and_emit("SELECT Person { t := (with c := (select Company.name limit 1) select .name ++ c) }");
9665        assert!(
9666            out.sql.contains("(\"t0\".\"name\" || (SELECT \"v\" FROM \"c\"))"),
9667            "{}",
9668            out.sql
9669        );
9670    }
9671
9672    #[test]
9673    fn test_hoisted_binding_joins_the_statements_own_with_clause() {
9674        let out = compile_and_emit(
9675            "WITH top := (select Person.name limit 1) \
9676             SELECT Person { t := (with c := (select Company.name limit 1) select c) }",
9677        );
9678        let top = out.sql.find("\"top\" AS (").expect("top-level binding");
9679        let inner = out.sql.find("\"c\" AS (").expect("hoisted binding");
9680        assert!(top < inner, "the statement's own bindings come first:\n{}", out.sql);
9681    }
9682
9683    #[test]
9684    fn test_a_binding_named_after_a_keyword_is_usable() {
9685        // The lexer is case-insensitive, so `order` arrives as the ORDER
9686        // token and read as "expected an expression, found 'order'" — and the
9687        // binding keeps the casing it was written with, not the keyword
9688        // table's.
9689        let out = compile_and_emit("WITH order := (select Person.name) SELECT Person { name } FILTER .name = order");
9690        assert!(out.sql.contains("(SELECT \"v\" FROM \"order\")"), "{}", out.sql);
9691    }
9692
9693    #[test]
9694    fn test_computed_declared_on_an_interface_is_visible_from_an_implementor() {
9695        use crate::schema::ComputedDescriptor;
9696        let mut schema = make_interface_schema();
9697        let account = schema.types.iter_mut().find(|t| t.name == "Account").unwrap();
9698        account.computed = vec![ComputedDescriptor {
9699            name: "tier".into(),
9700            expression: "'gold'".into(),
9701            return_type: None,
9702            link_target: None,
9703            link_multi: false,
9704        }];
9705        let individual = schema.types.iter_mut().find(|t| t.name == "Individual").unwrap();
9706        individual.computed = vec![];
9707        // Reported as "has no link or property 'tier'. Did you mean 'tier'?"
9708        // — the suggester searched interfaces, the resolver didn't.
9709        for q in ["SELECT Individual { tier }", "SELECT Individual { t := .tier }"] {
9710            let out = compile_and_emit_with(q, &schema);
9711            assert!(out.sql.contains("'gold'"), "{q}: {}", out.sql);
9712        }
9713    }
9714
9715    #[test]
9716    fn test_computed_may_carry_trailing_modifiers_without_a_select() {
9717        use crate::schema::ComputedDescriptor;
9718        let mut schema = make_schema();
9719        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
9720        person.computed = vec![ComputedDescriptor {
9721            name: "recent".into(),
9722            expression: ".posts order by .title desc limit 5".into(),
9723            return_type: None,
9724            link_target: None,
9725            link_multi: false,
9726        }];
9727        // A schema fragment has no statement around it to hang modifiers
9728        // off, so this used to be "expected an expression, found 'order'".
9729        let out = compile_and_emit_with("SELECT Person { recent { title } }", &schema);
9730        assert!(out.sql.contains("LIMIT 5"), "{}", out.sql);
9731        assert!(out.sql.contains("ORDER BY \"t1\".\"title\" DESC"), "{}", out.sql);
9732    }
9733
9734    #[test]
9735    fn test_computed_may_lead_with_a_bare_select() {
9736        use crate::schema::ComputedDescriptor;
9737        let mut schema = make_schema();
9738        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
9739        person.computed = vec![ComputedDescriptor {
9740            name: "recent".into(),
9741            expression: "select .posts order by .title desc limit 5".into(),
9742            return_type: None,
9743            link_target: None,
9744            link_multi: false,
9745        }];
9746        let out = compile_and_emit_with("SELECT Person { recent { title } }", &schema);
9747        assert!(out.sql.contains("LIMIT 5"), "{}", out.sql);
9748    }
9749
9750    #[test]
9751    fn test_sub_select_with_a_shape_may_project_a_property_off_it() {
9752        let out = compile_and_emit("SELECT Person { t := (select .posts { title } limit 1).title }");
9753        assert!(out.sql.contains("\"t2\".\"title\""), "{}", out.sql);
9754        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9755    }
9756
9757    #[test]
9758    fn test_sub_statement_error_names_what_it_got() {
9759        let err = compile_err("SELECT Person { t := (insert Company { name := 'a' }) }");
9760        assert!(err.contains("an insert cannot stand in for a value"), "{err}");
9761    }
9762
9763    #[test]
9764    fn test_with_bound_scalar_path_select_emits_a_real_select() {
9765        // The CTE body used to be just the FROM clause — `"xs" AS (
9766        // "public"."Person" AS "t0" )` — which is not SQL at all.
9767        let out = compile_and_emit("WITH xs := (select Person.name) SELECT Person { name }");
9768        assert!(
9769            out.sql
9770                .contains("SELECT ROW(v) AS result, v FROM (SELECT \"t0\".\"name\" AS v"),
9771            "{}",
9772            out.sql
9773        );
9774    }
9775
9776    #[test]
9777    fn test_with_bound_scalar_path_select_is_typed_as_its_result() {
9778        // Binding `Person.name` binds text, not `default::Person`, so a
9779        // reference to it reads the CTE's value column rather than an `id`.
9780        let out = compile_and_emit("WITH xs := (select Person.name) SELECT Person { name } FILTER .name IN xs");
9781        assert!(out.sql.contains("ANY((SELECT \"v\" FROM \"xs\"))"), "{}", out.sql);
9782    }
9783
9784    #[test]
9785    fn test_distinct_survives_a_with_binding() {
9786        let out = compile_and_emit("WITH xs := (select distinct Person.name) SELECT Person { name }");
9787        assert!(out.sql.contains("SELECT DISTINCT \"t0\".\"name\" AS v"), "{}", out.sql);
9788    }
9789
9790    #[test]
9791    fn test_for_over_a_derived_set_iterates_every_row() {
9792        // A non-literal iterator used to be wrapped in a one-row VALUES, so
9793        // the body ran once against a scalar subquery instead of per row.
9794        let out = compile_and_emit("FOR x IN (select Person.name) UNION (SELECT Person { name } FILTER .name = x)");
9795        assert!(!out.sql.contains("VALUES"), "{}", out.sql);
9796        assert!(out.sql.contains(") AS \"_for_x\""), "{}", out.sql);
9797        assert!(out.sql.contains("CROSS JOIN LATERAL"), "{}", out.sql);
9798        assert!(out.sql.contains("\"t1\".\"name\" = \"_for_x\".\"v\""), "{}", out.sql);
9799    }
9800
9801    #[test]
9802    fn test_for_insert_over_a_derived_set_uses_it_as_the_cte() {
9803        let out = compile_and_emit("FOR x IN (select Person.name) UNION (INSERT Company { name := x })");
9804        assert!(out.sql.starts_with("WITH \"_for_x\" AS ("), "{}", out.sql);
9805        assert!(
9806            out.sql.contains("SELECT \"_for_x\".\"v\" FROM \"_for_x\""),
9807            "{}",
9808            out.sql
9809        );
9810    }
9811
9812    #[test]
9813    fn test_for_over_objects_binds_their_id() {
9814        let out = compile_and_emit("FOR p IN (select Person) UNION (INSERT Company { name := <str>p })");
9815        assert!(out.sql.contains("SELECT \"id\" AS v FROM ("), "{}", out.sql);
9816    }
9817
9818    #[test]
9819    fn test_for_over_a_non_select_statement_is_rejected() {
9820        let ast = parse::parse("FOR x IN (INSERT Company { name := 'a' }) UNION (SELECT Person { name })").unwrap();
9821        let schema = make_schema();
9822        let err = match ir::compile(&ast, &schema) {
9823            Ok(_) => panic!("expected a compile error"),
9824            Err(e) => format!("{e}"),
9825        };
9826        assert!(err.contains("only a select can be iterated"), "{err}");
9827    }
9828
9829    #[test]
9830    fn test_multi_link_path_outside_a_comparison_is_an_array() {
9831        // It stands for a set of values — as a scalar subquery Postgres
9832        // would reject it the moment a Person had two posts.
9833        let out = compile_and_emit("SELECT Person { t := .posts.title }");
9834        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"title\""), "{}", out.sql);
9835        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
9836    }
9837
9838    #[test]
9839    fn test_multi_link_path_inside_a_comparison_is_still_exists() {
9840        let out = compile_and_emit("SELECT Person { name } filter .posts.title = 'x'");
9841        assert!(out.sql.contains("WHERE EXISTS("), "{}", out.sql);
9842        assert!(!out.sql.contains("ARRAY("), "{}", out.sql);
9843    }
9844
9845    #[test]
9846    fn test_path_traversal_deeper_than_two_steps() {
9847        let schema = make_schema_with_through();
9848        let out = compile_and_emit_with("SELECT Person { t := .friends.friends.name }", &schema);
9849        // Two junction hops, then the property — one subquery, not an error.
9850        assert_eq!(out.sql.matches("\"public\".\"PersonFriend\"").count(), 2, "{}", out.sql);
9851        assert!(out.sql.contains("ARRAY(SELECT \"t4\".\"name\""), "{}", out.sql);
9852    }
9853
9854    #[test]
9855    fn test_coalesce_over_backlinked_values_is_a_set_choice() {
9856        // The backlink was taken for a comparison and rewritten to an EXISTS,
9857        // with a COALESCE of a value and an array inside it.
9858        let out = compile_and_emit("SELECT Post ORDER BY max(.<posts[is Person].age ?? .<posts[is Person].age)");
9859        assert!(!out.sql.contains("EXISTS"), "{}", out.sql);
9860        assert!(!out.sql.contains("COALESCE("), "{}", out.sql);
9861        assert!(out.sql.contains("CASE WHEN (cardinality("), "{}", out.sql);
9862        assert!(
9863            out.sql
9864                .contains("(SELECT coalesce(max(\"_s\".\"v\"), NULL) FROM unnest("),
9865            "{}",
9866            out.sql
9867        );
9868    }
9869
9870    #[test]
9871    fn test_coalesce_over_multilink_values_is_a_set_choice() {
9872        let out = compile_and_emit("SELECT Person { t := .posts.title ?? .posts.title }");
9873        assert!(!out.sql.contains("EXISTS"), "{}", out.sql);
9874        assert!(out.sql.contains("CASE WHEN (cardinality("), "{}", out.sql);
9875    }
9876
9877    #[test]
9878    fn test_coalesce_of_a_set_with_a_single_value_falls_back_to_it() {
9879        let out = compile_and_emit("SELECT Post { a := .<posts[is Person].age ?? 0 }");
9880        assert!(out.sql.contains("ELSE array_remove(ARRAY[0], NULL) END"), "{}", out.sql);
9881    }
9882
9883    #[test]
9884    fn test_traversal_after_a_backlink() {
9885        let out = compile_and_emit("SELECT Post { t := .<posts[is Person].name }");
9886        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"name\""), "{}", out.sql);
9887        assert!(out.sql.contains("\"t3\".\"target\" = \"t1\".\"id\""), "{}", out.sql);
9888    }
9889
9890    #[test]
9891    fn test_computed_pointer_on_a_linked_type_is_reachable() {
9892        use crate::schema::ComputedDescriptor;
9893        let mut schema = make_schema();
9894        let company = schema.types.iter_mut().find(|t| t.name == "Company").unwrap();
9895        company.computed = vec![ComputedDescriptor {
9896            name: "shout".into(),
9897            expression: ".name ++ '!'".into(),
9898            return_type: Some("text".into()),
9899            link_target: None,
9900            link_multi: false,
9901        }];
9902        // `.company.shout` used to report "has no link or property 'shout'"
9903        // while helpfully suggesting 'shout' — path traversal never looked
9904        // at the target type's computed pointers.
9905        let out = compile_and_emit_with("SELECT Person { t := .company.shout }", &schema);
9906        assert!(out.sql.contains("(\"t2\".\"name\" || '!')"), "{}", out.sql);
9907    }
9908
9909    #[test]
9910    fn test_computed_pointer_behind_a_type_intersection() {
9911        let schema = make_interface_schema();
9912        let out = compile_and_emit_with("SELECT Account { n := [is Individual].full_name }", &schema);
9913        assert!(out.sql.contains("upper(\"t1\".\"first_name\")"), "{}", out.sql);
9914        assert!(out.sql.contains("FROM \"public\".\"Individual\""), "{}", out.sql);
9915        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
9916    }
9917
9918    #[test]
9919    fn test_computed_pointer_cannot_be_traversed_through() {
9920        use crate::schema::ComputedDescriptor;
9921        let mut schema = make_schema();
9922        let company = schema.types.iter_mut().find(|t| t.name == "Company").unwrap();
9923        company.computed = vec![ComputedDescriptor {
9924            name: "shout".into(),
9925            expression: ".name ++ '!'".into(),
9926            return_type: Some("text".into()),
9927            link_target: None,
9928            link_multi: false,
9929        }];
9930        let ast = parse::parse("SELECT Person { t := .company.shout.nope }").unwrap();
9931        let err = match ir::compile(&ast, &schema) {
9932            Ok(_) => panic!("expected a compile error"),
9933            Err(e) => format!("{e}"),
9934        };
9935        assert!(err.contains("is a computed pointer"), "{err}");
9936    }
9937
9938    #[test]
9939    fn test_sub_select_over_a_multi_link_path_keeps_its_modifiers() {
9940        let out = compile_and_emit("SELECT Person { t := (select .posts.title order by .title desc limit 2) }");
9941        assert!(out.sql.contains("ARRAY(SELECT"), "{}", out.sql);
9942        assert!(out.sql.contains("ORDER BY \"t2\".\"title\" DESC"), "{}", out.sql);
9943        assert!(out.sql.contains("LIMIT 2"), "{}", out.sql);
9944    }
9945
9946    #[test]
9947    fn test_limit_one_over_a_multi_link_path_stays_a_scalar() {
9948        // `limit 1` is what makes the set single-valued, so this one is a
9949        // scalar subquery rather than a one-element array.
9950        let out = compile_and_emit("SELECT Person { t := (select .posts.title limit 1) }");
9951        assert!(!out.sql.contains("ARRAY("), "{}", out.sql);
9952        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9953    }
9954
9955    #[test]
9956    fn test_computed_multilink_can_carry_a_nested_shape() {
9957        let out = compile_and_emit("SELECT Person { p := .posts { title } }");
9958        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
9959            panic!()
9960        };
9961        let ShapeNode::Array { name, element, .. } = &pointers[2] else {
9962            panic!("{:?}", pointers[2])
9963        };
9964        assert_eq!(name, "p");
9965        let ShapeNode::Object {
9966            pointers: elem_pointers,
9967            ..
9968        } = element.as_ref()
9969        else {
9970            panic!()
9971        };
9972        assert!(matches!(&elem_pointers[1], ShapeNode::Scalar { name, .. } if name == "title"));
9973    }
9974
9975    #[test]
9976    fn test_schema_declared_backlink_computed_is_an_object_pointer() {
9977        use crate::schema::ComputedDescriptor;
9978        let mut schema = make_schema();
9979        let post = schema.types.iter_mut().find(|t| t.name == "Post").unwrap();
9980        post.computed = vec![ComputedDescriptor {
9981            name: "authors".into(),
9982            expression: ".<posts[is Person]".into(),
9983            return_type: None,
9984            link_target: None,
9985            link_multi: false,
9986        }];
9987        // A backlink computed used to compile as an EXISTS boolean here —
9988        // the pointer builders were only reachable from an inline `:=`.
9989        let out = compile_and_emit_with("SELECT Post { authors { name } }", &schema);
9990        assert!(out.sql.contains("array_agg(ROW("), "{}", out.sql);
9991        assert!(out.sql.contains("\"t1\".\"name\"::text"), "{}", out.sql);
9992        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
9993            panic!()
9994        };
9995        assert!(matches!(&pointers[1], ShapeNode::Array { name, .. } if name == "authors"));
9996    }
9997
9998    #[test]
9999    fn test_schema_declared_multilink_computed_defaults_to_ids_without_a_shape() {
10000        use crate::schema::ComputedDescriptor;
10001        let mut schema = make_schema();
10002        schema.types[0].computed = vec![ComputedDescriptor {
10003            name: "everything".into(),
10004            expression: ".posts".into(),
10005            return_type: None,
10006            link_target: None,
10007            link_multi: false,
10008        }];
10009        let out = compile_and_emit_with("SELECT Person { everything }", &schema);
10010        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10011            panic!()
10012        };
10013        assert!(matches!(&pointers[2], ShapeNode::Array { name, .. } if name == "everything"));
10014    }
10015
10016    #[test]
10017    fn test_schema_declared_computed_sub_select_is_a_link_pointer() {
10018        use crate::schema::ComputedDescriptor;
10019        let mut schema = make_schema();
10020        schema.types[0].computed = vec![
10021            ComputedDescriptor {
10022                name: "recent".into(),
10023                expression: "(select .posts order by .title desc limit 1)".into(),
10024                return_type: None,
10025                link_target: None,
10026                link_multi: false,
10027            },
10028            ComputedDescriptor {
10029                name: "recent_title".into(),
10030                expression: "(select .posts order by .title desc limit 1).title".into(),
10031                return_type: Some("text".into()),
10032                link_target: None,
10033                link_multi: false,
10034            },
10035        ];
10036        let out = compile_and_emit_with("SELECT Person { recent { title }, recent_title }", &schema);
10037        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10038            panic!()
10039        };
10040        // A declared computed gets the same treatment as one written inline:
10041        // object-valued when it selects a link, scalar when it projects one.
10042        assert!(matches!(&pointers[2], ShapeNode::Object { name, .. } if name == "recent"));
10043        assert!(matches!(&pointers[3], ShapeNode::Scalar { name, .. } if name == "recent_title"));
10044        assert!(out.sql.contains("\"jt\".source = \"t0\".id"), "{}", out.sql);
10045        assert_eq!(out.sql.matches("LIMIT 1").count(), 2, "{}", out.sql);
10046        crate::validate::validate_schema_types(&schema).expect("schema should validate");
10047    }
10048
10049    /// `select (insert T { … }).id` — the mutation is hoisted into the
10050    /// statement's own WITH and the field read off it, which is what the
10051    /// `with x := (insert …) select x.id` spelling already produced. Without
10052    /// the CTE the projection would read the whole table instead.
10053    #[test]
10054    fn test_a_mutation_read_through_a_path_runs_as_a_hoisted_cte() {
10055        let out = compile_and_emit("SELECT (INSERT Person { name := 'x' }).name");
10056        assert!(
10057            out.sql.contains("\"_nested_dml_0\" AS (") && out.sql.contains("INSERT INTO"),
10058            "the mutation must run as a CTE:\n{}",
10059            out.sql
10060        );
10061        assert!(
10062            out.sql.contains("FROM \"_nested_dml_0\""),
10063            "the projection must read the mutation's own rows:\n{}",
10064            out.sql
10065        );
10066    }
10067
10068    #[test]
10069    fn test_dml_sub_statement_in_expression_position_still_rejected() {
10070        let err = compile_err("SELECT Person { x := (insert Company { name := 'c' }).name }");
10071        assert!(err.contains("an insert cannot stand in for a value"), "{err}");
10072    }
10073
10074    #[test]
10075    fn test_sub_select_with_a_shape_in_expression_position_is_rejected() {
10076        // Only when the shape *is* the result — projecting a property off
10077        // it is fine, since the shape then says nothing the projection
10078        // doesn't (see test_sub_select_with_a_shape_may_project_a_property_off_it).
10079        let err = compile_err("SELECT Person { name } filter (select .posts { title }) = 1");
10080        assert!(err.contains("sub-select with a shape"), "{err}");
10081    }
10082
10083    fn make_schema_with_through() -> SchemaDescriptor {
10084        let id_prop = || PropertyDescriptor {
10085            name: "id".into(),
10086            pg_type: "uuid".into(),
10087            nullable: false,
10088            default_sql: Some("gen_random_uuid()".into()),
10089            description: None,
10090            default_pyql: None,
10091            check_constraints: vec![],
10092            is_exclusive: true,
10093            is_pk: true,
10094            is_readonly: true,
10095            rewrites: vec![],
10096            tuple_members: None,
10097            column_type: None,
10098        };
10099        let name_prop = || PropertyDescriptor {
10100            name: "name".into(),
10101            pg_type: "text".into(),
10102            nullable: false,
10103            default_sql: None,
10104            description: None,
10105            check_constraints: vec![],
10106            default_pyql: None,
10107            is_exclusive: false,
10108            is_pk: false,
10109            is_readonly: false,
10110            rewrites: vec![],
10111            tuple_members: None,
10112            column_type: None,
10113        };
10114        SchemaDescriptor {
10115            types: vec![
10116                TypeDescriptor {
10117                    name: "Person".into(),
10118                    module: "default".into(),
10119                    table: "Person".into(),
10120                    abstract_: false,
10121                    materialized: false,
10122                    description: None,
10123                    parents: vec![],
10124                    interfaces: vec![],
10125                    bases: vec![],
10126                    properties: vec![id_prop(), name_prop()],
10127                    links: vec![],
10128                    multilinks: vec![MultiLinkDescriptor {
10129                        name: "friends".into(),
10130                        target: "default::Person".into(),
10131                        through: Some("default::PersonFriend".into()),
10132                        nullable: false,
10133                        description: None,
10134                        default_pyql: None,
10135                        on_delete: vec![],
10136                        is_exclusive: false,
10137                    }],
10138                    computed: vec![],
10139                    constraints: vec![],
10140                    indexes: vec![],
10141                    partition: None,
10142                    vector_indexes: vec![],
10143                    search_indexes: vec![],
10144                    triggers: vec![],
10145                    junction: false,
10146                    signals: vec![],
10147                },
10148                TypeDescriptor {
10149                    name: "PersonFriend".into(),
10150                    module: "default".into(),
10151                    table: "PersonFriend".into(),
10152                    abstract_: false,
10153                    materialized: false,
10154                    description: None,
10155                    parents: vec![],
10156                    interfaces: vec![],
10157                    bases: vec![],
10158                    properties: vec![id_prop()],
10159                    links: vec![
10160                        LinkDescriptor {
10161                            name: "person".into(),
10162                            target: "default::Person".into(),
10163                            nullable: false,
10164                            through: None,
10165                            description: None,
10166                            default_pyql: None,
10167                            is_exclusive: false,
10168                            is_readonly: false,
10169                            rewrites: vec![],
10170                            on_delete: vec![],
10171                        },
10172                        LinkDescriptor {
10173                            name: "friend".into(),
10174                            target: "default::Person".into(),
10175                            nullable: false,
10176                            through: None,
10177                            description: None,
10178                            default_pyql: None,
10179                            is_exclusive: false,
10180                            is_readonly: false,
10181                            rewrites: vec![],
10182                            on_delete: vec![],
10183                        },
10184                    ],
10185                    multilinks: vec![],
10186                    computed: vec![],
10187                    constraints: vec![],
10188                    indexes: vec![],
10189                    partition: None,
10190                    vector_indexes: vec![],
10191                    search_indexes: vec![],
10192                    triggers: vec![],
10193                    junction: false,
10194                    signals: vec![],
10195                },
10196            ],
10197            scalars: vec![],
10198            enums: vec![],
10199            named_tuples: vec![],
10200            globals: vec![],
10201            functions: vec![],
10202            aliases: vec![],
10203            channels: vec![],
10204            ..Default::default()
10205        }
10206    }
10207
10208    #[test]
10209    fn test_select_through_multi_link() {
10210        let schema = make_schema_with_through();
10211        let ast = crate::parse::parse("SELECT Person { name, friends { name } }").unwrap();
10212        let ir = crate::ir::compile(&ast, &schema).unwrap();
10213        let out = emit(&ir);
10214        // Junction table is the PersonFriend table, not the standard dotted name
10215        assert!(out.sql.contains("\"public\".\"PersonFriend\""));
10216        // Source FK column (person → Person) and target FK column (friend → Person)
10217        assert!(out.sql.contains("\"friend\""));
10218        assert!(out.sql.contains("\"person\""));
10219        // Still emits array_agg pattern
10220        assert!(out.sql.contains("array_agg(ROW("));
10221    }
10222
10223    /// Person/Org — a junction-backed single link (`Person.spouse: Link[Org,
10224    /// through(Marriage)]`, exclusive). Deliberately targets a different
10225    /// type (Org) rather than a self-link, for the same reason
10226    /// `test_select_through_multi_link` avoids Person-to-Person — see the
10227    /// comment on `make_schema_with_through` above.
10228    #[test]
10229    fn test_limited_multi_link_keeps_a_nested_multi_link_in_scope() {
10230        // The nested pointer's own correlated subquery moves inside the
10231        // derived table the LIMIT sits on, where its alias is still bound.
10232        let schema = make_schema_with_through();
10233        let ast = crate::parse::parse("SELECT Person { friends: { name, friends { name } } limit 1 }").unwrap();
10234        let ir = crate::ir::compile(&ast, &schema).unwrap();
10235        let out = emit(&ir);
10236        assert_eq!(out.sql.matches("array_agg(ROW(").count(), 2, "{}", out.sql);
10237        let derived = out.sql.find("AS \"__lim\"").unwrap();
10238        let nested = out.sql.rfind("array_agg(ROW(").unwrap();
10239        assert!(
10240            nested < derived,
10241            "nested aggregate must sit inside the derived table:\n{}",
10242            out.sql
10243        );
10244    }
10245
10246    fn make_schema_with_junction_backed_link() -> SchemaDescriptor {
10247        let id_prop = || PropertyDescriptor {
10248            name: "id".into(),
10249            pg_type: "uuid".into(),
10250            nullable: false,
10251            default_sql: Some("gen_random_uuid()".into()),
10252            description: None,
10253            default_pyql: None,
10254            check_constraints: vec![],
10255            is_exclusive: true,
10256            is_pk: true,
10257            is_readonly: true,
10258            rewrites: vec![],
10259            tuple_members: None,
10260            column_type: None,
10261        };
10262        let name_prop = || PropertyDescriptor {
10263            name: "name".into(),
10264            pg_type: "text".into(),
10265            nullable: false,
10266            default_sql: None,
10267            description: None,
10268            check_constraints: vec![],
10269            default_pyql: None,
10270            is_exclusive: false,
10271            is_pk: false,
10272            is_readonly: false,
10273            rewrites: vec![],
10274            tuple_members: None,
10275            column_type: None,
10276        };
10277        SchemaDescriptor {
10278            types: vec![
10279                TypeDescriptor {
10280                    name: "Person".into(),
10281                    module: "default".into(),
10282                    table: "Person".into(),
10283                    abstract_: false,
10284                    materialized: false,
10285                    description: None,
10286                    parents: vec![],
10287                    interfaces: vec![],
10288                    bases: vec![],
10289                    properties: vec![id_prop(), name_prop()],
10290                    links: vec![LinkDescriptor {
10291                        name: "spouse".into(),
10292                        target: "default::Org".into(),
10293                        nullable: true,
10294                        through: Some("default::Marriage".into()),
10295                        description: None,
10296                        default_pyql: None,
10297                        is_exclusive: true,
10298                        is_readonly: false,
10299                        rewrites: vec![],
10300                        on_delete: vec![],
10301                    }],
10302                    multilinks: vec![],
10303                    computed: vec![],
10304                    constraints: vec![],
10305                    indexes: vec![],
10306                    partition: None,
10307                    vector_indexes: vec![],
10308                    search_indexes: vec![],
10309                    triggers: vec![],
10310                    junction: false,
10311                    signals: vec![],
10312                },
10313                TypeDescriptor {
10314                    name: "Org".into(),
10315                    module: "default".into(),
10316                    table: "Org".into(),
10317                    abstract_: false,
10318                    materialized: false,
10319                    description: None,
10320                    parents: vec![],
10321                    interfaces: vec![],
10322                    bases: vec![],
10323                    properties: vec![id_prop(), name_prop()],
10324                    links: vec![],
10325                    multilinks: vec![],
10326                    computed: vec![],
10327                    constraints: vec![],
10328                    indexes: vec![],
10329                    partition: None,
10330                    vector_indexes: vec![],
10331                    search_indexes: vec![],
10332                    triggers: vec![],
10333                    junction: false,
10334                    signals: vec![],
10335                },
10336                TypeDescriptor {
10337                    name: "Marriage".into(),
10338                    module: "default".into(),
10339                    table: "Person.spouse".into(),
10340                    abstract_: false,
10341                    materialized: false,
10342                    description: None,
10343                    parents: vec![],
10344                    interfaces: vec![],
10345                    bases: vec![],
10346                    properties: vec![
10347                        id_prop(),
10348                        PropertyDescriptor {
10349                            name: "since".into(),
10350                            pg_type: "int8".into(),
10351                            nullable: true,
10352                            default_sql: None,
10353                            description: None,
10354                            check_constraints: vec![],
10355                            default_pyql: None,
10356                            is_exclusive: false,
10357                            is_pk: false,
10358                            is_readonly: false,
10359                            rewrites: vec![],
10360                            tuple_members: None,
10361                            column_type: None,
10362                        },
10363                    ],
10364                    links: vec![
10365                        LinkDescriptor {
10366                            name: "source".into(),
10367                            target: "default::Person".into(),
10368                            nullable: false,
10369                            through: None,
10370                            description: None,
10371                            default_pyql: None,
10372                            is_exclusive: false,
10373                            is_readonly: false,
10374                            rewrites: vec![],
10375                            on_delete: vec![],
10376                        },
10377                        LinkDescriptor {
10378                            name: "target".into(),
10379                            target: "default::Org".into(),
10380                            nullable: false,
10381                            through: None,
10382                            description: None,
10383                            default_pyql: None,
10384                            is_exclusive: false,
10385                            is_readonly: false,
10386                            rewrites: vec![],
10387                            on_delete: vec![],
10388                        },
10389                    ],
10390                    multilinks: vec![],
10391                    computed: vec![],
10392                    constraints: vec![],
10393                    indexes: vec![],
10394                    partition: None,
10395                    vector_indexes: vec![],
10396                    search_indexes: vec![],
10397                    triggers: vec![],
10398                    junction: true,
10399                    signals: vec![],
10400                },
10401            ],
10402            scalars: vec![],
10403            enums: vec![],
10404            named_tuples: vec![],
10405            globals: vec![],
10406            functions: vec![],
10407            aliases: vec![],
10408            channels: vec![],
10409            ..Default::default()
10410        }
10411    }
10412
10413    #[test]
10414    fn test_select_shape_over_junction_backed_single_link() {
10415        let schema = make_schema_with_junction_backed_link();
10416        let ast = crate::parse::parse("SELECT Person { name, spouse { name } }").unwrap();
10417        let ir = crate::ir::compile(&ast, &schema).unwrap();
10418        let out = emit(&ir);
10419        // Junction table is the through type's own table, joined like a multi-link's.
10420        assert!(out.sql.contains("\"public\".\"Person.spouse\""), "got:\n{}", out.sql);
10421        assert!(out.sql.contains("\"jt\".source"), "got:\n{}", out.sql);
10422        assert!(out.sql.contains("\"jt\".target"), "got:\n{}", out.sql);
10423        // Cardinality-one: a scalar correlated subquery, never array_agg.
10424        assert!(!out.sql.contains("array_agg"), "got:\n{}", out.sql);
10425        assert!(!out.sql.contains("spouse_id"), "got:\n{}", out.sql);
10426    }
10427
10428    #[test]
10429    fn test_select_shape_over_junction_backed_single_link_with_link_property() {
10430        // A junction-backed single link can read its own link properties
10431        // (`@since`) in a nested shape, same as a multi-link's `@weight` —
10432        // this is the one read site with no reusable FK-based form
10433        // (`IrSingleLinkPointer` needed its own `link_properties`, mirroring
10434        // `IrMultiLinkPointer`'s).
10435        let schema = make_schema_with_junction_backed_link();
10436        let ast = crate::parse::parse("SELECT Person { name, spouse { name, @since } }").unwrap();
10437        let ir = crate::ir::compile(&ast, &schema).unwrap();
10438        let out = emit(&ir);
10439        assert!(out.sql.contains("\"jt\".\"since\""), "got:\n{}", out.sql);
10440        assert!(!out.sql.contains("array_agg"), "got:\n{}", out.sql);
10441    }
10442
10443    #[test]
10444    fn test_select_path_over_junction_backed_single_link() {
10445        let schema = make_schema_with_junction_backed_link();
10446        let ast = crate::parse::parse("SELECT Person.spouse { name }").unwrap();
10447        let ir = crate::ir::compile(&ast, &schema).unwrap();
10448        let out = emit(&ir);
10449        assert!(out.sql.contains("\"public\".\"Person.spouse\""), "got:\n{}", out.sql);
10450        assert!(out.sql.contains("\"source\""), "got:\n{}", out.sql);
10451        assert!(out.sql.contains("\"target\""), "got:\n{}", out.sql);
10452        assert!(!out.sql.contains("spouse_id"), "got:\n{}", out.sql);
10453    }
10454
10455    #[test]
10456    fn test_insert_with_junction_backed_single_link() {
10457        let schema = make_schema_with_junction_backed_link();
10458        let out = compile_and_emit_with(
10459            "INSERT Person { name := $name, \
10460                spouse := (SELECT Org FILTER .id = $oid) { @since := <int64>$since } }",
10461            &schema,
10462        );
10463        assert!(out.sql.contains("\"public\".\"Person.spouse\""), "got:\n{}", out.sql);
10464        assert!(out.sql.contains("\"since\""), "got:\n{}", out.sql);
10465        assert!(!out.sql.contains("spouse_id"), "got:\n{}", out.sql);
10466        // A fresh row has no prior junction entry, but the emitted ON
10467        // CONFLICT target must still name only `source` — no `(source,
10468        // target)` composite unique constraint exists on this table (D3).
10469        // The conflict action is always an upsert (not DO NOTHING) since a
10470        // same-statement reassignment's paired DELETE shares this
10471        // statement's snapshot and can still "conflict" with the old row.
10472        assert!(out.sql.contains("ON CONFLICT (\"source\") DO UPDATE SET \"target\" = EXCLUDED.\"target\", \"since\" = EXCLUDED.\"since\""), "got:\n{}", out.sql);
10473    }
10474
10475    #[test]
10476    fn test_update_replace_junction_backed_single_link() {
10477        let schema = make_schema_with_junction_backed_link();
10478        let out = compile_and_emit_with(
10479            "UPDATE Person FILTER .id = $id SET { \
10480                spouse := (SELECT Org FILTER .id = $oid) { @since := <int64>$since } }",
10481            &schema,
10482        );
10483        // Replace = clear the existing junction row, then insert the new one.
10484        assert!(
10485            out.sql.contains("DELETE FROM \"public\".\"Person.spouse\""),
10486            "got:\n{}",
10487            out.sql
10488        );
10489        assert!(
10490            out.sql.contains("INSERT INTO \"public\".\"Person.spouse\""),
10491            "got:\n{}",
10492            out.sql
10493        );
10494        assert!(out.sql.contains("ON CONFLICT (\"source\") DO UPDATE SET \"target\" = EXCLUDED.\"target\", \"since\" = EXCLUDED.\"since\""), "got:\n{}", out.sql);
10495        // The paired DELETE must not race the new INSERT for a target that's
10496        // being kept, but for a single link (PK is `source` alone) ANY
10497        // existing row conflicts regardless of target, so the upsert above
10498        // is what actually makes reassignment work, not the exclusion here.
10499        assert!(out.sql.contains("NOT IN"), "got:\n{}", out.sql);
10500    }
10501
10502    /// `unless conflict on .k else (update T filter … set { … })` — the ON
10503    /// CONFLICT target says which row conflicts, the filter says whether to
10504    /// touch it. Dropping the filter turned automator's advisory lock into an
10505    /// unconditional steal of a live holder's row.
10506    #[test]
10507    fn test_unless_conflict_else_update_keeps_its_filter() {
10508        let out = compile_and_emit(
10509            "INSERT Person { name := 'a' } UNLESS CONFLICT ON .name \
10510             ELSE (UPDATE Person FILTER .age < 30 SET { name := 'b' })",
10511        );
10512        assert!(
10513            out.sql.contains("DO UPDATE SET") && out.sql.contains("WHERE"),
10514            "the ELSE UPDATE's filter must survive as a DO UPDATE predicate:\n{}",
10515            out.sql
10516        );
10517        assert!(
10518            out.sql.contains("\"Person\".\"age\""),
10519            "the predicate must read the existing row, not `excluded`:\n{}",
10520            out.sql
10521        );
10522    }
10523
10524    /// No filter, no predicate — an unconditional ELSE UPDATE still updates.
10525    #[test]
10526    fn test_unless_conflict_else_update_without_a_filter_has_no_predicate() {
10527        let out = compile_and_emit(
10528            "INSERT Person { name := 'a' } UNLESS CONFLICT ON .name ELSE (UPDATE Person SET { name := 'b' })",
10529        );
10530        let after_set = out.sql.split("DO UPDATE SET").nth(1).unwrap_or_default();
10531        assert!(
10532            !after_set.contains("WHERE"),
10533            "got a predicate we never asked for:\n{}",
10534            out.sql
10535        );
10536    }
10537
10538    #[test]
10539    fn test_junction_backed_single_link_rejected_in_unless_conflict_else() {
10540        // `compile_conflict_else` calls `compile_assignments_for_update`
10541        // directly on the raw ELSE-clause shape, bypassing the
10542        // shape-classification loop `compile_insert`/`compile_update` use to
10543        // intercept a junction-backed link before it reaches the plain
10544        // FK-column assignment path — must fail loudly here instead of
10545        // emitting a `spouse_id` column that doesn't exist.
10546        let schema = make_schema_with_junction_backed_link();
10547        let ast = parse::parse(
10548            "INSERT Person { name := $name } \
10549             UNLESS CONFLICT ON .name ELSE (UPDATE Person SET { \
10550                spouse := (SELECT Org FILTER .id = $oid) })",
10551        )
10552        .unwrap();
10553        assert!(ir::compile(&ast, &schema).is_err());
10554    }
10555
10556    #[test]
10557    fn test_update_clear_junction_backed_single_link() {
10558        let schema = make_schema_with_junction_backed_link();
10559        let out = compile_and_emit_with("UPDATE Person FILTER .id = $id SET { spouse := {} }", &schema);
10560        assert!(
10561            out.sql.contains("DELETE FROM \"public\".\"Person.spouse\""),
10562            "got:\n{}",
10563            out.sql
10564        );
10565        assert!(
10566            !out.sql.contains("INSERT INTO \"public\".\"Person.spouse\""),
10567            "clearing must not also insert:\n{}",
10568            out.sql
10569        );
10570    }
10571
10572    #[test]
10573    fn test_update_clear_junction_backed_single_link_with_cast_empty_set() {
10574        // Regression: the frontend (generateStatements.ts's `setNull`
10575        // handling) always emits the cast form `<Type>{}`, never the bare
10576        // `{}` — the junction-backed clear path originally only recognized
10577        // bare `Expr::Set([])`, so this form fell through to
10578        // `compile_multilink_values` and errored with "multilink value must
10579        // be a CTE reference, parenthesised subquery, or type path" instead
10580        // of clearing (confirmed live via the Data Explorer).
10581        let schema = make_schema_with_junction_backed_link();
10582        let out = compile_and_emit_with("UPDATE Person FILTER .id = $id SET { spouse := <Org>{} }", &schema);
10583        assert!(
10584            out.sql.contains("DELETE FROM \"public\".\"Person.spouse\""),
10585            "got:\n{}",
10586            out.sql
10587        );
10588        assert!(
10589            !out.sql.contains("INSERT INTO \"public\".\"Person.spouse\""),
10590            "clearing must not also insert:\n{}",
10591            out.sql
10592        );
10593    }
10594
10595    /// Product/Tag/ProductTag — the actual real-world shape link properties
10596    /// were built for (mirrors pylon-demo). Deliberately *not* built off
10597    /// `make_schema_with_through` (Person self-linking to Person): a
10598    /// same-type through-link hits a pre-existing, unrelated column-
10599    /// resolution bug in `multilink_junction_info` (both source_col and
10600    /// target_col resolve to the same link name when source type == target
10601    /// type) — tracked separately, not fixed here.
10602    fn make_schema_with_through_and_prop() -> SchemaDescriptor {
10603        let id_prop = || PropertyDescriptor {
10604            name: "id".into(),
10605            pg_type: "uuid".into(),
10606            nullable: false,
10607            default_sql: Some("gen_random_uuid()".into()),
10608            description: None,
10609            default_pyql: None,
10610            check_constraints: vec![],
10611            is_exclusive: true,
10612            is_pk: true,
10613            is_readonly: true,
10614            rewrites: vec![],
10615            tuple_members: None,
10616            column_type: None,
10617        };
10618        let name_prop = || PropertyDescriptor {
10619            name: "name".into(),
10620            pg_type: "text".into(),
10621            nullable: false,
10622            default_sql: None,
10623            description: None,
10624            check_constraints: vec![],
10625            default_pyql: None,
10626            is_exclusive: false,
10627            is_pk: false,
10628            is_readonly: false,
10629            rewrites: vec![],
10630            tuple_members: None,
10631            column_type: None,
10632        };
10633        SchemaDescriptor {
10634            types: vec![
10635                TypeDescriptor {
10636                    name: "Product".into(),
10637                    module: "default".into(),
10638                    table: "Product".into(),
10639                    abstract_: false,
10640                    materialized: false,
10641                    description: None,
10642                    parents: vec![],
10643                    interfaces: vec![],
10644                    bases: vec![],
10645                    properties: vec![id_prop(), name_prop()],
10646                    links: vec![],
10647                    multilinks: vec![MultiLinkDescriptor {
10648                        name: "tags".into(),
10649                        target: "default::Tag".into(),
10650                        through: Some("default::ProductTag".into()),
10651                        nullable: false,
10652                        description: None,
10653                        default_pyql: None,
10654                        on_delete: vec![],
10655                        is_exclusive: false,
10656                    }],
10657                    computed: vec![],
10658                    constraints: vec![],
10659                    indexes: vec![],
10660                    partition: None,
10661                    vector_indexes: vec![],
10662                    search_indexes: vec![],
10663                    triggers: vec![],
10664                    junction: false,
10665                    signals: vec![],
10666                },
10667                TypeDescriptor {
10668                    name: "Tag".into(),
10669                    module: "default".into(),
10670                    table: "Tag".into(),
10671                    abstract_: false,
10672                    materialized: false,
10673                    description: None,
10674                    parents: vec![],
10675                    interfaces: vec![],
10676                    bases: vec![],
10677                    properties: vec![id_prop(), name_prop()],
10678                    links: vec![],
10679                    multilinks: vec![],
10680                    computed: vec![],
10681                    constraints: vec![],
10682                    indexes: vec![],
10683                    partition: None,
10684                    vector_indexes: vec![],
10685                    search_indexes: vec![],
10686                    triggers: vec![],
10687                    junction: false,
10688                    signals: vec![],
10689                },
10690                TypeDescriptor {
10691                    name: "ProductTag".into(),
10692                    module: "default".into(),
10693                    table: "Product.tags".into(),
10694                    abstract_: false,
10695                    materialized: false,
10696                    description: None,
10697                    parents: vec![],
10698                    interfaces: vec![],
10699                    bases: vec![],
10700                    properties: vec![
10701                        id_prop(),
10702                        PropertyDescriptor {
10703                            name: "weight".into(),
10704                            pg_type: "float8".into(),
10705                            nullable: false,
10706                            default_sql: None,
10707                            default_pyql: None,
10708                            description: None,
10709                            check_constraints: vec![],
10710                            is_exclusive: false,
10711                            is_pk: false,
10712                            is_readonly: false,
10713                            rewrites: vec![],
10714                            tuple_members: None,
10715                            column_type: None,
10716                        },
10717                    ],
10718                    // No declared Link pointers — matches pylon-demo's actual
10719                    // ProductTag, which relies on multilink_junction_info's
10720                    // "source"/"target" defaults.
10721                    links: vec![],
10722                    multilinks: vec![],
10723                    computed: vec![],
10724                    constraints: vec![],
10725                    indexes: vec![],
10726                    partition: None,
10727                    vector_indexes: vec![],
10728                    search_indexes: vec![],
10729                    triggers: vec![],
10730                    junction: true,
10731                    signals: vec![],
10732                },
10733            ],
10734            scalars: vec![],
10735            enums: vec![],
10736            named_tuples: vec![],
10737            globals: vec![],
10738            functions: vec![],
10739            aliases: vec![],
10740            channels: vec![],
10741            ..Default::default()
10742        }
10743    }
10744
10745    #[test]
10746    fn test_link_property_in_a_links_own_filter() {
10747        // `filter @weight > x` reads the junction row of the link being
10748        // filtered — `@prop` used to parse only as a postfix on a named
10749        // path, so a bare one was "expected an expression, found '@'".
10750        let schema = make_schema_with_through_and_prop();
10751        let out = compile_and_emit_with("SELECT Product { tags: { } filter @weight > 0.5 }", &schema);
10752        assert!(out.sql.contains("\"jt\".\"weight\" > (0.5::float8)"), "{}", out.sql);
10753    }
10754
10755    #[test]
10756    fn test_exists_on_a_link_property() {
10757        // `exists @weight` — the junction column the link's own modifiers are
10758        // compiled against. `exists` only knew how to take a pointer name.
10759        let schema = make_schema_with_through_and_prop();
10760        let out = compile_and_emit_with("SELECT Product { tags: { } filter exists @weight }", &schema);
10761        assert!(out.sql.contains("\"jt\".\"weight\" IS NOT NULL"), "{}", out.sql);
10762    }
10763
10764    #[test]
10765    fn test_link_property_in_a_computed_link_filter() {
10766        let schema = make_schema_with_through_and_prop();
10767        let out = compile_and_emit_with(
10768            "SELECT Product { best := (select .tags filter @weight > 0.5 limit 1) { @weight } }",
10769            &schema,
10770        );
10771        assert!(out.sql.contains("\"jt\".\"weight\" > (0.5::float8)"), "{}", out.sql);
10772        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10773    }
10774
10775    #[test]
10776    fn test_link_property_in_a_projected_sub_select_reads_the_junction() {
10777        // The flat-join form names the junction with an ordinary alias
10778        // rather than "jt", so the scope has to carry the alias too.
10779        let schema = make_schema_with_through_and_prop();
10780        let out = compile_and_emit_with(
10781            "SELECT Product { t := (select .tags filter @weight > 0.5 limit 1).id }",
10782            &schema,
10783        );
10784        assert!(out.sql.contains("\"t3\".\"weight\" > (0.5::float8)"), "{}", out.sql);
10785    }
10786
10787    #[test]
10788    fn test_unknown_link_property_names_the_through_type() {
10789        let schema = make_schema_with_through_and_prop();
10790        let ast = parse::parse("SELECT Product { tags: { } filter @nope > 0.5 }").unwrap();
10791        let err = match ir::compile(&ast, &schema) {
10792            Ok(_) => panic!("expected a compile error"),
10793            Err(e) => format!("{e}"),
10794        };
10795        assert!(err.contains("default::ProductTag") && err.contains("nope"), "{err}");
10796    }
10797
10798    #[test]
10799    fn test_link_property_outside_a_link_says_where_it_belongs() {
10800        let schema = make_schema_with_through_and_prop();
10801        let ast = parse::parse("SELECT Product { t := @weight }").unwrap();
10802        let err = match ir::compile(&ast, &schema) {
10803            Ok(_) => panic!("expected a compile error"),
10804            Err(e) => format!("{e}"),
10805        };
10806        assert!(err.contains("is a link property"), "{err}");
10807    }
10808
10809    #[test]
10810    fn test_multilink_append_with_link_property() {
10811        let schema = make_schema_with_through_and_prop();
10812        let out = compile_and_emit_with(
10813            "UPDATE Product FILTER .id = $id SET { tags += (SELECT Tag FILTER .id = $tid) { @weight := <float64>$w } }",
10814            &schema,
10815        );
10816        // Extra junction column present in both the INSERT column list and the SELECT list.
10817        assert!(out.sql.contains("\"weight\""), "missing weight column:\n{}", out.sql);
10818        // Re-linking an existing pair with a new weight must update in place,
10819        // not silently keep the old value (a bare `DO NOTHING` would).
10820        assert!(
10821            out.sql
10822                .contains("ON CONFLICT (\"source\", \"target\") DO UPDATE SET \"weight\" = EXCLUDED.\"weight\""),
10823            "missing upsert conflict clause:\n{}",
10824            out.sql
10825        );
10826    }
10827
10828    #[test]
10829    fn test_multilink_append_union_with_different_link_property_values() {
10830        // The realistic Data Explorer scenario: multiple checked targets in
10831        // one `+=`, each with its own distinct property value — expressed as
10832        // a `union` of individually-shaped target selects.
10833        let schema = make_schema_with_through_and_prop();
10834        let out = compile_and_emit_with(
10835            "UPDATE Product FILTER .id = $id SET { \
10836                tags += (SELECT Tag FILTER .id = $aid) { @weight := <float64>$w1 } \
10837                    union (SELECT Tag FILTER .id = $bid) { @weight := <float64>$w2 } \
10838            }",
10839            &schema,
10840        );
10841        assert!(
10842            out.sql.contains("UNION ALL"),
10843            "expected a UNION ALL between the two shaped targets:\n{}",
10844            out.sql
10845        );
10846        // Both branches must select the same "weight" column (with their own
10847        // value) so the union has a consistent column list.
10848        assert_eq!(
10849            out.sql.matches("AS \"weight\"").count(),
10850            2,
10851            "each union branch must project its own weight:\n{}",
10852            out.sql
10853        );
10854    }
10855
10856    #[test]
10857    fn test_multilink_append_without_link_property_keeps_do_nothing() {
10858        // No `@prop := ...` anywhere — must keep the original DO NOTHING
10859        // behavior (no spurious upsert/extra columns for the common case).
10860        let schema = make_schema_with_through_and_prop();
10861        let out = compile_and_emit_with(
10862            "UPDATE Product FILTER .id = $id SET { tags += (SELECT Tag FILTER .id = $tid) }",
10863            &schema,
10864        );
10865        assert!(
10866            out.sql.contains("ON CONFLICT DO NOTHING"),
10867            "expected plain DO NOTHING when no link properties are set:\n{}",
10868            out.sql
10869        );
10870        assert!(
10871            !out.sql.contains("\"weight\""),
10872            "unexpected weight column with no link property assignment:\n{}",
10873            out.sql
10874        );
10875    }
10876
10877    #[test]
10878    fn test_multilink_link_property_rejected_on_standard_junction() {
10879        // Person.posts is a Standard (implicit) junction — no user-declared
10880        // properties, so `@prop := ...` must be rejected at compile time.
10881        let ast = crate::parse::parse(
10882            "UPDATE Person FILTER .id = $id SET { posts += (SELECT Post FILTER .title = $t) { @weight := <float64>$w } }",
10883        ).unwrap();
10884        match crate::ir::compile(&ast, &make_schema()) {
10885            Ok(_) => panic!("expected a compile error for link property on a Standard junction"),
10886            Err(e) => assert!(
10887                e.to_string().contains("Through"),
10888                "expected a Through[...]-related error, got: {e}"
10889            ),
10890        }
10891    }
10892
10893    #[test]
10894    fn test_multilink_link_property_rejected_on_remove() {
10895        let schema = make_schema_with_through_and_prop();
10896        let ast = crate::parse::parse(
10897            "UPDATE Product FILTER .id = $id SET { tags -= (SELECT Tag FILTER .id = $tid) { @weight := <float64>$w } }",
10898        )
10899        .unwrap();
10900        match crate::ir::compile(&ast, &schema) {
10901            Ok(_) => panic!("expected a compile error for link property on a remove (-=)"),
10902            Err(e) => assert!(
10903                e.to_string().contains("removing"),
10904                "expected a remove-related error, got: {e}"
10905            ),
10906        }
10907    }
10908
10909    #[test]
10910    fn test_insert_with_multilink_assignment() {
10911        // Regression test: `insert Product { tags := ... }` used to fail with
10912        // "object type 'default::Product' has no link or property 'tags'" —
10913        // compile_assignments_inner never checked resolve_multilink, and
10914        // IrInsert had no multi-link handling at all.
10915        let schema = make_schema_with_through_and_prop();
10916        let out = compile_and_emit_with(
10917            "INSERT Product { name := $name, tags := (SELECT Tag FILTER .id = $tid) { @weight := <float64>$w } }",
10918            &schema,
10919        );
10920        // The row insert must happen before the junction insert references its id.
10921        assert!(
10922            out.sql.contains("\"_w__ids\" AS (\nINSERT INTO"),
10923            "missing row-insert CTE:\n{}",
10924            out.sql
10925        );
10926        assert!(
10927            out.sql.contains("\"_w__ml_add_0\" AS ("),
10928            "missing junction-append CTE:\n{}",
10929            out.sql
10930        );
10931        assert!(out.sql.contains("\"weight\""), "missing weight column:\n{}", out.sql);
10932        assert!(
10933            out.sql.contains("\"_w\" AS (\n    SELECT * FROM \"_w__ids\"\n)"),
10934            "missing _w passthrough:\n{}",
10935            out.sql
10936        );
10937        assert_eq!(
10938            out.sql.matches("WITH\n").count(),
10939            1,
10940            "must be a single flat top-level WITH block:\n{}",
10941            out.sql
10942        );
10943    }
10944
10945    /// A user `with` binding *consumed by* a statement that builds CTEs of
10946    /// its own. The binding is emitted by the generic `ir.ctes` path while
10947    /// the multi-link/junction machinery has already opened its own `WITH`;
10948    /// prefixing a second one produced `WITH ... WITH ...`, which compiles
10949    /// cleanly and then fails at execution with `syntax error at or near
10950    /// "WITH"`. Covers both link kinds, since each reaches the junction
10951    /// emitter by a different route.
10952    #[test]
10953    fn test_user_with_binding_merges_into_a_statements_own_ctes() {
10954        let schema = make_schema_with_through_and_prop();
10955        for query in [
10956            "with t := (select Tag filter .id = $tid) insert Product { name := $name, tags := t }",
10957            "with t := (select Tag filter .id = $tid) \
10958             update Product filter .name = $name set { tags += t }",
10959        ] {
10960            let out = compile_and_emit_with(query, &schema);
10961            let flat: String = out.sql.split_whitespace().collect::<Vec<_>>().join(" ");
10962            assert!(
10963                !flat.contains(") WITH "),
10964                "a second WITH clause is invalid SQL:\n{}",
10965                out.sql
10966            );
10967            assert_eq!(
10968                out.sql.matches("WITH").count(),
10969                1,
10970                "exactly one WITH keyword expected:\n{}",
10971                out.sql
10972            );
10973        }
10974    }
10975
10976    #[test]
10977    fn test_with_bound_insert_with_multilink_assignment() {
10978        // Same as above, but bound via WITH (the Data Explorer's actual
10979        // shape) — exercises emit_user_cte_parts's insert_has_any_multilink
10980        // branch instead of emit_insert_stmt's bare-statement path.
10981        let schema = make_schema_with_through_and_prop();
10982        let out = compile_and_emit_with(
10983            "with insert0 := (insert Product { name := $name, tags := (select Tag filter .id = $tid) }) select insert0",
10984            &schema,
10985        );
10986        assert!(
10987            out.sql.contains("\"insert0__ids\" AS (\nINSERT INTO"),
10988            "missing row-insert CTE:\n{}",
10989            out.sql
10990        );
10991        assert!(
10992            out.sql.contains("\"insert0__ml_add_0\" AS ("),
10993            "missing junction-append CTE:\n{}",
10994            out.sql
10995        );
10996        assert!(
10997            out.sql
10998                .contains("\"insert0\" AS (\n    SELECT * FROM \"insert0__ids\"\n)"),
10999            "missing insert0 passthrough:\n{}",
11000            out.sql
11001        );
11002        assert_eq!(
11003            out.sql.matches("WITH\n").count(),
11004            1,
11005            "must be a single flat top-level WITH block:\n{}",
11006            out.sql
11007        );
11008    }
11009
11010    #[test]
11011    fn test_with_block_cte_over_computed_global_merges_into_single_with_clause() {
11012        // Regression: `with user := (select global current_user) select user;`
11013        // produced two separate top-level `WITH` keywords ("syntax error at
11014        // or near WITH" from Postgres) whenever the computed global's own
11015        // expression referenced a session global (registering a global CTE
11016        // in addition to the user-defined "user" CTE). The merge check in
11017        // emit() only recognized the "WITH " (space) prefix convention, not
11018        // emit_cte_prefix's "WITH\n" (newline) convention used here, so it
11019        // prepended a second WITH block instead of merging into the first.
11020        let mut schema = make_schema();
11021        schema.globals.push(GlobalDescriptor {
11022            name: "current_user_id".into(),
11023            module: "default".into(),
11024            scalar_type: "std::uuid".into(),
11025            required: false,
11026            default_expr: None,
11027            computed_expr: None,
11028        });
11029        schema.globals.push(GlobalDescriptor {
11030            name: "current_user".into(),
11031            module: "default".into(),
11032            scalar_type: "Person".into(),
11033            required: false,
11034            default_expr: None,
11035            computed_expr: Some("select default::Person filter .id = global current_user_id".into()),
11036        });
11037        let out = compile_and_emit_with("with\n  user := (select global current_user)\nselect user;", &schema);
11038        assert_eq!(
11039            out.sql.matches("WITH").count(),
11040            1,
11041            "must be a single WITH clause, got:\n{}",
11042            out.sql
11043        );
11044    }
11045
11046    #[test]
11047    fn test_several_mutations_in_a_free_set_each_become_a_cte() {
11048        let schema = make_schema();
11049        let out = compile_and_emit_with(
11050            "select { (update Person filter .name = 'a' set { age := 1 }), \
11051                      (update Company filter .name = 'b' set { name := 'c' }) }",
11052            &schema,
11053        );
11054        assert_eq!(
11055            out.sql.matches("UPDATE").count(),
11056            2,
11057            "both mutations have to run, got:\n{}",
11058            out.sql
11059        );
11060        assert!(out.sql.contains("WITH"), "each becomes a CTE, got:\n{}", out.sql);
11061    }
11062
11063    #[test]
11064    fn test_updating_a_binding_touches_only_its_own_rows() {
11065        // The binding names the rows to update, so resolving it to its type
11066        // without narrowing would rewrite every row in the table.
11067        let schema = make_schema();
11068        let out = compile_and_emit_with(
11069            "with mine := (select Person filter .name = 'a') select { (update mine set { age := 1 }) }",
11070            &schema,
11071        );
11072        assert!(
11073            out.sql.contains("UPDATE") && out.sql.contains("= ANY(ARRAY(SELECT"),
11074            "expected the update narrowed to the binding's rows, got:\n{}",
11075            out.sql
11076        );
11077    }
11078
11079    #[test]
11080    fn test_updating_a_traversal_touches_only_the_rows_it_lands_on() {
11081        let mut schema = make_schema();
11082        // The traversal's target needs an id to narrow against; every real
11083        // schema declares one, this one leaves it off Company.
11084        let company = schema
11085            .types
11086            .iter_mut()
11087            .find(|t| t.name == "Company")
11088            .expect("test schema has a Company type");
11089        company.properties.push(crate::schema::PropertyDescriptor {
11090            name: "id".into(),
11091            pg_type: "uuid".into(),
11092            nullable: false,
11093            default_sql: Some("uuidv7()".into()),
11094            default_pyql: None,
11095            description: None,
11096            check_constraints: vec![],
11097            is_exclusive: true,
11098            is_pk: true,
11099            is_readonly: true,
11100            rewrites: vec![],
11101            tuple_members: None,
11102            column_type: None,
11103        });
11104        let out = compile_and_emit_with(
11105            "with mine := (select Person filter .name = 'a') select { (update mine.company set { name := 'x' }) }",
11106            &schema,
11107        );
11108        assert!(
11109            out.sql.contains("= ANY(ARRAY(SELECT"),
11110            "expected the update narrowed to the traversal's rows, got:\n{}",
11111            out.sql
11112        );
11113        assert!(
11114            out.sql.contains("\"Company\""),
11115            "expected the table the traversal ends on, got:\n{}",
11116            out.sql
11117        );
11118    }
11119
11120    #[test]
11121    fn test_aggregate_over_a_multi_valued_path_keeps_the_set_flat() {
11122        // Regression: `array_agg(a.posts.title)` compiled the path as an
11123        // expression, where a multi-valued path stands for the array of its
11124        // elements — so the aggregate wrapped that array and the result came
11125        // back one level deep, `[[t1, t2]]` rather than `[t1, t2]`.
11126        let schema = make_schema();
11127        let out = compile_and_emit_with(
11128            "with a := (select Person limit 1) select { titles := array_agg(a.posts.title) }",
11129            &schema,
11130        );
11131        assert!(
11132            !out.sql.contains("array_agg(ARRAY("),
11133            "the aggregate must take the set, not the array standing for it, got:\n{}",
11134            out.sql
11135        );
11136        assert!(
11137            out.sql.contains("array_agg(") && out.sql.contains("\"title\""),
11138            "expected the aggregate over the traversal's own column, got:\n{}",
11139            out.sql
11140        );
11141        // The free object keeps its composite emission, so values stay typed
11142        // rather than passing through jsonb as strings.
11143        assert!(
11144            out.sql.contains("ROW(") && !out.sql.contains("jsonb_build_object"),
11145            "expected a composite row, got:\n{}",
11146            out.sql
11147        );
11148    }
11149
11150    #[test]
11151    fn test_with_bound_root_read_by_name_inside_a_nested_select() {
11152        // Regression: `compile_path_select` resolved a `with`-bound root
11153        // correctly, but the multi-valued check straight after it re-resolved
11154        // the same root as a plain type, so reading the binding by name from a
11155        // nested sub-select failed with "unknown type 'owner'".
11156        let schema = make_schema();
11157        let out = compile_and_emit_with(
11158            "with
11159  owner := (select Person limit 1)
11160select owner { posts := (select owner.posts.title) };",
11161            &schema,
11162        );
11163        assert!(
11164            out.sql.contains("WITH") && out.sql.contains("\"title\""),
11165            "expected the nested select to traverse the binding, got:\n{}",
11166            out.sql
11167        );
11168    }
11169
11170    #[test]
11171    fn test_path_traversal_into_with_bound_cte_of_object_type() {
11172        // Regression: `with user := (select global current_user) select
11173        // user.name;` failed with "unknown type 'user'" — compile_path_select
11174        // (used for multi-step absolute paths like `user.name`) only ever
11175        // tried resolve_type(root_name), never checking whether the root
11176        // name is a WITH-block CTE bound to an object type.
11177        let mut schema = make_schema();
11178        schema.globals.push(GlobalDescriptor {
11179            name: "current_user_id".into(),
11180            module: "default".into(),
11181            scalar_type: "std::uuid".into(),
11182            required: false,
11183            default_expr: None,
11184            computed_expr: None,
11185        });
11186        schema.globals.push(GlobalDescriptor {
11187            name: "current_user".into(),
11188            module: "default".into(),
11189            scalar_type: "Person".into(),
11190            required: false,
11191            default_expr: None,
11192            computed_expr: Some("select default::Person filter .id = global current_user_id".into()),
11193        });
11194        let out = compile_and_emit_with(
11195            "with\n  user := (select global current_user)\nselect user.name;",
11196            &schema,
11197        );
11198        assert!(
11199            out.sql.contains("FROM \"user\""),
11200            "expected path traversal from the CTE, got:\n{}",
11201            out.sql
11202        );
11203        assert_eq!(
11204            out.sql.matches("WITH").count(),
11205            1,
11206            "must be a single WITH clause, got:\n{}",
11207            out.sql
11208        );
11209    }
11210
11211    #[test]
11212    fn test_with_bound_schema_object_property_access_in_filter_expression() {
11213        // Regression: `with person := (select detached Person filter ...)
11214        // select Person filter .id not in person.id;` failed with "absolute
11215        // paths are not valid in expression context" — compile_path's
11216        // schema-bound-CTE-in-expression-position case only ever handled a
11217        // WITH-bound *free object* (resolve_cte_path), never a WITH-bound
11218        // *schema object* CTE, even though its own SELECT * CTE source
11219        // (emit_dml_as_cte_source) always exposes every raw column.
11220        let schema = make_schema();
11221        let out = compile_and_emit_with(
11222            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
11223             select Person filter .id not in person.id;",
11224            &schema,
11225        );
11226        assert!(
11227            out.sql.contains("<> ALL((SELECT") && out.sql.contains(".\"id\"") && out.sql.contains("FROM \"person\""),
11228            "got:\n{}",
11229            out.sql
11230        );
11231    }
11232
11233    #[test]
11234    fn test_with_bound_schema_object_scalar_property_access() {
11235        // Not just `.id` — any scalar property of the bound type is
11236        // reachable, since the CTE source is `SELECT *` regardless of the
11237        // binding's own declared shape.
11238        let schema = make_schema();
11239        let out = compile_and_emit_with(
11240            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
11241             select Person filter .name = person.name;",
11242            &schema,
11243        );
11244        assert!(
11245            out.sql.contains("(SELECT") && out.sql.contains(".\"name\"") && out.sql.contains("FROM \"person\""),
11246            "got:\n{}",
11247            out.sql
11248        );
11249    }
11250
11251    #[test]
11252    fn test_with_bound_schema_object_link_traversal_reaches_nested_property() {
11253        // Generalization: a with-bound schema object's CTE source is
11254        // `SELECT *`, so it's traversable exactly like a real type name —
11255        // not just its own scalar properties, but a *link's* properties too
11256        // (`person.company.name`), via the same path-traversal machinery
11257        // `TypeName.a.b.c` already uses (compile_path_select), wrapped as a
11258        // correlated subquery (IrExpr::PathSubquery).
11259        let schema = make_schema();
11260        let out = compile_and_emit_with(
11261            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
11262             select Company filter .name = person.company.name;",
11263            &schema,
11264        );
11265        assert!(
11266            out.sql.contains("(SELECT") && out.sql.contains("\"name\"") && out.sql.contains("\"Company\""),
11267            "got:\n{}",
11268            out.sql
11269        );
11270    }
11271
11272    #[test]
11273    fn test_with_bound_schema_object_bare_link_reduces_to_id() {
11274        // A bare link at the end of the chain (`person.company`, no further
11275        // traversal) reduces to the linked object's id, matching the
11276        // existing bare-CTE-reference convention (IrExpr::CteRef).
11277        let schema = make_schema();
11278        let out = compile_and_emit_with(
11279            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
11280             select Person filter .id = person.company;",
11281            &schema,
11282        );
11283        assert!(
11284            out.sql.contains("(SELECT") && out.sql.contains("\"id\""),
11285            "got:\n{}",
11286            out.sql
11287        );
11288    }
11289
11290    #[test]
11291    fn test_with_bound_schema_object_unknown_property_suggests_a_close_match() {
11292        let schema = make_schema();
11293        let ast = parse::parse(
11294            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
11295             select Person filter .name = person.nam;",
11296        )
11297        .unwrap();
11298        match ir::compile(&ast, &schema) {
11299            Err(err) => assert!(format!("{err}").contains("Did you mean 'name'"), "got: {err}"),
11300            Ok(_) => panic!("expected a compile error"),
11301        }
11302    }
11303
11304    #[test]
11305    fn test_with_bound_free_object_passthrough_preserves_all_fields() {
11306        // Regression: `with test := { test2 := 1.0, test3 := 'str' } select
11307        // test;` decoded as just `1.0` (the CTE's first field) — a bare
11308        // reference to a free-object CTE (`IrFreeExpr::CtePassthrough`) had
11309        // its shape hardcoded to `ShapeNode::Scalar` regardless of what the
11310        // CTE actually held.
11311        let out = compile_and_emit("with\n  test := { test2 := 1.0, test3 := 'str' }\nselect test;");
11312        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
11313            panic!("expected Object shape, got {:?}", out.shape.root)
11314        };
11315        assert_eq!(pointers.len(), 2);
11316        assert!(matches!(&pointers[0], ShapeNode::Scalar { name, .. } if name == "test2"));
11317        assert!(matches!(&pointers[1], ShapeNode::Scalar { name, .. } if name == "test3"));
11318    }
11319
11320    #[test]
11321    fn test_with_bound_free_object_field_access() {
11322        // Regression: `with test := {...} select test.test2;` failed with
11323        // "unknown type 'test'" — any absolute path with 2+ steps
11324        // unconditionally routed to compile_path_select, which only knows
11325        // how to resolve real schema types, never a free-value CTE.
11326        let out = compile_and_emit("with\n  test := { test2 := 1.0, test3 := 'str' }\nselect test.test2;");
11327        assert!(out.sql.contains("\"test2\" FROM \"test\""), "got:\n{}", out.sql);
11328    }
11329
11330    #[test]
11331    fn test_with_bound_free_object_nested_field_access_chain() {
11332        // A field-access chain through a *nested* free object literal
11333        // (`test.test3.foo`) must resolve the first hop via the CTE's own
11334        // materialized column, then extract `foo` as jsonb from that value
11335        // — the fix must not be hardcoded to exactly 2 path steps.
11336        let out =
11337            compile_and_emit("with\n  test := { test2 := 1.0, test3 := { foo := 'bar' } }\nselect test.test3.foo;");
11338        assert!(out.sql.contains("\"test3\" FROM \"test\""), "got:\n{}", out.sql);
11339        assert!(
11340            out.sql.contains("->'foo'"),
11341            "expected jsonb field extraction, got:\n{}",
11342            out.sql
11343        );
11344    }
11345
11346    #[test]
11347    fn test_with_bound_free_object_nested_field_access_wrong_field_errors() {
11348        // A typo'd field name anywhere in the chain must still be a compile
11349        // error, not silently emit SQL that returns NULL at runtime.
11350        let schema = make_schema();
11351        let ast = parse::parse("with\n  test := { test2 := 1.0, test3 := { foo := 'bar' } }\nselect test.test3.nope;")
11352            .unwrap();
11353        assert!(ir::compile(&ast, &schema).is_err());
11354    }
11355
11356    #[test]
11357    fn test_nested_free_object_literal_in_computed_shape_element() {
11358        // Regression: a free object literal (`{ foo := 'bar' }`) nested
11359        // inside a computed shape element — not the top-level SELECT
11360        // result — hit "shapes and set literals are not valid in
11361        // expression context"; free object literals were only ever handled
11362        // at the statement level.
11363        let out = compile_and_emit("select default::Person { id, test := { foo := 'bar' } };");
11364        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
11365            panic!("expected Object shape")
11366        };
11367        let test_node = pointers
11368            .iter()
11369            .find(|p| matches!(p, ShapeNode::NamedTuple { name, .. } if name == "test"))
11370            .unwrap_or_else(|| panic!("expected a NamedTuple shape node for 'test', got {:?}", pointers));
11371        assert!(matches!(
11372            test_node,
11373            ShapeNode::NamedTuple {
11374                is_free_object: true,
11375                ..
11376            }
11377        ));
11378    }
11379
11380    #[test]
11381    fn test_bare_free_cte_reference_in_computed_shape_collapses_to_empty() {
11382        // A free-object CTE referenced bare (no shape) as a computed shape
11383        // element's value has nothing to project — a free object needs an
11384        // explicit shape to know what to expose (unlike a tuple, which is
11385        // a plain value with no such requirement).
11386        let out = compile_and_emit(
11387            "with\n  test := { test2 := 1.0, test3 := 'str' }\n\
11388             select default::Person { id, test := test };",
11389        );
11390        assert!(
11391            out.sql.contains("jsonb_build_object()"),
11392            "expected an empty free object, got:\n{}",
11393            out.sql
11394        );
11395    }
11396
11397    #[test]
11398    fn test_shaped_free_cte_reference_projects_fields() {
11399        // `test := test { test2 }` must project just the named field out
11400        // of the underlying free object — neither collapsing to empty nor
11401        // returning every field.
11402        let out = compile_and_emit(
11403            "with\n  test := { test2 := 1.0, test3 := 'str' }\n\
11404             select default::Person { id, test := test { test2 } };",
11405        );
11406        assert!(out.sql.contains("jsonb_build_object('test2'"), "got:\n{}", out.sql);
11407        assert!(
11408            !out.sql.contains("'test3'"),
11409            "test3 should not be projected, got:\n{}",
11410            out.sql
11411        );
11412    }
11413
11414    #[test]
11415    fn test_insert_multilink_remove_rejected() {
11416        let schema = make_schema_with_through_and_prop();
11417        let ast =
11418            crate::parse::parse("INSERT Product { name := $name, tags -= (SELECT Tag FILTER .id = $tid) }").unwrap();
11419        match crate::ir::compile(&ast, &schema) {
11420            Ok(_) => panic!("expected a compile error for `-=` on a multi-link at insert time"),
11421            Err(e) => assert!(
11422                e.to_string().contains("nothing to remove"),
11423                "expected a 'nothing to remove yet' error, got: {e}"
11424            ),
11425        }
11426    }
11427
11428    #[test]
11429    fn test_multilink_junction_info_disambiguates_self_referencing_through_type() {
11430        // Regression test for a self-referencing through-link (Person.friends
11431        // via PersonFriend, which declares two Person-typed links: "person"
11432        // and "friend"). Naively matching purely by target type resolves
11433        // *both* source_col and target_col to the same first-matching link
11434        // ("person"), silently collapsing the junction to a single column
11435        // and losing the other side entirely.
11436        let schema = make_schema_with_through();
11437        let out = compile_and_emit_with(
11438            "UPDATE Person FILTER .id = $id SET { friends += (SELECT Person FILTER .id = $fid) }",
11439            &schema,
11440        );
11441        assert!(
11442            out.sql.contains("(\"person\", \"friend\")"),
11443            "expected two distinct FK columns:\n{}",
11444            out.sql
11445        );
11446        assert!(
11447            !out.sql.contains("(\"person\", \"person\")"),
11448            "source/target collapsed to the same column:\n{}",
11449            out.sql
11450        );
11451    }
11452
11453    #[test]
11454    fn test_shape_descriptor_scalars() {
11455        let out = compile_and_emit("SELECT Person { name, age }");
11456        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
11457            panic!()
11458        };
11459        assert_eq!(pointers.len(), 4); // __type__, the implicit id, name, age
11460        assert!(matches!(&pointers[0], ShapeNode::Scalar { name, position: 0 } if name == "__type__"));
11461        assert!(matches!(&pointers[1], ShapeNode::Scalar { name, position: 1 } if name == "id"));
11462        assert!(matches!(&pointers[2], ShapeNode::Scalar { name, position: 2 } if name == "name"));
11463        assert!(matches!(&pointers[3], ShapeNode::Scalar { name, position: 3 } if name == "age"));
11464    }
11465
11466    #[test]
11467    fn test_shape_descriptor_multi_link() {
11468        let out = compile_and_emit("SELECT Person { name, posts { title } }");
11469        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
11470            panic!()
11471        };
11472        // pointers: [__type__, id, name, posts]
11473        assert_eq!(pointers.len(), 4);
11474        let ShapeNode::Array {
11475            name,
11476            position,
11477            element,
11478        } = &pointers[3]
11479        else {
11480            panic!()
11481        };
11482        assert_eq!(name, "posts");
11483        assert_eq!(*position, 3);
11484        let ShapeNode::Object {
11485            pointers: elem_pointers,
11486            ..
11487        } = element.as_ref()
11488        else {
11489            panic!()
11490        };
11491        // element pointers: [__type__, title] — `Post` has no pk property in
11492        // this fixture, so nothing is injected into its shape.
11493        assert_eq!(elem_pointers.len(), 2);
11494    }
11495
11496    #[test]
11497    fn test_select_order_by_limit() {
11498        let out = compile_and_emit("SELECT Person { name } ORDER BY .name ASC LIMIT 10");
11499        assert!(out.sql.contains("ORDER BY"));
11500        assert!(out.sql.contains("LIMIT 10"));
11501    }
11502
11503    #[test]
11504    fn test_insert_returning() {
11505        let out = compile_and_emit("INSERT Person { name := 'Alice', age := 30 }");
11506        assert!(out.sql.contains("INSERT INTO \"public\".\"Person\""));
11507        assert!(out.sql.contains("RETURNING"));
11508        assert!(out.sql.contains("'default::Person'::text"));
11509        assert!(out.sql.contains(") AS result"));
11510        // Bare INSERT returns pk only
11511        let ShapeNode::Object {
11512            cardinality, pointers, ..
11513        } = &out.shape.root
11514        else {
11515            panic!()
11516        };
11517        assert_eq!(*cardinality, Cardinality::Required);
11518        // Only __type__ and id — not name or age
11519        assert!(
11520            pointers
11521                .iter()
11522                .any(|f| matches!(f, ShapeNode::Scalar { name, .. } if name == "id"))
11523        );
11524        assert!(
11525            !pointers
11526                .iter()
11527                .any(|f| matches!(f, ShapeNode::Scalar { name, .. } if name == "name"))
11528        );
11529    }
11530
11531    #[test]
11532    fn test_update_returning() {
11533        let out = compile_and_emit("UPDATE Person FILTER .name = $name SET { age := 31 }");
11534        assert!(out.sql.contains("UPDATE \"public\".\"Person\""));
11535        assert!(out.sql.contains("SET"));
11536        // Bare UPDATE returns pk only
11537        assert!(out.sql.contains("RETURNING"));
11538        assert!(out.sql.contains("'default::Person'::text"));
11539        assert!(!out.sql.contains("\"name\"::text"), "bare UPDATE must not return name");
11540    }
11541
11542    #[test]
11543    fn test_update_set_tuple_param_cast_uses_direct_jsonb_cast_not_to_jsonb() {
11544        // Regression: to_jsonb($N) on a bare, still-untyped parameter fails at
11545        // execution time with "could not determine polymorphic type because
11546        // input has type unknown" — Postgres can't dispatch a polymorphic
11547        // function against an unknown-typed placeholder. A direct
11548        // ($N)::jsonb cast resolves the parameter's type from the cast
11549        // itself instead, matching how e.g. `$1::uuid` already works.
11550        let out = compile_and_emit("UPDATE Person FILTER .id = $id SET { age := <tuple<x: float64>>$val }");
11551        assert!(
11552            out.sql.contains(")::jsonb"),
11553            "expected a direct ::jsonb cast, got:\n{}",
11554            out.sql
11555        );
11556        assert!(
11557            !out.sql.contains("to_jsonb($"),
11558            "must not pass a bare param straight into to_jsonb(): got:\n{}",
11559            out.sql
11560        );
11561    }
11562
11563    #[test]
11564    fn test_empty_set_cast_to_object_type_clears_optional_link() {
11565        // Regression: unsetting an optional single-link generates
11566        // `<TargetType>{}` (a cast of the empty set, not the bare `{}` the
11567        // assignment-position special case in compile_assignments_inner
11568        // already handled) — that TypeCast wraps the empty set, so it fell
11569        // through to the generic Set/Shape rejection instead ("shapes and
11570        // set literals are not valid in expression context"). Must compile
11571        // to a plain NULL for the link's FK column: `<AnyType>{}` is always
11572        // NULL regardless of context.
11573        let out = compile_and_emit("UPDATE Person FILTER .id = $id SET { company := <default::Company>{} }");
11574        assert!(out.sql.contains("\"company_id\" = NULL"), "got:\n{}", out.sql);
11575    }
11576
11577    #[test]
11578    fn test_an_aggregate_over_a_set_literal_inside_a_shape() {
11579        // `min({cap, .age})` is one value read from a two-element set, and the
11580        // set is the aggregate's argument wherever the call is written. In a
11581        // schema-bound shape it used to report "shapes and set literals are not
11582        // valid in expression context" while the same call compiled at the top
11583        // level, so a saturating cap had no spelling here at all.
11584        let out = compile_and_emit("UPDATE Person FILTER .id = $id SET { age := min({<int64>100, .age}) }");
11585        assert!(out.sql.contains("min(v)"), "got:\n{}", out.sql);
11586        assert!(
11587            out.sql.contains("UNION ALL"),
11588            "both operands must reach the aggregate:\n{}",
11589            out.sql
11590        );
11591    }
11592
11593    #[test]
11594    fn test_an_aggregate_over_a_set_literal_reads_the_subject() {
11595        // The operands compile with the shape's own context, so a relative path
11596        // among them still resolves to the subject's column.
11597        let out = compile_and_emit("SELECT Person { capped := min({<int64>100, .age}) }");
11598        assert!(out.sql.contains("min(v)"), "got:\n{}", out.sql);
11599        assert!(
11600            out.sql.contains("\"age\""),
11601            "the relative path must reach the column:\n{}",
11602            out.sql
11603        );
11604    }
11605
11606    #[test]
11607    fn test_an_aggregate_over_a_union_inside_a_shape() {
11608        // `min((a union b))` says the same thing as `min({a, b})`, and used to
11609        // report "union is not valid in expression context".
11610        let out = compile_and_emit("SELECT Person { capped := min((<int64>100 union .age)) }");
11611        assert!(out.sql.contains("min(v)"), "got:\n{}", out.sql);
11612        assert!(
11613            out.sql.contains("UNION ALL"),
11614            "both arms must reach the aggregate:\n{}",
11615            out.sql
11616        );
11617    }
11618
11619    #[test]
11620    fn test_an_aggregate_over_a_union_of_three_arms_flattens() {
11621        let out = compile_and_emit("SELECT Person { capped := min((<int64>1 union <int64>2 union .age)) }");
11622        assert_eq!(
11623            out.sql.matches("UNION ALL").count(),
11624            2,
11625            "three arms means two joins:\n{}",
11626            out.sql
11627        );
11628    }
11629
11630    #[test]
11631    fn test_an_aggregate_over_an_empty_set_emits_no_row_source() {
11632        // An empty set has nothing to aggregate, and the row source it would
11633        // emit (`FROM () AS _set(v)`) is not valid SQL -- so `{}` must not be
11634        // taken for an aggregate's operands, in either context.
11635        for query in ["SELECT Person { capped := min({}) }", "SELECT min({})"] {
11636            let ast = parse::parse(query).expect("parse failed");
11637            let sql = ir::compile(&ast, &make_schema())
11638                .map(|ir| emit(&ir).sql)
11639                .unwrap_or_default();
11640            assert!(
11641                !sql.contains("FROM () AS"),
11642                "{query} emitted an empty row source:\n{sql}"
11643            );
11644        }
11645    }
11646
11647    #[test]
11648    fn test_delete_returning() {
11649        let out = compile_and_emit("DELETE Person FILTER .id = $id");
11650        assert!(out.sql.contains("DELETE FROM \"public\".\"Person\""));
11651        // Bare DELETE returns pk only
11652        assert!(out.sql.contains("RETURNING"));
11653        assert!(out.sql.contains("'default::Person'::text"));
11654        assert!(!out.sql.contains("\"name\"::text"), "bare DELETE must not return name");
11655    }
11656
11657    #[test]
11658    fn test_select_over_insert() {
11659        let out = compile_and_emit("SELECT (INSERT Person { name := $name, age := $age }) { id, name }");
11660        // Must use a CTE
11661        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
11662        assert!(out.sql.contains("INSERT INTO"));
11663        assert!(out.sql.contains("RETURNING *"));
11664        // Outer SELECT shapes the result
11665        assert!(out.sql.contains("'default::Person'::text"));
11666        assert!(out.sql.contains("\"name\"::text"));
11667    }
11668
11669    #[test]
11670    fn test_select_over_update() {
11671        let out = compile_and_emit("SELECT (UPDATE Person FILTER .id = $id SET { name := $name }) { id, name }");
11672        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
11673        assert!(out.sql.contains("UPDATE"));
11674        assert!(out.sql.contains("RETURNING \"t1\".*"), "{}", out.sql);
11675        assert!(out.sql.contains("\"name\"::text"));
11676    }
11677
11678    #[test]
11679    fn test_insert_user_specified_id_denied_by_default() {
11680        let schema = make_schema();
11681        let ast = parse::parse("INSERT Person { id := <uuid>$id, name := $name, age := $age }").unwrap();
11682        match ir::compile_with_config(&ast, &schema, &ir::SessionConfig::default()) {
11683            Err(err) => assert!(err.to_string().contains("cannot assign to property 'id'"), "got: {err}"),
11684            Ok(_) => panic!("expected id assignment to be denied by default"),
11685        }
11686    }
11687
11688    #[test]
11689    fn test_insert_user_specified_id_allowed_when_configured() {
11690        let schema = make_schema();
11691        let ast = parse::parse("INSERT Person { id := <uuid>$id, name := $name, age := $age }").unwrap();
11692        let config = ir::SessionConfig {
11693            allow_user_specified_id: true,
11694        };
11695        let ir_out = ir::compile_with_config(&ast, &schema, &config)
11696            .expect("expected id assignment to be allowed with allow_user_specified_id");
11697        let out = emit(&ir_out);
11698        assert!(out.sql.contains("INSERT INTO"));
11699    }
11700
11701    #[test]
11702    fn test_update_user_specified_id_denied_even_when_configured() {
11703        let schema = make_schema();
11704        let ast = parse::parse("UPDATE Person FILTER .name = $name SET { id := <uuid>$id }").unwrap();
11705        let config = ir::SessionConfig {
11706            allow_user_specified_id: true,
11707        };
11708        match ir::compile_with_config(&ast, &schema, &config) {
11709            Err(err) => assert!(err.to_string().contains("cannot assign to property 'id'"), "got: {err}"),
11710            Ok(_) => panic!("expected UPDATE to always deny reassigning id"),
11711        }
11712    }
11713
11714    #[test]
11715    fn test_select_over_update_multilink_only() {
11716        // Regression test: an UPDATE bound to a single external name (here,
11717        // the implicit "_dml" wrapper for `SELECT (UPDATE ...)`) whose SET
11718        // clause is *only* a multi-link mutation, with no scalar/single-link
11719        // assignment. This used to either emit an empty `SET` clause
11720        // ("UPDATE ... SET  WHERE ..." — invalid SQL) or, in an earlier fix
11721        // attempt, nest the junction INSERT inside "_dml"'s own CTE body —
11722        // which Postgres rejects outright ("WITH clause containing a
11723        // data-modifying statement must be at the top level"). The junction
11724        // CTEs must instead be hoisted out as top-level siblings of "_dml".
11725        let out = compile_and_emit(
11726            "SELECT (UPDATE Person FILTER .id = $id SET { posts += (SELECT Post FILTER .title = $title) }) { id, name }",
11727        );
11728        assert!(
11729            out.sql.contains("\"_dml__ml_add_0\""),
11730            "missing junction-append CTE:\n{}",
11731            out.sql
11732        );
11733        assert!(out.sql.contains("INSERT INTO"), "missing junction INSERT:\n{}", out.sql);
11734        // No scalar changes -> the row-source CTE must be a SELECT, not an UPDATE
11735        // with an empty SET clause.
11736        assert!(
11737            out.sql.contains("\"_dml__ids\" AS (\nSELECT"),
11738            "expected SELECT-based _ids CTE:\n{}",
11739            out.sql
11740        );
11741        assert!(
11742            !out.sql.contains("SET\n\nWHERE") && !out.sql.contains("SET \nWHERE"),
11743            "empty SET clause regression:\n{}",
11744            out.sql
11745        );
11746        // The junction CTE must be a *sibling* at the top-level WITH, not
11747        // nested inside another CTE's body — only one "WITH" keyword total.
11748        assert_eq!(
11749            out.sql.matches("WITH\n").count(),
11750            1,
11751            "junction CTE must not be nested in a second WITH:\n{}",
11752            out.sql
11753        );
11754        // "_dml" itself must still resolve (as a passthrough) for the outer SELECT.
11755        assert!(
11756            out.sql.contains("\"_dml\" AS (\n    SELECT * FROM \"_dml__ids\"\n)"),
11757            "missing _dml passthrough:\n{}",
11758            out.sql
11759        );
11760    }
11761
11762    #[test]
11763    fn test_select_over_update_scalar_and_multilink() {
11764        // Mixed case: a scalar assignment alongside a multi-link mutation, both
11765        // bound to the same external name — the scalar SET must survive *and*
11766        // the junction mutation must still be emitted as its own top-level CTE.
11767        let out = compile_and_emit(
11768            "SELECT (UPDATE Person FILTER .id = $id SET { name := $name, posts += (SELECT Post FILTER .title = $title) }) { id, name }",
11769        );
11770        assert!(
11771            out.sql.contains("\"_dml__ml_add_0\""),
11772            "missing junction-append CTE:\n{}",
11773            out.sql
11774        );
11775        assert!(
11776            out.sql.contains("\"_dml__ids\" AS (\nUPDATE"),
11777            "expected UPDATE-based _ids CTE:\n{}",
11778            out.sql
11779        );
11780        assert!(
11781            out.sql.contains("\"name\" = "),
11782            "missing scalar SET assignment:\n{}",
11783            out.sql
11784        );
11785        assert_eq!(
11786            out.sql.matches("WITH\n").count(),
11787            1,
11788            "junction CTE must not be nested in a second WITH:\n{}",
11789            out.sql
11790        );
11791    }
11792
11793    #[test]
11794    fn test_with_bound_insert_and_multilink_update_forward_ref() {
11795        // The actual shape pylon-ui's Data Explorer generates for its
11796        // flagship "insert + link in one batch" scenario: a same-batch
11797        // forward-reference (Person.posts += a not-yet-existing Post, bound
11798        // to insert0) inside a multi-statement WITH. This is the exact query
11799        // that failed live against a real Postgres before this fix ("WITH
11800        // clause containing a data-modifying statement must be at the top
11801        // level"), so it's the most important regression to pin down.
11802        let out = compile_and_emit(
11803            "with insert0 := (insert Post { title := $title }), update0 := (update Person filter .id = $id set { posts += (select insert0) }) select { insert0, update0 }",
11804        );
11805        assert!(
11806            out.sql.contains("\"insert0\" AS (\n    INSERT INTO"),
11807            "missing insert0 CTE:\n{}",
11808            out.sql
11809        );
11810        assert!(
11811            out.sql.contains("\"update0__ml_add_0\""),
11812            "missing junction-append CTE for update0:\n{}",
11813            out.sql
11814        );
11815        assert!(
11816            out.sql.contains("\"update0__ids\" AS (\nSELECT"),
11817            "expected SELECT-based update0 ids CTE (no scalar changes):\n{}",
11818            out.sql
11819        );
11820        assert!(
11821            out.sql
11822                .contains("\"update0\" AS (\n    SELECT * FROM \"update0__ids\"\n)"),
11823            "missing update0 passthrough:\n{}",
11824            out.sql
11825        );
11826        // Exactly one WITH keyword — every CTE (insert0, update0__ids,
11827        // update0__ml_add_0, update0) must be a top-level sibling.
11828        assert_eq!(
11829            out.sql.matches("WITH\n").count(),
11830            1,
11831            "must be a single flat top-level WITH block:\n{}",
11832            out.sql
11833        );
11834    }
11835
11836    #[test]
11837    fn test_select_over_delete() {
11838        let out = compile_and_emit("SELECT (DELETE Person FILTER .id = $id) { id, name }");
11839        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
11840        assert!(out.sql.contains("DELETE FROM"));
11841        assert!(out.sql.contains("RETURNING *"));
11842        assert!(out.sql.contains("\"name\"::text"));
11843    }
11844
11845    fn make_schema_with_rewrite() -> SchemaDescriptor {
11846        use crate::schema::RewriteEntry;
11847        let mut schema = make_schema();
11848        // Add a `slug` property to Person with an INSERT+UPDATE rewrite: lower(.name)
11849        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
11850        person.properties.push(PropertyDescriptor {
11851            name: "slug".into(),
11852            pg_type: "text".into(),
11853            nullable: true,
11854            default_sql: None,
11855            default_pyql: None,
11856            description: None,
11857            check_constraints: vec![],
11858            is_exclusive: false,
11859            is_pk: false,
11860            is_readonly: false,
11861            rewrites: vec![
11862                RewriteEntry {
11863                    on: 1,
11864                    handler: "str_lower(.name)".into(),
11865                }, // INSERT
11866                RewriteEntry {
11867                    on: 2,
11868                    handler: "str_lower(.name)".into(),
11869                }, // UPDATE
11870            ],
11871            tuple_members: None,
11872            column_type: None,
11873        });
11874        schema
11875    }
11876
11877    #[test]
11878    fn a_link_assigned_from_a_select_keeps_its_clauses() {
11879        let out = compile_and_emit(
11880            "INSERT Person { name := 'a', age := 1, company := (SELECT Company ORDER BY .name LIMIT 1) }",
11881        );
11882        assert!(
11883            out.sql.contains("ORDER BY") && out.sql.contains("LIMIT 1"),
11884            "{}",
11885            out.sql
11886        );
11887    }
11888
11889    #[test]
11890    fn a_rewrite_leaves_the_statement_alone() {
11891        // It runs in the table's own `BEFORE` trigger, on the row as written.
11892        let schema = make_schema_with_rewrite();
11893        for query in [
11894            "INSERT Person { name := $name, age := 30 }",
11895            "UPDATE Person FILTER .id = $id SET { name := $name }",
11896        ] {
11897            let out = compile_and_emit_with(query, &schema);
11898            assert!(!out.sql.contains("\"slug\""), "{}", out.sql);
11899        }
11900    }
11901
11902    #[test]
11903    fn a_rewrite_reads_the_row_being_written() {
11904        let schema = make_schema_with_rewrite();
11905        let assignments = crate::ir::compile_rewrite_assignments("default::Person", 1, &schema).unwrap();
11906        let [assignment] = assignments.as_slice() else {
11907            panic!("one rewrite on insert")
11908        };
11909        assert_eq!(assignment.column, "slug");
11910        assert!(assignment.sql.contains("NEW.\"name\""), "{}", assignment.sql);
11911    }
11912
11913    #[test]
11914    fn rewrites_become_before_triggers() {
11915        let ddl = crate::export::export_schema(&make_schema_with_rewrite()).unwrap();
11916        for event in ["BEFORE INSERT", "BEFORE UPDATE"] {
11917            assert!(
11918                ddl.contains(&format!("{event} ON \"public\".\"Person\"")),
11919                "{event}:\n{ddl}"
11920            );
11921        }
11922        assert!(ddl.contains("NEW.\"slug\" := _pylon_rewrites.\"v0\";"), "{ddl}");
11923    }
11924
11925    #[test]
11926    fn a_rewrite_walking_a_link_starts_from_the_new_row() {
11927        // The row is not in its table yet when a `BEFORE INSERT` trigger runs.
11928        use crate::schema::RewriteEntry;
11929        let mut schema = make_schema_with_rewrite();
11930        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
11931        let slug = person.properties.iter_mut().find(|p| p.name == "slug").unwrap();
11932        slug.rewrites = vec![RewriteEntry {
11933            on: 1,
11934            handler: "<str>(.company is Company) ++ .company.name".into(),
11935        }];
11936        let assignments = crate::ir::compile_rewrite_assignments("default::Person", 1, &schema).unwrap();
11937        let sql = &assignments[0].sql;
11938        assert!(!sql.contains("\"public\".\"Person\""), "{sql}");
11939        assert!(
11940            sql.contains("(SELECT (NEW).*)") && sql.contains("NEW.\"company_id\""),
11941            "{sql}"
11942        );
11943    }
11944
11945    #[test]
11946    fn test_unless_conflict_do_nothing() {
11947        let out = compile_and_emit("INSERT Person { name := $name } UNLESS CONFLICT");
11948        assert!(out.sql.contains("ON CONFLICT DO NOTHING"));
11949    }
11950
11951    #[test]
11952    fn test_unless_conflict_on_do_nothing() {
11953        let out = compile_and_emit("INSERT Person { name := $name } UNLESS CONFLICT ON .name");
11954        assert!(out.sql.contains("ON CONFLICT (\"name\") DO NOTHING"));
11955    }
11956
11957    #[test]
11958    fn test_unless_conflict_do_update() {
11959        let out = compile_and_emit(
11960            "INSERT Person { name := $name, age := $age } \
11961             UNLESS CONFLICT ON .name \
11962             ELSE (UPDATE Person SET { age := $age })",
11963        );
11964        assert!(out.sql.contains("ON CONFLICT (\"name\") DO UPDATE SET"));
11965        assert!(out.sql.contains("\"age\" = $2"));
11966        // The param $age is shared — same index as in the INSERT VALUES
11967        assert!(!out.sql.contains("DO NOTHING"));
11968    }
11969
11970    #[test]
11971    fn test_unless_conflict_else_update_self_reference_is_qualified() {
11972        // Regression: a self-referencing ELSE-update assignment (`age :=
11973        // .age + 1`) used to compile its RHS with an empty (unqualified)
11974        // alias, emitting a bare `"age"` — ambiguous in Postgres's `ON
11975        // CONFLICT DO UPDATE SET` context between the existing row and the
11976        // `excluded` pseudo-row (confirmed live: "column reference ... is
11977        // ambiguous"). It must be qualified with the target table's own
11978        // bare name instead.
11979        let out = compile_and_emit(
11980            "INSERT Person { name := $name, age := 0 } \
11981             UNLESS CONFLICT ON .name \
11982             ELSE (UPDATE Person SET { age := .age + 1 })",
11983        );
11984        assert!(
11985            out.sql.contains("\"Person\".\"age\""),
11986            "self-reference must be qualified with the table's own name, got:\n{}",
11987            out.sql
11988        );
11989        assert!(
11990            !out.sql.contains("SET \"age\" = (\"age\""),
11991            "must not emit an unqualified (ambiguous) self-reference, got:\n{}",
11992            out.sql
11993        );
11994    }
11995
11996    #[test]
11997    fn test_unless_conflict_do_update_no_on() {
11998        let out = compile_and_emit(
11999            "INSERT Person { name := $name } \
12000             UNLESS CONFLICT \
12001             ELSE (UPDATE Person SET { age := 0 })",
12002        );
12003        assert!(out.sql.contains("ON CONFLICT DO UPDATE SET"));
12004        assert!(out.sql.contains("\"age\" = 0"));
12005    }
12006
12007    #[test]
12008    fn test_select_over_select() {
12009        let out = compile_and_emit("SELECT (SELECT Person FILTER .age > 18) { name }");
12010        // Must use a CTE
12011        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
12012        // CTE exposes raw columns via SELECT *
12013        assert!(out.sql.contains("SELECT *"));
12014        assert!(out.sql.contains("FROM \"public\".\"Person\""));
12015        // CTE carries the inner filter
12016        assert!(out.sql.contains("WHERE"));
12017        // Outer SELECT projects its own shape
12018        assert!(out.sql.contains("'default::Person'::text"));
12019        assert!(out.sql.contains("\"name\"::text"));
12020    }
12021
12022    #[test]
12023    fn test_select_over_select_with_outer_filter() {
12024        let out = compile_and_emit("SELECT (SELECT Person FILTER .age > 18) { name } FILTER .name = $name");
12025        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
12026        assert!(out.sql.contains("SELECT *"));
12027        // Both filters present: one inside CTE, one in outer SELECT
12028        assert_eq!(out.sql.matches("WHERE").count(), 2);
12029        assert!(out.sql.contains("$1"));
12030    }
12031
12032    #[test]
12033    fn test_insert_link_subquery() {
12034        let out = compile_and_emit("INSERT Person { name := $name, company := (SELECT Company FILTER .name = $co) }");
12035        // The company FK column should be assigned via a scalar subquery
12036        assert!(out.sql.contains("\"company_id\""));
12037        assert!(out.sql.contains("SELECT"));
12038        // The subquery must select the pk (id) of Company
12039        assert!(out.sql.contains("\"id\""));
12040        assert!(out.sql.contains("FROM \"public\".\"Company\""));
12041        // Filter param must appear
12042        assert!(out.sql.contains("$2")); // $1 = name, $2 = co
12043    }
12044
12045    #[test]
12046    fn test_update_link_subquery() {
12047        let out =
12048            compile_and_emit("UPDATE Person FILTER .id = $id SET { company := (SELECT Company FILTER .name = $co) }");
12049        assert!(out.sql.contains("\"company_id\""));
12050        assert!(out.sql.contains("SELECT"));
12051        assert!(out.sql.contains("FROM \"public\".\"Company\""));
12052    }
12053
12054    #[test]
12055    fn test_insert_link_value_from_nested_insert_hoists_a_with_cte() {
12056        // `company := (select (insert Company {...}) { id })` — Postgres has
12057        // no way to run a nested INSERT inside another statement's VALUES
12058        // list without hoisting it into its own WITH CTE first (confirmed
12059        // live against real Postgres — see live_execution_insert_nested.rs).
12060        // Regression: an earlier attempt at this compiled cleanly but
12061        // silently emitted a subquery that dropped the nested INSERT
12062        // entirely and selected an arbitrary unrelated row instead.
12063        let out = compile_and_emit(
12064            "INSERT Person { name := 'Alice', company := (select (insert Company { name := 'Acme' }) { id }) }",
12065        );
12066        assert!(
12067            out.sql.starts_with("WITH"),
12068            "expected a WITH-hoisted CTE, got:\n{}",
12069            out.sql
12070        );
12071        assert!(
12072            out.sql.contains("INSERT INTO \"public\".\"Company\""),
12073            "expected the nested insert to be its own CTE, got:\n{}",
12074            out.sql,
12075        );
12076        // The outer insert must reference the nested CTE's own id, not a
12077        // freestanding subquery against the Company table.
12078        assert!(
12079            out.sql.contains("\"company_id\") SELECT") && out.sql.contains(".\"id\" FROM"),
12080            "expected the outer insert to switch from VALUES to SELECT ... FROM <cte>, got:\n{}",
12081            out.sql,
12082        );
12083        assert!(
12084            !out.sql.contains("FROM \"public\".\"Company\" AS"),
12085            "must not select from the real Company table, got:\n{}",
12086            out.sql
12087        );
12088    }
12089
12090    #[test]
12091    fn test_update_link_value_from_nested_insert_hoists_a_with_cte_and_adds_from_clause() {
12092        let out = compile_and_emit(
12093            "UPDATE Person FILTER .id = $id SET { company := (select (insert Company { name := 'Acme' }) { id }) }",
12094        );
12095        assert!(
12096            out.sql.starts_with("WITH"),
12097            "expected a WITH-hoisted CTE, got:\n{}",
12098            out.sql
12099        );
12100        assert!(
12101            out.sql.contains("INSERT INTO \"public\".\"Company\""),
12102            "expected the nested insert to be its own CTE, got:\n{}",
12103            out.sql,
12104        );
12105        // Plain `UPDATE ... SET ...` has no FROM clause of its own — one
12106        // must be added so the SET clause can reference the CTE's column.
12107        assert!(
12108            out.sql.contains("\nFROM \""),
12109            "expected a FROM clause referencing the nested CTE, got:\n{}",
12110            out.sql
12111        );
12112        assert!(out.sql.contains("SET \"company_id\" = "), "got:\n{}", out.sql);
12113    }
12114
12115    #[test]
12116    fn test_update_link_value_from_nested_insert_combines_with_multilink_mutation() {
12117        // A nested-insert link value and a `+=` multi-link mutation in the
12118        // same UPDATE route through a completely different emitter branch
12119        // (the junction-CTE "_ids" path, not the plain-UPDATE path the
12120        // previous test covers) — both must still see the hoisted CTE.
12121        let out = compile_and_emit(
12122            "UPDATE Person FILTER .id = $id SET { \
12123                 company := (select (insert Company { name := 'Acme' }) { id }), \
12124                 posts += (SELECT Post FILTER .title = $t) \
12125             }",
12126        );
12127        assert!(out.sql.starts_with("WITH"), "got:\n{}", out.sql);
12128        assert!(
12129            out.sql.contains("INSERT INTO \"public\".\"Company\""),
12130            "expected the nested insert to be its own CTE, got:\n{}",
12131            out.sql,
12132        );
12133        assert!(
12134            out.sql.contains("\"_ids\" AS (\nUPDATE") && out.sql.contains("\nFROM \""),
12135            "expected the _ids UPDATE to gain a FROM clause referencing the nested CTE, got:\n{}",
12136            out.sql,
12137        );
12138        assert!(
12139            out.sql.contains("\"_ml_add_0\""),
12140            "expected the junction-append CTE to still be present, got:\n{}",
12141            out.sql
12142        );
12143    }
12144
12145    #[test]
12146    fn test_update_link_value_from_nested_insert_combines_with_interface_target() {
12147        // A nested-insert link value on an UPDATE targeting an interface
12148        // (abstract + materialized) type routes through the poly fan-out
12149        // branch (emit_poly_update_stmt) — a third, independent emitter path
12150        // from the previous two tests. The hoisted CTE must be materialized
12151        // once and referenced from every per-implementor UPDATE branch.
12152        fn id_prop() -> PropertyDescriptor {
12153            PropertyDescriptor {
12154                name: "id".into(),
12155                pg_type: "uuid".into(),
12156                nullable: false,
12157                default_sql: Some("uuidv7()".into()),
12158                default_pyql: None,
12159                description: None,
12160                check_constraints: vec![],
12161                is_exclusive: true,
12162                is_pk: true,
12163                is_readonly: true,
12164                rewrites: vec![],
12165                tuple_members: None,
12166                column_type: None,
12167            }
12168        }
12169        fn text_prop(name: &str) -> PropertyDescriptor {
12170            PropertyDescriptor {
12171                name: name.into(),
12172                pg_type: "text".into(),
12173                nullable: false,
12174                default_sql: None,
12175                default_pyql: None,
12176                description: None,
12177                check_constraints: vec![],
12178                is_exclusive: false,
12179                is_pk: false,
12180                is_readonly: false,
12181                rewrites: vec![],
12182                tuple_members: None,
12183                column_type: None,
12184            }
12185        }
12186        fn company_link() -> LinkDescriptor {
12187            LinkDescriptor {
12188                name: "company".into(),
12189                target: "default::Company".into(),
12190                nullable: true,
12191                through: None,
12192                description: None,
12193                default_pyql: None,
12194                is_exclusive: false,
12195                is_readonly: false,
12196                rewrites: vec![],
12197                on_delete: vec![],
12198            }
12199        }
12200        let schema = SchemaDescriptor {
12201            types: vec![
12202                TypeDescriptor {
12203                    name: "Company".into(),
12204                    module: "default".into(),
12205                    table: "Company".into(),
12206                    abstract_: false,
12207                    materialized: true,
12208                    description: None,
12209                    parents: vec![],
12210                    interfaces: vec![],
12211                    bases: vec![],
12212                    properties: vec![id_prop(), text_prop("name")],
12213                    links: vec![],
12214                    multilinks: vec![],
12215                    computed: vec![],
12216                    constraints: vec![],
12217                    indexes: vec![],
12218                    partition: None,
12219                    vector_indexes: vec![],
12220                    search_indexes: vec![],
12221                    triggers: vec![],
12222                    junction: false,
12223                    signals: vec![],
12224                },
12225                TypeDescriptor {
12226                    name: "Account".into(),
12227                    module: "default".into(),
12228                    table: "Account".into(),
12229                    abstract_: true,
12230                    materialized: true,
12231                    description: None,
12232                    parents: vec![],
12233                    interfaces: vec![],
12234                    bases: vec![],
12235                    properties: vec![id_prop(), text_prop("email")],
12236                    links: vec![company_link()],
12237                    multilinks: vec![],
12238                    computed: vec![],
12239                    constraints: vec![],
12240                    indexes: vec![],
12241                    partition: None,
12242                    vector_indexes: vec![],
12243                    search_indexes: vec![],
12244                    triggers: vec![],
12245                    junction: false,
12246                    signals: vec![],
12247                },
12248                TypeDescriptor {
12249                    name: "Individual".into(),
12250                    module: "default".into(),
12251                    table: "Individual".into(),
12252                    abstract_: false,
12253                    materialized: true,
12254                    description: None,
12255                    parents: vec![],
12256                    interfaces: vec!["default::Account".into()],
12257                    bases: vec![],
12258                    properties: vec![id_prop(), text_prop("email"), text_prop("first_name")],
12259                    links: vec![company_link()],
12260                    multilinks: vec![],
12261                    computed: vec![],
12262                    constraints: vec![],
12263                    indexes: vec![],
12264                    partition: None,
12265                    vector_indexes: vec![],
12266                    search_indexes: vec![],
12267                    triggers: vec![],
12268                    junction: false,
12269                    signals: vec![],
12270                },
12271            ],
12272            scalars: vec![],
12273            enums: vec![],
12274            named_tuples: vec![],
12275            globals: vec![],
12276            functions: vec![],
12277            aliases: vec![],
12278            channels: vec![],
12279            ..Default::default()
12280        };
12281        let out = compile_and_emit_with(
12282            "UPDATE Account FILTER .email = $email \
12283             SET { company := (select (insert Company { name := 'Acme' }) { id }) }",
12284            &schema,
12285        );
12286        assert!(out.sql.starts_with("WITH"), "got:\n{}", out.sql);
12287        assert!(
12288            out.sql.contains("INSERT INTO \"public\".\"Company\""),
12289            "expected the nested insert to be its own CTE, got:\n{}",
12290            out.sql,
12291        );
12292        assert!(
12293            out.sql.contains("UPDATE \"public\".\"Individual\"") && out.sql.contains("\nFROM \"_nested_dml_0\""),
12294            "expected the per-implementor UPDATE to gain a FROM clause referencing the nested CTE, got:\n{}",
12295            out.sql,
12296        );
12297    }
12298
12299    #[test]
12300    fn test_a_backlink_narrowed_to_a_supertype() {
12301        // `.<company[is Account]` — the intersection narrows what comes back,
12302        // while the link itself is declared further down. Pylon looked for it
12303        // on the narrowed type alone and reported it had no such link.
12304        let mut schema = interface_link_schema();
12305        // Leave `company` declared only on the implementor, so the narrowed
12306        // type genuinely does not have it.
12307        schema
12308            .types
12309            .iter_mut()
12310            .find(|t| t.name == "Account")
12311            .expect("the helper declares Account")
12312            .links
12313            .clear();
12314        let out = compile_and_emit_with("SELECT Company { owners := .<company[is Account] { email } }", &schema);
12315        assert!(
12316            out.sql.contains("\"public\".\"Individual\""),
12317            "the backlink must read the type that declares it:\n{}",
12318            out.sql
12319        );
12320    }
12321
12322    #[test]
12323    fn a_path_through_a_backlink_narrowed_to_a_supertype() {
12324        let mut schema = interface_link_schema();
12325        schema
12326            .types
12327            .iter_mut()
12328            .find(|t| t.name == "Account")
12329            .expect("the helper declares Account")
12330            .links
12331            .clear();
12332        let out = compile_and_emit_with(
12333            "SELECT Company { name } FILTER EXISTS (SELECT .<company[is Account])",
12334            &schema,
12335        );
12336        assert!(out.sql.contains("\"public\".\"Individual\""), "{}", out.sql);
12337    }
12338
12339    /// Company, the Account interface, its Individual implementor and a
12340    /// Token linking to the interface -- enough to exercise reading and
12341    /// backlinking across one.
12342    fn interface_link_schema() -> SchemaDescriptor {
12343        // A link whose target is an interface is read by fanning the
12344        // implementors out inline. That union used to project only the
12345        // interface's *properties*, so a shape that then followed one of the
12346        // interface's own links failed with "column t1.company_id does not
12347        // exist".
12348        fn id_prop() -> PropertyDescriptor {
12349            PropertyDescriptor {
12350                name: "id".into(),
12351                pg_type: "uuid".into(),
12352                nullable: false,
12353                default_sql: None,
12354                default_pyql: None,
12355                description: None,
12356                check_constraints: vec![],
12357                is_exclusive: true,
12358                is_pk: true,
12359                is_readonly: true,
12360                rewrites: vec![],
12361                tuple_members: None,
12362                column_type: None,
12363            }
12364        }
12365        fn text_prop(name: &str) -> PropertyDescriptor {
12366            PropertyDescriptor {
12367                name: name.into(),
12368                pg_type: "text".into(),
12369                nullable: false,
12370                default_sql: None,
12371                default_pyql: None,
12372                description: None,
12373                check_constraints: vec![],
12374                is_exclusive: false,
12375                is_pk: false,
12376                is_readonly: false,
12377                rewrites: vec![],
12378                tuple_members: None,
12379                column_type: None,
12380            }
12381        }
12382        fn link(name: &str, target: &str) -> LinkDescriptor {
12383            LinkDescriptor {
12384                name: name.into(),
12385                target: target.into(),
12386                nullable: true,
12387                through: None,
12388                description: None,
12389                default_pyql: None,
12390                is_exclusive: false,
12391                is_readonly: false,
12392                rewrites: vec![],
12393                on_delete: vec![],
12394            }
12395        }
12396        fn ty(
12397            name: &str,
12398            abstract_: bool,
12399            interfaces: Vec<String>,
12400            properties: Vec<PropertyDescriptor>,
12401            links: Vec<LinkDescriptor>,
12402        ) -> TypeDescriptor {
12403            TypeDescriptor {
12404                name: name.into(),
12405                module: "default".into(),
12406                table: name.into(),
12407                abstract_,
12408                materialized: true,
12409                description: None,
12410                parents: vec![],
12411                interfaces,
12412                bases: vec![],
12413                properties,
12414                links,
12415                multilinks: vec![],
12416                computed: vec![],
12417                constraints: vec![],
12418                indexes: vec![],
12419                partition: None,
12420                vector_indexes: vec![],
12421                search_indexes: vec![],
12422                triggers: vec![],
12423                junction: false,
12424                signals: vec![],
12425            }
12426        }
12427        SchemaDescriptor {
12428            types: vec![
12429                ty("Company", false, vec![], vec![id_prop(), text_prop("name")], vec![]),
12430                ty(
12431                    "Account",
12432                    true,
12433                    vec![],
12434                    vec![id_prop(), text_prop("email")],
12435                    vec![link("company", "default::Company")],
12436                ),
12437                ty(
12438                    "Individual",
12439                    false,
12440                    vec!["default::Account".into()],
12441                    vec![id_prop(), text_prop("email")],
12442                    vec![link("company", "default::Company")],
12443                ),
12444                ty(
12445                    "Token",
12446                    false,
12447                    vec![],
12448                    vec![id_prop(), text_prop("value")],
12449                    vec![link("account", "default::Account")],
12450                ),
12451            ],
12452            scalars: vec![],
12453            enums: vec![],
12454            named_tuples: vec![],
12455            globals: vec![],
12456            functions: vec![],
12457            aliases: vec![],
12458            channels: vec![],
12459            ..Default::default()
12460        }
12461    }
12462
12463    /// `Token`, but reached through a second single link and narrowed to the
12464    /// interface's implementor -- conduit's trust-tier walk,
12465    /// `installation.connector.provider[is Individual].staff`.
12466    fn two_hop_interface_schema() -> SchemaDescriptor {
12467        let mut schema = interface_link_schema();
12468        let token = schema
12469            .types
12470            .iter()
12471            .find(|t| t.name == "Token")
12472            .expect("the helper declares Token")
12473            .clone();
12474        let mut connector = token.clone();
12475        connector.name = "Connector".into();
12476        connector.table = "Connector".into();
12477        connector.links[0].name = "provider".into();
12478        let mut installation = token;
12479        installation.name = "Installation".into();
12480        installation.table = "Installation".into();
12481        installation.links[0].name = "connector".into();
12482        installation.links[0].target = "default::Connector".into();
12483        schema.types.push(connector);
12484        schema.types.push(installation);
12485        schema
12486    }
12487
12488    #[test]
12489    fn a_narrowed_two_hop_walk_off_a_pinned_row_is_one_value() {
12490        // Every step is a forward single link and the filter pins the id, so
12491        // the `[is Individual]` narrowing does not make the walk set-valued.
12492        // Gathered as an array the `??` default became `array_remove(ARRAY[…])`
12493        // and the result decoded as a one-element set rather than as the value
12494        // itself.
12495        let out = compile_and_emit_with(
12496            "SELECT { s := ((SELECT Installation FILTER .id = <uuid>$0).connector.provider[is Individual].email) ?? 'y' }",
12497            &two_hop_interface_schema(),
12498        );
12499        assert!(
12500            !out.sql.contains("ARRAY(SELECT"),
12501            "expected a value, not a set:\n{}",
12502            out.sql
12503        );
12504        assert!(
12505            out.sql.contains("COALESCE("),
12506            "the default must stay a scalar one:\n{}",
12507            out.sql
12508        );
12509    }
12510
12511    #[test]
12512    fn a_narrowed_two_hop_walk_off_an_unpinned_row_stays_a_set() {
12513        let out = compile_and_emit_with(
12514            "SELECT { s := (SELECT Installation FILTER .value = 'x').connector.provider[is Individual].email }",
12515            &two_hop_interface_schema(),
12516        );
12517        assert!(
12518            out.sql.contains("ARRAY(SELECT"),
12519            "a walk off many rows is a set:\n{}",
12520            out.sql
12521        );
12522    }
12523
12524    /// `exists .account[is Individual]` — a walk narrowed to an implementor
12525    /// with no pointer read after it. The two-step form needs a trailing
12526    /// column to read, so it rejected the intersection outright ("type
12527    /// intersections are not valid in expression context") even though the
12528    /// same walk with a pointer on the end (`[is Individual].email`) has
12529    /// always compiled.
12530    #[test]
12531    fn a_walk_ending_in_a_type_intersection_is_valid_in_a_filter() {
12532        let out = compile_and_emit_with(
12533            "SELECT Token { id } FILTER EXISTS .account[is Individual]",
12534            &interface_link_schema(),
12535        );
12536        assert!(
12537            out.sql.contains("\"public\".\"Individual\""),
12538            "the narrowing must reach the implementor's table:\n{}",
12539            out.sql
12540        );
12541        assert!(
12542            out.sql.contains("IS NOT NULL"),
12543            "exists over the narrowed walk is a nullness test:\n{}",
12544            out.sql
12545        );
12546    }
12547
12548    #[test]
12549    fn a_walk_ending_in_a_type_intersection_is_valid_off_a_binding() {
12550        let out = compile_and_emit_with(
12551            "WITH t := (SELECT Token LIMIT 1) SELECT { e := EXISTS t.account[is Individual] }",
12552            &interface_link_schema(),
12553        );
12554        assert!(
12555            out.sql.contains("\"public\".\"Individual\""),
12556            "the narrowing must reach the implementor's table:\n{}",
12557            out.sql
12558        );
12559    }
12560
12561    #[test]
12562    fn test_reading_a_link_through_an_interface_target() {
12563        let schema = interface_link_schema();
12564        let out = compile_and_emit_with("SELECT Token { account: { email, company: { name } } }", &schema);
12565        assert!(
12566            out.sql
12567                .contains("\"email\", \"company_id\" FROM \"public\".\"Individual\""),
12568            "the fanned-out interface must carry its own link columns:\n{}",
12569            out.sql
12570        );
12571    }
12572
12573    #[test]
12574    fn test_computed_pointer_in_shape_emits_expression() {
12575        let mut schema = make_schema();
12576        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
12577            name: "upper_name".into(),
12578            expression: "str_upper(.name)".into(),
12579            return_type: Some("text".into()),
12580            link_target: None,
12581            link_multi: false,
12582        });
12583        let out = compile_and_emit_with("SELECT Person { upper_name }", &schema);
12584        assert!(
12585            out.sql.to_lowercase().contains("upper"),
12586            "expected upper() in SQL, got:\n{}",
12587            out.sql
12588        );
12589    }
12590
12591    /// Interface `Account` (non-materialized) with one implementor,
12592    /// `Individual`, carrying a stored property and a computed pointer.
12593    fn make_interface_schema() -> SchemaDescriptor {
12594        fn id_prop() -> PropertyDescriptor {
12595            PropertyDescriptor {
12596                name: "id".into(),
12597                pg_type: "uuid".into(),
12598                nullable: false,
12599                default_sql: Some("uuidv7()".into()),
12600                default_pyql: None,
12601                description: None,
12602                check_constraints: vec![],
12603                is_exclusive: true,
12604                is_pk: true,
12605                is_readonly: true,
12606                rewrites: vec![],
12607                tuple_members: None,
12608                column_type: None,
12609            }
12610        }
12611        SchemaDescriptor {
12612            types: vec![
12613                TypeDescriptor {
12614                    name: "Account".into(),
12615                    module: "default".into(),
12616                    table: "Account".into(),
12617                    abstract_: true,
12618                    materialized: false,
12619                    description: None,
12620                    parents: vec![],
12621                    interfaces: vec![],
12622                    bases: vec![],
12623                    properties: vec![
12624                        id_prop(),
12625                        PropertyDescriptor {
12626                            name: "email".into(),
12627                            pg_type: "text".into(),
12628                            nullable: false,
12629                            default_sql: None,
12630                            default_pyql: None,
12631                            description: None,
12632                            check_constraints: vec![],
12633                            is_exclusive: false,
12634                            is_pk: false,
12635                            is_readonly: false,
12636                            rewrites: vec![],
12637                            tuple_members: None,
12638                            column_type: None,
12639                        },
12640                    ],
12641                    links: vec![],
12642                    multilinks: vec![],
12643                    computed: vec![],
12644                    constraints: vec![],
12645                    indexes: vec![],
12646                    partition: None,
12647                    vector_indexes: vec![],
12648                    search_indexes: vec![],
12649                    triggers: vec![],
12650                    junction: false,
12651                    signals: vec![],
12652                },
12653                TypeDescriptor {
12654                    name: "Individual".into(),
12655                    module: "default".into(),
12656                    table: "Individual".into(),
12657                    abstract_: false,
12658                    materialized: true,
12659                    description: None,
12660                    parents: vec![],
12661                    interfaces: vec!["default::Account".into()],
12662                    bases: vec![],
12663                    properties: vec![
12664                        id_prop(),
12665                        PropertyDescriptor {
12666                            name: "first_name".into(),
12667                            pg_type: "text".into(),
12668                            nullable: false,
12669                            default_sql: None,
12670                            default_pyql: None,
12671                            description: None,
12672                            check_constraints: vec![],
12673                            is_exclusive: false,
12674                            is_pk: false,
12675                            is_readonly: false,
12676                            rewrites: vec![],
12677                            tuple_members: None,
12678                            column_type: None,
12679                        },
12680                    ],
12681                    links: vec![],
12682                    multilinks: vec![],
12683                    computed: vec![crate::schema::ComputedDescriptor {
12684                        name: "full_name".into(),
12685                        expression: "str_upper(.first_name)".into(),
12686                        return_type: Some("text".into()),
12687                        link_target: None,
12688                        link_multi: false,
12689                    }],
12690                    constraints: vec![],
12691                    indexes: vec![],
12692                    partition: None,
12693                    vector_indexes: vec![],
12694                    search_indexes: vec![],
12695                    triggers: vec![],
12696                    junction: false,
12697                    signals: vec![],
12698                },
12699            ],
12700            scalars: vec![],
12701            enums: vec![],
12702            named_tuples: vec![],
12703            globals: vec![],
12704            functions: vec![],
12705            aliases: vec![],
12706            channels: vec![],
12707            ..Default::default()
12708        }
12709    }
12710
12711    #[test]
12712    fn test_type_intersection_splat_includes_concrete_computed_pointers() {
12713        // `[is Concrete].*` must expand to the concrete type's own computed
12714        // pointers too (e.g. `full_name`), not just its stored properties —
12715        // querying `Individual` directly with `*` already included them, but
12716        // the interface-splat path never even looked at `td.computed`.
12717        let schema = make_interface_schema();
12718        let out = compile_and_emit_with("SELECT Account { *, [is Individual].* }", &schema);
12719        assert!(
12720            out.sql.to_lowercase().contains("upper"),
12721            "expected the concrete type's computed pointer (str_upper(...)) in the shape, got:\n{}",
12722            out.sql
12723        );
12724        assert!(
12725            out.sql.contains("\"first_name\""),
12726            "expected the concrete type's stored property too, got:\n{}",
12727            out.sql
12728        );
12729    }
12730
12731    #[test]
12732    fn test_enum_array_property_casts_to_text_array_and_shapes_as_a_list() {
12733        // Regression: an `array<enum>` property's pg_type is
12734        // `"module"."Type"[]`, which starts with a quote exactly as a scalar
12735        // enum's does. Read as a scalar it was cast to a single `::text`,
12736        // so `{MagicLink}` -- Postgres's text rendering of the whole array --
12737        // came back as one enum value, and the polymorphic path, which casts
12738        // nothing, failed outright with "no decoder for PostgreSQL type OID".
12739        let mut schema = make_schema();
12740        schema.enums.push(crate::schema::EnumDescriptor {
12741            name: "Gender".into(),
12742            module: "default".into(),
12743            members: vec!["Male".into(), "Female".into()],
12744        });
12745        schema.types[0].properties.push(crate::schema::PropertyDescriptor {
12746            name: "genders".into(),
12747            pg_type: r#""default"."Gender"[]"#.into(),
12748            nullable: true,
12749            default_sql: None,
12750            default_pyql: None,
12751            description: None,
12752            check_constraints: vec![],
12753            is_exclusive: false,
12754            is_pk: false,
12755            is_readonly: false,
12756            rewrites: vec![],
12757            tuple_members: None,
12758            column_type: None,
12759        });
12760
12761        let out = compile_and_emit_with("SELECT Person { genders }", &schema);
12762        assert!(
12763            out.sql.contains(r#""genders"::text[]"#),
12764            "expected a text[] cast, got:\n{}",
12765            out.sql
12766        );
12767
12768        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
12769            panic!("expected Object shape, got {:?}", out.shape.root)
12770        };
12771        let genders = pointers
12772            .iter()
12773            .find(|node| matches!(node, crate::query::ShapeNode::Array { name, .. } if name == "genders"))
12774            .unwrap_or_else(|| panic!("expected an Array-shaped pointer, got {pointers:?}"));
12775        let crate::query::ShapeNode::Array { element, .. } = genders else {
12776            unreachable!()
12777        };
12778        assert!(
12779            matches!(
12780                element.as_ref(),
12781                crate::query::ShapeNode::Enum { enum_type, position: 0, .. }
12782                    if enum_type == "default::Gender"
12783            ),
12784            "expected the elements to be enum-shaped, got {element:?}",
12785        );
12786    }
12787
12788    #[test]
12789    fn test_scalar_enum_property_still_casts_to_a_single_text() {
12790        let mut schema = make_schema();
12791        schema.enums.push(crate::schema::EnumDescriptor {
12792            name: "Gender".into(),
12793            module: "default".into(),
12794            members: vec!["Male".into(), "Female".into()],
12795        });
12796        schema.types[0].properties.push(crate::schema::PropertyDescriptor {
12797            name: "gender".into(),
12798            pg_type: r#""default"."Gender""#.into(),
12799            nullable: true,
12800            default_sql: None,
12801            default_pyql: None,
12802            description: None,
12803            check_constraints: vec![],
12804            is_exclusive: false,
12805            is_pk: false,
12806            is_readonly: false,
12807            rewrites: vec![],
12808            tuple_members: None,
12809            column_type: None,
12810        });
12811
12812        let out = compile_and_emit_with("SELECT Person { gender }", &schema);
12813        assert!(
12814            out.sql.contains(r#""gender"::text"#) && !out.sql.contains(r#""gender"::text[]"#),
12815            "expected a plain text cast, got:\n{}",
12816            out.sql
12817        );
12818        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
12819            panic!("expected Object shape")
12820        };
12821        assert!(
12822            pointers.iter().any(|node| matches!(
12823                node,
12824                crate::query::ShapeNode::Enum { name, enum_type, .. }
12825                    if name == "gender" && enum_type == "default::Gender"
12826            )),
12827            "expected an Enum-shaped pointer, got {pointers:?}",
12828        );
12829    }
12830
12831    #[test]
12832    fn test_type_intersection_splat_keeps_enum_pointers_enum_shaped() {
12833        // Regression: `[is Concrete].*` wraps each property in a correlated
12834        // subquery, so the outer shape saw an `IrExpr::Subquery` rather than a
12835        // column and fell back to a plain Scalar — handing back `'System'`
12836        // where a direct select gave `InterfaceAppearance.System`.
12837        let mut schema = make_interface_schema();
12838        schema.enums.push(crate::schema::EnumDescriptor {
12839            name: "Gender".into(),
12840            module: "default".into(),
12841            members: vec!["Male".into(), "Female".into()],
12842        });
12843        // An enum that lives only on the concrete type, so it can only be
12844        // reached through the intersection.
12845        let individual = schema
12846            .types
12847            .iter_mut()
12848            .find(|t| t.name == "Individual")
12849            .expect("the interface schema has an Individual type");
12850        individual.properties.push(crate::schema::PropertyDescriptor {
12851            name: "gender".into(),
12852            pg_type: r#""default"."Gender""#.into(),
12853            nullable: true,
12854            default_sql: None,
12855            default_pyql: None,
12856            description: None,
12857            check_constraints: vec![],
12858            is_exclusive: false,
12859            is_pk: false,
12860            is_readonly: false,
12861            rewrites: vec![],
12862            tuple_members: None,
12863            column_type: None,
12864        });
12865
12866        let out = compile_and_emit_with("SELECT Account { [is Individual].* }", &schema);
12867        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
12868            panic!("expected Object shape, got {:?}", out.shape.root)
12869        };
12870        assert!(
12871            pointers.iter().any(|node| matches!(
12872                node,
12873                crate::query::ShapeNode::Enum { name, enum_type, .. }
12874                    if name == "gender" && enum_type == "default::Gender"
12875            )),
12876            "expected the enum property to stay enum-shaped through the intersection, got {pointers:?}",
12877        );
12878    }
12879
12880    #[test]
12881    fn test_shallow_splat_leaves_object_valued_computeds_to_the_deep_form() {
12882        // Regression: `*` expanded every computed, link-valued ones included.
12883        // `*` is properties only; `**` adds links.
12884        // On jaldis's schema that pulled a computed multi-link
12885        // (`members := .memberships.member`) into every splat query.
12886        let mut schema = make_schema();
12887        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
12888            name: "authors".into(),
12889            expression: ".posts".into(),
12890            return_type: None,
12891            link_target: None,
12892            link_multi: false,
12893        });
12894        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
12895            name: "age_next".into(),
12896            expression: ".age + 1".into(),
12897            return_type: Some("int8".into()),
12898            link_target: None,
12899            link_multi: false,
12900        });
12901
12902        let shallow = compile_and_emit_with("SELECT Person { * }", &schema);
12903        let crate::query::ShapeNode::Object { pointers, .. } = &shallow.shape.root else {
12904            panic!("expected Object shape")
12905        };
12906        let names: Vec<&str> = pointers.iter().map(shape_pointer_name).collect();
12907        assert!(
12908            names.contains(&"age_next"),
12909            "a computed property belongs in `*`: {names:?}"
12910        );
12911        assert!(!names.contains(&"authors"), "a computed link does not: {names:?}");
12912
12913        let deep = compile_and_emit_with("SELECT Person { ** }", &schema);
12914        let crate::query::ShapeNode::Object { pointers, .. } = &deep.shape.root else {
12915            panic!("expected Object shape")
12916        };
12917        let names: Vec<&str> = pointers.iter().map(shape_pointer_name).collect();
12918        assert!(
12919            names.contains(&"authors"),
12920            "`**` includes links, computed ones included: {names:?}"
12921        );
12922    }
12923
12924    fn shape_pointer_name(node: &crate::query::ShapeNode) -> &str {
12925        use crate::query::ShapeNode;
12926        match node {
12927            ShapeNode::Scalar { name, .. }
12928            | ShapeNode::Enum { name, .. }
12929            | ShapeNode::NamedTuple { name, .. }
12930            | ShapeNode::Object { name, .. }
12931            | ShapeNode::Array { name, .. } => name,
12932            _ => "",
12933        }
12934    }
12935
12936    #[test]
12937    fn test_any_over_a_multilink_comparison_does_not_warn() {
12938        // Regression: the warning fired whether or not the comparison was
12939        // wrapped, so `any()` -- the very thing it asks for -- did not silence
12940        // it. The argument is compiled before the call is resolved, so the
12941        // suppression has to be in place while the argument is compiled.
12942        let schema = make_schema();
12943        let ast = parse::parse("SELECT Person FILTER any(.posts.title = 'x')").unwrap();
12944        let ir = ir::compile(&ast, &schema).unwrap();
12945        assert!(
12946            !ir.warnings.iter().any(|w| w.contains("FILTER clause")),
12947            "any() states the intent, so nothing should warn: {:?}",
12948            ir.warnings
12949        );
12950    }
12951
12952    #[test]
12953    fn test_bare_multilink_comparison_still_warns() {
12954        let schema = make_schema();
12955        let ast = parse::parse("SELECT Person FILTER .posts.title = 'x'").unwrap();
12956        let ir = ir::compile(&ast, &schema).unwrap();
12957        assert!(
12958            ir.warnings.iter().any(|w| w.contains("FILTER clause")),
12959            "an unwrapped set-valued comparison still warns: {:?}",
12960            ir.warnings
12961        );
12962    }
12963
12964    #[test]
12965    fn test_link_to_an_interface_expands_over_its_implementors() {
12966        // Regression: an interface's view carries only the interface's own
12967        // columns, so reading a link through it tagged every row as the
12968        // interface and hydrated `Profile` where the row was an
12969        // `IndividualProfile`. The root of a query already expands inline;
12970        // a nested link did not.
12971        let mut schema = make_interface_schema();
12972        let account = schema
12973            .types
12974            .iter_mut()
12975            .find(|t| t.name == "Account")
12976            .expect("the interface schema has an Account type");
12977        account.materialized = true;
12978        // A second implementor, so the fan-out is a real union.
12979        let mut organization = schema
12980            .types
12981            .iter()
12982            .find(|t| t.name == "Individual")
12983            .expect("the interface schema has an Individual type")
12984            .clone();
12985        organization.name = "Organization".into();
12986        organization.table = "Organization".into();
12987        organization.computed.clear();
12988        schema.types.push(organization);
12989
12990        let owner = TypeDescriptor {
12991            name: "Note".into(),
12992            module: "default".into(),
12993            table: "Note".into(),
12994            abstract_: false,
12995            materialized: false,
12996            description: None,
12997            parents: vec![],
12998            interfaces: vec![],
12999            bases: vec![],
13000            properties: vec![],
13001            links: vec![LinkDescriptor {
13002                name: "owner".into(),
13003                target: "default::Account".into(),
13004                nullable: true,
13005                description: None,
13006                default_pyql: None,
13007                is_exclusive: false,
13008                is_readonly: false,
13009                rewrites: vec![],
13010                on_delete: vec![],
13011                through: None,
13012            }],
13013            multilinks: vec![],
13014            computed: vec![],
13015            constraints: vec![],
13016            indexes: vec![],
13017            partition: None,
13018            vector_indexes: vec![],
13019            search_indexes: vec![],
13020            triggers: vec![],
13021            junction: false,
13022            signals: vec![],
13023        };
13024        schema.types.push(owner);
13025
13026        let out = compile_and_emit_with("SELECT Note { owner: { id } }", &schema);
13027        assert!(
13028            out.sql.contains("UNION ALL"),
13029            "the link's target should expand over its implementors, got:\n{}",
13030            out.sql
13031        );
13032        assert!(
13033            out.sql.contains("'default::Individual'::text AS \"__type__\""),
13034            "each branch should carry its own discriminator, got:\n{}",
13035            out.sql
13036        );
13037        assert!(
13038            !out.sql.contains("FROM \"default\".\"Account\" AS"),
13039            "the interface's own view should no longer be read directly, got:\n{}",
13040            out.sql
13041        );
13042    }
13043
13044    /// `make_interface_schema` with a second implementor of `Account`, so a
13045    /// read of it is a real union of two concrete types.
13046    fn make_two_implementor_schema() -> SchemaDescriptor {
13047        let mut schema = make_interface_schema();
13048        let mut organization = schema
13049            .types
13050            .iter()
13051            .find(|t| t.name == "Individual")
13052            .expect("the interface schema has an Individual type")
13053            .clone();
13054        organization.name = "Organization".into();
13055        organization.table = "Organization".into();
13056        organization.computed.clear();
13057        schema.types.push(organization);
13058        schema
13059    }
13060
13061    /// Every row read of an interface names its own concrete type, so none may
13062    /// be labelled with the interface's.
13063    fn assert_rows_carry_their_concrete_type(query: &str) {
13064        let out = compile_and_emit_with(query, &make_two_implementor_schema());
13065        assert!(
13066            !out.sql.contains("'default::Account'::text"),
13067            "rows are labelled with the interface instead of their own type:\n{}",
13068            out.sql
13069        );
13070        assert!(
13071            out.sql.contains("'default::Individual'::text AS \"__type__\""),
13072            "the rows should come from the implementors, each tagging its own:\n{}",
13073            out.sql
13074        );
13075    }
13076
13077    #[test]
13078    fn an_aggregated_select_of_an_interface_carries_the_concrete_type() {
13079        assert_rows_carry_their_concrete_type("SELECT (xs := array_agg((SELECT Account { id })))");
13080    }
13081
13082    #[test]
13083    fn an_aggregated_binding_of_an_interface_carries_the_concrete_type() {
13084        assert_rows_carry_their_concrete_type("WITH a := (SELECT Account) SELECT (xs := array_agg((SELECT a { id })))");
13085    }
13086
13087    #[test]
13088    fn a_single_object_read_off_a_binding_carries_the_concrete_type() {
13089        assert_rows_carry_their_concrete_type("WITH a := (SELECT Account) SELECT (x := (SELECT a { id } LIMIT 1))");
13090    }
13091
13092    #[test]
13093    fn grouped_elements_of_an_interface_carry_the_concrete_type() {
13094        assert_rows_carry_their_concrete_type("group Account { id } by .email");
13095    }
13096
13097    /// `make_two_implementor_schema` with a `Note` that links to an `Account`,
13098    /// and an `Account` that links back to a `Note`.
13099    fn make_note_schema() -> SchemaDescriptor {
13100        fn link_to(name: &str, target: &str) -> LinkDescriptor {
13101            LinkDescriptor {
13102                name: name.into(),
13103                target: target.into(),
13104                nullable: true,
13105                description: None,
13106                default_pyql: None,
13107                is_exclusive: false,
13108                is_readonly: false,
13109                rewrites: vec![],
13110                on_delete: vec![],
13111                through: None,
13112            }
13113        }
13114        let mut schema = make_two_implementor_schema();
13115        for account in schema
13116            .types
13117            .iter_mut()
13118            .filter(|t| ["Account", "Individual", "Organization"].contains(&t.name.as_str()))
13119        {
13120            account.links.push(link_to("pinned", "default::Note"));
13121        }
13122        schema.types.push(TypeDescriptor {
13123            name: "Note".into(),
13124            module: "default".into(),
13125            table: "Note".into(),
13126            abstract_: false,
13127            materialized: false,
13128            description: None,
13129            parents: vec![],
13130            interfaces: vec![],
13131            bases: vec![],
13132            properties: vec![],
13133            links: vec![link_to("owner", "default::Account")],
13134            multilinks: vec![],
13135            computed: vec![],
13136            constraints: vec![],
13137            indexes: vec![],
13138            partition: None,
13139            vector_indexes: vec![],
13140            search_indexes: vec![],
13141            triggers: vec![],
13142            junction: false,
13143            signals: vec![],
13144        });
13145        schema
13146    }
13147
13148    #[test]
13149    fn a_backlink_to_an_interface_carries_the_concrete_type() {
13150        let out = compile_and_emit_with(
13151            "SELECT Note { pinners := .<pinned[is Account] { id } }",
13152            &make_note_schema(),
13153        );
13154        assert!(
13155            !out.sql.contains("'default::Account'::text"),
13156            "the backlink's rows are labelled with the interface instead of their own type:\n{}",
13157            out.sql
13158        );
13159    }
13160
13161    #[test]
13162    fn a_link_reached_through_a_deep_splat_carries_the_concrete_type() {
13163        let out = compile_and_emit_with("SELECT Note { ** }", &make_note_schema());
13164        assert!(
13165            !out.sql.contains("'default::Account'::text"),
13166            "the splatted link's rows are labelled with the interface instead of their own type:\n{}",
13167            out.sql
13168        );
13169    }
13170
13171    #[test]
13172    fn test_path_traversal_onto_an_interface_carries_the_concrete_type() {
13173        // The hole the chained-pointer fix landed on: a *path join* onto an
13174        // interface still read the interface's view, so the rows a traversal
13175        // returned were tagged with the interface and hydrated its class. Links
13176        // fan out; this is the traversal route they share with computed chains.
13177        let mut schema = make_interface_schema();
13178        let account = schema
13179            .types
13180            .iter_mut()
13181            .find(|t| t.name == "Account")
13182            .expect("the interface schema has an Account type");
13183        account.materialized = true;
13184        let mut organization = schema
13185            .types
13186            .iter()
13187            .find(|t| t.name == "Individual")
13188            .expect("the interface schema has an Individual type")
13189            .clone();
13190        organization.name = "Organization".into();
13191        organization.table = "Organization".into();
13192        organization.computed.clear();
13193        schema.types.push(organization);
13194        schema.types.push(TypeDescriptor {
13195            name: "Note".into(),
13196            module: "default".into(),
13197            table: "Note".into(),
13198            abstract_: false,
13199            materialized: false,
13200            description: None,
13201            parents: vec![],
13202            interfaces: vec![],
13203            bases: vec![],
13204            properties: vec![],
13205            links: vec![LinkDescriptor {
13206                name: "owner".into(),
13207                target: "default::Account".into(),
13208                nullable: true,
13209                description: None,
13210                default_pyql: None,
13211                is_exclusive: false,
13212                is_readonly: false,
13213                rewrites: vec![],
13214                on_delete: vec![],
13215                through: None,
13216            }],
13217            multilinks: vec![],
13218            computed: vec![],
13219            constraints: vec![],
13220            indexes: vec![],
13221            partition: None,
13222            vector_indexes: vec![],
13223            search_indexes: vec![],
13224            triggers: vec![],
13225            junction: false,
13226            signals: vec![],
13227        });
13228
13229        let out = compile_and_emit_with("SELECT Note.owner { id }", &schema);
13230        assert!(
13231            out.sql.contains("UNION ALL") && out.sql.contains("'default::Individual'::text AS \"__type__\""),
13232            "the traversal's target should fan out over implementors, got:\n{}",
13233            out.sql
13234        );
13235        assert!(
13236            !out.sql.contains("'default::Account'::text,"),
13237            "the row's type should be read off the row, not fixed to the interface, got:\n{}",
13238            out.sql
13239        );
13240    }
13241
13242    #[test]
13243    fn test_computed_multilink_chain_comes_back_as_rows() {
13244        // Regression: `members := .memberships.member` is a chain, and
13245        // try_compile_pointer_expr only recognised a single-step `.multilink`,
13246        // so it fell through to expression position and became a scalar
13247        // subquery — which Postgres rejects the moment a second row matches.
13248        let mut schema = make_schema();
13249        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
13250            name: "coauthors".into(),
13251            expression: ".posts.author".into(),
13252            return_type: None,
13253            link_target: None,
13254            link_multi: false,
13255        });
13256        let post = schema
13257            .types
13258            .iter_mut()
13259            .find(|t| t.name == "Post")
13260            .expect("test schema has a Post type");
13261        post.links.push(LinkDescriptor {
13262            name: "author".into(),
13263            target: "default::Person".into(),
13264            nullable: true,
13265            description: None,
13266            default_pyql: None,
13267            is_exclusive: false,
13268            is_readonly: false,
13269            rewrites: vec![],
13270            on_delete: vec![],
13271            through: None,
13272        });
13273
13274        let out = compile_and_emit_with("SELECT Person { coauthors: { name } }", &schema);
13275        assert!(
13276            out.sql.contains("ARRAY(SELECT"),
13277            "the chain should aggregate into an array, got:\n{}",
13278            out.sql
13279        );
13280        assert!(
13281            out.sql.contains("\"name\""),
13282            "the requested sub-shape should survive, got:\n{}",
13283            out.sql
13284        );
13285
13286        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
13287            panic!("expected Object shape, got {:?}", out.shape.root)
13288        };
13289        let coauthors = pointers
13290            .iter()
13291            .find(|node| matches!(node, crate::query::ShapeNode::Array { name, .. } if name == "coauthors"))
13292            .unwrap_or_else(|| panic!("expected an Array-shaped pointer, got {pointers:?}"));
13293        let crate::query::ShapeNode::Array { element, .. } = coauthors else {
13294            unreachable!()
13295        };
13296        assert!(
13297            matches!(
13298                element.as_ref(),
13299                crate::query::ShapeNode::Object { type_name: Some(t), .. } if t == "default::Person"
13300            ),
13301            "the elements should hydrate as objects, got {element:?}",
13302        );
13303    }
13304
13305    #[test]
13306    fn test_multi_sort_with_then_emits_two_order_keys() {
13307        let out = compile_and_emit("SELECT Person { name } ORDER BY .name THEN .age DESC");
13308        assert!(out.sql.contains("ORDER BY"), "expected ORDER BY");
13309        // Both columns should appear in the ORDER BY clause
13310        assert!(out.sql.contains("\"name\""));
13311        assert!(out.sql.contains("\"age\""));
13312        assert!(out.sql.contains("DESC"));
13313    }
13314
13315    #[test]
13316    fn test_string_index_emits_str_subscript() {
13317        let out = compile_and_emit("SELECT 'hello'[1]");
13318        assert!(
13319            out.sql.contains("_pylon.str_subscript"),
13320            "expected _pylon.str_subscript() for string index, got:\n{}",
13321            out.sql
13322        );
13323    }
13324
13325    /// PostgreSQL forbids a set-returning function inside `VALUES`, so a loop
13326    /// over `array_unpack` has to iterate a select instead.
13327    #[test]
13328    fn test_for_over_a_set_returning_call_is_not_a_values_clause() {
13329        let out = compile_and_emit("FOR n IN array_unpack(<array<int64>>$ns) UNION (SELECT n)");
13330        assert!(
13331            !out.sql.contains("VALUES (unnest"),
13332            "a set-returning iterator must not land in VALUES, got:\n{}",
13333            out.sql
13334        );
13335        assert!(
13336            out.sql.contains("SELECT unnest"),
13337            "expected the iterator in a select list, got:\n{}",
13338            out.sql
13339        );
13340    }
13341
13342    /// The loop variable's type is the unpacked array's element type, not
13343    /// whatever the generically-declared call says.
13344    #[test]
13345    fn test_for_over_array_unpack_binds_the_element_type() {
13346        let out = compile_and_emit("FOR j IN array_unpack(<array<json>>$rows) UNION (SELECT <str>j['k'])");
13347        assert!(
13348            out.sql.contains("::jsonb AS v"),
13349            "the loop variable should carry the element type, got:\n{}",
13350            out.sql
13351        );
13352    }
13353
13354    /// A binding that holds an array has to say so, or no overload of
13355    /// `contains` accepts it and the call is reported as one nobody wrote.
13356    #[test]
13357    fn test_a_binding_keeps_its_array_type_through_an_aggregate_and_a_condition() {
13358        for query in [
13359            "WITH ids := ([<uuid>$a] IF true ELSE [<uuid>$b]) SELECT Person FILTER contains(ids, .id)",
13360            "WITH ns := array_agg((SELECT Person.name)) SELECT Person FILTER contains(ns, .name)",
13361        ] {
13362            let out = compile_and_emit(query);
13363            assert!(
13364                out.sql.contains("@> ARRAY["),
13365                "expected the array overload of contains for `{query}`, got:\n{}",
13366                out.sql
13367            );
13368        }
13369    }
13370
13371    /// Each element of an array literal is one value. A walk gathered into an
13372    /// array of its own leaves `ARRAY[uuid, uuid[]]`, which PostgreSQL
13373    /// refuses: "ARRAY types uuid and uuid[] cannot be matched".
13374    #[test]
13375    fn test_an_array_literals_walk_element_is_read_as_one_value() {
13376        let out = compile_and_emit("WITH p := (SELECT Person LIMIT 1) SELECT [<uuid>$a, p.id]");
13377        assert!(
13378            !out.sql.contains("ARRAY(SELECT"),
13379            "the element should be a scalar subquery, not an array of its own:\n{}",
13380            out.sql
13381        );
13382    }
13383
13384    /// A sub-select's own filter speaks about its subject however the outer
13385    /// select is written: ordering the walk must not re-read `.name` off what
13386    /// the walk lands on, which silently filtered the wrong table.
13387    #[test]
13388    fn test_ordering_a_walk_leaves_the_inner_filter_on_its_own_subject() {
13389        let out = compile_and_emit("SELECT (SELECT Person FILTER .name = 'x' LIMIT 1).posts { title } ORDER BY .title");
13390        assert!(
13391            out.sql.contains("WHERE (\"t0\".\"name\" = 'x')"),
13392            "the filter belongs to the Person the walk starts from, got:\n{}",
13393            out.sql
13394        );
13395        assert!(
13396            out.sql.contains("\"title\" ASC"),
13397            "the ordering belongs to the Post the walk lands on, got:\n{}",
13398            out.sql
13399        );
13400    }
13401
13402    /// A sub-select's `limit` counts its own rows. Left on the joined result
13403    /// it took one row of the walk instead — one promotion of a cart that has
13404    /// two, with nothing to say it had dropped any.
13405    #[test]
13406    fn test_a_walks_head_keeps_its_own_limit() {
13407        let out = compile_and_emit("SELECT (SELECT Person FILTER .name = 'x' LIMIT 1).posts { title }");
13408        let (head, walk) = out.sql.split_once(")\nSELECT").expect("expected a bound head");
13409        assert!(
13410            head.contains("LIMIT 1"),
13411            "the head keeps its own row count, got:\n{}",
13412            out.sql
13413        );
13414        assert!(
13415            !walk.contains("LIMIT"),
13416            "the walk keeps every row it reaches, got:\n{}",
13417            out.sql
13418        );
13419    }
13420
13421    /// An overload that declares its element type carries it whatever it was
13422    /// handed, so the body reads the loop variable as json rather than as the
13423    /// text `jsonb_array_elements` would be cast to by default.
13424    #[test]
13425    fn test_for_over_json_array_unpack_binds_json() {
13426        let out = compile_and_emit(
13427            "FOR entry IN json_array_unpack(to_json(<str>$rows)) UNION (SELECT <str>json_get(entry, 'k'))",
13428        );
13429        assert!(
13430            out.sql.contains("::jsonb AS v"),
13431            "the loop variable should carry json, got:\n{}",
13432            out.sql
13433        );
13434    }
13435
13436    /// `json_get` yields json, so casting its result to a scalar reads the
13437    /// value out of it. `::text` on its own would keep a json string's quotes.
13438    #[test]
13439    fn test_casting_json_get_reads_the_value_not_its_json_spelling() {
13440        let out = compile_and_emit("SELECT <uuid><str>json_get(<json>$p, 'id')");
13441        assert!(
13442            out.sql.contains("#>> '{}'"),
13443            "expected the json value to be read out, got:\n{}",
13444            out.sql
13445        );
13446    }
13447
13448    /// Indexing json reaches into it with `->`; neither string nor array
13449    /// subscripting applies.
13450    #[test]
13451    fn test_indexing_json_emits_a_jsonb_accessor() {
13452        let out = compile_and_emit("SELECT (<json>$payload)['label']");
13453        assert!(
13454            out.sql.contains("->'label'"),
13455            "expected a jsonb field access, got:\n{}",
13456            out.sql
13457        );
13458        assert!(
13459            !out.sql.contains("str_subscript"),
13460            "json must not be subscripted as a string, got:\n{}",
13461            out.sql
13462        );
13463    }
13464
13465    /// Casting json to a scalar yields the value, not its JSON spelling —
13466    /// `<str>` of a json string has no quotes around it.
13467    #[test]
13468    fn test_casting_json_to_a_scalar_unwraps_it() {
13469        let out = compile_and_emit("SELECT <str>to_json(<str>$s)");
13470        assert!(
13471            out.sql.contains("#>> '{}'"),
13472            "expected the json value to be extracted, got:\n{}",
13473            out.sql
13474        );
13475    }
13476
13477    /// A `with` binding that names the loop variable holds a different value
13478    /// each iteration. Evaluated once for the statement it reads an iterator
13479    /// that is not in its FROM; every read of it belongs to one iteration.
13480    #[test]
13481    fn test_a_binding_that_reads_the_loop_variable_is_keyed_by_iteration() {
13482        let out = compile_and_emit(
13483            "WITH made := (FOR p IN (SELECT Person) UNION ( \
13484               WITH mine := (SELECT p.posts LIMIT 1) \
13485               INSERT Company { name := mine.title } \
13486             )) SELECT count(made)",
13487        );
13488        assert!(
13489            out.sql.contains("\"mine\" AS (\nSELECT \"_for_p\".\"v\" AS \"_outer\""),
13490            "the binding must be evaluated per iteration, got:\n{}",
13491            out.sql
13492        );
13493        assert!(
13494            out.sql.contains("\"mine\".\"_outer\" = \"_for_p\".\"v\""),
13495            "a read of it must pin to the iteration in scope, got:\n{}",
13496            out.sql
13497        );
13498    }
13499
13500    /// A `for … union (insert …)` assigned to a multi link *inside another
13501    /// loop* writes rows per iteration. They used to be cross-joined onto
13502    /// every row the outer loop wrote; they belong to one iteration of it.
13503    #[test]
13504    fn test_a_loop_nested_in_a_loop_pairs_its_rows_by_iteration() {
13505        let out = compile_and_emit(
13506            "FOR n IN {'a', 'b'} UNION (INSERT Person { name := n, posts := (FOR t IN {n} UNION (INSERT Post { title := t })) })",
13507        );
13508        assert!(
13509            out.sql.contains("\"_outer\""),
13510            "the nested rows must carry an iteration key, got:\n{}",
13511            out.sql
13512        );
13513        assert!(
13514            !out.sql.contains("CROSS JOIN \"_nested_dml_0\""),
13515            "they must not be cross-joined onto every row, got:\n{}",
13516            out.sql
13517        );
13518    }
13519
13520    /// A multi link assigned in a `for`-bodied insert used to be dropped: the
13521    /// rows went in, the junction rows never did, and nothing reported it.
13522    #[test]
13523    fn test_for_bodied_insert_writes_its_multi_link() {
13524        let out = compile_and_emit("FOR n IN {'a', 'b'} UNION (INSERT Person { name := n, posts := (SELECT Post) })");
13525        assert!(
13526            out.sql.contains("INSERT INTO \"public\".\"Person.posts\""),
13527            "the junction rows must be written too, got:\n{}",
13528            out.sql
13529        );
13530    }
13531
13532    #[test]
13533    fn test_string_slice_emits_substr() {
13534        let out = compile_and_emit("SELECT 'hello'[1:3]");
13535        assert!(
13536            out.sql.contains("substr"),
13537            "expected substr() for string slice, got:\n{}",
13538            out.sql
13539        );
13540    }
13541
13542    #[test]
13543    fn test_array_index_emits_subscript() {
13544        let out = compile_and_emit("SELECT [1, 2, 3][1]");
13545        assert!(
13546            out.sql.contains("_pylon.array_subscript"),
13547            "expected _pylon.array_subscript() for array index, got:\n{}",
13548            out.sql
13549        );
13550    }
13551
13552    #[test]
13553    fn test_index_into_array_returning_function_emits_array_subscript() {
13554        let out = compile_and_emit("SELECT str_split('a::b', '::')[-1]");
13555        assert!(
13556            out.sql.contains("_pylon.array_subscript(string_to_array"),
13557            "expected _pylon.array_subscript() over str_split, got:\n{}",
13558            out.sql
13559        );
13560    }
13561
13562    #[test]
13563    fn test_array_slice_emits_subscript() {
13564        let out = compile_and_emit("SELECT [1, 2, 3][0:2]");
13565        assert!(
13566            !out.sql.contains("substr"),
13567            "should not use substr for array, got:\n{}",
13568            out.sql
13569        );
13570        assert!(out.sql.contains(")["), "expected array slice syntax, got:\n{}", out.sql);
13571    }
13572
13573    #[test]
13574    fn test_open_ended_string_slice_emits_substr_no_length() {
13575        let out = compile_and_emit("SELECT 'hello'[2:]");
13576        // substr(expr, start) without length argument
13577        assert!(out.sql.contains("substr"), "expected substr(), got:\n{}", out.sql);
13578        // Should NOT have a third argument (length)
13579        let substr_idx = out.sql.find("substr").unwrap();
13580        let after = &out.sql[substr_idx..];
13581        let commas = after.chars().take_while(|&c| c != ')').filter(|&c| c == ',').count();
13582        assert_eq!(commas, 1, "open-ended slice should use 2-arg substr, got:\n{}", out.sql);
13583    }
13584
13585    #[test]
13586    fn test_group_by_single_key() {
13587        let out = compile_and_emit("group Person { name } by .age");
13588        // GROUP BY clause present
13589        assert!(out.sql.contains("GROUP BY"), "expected GROUP BY, got:\n{}", out.sql);
13590        // Key column referenced
13591        assert!(out.sql.contains("\"age\""), "expected age column, got:\n{}", out.sql);
13592        // array_agg for elements
13593        assert!(
13594            out.sql.contains("array_agg(ROW("),
13595            "expected array_agg, got:\n{}",
13596            out.sql
13597        );
13598        // grouping names array
13599        assert!(
13600            out.sql.contains("ARRAY['age']"),
13601            "expected grouping array, got:\n{}",
13602            out.sql
13603        );
13604        // shape node is Group
13605        assert!(matches!(out.shape.root, crate::query::ShapeNode::Group { .. }));
13606        if let crate::query::ShapeNode::Group {
13607            key_nodes,
13608            grouping_position,
13609            elements_position,
13610            ..
13611        } = &out.shape.root
13612        {
13613            assert_eq!(key_nodes.len(), 1);
13614            assert!(matches!(&key_nodes[0], crate::query::ShapeNode::Scalar { name, position: 1 } if name == "age"));
13615            assert_eq!(*grouping_position, 2);
13616            assert_eq!(*elements_position, 3);
13617        }
13618    }
13619
13620    #[test]
13621    fn test_group_using_alias() {
13622        let out = compile_and_emit("group Person using decade := .age // 10 by decade");
13623        assert!(out.sql.contains("GROUP BY"), "expected GROUP BY, got:\n{}", out.sql);
13624        assert!(
13625            out.sql.contains("ARRAY['decade']"),
13626            "expected grouping array, got:\n{}",
13627            out.sql
13628        );
13629        if let crate::query::ShapeNode::Group { key_nodes, .. } = &out.shape.root {
13630            assert_eq!(key_nodes.len(), 1);
13631            assert!(matches!(&key_nodes[0], crate::query::ShapeNode::Scalar { name, .. } if name == "decade"));
13632        }
13633    }
13634
13635    #[test]
13636    fn test_group_orders_elements_within_each_group() {
13637        let out = compile_and_emit("group Person { name } by .age order by .name desc");
13638        assert!(
13639            out.sql.contains(")::record ORDER BY \"t0\".\"name\" DESC NULLS LAST)"),
13640            "{}",
13641            out.sql
13642        );
13643        assert!(!out.sql.contains("row_number()"), "{}", out.sql);
13644    }
13645
13646    #[test]
13647    fn test_group_limit_trims_each_group_not_the_result() {
13648        // The newest row per key: a trailing LIMIT would drop whole groups,
13649        // so the rows are ranked within each key instead.
13650        let out = compile_and_emit("group Person { name } by .age order by .name desc limit 1");
13651        assert!(
13652            out.sql
13653                .contains("row_number() OVER (PARTITION BY \"t0\".\"age\" ORDER BY \"t0\".\"name\" DESC NULLS LAST)"),
13654            "{}",
13655            out.sql
13656        );
13657        assert!(
13658            out.sql.contains("WHERE \"__rk\" > 0 AND \"__rk\" <= 0 + 1"),
13659            "{}",
13660            out.sql
13661        );
13662        assert!(!out.sql.contains("\nLIMIT"), "{}", out.sql);
13663    }
13664
13665    #[test]
13666    fn test_group_by_two_keys_with_a_per_group_limit() {
13667        let out = compile_and_emit(
13668            "group Person { name } by .age, .name filter .age > 18 order by .name desc offset 1 limit 2",
13669        );
13670        assert!(
13671            out.sql.contains("PARTITION BY \"t0\".\"age\", \"t0\".\"name\""),
13672            "{}",
13673            out.sql
13674        );
13675        // The filter picks which rows are grouped, so it sits inside the
13676        // ranking subquery — ranking must not see rows the filter excluded.
13677        let where_pos = out.sql.find("WHERE (\"t0\".\"age\" > 18)").expect("filter");
13678        let rank_pos = out.sql.find("WHERE \"__rk\"").expect("rank filter");
13679        assert!(where_pos < rank_pos, "{}", out.sql);
13680        assert!(
13681            out.sql.contains("GROUP BY \"t0\".\"age\", \"t0\".\"name\""),
13682            "{}",
13683            out.sql
13684        );
13685    }
13686
13687    #[test]
13688    fn test_group_filter_without_a_limit_is_a_plain_where() {
13689        let out = compile_and_emit("group Person { name } by .age filter .age > 18");
13690        assert!(out.sql.contains("WHERE (\"t0\".\"age\" > 18)"), "{}", out.sql);
13691        assert!(!out.sql.contains("row_number()"), "{}", out.sql);
13692    }
13693
13694    #[test]
13695    fn test_abs_path_concat_same_type() {
13696        let out = compile_and_emit("SELECT Person.name ++ ' ' ++ Person.name");
13697        assert!(out.sql.contains("\"name\""), "expected name column, got:\n{}", out.sql);
13698        assert!(out.sql.contains("||"), "expected concat operator, got:\n{}", out.sql);
13699        assert!(out.sql.contains("FROM"), "expected FROM clause, got:\n{}", out.sql);
13700    }
13701
13702    #[test]
13703    fn test_abs_path_single_property() {
13704        let out = compile_and_emit("SELECT Person.name");
13705        assert!(out.sql.contains("\"name\""), "expected name column, got:\n{}", out.sql);
13706        assert!(out.sql.contains("FROM"), "expected FROM clause, got:\n{}", out.sql);
13707    }
13708
13709    #[test]
13710    fn test_pgvector_cast_emits_vector_type() {
13711        let out = compile_and_emit("SELECT <pgvector::vector>[1.0, 2.0, 3.0]");
13712        assert!(
13713            out.sql.contains("::vector"),
13714            "expected ::vector cast, got:\n{}",
13715            out.sql
13716        );
13717        assert!(out.sql.contains("ARRAY["), "expected ARRAY literal, got:\n{}", out.sql);
13718    }
13719
13720    #[test]
13721    fn test_pgvector_euclidean_distance_emits_l2_operator() {
13722        let out = compile_and_emit(
13723            "SELECT pgvector::euclidean_distance(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
13724        );
13725        assert!(out.sql.contains("<->"), "expected <-> operator, got:\n{}", out.sql);
13726    }
13727
13728    #[test]
13729    fn test_pgvector_cosine_distance_emits_cosine_operator() {
13730        let out = compile_and_emit(
13731            "SELECT pgvector::cosine_distance(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
13732        );
13733        assert!(out.sql.contains("<=>"), "expected <=> operator, got:\n{}", out.sql);
13734    }
13735
13736    #[test]
13737    fn test_pgvector_neg_inner_product_emits_ip_operator() {
13738        let out = compile_and_emit(
13739            "SELECT pgvector::neg_inner_product(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
13740        );
13741        assert!(out.sql.contains("<#>"), "expected <#> operator, got:\n{}", out.sql);
13742    }
13743
13744    #[test]
13745    fn test_pgvector_inner_product_negates_ip_operator() {
13746        let out = compile_and_emit(
13747            "SELECT pgvector::inner_product(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
13748        );
13749        assert!(out.sql.contains("<#>"), "expected <#> operator, got:\n{}", out.sql);
13750        assert!(out.sql.contains("0.0"), "expected negation of <#>, got:\n{}", out.sql);
13751    }
13752
13753    #[test]
13754    fn test_crypto_digest_str_and_bytes_overloads_both_use_pgcrypto_digest() {
13755        let out = compile_and_emit("SELECT crypto::digest('hello', 'sha256')");
13756        assert!(
13757            out.sql.contains("digest("),
13758            "expected pgcrypto's digest(), got:\n{}",
13759            out.sql
13760        );
13761
13762        let out = compile_and_emit("SELECT crypto::digest(std::from_hex('68656c6c6f'), 'sha256')");
13763        assert!(
13764            out.sql.contains("digest("),
13765            "expected pgcrypto's digest(), got:\n{}",
13766            out.sql
13767        );
13768    }
13769
13770    #[test]
13771    fn test_crypto_hmac_str_and_bytes_overloads_both_use_pgcrypto_hmac() {
13772        let out = compile_and_emit("SELECT crypto::hmac('hello', 'key', 'sha256')");
13773        assert!(
13774            out.sql.contains("hmac("),
13775            "expected pgcrypto's hmac(), got:\n{}",
13776            out.sql
13777        );
13778
13779        let out =
13780            compile_and_emit("SELECT crypto::hmac(std::from_hex('68656c6c6f'), std::from_hex('6b6579'), 'sha256')");
13781        assert!(
13782            out.sql.contains("hmac("),
13783            "expected pgcrypto's hmac(), got:\n{}",
13784            out.sql
13785        );
13786    }
13787
13788    #[test]
13789    fn test_crypto_gen_salt_zero_arg_defaults_to_blowfish() {
13790        let out = compile_and_emit("SELECT crypto::gen_salt()");
13791        assert!(
13792            out.sql.contains("gen_salt('bf')"),
13793            "expected default 'bf' salt type, got:\n{}",
13794            out.sql
13795        );
13796    }
13797
13798    #[test]
13799    fn test_crypto_gen_salt_one_arg_passes_type_through() {
13800        let out = compile_and_emit("SELECT crypto::gen_salt('xdes')");
13801        assert!(
13802            out.sql.contains("gen_salt("),
13803            "expected gen_salt() call, got:\n{}",
13804            out.sql
13805        );
13806    }
13807
13808    #[test]
13809    fn test_crypto_gen_salt_iter_count_casts_to_int4() {
13810        let out = compile_and_emit("SELECT crypto::gen_salt('xdes', 5)");
13811        assert!(
13812            out.sql.contains("::int4"),
13813            "expected int8 -> int4 narrowing cast, got:\n{}",
13814            out.sql
13815        );
13816    }
13817
13818    #[test]
13819    fn test_crypto_crypt_uses_pgcrypto_crypt() {
13820        let out = compile_and_emit("SELECT crypto::crypt('hunter2', crypto::gen_salt())");
13821        assert!(
13822            out.sql.contains("crypt("),
13823            "expected pgcrypto's crypt(), got:\n{}",
13824            out.sql
13825        );
13826    }
13827
13828    #[test]
13829    fn test_postgis_cast_emits_geometry_type() {
13830        let out = compile_and_emit("SELECT <postgis::geometry>'POINT(1 2)'");
13831        assert!(
13832            out.sql.contains("::geometry"),
13833            "expected ::geometry cast, got:\n{}",
13834            out.sql
13835        );
13836    }
13837
13838    #[test]
13839    fn test_postgis_x_uses_st_x_builtin() {
13840        let out = compile_and_emit("SELECT postgis::x(<postgis::geometry>'POINT(1 2)')");
13841        assert!(out.sql.contains("st_x("), "expected st_x() call, got:\n{}", out.sql);
13842    }
13843
13844    #[test]
13845    fn test_postgis_area_geometry_and_geography_overloads() {
13846        let out = compile_and_emit("SELECT postgis::area(<postgis::geometry>'POINT(1 2)')");
13847        assert!(
13848            out.sql.contains("st_area("),
13849            "expected st_area() call, got:\n{}",
13850            out.sql
13851        );
13852
13853        let out = compile_and_emit("SELECT postgis::area(<postgis::geography>'POINT(1 2)', true)");
13854        assert!(
13855            out.sql.contains("st_area("),
13856            "expected st_area() call, got:\n{}",
13857            out.sql
13858        );
13859    }
13860
13861    #[test]
13862    fn test_postgis_setsrid_casts_int64_arg_to_int4() {
13863        let out = compile_and_emit("SELECT postgis::setsrid(<postgis::geometry>'POINT(1 2)', 4326)");
13864        assert!(
13865            out.sql.contains("st_setsrid("),
13866            "expected st_setsrid() call, got:\n{}",
13867            out.sql
13868        );
13869        assert!(
13870            out.sql.contains("::int4"),
13871            "expected int8 -> int4 narrowing cast, got:\n{}",
13872            out.sql
13873        );
13874    }
13875
13876    #[test]
13877    fn test_postgis_quantizecoordinates_default_arity_variants_compile() {
13878        // The reference binding documents this with 3 trailing optional
13879        // params; Pylon has no notion of default args, so each arity is
13880        // its own registered overload — confirm both the 2-arg and 4-arg
13881        // forms resolve.
13882        let out = compile_and_emit("SELECT postgis::quantizecoordinates(<postgis::geometry>'POINT(1 2)', 5)");
13883        assert!(out.sql.contains("st_quantizecoordinates("), "got:\n{}", out.sql);
13884
13885        let out = compile_and_emit("SELECT postgis::quantizecoordinates(<postgis::geometry>'POINT(1 2)', 5, 5, 5)");
13886        assert!(out.sql.contains("st_quantizecoordinates("), "got:\n{}", out.sql);
13887    }
13888
13889    #[test]
13890    fn test_postgis_op_contains_emits_infix_operator_not_function_call() {
13891        // Regression: ImplStrategy::SqlOperator was never actually consulted
13892        // by resolve_fn_call — it fell through to the generic "schema.name(args)"
13893        // FunctionCall path, which would have emitted a nonexistent
13894        // `"postgis".op_contains(...)` call instead of the `~` operator.
13895        let out = compile_and_emit(
13896            "SELECT postgis::op_contains(<postgis::geometry>'POINT(1 2)', <postgis::geometry>'POINT(3 4)')",
13897        );
13898        assert!(out.sql.contains(" ~ "), "expected infix ~ operator, got:\n{}", out.sql);
13899        assert!(
13900            !out.sql.contains("op_contains("),
13901            "must not call a literal op_contains function, got:\n{}",
13902            out.sql
13903        );
13904    }
13905
13906    #[test]
13907    fn test_postgis_op_overlaps_geometry_and_geography_overloads() {
13908        let out = compile_and_emit(
13909            "SELECT postgis::op_overlaps(<postgis::geometry>'POINT(1 2)', <postgis::geometry>'POINT(3 4)')",
13910        );
13911        assert!(
13912            out.sql.contains(" && "),
13913            "expected infix && operator, got:\n{}",
13914            out.sql
13915        );
13916
13917        let out = compile_and_emit(
13918            "SELECT postgis::op_overlaps(<postgis::geography>'POINT(1 2)', <postgis::geography>'POINT(3 4)')",
13919        );
13920        assert!(
13921            out.sql.contains(" && "),
13922            "expected infix && operator, got:\n{}",
13923            out.sql
13924        );
13925    }
13926
13927    // ── User-defined function tests ───────────────────────────────────────────
13928
13929    fn make_schema_with_fns() -> SchemaDescriptor {
13930        let mut s = make_schema();
13931        s.functions = vec![
13932            FunctionDescriptor {
13933                name: "mysum".into(),
13934                module: "default".into(),
13935                params: vec![
13936                    FunctionParamDescriptor {
13937                        name: "a".into(),
13938                        pg_type: "int8".into(),
13939                    },
13940                    FunctionParamDescriptor {
13941                        name: "b".into(),
13942                        pg_type: "int8".into(),
13943                    },
13944                ],
13945                return_pg_type: "int8".into(),
13946                return_is_object: false,
13947                return_is_set: false,
13948                return_is_polymorphic: false,
13949                volatility: "immutable".into(),
13950                body: "a + b".into(),
13951            },
13952            FunctionDescriptor {
13953                name: "adults".into(),
13954                module: "default".into(),
13955                params: vec![],
13956                return_pg_type: "default::Person".into(),
13957                return_is_object: true,
13958                return_is_set: true,
13959                return_is_polymorphic: false,
13960                volatility: "stable".into(),
13961                body: "select Person filter .age > 18".into(),
13962            },
13963            // A second, 3-arg overload of `mysum`, declared *before* the
13964            // 2-arg one is looked up by resolution below — regression for a
13965            // bug where call resolution grabbed the first name-matching
13966            // overload regardless of arity, instead of searching all
13967            // same-named overloads for one whose param count actually
13968            // matches the call site.
13969            FunctionDescriptor {
13970                name: "mysum".into(),
13971                module: "default".into(),
13972                params: vec![
13973                    FunctionParamDescriptor {
13974                        name: "a".into(),
13975                        pg_type: "int8".into(),
13976                    },
13977                    FunctionParamDescriptor {
13978                        name: "b".into(),
13979                        pg_type: "int8".into(),
13980                    },
13981                    FunctionParamDescriptor {
13982                        name: "c".into(),
13983                        pg_type: "int8".into(),
13984                    },
13985                ],
13986                return_pg_type: "int8".into(),
13987                return_is_object: false,
13988                return_is_set: false,
13989                return_is_polymorphic: false,
13990                volatility: "immutable".into(),
13991                body: "a + b + c".into(),
13992            },
13993        ];
13994        s
13995    }
13996
13997    #[test]
13998    fn test_user_fn_scalar_call() {
13999        let schema = make_schema_with_fns();
14000        let out = compile_and_emit_with("SELECT mysum(1, 2)", &schema);
14001        assert!(out.sql.contains("\"public\".\"mysum\""), "got:\n{}", out.sql);
14002    }
14003
14004    #[test]
14005    fn test_user_fn_overload_resolved_by_argument_count() {
14006        // Regression: with a 2-arg and a 3-arg `mysum` overload both
14007        // declared, a 3-arg call must resolve to the 3-arg overload rather
14008        // than erroring against whichever overload happens to be first in
14009        // `schema.functions`.
14010        let schema = make_schema_with_fns();
14011        let out = compile_and_emit_with("SELECT mysum(1, 2, 3)", &schema);
14012        assert!(
14013            out.sql
14014                .contains("\"public\".\"mysum\"((1)::int8, (2)::int8, (3)::int8)"),
14015            "got:\n{}",
14016            out.sql,
14017        );
14018
14019        // The 2-arg call must still resolve to the 2-arg overload.
14020        let out = compile_and_emit_with("SELECT mysum(1, 2)", &schema);
14021        assert!(
14022            out.sql.contains("\"public\".\"mysum\"((1)::int8, (2)::int8)"),
14023            "got:\n{}",
14024            out.sql,
14025        );
14026    }
14027
14028    #[test]
14029    fn test_user_fn_object_select_no_shape() {
14030        let schema = make_schema_with_fns();
14031        let out = compile_and_emit_with("SELECT adults()", &schema);
14032        assert!(out.sql.contains("\"public\".\"adults\"()"), "got:\n{}", out.sql);
14033        assert!(out.sql.contains("FROM"), "got:\n{}", out.sql);
14034    }
14035
14036    #[test]
14037    fn test_user_fn_object_select_with_shape() {
14038        let schema = make_schema_with_fns();
14039        let out = compile_and_emit_with("SELECT adults() { name }", &schema);
14040        assert!(out.sql.contains("\"public\".\"adults\"()"), "got:\n{}", out.sql);
14041        assert!(out.sql.contains("\"name\""), "got:\n{}", out.sql);
14042    }
14043
14044    #[test]
14045    fn test_object_fn_projected_to_a_property_inside_an_expression() {
14046        let schema = make_schema_with_fns();
14047        let out = compile_and_emit_with("SELECT Person { n := adults().name }", &schema);
14048        assert!(out.sql.contains("FROM \"public\".\"adults\"() AS"), "got:\n{}", out.sql);
14049        assert!(out.sql.contains("\".\"name\"\n"), "got:\n{}", out.sql);
14050    }
14051
14052    #[test]
14053    fn test_object_fn_sub_select_carries_its_modifiers() {
14054        let schema = make_schema_with_fns();
14055        let out = compile_and_emit_with(
14056            "SELECT Person { n := (select adults() filter .age > 21 limit 1).name }",
14057            &schema,
14058        );
14059        assert!(out.sql.contains("FROM \"public\".\"adults\"() AS"), "got:\n{}", out.sql);
14060        assert!(out.sql.contains("\"age\" > 21"), "got:\n{}", out.sql);
14061        assert!(out.sql.contains("LIMIT 1"), "got:\n{}", out.sql);
14062    }
14063
14064    #[test]
14065    fn test_object_fn_without_a_property_still_explains_the_restriction() {
14066        let schema = make_schema_with_fns();
14067        let ast = parse::parse("SELECT Person { n := adults() + 1 }").unwrap();
14068        let err = match ir::compile(&ast, &schema) {
14069            Ok(_) => panic!("expected a compile error"),
14070            Err(e) => format!("{e}"),
14071        };
14072        assert!(err.contains("returns objects"), "{err}");
14073    }
14074
14075    #[test]
14076    fn test_object_fn_projected_to_an_unknown_property_is_rejected() {
14077        let schema = make_schema_with_fns();
14078        let ast = parse::parse("SELECT Person { n := adults().nope }").unwrap();
14079        let err = match ir::compile(&ast, &schema) {
14080            Ok(_) => panic!("expected a compile error"),
14081            Err(e) => format!("{e}"),
14082        };
14083        assert!(err.contains("nope"), "{err}");
14084    }
14085
14086    #[test]
14087    fn test_user_fn_in_cte_exposes_raw_columns() {
14088        // Regression: FunctionSelect as a CTE source must emit SELECT * FROM fn()
14089        // so the outer query can reference raw columns like t1.age.
14090        let schema = make_schema_with_fns();
14091        let out = compile_and_emit_with("WITH persons := adults() SELECT persons FILTER .age > 25", &schema);
14092        assert!(
14093            out.sql.contains("SELECT * FROM \"public\".\"adults\"()"),
14094            "CTE source must be SELECT * FROM fn(), got:\n{}",
14095            out.sql,
14096        );
14097        assert!(
14098            out.sql.contains("\"age\""),
14099            "outer filter must reference raw column, got:\n{}",
14100            out.sql
14101        );
14102    }
14103
14104    // ── vector::search tests ──────────────────────────────────────────────────
14105
14106    fn make_schema_with_vector() -> SchemaDescriptor {
14107        use crate::schema::VectorIndexDescriptor;
14108        let mut s = make_schema();
14109        if let Some(td) = s.types.iter_mut().find(|t| t.name == "Person") {
14110            td.vector_indexes.push(VectorIndexDescriptor {
14111                index_name: None,
14112                pointers: vec!["name".into()],
14113                model: "test-embed".into(),
14114                metric: "cosine".into(),
14115                dimensions: 4,
14116            });
14117        }
14118        s
14119    }
14120
14121    #[test]
14122    fn test_vector_search_bare_type_name() {
14123        let schema = make_schema_with_vector();
14124        let out = compile_and_emit_with(
14125            "WITH search := vector::search(Person, <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
14126             SELECT search { object { name }, distance }",
14127            &schema,
14128        );
14129        assert!(
14130            out.sql.contains("\"Person\""),
14131            "expected Person table, got:\n{}",
14132            out.sql
14133        );
14134        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
14135    }
14136
14137    #[test]
14138    fn test_vector_search_qualified_type_name() {
14139        let schema = make_schema_with_vector();
14140        let out = compile_and_emit_with(
14141            "WITH search := vector::search(default::Person, <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
14142             SELECT search { object { name }, distance }",
14143            &schema,
14144        );
14145        assert!(
14146            out.sql.contains("\"Person\""),
14147            "expected Person table, got:\n{}",
14148            out.sql
14149        );
14150        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
14151    }
14152
14153    #[test]
14154    fn test_vector_search_subquery_filter_included_in_where() {
14155        let schema = make_schema_with_vector();
14156        let out = compile_and_emit_with(
14157            "WITH search := vector::search((select Person filter .name = 'Alice'), <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
14158             SELECT search { object { name }, distance }",
14159            &schema,
14160        );
14161        assert!(
14162            out.sql.contains("\"Person\""),
14163            "expected Person table, got:\n{}",
14164            out.sql
14165        );
14166        assert!(out.sql.contains("\"name\""), "expected name filter, got:\n{}", out.sql);
14167        assert!(out.sql.contains("Alice"), "expected filter value, got:\n{}", out.sql);
14168        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
14169    }
14170
14171    #[test]
14172    fn test_vector_search_subquery_filter_combined_with_outer_property_filter() {
14173        let schema = make_schema_with_vector();
14174        let out = compile_and_emit_with(
14175            "WITH search := vector::search((select Person filter .age > 18), <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
14176             SELECT search { object { name }, distance }",
14177            &schema,
14178        );
14179        assert!(
14180            out.sql.contains("\"age\""),
14181            "expected age pre-filter, got:\n{}",
14182            out.sql
14183        );
14184        assert!(out.sql.contains("18"), "expected filter value 18, got:\n{}", out.sql);
14185        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
14186    }
14187
14188    #[test]
14189    fn test_vector_search_text_overload_with_subquery_filter() {
14190        let schema = make_schema_with_vector();
14191        let out = compile_and_emit_with(
14192            "WITH search := vector::search((select Person filter .name = 'Alice'), query := $q) \
14193             SELECT search { object { name }, distance }",
14194            &schema,
14195        );
14196        assert!(
14197            out.sql.contains("\"Person\""),
14198            "expected Person table, got:\n{}",
14199            out.sql
14200        );
14201        assert!(
14202            out.sql.contains("Alice"),
14203            "expected pre-filter value, got:\n{}",
14204            out.sql
14205        );
14206        assert!(
14207            out.sql.contains("float8[]"),
14208            "expected float8[] cast for deferred vec param, got:\n{}",
14209            out.sql
14210        );
14211        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
14212    }
14213
14214    #[test]
14215    fn test_count_type_ref_compiles_to_agg_over_query() {
14216        let out = compile_and_emit("SELECT count(Person)");
14217        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
14218        assert!(
14219            out.sql.contains("\"Person\""),
14220            "expected Person table, got:\n{}",
14221            out.sql
14222        );
14223    }
14224
14225    #[test]
14226    fn test_count_qualified_type_ref_compiles_to_agg_over_query() {
14227        let out = compile_and_emit("SELECT count(default::Person)");
14228        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
14229        assert!(
14230            out.sql.contains("\"Person\""),
14231            "expected Person table, got:\n{}",
14232            out.sql
14233        );
14234    }
14235
14236    #[test]
14237    fn test_count_subquery_compiles_to_agg_over_query() {
14238        let out = compile_and_emit("SELECT count((select Person))");
14239        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
14240        assert!(
14241            out.sql.contains("\"Person\""),
14242            "expected Person table, got:\n{}",
14243            out.sql
14244        );
14245    }
14246
14247    #[test]
14248    fn test_count_subquery_with_filter() {
14249        let out = compile_and_emit("SELECT count((select Person filter .name = 'Alice'))");
14250        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
14251        assert!(
14252            out.sql.contains("\"name\""),
14253            "expected filter on name, got:\n{}",
14254            out.sql
14255        );
14256    }
14257
14258    #[test]
14259    fn test_positional_param_compiles_to_dollar_n() {
14260        let out = compile_and_emit("SELECT Person FILTER .name = $0");
14261        assert!(out.sql.contains("$1"), "expected $1 placeholder, got:\n{}", out.sql);
14262    }
14263
14264    #[test]
14265    fn test_multiple_positional_params_compile_in_order() {
14266        let out = compile_and_emit("SELECT Person FILTER .name = $0 AND .age > $1");
14267        assert!(out.sql.contains("$1"), "expected $1, got:\n{}", out.sql);
14268        assert!(out.sql.contains("$2"), "expected $2, got:\n{}", out.sql);
14269    }
14270
14271    #[test]
14272    fn test_repeated_positional_param_reuses_slot() {
14273        let out = compile_and_emit("SELECT Person FILTER .name = $0 OR .name = $0");
14274        assert_eq!(
14275            out.sql.matches("$1").count(),
14276            2,
14277            "both uses must reference $1, got:\n{}",
14278            out.sql
14279        );
14280    }
14281
14282    #[test]
14283    fn test_cast_to_nonexistent_type_names_full_type() {
14284        let schema = make_schema();
14285        let ast = parse::parse("SELECT Person FILTER .name = <default::Ghost>$name").unwrap();
14286        match ir::compile(&ast, &schema) {
14287            Ok(_) => panic!("expected compile error for unknown type"),
14288            Err(e) => {
14289                let msg = e.to_string();
14290                assert!(
14291                    msg.contains("unknown type 'default::Ghost'"),
14292                    "expected full type name in error, got: {msg}",
14293                );
14294            }
14295        }
14296    }
14297
14298    #[test]
14299    fn test_cast_to_nonexistent_unqualified_type_names_type() {
14300        let schema = make_schema();
14301        let ast = parse::parse("SELECT Person FILTER .name = <Ghost>$name").unwrap();
14302        match ir::compile(&ast, &schema) {
14303            Ok(_) => panic!("expected compile error for unknown type"),
14304            Err(e) => {
14305                let msg = e.to_string();
14306                assert!(
14307                    msg.contains("unknown type 'Ghost'"),
14308                    "expected type name in error, got: {msg}",
14309                );
14310            }
14311        }
14312    }
14313
14314    #[test]
14315    fn test_top_level_cast_to_registered_scalar_uses_its_own_domain() {
14316        // A bare top-level `SELECT <module::Scalar>expr` must compile as a
14317        // plain scalar cast to that scalar's own DOMAIN — not get misrouted
14318        // to `compile_schema_cast_select`'s object-by-id lookup (which
14319        // treated any non-stdlib-module named cast target as an object type,
14320        // since it never checked `resolve_scalar` before falling through).
14321        use crate::schema::ScalarDescriptor;
14322        let mut schema = make_schema();
14323        schema.scalars.push(ScalarDescriptor {
14324            name: "Email".into(),
14325            module: "default".into(),
14326            base: "Str".into(),
14327            pg_type: "text".into(),
14328            check_constraints: vec!["value ~ '^[^@]+@[^@]+\\.[^@]+$'".into()],
14329            is_sequence: false,
14330        });
14331        let out = compile_and_emit_with("SELECT <default::Email>'test@test.de'", &schema);
14332        assert!(
14333            out.sql.contains("\"public\".\"Email\""),
14334            "expected a cast to the scalar's own domain, got:\n{}",
14335            out.sql
14336        );
14337        assert!(
14338            !out.sql.to_lowercase().contains("\"person\""),
14339            "must not be misrouted to an object-type lookup, got:\n{}",
14340            out.sql
14341        );
14342    }
14343
14344    #[test]
14345    fn test_top_level_cast_to_unknown_type_names_full_type() {
14346        // The object-cast fallback (`compile_schema_cast_select`) used to
14347        // discard the module before building its synthetic path, so an
14348        // unknown top-level cast target reported a bare name even when
14349        // written with an explicit module.
14350        let schema = make_schema();
14351        let ast = parse::parse("SELECT <default::Ghost>$name").unwrap();
14352        match ir::compile(&ast, &schema) {
14353            Ok(_) => panic!("expected compile error for unknown type"),
14354            Err(e) => {
14355                let msg = e.to_string();
14356                assert!(
14357                    msg.contains("unknown type 'default::Ghost'"),
14358                    "expected full type name in error, got: {msg}",
14359                );
14360            }
14361        }
14362    }
14363
14364    #[test]
14365    fn test_structural_tuple_cast_unnamed_resolves_to_jsonb() {
14366        let out = compile_and_emit("SELECT <tuple<str, bool>>$p");
14367        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
14368    }
14369
14370    #[test]
14371    fn test_jsonb_to_uuid_cast_extracts_via_text() {
14372        // PostgreSQL has no native jsonb -> uuid cast; extract raw text then cast.
14373        let out = compile_and_emit("SELECT <uuid>(<json>$p)");
14374        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
14375        assert!(out.sql.contains("::uuid"), "got:\n{}", out.sql);
14376    }
14377
14378    #[test]
14379    fn test_jsonb_to_datetime_cast_extracts_via_text() {
14380        let out = compile_and_emit("SELECT <datetime>(<json>$p)");
14381        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
14382        assert!(out.sql.contains("::timestamptz"), "got:\n{}", out.sql);
14383    }
14384
14385    #[test]
14386    fn test_jsonb_to_duration_cast_extracts_via_text() {
14387        let out = compile_and_emit("SELECT <duration>(<json>$p)");
14388        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
14389        assert!(out.sql.contains("::interval"), "got:\n{}", out.sql);
14390    }
14391
14392    #[test]
14393    fn test_jsonb_to_array_cast_unpacks_each_element() {
14394        let out = compile_and_emit("SELECT <array<int64>>(<json>$p)");
14395        assert!(out.sql.contains("jsonb_array_elements("), "got:\n{}", out.sql);
14396        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
14397        assert!(out.sql.contains("::int8"), "got:\n{}", out.sql);
14398    }
14399
14400    #[test]
14401    fn test_non_jsonb_cast_is_unaffected_by_jsonb_extraction() {
14402        // A plain str -> uuid cast (source isn't jsonb) must still use the
14403        // ordinary `::pg_type` path, not the jsonb-extraction template.
14404        let out = compile_and_emit("SELECT <uuid>$p");
14405        assert!(!out.sql.contains("#>>"), "got:\n{}", out.sql);
14406        assert!(out.sql.contains("::uuid"), "got:\n{}", out.sql);
14407    }
14408
14409    #[test]
14410    fn test_datetime_plus_duration_type_checks() {
14411        // Regression: types_compatible's bucket-matching (same type, or
14412        // both-int, or both-float) rejected this outright, even though
14413        // Postgres supports `timestamptz + interval` natively and this is
14414        // a legitimate operator overload.
14415        let out = compile_and_emit("SELECT <datetime>$p + <duration>$d");
14416        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
14417    }
14418
14419    #[test]
14420    fn test_duration_plus_datetime_type_checks_either_order() {
14421        let out = compile_and_emit("SELECT <duration>$d + <datetime>$p");
14422        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
14423    }
14424
14425    #[test]
14426    fn test_local_date_plus_duration_type_checks() {
14427        let out = compile_and_emit("SELECT <cal::local_date>$p + <duration>$d");
14428        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
14429    }
14430
14431    #[test]
14432    fn test_datetime_minus_duration_type_checks() {
14433        let out = compile_and_emit("SELECT <datetime>$p - <duration>$d");
14434        assert!(out.sql.contains(" - "), "got:\n{}", out.sql);
14435    }
14436
14437    #[test]
14438    fn test_local_time_plus_duration_type_checks() {
14439        let out = compile_and_emit("SELECT <cal::local_time>$p + <duration>$d");
14440        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
14441    }
14442
14443    #[test]
14444    fn test_datetime_plus_duration_only_allowed_for_add_and_sub() {
14445        // Comparing (or otherwise combining) a datetime and a duration with
14446        // anything other than +/- still isn't meaningful and must still error.
14447        let schema = make_schema();
14448        let ast = parse::parse("SELECT <datetime>$p = <duration>$d").unwrap();
14449        match ir::compile(&ast, &schema) {
14450            Err(err) => assert!(format!("{err}").contains("cannot be applied"), "got: {err}"),
14451            Ok(_) => panic!("expected a compile error"),
14452        }
14453    }
14454
14455    #[test]
14456    fn test_int_and_float_mix_is_allowed() {
14457        // Every int width implicitly casts to every float width, so mixed
14458        // int/float arithmetic type-checks without an explicit cast —
14459        // Postgres's own operator resolution handles the actual promotion.
14460        let out = compile_and_emit("SELECT <int16>1 + <float32>2.0");
14461        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
14462    }
14463
14464    #[test]
14465    fn test_int_and_decimal_mix_is_allowed() {
14466        // int64 -> bigint -> decimal is a separate implicit-cast branch
14467        // from int64 -> float32 -> float64, but every int width still
14468        // mixes with numeric/decimal directly.
14469        let out = compile_and_emit("SELECT <int64>1 + <decimal>2.0");
14470        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
14471    }
14472
14473    #[test]
14474    fn test_float_and_decimal_mix_still_rejected() {
14475        // float and numeric/decimal are separate branches past int64 in
14476        // the implicit-cast graph and don't cast to each other — this
14477        // still needs an explicit cast.
14478        let schema = make_schema();
14479        let ast = parse::parse("SELECT <decimal>$p + <float64>$n").unwrap();
14480        match ir::compile(&ast, &schema) {
14481            Err(err) => assert!(format!("{err}").contains("cannot be applied"), "got: {err}"),
14482            Ok(_) => panic!("expected a compile error"),
14483        }
14484    }
14485
14486    #[test]
14487    fn test_unknown_pointer_suggests_a_close_match() {
14488        // `Person` has a `name` property (see make_schema) — `nam` is close
14489        // enough (missing one trailing character) that a "Did you mean"
14490        // suggestion should fire.
14491        let schema = make_schema();
14492        let ast = parse::parse("SELECT Person { nam }").unwrap();
14493        match ir::compile(&ast, &schema) {
14494            Err(err) => assert_eq!(
14495                format!("{err}"),
14496                "object type 'default::Person' has no link or property 'nam'. Did you mean 'name'?"
14497            ),
14498            Ok(_) => panic!("expected a compile error"),
14499        }
14500    }
14501
14502    #[test]
14503    fn test_unrelated_unknown_pointer_gets_no_suggestion() {
14504        // A typo with no plausible match on the type shouldn't force a
14505        // confusing, unrelated suggestion onto the user.
14506        let schema = make_schema();
14507        let ast = parse::parse("SELECT Person { xyzxyzxyz }").unwrap();
14508        match ir::compile(&ast, &schema) {
14509            Err(err) => {
14510                let msg = format!("{err}");
14511                assert!(msg.contains("has no link or property 'xyzxyzxyz'"), "got: {msg}");
14512                assert!(!msg.contains("Did you mean"), "got: {msg}");
14513            }
14514            Ok(_) => panic!("expected a compile error"),
14515        }
14516    }
14517
14518    #[test]
14519    fn test_multilink_sourced_backlink_exists_filter_compiles() {
14520        // Regression for a gap live-execution testing caught: `filter exists
14521        // .<multilink[is Type]` (a backlink whose source pointer on the
14522        // target type is a multi-link, e.g. `Person.posts`, not a single
14523        // FK link) used to fail to compile with "has no link ... pointing
14524        // to" — `compile_backlink_as_exists` only ever checked
14525        // `target_td.links`, never `target_td.multilinks`. Fast SQL-text
14526        // companion to the live-execution test in
14527        // `tests/live_execution_backlinks.rs`, which additionally confirms
14528        // the emitted SQL returns the right rows.
14529        let out = compile_and_emit("SELECT Post filter exists .<posts[is Person]");
14530        assert!(out.sql.contains("EXISTS"), "got:\n{}", out.sql);
14531    }
14532
14533    #[test]
14534    fn test_backlink_as_computed_shape_pointer_compiles() {
14535        // Regression for the bigger gap live-execution testing caught: a
14536        // backlink could only be used in `filter exists .<...>` — using one
14537        // to select/project actual objects as a computed pointer's value
14538        // (`authors := .<posts[is Person] { name }`) failed to compile at
14539        // all ("shapes and set literals are not valid in expression
14540        // context"). `compile_shape_element`'s computed-pointer dispatch
14541        // now recognizes a backlink-rooted `Expr::Shape`/`Expr::Path` RHS
14542        // and routes it through `compile_backlink_pointer`, mirroring
14543        // `compile_multilink_pointer` for the forward direction. Fast
14544        // SQL-text companion to the live-execution tests in
14545        // `tests/live_execution_backlinks.rs`, which additionally confirm
14546        // the emitted SQL returns the right rows, nested 2 levels deep.
14547        let out = compile_and_emit("SELECT Post { title, authors := .<posts[is Person] { name } }");
14548        assert!(out.sql.contains("array_agg(ROW("), "got:\n{}", out.sql);
14549    }
14550
14551    fn schema_with_exclusive_posts() -> SchemaDescriptor {
14552        let mut schema = make_schema();
14553        for td in schema.types.iter_mut().filter(|td| td.name == "Person") {
14554            for ml in td.multilinks.iter_mut().filter(|ml| ml.name == "posts") {
14555                ml.is_exclusive = true;
14556            }
14557        }
14558        schema
14559    }
14560
14561    #[test]
14562    fn test_backlink_through_an_exclusive_multilink_is_single() {
14563        let out = compile_and_emit_with(
14564            "SELECT Post { title, author := .<posts[is Person] { name } }",
14565            &schema_with_exclusive_posts(),
14566        );
14567        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
14568            panic!("expected an object root, got {:?}", out.shape.root)
14569        };
14570        assert!(
14571            matches!(pointers.last(), Some(ShapeNode::Object { name, .. }) if name == "author"),
14572            "got {pointers:?}"
14573        );
14574    }
14575
14576    #[test]
14577    fn test_a_path_through_an_exclusive_backlink_is_single() {
14578        let out = compile_and_emit_with(
14579            "SELECT Post { title, author_name := .<posts[is Person].name }",
14580            &schema_with_exclusive_posts(),
14581        );
14582        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
14583            panic!("expected an object root, got {:?}", out.shape.root)
14584        };
14585        assert!(
14586            matches!(pointers.last(), Some(ShapeNode::Scalar { name, .. }) if name == "author_name"),
14587            "got {pointers:?}"
14588        );
14589        assert!(!out.sql.contains("ARRAY("), "got:\n{}", out.sql);
14590    }
14591
14592    #[test]
14593    fn test_structural_tuple_cast_named_resolves_to_jsonb() {
14594        let out = compile_and_emit("SELECT <tuple<x: float64, y: float64>>$p");
14595        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
14596    }
14597
14598    #[test]
14599    fn test_structural_tuple_cast_nested_resolves_to_jsonb() {
14600        let out = compile_and_emit("SELECT <tuple<point: tuple<x: float64, y: float64>, label: str>>$p");
14601        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
14602    }
14603
14604    #[test]
14605    fn test_nominal_named_tuple_cast_resolves_to_jsonb() {
14606        let mut schema = make_schema();
14607        schema.named_tuples.push(NamedTupleDescriptor {
14608            name: "Point".into(),
14609            module: "default".into(),
14610            members: vec![],
14611        });
14612        let out = compile_and_emit_with("SELECT <default::Point>$p", &schema);
14613        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
14614    }
14615
14616    #[test]
14617    fn test_array_literal_cast_resolves_to_native_pg_array_not_jsonb() {
14618        // The exact query reported as failing — must compile now, and must
14619        // resolve to a real Postgres array (text[]), never jsonb (arrays
14620        // decode natively, unlike tuples).
14621        let out = compile_and_emit("SELECT <array<str>>['foo', 'bar']");
14622        assert!(
14623            out.sql.contains("::text[]") || out.sql.contains("ARRAY["),
14624            "got:\n{}",
14625            out.sql
14626        );
14627        assert!(
14628            !out.sql.contains("jsonb"),
14629            "arrays must not use jsonb, got:\n{}",
14630            out.sql
14631        );
14632    }
14633
14634    #[test]
14635    fn test_array_literal_cast_applies_per_element_cast() {
14636        // Each element gets its own real cast, not a raw untyped ARRAY[...] —
14637        // '1' and '2' must actually coerce to int8 (mirrors the equivalent
14638        // tuple test).
14639        let out = compile_and_emit("SELECT <array<int64>>['1', '2']");
14640        assert!(out.sql.contains("ARRAY[('1')::int8, ('2')::int8]"), "got:\n{}", out.sql);
14641    }
14642
14643    #[test]
14644    fn test_array_param_cast_uses_direct_suffix_cast() {
14645        let out = compile_and_emit("SELECT <array<int64>>$p");
14646        assert!(out.sql.contains("::int8[]"), "got:\n{}", out.sql);
14647    }
14648
14649    #[test]
14650    fn test_array_of_named_tuple_element_casts_to_jsonb_array() {
14651        // An array's element type can be anything except another array —
14652        // including a structural tuple, which still resolves that one
14653        // element to jsonb while the array itself stays a native pg array.
14654        let out = compile_and_emit("SELECT <array<tuple<x: float64, y: float64>>>$p");
14655        assert!(out.sql.contains("::jsonb[]"), "got:\n{}", out.sql);
14656    }
14657
14658    #[test]
14659    fn test_contains_on_array_literal_cast_uses_array_overload_not_strpos() {
14660        // Regression: contains()'s array<Any> overload only matched a bare
14661        // IrExpr::Array node, never a TypeCast-wrapped one — which is what
14662        // every `<array<T>>[...]` cast actually compiles to. Overload
14663        // resolution silently fell back to the *first* registered `contains`
14664        // overload (str, str), emitting a bogus `strpos(text[], text)` call.
14665        let out = compile_and_emit("SELECT contains(<array<str>>[1, 2], '2')");
14666        assert!(out.sql.contains("@> ARRAY["), "got:\n{}", out.sql);
14667        assert!(
14668            !out.sql.contains("strpos"),
14669            "must not fall back to the str/str overload, got:\n{}",
14670            out.sql
14671        );
14672    }
14673
14674    #[test]
14675    fn test_nested_array_type_rejected_at_parse_time() {
14676        match parse::parse("SELECT <array<array<str>>>$p") {
14677            Ok(_) => panic!("expected parse error for nested array type"),
14678            Err(e) => assert!(e.to_string().contains("nested arrays are not supported"), "got: {}", e),
14679        }
14680    }
14681
14682    #[test]
14683    fn test_array_cast_in_computed_shape_field_schema_bound_context() {
14684        // Schema-bound counterpart (compile_expr, not compile_free_expr) — a
14685        // computed shape field casting an array literal.
14686        let out = compile_and_emit("SELECT Person { name, tags := <array<str>>['a', 'b'] }");
14687        assert!(out.sql.contains("ARRAY[('a')::text, ('b')::text]"), "got:\n{}", out.sql);
14688    }
14689
14690    #[test]
14691    fn test_nominal_named_tuple_cast_shape_carries_real_members() {
14692        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
14693        let mut schema = make_schema();
14694        schema.named_tuples.push(NamedTupleDescriptor {
14695            name: "Point".into(),
14696            module: "default".into(),
14697            members: vec![
14698                TupleMemberDescriptor {
14699                    name: Some("x".into()),
14700                    kind: TupleMemberKind::Scalar {
14701                        pg_type: "float8".into(),
14702                    },
14703                },
14704                TupleMemberDescriptor {
14705                    name: Some("y".into()),
14706                    kind: TupleMemberKind::Scalar {
14707                        pg_type: "float8".into(),
14708                    },
14709                },
14710            ],
14711        });
14712        let out = compile_and_emit_with("SELECT <default::Point>$p", &schema);
14713        match &out.shape.root {
14714            crate::query::ShapeNode::NamedTuple { type_name, members, .. } => {
14715                assert_eq!(type_name.as_deref(), Some("default::Point"));
14716                let members = members.as_ref().expect("expected resolved members");
14717                assert_eq!(members.len(), 2);
14718                assert_eq!(members[0].key.as_deref(), Some("x"));
14719                assert_eq!(members[1].key.as_deref(), Some("y"));
14720            }
14721            other => panic!("expected ShapeNode::NamedTuple, got {other:?}"),
14722        }
14723    }
14724
14725    #[test]
14726    fn test_structural_tuple_property_read_shape_carries_real_members() {
14727        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
14728        let schema = SchemaDescriptor {
14729            types: vec![TypeDescriptor {
14730                name: "Person".into(),
14731                module: "default".into(),
14732                table: "Person".into(),
14733                abstract_: false,
14734                materialized: false,
14735                description: None,
14736                parents: vec![],
14737                interfaces: vec![],
14738                bases: vec![],
14739                properties: vec![PropertyDescriptor {
14740                    name: "address".into(),
14741                    pg_type: "jsonb".into(),
14742                    nullable: true,
14743                    default_sql: None,
14744                    default_pyql: None,
14745                    description: None,
14746                    check_constraints: vec![],
14747                    is_exclusive: false,
14748                    is_pk: false,
14749                    is_readonly: false,
14750                    rewrites: vec![],
14751                    tuple_members: Some(vec![
14752                        TupleMemberDescriptor {
14753                            name: Some("street".into()),
14754                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
14755                        },
14756                        TupleMemberDescriptor {
14757                            name: Some("zip".into()),
14758                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
14759                        },
14760                    ]),
14761                    column_type: None,
14762                }],
14763                links: vec![],
14764                multilinks: vec![],
14765                computed: vec![],
14766                constraints: vec![],
14767                indexes: vec![],
14768                partition: None,
14769                vector_indexes: vec![],
14770                search_indexes: vec![],
14771                triggers: vec![],
14772                junction: false,
14773                signals: vec![],
14774            }],
14775            scalars: vec![],
14776            enums: vec![],
14777            named_tuples: vec![],
14778            globals: vec![],
14779            functions: vec![],
14780            aliases: vec![],
14781            channels: vec![],
14782            ..Default::default()
14783        };
14784        let out = compile_and_emit_with("SELECT Person { address }", &schema);
14785        assert!(out.sql.contains("::jsonb"), "got:\n{}", out.sql);
14786        match &out.shape.root {
14787            crate::query::ShapeNode::Object { pointers, .. } => {
14788                let address = pointers
14789                    .iter()
14790                    .find(|p| matches!(p, crate::query::ShapeNode::NamedTuple { name, .. } if name == "address"))
14791                    .expect("expected address pointer in shape");
14792                match address {
14793                    crate::query::ShapeNode::NamedTuple { type_name, members, .. } => {
14794                        assert_eq!(*type_name, None);
14795                        let members = members.as_ref().expect("expected resolved members");
14796                        assert_eq!(members.len(), 2);
14797                        assert_eq!(members[0].key.as_deref(), Some("street"));
14798                        assert_eq!(members[1].key.as_deref(), Some("zip"));
14799                    }
14800                    other => panic!("expected NamedTuple, got {other:?}"),
14801                }
14802            }
14803            other => panic!("expected ShapeNode::Object, got {other:?}"),
14804        }
14805    }
14806
14807    #[test]
14808    fn test_bare_path_select_structural_tuple_property_shape_carries_real_members() {
14809        // A bare `select Type.property` path traversal (no `{ }` shape) used
14810        // to fall back to `ShapeNode::Scalar` for a *structural* tuple
14811        // property — `emit_path_select`'s IrPathResult::Scalar branch only
14812        // detected a *nominal* named tuple (via the "__nt__:" pg_type
14813        // marker), never consulting the property's own `tuple_members`. This
14814        // decoded every row as an opaque blob instead of a proper tuple
14815        // literal (reported as `select default::Person.address` rendering
14816        // wrong in both pylon-ui and the CLI REPL, while a `{ address }`
14817        // shape query — the test above — already worked).
14818        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
14819        let schema = SchemaDescriptor {
14820            types: vec![TypeDescriptor {
14821                name: "Person".into(),
14822                module: "default".into(),
14823                table: "Person".into(),
14824                abstract_: false,
14825                materialized: false,
14826                description: None,
14827                parents: vec![],
14828                interfaces: vec![],
14829                bases: vec![],
14830                properties: vec![PropertyDescriptor {
14831                    name: "address".into(),
14832                    pg_type: "jsonb".into(),
14833                    nullable: true,
14834                    default_sql: None,
14835                    default_pyql: None,
14836                    description: None,
14837                    check_constraints: vec![],
14838                    is_exclusive: false,
14839                    is_pk: false,
14840                    is_readonly: false,
14841                    rewrites: vec![],
14842                    tuple_members: Some(vec![
14843                        TupleMemberDescriptor {
14844                            name: Some("street".into()),
14845                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
14846                        },
14847                        TupleMemberDescriptor {
14848                            name: Some("zip".into()),
14849                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
14850                        },
14851                    ]),
14852                    column_type: None,
14853                }],
14854                links: vec![],
14855                multilinks: vec![],
14856                computed: vec![],
14857                constraints: vec![],
14858                indexes: vec![],
14859                partition: None,
14860                vector_indexes: vec![],
14861                search_indexes: vec![],
14862                triggers: vec![],
14863                junction: false,
14864                signals: vec![],
14865            }],
14866            scalars: vec![],
14867            enums: vec![],
14868            named_tuples: vec![],
14869            globals: vec![],
14870            functions: vec![],
14871            aliases: vec![],
14872            channels: vec![],
14873            ..Default::default()
14874        };
14875        let out = compile_and_emit_with("SELECT Person.address", &schema);
14876        match &out.shape.root {
14877            crate::query::ShapeNode::NamedTuple { type_name, members, .. } => {
14878                assert_eq!(*type_name, None);
14879                let members = members.as_ref().expect("expected resolved members");
14880                assert_eq!(members.len(), 2);
14881                assert_eq!(members[0].key.as_deref(), Some("street"));
14882                assert_eq!(members[1].key.as_deref(), Some("zip"));
14883            }
14884            other => panic!("expected ShapeNode::NamedTuple, got {other:?}"),
14885        }
14886    }
14887
14888    #[test]
14889    fn test_path_traversal_into_structural_tuple_property() {
14890        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
14891        let schema = SchemaDescriptor {
14892            types: vec![TypeDescriptor {
14893                name: "Person".into(),
14894                module: "default".into(),
14895                table: "Person".into(),
14896                abstract_: false,
14897                materialized: false,
14898                description: None,
14899                parents: vec![],
14900                interfaces: vec![],
14901                bases: vec![],
14902                properties: vec![PropertyDescriptor {
14903                    name: "address".into(),
14904                    pg_type: "jsonb".into(),
14905                    nullable: true,
14906                    default_sql: None,
14907                    default_pyql: None,
14908                    description: None,
14909                    check_constraints: vec![],
14910                    is_exclusive: false,
14911                    is_pk: false,
14912                    is_readonly: false,
14913                    rewrites: vec![],
14914                    tuple_members: Some(vec![
14915                        TupleMemberDescriptor {
14916                            name: Some("street".into()),
14917                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
14918                        },
14919                        TupleMemberDescriptor {
14920                            name: Some("zip".into()),
14921                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
14922                        },
14923                    ]),
14924                    column_type: None,
14925                }],
14926                links: vec![],
14927                multilinks: vec![],
14928                computed: vec![],
14929                constraints: vec![],
14930                indexes: vec![],
14931                partition: None,
14932                vector_indexes: vec![],
14933                search_indexes: vec![],
14934                triggers: vec![],
14935                junction: false,
14936                signals: vec![],
14937            }],
14938            scalars: vec![],
14939            enums: vec![],
14940            named_tuples: vec![],
14941            globals: vec![],
14942            functions: vec![],
14943            aliases: vec![],
14944            channels: vec![],
14945            ..Default::default()
14946        };
14947        // Regression: a structural (unnamed) tuple property previously failed
14948        // path traversal with "'address' is a scalar property, not a link —
14949        // cannot traverse further" because compile_path_select only allowed
14950        // further traversal for the nominal `__nt__:` named-tuple marker,
14951        // ignoring `tuple_members` on plain jsonb-typed properties.
14952        let out = compile_and_emit_with("SELECT default::Person.address.street", &schema);
14953        assert!(out.sql.contains("\"address\"->'street'"), "got:\n{}", out.sql);
14954    }
14955
14956    #[test]
14957    fn test_structural_tuple_cast_shape_carries_real_members() {
14958        let out = compile_and_emit("SELECT <tuple<street: str, zip: str>>$p");
14959        match &out.shape.root {
14960            crate::query::ShapeNode::NamedTuple { type_name, members, .. } => {
14961                assert_eq!(*type_name, None);
14962                let members = members.as_ref().expect("expected resolved members");
14963                assert_eq!(members.len(), 2);
14964                assert_eq!(members[0].key.as_deref(), Some("street"));
14965                assert_eq!(members[1].key.as_deref(), Some("zip"));
14966            }
14967            other => panic!("expected ShapeNode::NamedTuple, got {other:?}"),
14968        }
14969    }
14970
14971    #[test]
14972    fn test_tuple_cast_mixed_named_and_unnamed_elements_rejected() {
14973        match parse::parse("SELECT <tuple<x: float64, bool>>$p") {
14974            Ok(_) => panic!("expected parse error for mixed named/unnamed tuple elements"),
14975            Err(e) => assert!(e.to_string().contains("all named or all unnamed"), "got: {}", e),
14976        }
14977    }
14978
14979    #[test]
14980    fn test_is_with_tuple_type_rejected() {
14981        let schema = make_schema();
14982        let ast = parse::parse("SELECT Person FILTER Person is tuple<x: float64, y: float64>").unwrap();
14983        match ir::compile(&ast, &schema) {
14984            Ok(_) => panic!("expected error for IS with a tuple type"),
14985            Err(e) => {
14986                assert!(
14987                    e.to_string().contains("cannot use IS with a tuple or array type"),
14988                    "got: {}",
14989                    e
14990                );
14991            }
14992        }
14993    }
14994
14995    #[test]
14996    fn test_is_with_array_type_rejected() {
14997        let schema = make_schema();
14998        let ast = parse::parse("SELECT Person FILTER Person is array<str>").unwrap();
14999        match ir::compile(&ast, &schema) {
15000            Ok(_) => panic!("expected error for IS with an array type"),
15001            Err(e) => {
15002                assert!(
15003                    e.to_string().contains("cannot use IS with a tuple or array type"),
15004                    "got: {}",
15005                    e
15006                );
15007            }
15008        }
15009    }
15010
15011    #[test]
15012    fn test_tuple_index_on_non_literal_falls_back_to_jsonb_index() {
15013        // `.1` only constant-folds against a literal tuple; anything else
15014        // (a $param, a cast result, …) needs a genuine runtime jsonb index
15015        // instead of erroring "only supported on tuple literals".
15016        let out = compile_and_emit("SELECT (<tuple<int64, str>>('1', 3)).1");
15017        assert!(out.sql.contains("->1"), "got:\n{}", out.sql);
15018    }
15019
15020    #[test]
15021    fn test_tuple_index_out_of_bounds_on_cast_target_errors_at_compile_time() {
15022        // Regression: `.2` on a 2-element tuple cast silently fell back to a
15023        // runtime jsonb index (returning null) instead of failing, because
15024        // the source is a TypeCast, not a literal Tuple/NamedTuple. The cast's
15025        // target type is statically known here, so this must bounds-check
15026        // and error instead.
15027        let ast = parse::parse("SELECT (<tuple<int64, str>>('1', 3)).2").unwrap();
15028        let schema = make_schema();
15029        match ir::compile(&ast, &schema) {
15030            Ok(_) => panic!("expected out-of-bounds tuple index error"),
15031            Err(e) => {
15032                assert!(
15033                    e.to_string()
15034                        .contains("2 is not a member of tuple<std::int64, std::str>"),
15035                    "got: {}",
15036                    e
15037                );
15038            }
15039        }
15040    }
15041
15042    #[test]
15043    fn test_positional_tuple_literal_cast_to_tuple_type_compiles() {
15044        // Each element must be cast to its own declared type — '1' isn't
15045        // silently jsonb-wrapped unchanged as a string; it's coerced to
15046        // int8.
15047        let out = compile_and_emit("SELECT <tuple<int64, str>>(1, 'x')");
15048        assert!(
15049            out.sql.contains("jsonb_build_array((1)::int8, ('x')::text)"),
15050            "got:\n{}",
15051            out.sql
15052        );
15053    }
15054
15055    #[test]
15056    fn test_positional_tuple_literal_cast_coerces_mismatched_literal_types() {
15057        // The exact case that was silently wrong: casting a string literal to
15058        // int64 and an int literal to str must actually coerce each one, not
15059        // just pass the raw literal through untouched.
15060        let out = compile_and_emit("SELECT <tuple<int64, str>>('1', 3)");
15061        assert!(
15062            out.sql.contains("jsonb_build_array(('1')::int8, (3)::text)"),
15063            "got:\n{}",
15064            out.sql
15065        );
15066    }
15067
15068    #[test]
15069    fn test_nested_tuple_literal_cast_applies_casts_recursively() {
15070        let out = compile_and_emit(
15071            "SELECT <tuple<point: tuple<x: float64, y: float64>, label: str>>(point := ('1', 2), label := 5)",
15072        );
15073        assert!(
15074            out.sql.contains(
15075                "jsonb_build_object('point', jsonb_build_object('x', ('1')::float8, 'y', (2)::float8), 'label', (5)::text)"
15076            ),
15077            "got:\n{}",
15078            out.sql
15079        );
15080    }
15081
15082    #[test]
15083    fn test_positional_tuple_literal_nested_inside_named_tuple_compiles() {
15084        let out = compile_and_emit("SELECT (point := (1, 2), label := 'origin')");
15085        assert!(out.sql.contains("jsonb_build_array(1, 2)"), "got:\n{}", out.sql);
15086        assert!(out.sql.contains("jsonb_build_object("), "got:\n{}", out.sql);
15087    }
15088
15089    #[test]
15090    fn test_positional_tuple_literal_in_schema_bound_shape_field_compiles() {
15091        let out = compile_and_emit("SELECT Person { name, pair := (1, 2) }");
15092        assert!(out.sql.contains("jsonb_build_array(1, 2)"), "got:\n{}", out.sql);
15093    }
15094
15095    // ── search index outbox enqueue tests ─────────────────────────────────────
15096    //
15097    // Regression coverage for a real bug: `collect_search_enqueue` used to
15098    // filter for `SearchBackend::OpenSearch` only, so a Meilisearch-backed
15099    // SearchIndex never got an outbox row on insert/update/delete — the
15100    // Meilisearch worker (Rust or the old Python one) never received real
15101    // traffic from normal DML, only from a manually-inserted outbox row.
15102
15103    fn make_schema_with_search_index(backend: crate::schema::SearchBackend) -> SchemaDescriptor {
15104        use crate::schema::{SearchIndexDescriptor, SearchPointerDescriptor, SearchWeight};
15105        let mut s = make_schema();
15106        if let Some(td) = s.types.iter_mut().find(|t| t.name == "Person") {
15107            td.search_indexes.push(SearchIndexDescriptor {
15108                index_name: None,
15109                backend,
15110                pointers: vec![SearchPointerDescriptor {
15111                    name: "name".into(),
15112                    weight: SearchWeight::A,
15113                }],
15114            });
15115        }
15116        s
15117    }
15118
15119    #[test]
15120    fn test_insert_enqueues_a_meilisearch_outbox_row() {
15121        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Meilisearch);
15122        let out = compile_and_emit_with("INSERT Person { name := 'Alice', age := 30 }", &schema);
15123        assert!(
15124            out.sql.contains("'Meilisearch'::_pylon.\"IndexKind\""),
15125            "expected a Meilisearch outbox enqueue CTE, got:\n{}",
15126            out.sql,
15127        );
15128        assert!(
15129            out.sql.contains("INSERT INTO _pylon.\"IndexOutbox\""),
15130            "got:\n{}",
15131            out.sql
15132        );
15133    }
15134
15135    #[test]
15136    fn test_insert_enqueues_an_opensearch_outbox_row() {
15137        let schema = make_schema_with_search_index(crate::schema::SearchBackend::OpenSearch);
15138        let out = compile_and_emit_with("INSERT Person { name := 'Alice', age := 30 }", &schema);
15139        assert!(
15140            out.sql.contains("'OpenSearch'::_pylon.\"IndexKind\""),
15141            "expected an OpenSearch outbox enqueue CTE, got:\n{}",
15142            out.sql,
15143        );
15144    }
15145
15146    #[test]
15147    fn test_insert_does_not_enqueue_an_outbox_row_for_a_postgres_backed_search_index() {
15148        // Postgres-backed search indexes are maintained synchronously by a
15149        // trigger-updated tsvector column — no async worker involved.
15150        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Postgres);
15151        let out = compile_and_emit_with("INSERT Person { name := 'Alice', age := 30 }", &schema);
15152        assert!(
15153            !out.sql.contains("_pylon.\"IndexOutbox\""),
15154            "did not expect an outbox enqueue, got:\n{}",
15155            out.sql
15156        );
15157    }
15158
15159    #[test]
15160    fn test_update_enqueues_a_meilisearch_outbox_row() {
15161        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Meilisearch);
15162        let out = compile_and_emit_with("UPDATE Person FILTER .name = 'Alice' SET { age := 31 }", &schema);
15163        assert!(
15164            out.sql.contains("'Meilisearch'::_pylon.\"IndexKind\""),
15165            "expected a Meilisearch outbox enqueue CTE, got:\n{}",
15166            out.sql,
15167        );
15168    }
15169
15170    #[test]
15171    fn test_delete_enqueues_a_meilisearch_outbox_delete_job() {
15172        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Meilisearch);
15173        let out = compile_and_emit_with("DELETE Person FILTER .name = 'Alice'", &schema);
15174        assert!(
15175            out.sql.contains("'Meilisearch'::_pylon.\"IndexKind\""),
15176            "expected a Meilisearch outbox enqueue CTE, got:\n{}",
15177            out.sql,
15178        );
15179        assert!(
15180            out.sql.contains("'delete'"),
15181            "expected the delete operation literal, got:\n{}",
15182            out.sql
15183        );
15184    }
15185
15186    #[test]
15187    fn test_range_intrinsic_resolves_int_literals_to_int8range() {
15188        // Regression: std::range's ImplStrategy::TranspilerIntrinsic had no
15189        // actual substitution anywhere — it fell through resolve_fn_call's
15190        // catch-all, which emitted a literal (nonexistent) `"std"."range"(...)`
15191        // call instead of a real PostgreSQL range constructor.
15192        let out = compile_and_emit("SELECT std::overlaps(std::range(1, 3), std::range(2, 5))");
15193        assert!(out.sql.contains("int8range(1, 3)"), "got:\n{}", out.sql);
15194        assert!(out.sql.contains("int8range(2, 5)"), "got:\n{}", out.sql);
15195        assert!(
15196            out.sql.contains(" && "),
15197            "expected infix && for overlaps, got:\n{}",
15198            out.sql
15199        );
15200        assert!(
15201            !out.sql.contains("\"std\""),
15202            "must not emit a literal std schema call, got:\n{}",
15203            out.sql
15204        );
15205    }
15206
15207    #[test]
15208    fn test_range_intrinsic_resolves_datetime_to_tstzrange() {
15209        let out =
15210            compile_and_emit("SELECT std::range(<datetime>'2024-01-01T00:00:00Z', <datetime>'2024-06-01T00:00:00Z')");
15211        assert!(out.sql.contains("tstzrange("), "got:\n{}", out.sql);
15212    }
15213
15214    /// Bounds written out as literals are folded into the constructor's own
15215    /// bounds string; only the default `[)` leaves it out entirely.
15216    #[test]
15217    fn test_range_intrinsic_named_bounds_become_a_bounds_string() {
15218        let out = compile_and_emit("SELECT std::range(1, 3, inc_lower := true, inc_upper := true)");
15219        assert!(out.sql.contains("int8range(1, 3, '[]')"), "got:\n{}", out.sql);
15220        let out = compile_and_emit("SELECT std::range(1, 3, inc_lower := false)");
15221        assert!(out.sql.contains("int8range(1, 3, '()')"), "got:\n{}", out.sql);
15222    }
15223
15224    /// A bound flag that is not a literal keeps the decision in the SQL.
15225    #[test]
15226    fn test_range_intrinsic_computes_a_bounds_string_from_an_expression() {
15227        let out = compile_and_emit("SELECT std::range(1, 3, inc_upper := <bool>$closed)");
15228        assert!(out.sql.contains("CASE WHEN"), "got:\n{}", out.sql);
15229    }
15230
15231    /// An open-ended range names only the bound it has.
15232    #[test]
15233    fn test_range_intrinsic_leaves_a_missing_upper_bound_unbounded() {
15234        let out = compile_and_emit("SELECT std::range(1)");
15235        assert!(out.sql.contains("int8range(1, NULL)"), "got:\n{}", out.sql);
15236    }
15237
15238    /// A call that writes a named-only argument by position, one that leaves a
15239    /// required one out, and one no overload accepts each say which.
15240    #[test]
15241    fn test_a_named_only_parameter_is_reported_as_named_only() {
15242        assert!(
15243            compile_err("SELECT std::range(1, 5, true, false)").contains("takes 'inc_lower' as a named argument only"),
15244            "{}",
15245            compile_err("SELECT std::range(1, 5, true, false)")
15246        );
15247        assert!(
15248            compile_err("SELECT std::json_set(std::to_json('{}'), 'a', std::to_json('1'))")
15249                .contains("requires the named argument 'value'"),
15250            "{}",
15251            compile_err("SELECT std::json_set(std::to_json('{}'), 'a', std::to_json('1'))")
15252        );
15253        // Counted by positions, not by the whole parameter list.
15254        assert!(
15255            compile_err("SELECT std::range()").contains("takes 1 or 2 argument(s), got 0"),
15256            "{}",
15257            compile_err("SELECT std::range()")
15258        );
15259        // And an overload's named-only parameters are spelled as such.
15260        let message = compile_err("SELECT std::json_get(std::to_json('{}'), 'a', 1, 2)");
15261        assert!(message.contains("default := optional<json>"), "{message}");
15262    }
15263
15264    #[test]
15265    fn test_range_intrinsic_builds_the_empty_range() {
15266        let out = compile_and_emit("SELECT std::range(1, 3, empty := true)");
15267        assert!(out.sql.contains("'empty'::int8range"), "got:\n{}", out.sql);
15268    }
15269
15270    #[test]
15271    fn test_multirange_intrinsic_resolves_from_range_element() {
15272        let out = compile_and_emit("SELECT std::multirange([std::range(1, 3), std::range(5, 7)])");
15273        assert!(out.sql.contains("int8multirange(VARIADIC "), "got:\n{}", out.sql);
15274        assert!(
15275            !out.sql.contains("\"std\""),
15276            "must not emit a literal std schema call, got:\n{}",
15277            out.sql
15278        );
15279    }
15280}