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(&binding_outer_key(cte_name)),
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(IrPathJoin::target)
450        .find(|target| target.alias == alias)
451}
452
453/// The type discriminator for a row from `src`: the fanned-out column when the
454/// source reads from implementors, else the one type its table holds.
455fn source_type_disc(src: &IrSource) -> String {
456    match source_fanout(src) {
457        Some(_) => format!("{}.\"__type__\"", qi(&src.alias)),
458        None => type_disc(&src.type_name),
459    }
460}
461
462// ── Polymorphic UNION ALL ───────────────────────────────────────────────────
463
464fn emit_poly_union(implementors: &[IrPolyImplementor], columns: &[String]) -> String {
465    let col_list = columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
466    implementors
467        .iter()
468        .map(|imp| {
469            format!(
470                "    SELECT {}::text AS \"__type__\", {} FROM {}",
471                sql_str(&imp.type_name),
472                col_list,
473                qn(&imp.module, &imp.table),
474            )
475        })
476        .collect::<Vec<_>>()
477        .join("\n    UNION ALL\n")
478}
479
480// ── SELECT statement ────────────────────────────────────────────────────────
481
482fn emit_select_stmt(sel: &IrSelect, ctes: &[IrCteDef]) -> SqlOutput {
483    match sel.rows.as_slice() {
484        [IrRowSource::Bound { source, shape }] => emit_bound_select(sel, source, shape),
485        rows if rows.len() > 1 && rows.iter().all(|r| matches!(r, IrRowSource::Bound { .. })) => {
486            emit_bound_union_select(sel, rows)
487        }
488        rows => emit_free_rows(sel, rows, ctes),
489    }
490}
491
492/// The branches of `select (a union b)` all carry the same object type, so
493/// they share a column set: union them in the FROM clause and project, filter
494/// and order the combined set once, under the shape's single alias.
495/// `common_columns`, when set, are the columns of the ancestor branches of
496/// different types share: each branch projects those and its rows' concrete
497/// type, rather than columns the others do not have.
498fn bound_union_from_clause(rows: &[IrRowSource], common_columns: &[String]) -> String {
499    let columns = common_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
500    rows.iter()
501        .map(|row| match row {
502            IrRowSource::Bound { source, .. } if !common_columns.is_empty() => {
503                let own_type = match source_fanout(source) {
504                    Some(_) => "\"__type__\"".to_string(),
505                    None => format!("{} AS \"__type__\"", type_disc(&source.type_name)),
506                };
507                format!("    SELECT {own_type}, {columns} FROM {}", source_ref(source))
508            }
509            IrRowSource::Bound { source, .. } => format!("    SELECT * FROM {}", source_ref(source)),
510            IrRowSource::Free(_) => unreachable!("caller checked every row is bound"),
511        })
512        .collect::<Vec<_>>()
513        .join("\n    UNION ALL\n")
514}
515
516fn emit_bound_union_select(sel: &IrSelect, rows: &[IrRowSource]) -> SqlOutput {
517    let IrRowSource::Bound { source, shape } = &rows[0] else {
518        unreachable!("caller checked every row is bound")
519    };
520    let alias = &source.alias;
521    let (pointer_exprs, shape_pointers) = build_shape(shape, alias);
522    let row_type = if sel.poly_columns.is_empty() {
523        type_disc(&source.type_name)
524    } else {
525        format!("{}.\"__type__\"", qi(alias))
526    };
527    let mut parts = vec![row_type];
528    parts.extend(pointer_exprs);
529
530    let mut sql = format!(
531        "SELECT {}(\n    {}\n) AS result\nFROM (\n{}\n) AS {}",
532        if sel.distinct { "DISTINCT " } else { "" },
533        parts.join(",\n    "),
534        bound_union_from_clause(rows, &sel.poly_columns),
535        qi(alias),
536    );
537    append_filter(&mut sql, &sel.filter);
538    append_order_by(&mut sql, &sel.order_by);
539    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
540
541    SqlOutput {
542        sql,
543        shape: ShapeDescriptor {
544            root: ShapeNode::Object {
545                name: String::new(),
546                type_name: Some(source.type_name.clone()),
547                position: 0,
548                cardinality: Cardinality::Many,
549                pointers: prepend_type(shape_pointers),
550                has_implicit_id: shape_has_implicit_id(shape),
551            },
552        },
553        inference_plan: None,
554    }
555}
556
557/// Schema-bound SELECT — has a FROM clause, projected column shape, and
558/// (optionally) a DML source / polymorphic fan-out. `source`/`shape` are the
559/// single `IrRowSource::Bound` entry destructured by `emit_select_stmt`;
560/// everything else (filter/order_by/offset/limit/distinct/dml_source/
561/// polymorphic/poly_*) stays on the outer `IrSelect`.
562fn emit_bound_select(sel: &IrSelect, source: &IrSource, shape: &[IrShapePointer]) -> SqlOutput {
563    let alias = &source.alias;
564    let (pointer_exprs, shape_pointers) = build_shape(shape, alias);
565
566    let type_expr = if sel.polymorphic {
567        format!("{}.\"__type__\"", qi(alias))
568    } else {
569        type_disc(&source.type_name)
570    };
571    let mut parts = vec![type_expr];
572    parts.extend(pointer_exprs);
573    let tuple = parts.join(",\n    ");
574
575    let distinct = if sel.distinct { "DISTINCT " } else { "" };
576
577    // SELECT-over-DML: wrap inner statement in a CTE, select from it.
578    let from_clause = if let Some(dml) = &sel.dml_source {
579        let mut cte_parts = match dml.as_ref() {
580            IrStmt::Update(upd) if update_has_any_multilink(upd) => emit_update_multilink_ctes(upd, DML_CTE),
581            IrStmt::Insert(ins) if insert_has_any_multilink(ins) => emit_insert_multilink_ctes(ins, DML_CTE),
582            // Nested DML the row's values read from: hoisted ahead of the row
583            // itself, since a data-modifying WITH only works at the top level.
584            IrStmt::Insert(ins) if !ins.nested_ctes.is_empty() => {
585                let mut parts = emit_user_cte_parts(&ins.nested_ctes);
586                parts.push(format!("\"_dml\" AS (\n{}\n)", emit_dml_as_cte_source(dml)));
587                parts
588            }
589            IrStmt::Update(upd) if !upd.nested_ctes.is_empty() => {
590                let mut parts = emit_user_cte_parts(&upd.nested_ctes);
591                parts.push(format!("\"_dml\" AS (\n{}\n)", emit_dml_as_cte_source(dml)));
592                parts
593            }
594            IrStmt::Update(upd) if !upd.poly_implementors.is_empty() => emit_poly_update_dml_ctes(upd, "_dml"),
595            IrStmt::Delete(del) if !del.poly_implementors.is_empty() => emit_poly_delete_dml_ctes(del, "_dml"),
596            _ => vec![format!("\"_dml\" AS (\n{}\n)", emit_dml_as_cte_source(dml))],
597        };
598        let (enqueue_v, enqueue_s) = match dml.as_ref() {
599            IrStmt::Insert(ins) => (ins.enqueue_vector.as_slice(), ins.enqueue_search.as_slice()),
600            IrStmt::Update(upd) => (upd.enqueue_vector.as_slice(), upd.enqueue_search.as_slice()),
601            _ => (&[][..], &[][..]),
602        };
603        cte_parts.extend(enqueue_ctes(enqueue_v, "_dml"));
604        cte_parts.extend(enqueue_search_ctes(enqueue_s, "_dml", enqueue_v.len()));
605        format!(
606            "WITH\n{}\nSELECT {}(\n    {}\n) AS result\nFROM \"_dml\" AS {}",
607            cte_parts.join(",\n"),
608            distinct,
609            tuple,
610            qi(alias)
611        )
612    } else if sel.polymorphic && !source.table.starts_with("@cte:") {
613        // Polymorphic interface with no CTE indirection: fan out to implementor tables.
614        let union_sql = emit_poly_union(&sel.poly_implementors, &sel.poly_columns);
615        format!(
616            "SELECT {}(\n    {}\n) AS result\nFROM (\n{}\n) AS {}",
617            distinct,
618            tuple,
619            union_sql,
620            qi(alias)
621        )
622    } else {
623        // Concrete table or CTE (pre-filtered): query directly.
624        // For CTE-backed polymorphic sources, __type__ is already present in the CTE result.
625        format!(
626            "SELECT {}(\n    {}\n) AS result\nFROM {} AS {}",
627            distinct,
628            tuple,
629            source_ref(source),
630            qi(alias)
631        )
632    };
633
634    let mut sql = from_clause;
635    append_filter(&mut sql, &sel.filter);
636    append_order_by(&mut sql, &sel.order_by);
637    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
638    append_lock_clause(&mut sql, &sel.lock);
639
640    let root_pointers = prepend_type(shape_pointers);
641    SqlOutput {
642        sql,
643        shape: ShapeDescriptor {
644            root: ShapeNode::Object {
645                name: String::new(),
646                type_name: Some(source.type_name.clone()),
647                position: 0,
648                cardinality: Cardinality::Many,
649                pointers: root_pointers,
650                has_implicit_id: shape_has_implicit_id(shape),
651            },
652        },
653        inference_plan: None,
654    }
655}
656
657/// Emit a DML statement for use as a CTE source, using `RETURNING *` to expose
658/// all columns to the outer SELECT.  The DML's own returning shape is ignored.
659/// `FROM` over the nested-DML CTEs `reading` takes a column of, prefixed by
660/// `lead`; empty when it takes none. One read through a scalar subquery
661/// (`(SELECT "id" FROM cte)`) needs no join, and joining it anyway drops every
662/// row the moment that CTE is empty -- which a guarded branch is.
663fn nested_cte_from(ctes: &[IrCteDef], reading: &str, lead: &str) -> String {
664    let joined: Vec<String> = ctes
665        .iter()
666        .map(|c| qi(&c.name))
667        .filter(|name| reading.contains(&format!("{name}.")))
668        .collect();
669    if joined.is_empty() {
670        return String::new();
671    }
672    format!("{lead}FROM {}", joined.join(", "))
673}
674
675/// Each operand's rows read through the bare `v` every scalar statement
676/// exposes as a CTE source, so operands of different kinds line up.
677fn emit_scalar_union(branches: &[IrStmt]) -> String {
678    let operands: Vec<String> = branches
679        .iter()
680        .enumerate()
681        .map(|(i, branch)| {
682            format!(
683                "SELECT \"v\" FROM (\n{}\n) AS \"_u{i}\"",
684                emit_dml_as_cte_source(branch)
685            )
686        })
687        .collect();
688    format!(
689        "SELECT ROW(v) AS result, v FROM (\n{}\n) AS _scalar",
690        operands.join("\nUNION ALL\n")
691    )
692}
693
694fn emit_dml_as_cte_source(stmt: &IrStmt) -> String {
695    match stmt {
696        IrStmt::ScalarUnion(branches) => format!("    {}", emit_scalar_union(branches)),
697        IrStmt::Insert(ins) => {
698            let rewrite_cols: std::collections::HashSet<&str> =
699                ins.rewrites.iter().map(|r| r.column.as_str()).collect();
700            let cols: Vec<String> = ins
701                .assignments
702                .iter()
703                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
704                .map(|(c, _)| format!("    {}", qi(c)))
705                .chain(ins.rewrites.iter().map(|r| format!("    {}", qi(&r.column))))
706                .collect();
707            let vals: Vec<String> = ins
708                .assignments
709                .iter()
710                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
711                .map(|(_, e)| format!("    {}", emit_expr(e)))
712                .chain(ins.rewrites.iter().map(|r| format!("    {}", emit_expr(&r.expr))))
713                .collect();
714            let mut sql = if !ins.nested_ctes.is_empty() || ins.guard.is_some() {
715                // The row reads columns out of hoisted nested-DML CTEs, so it
716                // selects from them rather than listing literal values. Those
717                // CTEs are emitted by the caller: Postgres only allows a
718                // data-modifying WITH at the top level, never inside a CTE.
719                // A guarded insert likewise has to be a `SELECT … WHERE`, so
720                // that it writes nothing when the condition is false.
721                format!("    {}", emit_insert_row_sql(ins))
722            } else if cols.is_empty() {
723                // See `emit_insert_row_sql`: a row of nothing but defaults.
724                format!("    INSERT INTO {} DEFAULT VALUES", target_ref(&ins.target))
725            } else {
726                format!(
727                    "    INSERT INTO {} (\n{}\n    ) VALUES (\n{}\n    )",
728                    target_ref(&ins.target),
729                    cols.join(",\n"),
730                    vals.join(",\n"),
731                )
732            };
733            if let Some(conflict) = &ins.unless_conflict {
734                emit_conflict(&mut sql, conflict);
735            }
736            sql.push_str("\n    RETURNING *");
737            sql
738        }
739        IrStmt::Update(upd) => {
740            // Callers must route an update with any multi-link (junction
741            // table) mutation through emit_update_multilink_ctes instead —
742            // junction INSERT/DELETE CTEs are data-modifying and Postgres
743            // requires those to sit at the *top level* of the query, so they
744            // can't be nested inside this function's single-CTE-body return
745            // value. See emit_cte_prefix and emit_select_stmt's dml_source
746            // branch, both of which check has_any_multilink before calling
747            // this function at all.
748            let alias = &upd.target.alias;
749            let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "    ");
750            let mut sql = format!(
751                "    UPDATE {} AS {}\n    SET\n{}",
752                target_ref(&upd.target),
753                qi(alias),
754                sets.join(",\n"),
755            );
756            // The nested CTEs are emitted alongside this body (see
757            // `emit_user_cte_parts`), so the reference to them needs a FROM.
758            sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n    "));
759            append_filter(&mut sql, &upd.filter);
760            // Qualified: past a FROM, a bare `*` carries the joined relation's
761            // columns too, and a reader of this CTE then finds two `id`s.
762            sql.push_str(&format!("\n    RETURNING {}.*", qi(alias)));
763            sql
764        }
765        IrStmt::Delete(del) => {
766            let alias = &del.target.alias;
767            let mut sql = format!("    DELETE FROM {} AS {}", target_ref(&del.target), qi(alias),);
768            append_filter(&mut sql, &del.filter);
769            sql.push_str("\n    RETURNING *");
770            sql
771        }
772        IrStmt::Select(inner) => match inner.rows.as_slice() {
773            // `x := (select (select T filter …) filter …)` — the inner select is
774            // what this one reads, the same `_dml` the top-level emitter reads
775            // it from. Read from the table instead, it returned every row of
776            // the type, with neither filter applied.
777            [IrRowSource::Bound { source, .. }]
778                if let Some(read) = inner.dml_source.as_deref()
779                    && !matches!(read, IrStmt::Insert(_) | IrStmt::Update(_) | IrStmt::Delete(_)) =>
780            {
781                // A subquery, not a `WITH`: this may already sit inside one.
782                let mut sql = format!(
783                    "    SELECT {}* FROM (\n{}\n    ) AS {}",
784                    if inner.distinct { "DISTINCT " } else { "" },
785                    emit_dml_as_cte_source(read),
786                    qi(&source.alias),
787                );
788                append_filter(&mut sql, &inner.filter);
789                append_order_by(&mut sql, &inner.order_by);
790                append_offset_limit(&mut sql, &inner.offset, &inner.limit);
791                sql
792            }
793            [IrRowSource::Bound { source, .. }] => {
794                // SELECT-over-SELECT: expose raw columns so the outer SELECT can
795                // project its own shape from them, mirroring DML's RETURNING *.
796                let from = if inner.polymorphic && !source.table.starts_with("@cte:") {
797                    format!(
798                        "(\n{}\n    ) AS {}",
799                        emit_poly_union(&inner.poly_implementors, &inner.poly_columns),
800                        qi(&source.alias),
801                    )
802                } else {
803                    format!("{} AS {}", source_ref(source), qi(&source.alias))
804                };
805                let mut sql = format!(
806                    "    SELECT {}* FROM {}",
807                    if inner.distinct { "DISTINCT " } else { "" },
808                    from
809                );
810                append_filter(&mut sql, &inner.filter);
811                append_order_by(&mut sql, &inner.order_by);
812                append_offset_limit(&mut sql, &inner.offset, &inner.limit);
813                sql
814            }
815            // Object union: same FROM-clause union as `emit_bound_union_select`,
816            // but exposing raw columns like every other arm here.
817            rows if rows.len() > 1 && rows.iter().all(|r| matches!(r, IrRowSource::Bound { .. })) => {
818                let IrRowSource::Bound { source, .. } = &rows[0] else {
819                    unreachable!("checked by the guard")
820                };
821                let mut sql = format!(
822                    "    SELECT {}* FROM (\n{}\n    ) AS {}",
823                    if inner.distinct { "DISTINCT " } else { "" },
824                    bound_union_from_clause(rows, &inner.poly_columns),
825                    qi(&source.alias),
826                );
827                append_filter(&mut sql, &inner.filter);
828                append_order_by(&mut sql, &inner.order_by);
829                append_offset_limit(&mut sql, &inner.offset, &inner.limit);
830                sql
831            }
832            // Free rows: full UNION-ALL emission, same as before the merge
833            // (this arm used to be a separate IrStmt::FreeSelect match). Only
834            // `.sql` is used here, and CtePassthrough's SQL text doesn't
835            // depend on the ctes list (only its *shape* resolution does), so
836            // an empty slice is fine.
837            _ => emit_select_stmt(inner, &[]).sql,
838        },
839        IrStmt::FunctionSelect(sel) => {
840            // Expose raw columns so the outer SELECT can project its own shape,
841            // mirroring how IrStmt::Select works as a CTE source.
842            let args_sql = sel.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
843            let fn_call = format!("{}.{}({})", pg_schema(&sel.fn_module), qi(&sel.fn_name), args_sql);
844            let mut sql = format!("    SELECT * FROM {} AS {}", fn_call, qi(&sel.alias));
845            append_filter(&mut sql, &sel.filter);
846            append_order_by(&mut sql, &sel.order_by);
847            append_offset_limit(&mut sql, &sel.offset, &sel.limit);
848            sql
849        }
850        IrStmt::For(f) => {
851            // `for x in S union (...)` as a set source: iterate in the FROM
852            // clause and expose the body's raw columns, the same contract the
853            // other arms honour. An empty literal iterator yields no rows, but
854            // still has to name a column for the outer SELECT to project.
855            if matches!(&f.iterator, IrForIterator::Values { exprs, .. } if exprs.is_empty()) {
856                return "    SELECT NULL AS \"id\" WHERE FALSE".to_string();
857            }
858            let (values_from, _) = emit_for_iterator(&f.iterator, &format!("_for_{}", f.var_name));
859            let body =
860                prefix_body_ctes(emit_dml_as_cte_source(&f.body), &uncorrelated_body_ctes(f)).replace('\n', "\n    ");
861            format!(
862                "    SELECT \"_body\".*\n    FROM {}\n    CROSS JOIN LATERAL (\n    {}\n    ) AS \"_body\"",
863                values_from.replace('\n', "\n    "),
864                body,
865            )
866        }
867        IrStmt::Group(grp) if matches!(grp.output, IrGroupOutput::Elements) => emit_group_elements_source(grp),
868        IrStmt::Group(_) | IrStmt::VectorSearch(_) | IrStmt::FtsSearch(_) => {
869            unreachable!("cannot appear as a CTE source")
870        }
871        IrStmt::PathSelect(ps) => {
872            // A path traversal as a CTE body. What it projects depends on
873            // where the path ends: a scalar becomes a `result` column (which
874            // is what `IrExpr::CteRef { scalar: true }` reads back), an
875            // object exposes its raw columns so the outer query can both
876            // traverse it (`@cte:` source) and read its `id`.
877            let distinct = if ps.distinct { "DISTINCT " } else { "" };
878            let projection = match &ps.result {
879                // Same two-column convention a free scalar binding uses:
880                // `result` is the ROW() composite top-level decoding wants,
881                // `v` the bare value `IrExpr::CteRef { scalar: true }` reads.
882                IrPathResult::Scalar(e, _) => format!("{}{} AS v", distinct, emit_expr(e)),
883                IrPathResult::Object { alias, .. } => format!("{}{}.*", distinct, qi(alias)),
884            };
885            let mut sql = format!(
886                "SELECT {}\n    FROM {}",
887                projection,
888                emit_path_joins(&ps.root, &ps.joins)
889            );
890            append_filter(&mut sql, &ps.filter);
891            append_order_by(&mut sql, &ps.order_by);
892            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
893            match &ps.result {
894                IrPathResult::Scalar(e, _) => {
895                    let row_value = if enum_type_of_expr(e).is_some() { "v::text" } else { "v" };
896                    format!("    SELECT ROW({row_value}) AS result, v FROM ({sql}) AS _scalar")
897                }
898                IrPathResult::Object { .. } => format!("    {sql}"),
899            }
900        }
901    }
902}
903
904// ── Polymorphic DML-as-CTE fan-out ──────────────────────────────────────────
905//
906// A DML statement targeting an interface/abstract type can't be a single
907// `UPDATE`/`DELETE ... RETURNING *` the way emit_dml_as_cte_source handles a
908// concrete-type target — Postgres has no single physical relation backing
909// the interface (each concrete implementor has its own table), and a bare
910// `RETURNING *` off whichever one table `source_ref` happened to resolve
911// would only ever see that one implementor's rows and never carry a
912// `__type__` discriminator column at all (confirmed live: querying such a
913// CTE from an outer polymorphic SELECT raised "column t1.__type__ does not
914// exist", since the outer SELECT — see emit_select_stmt and the "CTE-backed
915// polymorphic sources" comment there — assumes any CTE tied to a polymorphic
916// type already exposes one).
917//
918// Mirrors emit_poly_update_stmt/emit_poly_delete_stmt's per-implementor
919// UNION ALL fan-out (one CTE per concrete table, each RETURNING-ing rows,
920// unioned together with a compile-time-known type literal per branch — the
921// type is known per branch even though it can vary per row across the whole
922// statement, since each branch only ever touches one implementor's table).
923// The difference here: those two functions only need `id` back (a
924// standalone DML's own default RETURNING shape), while a DML-as-CTE source
925// must expose whatever columns the *outer* SELECT might project — so this
926// returns the interface's full common column set (poly_columns: every
927// property + link FK the type declares, guaranteed present on every
928// implementor table) instead of just id.
929//
930// Not handled: a multi-link mutation (+=/-=/:=) on the interface's own
931// pointer combined with poly_implementors on the same statement — that
932// would need per-implementor junction-table CTEs too, which
933// emit_update_multilink_ctes doesn't do either today. Callers check
934// update_has_any_multilink first and keep routing that (rarer) combination
935// through the existing (equally not-poly-aware) path rather than silently
936// mishandling it here.
937
938fn emit_poly_update_dml_ctes(upd: &IrUpdate, name: &str) -> Vec<String> {
939    let alias = &upd.target.alias;
940    let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
941    let col_list = upd.poly_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
942
943    let mut cte_parts = vec![];
944    let mut union_parts = vec![];
945    for (i, imp) in upd.poly_implementors.iter().enumerate() {
946        let cte_name = format!("{}__u{}", name, i);
947        let mut upd_sql = format!(
948            "UPDATE {} AS {}\nSET {}",
949            qn(&imp.module, &imp.table),
950            qi(alias),
951            sets.join(", "),
952        );
953        append_filter(&mut upd_sql, &upd.filter);
954        upd_sql.push_str(&format!("\nRETURNING {}", col_list));
955        cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, upd_sql));
956
957        union_parts.push(format!(
958            "SELECT {}::text AS \"__type__\", {} FROM \"{}\"",
959            sql_str(&imp.type_name),
960            col_list,
961            cte_name,
962        ));
963    }
964    cte_parts.push(format!("\"{}\" AS (\n{}\n)", name, union_parts.join("\nUNION ALL\n")));
965    cte_parts
966}
967
968/// One implementor's own copy of a polymorphic update: it writes to that
969/// implementor's table, and to that implementor's own junction tables — an
970/// interface's junction is a view over them, which Postgres refuses to
971/// insert into.
972fn concrete_poly_update(upd: &IrUpdate, imp: &IrPolyImplementor) -> IrUpdate {
973    let interface_prefix = format!("{}.", upd.target.table);
974    let own_prefix = format!("{}.", imp.table);
975    let own_junction = |table: &mut String, module: &mut String| {
976        if let Some(link) = table.strip_prefix(&interface_prefix) {
977            *table = format!("{own_prefix}{link}");
978            module.clone_from(&imp.module);
979        }
980    };
981    let mut concrete = upd.clone();
982    concrete.poly_implementors = vec![];
983    concrete.target.table = imp.table.clone();
984    concrete.target.type_name = imp.type_name.clone();
985    concrete.target.poly = None;
986    concrete
987        .multi_link_clears
988        .iter_mut()
989        .for_each(|c| own_junction(&mut c.junction_table, &mut c.module));
990    concrete
991        .multi_link_replaces
992        .iter_mut()
993        .chain(concrete.multi_link_appends.iter_mut())
994        .chain(concrete.multi_link_removals.iter_mut())
995        .for_each(|m| own_junction(&mut m.junction_table, &mut m.module));
996    concrete
997}
998
999/// `emit_poly_update_dml_ctes` for an update that also mutates a multi-link:
1000/// each implementor gets the whole sibling set `emit_update_multilink_ctes`
1001/// builds, against its own tables.
1002fn emit_poly_update_multilink_ctes(upd: &IrUpdate, name: &str) -> Vec<String> {
1003    let col_list = upd.poly_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
1004    let mut cte_parts = emit_user_cte_parts(&upd.nested_ctes);
1005    let mut union_parts = vec![];
1006    for (i, imp) in upd.poly_implementors.iter().enumerate() {
1007        let cte_name = format!("{}__u{}", name, i);
1008        cte_parts.extend(emit_update_multilink_cte_parts(
1009            &concrete_poly_update(upd, imp),
1010            &cte_name,
1011            false,
1012        ));
1013        union_parts.push(format!(
1014            "SELECT {}::text AS \"__type__\", {} FROM \"{}\"",
1015            sql_str(&imp.type_name),
1016            col_list,
1017            cte_name,
1018        ));
1019    }
1020    cte_parts.push(format!("\"{}\" AS (\n{}\n)", name, union_parts.join("\nUNION ALL\n")));
1021    cte_parts
1022}
1023
1024fn emit_poly_delete_dml_ctes(del: &IrDelete, name: &str) -> Vec<String> {
1025    let alias = &del.target.alias;
1026    let col_list = del.poly_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
1027
1028    let mut cte_parts = vec![];
1029    let mut union_parts = vec![];
1030    for (i, imp) in del.poly_implementors.iter().enumerate() {
1031        let cte_name = format!("{}__d{}", name, i);
1032        let mut del_sql = format!("DELETE FROM {} AS {}", qn(&imp.module, &imp.table), qi(alias),);
1033        append_filter(&mut del_sql, &del.filter);
1034        del_sql.push_str(&format!("\nRETURNING {}", col_list));
1035        cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, del_sql));
1036
1037        union_parts.push(format!(
1038            "SELECT {}::text AS \"__type__\", {} FROM \"{}\"",
1039            sql_str(&imp.type_name),
1040            col_list,
1041            cte_name,
1042        ));
1043    }
1044    cte_parts.push(format!("\"{}\" AS (\n{}\n)", name, union_parts.join("\nUNION ALL\n")));
1045    cte_parts
1046}
1047
1048// ── User CTE helpers ────────────────────────────────────────────────────────
1049
1050fn update_has_any_multilink(upd: &IrUpdate) -> bool {
1051    !upd.multi_link_clears.is_empty()
1052        || !upd.multi_link_replaces.is_empty()
1053        || !upd.multi_link_appends.is_empty()
1054        || !upd.multi_link_removals.is_empty()
1055}
1056
1057/// Extra `AND target NOT IN (...)` clause for a junction clear paired with
1058/// a replace at the same index (`friends := expr` — see `IrUpdate.
1059/// multi_link_clears`'s own doc comment on the shared-index pairing).
1060///
1061/// Without this, a target present in *both* the old and new sets gets
1062/// deleted here and then silently fails to come back: the paired INSERT
1063/// (`emit_ml_append_cte`) runs its `ON CONFLICT` check against the same
1064/// statement-wide snapshot every CTE in one `WITH` shares, which still
1065/// shows the row this DELETE is *about to* remove as present — Postgres
1066/// treats that as a real conflict and skips the insert (`ON CONFLICT DO
1067/// NOTHING`) or updates the row this DELETE is also deleting (`DO UPDATE`,
1068/// the link-properties case), not "insert the row back." Confirmed live:
1069/// reassigning a junction-backed single link to its own already-set value,
1070/// or replacing a multi-link with a set overlapping its current one,
1071/// silently dropped every overlapping member. Excluding still-wanted
1072/// targets from the DELETE instead means they're never touched at all, so
1073/// there's nothing for the INSERT to conflict with.
1074fn ml_clear_exclusion(rep: Option<&IrMultiLinkMutation>) -> String {
1075    match rep {
1076        Some(rep) => {
1077            let vals_ref = emit_multilink_values_subquery(&rep.values, &[]);
1078            format!(
1079                " AND {} NOT IN (SELECT \"_v\".\"id\" FROM {} AS \"_v\")",
1080                qi(&rep.target_col),
1081                vals_ref
1082            )
1083        }
1084        None => String::new(),
1085    }
1086}
1087
1088/// Builds the CTE chain for an UPDATE with multi-link (junction table)
1089/// mutations, bound to a single external `name` (a user WITH-binding, or the
1090/// implicit "_dml" wrapper for `SELECT (UPDATE ...)`). Junction INSERT/DELETE
1091/// statements are themselves data-modifying, and Postgres requires
1092/// data-modifying CTEs to sit at the *top level* of the query — they can't
1093/// be nested inside another CTE's own body (confirmed live: nesting them
1094/// raises "WITH clause containing a data-modifying statement must be at the
1095/// top level"). So this returns a flat list of sibling CTE parts, prefixed
1096/// by `name` to stay collision-free alongside any other bound statements in
1097/// the same WITH block, ending in a `"{name}" AS (SELECT * FROM
1098/// "{name}__ids")` passthrough — every other reference to `name` keeps
1099/// seeing the updated row's full columns exactly as `RETURNING *` would have
1100/// exposed them.
1101fn emit_update_multilink_ctes(upd: &IrUpdate, name: &str) -> Vec<String> {
1102    emit_update_multilink_cte_parts(upd, name, true)
1103}
1104
1105/// `emit_nested` is false for a caller that has already emitted
1106/// `upd.nested_ctes` beside this one: a polymorphic update emits one branch
1107/// per implementor off the same `IrUpdate`, and a `WITH` may name each
1108/// hoisted statement only once.
1109fn emit_update_multilink_cte_parts(upd: &IrUpdate, name: &str, emit_nested: bool) -> Vec<String> {
1110    let alias = &upd.target.alias;
1111    let has_scalar_changes = !upd.assignments.is_empty() || !upd.rewrites.is_empty();
1112    let ids_name = format!("{}__ids", name);
1113    // The nested statements its values read from, ahead of it.
1114    let mut parts: Vec<String> = if emit_nested {
1115        emit_user_cte_parts(&upd.nested_ctes)
1116    } else {
1117        vec![]
1118    };
1119
1120    if has_scalar_changes {
1121        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
1122        let mut upd_sql = format!(
1123            "UPDATE {} AS {}\nSET {}",
1124            target_ref(&upd.target),
1125            qi(alias),
1126            sets.join(", "),
1127        );
1128        // A nested statement's CTE sits beside this one, so reading its id
1129        // needs a FROM — the same reason the plain update path has one.
1130        upd_sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n"));
1131        append_filter(&mut upd_sql, &upd.filter);
1132        // See `emit_dml_as_cte_source`: past a FROM, a bare `*` would carry the
1133        // joined relation's columns into this CTE beside the target's.
1134        upd_sql.push_str(&format!("\nRETURNING {}.*", qi(alias)));
1135        parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, upd_sql));
1136    } else {
1137        let mut sel = format!(
1138            "SELECT {}.* FROM {} AS {}",
1139            qi(alias),
1140            target_ref(&upd.target),
1141            qi(alias),
1142        );
1143        append_filter(&mut sel, &upd.filter);
1144        parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sel));
1145    }
1146
1147    for (i, clr) in upd.multi_link_clears.iter().enumerate() {
1148        let exclude = ml_clear_exclusion(upd.multi_link_replaces.get(i));
1149        let del = format!(
1150            "DELETE FROM {} WHERE {} IN (SELECT id FROM \"{}\"){}",
1151            qn(&clr.module, &clr.junction_table),
1152            qi(&clr.source_col),
1153            ids_name,
1154            exclude,
1155        );
1156        parts.push(format!("\"{}__clr_{}\" AS (\n{}\n)", name, i, del));
1157    }
1158    for (i, app) in upd.multi_link_appends.iter().enumerate() {
1159        parts.push(emit_ml_append_cte(
1160            app,
1161            &ids_name,
1162            &upd.target.alias,
1163            &format!("{}__ml_add_{}", name, i),
1164        ));
1165    }
1166    for (i, rem) in upd.multi_link_removals.iter().enumerate() {
1167        parts.push(emit_ml_remove_cte(
1168            rem,
1169            &ids_name,
1170            &upd.target.alias,
1171            &format!("{}__ml_rm_{}", name, i),
1172        ));
1173    }
1174    for (i, rep) in upd.multi_link_replaces.iter().enumerate() {
1175        parts.push(emit_ml_append_cte(
1176            rep,
1177            &ids_name,
1178            &upd.target.alias,
1179            &format!("{}__ml_rep_{}", name, i),
1180        ));
1181    }
1182
1183    parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, ids_name));
1184    parts
1185}
1186
1187fn insert_has_any_multilink(ins: &IrInsert) -> bool {
1188    !ins.multi_link_appends.is_empty()
1189}
1190
1191/// Builds the CTE chain for an INSERT with multi-link (junction table)
1192/// assignments at creation time — same top-level-CTE constraint as
1193/// `emit_update_multilink_ctes` (junction INSERTs are themselves
1194/// data-modifying). The row itself is inserted first (`{name}__ids`, via
1195/// `RETURNING *`) so the junction CTEs can reference its freshly-generated id.
1196fn emit_insert_multilink_ctes(ins: &IrInsert, name: &str) -> Vec<String> {
1197    let ids_name = format!("{}__ids", name);
1198    let mut parts: Vec<String> = emit_user_cte_parts(&ins.nested_ctes);
1199
1200    let mut insert_sql = emit_insert_row_sql(ins);
1201    if let Some(conflict) = &ins.unless_conflict {
1202        emit_conflict(&mut insert_sql, conflict);
1203    }
1204    insert_sql.push_str("\nRETURNING *");
1205    parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, insert_sql));
1206
1207    for (i, app) in ins.multi_link_appends.iter().enumerate() {
1208        parts.push(emit_ml_append_cte(
1209            app,
1210            &ids_name,
1211            &ins.target.alias,
1212            &format!("{}__ml_add_{}", name, i),
1213        ));
1214    }
1215
1216    parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, ids_name));
1217    parts
1218}
1219
1220/// A `for` whose body mutates, bound to a WITH name: the same iteration-driven
1221/// shapes `emit_for_insert`/`emit_for_update` build at the top level, but split
1222/// into sibling CTE parts. A data-modifying statement can neither sit inside
1223/// another CTE's body nor in a LATERAL, so the loop cannot be emitted as the
1224/// binding's own subquery the way a select-bodied one is.
1225/// A `for` whose body mutates, bound to a WITH name. `outer_alias` names an
1226/// enclosing loop's iteration when this loop reads it, which makes every row
1227/// this one writes belong to one iteration of it rather than to all of them. `outer_alias` names
1228/// that loop's iteration when this loop reads it, which makes every row this
1229/// one writes belong to one iteration of it rather than to all of them.
1230/// A guarded insert writes only where its condition holds. The rows it is
1231/// driven from are filtered rather than the statement skipped, so an
1232/// iteration-driven insert stays one statement.
1233fn append_insert_guard(sql: &mut String, ins: &IrInsert) {
1234    if let Some(guard) = &ins.guard {
1235        sql.push_str(&format!("\nWHERE {}", emit_expr(guard)));
1236    }
1237}
1238
1239fn emit_for_dml_ctes_within(f: &IrFor, name: &str, outer_alias: Option<&str>) -> Vec<String> {
1240    let iter_alias = format!("_for_{}", f.var_name);
1241    let (_, plain_iter_cte) = emit_for_iterator(&f.iterator, &iter_alias);
1242    let correlated = outer_alias;
1243    let iter_cte = match correlated {
1244        Some(outer) => emit_nested_for_iterator(&f.iterator, &iter_alias, outer),
1245        None => plain_iter_cte,
1246    };
1247    // The iterator first: a body CTE may read the loop variable, and a WITH
1248    // name is only in scope for the bindings that follow it.
1249    let mut parts: Vec<String> = vec![iter_cte];
1250    parts.extend(emit_user_cte_parts(&f.body_ctes));
1251    let ids_name = format!("{}__ids", name);
1252
1253    // Rows written under a correlated loop carry their iteration key, so the
1254    // enclosing statement can pair them with the row that iteration wrote.
1255    if correlated.is_some()
1256        && let IrStmt::Insert(ins) = f.body.as_ref()
1257        && ins.multi_link_appends.is_empty()
1258    {
1259        let rows_name = format!("{}__rows", name);
1260        let outer_source = format!("{}.{}", qi(&iter_alias), qi(OUTER_KEY));
1261        parts.push(emit_iteration_rows_cte(ins, &rows_name, &iter_alias, &outer_source));
1262        parts.push(emit_insert_from_rows_cte(ins, &ids_name, &rows_name));
1263        parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, rows_name));
1264        return parts;
1265    }
1266
1267    match f.body.as_ref() {
1268        IrStmt::Insert(ins) => {
1269            let rewrite_cols: std::collections::HashSet<&str> =
1270                ins.rewrites.iter().map(|r| r.column.as_str()).collect();
1271            let cols: Vec<String> = ins
1272                .assignments
1273                .iter()
1274                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1275                .map(|(c, _)| qi(c))
1276                .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
1277                .collect();
1278            let values: Vec<String> = ins
1279                .assignments
1280                .iter()
1281                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1282                .map(|(_, e)| emit_expr(e))
1283                .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
1284                .collect();
1285            // A nested loop that carried the iteration key back means these
1286            // rows need one too, to pair with.
1287            let rows_name = format!("{}__rows", name);
1288            let correlated_targets =
1289                correlated_append_indices(&ins.multi_link_appends, &[&f.body_ctes, &ins.nested_ctes]);
1290            if correlated_targets.is_empty() {
1291                let mut sql = format!(
1292                    "INSERT INTO {} ({})\nSELECT {} FROM {}",
1293                    target_ref(&ins.target),
1294                    cols.join(", "),
1295                    values.join(", "),
1296                    qi(&iter_alias),
1297                );
1298                append_insert_guard(&mut sql, ins);
1299                if let Some(conflict) = &ins.unless_conflict {
1300                    emit_conflict(&mut sql, conflict);
1301                }
1302                sql.push_str("\nRETURNING *");
1303                parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sql));
1304            } else {
1305                let outer_source = match correlated {
1306                    Some(_) => format!("{}.{}", qi(&iter_alias), qi(OUTER_KEY)),
1307                    None => format!("{}.{}", qi(&iter_alias), qi("v")),
1308                };
1309                parts.push(emit_iteration_rows_cte(ins, &rows_name, &iter_alias, &outer_source));
1310                parts.push(emit_insert_from_rows_cte(ins, &ids_name, &rows_name));
1311            }
1312            for (i, append) in ins.multi_link_appends.iter().enumerate() {
1313                let cte_name = format!("{}__ml_add_{}", name, i);
1314                if correlated_targets.contains(&i) {
1315                    let IrMultiLinkValueSource::CteRef(target) = &append.values.source else {
1316                        unreachable!("only a CTE-backed value can carry an iteration key")
1317                    };
1318                    parts.push(emit_correlated_ml_append_cte(append, &rows_name, target, &cte_name));
1319                } else {
1320                    parts.push(emit_ml_append_cte(append, &ids_name, &ins.target.alias, &cte_name));
1321                }
1322            }
1323        }
1324        IrStmt::Update(upd) => {
1325            let alias = &upd.target.alias;
1326            if upd.assignments.is_empty() && upd.rewrites.is_empty() && !upd.multi_link_appends.is_empty() {
1327                const ITER_COL: &str = "_iter";
1328                // A nested insert gets its id generated here rather than by the
1329                // column default, so the junction below can name the row this
1330                // iteration inserted. `RETURNING` offers no order to pair on.
1331                let generated: Vec<Option<&IrInsert>> = upd
1332                    .multi_link_appends
1333                    .iter()
1334                    .map(|a| per_iteration_insert(a, &upd.nested_ctes))
1335                    .collect();
1336                let new_cols: String = generated
1337                    .iter()
1338                    .enumerate()
1339                    .filter_map(|(i, ins)| {
1340                        ins.map(|ins| {
1341                            format!(
1342                                ", {} AS {}",
1343                                ins.id_default_sql.as_deref().unwrap_or("uuidv7()"),
1344                                qi(&format!("_new_{i}"))
1345                            )
1346                        })
1347                    })
1348                    .collect();
1349                let mut ids_sql = format!(
1350                    "\"{}\" AS (\nSELECT {}.*, {}.\"v\" AS {}{} FROM {} AS {}, {}",
1351                    ids_name,
1352                    qi(alias),
1353                    qi(&iter_alias),
1354                    qi(ITER_COL),
1355                    new_cols,
1356                    target_ref(&upd.target),
1357                    qi(alias),
1358                    qi(&iter_alias),
1359                );
1360                append_filter(&mut ids_sql, &upd.filter);
1361                ids_sql.push_str("\n)");
1362                parts.push(ids_sql);
1363                for (i, append) in upd.multi_link_appends.iter().enumerate() {
1364                    match generated[i] {
1365                        Some(ins) => {
1366                            parts.push(emit_for_nested_insert_cte(
1367                                ins,
1368                                &ids_name,
1369                                &iter_alias,
1370                                &format!("{}__ml_ins_{}", name, i),
1371                                &format!("_new_{i}"),
1372                            ));
1373                            parts.push(emit_for_ml_append_cte(
1374                                append,
1375                                &ids_name,
1376                                &format!("{}__ml_add_{}", name, i),
1377                                &format!("_new_{i}"),
1378                            ));
1379                        }
1380                        None => parts.push(emit_for_ml_append_cte(
1381                            append,
1382                            &ids_name,
1383                            &format!("{}__ml_add_{}", name, i),
1384                            ITER_COL,
1385                        )),
1386                    }
1387                }
1388            } else if !upd.poly_implementors.is_empty() {
1389                // An interface has no table to update: each implementor is
1390                // driven from the iteration on its own, and the rows unioned
1391                // back under the interface's common columns.
1392                let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
1393                let col_list = upd.poly_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
1394                // Qualified: the iterated set is in scope here too, so a bare
1395                // column name could name either side.
1396                let ret_list = upd
1397                    .poly_columns
1398                    .iter()
1399                    .map(|c| format!("{}.{}", qi(alias), qi(c)))
1400                    .collect::<Vec<_>>()
1401                    .join(", ");
1402                let mut union_parts = vec![];
1403                for (i, imp) in upd.poly_implementors.iter().enumerate() {
1404                    let cte_name = format!("{}__u{}", ids_name, i);
1405                    let mut upd_sql = format!(
1406                        "UPDATE {} AS {}\nSET {}\nFROM {}",
1407                        qn(&imp.module, &imp.table),
1408                        qi(alias),
1409                        sets.join(", "),
1410                        qi(&iter_alias),
1411                    );
1412                    append_filter(&mut upd_sql, &upd.filter);
1413                    upd_sql.push_str(&format!("\nRETURNING {}", ret_list));
1414                    parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, upd_sql));
1415                    union_parts.push(format!(
1416                        "SELECT {}::text AS \"__type__\", {} FROM \"{}\"",
1417                        sql_str(&imp.type_name),
1418                        col_list,
1419                        cte_name,
1420                    ));
1421                }
1422                parts.push(format!(
1423                    "\"{}\" AS (\n{}\n)",
1424                    ids_name,
1425                    union_parts.join("\nUNION ALL\n")
1426                ));
1427            } else {
1428                let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
1429                let mut sql = format!(
1430                    "UPDATE {} AS {}\nSET {}\nFROM {}",
1431                    target_ref(&upd.target),
1432                    qi(alias),
1433                    sets.join(", "),
1434                    qi(&iter_alias),
1435                );
1436                append_filter(&mut sql, &upd.filter);
1437                sql.push_str(&format!("\nRETURNING {}.*", qi(alias)));
1438                parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sql));
1439            }
1440        }
1441        // `for line in … union (for component in … union (insert …))` — one
1442        // iterator CTE per loop, the inner carrying the outer's key, and the
1443        // insert driven from the two joined on it.
1444        IrStmt::For(inner) => {
1445            let inner_alias = format!("_for_{}", inner.var_name);
1446            parts.push(emit_nested_for_iterator(&inner.iterator, &inner_alias, &iter_alias));
1447            parts.extend(emit_user_cte_parts(&inner.body_ctes));
1448            let IrStmt::Insert(ins) = inner.body.as_ref() else {
1449                unreachable!("a nested for-loop's own body is an insert: {:?}", inner.body)
1450            };
1451            let rewrite_cols: std::collections::HashSet<&str> =
1452                ins.rewrites.iter().map(|r| r.column.as_str()).collect();
1453            let cols: Vec<String> = ins
1454                .assignments
1455                .iter()
1456                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1457                .map(|(c, _)| qi(c))
1458                .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
1459                .collect();
1460            let values: Vec<String> = ins
1461                .assignments
1462                .iter()
1463                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1464                .map(|(_, e)| emit_expr(e))
1465                .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
1466                .collect();
1467            let mut sql = format!(
1468                "INSERT INTO {} ({})\nSELECT {} FROM {}",
1469                target_ref(&ins.target),
1470                cols.join(", "),
1471                values.join(", "),
1472                nested_for_from(&inner_alias, &iter_alias),
1473            );
1474            append_insert_guard(&mut sql, ins);
1475            if let Some(conflict) = &ins.unless_conflict {
1476                emit_conflict(&mut sql, conflict);
1477            }
1478            sql.push_str("\nRETURNING *");
1479            parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sql));
1480            for (i, append) in ins.multi_link_appends.iter().enumerate() {
1481                parts.push(emit_ml_append_cte(
1482                    append,
1483                    &ids_name,
1484                    &ins.target.alias,
1485                    &format!("{}__ml_add_{}", name, i),
1486                ));
1487            }
1488        }
1489        // Unreachable: only a mutating body is routed here.
1490        other => unreachable!("for-loop body is not a mutation: {other:?}"),
1491    }
1492
1493    parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, ids_name));
1494    parts
1495}
1496
1497/// Expands a list of user-bound WITH names into their top-level CTE parts —
1498/// usually one part per name, except an UPDATE or INSERT with any multi-link
1499/// mutation expands into several sibling parts (see
1500/// emit_update_multilink_ctes / emit_insert_multilink_ctes), and an UPDATE or
1501/// DELETE targeting an interface/abstract type similarly fans out into one
1502/// part per concrete implementor (see emit_poly_update_dml_ctes /
1503/// emit_poly_delete_dml_ctes) — neither can be nested inside a single name's
1504/// own CTE body.
1505fn emit_user_cte_parts(ctes: &[IrCteDef]) -> Vec<String> {
1506    let mut parts: Vec<String> = vec![];
1507    for c in ctes {
1508        // A hoisted statement may have hoisted one of its own —
1509        // `credentials := (insert access::Credentials { … })` inside an update.
1510        // Those CTEs are emitted beside it rather than inside its body, which
1511        // is where its own SQL expects to read them from.
1512        // The multi-link builders emit a statement's own, ahead of it.
1513        let nested: &[IrCteDef] = match &c.stmt {
1514            IrStmt::Insert(ins) if insert_has_any_multilink(ins) => &[],
1515            IrStmt::Update(upd) if update_has_any_multilink(upd) => &[],
1516            IrStmt::Insert(ins) => &ins.nested_ctes,
1517            IrStmt::Update(upd) => &upd.nested_ctes,
1518            _ => &[],
1519        };
1520        if !nested.is_empty() {
1521            parts.extend(emit_user_cte_parts(nested));
1522        }
1523        if let IrStmt::Update(upd) = &c.stmt {
1524            if !upd.poly_implementors.is_empty() {
1525                parts.extend(if update_has_any_multilink(upd) {
1526                    emit_poly_update_multilink_ctes(upd, &c.name)
1527                } else {
1528                    emit_poly_update_dml_ctes(upd, &c.name)
1529                });
1530                continue;
1531            }
1532            if update_has_any_multilink(upd) {
1533                parts.extend(emit_update_multilink_ctes(upd, &c.name));
1534                continue;
1535            }
1536        }
1537        if let IrStmt::Insert(ins) = &c.stmt
1538            && insert_has_any_multilink(ins)
1539        {
1540            parts.extend(emit_insert_multilink_ctes(ins, &c.name));
1541            continue;
1542        }
1543        if let IrStmt::Delete(del) = &c.stmt
1544            && !del.poly_implementors.is_empty()
1545        {
1546            parts.extend(emit_poly_delete_dml_ctes(del, &c.name));
1547            continue;
1548        }
1549        if let IrStmt::For(f) = &c.stmt
1550            && matches!(f.body.as_ref(), IrStmt::Insert(_) | IrStmt::Update(_) | IrStmt::For(_))
1551        {
1552            parts.extend(emit_for_dml_ctes_within(f, &c.name, c.correlated_to.as_deref()));
1553            continue;
1554        }
1555        let body = emit_dml_as_cte_source(&c.stmt);
1556        parts.push(match &c.correlated_to {
1557            // Evaluated once per iteration and keyed by it, since a CTE is
1558            // evaluated once for the whole statement whatever it reads.
1559            Some(iterator) => format!(
1560                "\"{}\" AS (\nSELECT {}.\"v\" AS {}, \"_row\".*\nFROM {}\nCROSS JOIN LATERAL (\n{}\n) AS \"_row\"\n)",
1561                c.name,
1562                qi(iterator),
1563                qi(&binding_outer_key(&c.name)),
1564                qi(iterator),
1565                body,
1566            ),
1567            None => format!("\"{}\" AS (\n{}\n)", c.name, body),
1568        });
1569    }
1570    parts
1571}
1572
1573/// Emit `WITH "name" AS (...), ...` prefix (WITH keyword + trailing newline included).
1574fn emit_cte_prefix(ctes: &[IrCteDef]) -> String {
1575    format!("WITH\n{}\n", emit_user_cte_parts(ctes).join(",\n"))
1576}
1577
1578/// Collects every link-property name assigned anywhere in a multilink value
1579/// tree (both sides of any nested `union`), in first-seen order — the SQL
1580/// layer needs one consistent column list across every unioned branch, even
1581/// when different targets set different (or no) properties.
1582pub(crate) fn collect_link_prop_names(vals: &IrMultiLinkValues, names: &mut Vec<String>) {
1583    for (name, _) in &vals.link_props {
1584        if !names.contains(name) {
1585            names.push(name.clone());
1586        }
1587    }
1588    match &vals.source {
1589        IrMultiLinkValueSource::Union(a, b) => {
1590            collect_link_prop_names(a, names);
1591            collect_link_prop_names(b, names);
1592        }
1593        IrMultiLinkValueSource::Asserted { inner, .. } => collect_link_prop_names(inner, names),
1594        _ => {}
1595    }
1596}
1597
1598/// `, <expr> AS "name"` for each entry in `prop_names` — the value assigned to
1599/// *this* node if any, else a bare `NULL` (Postgres infers its type from the
1600/// other union branch's typed value in the same column position; if no
1601/// branch ever sets it, the INSERT's target column type coerces it).
1602fn emit_link_prop_cols(vals: &IrMultiLinkValues, prop_names: &[String]) -> String {
1603    prop_names
1604        .iter()
1605        .map(|name| match vals.link_props.iter().find(|(n, _)| n == name) {
1606            Some((_, expr)) => format!(", {} AS {}", emit_expr(expr), qi(name)),
1607            None => format!(", NULL AS {}", qi(name)),
1608        })
1609        .collect()
1610}
1611
1612/// Emit `(SELECT id[, prop, ...] FROM ...)` sub-expression for a multi-link
1613/// values source. `prop_names` is the full set of link-property names used
1614/// anywhere in the enclosing mutation (see `collect_link_prop_names`) — every
1615/// leaf projects all of them so a `union` of heterogeneous branches has a
1616/// consistent column list.
1617fn emit_multilink_values_subquery(vals: &IrMultiLinkValues, prop_names: &[String]) -> String {
1618    emit_multilink_values_inner(vals, prop_names, true)
1619}
1620
1621/// `bare_cte_ok` is false wherever the result has to be a SELECT in its own
1622/// right — a `UNION ALL` operand, or the body of an assert. A CTE reference
1623/// otherwise emits just its quoted name, which only works where the caller
1624/// aliases it into a FROM.
1625fn emit_multilink_values_inner(vals: &IrMultiLinkValues, prop_names: &[String], bare_cte_ok: bool) -> String {
1626    if let IrMultiLinkValueSource::Union(a, b) = &vals.source {
1627        return format!(
1628            "({}\nUNION ALL\n{})",
1629            emit_multilink_values_inner(a, prop_names, false),
1630            emit_multilink_values_inner(b, prop_names, false),
1631        );
1632    }
1633
1634    // The assert wraps the whole value, so the ids are collected once, passed
1635    // through the check, and unnested back into rows. Reading the inner value
1636    // twice — once for the check, once for the rows — would run any `insert`
1637    // inside it twice.
1638    if let IrMultiLinkValueSource::Asserted {
1639        fn_name,
1640        inner,
1641        message,
1642    } = &vals.source
1643    {
1644        let inner_sql = emit_multilink_values_inner(inner, &[], false);
1645        let prop_cols = emit_link_prop_cols(vals, prop_names);
1646        return format!(
1647            "(SELECT unnest(\"_pylon\".{}(ARRAY(SELECT \"_v\".\"id\" FROM {} AS \"_v\"){})) AS \"id\"{})",
1648            qi(fn_name),
1649            inner_sql,
1650            assert_message_arg(message),
1651            prop_cols,
1652        );
1653    }
1654
1655    let prop_cols = emit_link_prop_cols(vals, prop_names);
1656
1657    match &vals.source {
1658        IrMultiLinkValueSource::CteRef(name) => {
1659            if prop_cols.is_empty() && bare_cte_ok {
1660                // Just the CTE name; will be aliased at the call site.
1661                format!("\"{}\"", name)
1662            } else {
1663                format!("(SELECT \"_s\".\"id\"{} FROM \"{}\" AS \"_s\")", prop_cols, name)
1664            }
1665        }
1666        IrMultiLinkValueSource::Select(s) => {
1667            // Compiler only ever constructs this variant for a single
1668            // schema-bound row (compile_subquery_exists's callers reject
1669            // free rows before wrapping) — see compiler.rs's multilink-
1670            // value-source sites.
1671            let [IrRowSource::Bound { source, .. }] = s.rows.as_slice() else {
1672                unreachable!("IrMultiLinkValueSource::Select is always schema-bound")
1673            };
1674            let alias = &source.alias;
1675            let mut sql = format!(
1676                "(SELECT {}.\"id\"{} FROM {} AS {}",
1677                qi(alias),
1678                prop_cols,
1679                source_ref(source),
1680                qi(alias)
1681            );
1682            append_filter(&mut sql, &s.filter);
1683            sql.push(')');
1684            sql
1685        }
1686        IrMultiLinkValueSource::PathSelect(ps) => {
1687            let root_alias = &ps.root.alias;
1688            // The targets are the rows the walk lands on, not the one it
1689            // starts from.
1690            let target_alias = match &ps.result {
1691                IrPathResult::Object { alias, .. } => alias,
1692                IrPathResult::Scalar(..) => root_alias,
1693            };
1694            let mut sql = format!(
1695                "(SELECT {}.\"id\"{} FROM {} AS {}",
1696                qi(target_alias),
1697                prop_cols,
1698                source_ref(&ps.root),
1699                qi(root_alias)
1700            );
1701            for join in &ps.joins {
1702                sql.push_str(&emit_path_join_sql(join));
1703            }
1704            append_filter(&mut sql, &ps.filter);
1705            sql.push(')');
1706            sql
1707        }
1708        IrMultiLinkValueSource::Function(fs) => {
1709            let args = fs.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
1710            let mut sql = format!(
1711                "(SELECT {}.\"id\"{} FROM {}.{}({}) AS {}",
1712                qi(&fs.alias),
1713                prop_cols,
1714                pg_schema(&fs.fn_module),
1715                qi(&fs.fn_name),
1716                args,
1717                qi(&fs.alias),
1718            );
1719            append_filter(&mut sql, &fs.filter);
1720            sql.push(')');
1721            sql
1722        }
1723        IrMultiLinkValueSource::Union(..) | IrMultiLinkValueSource::Asserted { .. } => {
1724            unreachable!("handled above")
1725        }
1726    }
1727}
1728
1729/// SQL fragment for a single path join (used in multilink values emission).
1730fn emit_path_join_sql(join: &IrPathJoin) -> String {
1731    match join {
1732        IrPathJoin::Lateral { inner, target } => {
1733            let projected = match &inner.result {
1734                IrPathResult::Object { alias, .. } => alias.clone(),
1735                IrPathResult::Scalar(..) => unreachable!("a lateral step is only built for an object-valued computed"),
1736            };
1737            let mut body = format!(
1738                "SELECT {}.* FROM {}",
1739                qi(&projected),
1740                emit_path_joins(&inner.root, &inner.joins)
1741            );
1742            append_filter(&mut body, &inner.filter);
1743            append_order_by(&mut body, &inner.order_by);
1744            append_offset_limit(&mut body, &inner.offset, &inner.limit);
1745            format!(" JOIN LATERAL (\n{}\n) AS {} ON TRUE", body, qi(&target.alias))
1746        }
1747        IrPathJoin::Function {
1748            fn_module,
1749            fn_name,
1750            args,
1751            target,
1752        } => {
1753            let args_sql = args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
1754            format!(
1755                " CROSS JOIN LATERAL {}.{}({}) AS {}",
1756                pg_schema(fn_module),
1757                qi(fn_name),
1758                args_sql,
1759                qi(&target.alias),
1760            )
1761        }
1762        IrPathJoin::Single {
1763            source_alias,
1764            fk_col,
1765            target,
1766        } => {
1767            format!(
1768                " JOIN {} AS {} ON {}.\"id\" = {}.{}",
1769                source_ref(target),
1770                qi(&target.alias),
1771                qi(&target.alias),
1772                qi(source_alias),
1773                qi(fk_col)
1774            )
1775        }
1776        IrPathJoin::Multi {
1777            source_alias,
1778            junction_alias,
1779            join: ml_join,
1780            target,
1781        } => {
1782            let (jt_ref, src_col, tgt_col) = match ml_join {
1783                IrMultiLinkJoin::Standard { junction_table, module } => (
1784                    junction_ref(module, junction_table),
1785                    "source".to_string(),
1786                    "target".to_string(),
1787                ),
1788                IrMultiLinkJoin::Through {
1789                    junction_table,
1790                    module,
1791                    source_col,
1792                    target_col,
1793                } => (
1794                    junction_ref(module, junction_table),
1795                    source_col.clone(),
1796                    target_col.clone(),
1797                ),
1798                // A forward `IrPathJoin::Multi` step is always built from a
1799                // real multi-link (`compile_path_select`'s middle-step
1800                // handling), never a backlink — the Backlink* variants only
1801                // ever appear inside `IrShapePointer::MultiLink.join`.
1802                IrMultiLinkJoin::BacklinkFk { .. } | IrMultiLinkJoin::BacklinkJunction { .. } => {
1803                    unreachable!("a forward multi-link path step never uses a backlink join variant")
1804                }
1805            };
1806            format!(
1807                " JOIN {} AS {} ON {}.{} = {}.\"id\" JOIN {} AS {} ON {}.{} = {}.\"id\"",
1808                jt_ref,
1809                qi(junction_alias),
1810                qi(junction_alias),
1811                qi(&src_col),
1812                qi(source_alias),
1813                source_ref(target),
1814                qi(&target.alias),
1815                qi(junction_alias),
1816                qi(&tgt_col),
1817                qi(&target.alias),
1818            )
1819        }
1820        IrPathJoin::BacklinkSingle {
1821            source_alias,
1822            fk_col,
1823            target,
1824        } => {
1825            format!(
1826                " JOIN {} AS {} ON {}.{} = {}.\"id\"",
1827                source_ref(target),
1828                qi(&target.alias),
1829                qi(&target.alias),
1830                qi(fk_col),
1831                qi(source_alias),
1832            )
1833        }
1834        IrPathJoin::BacklinkMulti {
1835            source_alias,
1836            junction_alias,
1837            junction_table,
1838            module,
1839            owner_col,
1840            current_col,
1841            target,
1842        } => {
1843            format!(
1844                " JOIN {} AS {} ON {}.{} = {}.\"id\" JOIN {} AS {} ON {}.\"id\" = {}.{}",
1845                junction_ref(module, junction_table),
1846                qi(junction_alias),
1847                qi(junction_alias),
1848                qi(current_col),
1849                qi(source_alias),
1850                source_ref(target),
1851                qi(&target.alias),
1852                qi(&target.alias),
1853                qi(junction_alias),
1854                qi(owner_col),
1855            )
1856        }
1857    }
1858}
1859
1860/// Emit the CTE clause for a junction table INSERT (append / replace-insert).
1861/// `ids_name` is the row-source CTE to join against (usually "_ids", but
1862/// callers hoisting this into a shared top-level WITH block alongside other
1863/// user-bound statements pass a name prefixed for collision-safety instead).
1864/// The loop variable an append's value stands for, when the value is exactly
1865/// that variable (`memberships += membership`).
1866///
1867/// Such a value is compiled as a select over the target table narrowed to the
1868/// key the variable holds. Inside a `for` that key is already carried on the
1869/// driving rows, so the junction insert can read it from there instead of
1870/// correlating to a CTE it has no range-table entry for.
1871pub(crate) fn append_value_is_the_loop_variable(values: &IrMultiLinkValues, var: &str) -> bool {
1872    let IrMultiLinkValueSource::Select(sel) = &values.source else {
1873        return false;
1874    };
1875    let Some(IrExpr::BinOp(cmp)) = sel.filter.as_ref() else {
1876        return false;
1877    };
1878    matches!(
1879        (&cmp.left, &cmp.op, &cmp.right),
1880        (
1881            IrExpr::ColumnRef { column, .. },
1882            crate::parse::ast::BinOpKind::Eq,
1883            IrExpr::ForVar { name, .. },
1884        ) if column == "id" && name == var
1885    ) && values.link_props.is_empty()
1886}
1887
1888/// `emit_ml_append_cte` for a `for` body: the target comes from the column the
1889/// driving rows carry rather than from a correlated subquery.
1890/// The nested insert a multi-link append's value names, when it is one of this
1891/// update's own hoisted CTEs. Inside a `for` body such an insert runs once per
1892/// iteration, so it cannot stay a standalone CTE.
1893pub(crate) fn per_iteration_insert<'c>(mutation: &IrMultiLinkMutation, nested: &'c [IrCteDef]) -> Option<&'c IrInsert> {
1894    let IrMultiLinkValueSource::CteRef(name) = &mutation.values.source else {
1895        return None;
1896    };
1897    if !mutation.values.link_props.is_empty() {
1898        return None;
1899    }
1900    nested.iter().find(|c| &c.name == name).and_then(|c| match &c.stmt {
1901        IrStmt::Insert(ins) if ins.id_default_sql.is_some() && ins.multi_link_appends.is_empty() => Some(ins),
1902        _ => None,
1903    })
1904}
1905
1906/// A nested insert inside a `for` body: one row per driving row, with its id
1907/// taken from the column the driving CTE generated so the junction can pair
1908/// with it. The iterator CTE is joined in because the inserted values are
1909/// correlated subqueries that read the loop variable.
1910fn emit_for_nested_insert_cte(
1911    ins: &IrInsert,
1912    ids_name: &str,
1913    iter_alias: &str,
1914    cte_name: &str,
1915    id_col: &str,
1916) -> String {
1917    let mut cols = vec![qi("id")];
1918    let mut values = vec![format!("\"{}\".{}", ids_name, qi(id_col))];
1919    for (col, expr) in &ins.assignments {
1920        cols.push(qi(col));
1921        values.push(emit_expr(expr));
1922    }
1923    format!(
1924        "\"{}\" AS (\nINSERT INTO {} ({})\nSELECT {} FROM \"{}\", {}\nWHERE \"{}\".{} = {}.\"v\"\nRETURNING {}\n)",
1925        cte_name,
1926        target_ref(&ins.target),
1927        cols.join(", "),
1928        values.join(", "),
1929        ids_name,
1930        qi(iter_alias),
1931        ids_name,
1932        qi("_iter"),
1933        qi(iter_alias),
1934        qi("id"),
1935    )
1936}
1937
1938fn emit_for_ml_append_cte(mutation: &IrMultiLinkMutation, ids_name: &str, cte_name: &str, iter_col: &str) -> String {
1939    format!(
1940        "\"{}\" AS (\nINSERT INTO {} ({}, {})\nSELECT \"{}\".\"id\", \"{}\".{} FROM \"{}\"\nON CONFLICT DO NOTHING\nRETURNING {}, {}\n)",
1941        cte_name,
1942        qn(&mutation.module, &mutation.junction_table),
1943        qi(&mutation.source_col),
1944        qi(&mutation.target_col),
1945        ids_name,
1946        ids_name,
1947        qi(iter_col),
1948        ids_name,
1949        qi(&mutation.source_col),
1950        qi(&mutation.target_col),
1951    )
1952}
1953
1954/// `ids_alias` is the name the rows being written go by inside the value:
1955/// a walk off the row (`+= (select .notifications …)`) is correlated to it.
1956fn emit_ml_append_cte(mutation: &IrMultiLinkMutation, ids_name: &str, ids_alias: &str, cte_name: &str) -> String {
1957    let mut prop_names = vec![];
1958    collect_link_prop_names(&mutation.values, &mut prop_names);
1959    let vals_ref = emit_multilink_values_subquery(&mutation.values, &prop_names);
1960
1961    let extra_cols: String = prop_names.iter().map(|n| format!(", {}", qi(n))).collect();
1962    let extra_select: String = prop_names.iter().map(|n| format!(", \"_v\".{}", qi(n))).collect();
1963
1964    // Re-checking an already-linked target with a new property value must
1965    // update in place rather than error — a bare `DO NOTHING` (the no-
1966    // properties case) would silently keep the old value instead.
1967    let conflict_clause = if mutation.single {
1968        // A junction-backed single link's table has `PRIMARY KEY (source)`
1969        // alone (D3, cardinality-one) — the paired `_clr_i` DELETE (see
1970        // `ml_clear_exclusion`) removes any existing row for this source in
1971        // a sibling CTE that shares this whole statement's one snapshot, so
1972        // this INSERT still "sees" that old row and treats it as a
1973        // same-key conflict regardless of what the new target is — a plain
1974        // `DO NOTHING` would then silently drop a genuine reassignment.
1975        // `source` alone is always this table's conflict target, so this
1976        // must always be an upsert, not just when link properties exist.
1977        let mut sets = vec![format!(
1978            "{} = EXCLUDED.{}",
1979            qi(&mutation.target_col),
1980            qi(&mutation.target_col)
1981        )];
1982        sets.extend(prop_names.iter().map(|n| format!("{} = EXCLUDED.{}", qi(n), qi(n))));
1983        format!(
1984            "ON CONFLICT ({}) DO UPDATE SET {}",
1985            qi(&mutation.source_col),
1986            sets.join(", ")
1987        )
1988    } else if prop_names.is_empty() {
1989        "ON CONFLICT DO NOTHING".to_string()
1990    } else {
1991        let sets: Vec<String> = prop_names
1992            .iter()
1993            .map(|n| format!("{} = EXCLUDED.{}", qi(n), qi(n)))
1994            .collect();
1995        format!(
1996            "ON CONFLICT ({}, {}) DO UPDATE SET {}",
1997            qi(&mutation.source_col),
1998            qi(&mutation.target_col),
1999            sets.join(", "),
2000        )
2001    };
2002
2003    // A subquery value may read the row it is written for, so it joins
2004    // laterally; a bare CTE name cannot take LATERAL.
2005    let lateral = if vals_ref.starts_with('(') { "LATERAL " } else { "" };
2006    let ins = format!(
2007        "INSERT INTO {} ({}, {}{})\nSELECT {}.\"id\", \"_v\".\"id\"{} FROM \"{}\" AS {} CROSS JOIN {}{} AS \"_v\"\n{}\nRETURNING {}, {}",
2008        qn(&mutation.module, &mutation.junction_table),
2009        qi(&mutation.source_col),
2010        qi(&mutation.target_col),
2011        extra_cols,
2012        qi(ids_alias),
2013        extra_select,
2014        ids_name,
2015        qi(ids_alias),
2016        lateral,
2017        vals_ref,
2018        conflict_clause,
2019        qi(&mutation.source_col),
2020        qi(&mutation.target_col),
2021    );
2022    format!("\"{}\" AS (\n{}\n)", cte_name, ins)
2023}
2024
2025/// Emit the CTE clause for a junction table DELETE (remove). See
2026/// emit_ml_append_cte re: `ids_name` and `ids_alias`.
2027fn emit_ml_remove_cte(mutation: &IrMultiLinkMutation, ids_name: &str, ids_alias: &str, cte_name: &str) -> String {
2028    // Removal never carries link-property assignments (rejected at compile
2029    // time in ir/compiler.rs), so no extra columns are ever needed here.
2030    let vals_ref = emit_multilink_values_subquery(&mutation.values, &[]);
2031    // A walk off the row (`-= (select .line_items …)`) is correlated to
2032    // `ids_alias`, so the rows being written are joined in under that name
2033    // rather than read through an uncorrelated `IN`.
2034    let del = format!(
2035        "DELETE FROM {} AS \"_jn\"\nUSING \"{}\" AS {}\nWHERE \"_jn\".{} = {}.\"id\"\n  AND \"_jn\".{} IN (SELECT \"id\" FROM {})\nRETURNING \"_jn\".{}, \"_jn\".{}",
2036        qn(&mutation.module, &mutation.junction_table),
2037        ids_name,
2038        qi(ids_alias),
2039        qi(&mutation.source_col),
2040        qi(ids_alias),
2041        qi(&mutation.target_col),
2042        vals_ref,
2043        qi(&mutation.source_col),
2044        qi(&mutation.target_col),
2045    );
2046    format!("\"{}\" AS (\n{}\n)", cte_name, del)
2047}
2048
2049// ── Conflict helper ─────────────────────────────────────────────────────────
2050
2051use crate::ir::IrConflict;
2052
2053fn emit_conflict(sql: &mut String, conflict: &IrConflict) {
2054    // `unless conflict on (.email, .invitee)` names several columns, not one
2055    // composite value: Postgres infers the arbiter index from a column list,
2056    // and would look for an expression index on the tuple instead.
2057    let on_sql = conflict.on.as_ref().map(|e| match e {
2058        IrExpr::Tuple(elements) | IrExpr::Row { elements, .. } => {
2059            format!("({})", elements.iter().map(emit_expr).collect::<Vec<_>>().join(", "))
2060        }
2061        other => format!("({})", emit_expr(other)),
2062    });
2063    match (&on_sql, &conflict.do_update) {
2064        (None, None) => sql.push_str(" ON CONFLICT DO NOTHING"),
2065        (Some(on), None) => sql.push_str(&format!(" ON CONFLICT {} DO NOTHING", on)),
2066        (None, Some(updates)) => {
2067            sql.push_str(&format!(" ON CONFLICT DO UPDATE SET {}", do_update_sets(updates)));
2068        }
2069        (Some(on), Some(updates)) => {
2070            sql.push_str(&format!(
2071                " ON CONFLICT {} DO UPDATE SET {}",
2072                on,
2073                do_update_sets(updates),
2074            ));
2075        }
2076    }
2077    if conflict.do_update.is_some()
2078        && let Some(predicate) = &conflict.do_update_where
2079    {
2080        sql.push_str(&format!(" WHERE {}", emit_expr(predicate)));
2081    }
2082}
2083
2084fn do_update_sets(updates: &[(String, IrExpr)]) -> String {
2085    updates
2086        .iter()
2087        .map(|(col, expr)| format!("{} = {}", qi(col), emit_expr(expr)))
2088        .collect::<Vec<_>>()
2089        .join(", ")
2090}
2091
2092// ── FREE SELECT ─────────────────────────────────────────────────────────────
2093
2094/// Types returned as plain top-level columns rather than wrapped in the
2095/// `result` ROW() composite, so each keeps its own natural top-level shape.
2096fn is_integer_expr(expr: &IrExpr) -> bool {
2097    match expr {
2098        IrExpr::ColumnRef { pg_type, .. } => matches!(
2099            pg_type.as_str(),
2100            "int2" | "int4" | "int8" | "integer" | "bigint" | "smallint"
2101        ),
2102        IrExpr::Literal(crate::ir::IrLiteral::Int(_)) => true,
2103        IrExpr::BinOp(op) => is_integer_expr(&op.left) && is_integer_expr(&op.right),
2104        _ => false,
2105    }
2106}
2107
2108/// A cast to a declared tuple type's *own composite type* — as opposed to
2109/// one that travels as jsonb (`<json>`, or a tuple arriving as a
2110/// parameter). The value is a composite row, so it is described and
2111/// returned like one.
2112fn is_composite_tuple_cast(cast: &crate::ir::IrTypeCast) -> bool {
2113    cast.tuple_shape.is_some() && cast.pg_type != "jsonb" && cast.pg_type != "jsonb[]"
2114}
2115
2116fn is_raw_scalar(expr: &IrExpr) -> bool {
2117    // An array of tuples is the exception: it is described element-wise (see
2118    // `tuple_array_shape_node`), and a described array is read *at a
2119    // position* in the row — returned bare there is nothing to index.
2120    matches!(expr, IrExpr::Array(items) if !matches!(items.first(), Some(IrExpr::Row { .. })))
2121        || matches!(expr, IrExpr::TypeCast(c) if c.pg_type == "jsonb")
2122        // A row *is* the composite the result column holds; wrapping it in
2123        // another would bury its members one level down from the shape. The
2124        // same holds once it is cast to a declared tuple type.
2125        || matches!(expr, IrExpr::Row { .. })
2126        // An *array* of them is read at a position like any other array,
2127        // so only a single composite is the result column itself.
2128        || matches!(expr, IrExpr::TypeCast(c) if is_composite_tuple_cast(c) && !c.pg_type.ends_with("[]"))
2129        || matches!(expr, IrExpr::JsonbField { .. })
2130        || matches!(expr, IrExpr::JsonbIndex { .. })
2131}
2132
2133/// Free (non-schema-bound) SELECT rows — one or more UNION ALL branches with
2134/// a ROW(…) wrapper. `rows` is `sel.rows` with any leading `Bound` entries
2135/// already ruled out by `emit_select_stmt`'s dispatch (a schema object and a
2136/// free literal can never appear in the same UNION — `check_union_type_compat`
2137/// rejects that at compile time), so every entry here is `IrRowSource::Free`.
2138fn emit_free_rows(sel: &IrSelect, rows: &[IrRowSource], ctes: &[IrCteDef]) -> SqlOutput {
2139    use crate::query::ShapeNode;
2140
2141    let items: Vec<&IrFreeExpr> = rows
2142        .iter()
2143        .map(|r| match r {
2144            IrRowSource::Free(item) => item,
2145            IrRowSource::Bound { .. } => unreachable!("mixed Bound/Free rows rejected at compile time"),
2146        })
2147        .collect();
2148
2149    if items.is_empty() {
2150        return SqlOutput {
2151            sql: "SELECT NULL AS result WHERE FALSE".to_string(),
2152            shape: ShapeDescriptor {
2153                root: ShapeNode::Scalar {
2154                    name: String::new(),
2155                    position: 0,
2156                },
2157            },
2158            inference_plan: None,
2159        };
2160    }
2161
2162    // assert_exists / assert_distinct: set-returning — emit as unnest, not UNION ALL
2163    if items.len() == 1
2164        && let IrFreeExpr::AssertSet {
2165            fn_name,
2166            inner,
2167            message,
2168        } = items[0]
2169    {
2170        let array_sql = emit_array_source(inner) + &assert_message_arg(message);
2171        let mut sql = format!(
2172            "SELECT ROW(v) AS result FROM unnest(\"_pylon\".{}({})) AS _assert(v)",
2173            fn_name, array_sql,
2174        );
2175        if sel.distinct {
2176            sql = format!("SELECT DISTINCT * FROM ({}) AS \"_distinct\"", sql);
2177        }
2178        append_order_by(&mut sql, &sel.order_by);
2179        append_offset_limit(&mut sql, &sel.offset, &sel.limit);
2180        return SqlOutput {
2181            sql,
2182            shape: ShapeDescriptor {
2183                root: ShapeNode::Scalar {
2184                    name: String::new(),
2185                    position: 0,
2186                },
2187            },
2188            inference_plan: None,
2189        };
2190    }
2191
2192    let shape_root = free_item_shape(items.first().unwrap(), ctes);
2193
2194    let branches: Vec<String> = items
2195        .iter()
2196        .map(|item| match item {
2197            IrFreeExpr::Scalar(expr) => {
2198                // An object standing as the whole result is already the
2199                // composite row `result` is meant to be — wrapping it in
2200                // another ROW() would bury its columns one level down from
2201                // where its shape node says they are.
2202                if matches!(expr, IrExpr::ObjectSubquery(_)) {
2203                    return format!("SELECT v AS result, v FROM (SELECT {} AS v) AS _obj", emit_expr(expr));
2204                }
2205                // Arrays and jsonb are returned as plain top-level columns
2206                // rather than wrapped, so they keep their own shape node.
2207                if is_raw_scalar(expr) {
2208                    // `v` alongside it for the same reason the wrapped form
2209                    // has one: a `with` binding of an array is read by name.
2210                    format!("SELECT v AS result, v FROM (SELECT {} AS v) AS _raw", emit_expr(expr))
2211                } else {
2212                    // `result` is a ROW() composite for top-level decoding.
2213                    // `v` is the unwrapped scalar for use in CteRef expression context.
2214                    // Wrap in a subquery so volatile functions (nextval, etc.) are called once.
2215                    // Enum values also need a ::text cast inside the ROW() — same
2216                    // unregistered-OID problem as arrays/jsonb — but the bare `v`
2217                    // column stays natively typed for CteRef expression use.
2218                    let e = emit_expr(expr);
2219                    let row_value = if enum_type_of_expr(expr).is_some() {
2220                        "v::text"
2221                    } else {
2222                        "v"
2223                    };
2224                    // In a set of several, an element that is empty is not
2225                    // in the set at all (`{a.id, b.id}` with no `a` is just
2226                    // `b.id`) — where SQL would carry it as a NULL row.
2227                    let present = if items.len() > 1 { " WHERE v IS NOT NULL" } else { "" };
2228                    format!("SELECT ROW({row_value}) AS result, v FROM (SELECT {e} AS v) AS _scalar{present}")
2229                }
2230            }
2231            IrFreeExpr::FreeObject(fields) => {
2232                // Each field is computed once in an inner subquery (so a
2233                // volatile expression like nextval() isn't evaluated twice) and
2234                // exposed both packed into the `result` composite (for whole-
2235                // object passthrough/decoding) and as its own named column (for
2236                // `IrExpr::CteFieldRef` — `with x := {a := ...} select x.a`).
2237                let inner_cols: Vec<String> = fields
2238                    .iter()
2239                    .enumerate()
2240                    .map(|(i, (_, e))| format!("{} AS \"_f{}\"", emit_expr(e), i))
2241                    .collect();
2242                let row_items: Vec<String> = fields
2243                    .iter()
2244                    .enumerate()
2245                    .map(|(i, (_, e))| {
2246                        if enum_type_of_expr(e).is_some() {
2247                            format!("\"_f{}\"::text", i)
2248                        } else {
2249                            format!("\"_f{}\"", i)
2250                        }
2251                    })
2252                    .collect();
2253                let named_cols: Vec<String> = fields
2254                    .iter()
2255                    .enumerate()
2256                    .map(|(i, (name, _))| format!("\"_f{}\" AS {}", i, qi(name)))
2257                    .collect();
2258                format!(
2259                    "SELECT ROW({}) AS result, {} FROM (SELECT {}) AS _obj",
2260                    row_items.join(", "),
2261                    named_cols.join(", "),
2262                    inner_cols.join(", "),
2263                )
2264            }
2265            IrFreeExpr::Tuple(exprs) => {
2266                if exprs.len() == 1 {
2267                    format!("SELECT ROW({}) AS result", emit_free_field_expr(&exprs[0]))
2268                } else {
2269                    let parts: Vec<String> = exprs.iter().map(emit_free_field_expr).collect();
2270                    format!("SELECT ({}) AS result", parts.join(", "))
2271                }
2272            }
2273            IrFreeExpr::NamedTupleRow(fields) => {
2274                let parts: Vec<String> = fields.iter().map(|(_, e)| emit_free_field_expr(e)).collect();
2275                if parts.len() == 1 {
2276                    format!("SELECT ROW({}) AS result", parts[0])
2277                } else {
2278                    format!("SELECT ({}) AS result", parts.join(", "))
2279                }
2280            }
2281            IrFreeExpr::AssertSet { .. } => unreachable!("AssertSet is handled by early return above"),
2282            IrFreeExpr::CtePassthrough(name) => format!("SELECT \"result\" FROM {}", qi(name)),
2283        })
2284        .collect();
2285
2286    let union_sql = branches.join("\nUNION ALL\n");
2287
2288    let mut sql = if sel.distinct {
2289        // Wrap UNION ALL in an outer SELECT DISTINCT to deduplicate.
2290        format!("SELECT DISTINCT * FROM (\n{}\n) AS \"_distinct\"", union_sql)
2291    } else {
2292        union_sql
2293    };
2294    // A free SELECT's FILTER has no row of its own to sit beside, so it gates
2295    // the result the branches already produced rather than joining their own
2296    // WHERE clauses.
2297    if let Some(filter) = &sel.filter {
2298        sql = format!(
2299            "SELECT * FROM (\n{}\n) AS \"_filtered\"\nWHERE {}",
2300            sql,
2301            emit_expr(filter)
2302        );
2303    }
2304    append_order_by(&mut sql, &sel.order_by);
2305    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
2306
2307    SqlOutput {
2308        sql,
2309        shape: ShapeDescriptor { root: shape_root },
2310        inference_plan: None,
2311    }
2312}
2313
2314/// Determine whether `expr`'s runtime SQL type is a custom/enum type whose
2315/// OID is assigned by the database rather than fixed — and
2316/// if so, the Postgres-schema-qualified enum type name for `ShapeNode::Enum`
2317/// tagging (same convention `pg_quoted_to_pylon` expects). Covers both a
2318/// column reference to an enum-typed property (quoted pg_type) and a bare
2319/// enum literal (`default::Gender.Male`) — the two ways an enum value can
2320/// appear as a free scalar/object/tuple field, which (unlike a schema
2321/// object's `emit_scalar`) has no dedicated per-pointer descriptor to carry
2322/// this, so it must be recovered from the expression itself.
2323fn enum_type_of_expr(expr: &IrExpr) -> Option<QualifiedPgType> {
2324    match expr {
2325        IrExpr::ColumnRef { pg_type, .. } => QualifiedPgType::of(pg_type),
2326        IrExpr::EnumLiteral { pg_type, .. } => Some(QualifiedPgType {
2327            name: pg_quoted_to_pylon(pg_type),
2328            is_array: false,
2329        }),
2330        _ => None,
2331    }
2332}
2333
2334/// The enum type a shape position decodes as — wider than `enum_type_of_expr`,
2335/// which only answers for the values that need a `::text` cast emitting.
2336///
2337/// `[is Concrete].*` wraps each of the concrete type's properties in a
2338/// correlated subquery of its own, so the enum sits a level down and the
2339/// subquery's own emission has already cast it. Without looking through to it
2340/// the outer shape stays a plain `Scalar` and the label is handed back as a
2341/// bare string rather than the enum member.
2342fn enum_type_of_shape_expr(expr: &IrExpr) -> Option<QualifiedPgType> {
2343    if let Some(qualified) = enum_type_of_expr(expr) {
2344        return Some(qualified);
2345    }
2346    let IrExpr::Subquery(select) = expr else {
2347        return None;
2348    };
2349    let [IrRowSource::Bound { shape, .. }] = select.rows.as_slice() else {
2350        return None;
2351    };
2352    let [IrShapePointer::Scalar(scalar)] = shape.as_slice() else {
2353        return None;
2354    };
2355    QualifiedPgType::of(&scalar.pg_type)
2356}
2357
2358/// Emit `expr` for use as a free object/tuple field value — casts to
2359/// `::text` when it's enum-typed (see `enum_type_of_expr`), which keeps the
2360/// value's shape independent of the enum's database-assigned type OID.
2361/// (`pylon-pgcon` also discovers enum OIDs at connect time now, so this cast
2362/// is belt-and-braces rather than load-bearing for decoding.)
2363fn emit_free_field_expr(expr: &IrExpr) -> String {
2364    match enum_type_of_expr(expr) {
2365        Some(qualified) => format!("{}{}", emit_expr(expr), qualified.text_cast()),
2366        None => emit_expr(expr),
2367    }
2368}
2369
2370/// The shape of a tuple read as the composite row it is: one node per
2371/// member, at the position the composite holds it.
2372///
2373/// Where the jsonb form needs the declaration to tell a `decimal` member
2374/// from a `float64` (see `JsonMemberKind::Decimal`), a composite's field
2375/// carries its own type on the wire — so every member here is just read.
2376fn composite_tuple_shape(
2377    name: &str,
2378    position: usize,
2379    type_name: Option<String>,
2380    members: &[crate::query::JsonMember],
2381) -> crate::query::ShapeNode {
2382    use crate::query::ShapeNode;
2383    ShapeNode::Tuple {
2384        name: name.to_string(),
2385        position,
2386        elements: members
2387            .iter()
2388            .enumerate()
2389            .map(|(i, m)| composite_member_shape(i, m))
2390            .collect(),
2391        // All-unnamed means positional, which hydrates to a plain tuple.
2392        names: members.iter().map(|m| m.key.clone()).collect::<Option<Vec<_>>>(),
2393        type_name,
2394    }
2395}
2396
2397fn composite_member_shape(position: usize, member: &crate::query::JsonMember) -> crate::query::ShapeNode {
2398    use crate::query::{JsonMemberKind, ShapeNode};
2399    match &member.kind {
2400        // `Decimal` says "this jsonb number was written as a decimal"; a
2401        // composite's numeric field needs nobody to say so.
2402        JsonMemberKind::Scalar | JsonMemberKind::Decimal => ShapeNode::Scalar {
2403            name: String::new(),
2404            position,
2405        },
2406        JsonMemberKind::Enum { enum_type } => ShapeNode::Enum {
2407            name: String::new(),
2408            position,
2409            enum_type: enum_type.clone(),
2410        },
2411        JsonMemberKind::Tuple { type_name, members } => composite_tuple_shape("", position, type_name.clone(), members),
2412    }
2413}
2414
2415/// The shape of an array literal whose elements are tuples — `None` for
2416/// every other array, which decodes as the value it already did.
2417///
2418/// An array's elements all have one type, so the first describes them all.
2419/// Without this the array is read as a plain scalar and its elements come
2420/// back as bare tuples, their members' names gone.
2421fn tuple_array_shape_node(name: &str, position: usize, items: &[IrExpr]) -> Option<crate::query::ShapeNode> {
2422    let IrExpr::Row {
2423        elements,
2424        names,
2425        is_free_object,
2426    } = items.first()?
2427    else {
2428        return None;
2429    };
2430    Some(crate::query::ShapeNode::Array {
2431        name: name.to_string(),
2432        position,
2433        // Each element is the whole of its own value, the way an array of
2434        // objects reads.
2435        element: Box::new(row_shape_node("", 0, elements, names, *is_free_object)),
2436    })
2437}
2438
2439/// The shape of a tuple literal — the composite row it compiles to, one
2440/// element per member at the position the row holds it.
2441///
2442/// Each member is described by what it holds, so a nested tuple recurses and
2443/// an object element keeps its own shape: unlike the jsonb form, a composite
2444/// row has a place for either.
2445fn row_shape_node(
2446    name: &str,
2447    position: usize,
2448    elements: &[IrExpr],
2449    names: &Option<Vec<String>>,
2450    is_free_object: bool,
2451) -> crate::query::ShapeNode {
2452    use crate::query::{Cardinality, ShapeNode};
2453    let members: Vec<ShapeNode> = elements
2454        .iter()
2455        .enumerate()
2456        .map(|(i, e)| {
2457            let member = names
2458                .as_ref()
2459                .and_then(|ns| ns.get(i))
2460                .map(String::as_str)
2461                .unwrap_or("");
2462            free_field_shape_node(member, i, e)
2463        })
2464        .collect();
2465    // The same row either way — but `{ a := 1 }` reads as an object, so it
2466    // is described as one: its fields are a free object's pointers rather
2467    // than a tuple's members, which is what hydrates it to `pylon.Object`
2468    // and renders it expandable instead of as `(a := 1)`.
2469    if is_free_object {
2470        return ShapeNode::Object {
2471            name: name.to_string(),
2472            type_name: None,
2473            position,
2474            cardinality: Cardinality::Many,
2475            pointers: members,
2476            has_implicit_id: false,
2477        };
2478    }
2479    ShapeNode::Tuple {
2480        name: name.to_string(),
2481        position,
2482        elements: members,
2483        names: names.clone(),
2484        // A tuple written out in the query names no declared type to
2485        // hydrate into.
2486        type_name: None,
2487    }
2488}
2489
2490/// Shape node for one free scalar/object/tuple field — `Enum` when the
2491/// value is enum-typed (see `enum_type_of_expr`), else a plain `Scalar`.
2492fn free_field_shape_node(name: &str, position: usize, expr: &IrExpr) -> crate::query::ShapeNode {
2493    use crate::query::{Cardinality, ShapeNode};
2494    if let IrExpr::Row {
2495        elements,
2496        names,
2497        is_free_object,
2498    } = expr
2499    {
2500        return row_shape_node(name, position, elements, names, *is_free_object);
2501    }
2502    if let IrExpr::Array(items) = expr
2503        && let Some(node) = tuple_array_shape_node(name, position, items)
2504    {
2505        return node;
2506    }
2507    if let IrExpr::ObjectSubquery(sel) = expr {
2508        let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
2509            unreachable!("an object subquery is always schema-bound")
2510        };
2511        let (_, nodes) = build_shape(shape, &source.alias);
2512        return ShapeNode::Object {
2513            name: name.to_string(),
2514            type_name: Some(source.type_name.clone()),
2515            position,
2516            cardinality: Cardinality::Optional,
2517            pointers: prepend_type(nodes),
2518            has_implicit_id: shape_has_implicit_id(shape),
2519        };
2520    }
2521    // A walk landing on one object: `(select … limit 1).latest_data { … }`.
2522    if matches!(expr, IrExpr::ObjectPathSubquery(_) | IrExpr::ObjectPathUnion { .. }) {
2523        return expr_shape_node(name, position, expr);
2524    }
2525    // A set of objects keeps its rows' own shape, as it does on a schema
2526    // shape; described as a scalar, each row hydrates as a bare tuple.
2527    if let IrExpr::ArrayFromSelect(source) = expr
2528        && matches!(
2529            source.as_ref(),
2530            IrArraySource::ObjectSelect(_)
2531                | IrArraySource::ObjectFunction(_)
2532                | IrArraySource::PathSelect(_)
2533                | IrArraySource::Group(_)
2534        )
2535    {
2536        return expr_shape_node(name, position, expr);
2537    }
2538    match enum_type_of_shape_expr(expr) {
2539        Some(qualified) => qualified.shape_node(name.to_string(), position),
2540        None => ShapeNode::Scalar {
2541            name: name.to_string(),
2542            position,
2543        },
2544    }
2545}
2546
2547/// Shape node for an arbitrary compiled expression appearing at a shape
2548/// position (a computed pointer, or a free scalar/tuple/object field) —
2549/// `NamedTuple`/`JsonScalar` when the expression produces jsonb (tuple
2550/// literals, nested free objects, tuple-typed casts), `RawScalar` when
2551/// it is returned as a bare top-level column, `Enum` when
2552/// enum-typed, else a plain `Scalar`.
2553fn expr_shape_node(name: &str, position: usize, expr: &IrExpr) -> crate::query::ShapeNode {
2554    use crate::query::{Cardinality, ShapeNode};
2555    match expr {
2556        IrExpr::Row {
2557            elements,
2558            names,
2559            is_free_object,
2560        } => row_shape_node(name, position, elements, names, *is_free_object),
2561        IrExpr::Array(items) if matches!(items.first(), Some(IrExpr::Row { .. })) => {
2562            tuple_array_shape_node(name, position, items).expect("matched as an array of rows just above")
2563        }
2564        IrExpr::ObjectPathUnion { branches, multi, .. } => {
2565            let first = branches.first().expect("a union has at least one branch");
2566            let IrPathResult::Object {
2567                alias,
2568                type_name,
2569                shape,
2570            } = &first.result
2571            else {
2572                unreachable!("an object path union's branches always land on objects")
2573            };
2574            let (_, pointer_nodes) = build_shape(shape, alias);
2575            let object = ShapeNode::Object {
2576                name: if *multi { String::new() } else { name.to_string() },
2577                type_name: Some(type_name.clone()),
2578                position: if *multi { 0 } else { position },
2579                cardinality: if *multi {
2580                    Cardinality::Many
2581                } else {
2582                    Cardinality::Optional
2583                },
2584                pointers: prepend_type(pointer_nodes),
2585                has_implicit_id: shape_has_implicit_id(shape),
2586            };
2587            if *multi {
2588                ShapeNode::Array {
2589                    name: name.to_string(),
2590                    position,
2591                    element: Box::new(object),
2592                }
2593            } else {
2594                object
2595            }
2596        }
2597        IrExpr::ObjectPathSubquery(ps) => {
2598            let IrPathResult::Object {
2599                alias,
2600                type_name,
2601                shape,
2602            } = &ps.result
2603            else {
2604                unreachable!("an object path subquery always lands on an object")
2605            };
2606            let (_, pointer_nodes) = build_shape(shape, alias);
2607            ShapeNode::Object {
2608                name: name.to_string(),
2609                type_name: Some(type_name.clone()),
2610                position,
2611                cardinality: Cardinality::Optional,
2612                pointers: prepend_type(pointer_nodes),
2613                has_implicit_id: shape_has_implicit_id(shape),
2614            }
2615        }
2616        // A cast to a declared tuple type's own composite: the value is the
2617        // row, read a member at a time, not a jsonb blob.
2618        IrExpr::TypeCast(c) if is_composite_tuple_cast(c) => {
2619            let shape = c.tuple_shape.as_ref().expect("checked by `is_composite_tuple_cast`");
2620            // `<array<module::Money>>`: the shape resolved above is one
2621            // element's, each the whole of its own value.
2622            let is_array = c.pg_type.ends_with("[]");
2623            let tuple = composite_tuple_shape(
2624                if is_array { "" } else { name },
2625                if is_array { 0 } else { position },
2626                shape.type_name.clone(),
2627                &shape.members,
2628            );
2629            match is_array {
2630                true => ShapeNode::Array {
2631                    name: name.to_string(),
2632                    position,
2633                    element: Box::new(tuple),
2634                },
2635                false => tuple,
2636            }
2637        }
2638        IrExpr::TypeCast(c) if c.tuple_shape.is_some() => {
2639            let shape = c.tuple_shape.as_ref().unwrap();
2640            // `<array<tuple<…>>>`: the shape resolved above is one element's,
2641            // each sitting at position 0 of its own jsonb value.
2642            let is_array = c.pg_type.ends_with("[]");
2643            let tuple = ShapeNode::NamedTuple {
2644                name: if is_array { String::new() } else { name.to_string() },
2645                position: if is_array { 0 } else { position },
2646                type_name: shape.type_name.clone(),
2647                members: Some(shape.members.clone()),
2648                is_free_object: false,
2649            };
2650            if is_array {
2651                ShapeNode::Array {
2652                    name: name.to_string(),
2653                    position,
2654                    element: Box::new(tuple),
2655                }
2656            } else {
2657                tuple
2658            }
2659        }
2660        // A `<json>` cast at a shape position is an ordinary column of the row
2661        // tuple, so it needs the pointer's own name and position like any
2662        // other leaf. `JsonScalar` is the *root-only* form, where the result
2663        // column itself is the value and there is no pointer to name — it
2664        // carries neither field, and `free_item_shape` is what selects it.
2665        IrExpr::TypeCast(c) if c.pg_type == "jsonb" => ShapeNode::Scalar {
2666            name: name.to_string(),
2667            position,
2668        },
2669        // A path select aggregated into an array keeps its rows' own shape, so
2670        // the elements hydrate as objects rather than as opaque scalars.
2671        IrExpr::ArrayFromSelect(source) => match source.as_ref() {
2672            IrArraySource::ObjectFunction(fs) => {
2673                let (_, nodes) = build_shape(&fs.shape, &fs.alias);
2674                ShapeNode::Array {
2675                    name: name.to_string(),
2676                    position,
2677                    element: Box::new(ShapeNode::Object {
2678                        name: String::new(),
2679                        type_name: Some(fs.type_name.clone()),
2680                        position: 0,
2681                        cardinality: Cardinality::Many,
2682                        pointers: prepend_type(nodes),
2683                        has_implicit_id: shape_has_implicit_id(&fs.shape),
2684                    }),
2685                }
2686            }
2687            IrArraySource::Group(grp) => ShapeNode::Array {
2688                name: name.to_string(),
2689                position,
2690                element: Box::new(emit_group(grp).shape.root),
2691            },
2692            IrArraySource::ObjectSelect(sel) => {
2693                let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
2694                    unreachable!("IrArraySource::ObjectSelect is always schema-bound")
2695                };
2696                let (_, nodes) = build_shape(shape, &source.alias);
2697                ShapeNode::Array {
2698                    name: name.to_string(),
2699                    position,
2700                    element: Box::new(ShapeNode::Object {
2701                        name: String::new(),
2702                        type_name: Some(source.type_name.clone()),
2703                        position: 0,
2704                        cardinality: Cardinality::Many,
2705                        pointers: prepend_type(nodes),
2706                        has_implicit_id: shape_has_implicit_id(shape),
2707                    }),
2708                }
2709            }
2710            IrArraySource::PathSelect(ps) => match &ps.result {
2711                IrPathResult::Object {
2712                    alias,
2713                    type_name,
2714                    shape,
2715                } => {
2716                    let (_, pointer_nodes) = build_shape(shape, alias);
2717                    ShapeNode::Array {
2718                        name: name.to_string(),
2719                        position,
2720                        element: Box::new(ShapeNode::Object {
2721                            name: String::new(),
2722                            type_name: Some(type_name.clone()),
2723                            position: 0,
2724                            cardinality: Cardinality::Many,
2725                            pointers: prepend_type(pointer_nodes),
2726                            has_implicit_id: shape_has_implicit_id(shape),
2727                        }),
2728                    }
2729                }
2730                IrPathResult::Scalar(e, tuple_shape) if yields_array(e) => ShapeNode::Array {
2731                    name: name.to_string(),
2732                    position,
2733                    element: Box::new(aggregated_array_element(e, tuple_shape.as_ref())),
2734                },
2735                IrPathResult::Scalar(..) => ShapeNode::Scalar {
2736                    name: name.to_string(),
2737                    position,
2738                },
2739            },
2740            _ => ShapeNode::Scalar {
2741                name: name.to_string(),
2742                position,
2743            },
2744        },
2745        e if is_raw_scalar(e) => ShapeNode::RawScalar,
2746        e => free_field_shape_node(name, position, e),
2747    }
2748}
2749
2750fn free_item_shape(item: &IrFreeExpr, ctes: &[IrCteDef]) -> crate::query::ShapeNode {
2751    use crate::query::{Cardinality, ShapeNode};
2752    match item {
2753        IrFreeExpr::Scalar(e) => match e {
2754            // Top level: the result column *is* the jsonb value rather than a
2755            // field inside a row tuple. A tuple-shaped cast is excluded because
2756            // `expr_shape_node` decodes that as a `NamedTuple`.
2757            IrExpr::TypeCast(c) if c.pg_type == "jsonb" && c.tuple_shape.is_none() => ShapeNode::JsonScalar,
2758            _ => expr_shape_node("", 0, e),
2759        },
2760        IrFreeExpr::FreeObject(fields) => ShapeNode::Object {
2761            name: String::new(),
2762            type_name: None,
2763            position: 0,
2764            cardinality: Cardinality::Many,
2765            pointers: fields
2766                .iter()
2767                .enumerate()
2768                .map(|(i, (name, e))| free_field_shape_node(name, i, e))
2769                .collect(),
2770            has_implicit_id: false,
2771        },
2772        IrFreeExpr::Tuple(exprs) => ShapeNode::Tuple {
2773            name: String::new(),
2774            position: 0,
2775            elements: exprs
2776                .iter()
2777                .enumerate()
2778                .map(|(i, e)| free_field_shape_node("", i, e))
2779                .collect(),
2780            names: None,
2781            // A tuple literal is written out, not declared, so there is no
2782            // registered class for it to hydrate into.
2783            type_name: None,
2784        },
2785        IrFreeExpr::NamedTupleRow(fields) => ShapeNode::Tuple {
2786            name: String::new(),
2787            position: 0,
2788            elements: fields
2789                .iter()
2790                .enumerate()
2791                .map(|(i, (name, e))| free_field_shape_node(name, i, e))
2792                .collect(),
2793            names: Some(fields.iter().map(|(name, _)| name.clone()).collect()),
2794            type_name: None,
2795        },
2796        IrFreeExpr::AssertSet { .. } => ShapeNode::Scalar {
2797            name: String::new(),
2798            position: 0,
2799        },
2800        // The referenced CTE's own `result` column carries whatever shape its
2801        // defining free row has (a bare scalar, a multi-field free object, a
2802        // tuple, or even another passthrough) — resolve it by looking the CTE
2803        // up rather than assuming Scalar, otherwise a multi-field free object
2804        // bound in a `WITH` clause decodes as just its first field's value.
2805        IrFreeExpr::CtePassthrough(name) => ctes
2806            .iter()
2807            .find(|c| &c.name == name)
2808            .and_then(|c| match &c.stmt {
2809                IrStmt::Select(sel) => match sel.rows.first() {
2810                    Some(IrRowSource::Free(inner)) => Some(free_item_shape(inner, ctes)),
2811                    _ => None,
2812                },
2813                // `with entries := Webhook.headers select entries` reads the
2814                // CTE's own `result` column, so it decodes exactly as the
2815                // path select itself does — which for an enum or a tuple is
2816                // not the plain scalar the fallback below assumes.
2817                IrStmt::PathSelect(ps) => Some(emit_path_select(ps).shape.root),
2818                _ => None,
2819            })
2820            .unwrap_or(ShapeNode::Scalar {
2821                name: String::new(),
2822                position: 0,
2823            }),
2824    }
2825}
2826
2827// ── PATH SELECT ─────────────────────────────────────────────────────────────
2828
2829fn emit_path_joins(root: &IrSource, joins: &[IrPathJoin]) -> String {
2830    let mut parts = vec![format!("{} AS {}", source_ref(root), qi(&root.alias))];
2831    for join in joins {
2832        match join {
2833            IrPathJoin::Single {
2834                source_alias,
2835                fk_col,
2836                target,
2837            } => {
2838                parts.push(format!(
2839                    "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2840                    source_ref(target),
2841                    qi(&target.alias),
2842                    qi(source_alias),
2843                    qi(fk_col),
2844                    qi(&target.alias),
2845                ));
2846            }
2847            IrPathJoin::Multi {
2848                source_alias,
2849                junction_alias,
2850                join,
2851                target,
2852            } => {
2853                match join {
2854                    IrMultiLinkJoin::Standard { junction_table, module } => {
2855                        parts.push(format!(
2856                            "JOIN {} AS {} ON {}.\"source\" = {}.\"id\"",
2857                            junction_ref(module, junction_table),
2858                            qi(junction_alias),
2859                            qi(junction_alias),
2860                            qi(source_alias),
2861                        ));
2862                        parts.push(format!(
2863                            "JOIN {} AS {} ON {}.\"id\" = {}.\"target\"",
2864                            source_ref(target),
2865                            qi(&target.alias),
2866                            qi(&target.alias),
2867                            qi(junction_alias),
2868                        ));
2869                    }
2870                    IrMultiLinkJoin::Through {
2871                        junction_table,
2872                        module,
2873                        source_col,
2874                        target_col,
2875                    } => {
2876                        parts.push(format!(
2877                            "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2878                            junction_ref(module, junction_table),
2879                            qi(junction_alias),
2880                            qi(junction_alias),
2881                            qi(source_col),
2882                            qi(source_alias),
2883                        ));
2884                        parts.push(format!(
2885                            "JOIN {} AS {} ON {}.\"id\" = {}.{}",
2886                            source_ref(target),
2887                            qi(&target.alias),
2888                            qi(&target.alias),
2889                            qi(junction_alias),
2890                            qi(target_col),
2891                        ));
2892                    }
2893                    // See the identical comment in `emit_path_join_sql` above.
2894                    IrMultiLinkJoin::BacklinkFk { .. } | IrMultiLinkJoin::BacklinkJunction { .. } => {
2895                        unreachable!("a forward multi-link path step never uses a backlink join variant")
2896                    }
2897                }
2898            }
2899            IrPathJoin::BacklinkSingle {
2900                source_alias,
2901                fk_col,
2902                target,
2903            } => {
2904                parts.push(format!(
2905                    "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2906                    source_ref(target),
2907                    qi(&target.alias),
2908                    qi(&target.alias),
2909                    qi(fk_col),
2910                    qi(source_alias),
2911                ));
2912            }
2913            // LATERAL, not a plain join: the arguments read from the alias
2914            // the traversal has reached, which a non-lateral function call
2915            // in FROM cannot see.
2916            IrPathJoin::Function {
2917                fn_module,
2918                fn_name,
2919                args,
2920                target,
2921            } => {
2922                let args_sql = args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
2923                parts.push(format!(
2924                    "CROSS JOIN LATERAL {}.{}({}) AS {}",
2925                    pg_schema(fn_module),
2926                    qi(fn_name),
2927                    args_sql,
2928                    qi(&target.alias),
2929                ));
2930            }
2931            IrPathJoin::Lateral { inner, target } => {
2932                let projected = match &inner.result {
2933                    IrPathResult::Object { alias, .. } => alias.clone(),
2934                    IrPathResult::Scalar(..) => {
2935                        unreachable!("a lateral step is only built for an object-valued computed")
2936                    }
2937                };
2938                let mut body = format!(
2939                    "SELECT {}.* FROM {}",
2940                    qi(&projected),
2941                    emit_path_joins(&inner.root, &inner.joins)
2942                );
2943                append_filter(&mut body, &inner.filter);
2944                append_order_by(&mut body, &inner.order_by);
2945                append_offset_limit(&mut body, &inner.offset, &inner.limit);
2946                parts.push(format!("JOIN LATERAL (\n{}\n) AS {} ON TRUE", body, qi(&target.alias)));
2947            }
2948            IrPathJoin::BacklinkMulti {
2949                source_alias,
2950                junction_alias,
2951                junction_table,
2952                module,
2953                owner_col,
2954                current_col,
2955                target,
2956            } => {
2957                parts.push(format!(
2958                    "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2959                    junction_ref(module, junction_table),
2960                    qi(junction_alias),
2961                    qi(junction_alias),
2962                    qi(current_col),
2963                    qi(source_alias),
2964                ));
2965                parts.push(format!(
2966                    "JOIN {} AS {} ON {}.\"id\" = {}.{}",
2967                    source_ref(target),
2968                    qi(&target.alias),
2969                    qi(&target.alias),
2970                    qi(junction_alias),
2971                    qi(owner_col),
2972                ));
2973            }
2974        }
2975    }
2976    parts.join("\n")
2977}
2978
2979/// Emit `ARRAY(SELECT scalar FROM source [JOINs] [WHERE filter])`.
2980fn emit_array_source(src: &IrArraySource) -> String {
2981    match src {
2982        IrArraySource::Select(s) => {
2983            // compile_subquery_to_array_source only ever constructs this
2984            // variant for a single schema-bound row.
2985            let [IrRowSource::Bound { source, shape }] = s.rows.as_slice() else {
2986                unreachable!("IrArraySource::Select is always schema-bound")
2987            };
2988            let scalar = match shape.first() {
2989                Some(IrShapePointer::Scalar(sf)) => format!("{}.{}", qi(&source.alias), qi(&sf.column)),
2990                _ => format!("{}.\"id\"", qi(&source.alias)),
2991            };
2992            let mut sql = format!("SELECT {} FROM {} AS {}", scalar, source_ref(source), qi(&source.alias));
2993            append_filter(&mut sql, &s.filter);
2994            format!("ARRAY({})", sql)
2995        }
2996        IrArraySource::ObjectFunction(fs) => {
2997            let (exprs, _) = build_shape(&fs.shape, &fs.alias);
2998            let mut parts = vec![sql_str(&fs.type_name) + "::text"];
2999            parts.extend(exprs);
3000            let args_sql = fs.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
3001            let mut sql = format!(
3002                "SELECT (\n    {}\n) FROM {}.{}({}) AS {}",
3003                parts.join(",\n    "),
3004                pg_schema(&fs.fn_module),
3005                qi(&fs.fn_name),
3006                args_sql,
3007                qi(&fs.alias),
3008            );
3009            append_filter(&mut sql, &fs.filter);
3010            append_order_by(&mut sql, &fs.order_by);
3011            append_offset_limit(&mut sql, &fs.offset, &fs.limit);
3012            format!("ARRAY({})", sql)
3013        }
3014        IrArraySource::ObjectSelect(s) => {
3015            let [IrRowSource::Bound { source, shape }] = s.rows.as_slice() else {
3016                unreachable!("IrArraySource::ObjectSelect is always schema-bound")
3017            };
3018            let (exprs, _) = build_shape(shape, &source.alias);
3019            let mut parts = vec![source_type_disc(source)];
3020            parts.extend(exprs);
3021            let mut sql = format!(
3022                "SELECT (\n    {}\n) FROM {} AS {}",
3023                parts.join(",\n    "),
3024                source_ref(source),
3025                qi(&source.alias)
3026            );
3027            append_filter(&mut sql, &s.filter);
3028            append_order_by(&mut sql, &s.order_by);
3029            append_offset_limit(&mut sql, &s.offset, &s.limit);
3030            format!("ARRAY({})", sql)
3031        }
3032        IrArraySource::Group(grp) => format!("ARRAY({})", emit_group(grp).sql),
3033        IrArraySource::StmtColumn { stmt, column } => format!(
3034            "ARRAY(SELECT {} FROM (\n{}\n) AS \"_rows\")",
3035            qi(column),
3036            emit_dml_as_cte_source(stmt),
3037        ),
3038        IrArraySource::PathSelect(ps) => {
3039            let scalar = match &ps.result {
3040                // PostgreSQL has no array of arrays, so a set of
3041                // array-valued properties cannot be accumulated into one
3042                // ("cannot accumulate arrays of different dimensionality").
3043                // Each element travels as a record of one field instead —
3044                // the same way a set of objects already does, and with every
3045                // element keeping its own column type rather than being
3046                // flattened through jsonb.
3047                IrPathResult::Scalar(e, _) if yields_array(e) => {
3048                    // Same reason `emit_scalar` casts one: a custom type's
3049                    // OID is assigned by the database and means nothing to a
3050                    // static record decode.
3051                    let cast = match e {
3052                        IrExpr::ColumnRef { pg_type, .. } => {
3053                            QualifiedPgType::of(pg_type).map(|qualified| qualified.text_cast())
3054                        }
3055                        _ => None,
3056                    };
3057                    format!("ROW({}{})", emit_expr(e), cast.unwrap_or(""))
3058                }
3059                IrPathResult::Scalar(e, _) => emit_expr(e),
3060                // Aggregating objects keeps the whole row, not the id: this is
3061                // how a computed pointer that walks through a multi-link comes
3062                // back as hydrated rows rather than a list of uuids.
3063                IrPathResult::Object {
3064                    alias,
3065                    type_name,
3066                    shape,
3067                } => {
3068                    let (pointer_exprs, _) = build_shape(shape, alias);
3069                    let mut parts = vec![result_type_disc(ps, alias, type_name)];
3070                    parts.extend(pointer_exprs);
3071                    format!("(\n    {}\n)", parts.join(",\n    "))
3072                }
3073            };
3074            let from_sql = emit_path_joins(&ps.root, &ps.joins);
3075            let mut sql = format!(
3076                "SELECT {}{} FROM {}",
3077                if ps.distinct { "DISTINCT " } else { "" },
3078                scalar,
3079                from_sql
3080            );
3081            append_filter(&mut sql, &ps.filter);
3082            append_order_by(&mut sql, &ps.order_by);
3083            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
3084            format!("ARRAY({})", sql)
3085        }
3086        IrArraySource::RawExpr {
3087            source,
3088            poly_implementors,
3089            poly_columns,
3090            expr,
3091        } => {
3092            let from_sql = if !poly_implementors.is_empty() {
3093                format!(
3094                    "(\n{}\n) AS {}",
3095                    emit_poly_union(poly_implementors, poly_columns),
3096                    qi(&source.alias)
3097                )
3098            } else {
3099                format!("{} AS {}", source_ref(source), qi(&source.alias))
3100            };
3101            format!("ARRAY(SELECT {} FROM {})", emit_expr(expr), from_sql)
3102        }
3103    }
3104}
3105
3106/// Emit a group-key expression, casting schema-qualified enum ColumnRefs to `::text`
3107/// so they decode outside of a typed composite.
3108fn emit_key_expr(expr: &IrExpr) -> String {
3109    if let IrExpr::ColumnRef { alias, column, pg_type } = expr
3110        && let Some(qualified) = QualifiedPgType::of(pg_type)
3111    {
3112        let col_ref = if alias.is_empty() {
3113            qi(column)
3114        } else {
3115            format!("{}.{}", qi(alias), qi(column))
3116        };
3117        return format!("{}{}", col_ref, qualified.text_cast());
3118    }
3119    emit_expr(expr)
3120}
3121
3122/// The rows a group reads, as `(FROM, WHERE)`. A per-group OFFSET/LIMIT
3123/// can't be a plain LIMIT — that would cut whole groups — so the rows are
3124/// ranked within each key first and only the wanted slice kept.
3125fn group_rows(grp: &IrGroup) -> (String, Option<String>) {
3126    let alias = &grp.source.alias;
3127    if grp.limit.is_none() && grp.offset.is_none() {
3128        return (
3129            format!("{} AS {}", source_ref(&grp.source), qi(alias)),
3130            grp.filter.as_ref().map(emit_expr),
3131        );
3132    }
3133    let ranked = format!(
3134        "(SELECT {}.*, row_number() OVER (PARTITION BY {}{}) AS \"__rk\"\n    FROM {} AS {}{}) AS {}",
3135        qi(alias),
3136        group_by_sql(grp),
3137        group_element_order(grp),
3138        source_ref(&grp.source),
3139        qi(alias),
3140        grp.filter
3141            .as_ref()
3142            .map(|f| format!("\n    WHERE {}", emit_expr(f)))
3143            .unwrap_or_default(),
3144        qi(alias),
3145    );
3146    let lower = grp.offset.as_ref().map(emit_expr).unwrap_or_else(|| "0".to_string());
3147    let mut conds = vec![format!("\"__rk\" > {}", lower)];
3148    if let Some(l) = &grp.limit {
3149        conds.push(format!("\"__rk\" <= {} + {}", lower, emit_expr(l)));
3150    }
3151    (ranked, Some(conds.join(" AND ")))
3152}
3153
3154fn group_by_sql(grp: &IrGroup) -> String {
3155    grp.keys
3156        .iter()
3157        .map(|(_, key_expr)| emit_expr(key_expr))
3158        .collect::<Vec<_>>()
3159        .join(", ")
3160}
3161
3162fn group_element_order(grp: &IrGroup) -> String {
3163    if grp.order_by.is_empty() {
3164        return String::new();
3165    }
3166    let s: Vec<_> = grp.order_by.iter().map(emit_sort_clause).collect();
3167    format!(" ORDER BY {}", s.join(", "))
3168}
3169
3170/// A `for` over a group's elements used as a CTE body: the rows themselves,
3171/// so the outer query can traverse them like any object binding.
3172fn emit_group_elements_source(grp: &IrGroup) -> String {
3173    let (from_sql, cond) = group_rows(grp);
3174    let mut sql = format!("SELECT {}.*\n    FROM {}", qi(&grp.source.alias), from_sql);
3175    if let Some(cond) = cond {
3176        sql.push_str(&format!("\n    WHERE {}", cond));
3177    }
3178    sql
3179}
3180
3181fn emit_group(grp: &IrGroup) -> SqlOutput {
3182    match &grp.output {
3183        IrGroupOutput::Groups => emit_group_rows(grp),
3184        IrGroupOutput::Projection(projection) => emit_group_projection(
3185            grp,
3186            &projection.pointers,
3187            &projection.order_by,
3188            &projection.offset,
3189            &projection.limit,
3190        ),
3191        IrGroupOutput::Elements => emit_group_elements(grp),
3192    }
3193}
3194
3195fn emit_group_projection(
3196    grp: &IrGroup,
3197    pointers: &[IrShapePointer],
3198    order_by: &[IrSort],
3199    offset: &Option<IrExpr>,
3200    limit: &Option<IrExpr>,
3201) -> SqlOutput {
3202    let (exprs, nodes) = build_shape(pointers, &grp.source.alias);
3203    let mut parts = vec!["NULL::text".to_string()];
3204    parts.extend(exprs);
3205    let (from_sql, cond) = group_rows(grp);
3206    let mut sql = format!(
3207        "SELECT (\n    {}\n) AS \"result\"\nFROM {}",
3208        parts.join(",\n    "),
3209        from_sql
3210    );
3211    if let Some(cond) = cond {
3212        sql.push_str(&format!("\nWHERE {}", cond));
3213    }
3214    sql.push_str(&format!("\nGROUP BY {}", group_by_sql(grp)));
3215    if !order_by.is_empty() {
3216        let sorts: Vec<_> = order_by.iter().map(emit_sort_clause).collect();
3217        sql.push_str(&format!("\nORDER BY {}", sorts.join(", ")));
3218    }
3219    append_offset_limit(&mut sql, offset, limit);
3220    SqlOutput {
3221        sql,
3222        shape: ShapeDescriptor {
3223            root: ShapeNode::Object {
3224                name: String::new(),
3225                type_name: None,
3226                position: 0,
3227                cardinality: Cardinality::Many,
3228                pointers: nodes,
3229                has_implicit_id: false,
3230            },
3231        },
3232        inference_plan: None,
3233    }
3234}
3235
3236fn emit_group_elements(grp: &IrGroup) -> SqlOutput {
3237    let (exprs, nodes) = build_shape(&grp.shape, &grp.source.alias);
3238    let mut parts = vec![source_type_disc(&grp.source)];
3239    parts.extend(exprs);
3240    let (from_sql, cond) = group_rows(grp);
3241    let mut sql = format!(
3242        "SELECT (\n    {}\n) AS \"result\"\nFROM {}",
3243        parts.join(",\n    "),
3244        from_sql
3245    );
3246    if let Some(cond) = cond {
3247        sql.push_str(&format!("\nWHERE {}", cond));
3248    }
3249    SqlOutput {
3250        sql,
3251        shape: ShapeDescriptor {
3252            root: ShapeNode::Object {
3253                name: String::new(),
3254                type_name: Some(grp.source.type_name.clone()),
3255                position: 0,
3256                cardinality: Cardinality::Many,
3257                pointers: prepend_type(nodes),
3258                has_implicit_id: shape_has_implicit_id(&grp.shape),
3259            },
3260        },
3261        inference_plan: None,
3262    }
3263}
3264
3265fn emit_group_rows(grp: &IrGroup) -> SqlOutput {
3266    let alias = &grp.source.alias;
3267    let (shape_exprs, shape_nodes) = build_shape(&grp.shape, alias);
3268
3269    // Build the elements ROW: type discriminator at pos 0, then shape pointers.
3270    let mut elem_row_parts = vec![source_type_disc(&grp.source)];
3271    elem_row_parts.extend(shape_exprs);
3272    let elem_row = elem_row_parts.join(",\n            ");
3273
3274    // Key positions: NULL at pos 0 (type slot), keys at 1..=N, grouping at N+1, elements at N+2.
3275    let n_keys = grp.keys.len();
3276    let grouping_pos = n_keys + 1;
3277    let elements_pos = n_keys + 2;
3278
3279    // Build key SQL expressions and ShapeNodes.
3280    let mut key_exprs_sql: Vec<String> = vec![];
3281    let mut key_nodes: Vec<ShapeNode> = vec![];
3282    for (i, (key_name, key_expr)) in grp.keys.iter().enumerate() {
3283        let pos = i + 1;
3284        if let IrExpr::ColumnRef { pg_type, .. } = key_expr
3285            && let Some(qualified) = QualifiedPgType::of(pg_type)
3286        {
3287            key_exprs_sql.push(emit_key_expr(key_expr));
3288            key_nodes.push(qualified.shape_node(key_name.clone(), pos));
3289            continue;
3290        }
3291        key_exprs_sql.push(emit_expr(key_expr));
3292        key_nodes.push(ShapeNode::Scalar {
3293            name: key_name.clone(),
3294            position: pos,
3295        });
3296    }
3297
3298    // Build the outer SELECT tuple.
3299    let mut outer_parts = vec!["NULL::text".to_string()];
3300    outer_parts.extend(key_exprs_sql.clone());
3301    let key_names_sql = grp
3302        .keys
3303        .iter()
3304        .map(|(name, _)| format!("'{}'", name))
3305        .collect::<Vec<_>>()
3306        .join(", ");
3307    outer_parts.push(format!("ARRAY[{}]::text[]", key_names_sql));
3308    outer_parts.push(format!(
3309        "array_agg(ROW(\n            {}\n        )::record{})",
3310        elem_row,
3311        group_element_order(grp)
3312    ));
3313
3314    let outer_tuple = outer_parts.join(",\n    ");
3315    let (from_sql, cond) = group_rows(grp);
3316    let mut sql = format!("SELECT (\n    {}\n) AS \"result\"\nFROM {}", outer_tuple, from_sql,);
3317    if let Some(cond) = cond {
3318        sql.push_str(&format!("\nWHERE {}", cond));
3319    }
3320    sql.push_str(&format!("\nGROUP BY {}", group_by_sql(grp)));
3321
3322    // ShapeNode for each element (Object with the selected pointers).
3323    let element_node = ShapeNode::Object {
3324        name: String::new(),
3325        type_name: Some(grp.source.type_name.clone()),
3326        position: 0,
3327        cardinality: Cardinality::Many,
3328        pointers: prepend_type(shape_nodes),
3329        has_implicit_id: shape_has_implicit_id(&grp.shape),
3330    };
3331
3332    let root = ShapeNode::Group {
3333        key_nodes,
3334        grouping_position: grouping_pos,
3335        elements_position: elements_pos,
3336        element: Box::new(element_node),
3337    };
3338
3339    SqlOutput {
3340        sql,
3341        shape: ShapeDescriptor { root },
3342        inference_plan: None,
3343    }
3344}
3345
3346fn emit_poly_union_type_only(implementors: &[IrPolyImplementor]) -> String {
3347    implementors
3348        .iter()
3349        .map(|imp| {
3350            format!(
3351                "    SELECT {}::text AS \"__type__\" FROM {}",
3352                sql_str(&imp.type_name),
3353                qn(&imp.module, &imp.table),
3354            )
3355        })
3356        .collect::<Vec<_>>()
3357        .join("\n    UNION ALL\n")
3358}
3359
3360fn emit_path_select(sel: &IrPathSelect) -> SqlOutput {
3361    let distinct = if sel.distinct { "DISTINCT " } else { "" };
3362    let from_sql = if !sel.poly_implementors.is_empty() {
3363        format!(
3364            "(\n{}\n) AS {}",
3365            emit_poly_union_type_only(&sel.poly_implementors),
3366            qi(&sel.root.alias),
3367        )
3368    } else {
3369        emit_path_joins(&sel.root, &sel.joins)
3370    };
3371
3372    let (result_expr, shape_root) = match &sel.result {
3373        IrPathResult::Scalar(ir_expr, tuple_shape) => {
3374            // A jsonb field access can't be decoded inside ROW() — emit raw.
3375            // (A tuple can: it is a composite row, and a record nests.)
3376            let is_nt = matches!(ir_expr, IrExpr::JsonbField { .. })
3377                || matches!(ir_expr, IrExpr::JsonbIndex { .. })
3378                || matches!(ir_expr, IrExpr::ColumnRef { pg_type, .. } if pg_type.starts_with("__nt__:"))
3379                || tuple_shape.is_some();
3380            // An `array<tuple<…>>` property is the one tuple-shaped value that
3381            // keeps the ROW() wrapper: the array itself is read at a position
3382            // inside the row, and only then is each element a jsonb tuple.
3383            let array_of_tuples = matches!(ir_expr, IrExpr::ColumnRef { pg_type, .. } if pg_type.ends_with("[]"));
3384            if let Some(shape) = tuple_shape.as_ref().filter(|_| array_of_tuples) {
3385                let node = ShapeNode::Array {
3386                    name: String::new(),
3387                    position: 0,
3388                    element: Box::new(composite_tuple_shape("", 0, shape.type_name.clone(), &shape.members)),
3389                };
3390                (format!("ROW({}) AS result", emit_expr(ir_expr)), node)
3391            } else if is_nt {
3392                let expr_sql = format!("{} AS result", emit_expr(ir_expr));
3393                let shape = if matches!(ir_expr, IrExpr::JsonbField { .. } | IrExpr::JsonbIndex { .. }) {
3394                    ShapeNode::RawScalar
3395                } else if let Some(shape) = tuple_shape {
3396                    // A bare tuple-typed property reference (nominal or
3397                    // structural) — real member shape already resolved at
3398                    // compile time (see resolve_property_tuple_shape), same
3399                    // as a `Type { tuple_property }` shape query gets via
3400                    // emit_scalar. The composite is the result column itself,
3401                    // so its own fields are the tuple's members.
3402                    composite_tuple_shape("", 0, shape.type_name.clone(), &shape.members)
3403                } else {
3404                    let type_name = match ir_expr {
3405                        IrExpr::ColumnRef { pg_type, .. } => pg_type.strip_prefix("__nt__:").map(|s| s.to_string()),
3406                        _ => None,
3407                    };
3408                    ShapeNode::NamedTuple {
3409                        name: String::new(),
3410                        position: 0,
3411                        type_name,
3412                        members: None,
3413                        is_free_object: false,
3414                    }
3415                };
3416                (expr_sql, shape)
3417            } else {
3418                // Schema-qualified types (enums, domains) have unknown OIDs inside ROW() —
3419                // cast to text so the value's shape doesn't depend on a runtime OID.
3420                if let IrExpr::ColumnRef { pg_type, .. } = ir_expr {
3421                    if let Some(qualified) = QualifiedPgType::of(pg_type) {
3422                        let expr = format!("ROW({}{}) AS result", emit_expr(ir_expr), qualified.text_cast());
3423                        let shape = qualified.shape_node(String::new(), 0);
3424                        (expr, shape)
3425                    } else {
3426                        let expr = format!("ROW({}) AS result", emit_expr(ir_expr));
3427                        (
3428                            expr,
3429                            ShapeNode::Scalar {
3430                                name: String::new(),
3431                                position: 0,
3432                            },
3433                        )
3434                    }
3435                } else {
3436                    let expr = format!("ROW({}) AS result", emit_expr(ir_expr));
3437                    (
3438                        expr,
3439                        ShapeNode::Scalar {
3440                            name: String::new(),
3441                            position: 0,
3442                        },
3443                    )
3444                }
3445            }
3446        }
3447        IrPathResult::Object {
3448            alias,
3449            type_name,
3450            shape,
3451        } => {
3452            let (pointer_exprs, pointer_nodes) = build_shape(shape, alias);
3453            let mut parts = vec![result_type_disc(sel, alias, type_name)];
3454            parts.extend(pointer_exprs);
3455            let expr = format!("(\n    {}\n) AS result", parts.join(",\n    "));
3456            let shape_root = ShapeNode::Object {
3457                name: String::new(),
3458                type_name: Some(type_name.clone()),
3459                position: 0,
3460                cardinality: Cardinality::Many,
3461                pointers: prepend_type(pointer_nodes),
3462                has_implicit_id: shape_has_implicit_id(shape),
3463            };
3464            (expr, shape_root)
3465        }
3466    };
3467
3468    let mut sql = format!("SELECT {}{}\nFROM {}", distinct, result_expr, from_sql);
3469    append_filter(&mut sql, &sel.filter);
3470    append_order_by(&mut sql, &sel.order_by);
3471    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
3472
3473    SqlOutput {
3474        sql,
3475        shape: ShapeDescriptor { root: shape_root },
3476        inference_plan: None,
3477    }
3478}
3479
3480// ── FOR LOOP ─────────────────────────────────────────────────────────────────
3481
3482/// The loop's iterator as a relation with a single column named `v`, in both
3483/// the forms the two body kinds need: one for a `FROM` clause, one for a CTE.
3484/// True for an expression PostgreSQL hands back as `jsonb`, whose value a
3485/// scalar cast has to reach into rather than render.
3486fn yields_jsonb(expr: &IrExpr) -> bool {
3487    match expr {
3488        IrExpr::JsonbField { .. } | IrExpr::JsonbIndex { .. } | IrExpr::NamedTuple { .. } => true,
3489        IrExpr::TypeCast(c) => c.pg_type == "jsonb",
3490        IrExpr::ColumnRef { pg_type, .. } | IrExpr::FnParam { pg_type, .. } => pg_type == "jsonb",
3491        IrExpr::CteRef { pg_type, .. } | IrExpr::ForVar { pg_type, .. } => pg_type.as_deref() == Some("jsonb"),
3492        // A call that recorded a return type answers from it, which is the
3493        // only thing that speaks for one reached through `_pylon` or a schema
3494        // module — the registry scan below only recognises an inline builtin.
3495        IrExpr::FunctionCall(f) if f.return_pg_type.is_some() => f.return_pg_type.as_deref() == Some("jsonb"),
3496        IrExpr::FunctionCall(f) if f.schema.is_none() => {
3497            let mut overloads = crate::stdlib::registry().iter().filter(|d| d.name == f.name).peekable();
3498            overloads.peek().is_some() && overloads.all(|d| matches!(d.return_type, crate::stdlib::PylonType::Json))
3499        }
3500        _ => false,
3501    }
3502}
3503
3504/// True for an expression whose value is a PostgreSQL array — mirrors
3505/// `yields_jsonb` for the other type the emitter has to know by sight.
3506fn yields_array(expr: &IrExpr) -> bool {
3507    match expr {
3508        IrExpr::Array(_) | IrExpr::ArrayFromSelect(_) => true,
3509        IrExpr::ColumnRef { pg_type, .. } | IrExpr::FnParam { pg_type, .. } => pg_type.ends_with("[]"),
3510        IrExpr::CteRef { pg_type, .. } | IrExpr::ForVar { pg_type, .. } => {
3511            pg_type.as_deref().is_some_and(|ty| ty.ends_with("[]"))
3512        }
3513        IrExpr::TypeCast(cast) => cast.pg_type.ends_with("[]"),
3514        _ => false,
3515    }
3516}
3517
3518/// One element of an aggregated set of array-valued properties: the array
3519/// sits at position 0 of the one-field record it travels in (see
3520/// `IrArraySource::PathSelect`'s emission), described the same way the
3521/// property is inside an object's own row — a named tuple per element where
3522/// the declaration says so, an enum label per element for an enum array,
3523/// else the array as the driver decodes it.
3524fn aggregated_array_element(expr: &IrExpr, tuple_shape: Option<&crate::ir::TupleCastShape>) -> ShapeNode {
3525    if let Some(shape) = tuple_shape {
3526        return ShapeNode::Array {
3527            name: String::new(),
3528            position: 0,
3529            element: Box::new(composite_tuple_shape("", 0, shape.type_name.clone(), &shape.members)),
3530        };
3531    }
3532    if let IrExpr::ColumnRef { pg_type, .. } = expr
3533        && let Some(qualified) = QualifiedPgType::of(pg_type)
3534    {
3535        return qualified.shape_node(String::new(), 0);
3536    }
3537    ShapeNode::Scalar {
3538        name: String::new(),
3539        position: 0,
3540    }
3541}
3542
3543fn emit_for_iterator(it: &IrForIterator, iter_alias: &str) -> (String, String) {
3544    match it {
3545        IrForIterator::Values { exprs, pg_type } => {
3546            let rows: Vec<String> = exprs
3547                .iter()
3548                .map(|e| format!("({}::{})", emit_expr(e), pg_type))
3549                .collect();
3550            (
3551                format!("(VALUES {}) AS {}(\"v\")", rows.join(", "), qi(iter_alias)),
3552                format!("{}(\"v\") AS (VALUES {})", qi(iter_alias), rows.join(", ")),
3553            )
3554        }
3555        IrForIterator::Query { stmt, scalar } => {
3556            let inner = emit_dml_as_cte_source(stmt);
3557            // A scalar-yielding select already exposes `v` (the same
3558            // convention a scalar `with` binding uses); an object-yielding
3559            // one exposes its raw columns, so the loop variable binds `id`.
3560            let body = if *scalar {
3561                inner
3562            } else {
3563                format!("    SELECT \"id\" AS v FROM (\n{}\n    ) AS _src", inner)
3564            };
3565            (
3566                format!("(\n{}\n) AS {}", body, qi(iter_alias)),
3567                format!("{} AS (\n{}\n)", qi(iter_alias), body),
3568            )
3569        }
3570        IrForIterator::SetReturning { expr, pg_type } => {
3571            let body = format!("    SELECT {}::{} AS v", emit_expr(expr), pg_type);
3572            (
3573                format!("(\n{}\n) AS {}", body, qi(iter_alias)),
3574                format!("{} AS (\n{}\n)", qi(iter_alias), body),
3575            )
3576        }
3577    }
3578}
3579
3580/// The iterator CTE of a `for` nested in another one. The enclosing iterator
3581/// goes in its FROM, so the walk it iterates can read the outer loop variable,
3582/// and its key rides along as `_outer` for the body to join back on — the bond
3583/// that holds a chain of iterator CTEs together.
3584fn emit_nested_for_iterator(it: &IrForIterator, iter_alias: &str, outer_alias: &str) -> String {
3585    const OUTER: &str = "_outer";
3586    match it {
3587        IrForIterator::Values { exprs, pg_type } => {
3588            let rows: Vec<String> = exprs
3589                .iter()
3590                .map(|e| format!("({}::{})", emit_expr(e), pg_type))
3591                .collect();
3592            format!(
3593                "{} AS (\nSELECT {}.\"v\" AS {}, \"_vals\".\"v\" AS v\nFROM {}, (VALUES {}) AS \"_vals\"(\"v\")\n)",
3594                qi(iter_alias),
3595                qi(outer_alias),
3596                qi(OUTER),
3597                qi(outer_alias),
3598                rows.join(", "),
3599            )
3600        }
3601        IrForIterator::Query { stmt, scalar } => {
3602            let inner = emit_dml_as_cte_source(stmt);
3603            let projected = if *scalar { "\"_src\".\"v\"" } else { "\"_src\".\"id\"" };
3604            format!(
3605                "{} AS (\nSELECT {}.\"v\" AS {}, {} AS v\nFROM {}\nCROSS JOIN LATERAL (\n{}\n) AS \"_src\"\n)",
3606                qi(iter_alias),
3607                qi(outer_alias),
3608                qi(OUTER),
3609                projected,
3610                qi(outer_alias),
3611                inner,
3612            )
3613        }
3614        IrForIterator::SetReturning { expr, pg_type } => format!(
3615            "{} AS (\nSELECT {}.\"v\" AS {}, \"_vals\".\"v\" AS v\nFROM {}, LATERAL (SELECT {}::{} AS v) AS \"_vals\"\n)",
3616            qi(iter_alias),
3617            qi(outer_alias),
3618            qi(OUTER),
3619            qi(outer_alias),
3620            emit_expr(expr),
3621            pg_type,
3622        ),
3623    }
3624}
3625
3626/// `FROM <inner iterator> JOIN <outer iterator> ON …` — both loop variables in
3627/// scope for the body, each still read as `"_for_<name>"."v"`.
3628fn nested_for_from(inner_alias: &str, outer_alias: &str) -> String {
3629    format!(
3630        "{} JOIN {} ON {}.\"v\" = {}.\"_outer\"",
3631        qi(inner_alias),
3632        qi(outer_alias),
3633        qi(outer_alias),
3634        qi(inner_alias),
3635    )
3636}
3637
3638/// Put the bindings a `for` body declared in front of the body itself: they may
3639/// read the loop variable, so they cannot sit in the enclosing WITH clause.
3640/// A `for` body's CTEs as they must read inside the LATERAL that runs the body.
3641///
3642/// `emit_user_cte_parts` keys a binding that reads the loop variable by the
3643/// iteration, joining `_for_<slot>` to carry its key — right where the binding
3644/// sits in the statement's own WITH beside the iterator CTE, as the DML forms
3645/// emit it. Inside the LATERAL there is no such relation: `_for_<slot>` is the
3646/// outer FROM item's alias, so `FROM "_for_<slot>"` is SQL PostgreSQL rejects
3647/// ("relation does not exist"), and spelling it legally would widen the binding
3648/// to every iteration's rows at once. The LATERAL has already pinned the
3649/// iteration to one row, and the correlated column reference the body carries
3650/// reads it, so the key is neither needed nor correct here.
3651fn uncorrelated_body_ctes(f: &IrFor) -> Vec<IrCteDef> {
3652    f.body_ctes
3653        .iter()
3654        .map(|cte| IrCteDef {
3655            correlated_to: None,
3656            ..cte.clone()
3657        })
3658        .collect()
3659}
3660
3661fn prefix_body_ctes(sql: String, body_ctes: &[IrCteDef]) -> String {
3662    if body_ctes.is_empty() {
3663        return sql;
3664    }
3665    merge_into_existing_with(&sql, &emit_user_cte_parts(body_ctes))
3666        .unwrap_or_else(|| format!("{}{}", emit_cte_prefix(body_ctes), sql))
3667}
3668
3669fn emit_for_stmt(f: &IrFor, user_ctes: &[IrCteDef]) -> SqlOutput {
3670    let iter_alias = format!("_for_{}", f.var_name);
3671
3672    if matches!(&f.iterator, IrForIterator::Values { exprs, .. } if exprs.is_empty()) {
3673        let empty = SqlOutput {
3674            sql: "SELECT NULL AS result WHERE FALSE".to_string(),
3675            shape: ShapeDescriptor {
3676                root: ShapeNode::Scalar {
3677                    name: String::new(),
3678                    position: 0,
3679                },
3680            },
3681            inference_plan: None,
3682        };
3683        return empty;
3684    }
3685
3686    let (values_from, iter_cte) = emit_for_iterator(&f.iterator, &iter_alias);
3687
3688    match f.body.as_ref() {
3689        IrStmt::Insert(ins) => emit_for_insert(ins, &iter_alias, &iter_cte, user_ctes, &f.body_ctes),
3690        IrStmt::Update(upd) => emit_for_update(upd, &iter_alias, &iter_cte, user_ctes, &f.body_ctes),
3691        body => {
3692            let body_out = match body {
3693                IrStmt::Select(sel) => emit_select_stmt(sel, user_ctes),
3694                IrStmt::PathSelect(sel) => emit_path_select(sel),
3695                // Unreachable: `Compiler::compile_for` rejects every other
3696                // body kind with a PyQL error before an `IrFor` is built.
3697                other => unreachable!("for-loop body should have been rejected at compile time: {other:?}"),
3698            };
3699            let indent_body = prefix_body_ctes(body_out.sql, &uncorrelated_body_ctes(f)).replace('\n', "\n    ");
3700            let cte_prefix = if !user_ctes.is_empty() {
3701                emit_cte_prefix(user_ctes)
3702            } else {
3703                String::new()
3704            };
3705            let sql = format!(
3706                "{}SELECT \"_body\".result\nFROM {}\nCROSS JOIN LATERAL (\n    {}\n) AS \"_body\"",
3707                cte_prefix, values_from, indent_body,
3708            );
3709            SqlOutput {
3710                sql,
3711                shape: body_out.shape,
3712                inference_plan: None,
3713            }
3714        }
3715    }
3716}
3717
3718/// A `for` whose body updates: driven from the iteration with `UPDATE … FROM`,
3719/// since a LATERAL -- what the select-bodied form uses -- cannot hold DML.
3720/// Both the SET clause and the filter may read the loop variable, and both are
3721/// already compiled against the iteration alias.
3722fn emit_for_update(
3723    upd: &IrUpdate,
3724    iter_alias: &str,
3725    iter_cte: &str,
3726    user_ctes: &[IrCteDef],
3727    body_ctes: &[IrCteDef],
3728) -> SqlOutput {
3729    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
3730    // The iterator first: a body CTE may read the loop variable, and a WITH
3731    // name is only in scope for the bindings that follow it.
3732    cte_parts.push(iter_cte.to_string());
3733    cte_parts.extend(emit_user_cte_parts(body_ctes));
3734
3735    let alias = &upd.target.alias;
3736    // With only junction rows to write there is nothing to SET, so the driving
3737    // rows are selected rather than updated -- the same shape a multi-link-only
3738    // update takes outside a loop.
3739    if upd.assignments.is_empty() && upd.rewrites.is_empty() && !upd.multi_link_appends.is_empty() {
3740        const ITER_COL: &str = "_iter";
3741        let ids_name = "_ids";
3742        // A nested insert gets its id generated here rather than by the column
3743        // default, so the junction below can name the row *this* iteration
3744        // inserted — `RETURNING` offers no order to pair on.
3745        let generated: Vec<Option<&IrInsert>> = upd
3746            .multi_link_appends
3747            .iter()
3748            .map(|a| per_iteration_insert(a, &upd.nested_ctes))
3749            .collect();
3750        let new_cols: String = generated
3751            .iter()
3752            .enumerate()
3753            .filter_map(|(i, ins)| {
3754                ins.map(|ins| {
3755                    format!(
3756                        ", {} AS {}",
3757                        ins.id_default_sql.as_deref().unwrap_or("uuidv7()"),
3758                        qi(&format!("_new_{i}"))
3759                    )
3760                })
3761            })
3762            .collect();
3763        let mut ids_sql = format!(
3764            "\"{}\" AS (\nSELECT {}.*, {}.\"v\" AS {}{} FROM {} AS {}, {}",
3765            ids_name,
3766            qi(alias),
3767            qi(iter_alias),
3768            qi(ITER_COL),
3769            new_cols,
3770            target_ref(&upd.target),
3771            qi(alias),
3772            qi(iter_alias),
3773        );
3774        append_filter(&mut ids_sql, &upd.filter);
3775        ids_sql.push_str("\n)");
3776        cte_parts.push(ids_sql);
3777        for (i, append) in upd.multi_link_appends.iter().enumerate() {
3778            match generated[i] {
3779                Some(ins) => {
3780                    cte_parts.push(emit_for_nested_insert_cte(
3781                        ins,
3782                        ids_name,
3783                        iter_alias,
3784                        &format!("_ml_ins_{i}"),
3785                        &format!("_new_{i}"),
3786                    ));
3787                    cte_parts.push(emit_for_ml_append_cte(
3788                        append,
3789                        ids_name,
3790                        &format!("_ml_add_{i}"),
3791                        &format!("_new_{i}"),
3792                    ));
3793                }
3794                None => cte_parts.push(emit_for_ml_append_cte(
3795                    append,
3796                    ids_name,
3797                    &format!("_ml_add_{i}"),
3798                    ITER_COL,
3799                )),
3800            }
3801        }
3802        let (pointer_exprs, shape_nodes) = build_shape(&upd.returning, ids_name);
3803        let mut parts = vec![type_disc(&upd.target.type_name)];
3804        parts.extend(pointer_exprs);
3805        let sql = format!(
3806            "WITH {}\nSELECT (\n    {}\n) AS result\nFROM \"{}\"",
3807            cte_parts.join(",\n"),
3808            parts.join(",\n    "),
3809            ids_name,
3810        );
3811        return SqlOutput {
3812            sql,
3813            shape: crate::query::ShapeDescriptor {
3814                root: ShapeNode::Object {
3815                    name: String::new(),
3816                    type_name: Some(upd.target.type_name.clone()),
3817                    position: 0,
3818                    cardinality: Cardinality::Many,
3819                    pointers: prepend_type(shape_nodes),
3820                    has_implicit_id: shape_has_implicit_id(&upd.returning),
3821                },
3822            },
3823            inference_plan: None,
3824        };
3825    }
3826    let sets: Vec<String> = upd
3827        .assignments
3828        .iter()
3829        .map(|(col, expr)| format!("{} = {}", qi(col), emit_expr(expr)))
3830        .chain(
3831            upd.rewrites
3832                .iter()
3833                .map(|rw| format!("{} = {}", qi(&rw.column), emit_expr(&rw.expr))),
3834        )
3835        .collect();
3836    if !upd.poly_implementors.is_empty() {
3837        return emit_for_poly_update(upd, iter_alias, &sets, cte_parts);
3838    }
3839    let mut sql = format!(
3840        "WITH {}\nUPDATE {} AS {}\nSET {}\nFROM {}",
3841        cte_parts.join(",\n"),
3842        target_ref(&upd.target),
3843        qi(alias),
3844        sets.join(", "),
3845        qi(iter_alias),
3846    );
3847    append_filter(&mut sql, &upd.filter);
3848    let mut parts = vec![type_disc(&upd.target.type_name)];
3849    let (pointer_exprs, shape_nodes) = build_shape(&upd.returning, alias);
3850    parts.extend(pointer_exprs);
3851    sql.push_str(&format!("\nRETURNING (\n    {}\n) AS result", parts.join(",\n    ")));
3852    SqlOutput {
3853        sql,
3854        shape: crate::query::ShapeDescriptor {
3855            root: ShapeNode::Object {
3856                name: String::new(),
3857                type_name: Some(upd.target.type_name.clone()),
3858                position: 0,
3859                cardinality: Cardinality::Many,
3860                pointers: prepend_type(shape_nodes),
3861                has_implicit_id: shape_has_implicit_id(&upd.returning),
3862            },
3863        },
3864        inference_plan: None,
3865    }
3866}
3867
3868/// The iteration key a correlated nested CTE carries, so rows written per
3869/// iteration can be paired back with the row that iteration wrote. `RETURNING`
3870/// can only name the inserted row's own columns, so the pairing is computed in
3871/// a staging CTE beside the insert rather than read back out of it.
3872const OUTER_KEY: &str = "_outer";
3873
3874/// The iteration key a correlated `with` binding carries, named after the
3875/// binding itself. A binding that reads another one re-exports that one's key
3876/// alongside its own, so a single shared name would leave two columns under
3877/// it and make every read of either ambiguous.
3878fn binding_outer_key(cte_name: &str) -> String {
3879    format!("_outer__{cte_name}")
3880}
3881
3882/// `SELECT <id> AS "id", <iter>."v" AS "_outer", <value> AS "<column>", …` —
3883/// the rows one iteration-driven insert will write, with their ids generated
3884/// here rather than by the column default so a junction can name them before
3885/// the insert has run.
3886fn emit_iteration_rows_cte(ins: &IrInsert, cte_name: &str, iter_alias: &str, outer_source: &str) -> String {
3887    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
3888    let mut selected = vec![
3889        format!(
3890            "{} AS {}",
3891            ins.id_default_sql.as_deref().unwrap_or("uuidv7()"),
3892            qi("id")
3893        ),
3894        format!("{} AS {}", outer_source, qi(OUTER_KEY)),
3895    ];
3896    selected.extend(
3897        ins.assignments
3898            .iter()
3899            .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3900            .map(|(c, e)| format!("{} AS {}", emit_expr(e), qi(c)))
3901            .chain(
3902                ins.rewrites
3903                    .iter()
3904                    .map(|r| format!("{} AS {}", emit_expr(&r.expr), qi(&r.column))),
3905            ),
3906    );
3907    let mut sql = format!(
3908        "\"{}\" AS (\nSELECT {}\nFROM {}",
3909        cte_name,
3910        selected.join(", "),
3911        qi(iter_alias),
3912    );
3913    append_insert_guard(&mut sql, ins);
3914    sql.push_str("\n)");
3915    sql
3916}
3917
3918/// The insert that reads its rows back out of the staging CTE above.
3919fn emit_insert_from_rows_cte(ins: &IrInsert, cte_name: &str, rows_name: &str) -> String {
3920    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
3921    let cols: Vec<String> = std::iter::once(qi("id"))
3922        .chain(
3923            ins.assignments
3924                .iter()
3925                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3926                .map(|(c, _)| qi(c)),
3927        )
3928        .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
3929        .collect();
3930    let mut sql = format!(
3931        "INSERT INTO {} ({})\nSELECT {} FROM \"{}\"",
3932        target_ref(&ins.target),
3933        cols.join(", "),
3934        cols.join(", "),
3935        rows_name,
3936    );
3937    if let Some(conflict) = &ins.unless_conflict {
3938        emit_conflict(&mut sql, conflict);
3939    }
3940    sql.push_str("\nRETURNING *");
3941    format!("\"{}\" AS (\n{}\n)", cte_name, sql)
3942}
3943
3944/// A junction append whose targets were written per iteration: pair the two
3945/// staging CTEs on the iteration key rather than cross-joining every row
3946/// written against every row that wrote one.
3947fn emit_correlated_ml_append_cte(
3948    mutation: &IrMultiLinkMutation,
3949    rows_name: &str,
3950    targets_name: &str,
3951    cte_name: &str,
3952) -> String {
3953    format!(
3954        "\"{}\" AS (\nINSERT INTO {} ({}, {})\nSELECT \"_src\".{}, \"_tgt\".{} FROM \"{}\" AS \"_src\" JOIN \"{}\" AS \"_tgt\" ON \"_tgt\".{} = \"_src\".{}\nON CONFLICT DO NOTHING\nRETURNING {}, {}\n)",
3955        cte_name,
3956        qn(&mutation.module, &mutation.junction_table),
3957        qi(&mutation.source_col),
3958        qi(&mutation.target_col),
3959        qi("id"),
3960        qi("id"),
3961        rows_name,
3962        targets_name,
3963        qi(OUTER_KEY),
3964        qi(OUTER_KEY),
3965        qi(&mutation.source_col),
3966        qi(&mutation.target_col),
3967    )
3968}
3969
3970/// Which of `appends` name a CTE that wrote its rows per iteration, and so
3971/// pair on the iteration key instead of cross-joining.
3972fn correlated_append_indices(appends: &[IrMultiLinkMutation], defs: &[&[IrCteDef]]) -> Vec<usize> {
3973    appends
3974        .iter()
3975        .enumerate()
3976        .filter(|(_, append)| match &append.values.source {
3977            IrMultiLinkValueSource::CteRef(target) => defs
3978                .iter()
3979                .flat_map(|group| group.iter())
3980                .any(|c| &c.name == target && c.correlated_to.is_some()),
3981            _ => false,
3982        })
3983        .map(|(i, _)| i)
3984        .collect()
3985}
3986
3987/// `emit_for_update` for an interface target: one UPDATE per implementor,
3988/// each driven from the iteration, unioned back under a per-branch type
3989/// literal the way a standalone polymorphic update is.
3990fn emit_for_poly_update(upd: &IrUpdate, iter_alias: &str, sets: &[String], mut cte_parts: Vec<String>) -> SqlOutput {
3991    let alias = &upd.target.alias;
3992    let mut union_parts = vec![];
3993    for (i, imp) in upd.poly_implementors.iter().enumerate() {
3994        let cte_name = format!("_u{}", i);
3995        let mut upd_sql = format!(
3996            "UPDATE {} AS {}\nSET {}\nFROM {}",
3997            qn(&imp.module, &imp.table),
3998            qi(alias),
3999            sets.join(", "),
4000            qi(iter_alias),
4001        );
4002        append_filter(&mut upd_sql, &upd.filter);
4003        upd_sql.push_str(&format!("\nRETURNING {}.\"id\"", qi(alias)));
4004        cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, upd_sql));
4005
4006        let r_alias = format!("_r{}", i);
4007        union_parts.push(format!(
4008            "SELECT ROW({}::text, {}.\"id\") AS result FROM \"{}\" AS {}",
4009            sql_str(&imp.type_name),
4010            qi(&r_alias),
4011            cte_name,
4012            qi(&r_alias),
4013        ));
4014    }
4015    let sql = format!("WITH {}\n{}", cte_parts.join(",\n"), union_parts.join("\nUNION ALL\n"));
4016    let (shape, _) = emit_returning_shape(&upd.target, &upd.returning, true);
4017    SqlOutput {
4018        sql,
4019        shape,
4020        inference_plan: None,
4021    }
4022}
4023
4024fn emit_for_insert(
4025    ins: &IrInsert,
4026    iter_alias: &str,
4027    iter_cte: &str,
4028    user_ctes: &[IrCteDef],
4029    body_ctes: &[IrCteDef],
4030) -> SqlOutput {
4031    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
4032
4033    let cols: Vec<String> = ins
4034        .assignments
4035        .iter()
4036        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
4037        .map(|(c, _)| qi(c))
4038        .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
4039        .collect();
4040    let sel_exprs: Vec<String> = ins
4041        .assignments
4042        .iter()
4043        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
4044        .map(|(_, e)| emit_expr(e))
4045        .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
4046        .collect();
4047
4048    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
4049    // The iterator first: a body CTE, and any DML nested in the row's values,
4050    // may read the loop variable, and a WITH name is only in scope for the
4051    // bindings that follow it.
4052    cte_parts.push(iter_cte.to_string());
4053    cte_parts.extend(emit_user_cte_parts(body_ctes));
4054    cte_parts.extend(emit_user_cte_parts(&ins.nested_ctes));
4055    // A nested loop that carried the iteration key back means this insert's
4056    // own rows need one too, to pair with.
4057    let correlated_targets = correlated_append_indices(&ins.multi_link_appends, &[body_ctes, &ins.nested_ctes]);
4058
4059    let mut insert_sql = format!(
4060        "INSERT INTO {} ({})\nSELECT {} FROM {}",
4061        target_ref(&ins.target),
4062        cols.join(", "),
4063        sel_exprs.join(", "),
4064        qi(iter_alias),
4065    );
4066    append_insert_guard(&mut insert_sql, ins);
4067    if let Some(conflict) = &ins.unless_conflict {
4068        emit_conflict(&mut insert_sql, conflict);
4069    }
4070    let (shape, returning_sql) = emit_returning_shape(&ins.target, &ins.returning, false);
4071
4072    if ins.multi_link_appends.is_empty() {
4073        let mut sql = format!("WITH {}\n{}", cte_parts.join(",\n"), insert_sql);
4074        if let Some(r) = returning_sql {
4075            sql.push_str(&r);
4076        }
4077        return SqlOutput {
4078            sql,
4079            shape,
4080            inference_plan: None,
4081        };
4082    }
4083
4084    // A junction row needs the id of the row this iteration just wrote, so
4085    // the insert becomes a CTE the junction inserts read back from.
4086    const IDS: &str = "_for_dml__ids";
4087    const ROWS: &str = "_for_dml__rows";
4088    if correlated_targets.is_empty() {
4089        insert_sql.push_str("\nRETURNING *");
4090        cte_parts.push(format!("\"{}\" AS (\n{}\n)", IDS, insert_sql));
4091    } else {
4092        let outer_source = format!("{}.{}", qi(iter_alias), qi("v"));
4093        cte_parts.push(emit_iteration_rows_cte(ins, ROWS, iter_alias, &outer_source));
4094        cte_parts.push(emit_insert_from_rows_cte(ins, IDS, ROWS));
4095    }
4096    for (i, append) in ins.multi_link_appends.iter().enumerate() {
4097        let cte_name = format!("_for_dml__ml_add_{}", i);
4098        if correlated_targets.contains(&i) {
4099            let IrMultiLinkValueSource::CteRef(target) = &append.values.source else {
4100                unreachable!("only a CTE-backed value can carry an iteration key")
4101            };
4102            cte_parts.push(emit_correlated_ml_append_cte(append, ROWS, target, &cte_name));
4103        } else {
4104            cte_parts.push(emit_ml_append_cte(append, IDS, &ins.target.alias, &cte_name));
4105        }
4106    }
4107
4108    let projection = match &returning_sql {
4109        // `emit_returning_shape` spells the projection as a RETURNING clause;
4110        // read off a CTE it is the same tuple in a SELECT list.
4111        Some(r) => r.trim_start_matches('\n').replacen("RETURNING ", "SELECT ", 1),
4112        None => "SELECT 1".to_string(),
4113    };
4114    SqlOutput {
4115        sql: format!("WITH {}\n{}\nFROM \"{}\"", cte_parts.join(",\n"), projection, IDS),
4116        shape,
4117        inference_plan: None,
4118    }
4119}
4120
4121// ── INSERT ──────────────────────────────────────────────────────────────────
4122
4123// ── Vector index enqueue helpers ─────────────────────────────────────────────
4124
4125/// Build one `"_eqN" AS (INSERT INTO _pylon."IndexOutbox" ...)` CTE string.
4126fn enqueue_cte_sql(eq: &VectorEnqueueInfo, source_cte: &str, cte_name: &str) -> String {
4127    let index_name_sql = match &eq.index_name {
4128        None => "NULL".to_string(),
4129        Some(name) => sql_str(name),
4130    };
4131    format!(
4132        concat!(
4133            "\"{}\" AS (\n",
4134            "    INSERT INTO _pylon.\"IndexOutbox\"\n",
4135            "        (object_id, type_name, index_kind, index_name)\n",
4136            "    SELECT \"id\", {}, 'Vector'::_pylon.\"IndexKind\", {}\n",
4137            "    FROM \"{}\"\n",
4138            "    ON CONFLICT (object_id, index_kind, index_name)\n",
4139            "    DO UPDATE SET status = 'Pending', enqueued_at = now()\n",
4140            ")",
4141        ),
4142        cte_name,
4143        sql_str(&eq.type_name),
4144        index_name_sql,
4145        source_cte,
4146    )
4147}
4148
4149/// Build the full list of enqueue CTE strings for a set of vector indexes.
4150fn enqueue_ctes(enqueue: &[VectorEnqueueInfo], source_cte: &str) -> Vec<String> {
4151    enqueue
4152        .iter()
4153        .enumerate()
4154        .map(|(i, eq)| enqueue_cte_sql(eq, source_cte, &format!("_eq{}", i)))
4155        .collect()
4156}
4157
4158/// The `_pylon."IndexOutbox".index_kind` enum label for a search backend —
4159/// `Postgres`-backed indexes never reach this (see `collect_search_enqueue`),
4160/// so there's no corresponding `IndexKind` value for that variant.
4161fn search_backend_index_kind(backend: &crate::schema::SearchBackend) -> &'static str {
4162    match backend {
4163        crate::schema::SearchBackend::OpenSearch => "OpenSearch",
4164        crate::schema::SearchBackend::Meilisearch => "Meilisearch",
4165        crate::schema::SearchBackend::Postgres => {
4166            unreachable!("Postgres-backed search indexes are never collected into SearchEnqueueInfo")
4167        }
4168    }
4169}
4170
4171/// Build one OpenSearch/Meilisearch outbox CTE string.
4172fn enqueue_search_cte_sql(eq: &SearchEnqueueInfo, source_cte: &str, cte_name: &str) -> String {
4173    let index_name_sql = match &eq.index_name {
4174        None => "NULL".to_string(),
4175        Some(name) => sql_str(name),
4176    };
4177    format!(
4178        concat!(
4179            "\"{}\" AS (\n",
4180            "    INSERT INTO _pylon.\"IndexOutbox\"\n",
4181            "        (object_id, type_name, index_kind, index_name, operation)\n",
4182            "    SELECT \"id\", {}, '{}'::_pylon.\"IndexKind\", {}, {}\n",
4183            "    FROM \"{}\"\n",
4184            "    ON CONFLICT (object_id, index_kind, index_name)\n",
4185            "    DO UPDATE SET status = 'Pending', operation = EXCLUDED.operation, enqueued_at = now()\n",
4186            ")",
4187        ),
4188        cte_name,
4189        sql_str(&eq.type_name),
4190        search_backend_index_kind(&eq.backend),
4191        index_name_sql,
4192        sql_str(eq.operation),
4193        source_cte,
4194    )
4195}
4196
4197/// Build the full list of OpenSearch/Meilisearch enqueue CTE strings.
4198fn enqueue_search_ctes(enqueue: &[SearchEnqueueInfo], source_cte: &str, offset: usize) -> Vec<String> {
4199    enqueue
4200        .iter()
4201        .enumerate()
4202        .map(|(i, eq)| enqueue_search_cte_sql(eq, source_cte, &format!("_es{}", offset + i)))
4203        .collect()
4204}
4205
4206/// Build `SELECT (...) AS result FROM "cte_name"` plus its ShapeDescriptor,
4207/// mirroring `emit_returning_shape` but for the CTE-wrapper SELECT path.
4208fn shape_select_from_cte(
4209    target: &IrSource,
4210    returning: &[IrShapePointer],
4211    cte_name: &str,
4212) -> (ShapeDescriptor, Option<String>) {
4213    if returning.is_empty() {
4214        return (
4215            ShapeDescriptor {
4216                root: ShapeNode::Scalar {
4217                    name: String::new(),
4218                    position: 0,
4219                },
4220            },
4221            None,
4222        );
4223    }
4224    let (pointer_exprs, shape_pointers) = build_shape(returning, "");
4225    let mut parts = vec![type_disc(&target.type_name)];
4226    parts.extend(pointer_exprs);
4227    let tuple = parts.join(",\n    ");
4228    let sql = format!("SELECT (\n    {}\n) AS result\nFROM {}", tuple, qi(cte_name));
4229    let root_pointers = prepend_type(shape_pointers);
4230    let shape = ShapeDescriptor {
4231        root: ShapeNode::Object {
4232            name: String::new(),
4233            type_name: Some(target.type_name.clone()),
4234            position: 0,
4235            cardinality: Cardinality::Required,
4236            pointers: root_pointers,
4237            has_implicit_id: shape_has_implicit_id(returning),
4238        },
4239    };
4240    (shape, Some(sql))
4241}
4242
4243/// Builds the base `INSERT INTO t (cols) VALUES (...)` row source — or,
4244/// when `ins.nested_ctes` is non-empty, `INSERT INTO t (cols) SELECT ...
4245/// FROM cte1, cte2, ...` instead, since a VALUES list can't reference a
4246/// CTE's columns (needed when a link's value is sourced from a hoisted
4247/// nested INSERT/UPDATE/DELETE — see `IrInsert::nested_ctes`). Each nested
4248/// CTE is guaranteed exactly one row, so an implicit cross join is safe.
4249/// Shared by the plain wrap path and the multi-link junction wrap path
4250/// below, both of which need this same VALUES/SELECT choice.
4251fn emit_insert_row_sql(ins: &IrInsert) -> String {
4252    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
4253    let cols: Vec<String> = ins
4254        .assignments
4255        .iter()
4256        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
4257        .map(|(c, _)| qi(c))
4258        .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
4259        .collect();
4260    let vals: Vec<String> = ins
4261        .assignments
4262        .iter()
4263        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
4264        .map(|(_, e)| emit_expr(e))
4265        .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
4266        .collect();
4267    // A guarded insert writes nothing when its condition is false, so the row
4268    // comes from a `SELECT` that returns none -- `VALUES` always yields one.
4269    if let Some(guard) = &ins.guard {
4270        let from_ctes = nested_cte_from(&ins.nested_ctes, &vals.join(","), " ");
4271        let projection = if cols.is_empty() {
4272            String::new()
4273        } else {
4274            format!(" {}", vals.join(", "))
4275        };
4276        let column_list = if cols.is_empty() {
4277            String::new()
4278        } else {
4279            format!(" ({})", cols.join(", "))
4280        };
4281        return format!(
4282            "INSERT INTO {}{} SELECT{}{} WHERE {}",
4283            target_ref(&ins.target),
4284            column_list,
4285            projection,
4286            from_ctes,
4287            emit_expr(guard),
4288        );
4289    }
4290    if cols.is_empty() && ins.nested_ctes.is_empty() {
4291        // `insert Preferences {}` — a row made entirely of its own defaults.
4292        // An empty column list is not SQL; `DEFAULT VALUES` is how it is said.
4293        format!("INSERT INTO {} DEFAULT VALUES", target_ref(&ins.target))
4294    } else if ins.nested_ctes.is_empty() {
4295        format!(
4296            "INSERT INTO {} ({}) VALUES ({})",
4297            target_ref(&ins.target),
4298            cols.join(", "),
4299            vals.join(", ")
4300        )
4301    } else {
4302        format!(
4303            "INSERT INTO {} ({}) SELECT {}{}",
4304            target_ref(&ins.target),
4305            cols.join(", "),
4306            vals.join(", "),
4307            nested_cte_from(&ins.nested_ctes, &vals.join(","), " "),
4308        )
4309    }
4310}
4311
4312fn emit_insert_stmt(ins: &IrInsert) -> SqlOutput {
4313    if ins.enqueue_vector.is_empty()
4314        && ins.enqueue_search.is_empty()
4315        && !insert_has_any_multilink(ins)
4316        && ins.nested_ctes.is_empty()
4317    {
4318        let mut sql = emit_insert_row_sql(ins);
4319        if let Some(conflict) = &ins.unless_conflict {
4320            emit_conflict(&mut sql, conflict);
4321        }
4322        let (shape, returning_sql) = emit_returning_shape(&ins.target, &ins.returning, false);
4323        if let Some(r) = returning_sql {
4324            sql.push_str(&r);
4325        }
4326        return SqlOutput {
4327            sql,
4328            shape,
4329            inference_plan: None,
4330        };
4331    }
4332
4333    // Wrap path: needed for outbox enqueue CTEs, junction-table population
4334    // (see emit_insert_multilink_ctes), and/or hoisted nested-DML CTEs (see
4335    // IrInsert::nested_ctes) — all of which require either the row's own id
4336    // or a WITH prefix, so the plain single-statement INSERT above can't be
4337    // used.
4338    let mut cte_parts = if insert_has_any_multilink(ins) {
4339        emit_insert_multilink_ctes(ins, "_w")
4340    } else {
4341        let mut cte_parts = emit_user_cte_parts(&ins.nested_ctes);
4342        let mut insert_sql = emit_insert_row_sql(ins);
4343        if let Some(conflict) = &ins.unless_conflict {
4344            emit_conflict(&mut insert_sql, conflict);
4345        }
4346        insert_sql.push_str("\nRETURNING \"id\"");
4347        cte_parts.push(format!("\"_w\" AS (\n{}\n)", insert_sql));
4348        cte_parts
4349    };
4350    cte_parts.extend(enqueue_ctes(&ins.enqueue_vector, "_w"));
4351    cte_parts.extend(enqueue_search_ctes(&ins.enqueue_search, "_w", ins.enqueue_vector.len()));
4352
4353    let (shape, select_sql) = shape_select_from_cte(&ins.target, &ins.returning, "_w");
4354    let sql = format!(
4355        "WITH\n{}\n{}",
4356        cte_parts.join(",\n"),
4357        select_sql.unwrap_or_else(|| "SELECT * FROM \"_w\"".to_string()),
4358    );
4359    SqlOutput {
4360        sql,
4361        shape,
4362        inference_plan: None,
4363    }
4364}
4365
4366// ── UPDATE ──────────────────────────────────────────────────────────────────
4367
4368fn emit_poly_update_stmt(upd: &IrUpdate, user_ctes: &[IrCteDef]) -> SqlOutput {
4369    let alias = &upd.target.alias;
4370    let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4371
4372    // Nested-DML CTEs first — a single hoisted CTE (e.g. a nested INSERT)
4373    // is materialized once and safely referenced from every per-implementor
4374    // branch below via its own FROM clause; at most one branch's filter
4375    // will ever actually match a row, since each row belongs to exactly
4376    // one concrete implementor table.
4377    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
4378    cte_parts.extend(emit_user_cte_parts(&upd.nested_ctes));
4379    let mut union_parts = vec![];
4380    let from_ctes = nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n");
4381
4382    let has_any_multilink = !upd.multi_link_clears.is_empty()
4383        || !upd.multi_link_replaces.is_empty()
4384        || !upd.multi_link_appends.is_empty()
4385        || !upd.multi_link_removals.is_empty();
4386    for (i, imp) in upd.poly_implementors.iter().enumerate() {
4387        let cte_name = format!("_u{}", i);
4388        // Junction rows live in each implementor's own junction table, so
4389        // with any to write each implementor is updated as the concrete type
4390        // it is.
4391        if has_any_multilink {
4392            cte_parts.extend(emit_update_multilink_cte_parts(
4393                &concrete_poly_update(upd, imp),
4394                &cte_name,
4395                false,
4396            ));
4397        } else {
4398            let mut upd_sql = format!(
4399                "UPDATE {} AS {}\nSET {}{}",
4400                qn(&imp.module, &imp.table),
4401                qi(alias),
4402                sets.join(", "),
4403                from_ctes,
4404            );
4405            append_filter(&mut upd_sql, &upd.filter);
4406            upd_sql.push_str(&format!("\nRETURNING {}.\"id\"", qi(alias)));
4407            cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, upd_sql));
4408        }
4409
4410        let r_alias = format!("_r{}", i);
4411        union_parts.push(format!(
4412            "SELECT ROW({}::text, {}.\"id\") AS result FROM \"{}\" AS {}",
4413            sql_str(&imp.type_name),
4414            qi(&r_alias),
4415            cte_name,
4416            qi(&r_alias),
4417        ));
4418    }
4419
4420    let sql = format!("WITH\n{}\n{}", cte_parts.join(",\n"), union_parts.join("\nUNION ALL\n"),);
4421
4422    let (shape, _) = emit_returning_shape(&upd.target, &upd.returning, true);
4423    SqlOutput {
4424        sql,
4425        shape,
4426        inference_plan: None,
4427    }
4428}
4429
4430fn emit_update_stmt(upd: &IrUpdate, user_ctes: &[IrCteDef]) -> SqlOutput {
4431    if !upd.poly_implementors.is_empty() {
4432        return emit_poly_update_stmt(upd, user_ctes);
4433    }
4434    let alias = &upd.target.alias;
4435    let (shape, returning_sql) = emit_returning_shape(&upd.target, &upd.returning, true);
4436
4437    let has_any_multilink = !upd.multi_link_clears.is_empty()
4438        || !upd.multi_link_replaces.is_empty()
4439        || !upd.multi_link_appends.is_empty()
4440        || !upd.multi_link_removals.is_empty();
4441
4442    if !has_any_multilink && upd.enqueue_vector.is_empty() && upd.enqueue_search.is_empty() {
4443        // No junction changes, no enqueue — plain UPDATE (possibly with a
4444        // user CTE prefix and/or hoisted nested-DML CTEs from a link value
4445        // like `author := (select (insert Person {...}) { id })` — see
4446        // IrUpdate::nested_ctes). A nested CTE's `id` column is referenced
4447        // from the SET clause, which needs a `FROM` clause to see it —
4448        // ordinary `UPDATE ... SET ...` has no FROM of its own otherwise.
4449        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4450        let mut sql = format!(
4451            "UPDATE {} AS {}\nSET {}",
4452            target_ref(&upd.target),
4453            qi(alias),
4454            sets.join(", "),
4455        );
4456        sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n"));
4457        append_filter(&mut sql, &upd.filter);
4458        if let Some(r) = returning_sql {
4459            sql.push_str(&r);
4460        }
4461        // The user's bindings first: a nested statement may read them, and a
4462        // CTE only sees the ones before it.
4463        let combined_ctes: Vec<IrCteDef> = user_ctes
4464            .iter()
4465            .cloned()
4466            .chain(upd.nested_ctes.iter().cloned())
4467            .collect();
4468        if !combined_ctes.is_empty() {
4469            sql = format!("{}{}", emit_cte_prefix(&combined_ctes), sql);
4470        }
4471        return SqlOutput {
4472            sql,
4473            shape,
4474            inference_plan: None,
4475        };
4476    }
4477
4478    if !has_any_multilink && (!upd.enqueue_vector.is_empty() || !upd.enqueue_search.is_empty()) {
4479        // No junction changes but need to enqueue — wrap UPDATE in a CTE.
4480        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4481        let mut upd_sql = format!(
4482            "    UPDATE {} AS {}\n    SET {}",
4483            target_ref(&upd.target),
4484            qi(alias),
4485            sets.join(", "),
4486        );
4487        upd_sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n    "));
4488        append_filter(&mut upd_sql, &upd.filter);
4489        upd_sql.push_str("\n    RETURNING \"id\"");
4490
4491        let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
4492        cte_parts.extend(emit_user_cte_parts(&upd.nested_ctes));
4493        cte_parts.push(format!("\"_w\" AS (\n{}\n)", upd_sql));
4494        cte_parts.extend(enqueue_ctes(&upd.enqueue_vector, "_w"));
4495        cte_parts.extend(enqueue_search_ctes(&upd.enqueue_search, "_w", upd.enqueue_vector.len()));
4496
4497        let (shape2, select_sql) = shape_select_from_cte(&upd.target, &upd.returning, "_w");
4498        let sql = format!(
4499            "WITH\n{}\n{}",
4500            cte_parts.join(",\n"),
4501            select_sql.unwrap_or_else(|| "SELECT * FROM \"_w\"".to_string()),
4502        );
4503        return SqlOutput {
4504            sql,
4505            shape: shape2,
4506            inference_plan: None,
4507        };
4508    }
4509
4510    // CTE-based UPDATE for junction table mutations.
4511    let result_expr = if !upd.returning.is_empty() {
4512        let (pointer_exprs, _) = build_shape(&upd.returning, alias);
4513        let mut parts = vec![type_disc(&upd.target.type_name)];
4514        parts.extend(pointer_exprs);
4515        parts.join(",\n    ")
4516    } else {
4517        format!("{}.id", qi(alias))
4518    };
4519
4520    let has_scalar_changes = !upd.assignments.is_empty() || !upd.rewrites.is_empty();
4521
4522    // Nested-DML CTEs first (see IrUpdate::nested_ctes) — only relevant when
4523    // has_scalar_changes, since nested_ctes is only ever populated while
4524    // compiling scalar assignments, which is exactly the condition below.
4525    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
4526    cte_parts.extend(emit_user_cte_parts(&upd.nested_ctes));
4527
4528    // _ids: the target rows (updated or selected).
4529    if has_scalar_changes {
4530        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4531        let mut upd_sql = format!(
4532            "UPDATE {} AS {}\nSET {}",
4533            target_ref(&upd.target),
4534            qi(alias),
4535            sets.join(", "),
4536        );
4537        upd_sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n"));
4538        append_filter(&mut upd_sql, &upd.filter);
4539        upd_sql.push_str("\nRETURNING *");
4540        cte_parts.push(format!("\"_ids\" AS (\n{}\n)", upd_sql));
4541    } else {
4542        let mut sel = format!(
4543            "SELECT {}.* FROM {} AS {}",
4544            qi(alias),
4545            target_ref(&upd.target),
4546            qi(alias),
4547        );
4548        append_filter(&mut sel, &upd.filter);
4549        cte_parts.push(format!("\"_ids\" AS (\n{}\n)", sel));
4550    }
4551
4552    // Junction clears (`:= {}` and `:= expr` — the clear part of replace).
4553    for (i, clr) in upd.multi_link_clears.iter().enumerate() {
4554        let exclude = ml_clear_exclusion(upd.multi_link_replaces.get(i));
4555        let del = format!(
4556            "DELETE FROM {} WHERE {} IN (SELECT id FROM \"_ids\"){}",
4557            qn(&clr.module, &clr.junction_table),
4558            qi(&clr.source_col),
4559            exclude,
4560        );
4561        cte_parts.push(format!("\"_clr_{}\" AS (\n{}\n)", i, del));
4562    }
4563
4564    // Junction appends (`+=`).
4565    for (i, app) in upd.multi_link_appends.iter().enumerate() {
4566        cte_parts.push(emit_ml_append_cte(
4567            app,
4568            "_ids",
4569            &upd.target.alias,
4570            &format!("_ml_add_{}", i),
4571        ));
4572    }
4573
4574    // Junction removals (`-=`).
4575    for (i, rem) in upd.multi_link_removals.iter().enumerate() {
4576        cte_parts.push(emit_ml_remove_cte(
4577            rem,
4578            "_ids",
4579            &upd.target.alias,
4580            &format!("_ml_rm_{}", i),
4581        ));
4582    }
4583
4584    // Junction inserts for replace (`:= expr` — insert after the clear).
4585    for (i, rep) in upd.multi_link_replaces.iter().enumerate() {
4586        cte_parts.push(emit_ml_append_cte(
4587            rep,
4588            "_ids",
4589            &upd.target.alias,
4590            &format!("_ml_rep_{}", i),
4591        ));
4592    }
4593
4594    // Enqueue CTEs (source is _ids which has all columns including id).
4595    cte_parts.extend(enqueue_ctes(&upd.enqueue_vector, "_ids"));
4596    cte_parts.extend(enqueue_search_ctes(
4597        &upd.enqueue_search,
4598        "_ids",
4599        upd.enqueue_vector.len(),
4600    ));
4601
4602    let sql = format!(
4603        "WITH\n{}\nSELECT (\n    {}\n) AS result\nFROM \"_ids\" AS {}",
4604        cte_parts.join(",\n"),
4605        result_expr,
4606        qi(alias),
4607    );
4608    SqlOutput {
4609        sql,
4610        shape,
4611        inference_plan: None,
4612    }
4613}
4614
4615// ── DELETE ──────────────────────────────────────────────────────────────────
4616
4617fn emit_delete_stmt(del: &IrDelete) -> SqlOutput {
4618    if !del.poly_implementors.is_empty() {
4619        return emit_poly_delete_stmt(del);
4620    }
4621    let alias = &del.target.alias;
4622
4623    if del.enqueue_search.is_empty() {
4624        let mut sql = format!("DELETE FROM {} AS {}", target_ref(&del.target), qi(alias),);
4625        append_filter(&mut sql, &del.filter);
4626        let (shape, returning_sql) = emit_returning_shape(&del.target, &del.returning, true);
4627        if let Some(r) = returning_sql {
4628            sql.push_str(&r);
4629        }
4630        return SqlOutput {
4631            sql,
4632            shape,
4633            inference_plan: None,
4634        };
4635    }
4636
4637    // Wrap DELETE in a CTE to enqueue OpenSearch delete jobs.
4638    let mut del_sql = format!("    DELETE FROM {} AS {}", target_ref(&del.target), qi(alias),);
4639    append_filter(&mut del_sql, &del.filter);
4640    del_sql.push_str("\n    RETURNING \"id\"");
4641
4642    let mut cte_parts = vec![format!("\"_del\" AS (\n{}\n)", del_sql)];
4643    cte_parts.extend(enqueue_search_ctes(&del.enqueue_search, "_del", 0));
4644
4645    let (shape, select_sql) = shape_select_from_cte(&del.target, &del.returning, "_del");
4646    let sql = format!(
4647        "WITH\n{}\n{}",
4648        cte_parts.join(",\n"),
4649        select_sql.unwrap_or_else(|| "SELECT * FROM \"_del\"".to_string()),
4650    );
4651    SqlOutput {
4652        sql,
4653        shape,
4654        inference_plan: None,
4655    }
4656}
4657
4658fn emit_poly_delete_stmt(del: &IrDelete) -> SqlOutput {
4659    let alias = &del.target.alias;
4660    let mut cte_parts = vec![];
4661    let mut union_parts = vec![];
4662
4663    for (i, imp) in del.poly_implementors.iter().enumerate() {
4664        let cte_name = format!("_d{}", i);
4665        let mut del_sql = format!("DELETE FROM {} AS {}", qn(&imp.module, &imp.table), qi(alias),);
4666        append_filter(&mut del_sql, &del.filter);
4667        del_sql.push_str(&format!("\nRETURNING {}.\"id\"", qi(alias)));
4668        cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, del_sql));
4669
4670        let r_alias = format!("_r{}", i);
4671        union_parts.push(format!(
4672            "SELECT ROW({}::text, {}.\"id\") AS result FROM \"{}\" AS {}",
4673            sql_str(&imp.type_name),
4674            qi(&r_alias),
4675            cte_name,
4676            qi(&r_alias),
4677        ));
4678    }
4679
4680    let sql = format!("WITH\n{}\n{}", cte_parts.join(",\n"), union_parts.join("\nUNION ALL\n"),);
4681
4682    let (shape, _) = emit_returning_shape(&del.target, &del.returning, true);
4683    SqlOutput {
4684        sql,
4685        shape,
4686        inference_plan: None,
4687    }
4688}
4689
4690// ── RETURNING helper ─────────────────────────────────────────────────────────
4691
4692/// Builds the RETURNING clause and ShapeDescriptor for DML.
4693/// `with_alias`: UPDATE/DELETE can use the table alias; INSERT cannot.
4694fn emit_returning_shape(
4695    target: &IrSource,
4696    returning: &[IrShapePointer],
4697    with_alias: bool,
4698) -> (ShapeDescriptor, Option<String>) {
4699    if returning.is_empty() {
4700        return (
4701            ShapeDescriptor {
4702                root: ShapeNode::Scalar {
4703                    name: String::new(),
4704                    position: 0,
4705                },
4706            },
4707            None,
4708        );
4709    }
4710
4711    let alias = if with_alias { target.alias.as_str() } else { "" };
4712    let (pointer_exprs, shape_pointers) = build_shape(returning, alias);
4713
4714    let mut parts = vec![type_disc(&target.type_name)];
4715    parts.extend(pointer_exprs);
4716    let tuple = parts.join(",\n    ");
4717    let sql = format!("\nRETURNING (\n    {}\n) AS result", tuple);
4718
4719    let root_pointers = prepend_type(shape_pointers);
4720    let shape = ShapeDescriptor {
4721        root: ShapeNode::Object {
4722            name: String::new(),
4723            type_name: Some(target.type_name.clone()),
4724            position: 0,
4725            cardinality: Cardinality::Required,
4726            pointers: root_pointers,
4727            has_implicit_id: shape_has_implicit_id(returning),
4728        },
4729    };
4730    (shape, Some(sql))
4731}
4732
4733// ── Shape emission ───────────────────────────────────────────────────────────
4734
4735fn emit_scalar_set(f: &IrScalarSetPointer, pos: usize) -> (String, ShapeNode) {
4736    let from_sql = if !f.poly_implementors.is_empty() {
4737        format!(
4738            "(\n{}\n) AS {}",
4739            emit_poly_union(&f.poly_implementors, &f.poly_columns),
4740            qi(&f.source.alias)
4741        )
4742    } else {
4743        format!("{} AS {}", source_ref(&f.source), qi(&f.source.alias))
4744    };
4745    let sql = format!(
4746        "(SELECT COALESCE(array_agg(ROW({})::record), ARRAY[]::record[]) FROM {})",
4747        emit_expr(&f.bool_expr),
4748        from_sql,
4749    );
4750    let node = ShapeNode::Array {
4751        name: f.alias.clone(),
4752        position: pos,
4753        element: Box::new(ShapeNode::Scalar {
4754            name: String::new(),
4755            position: 0,
4756        }),
4757    };
4758    (sql, node)
4759}
4760
4761/// Build SQL expressions and ShapeNodes for `pointers`, starting at position 1
4762/// (position 0 is always the type discriminator, added by the caller).
4763/// True when the compiler put an `id` at the front of this shape that the
4764/// query never asked for — see `IrScalarPointer::implicit_id`.
4765fn shape_has_implicit_id(pointers: &[IrShapePointer]) -> bool {
4766    matches!(pointers.first(), Some(IrShapePointer::Scalar(p)) if p.implicit_id)
4767}
4768
4769fn build_shape(pointers: &[IrShapePointer], table_alias: &str) -> (Vec<String>, Vec<ShapeNode>) {
4770    let mut exprs = Vec::new();
4771    let mut nodes = Vec::new();
4772
4773    for (i, pointer) in pointers.iter().enumerate() {
4774        let (sql, node) = emit_shape_pointer(pointer, table_alias, i + 1);
4775        exprs.push(sql);
4776        nodes.push(node);
4777    }
4778
4779    (exprs, nodes)
4780}
4781
4782/// A `$1`, `$2`, … template with each placeholder replaced by its argument's
4783/// SQL, in one pass over the template alone. Replacing one number at a time
4784/// over the growing string would rewrite a query parameter an argument
4785/// carries (`$5` inside the first argument, replaced again as the fifth), and
4786/// `$1` also matches the start of `$10`.
4787fn fill_template(template: &str, args: &[String]) -> String {
4788    let mut out = String::with_capacity(template.len());
4789    let mut chars = template.char_indices().peekable();
4790    while let Some((start, c)) = chars.next() {
4791        if c != '$' {
4792            out.push(c);
4793            continue;
4794        }
4795        let mut end = start + 1;
4796        while let Some((i, d)) = chars.peek().copied()
4797            && d.is_ascii_digit()
4798        {
4799            end = i + d.len_utf8();
4800            chars.next();
4801        }
4802        match template[start + 1..end].parse::<usize>() {
4803            Ok(n) if (1..=args.len()).contains(&n) => out.push_str(&args[n - 1]),
4804            _ => out.push_str(&template[start..end]),
4805        }
4806    }
4807    out
4808}
4809
4810/// `, message` for an assert given `message := …`, else nothing.
4811fn assert_message_arg(message: &Option<IrExpr>) -> String {
4812    message
4813        .as_ref()
4814        .map(|m| format!(", ({})::text", emit_expr(m)))
4815        .unwrap_or_default()
4816}
4817
4818fn emit_shape_pointer(pointer: &IrShapePointer, table_alias: &str, pos: usize) -> (String, ShapeNode) {
4819    match pointer {
4820        IrShapePointer::Scalar(f) => emit_scalar(f, table_alias, pos),
4821        IrShapePointer::SingleLink(f) => emit_single_link(f, table_alias, pos),
4822        IrShapePointer::MultiLink(f) => emit_multi_link(f, table_alias, pos),
4823        IrShapePointer::Computed(f) => (emit_expr(&f.expr), expr_shape_node(&f.alias, pos, &f.expr)),
4824        IrShapePointer::ScalarSet(f) => emit_scalar_set(f, pos),
4825        IrShapePointer::Asserted(a) => {
4826            let (sql, node) = emit_shape_pointer(&a.inner, table_alias, pos);
4827            // The inner pointer aggregates rows to `record[]`, which a PL/pgSQL
4828            // function cannot take as a parameter at all -- hence the cast to
4829            // `text[]`, whose rendering compares element-for-element the same
4830            // way. Bound in a sub-select so the pointer's own subquery is
4831            // evaluated once rather than in both the check and the result, and
4832            // read through `cardinality` because the assert hands back the
4833            // array it was given.
4834            // A pointer landing on one object reads as a lone record, not an
4835            // array, so its set is re-read through `ARRAY(…)`; the unlimited
4836            // check form would otherwise fail on more than one row.
4837            let checked_set = match &a.check {
4838                Some(check) if emits_one_object(check) => {
4839                    format!("ARRAY{}", emit_shape_pointer(check, table_alias, pos).0)
4840                }
4841                Some(check) => emit_shape_pointer(check, table_alias, pos).0,
4842                None if emits_one_object(&a.inner) => format!("ARRAY{sql}"),
4843                None => "\"_a\".\"v\"".to_string(),
4844            };
4845            let checked = format!(
4846                "(SELECT \"_a\".\"v\" FROM (SELECT {sql} AS \"v\") AS \"_a\"\n                     WHERE cardinality(\"_pylon\".{}({checked_set}::text[]{})) >= 0)",
4847                qi(&a.fn_name),
4848                assert_message_arg(&a.message),
4849            );
4850            (checked, node)
4851        }
4852    }
4853}
4854
4855/// True when the pointer is emitted as a single `(SELECT …)` record rather
4856/// than an aggregated array of them.
4857fn emits_one_object(pointer: &IrShapePointer) -> bool {
4858    match pointer {
4859        IrShapePointer::SingleLink(_) => true,
4860        IrShapePointer::Computed(c) => matches!(
4861            c.expr,
4862            IrExpr::ObjectPathSubquery(_) | IrExpr::ObjectPathUnion { multi: false, .. }
4863        ),
4864        IrShapePointer::Asserted(a) => emits_one_object(&a.inner),
4865        _ => false,
4866    }
4867}
4868
4869/// Convert a PostgreSQL schema-qualified type name (`"module"."TypeName"`) to
4870/// a Pylon-qualified name (`module::TypeName`).
4871fn pg_quoted_to_pylon(pg_type: &str) -> String {
4872    let inner = pg_type.trim_start_matches('"');
4873    if let Some(idx) = inner.find(r#""."#) {
4874        let module = &inner[..idx];
4875        let type_name = inner[idx + 3..].trim_end_matches('"');
4876        format!("{}::{}", module, type_name)
4877    } else {
4878        pg_type.to_string()
4879    }
4880}
4881
4882/// A schema-qualified custom type (an enum or a domain) named in a property's
4883/// `pg_type`, together with whether the column holds an array of it.
4884///
4885/// Both forms are emitted as text -- `::text` for a scalar, `::text[]` for an
4886/// array -- so a value's shape never depends on an OID the database assigns
4887/// when the migration creating the type runs. The array case has to be told
4888/// apart from the scalar one: `"access"."AuthenticationMethod"[]` starts with a
4889/// quote just as `"account"."InterfaceAppearance"` does, and reading it as a
4890/// scalar enum casts a whole array to a single `text` (`'{MagicLink}'`) and
4891/// shapes it as one enum value.
4892struct QualifiedPgType {
4893    /// Pylon-qualified name of the element type, e.g. `access::AuthenticationMethod`.
4894    name: String,
4895    is_array: bool,
4896}
4897
4898impl QualifiedPgType {
4899    fn of(pg_type: &str) -> Option<Self> {
4900        let (element, is_array) = match pg_type.strip_suffix("[]") {
4901            Some(element) => (element, true),
4902            None => (pg_type, false),
4903        };
4904        element.starts_with('"').then(|| Self {
4905            name: pg_quoted_to_pylon(element),
4906            is_array,
4907        })
4908    }
4909
4910    /// The cast that carries this value back as text.
4911    fn text_cast(&self) -> &'static str {
4912        if self.is_array { "::text[]" } else { "::text" }
4913    }
4914
4915    /// How the decoded value is shaped: a single enum, or a list of them.
4916    fn shape_node(&self, name: String, position: usize) -> ShapeNode {
4917        let element = ShapeNode::Enum {
4918            name: name.clone(),
4919            position,
4920            enum_type: self.name.clone(),
4921        };
4922        if !self.is_array {
4923            return element;
4924        }
4925        ShapeNode::Array {
4926            name,
4927            // Each element arrives as the label itself rather than as a field
4928            // of a record, which the decoders read at position 0.
4929            element: Box::new(ShapeNode::Enum {
4930                name: String::new(),
4931                position: 0,
4932                enum_type: self.name.clone(),
4933            }),
4934            position,
4935        }
4936    }
4937}
4938
4939fn emit_scalar(f: &IrScalarPointer, table_alias: &str, pos: usize) -> (String, ShapeNode) {
4940    // A tuple-typed property — nominal (the `__nt__:module::Name` marker,
4941    // hydrating to the registered class) or structural (`tuple_shape`, with
4942    // no registered type to name). Either way the column is a composite
4943    // type, read as it stands: every member arrives carrying its own
4944    // PostgreSQL type, which is the whole reason the column has that type
4945    // (see `schema::tuple_type`).
4946    let nominal_tuple = f
4947        .pg_type
4948        .strip_suffix("[]")
4949        .unwrap_or(&f.pg_type)
4950        .strip_prefix("__nt__:");
4951    if nominal_tuple.is_some() || f.tuple_shape.is_some() {
4952        let is_array = f.pg_type.ends_with("[]");
4953        let sql = if table_alias.is_empty() {
4954            qi(&f.column)
4955        } else {
4956            format!("{}.{}", qi(table_alias), qi(&f.column))
4957        };
4958        let type_name = nominal_tuple
4959            .map(str::to_string)
4960            .or_else(|| f.tuple_shape.as_ref().and_then(|s| s.type_name.clone()));
4961        let members = f.tuple_shape.as_ref().map(|s| s.members.as_slice()).unwrap_or(&[]);
4962        // Inside an array the element is the whole of its own value and
4963        // carries no name, the way an array of objects does.
4964        let tuple = composite_tuple_shape(
4965            if is_array { "" } else { &f.alias },
4966            if is_array { 0 } else { pos },
4967            type_name,
4968            members,
4969        );
4970        let node = if is_array {
4971            ShapeNode::Array {
4972                name: f.alias.clone(),
4973                position: pos,
4974                element: Box::new(tuple),
4975            }
4976        } else {
4977            tuple
4978        };
4979        return (sql, node);
4980    }
4981    // Schema-qualified custom types (enums, domains) have runtime OIDs unknown to a static
4982    // anonymous_record_decode. Cast to text — the string label is all the decoder needs.
4983    if let Some(qualified) = QualifiedPgType::of(&f.pg_type) {
4984        let cast = qualified.text_cast();
4985        let sql = if table_alias.is_empty() {
4986            format!("{}{}", qi(&f.column), cast)
4987        } else {
4988            format!("{}.{}{}", qi(table_alias), qi(&f.column), cast)
4989        };
4990        return (sql, qualified.shape_node(f.alias.clone(), pos));
4991    }
4992    let sql = if table_alias.is_empty() {
4993        format!("{}::{}", qi(&f.column), f.pg_type)
4994    } else {
4995        format!("{}.{}::{}", qi(table_alias), qi(&f.column), f.pg_type)
4996    };
4997    (
4998        sql,
4999        ShapeNode::Scalar {
5000            name: f.alias.clone(),
5001            position: pos,
5002        },
5003    )
5004}
5005
5006fn emit_single_link(f: &IrSingleLinkPointer, parent_alias: &str, pos: usize) -> (String, ShapeNode) {
5007    let sub = &f.subquery;
5008    let [IrRowSource::Bound { source, shape }] = sub.rows.as_slice() else {
5009        unreachable!("single-link subquery is always schema-bound")
5010    };
5011    let sub_alias = &source.alias;
5012
5013    let (sub_exprs, mut sub_nodes) = build_shape(shape, sub_alias);
5014    let mut parts = vec![source_type_disc(source)];
5015    parts.extend(sub_exprs);
5016
5017    // Link properties: read from the junction table alias "jt" — only ever
5018    // populated for a junction-backed link (see `IrSingleLinkPointer`'s doc
5019    // comment), which is exactly when `f.correlation` below puts "jt" in
5020    // scope.
5021    for lp in &f.link_properties {
5022        parts.push(format!("\"jt\".{}", qi(&lp.name)));
5023        let pos = sub_nodes.len() + 1;
5024        sub_nodes.push(ShapeNode::Scalar {
5025            name: format!("@{}", lp.name),
5026            position: pos,
5027        });
5028    }
5029
5030    let tuple = parts.join(",\n        ");
5031
5032    let (from_sql, mut where_parts) = match &f.correlation {
5033        IrSingleLinkCorrelation::Fk { fk_column, target_pk } => {
5034            let from = format!("FROM {} AS {}", source_ref(source), qi(sub_alias));
5035            let cond = format!(
5036                "{}.{} = {}.{}",
5037                qi(parent_alias),
5038                qi(fk_column),
5039                qi(sub_alias),
5040                qi(target_pk),
5041            );
5042            (from, vec![cond])
5043        }
5044        // Junction-backed — same join shape a multi-link's correlated
5045        // subquery uses; cardinality-one is a schema-level invariant on the
5046        // junction table, not something this query needs to enforce itself.
5047        IrSingleLinkCorrelation::Junction { join, target_pk: _ } => match join {
5048            IrMultiLinkJoin::Standard { junction_table, module } => {
5049                let from = format!(
5050                    "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".target",
5051                    junction_ref(module, junction_table),
5052                    source_ref(source),
5053                    qi(sub_alias),
5054                    qi(sub_alias),
5055                );
5056                let cond = format!("\"jt\".source = {}.id", qi(parent_alias));
5057                (from, vec![cond])
5058            }
5059            IrMultiLinkJoin::Through {
5060                junction_table,
5061                module,
5062                source_col,
5063                target_col,
5064            } => {
5065                let from = format!(
5066                    "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".{}",
5067                    junction_ref(module, junction_table),
5068                    source_ref(source),
5069                    qi(sub_alias),
5070                    qi(sub_alias),
5071                    qi(target_col),
5072                );
5073                let cond = format!("\"jt\".{} = {}.id", qi(source_col), qi(parent_alias));
5074                (from, vec![cond])
5075            }
5076            IrMultiLinkJoin::BacklinkFk { .. } | IrMultiLinkJoin::BacklinkJunction { .. } => {
5077                unreachable!("a junction-backed single link's own forward join is always Standard or Through")
5078            }
5079        },
5080    };
5081    if let Some(filter) = &sub.filter {
5082        where_parts.push(emit_expr(filter));
5083    }
5084
5085    let mut sql = format!(
5086        "(SELECT (\n        {}\n    )\n    {}\n    WHERE {}",
5087        tuple,
5088        from_sql,
5089        where_parts.join(" AND "),
5090    );
5091    if !sub.order_by.is_empty() {
5092        let s: Vec<_> = sub.order_by.iter().map(emit_sort_clause).collect();
5093        sql.push_str(&format!("\n    ORDER BY {}", s.join(", ")));
5094    }
5095    sql.push(')');
5096
5097    let node = ShapeNode::Object {
5098        name: f.alias.clone(),
5099        type_name: Some(source.type_name.clone()),
5100        position: pos,
5101        cardinality: Cardinality::Optional,
5102        pointers: prepend_type(sub_nodes),
5103        has_implicit_id: shape_has_implicit_id(shape),
5104    };
5105    (sql, node)
5106}
5107
5108fn emit_multi_link(f: &IrMultiLinkPointer, parent_alias: &str, pos: usize) -> (String, ShapeNode) {
5109    let sub = &f.subquery;
5110    let [IrRowSource::Bound { source, shape }] = sub.rows.as_slice() else {
5111        unreachable!("multi-link subquery is always schema-bound")
5112    };
5113    let sub_alias = &source.alias;
5114
5115    let (sub_exprs, mut sub_nodes) = build_shape(shape, sub_alias);
5116    let mut row_parts = vec![source_type_disc(source)];
5117    row_parts.extend(sub_exprs);
5118
5119    // Link properties: read from the junction table alias "jt".
5120    // The ShapeNode name carries the `@` prefix so hydration stores it as
5121    // `@prop` in the object's __dict__ and the REPL displays it with `@`.
5122    for lp in &f.link_properties {
5123        row_parts.push(format!("\"jt\".{}", qi(&lp.name)));
5124        // Positions are 1-based (0 = type discriminator). sub_nodes.len() gives
5125        // the count of already-assigned positions, so the next position is len+1.
5126        let pos = sub_nodes.len() + 1;
5127        sub_nodes.push(ShapeNode::Scalar {
5128            name: format!("@{}", lp.name),
5129            position: pos,
5130        });
5131    }
5132
5133    let row = row_parts.join(",\n            ");
5134
5135    // ORDER BY inside array_agg
5136    let order_sql = if !sub.order_by.is_empty() {
5137        let s: Vec<_> = sub.order_by.iter().map(emit_sort_clause).collect();
5138        format!(" ORDER BY {}", s.join(", "))
5139    } else {
5140        String::new()
5141    };
5142
5143    let (from_sql, source_cond) = match &f.join {
5144        IrMultiLinkJoin::Standard { junction_table, module } => {
5145            let from = format!(
5146                "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".target",
5147                junction_ref(module, junction_table),
5148                source_ref(source),
5149                qi(sub_alias),
5150                qi(sub_alias),
5151            );
5152            let cond = format!("\"jt\".source = {}.id", qi(parent_alias));
5153            (from, cond)
5154        }
5155        IrMultiLinkJoin::Through {
5156            junction_table,
5157            module,
5158            source_col,
5159            target_col,
5160        } => {
5161            let from = format!(
5162                "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".{}",
5163                junction_ref(module, junction_table),
5164                source_ref(source),
5165                qi(sub_alias),
5166                qi(sub_alias),
5167                qi(target_col),
5168            );
5169            let cond = format!("\"jt\".{} = {}.id", qi(source_col), qi(parent_alias));
5170            (from, cond)
5171        }
5172        // Reverse of a single link: no junction table, the owner
5173        // (sub-select) rows are correlated directly by their own FK column.
5174        IrMultiLinkJoin::BacklinkFk { fk_col } => {
5175            let from = format!("FROM {} AS {}", source_ref(source), qi(sub_alias));
5176            let cond = format!("{}.{} = {}.id", qi(sub_alias), qi(fk_col), qi(parent_alias));
5177            (from, cond)
5178        }
5179        // Reverse of a multi-link: same junction table shape as `Standard`/
5180        // `Through`, with the owner/current column roles swapped — the
5181        // sub-select's own rows join via `owner_col`, the outer (current)
5182        // row correlates via `current_col`.
5183        IrMultiLinkJoin::BacklinkJunction {
5184            junction_table,
5185            module,
5186            owner_col,
5187            current_col,
5188        } => {
5189            let from = format!(
5190                "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".{}",
5191                junction_ref(module, junction_table),
5192                source_ref(source),
5193                qi(sub_alias),
5194                qi(sub_alias),
5195                qi(owner_col),
5196            );
5197            let cond = format!("\"jt\".{} = {}.id", qi(current_col), qi(parent_alias));
5198            (from, cond)
5199        }
5200    };
5201
5202    let mut where_parts = vec![source_cond];
5203    if let Some(filter) = &sub.filter {
5204        where_parts.push(emit_expr(filter));
5205    }
5206
5207    if f.single {
5208        let mut sql = format!(
5209            "(SELECT (\n            {}\n        )\n    {}\n    WHERE {}",
5210            row,
5211            from_sql,
5212            where_parts.join(" AND "),
5213        );
5214        append_order_by(&mut sql, &sub.order_by);
5215        append_offset_limit(&mut sql, &sub.offset, &sub.limit);
5216        sql.push(')');
5217        let node = ShapeNode::Object {
5218            name: f.alias.clone(),
5219            type_name: Some(source.type_name.clone()),
5220            position: pos,
5221            cardinality: Cardinality::Optional,
5222            pointers: prepend_type(sub_nodes),
5223            has_implicit_id: shape_has_implicit_id(shape),
5224        };
5225        return (sql, node);
5226    }
5227
5228    if f.single {
5229        let mut sql = format!(
5230            "(SELECT (\n            {}\n        )\n    {}\n    WHERE {}",
5231            row,
5232            from_sql,
5233            where_parts.join(" AND "),
5234        );
5235        append_order_by(&mut sql, &sub.order_by);
5236        append_offset_limit(&mut sql, &sub.offset, &sub.limit);
5237        sql.push(')');
5238        let node = ShapeNode::Object {
5239            name: f.alias.clone(),
5240            type_name: Some(source.type_name.clone()),
5241            position: pos,
5242            cardinality: Cardinality::Optional,
5243            pointers: prepend_type(sub_nodes),
5244            has_implicit_id: shape_has_implicit_id(shape),
5245        };
5246        return (sql, node);
5247    }
5248
5249    let sql = if sub.limit.is_some() || sub.offset.is_some() {
5250        // OFFSET/LIMIT cut the *rows* that go into the array, so they can't
5251        // sit next to the aggregate — the aggregate collapses them to one
5252        // row first. Select the row's parts (and any sort keys) in a derived
5253        // table that carries the modifiers, then aggregate over that.
5254        let mut cols: Vec<String> = row_parts
5255            .iter()
5256            .enumerate()
5257            .map(|(i, part)| format!("{} AS \"c{}\"", part, i))
5258            .collect();
5259        let agg_row: Vec<String> = (0..row_parts.len()).map(|i| format!("\"__lim\".\"c{}\"", i)).collect();
5260        let mut agg_order: Vec<String> = vec![];
5261        for (i, s) in sub.order_by.iter().enumerate() {
5262            cols.push(format!("{} AS \"s{}\"", emit_expr(&s.expr), i));
5263            agg_order.push(emit_sort_clause(&IrSort {
5264                expr: IrExpr::ColumnRef {
5265                    alias: "__lim".to_string(),
5266                    column: format!("s{}", i),
5267                    pg_type: String::new(),
5268                },
5269                direction: s.direction.clone(),
5270                nulls: s.nulls.clone(),
5271            }));
5272        }
5273        let mut inner = format!(
5274            "SELECT {}\n    {}\n    WHERE {}",
5275            cols.join(",\n        "),
5276            from_sql,
5277            where_parts.join(" AND "),
5278        );
5279        append_order_by(&mut inner, &sub.order_by);
5280        append_offset_limit(&mut inner, &sub.offset, &sub.limit);
5281        let agg_order_sql = if agg_order.is_empty() {
5282            String::new()
5283        } else {
5284            format!(" ORDER BY {}", agg_order.join(", "))
5285        };
5286        format!(
5287            "(SELECT COALESCE(\n        array_agg(ROW(\n            {}\n        )::record{}),\n        ARRAY[]::record[]\n    )\n    FROM ({}) AS \"__lim\")",
5288            agg_row.join(",\n            "),
5289            agg_order_sql,
5290            inner,
5291        )
5292    } else {
5293        format!(
5294            "(SELECT COALESCE(\n        array_agg(ROW(\n            {}\n        )::record{}),\n        ARRAY[]::record[]\n    )\n    {}\n    WHERE {})",
5295            row,
5296            order_sql,
5297            from_sql,
5298            where_parts.join(" AND "),
5299        )
5300    };
5301
5302    let node = ShapeNode::Array {
5303        name: f.alias.clone(),
5304        position: pos,
5305        element: Box::new(ShapeNode::Object {
5306            name: String::new(),
5307            type_name: Some(source.type_name.clone()),
5308            position: 0,
5309            cardinality: Cardinality::Required,
5310            pointers: prepend_type(sub_nodes),
5311            has_implicit_id: shape_has_implicit_id(shape),
5312        }),
5313    };
5314    (sql, node)
5315}
5316
5317/// Prepend `ShapeNode::Scalar { name: "__type__", position: 0 }` and shift
5318/// existing nodes' positions by 1.
5319fn prepend_type(nodes: Vec<ShapeNode>) -> Vec<ShapeNode> {
5320    let mut out = vec![ShapeNode::Scalar {
5321        name: "__type__".into(),
5322        position: 0,
5323    }];
5324    out.extend(nodes);
5325    out
5326}
5327
5328// ── SQL clause helpers ──────────────────────────────────────────────────────
5329
5330fn append_filter(sql: &mut String, filter: &Option<IrExpr>) {
5331    if let Some(f) = filter {
5332        sql.push_str(&format!("\nWHERE {}", emit_expr(f)));
5333    }
5334}
5335
5336fn append_order_by(sql: &mut String, order_by: &[IrSort]) {
5337    if !order_by.is_empty() {
5338        let s: Vec<_> = order_by.iter().map(emit_sort_clause).collect();
5339        sql.push_str(&format!("\nORDER BY {}", s.join(", ")));
5340    }
5341}
5342
5343fn append_offset_limit(sql: &mut String, offset: &Option<IrExpr>, limit: &Option<IrExpr>) {
5344    if let Some(o) = offset {
5345        sql.push_str(&format!("\nOFFSET {}", emit_expr(o)));
5346    }
5347    if let Some(l) = limit {
5348        sql.push_str(&format!("\nLIMIT {}", emit_expr(l)));
5349    }
5350}
5351
5352/// `FOR UPDATE`/`FOR SHARE`/... — Postgres's own grammar places this last,
5353/// after `ORDER BY`/`LIMIT`/`OFFSET`, so this must be the final thing
5354/// appended to a bound SELECT's SQL. No `OF table_name` — the outer
5355/// SELECT's own aliased table is always the only lockable target (nested
5356/// shape pointers are correlated subqueries, never part of the outer FROM),
5357/// so a bare `FOR UPDATE` already targets exactly the right row(s).
5358fn append_lock_clause(sql: &mut String, lock: &Option<IrLockClause>) {
5359    let Some(lock) = lock else { return };
5360    let strength = match lock.strength {
5361        IrLockStrength::Update => "UPDATE",
5362        IrLockStrength::NoKeyUpdate => "NO KEY UPDATE",
5363        IrLockStrength::Share => "SHARE",
5364        IrLockStrength::KeyShare => "KEY SHARE",
5365    };
5366    sql.push_str(&format!("\nFOR {}", strength));
5367    match lock.wait {
5368        IrLockWait::Block => {}
5369        IrLockWait::NoWait => sql.push_str(" NOWAIT"),
5370        IrLockWait::SkipLocked => sql.push_str(" SKIP LOCKED"),
5371    }
5372}
5373
5374fn emit_sort_clause(s: &IrSort) -> String {
5375    let dir = match s.direction {
5376        IrSortDir::Asc => "ASC",
5377        IrSortDir::Desc => "DESC",
5378    };
5379    let nulls = match s.nulls {
5380        IrNulls::First => "NULLS FIRST",
5381        IrNulls::Last => "NULLS LAST",
5382    };
5383    format!("{} {} {}", emit_expr(&s.expr), dir, nulls)
5384}
5385
5386// ── Expression emission ─────────────────────────────────────────────────────
5387
5388/// One side of `a intersect b` as the rows of the set it stands for: an
5389/// array's elements, a binding's rows, else the single value.
5390fn set_operand(expr: &IrExpr) -> String {
5391    match expr {
5392        IrExpr::ArrayFromSelect(_) => format!("SELECT unnest({})", emit_expr(expr)),
5393        IrExpr::CteRef { name, scalar, .. } => {
5394            format!("SELECT {} FROM {}", if *scalar { "\"v\"" } else { "\"id\"" }, qi(name))
5395        }
5396        _ => format!("SELECT {}", emit_expr(expr)),
5397    }
5398}
5399
5400/// `emit_expr`, reading the types with subtypes through `fanouts` the way
5401/// `emit` does — see `IrOutput::subtype_fanouts`.
5402pub fn emit_expr_with_fanouts(expr: &IrExpr, fanouts: &HashMap<(String, String), IrPolyFanout>) -> String {
5403    with_subtype_fanouts(fanouts, || emit_expr(expr))
5404}
5405
5406pub fn emit_expr(expr: &IrExpr) -> String {
5407    match expr {
5408        IrExpr::ColumnRef { alias, column, .. } => {
5409            if alias.is_empty() {
5410                qi(column)
5411            } else if alias == "NEW" || alias == "OLD" {
5412                // plpgsql's trigger row variables (`ir::compile_trigger_handler`'s
5413                // `__new__`/`__old__` anchors) are recognized by the plpgsql
5414                // parser as bare, unquoted identifiers — quoting them like an
5415                // ordinary table alias (`"NEW"."col"`) turns this into an
5416                // *ordinary* SQL column reference instead, which Postgres then
5417                // rejects with "missing FROM-clause entry for table NEW" the
5418                // moment it appears inside a nested SQL command (e.g. the
5419                // VALUES list of an `INSERT` the trigger handler runs) — there's
5420                // no real `NEW` table to resolve it against there, only
5421                // plpgsql's own row-variable substitution recognizes it.
5422                format!("{alias}.{}", qi(column))
5423            } else {
5424                format!("{}.{}", qi(alias), qi(column))
5425            }
5426        }
5427        IrExpr::Param { index } => format!("${}", index + 1),
5428        IrExpr::Literal(lit) => emit_literal(lit),
5429        IrExpr::BinOp(op) => {
5430            let l = emit_expr(&op.left);
5431            let r = emit_expr(&op.right);
5432            match op.op {
5433                BinOpKind::Add => format!("({} + {})", l, r),
5434                BinOpKind::Sub => format!("({} - {})", l, r),
5435                BinOpKind::Mul => format!("({} * {})", l, r),
5436                BinOpKind::Div => format!("({} / {})", l, r),
5437                BinOpKind::FloorDiv => {
5438                    if is_integer_expr(&op.left) && is_integer_expr(&op.right) {
5439                        format!("({} / {})", l, r)
5440                    } else {
5441                        format!("floor(({}) / ({}))", l, r)
5442                    }
5443                }
5444                BinOpKind::Mod => format!("({} % {})", l, r),
5445                BinOpKind::Pow => format!("power({}, {})", l, r),
5446                BinOpKind::Eq => format!("({} = {})", l, r),
5447                BinOpKind::Ne => format!("({} <> {})", l, r),
5448                BinOpKind::Lt => format!("({} < {})", l, r),
5449                BinOpKind::Le => format!("({} <= {})", l, r),
5450                BinOpKind::Gt => format!("({} > {})", l, r),
5451                BinOpKind::Ge => format!("({} >= {})", l, r),
5452                BinOpKind::And => format!("({} AND {})", l, r),
5453                BinOpKind::Or => format!("({} OR {})", l, r),
5454                BinOpKind::Like => format!("({} LIKE {})", l, r),
5455                BinOpKind::Ilike => format!("({} ILIKE {})", l, r),
5456                BinOpKind::NotLike => format!("({} NOT LIKE {})", l, r),
5457                BinOpKind::NotIlike => format!("({} NOT ILIKE {})", l, r),
5458                // `ANY`/`ALL` take the array directly, so unwrap a
5459                // right-hand `std::array_unpack` rather than emitting its
5460                // `unnest()` — a set-returning function is not allowed in
5461                // `WHERE`, and PostgreSQL rejects it at execution time even
5462                // though the PyQL and the SQL both look fine.
5463                BinOpKind::In => format!("({} = ANY({}))", l, unwrap_unnest_for_any(&op.right, &r)),
5464                BinOpKind::NotIn => format!("({} <> ALL({}))", l, unwrap_unnest_for_any(&op.right, &r)),
5465                BinOpKind::Coalesce => format!("COALESCE({}, {})", l, r),
5466                BinOpKind::CoalesceEq => format!("({} IS NOT DISTINCT FROM {})", l, r),
5467                BinOpKind::CoalesceNe => format!("({} IS DISTINCT FROM {})", l, r),
5468                BinOpKind::Concat => format!("({} || {})", l, r),
5469            }
5470        }
5471        IrExpr::UnaryOp(op) => {
5472            let inner = emit_expr(&op.operand);
5473            match op.op {
5474                UnaryOpKind::Not => format!("(NOT {})", inner),
5475                UnaryOpKind::Minus => format!("(-{})", inner),
5476                UnaryOpKind::Exists => format!("EXISTS({})", inner),
5477                // A value that reached expression position is a single one,
5478                // and a single value is already distinct — `DISTINCT x` is not
5479                // an SQL expression. Set-valued operands carry their own
5480                // DISTINCT, applied where the set is built.
5481                UnaryOpKind::Distinct => inner,
5482            }
5483        }
5484        IrExpr::FunctionCall(f) => {
5485            let args: Vec<_> = f.args.iter().map(emit_expr).collect();
5486            if let Some(tmpl) = &f.sql_template {
5487                return fill_template(tmpl, &args);
5488            }
5489            let name = match &f.schema {
5490                Some(s) => format!("{}.{}", pg_schema(s), qi(&f.name)),
5491                None => f.name.clone(),
5492            };
5493            format!("{}({})", name, args.join(", "))
5494        }
5495        IrExpr::TypeCast(c) => {
5496            // PostgreSQL doesn't support arbitrary_type::jsonb; to_jsonb() accepts any input.
5497            // String literals have type "unknown" in PG, so cast to text first.
5498            if c.pg_type == "jsonb" {
5499                match &c.expr {
5500                    // A bare `$N` parameter has no PG type at all until told
5501                    // otherwise. to_jsonb($N) can't resolve it — it's a
5502                    // polymorphic function, which needs a *concrete* input
5503                    // type to dispatch on, and an unknown-typed placeholder
5504                    // gives it nothing to work with ("could not determine
5505                    // polymorphic type because input has type unknown").
5506                    // A direct ($N)::jsonb cast works because PG specially
5507                    // resolves an unknown-typed parameter against an
5508                    // explicit cast — the same reason `$1::uuid` works fine
5509                    // elsewhere in this file.
5510                    IrExpr::Param { .. } => format!("({})::jsonb", emit_expr(&c.expr)),
5511                    IrExpr::Literal(IrLiteral::Str(_)) => {
5512                        format!("to_jsonb({}::text)", emit_expr(&c.expr))
5513                    }
5514                    _ => format!("to_jsonb({})", emit_expr(&c.expr)),
5515                }
5516            } else if yields_jsonb(&c.expr) {
5517                // Casting json to a scalar yields the value, not its JSON
5518                // spelling — `<str>` of a json string is the string, with no
5519                // quotes around it. `::text` would keep them.
5520                format!("(({}) #>> '{{}}')::{}", emit_expr(&c.expr), c.pg_type)
5521            } else {
5522                format!("({})::{}", emit_expr(&c.expr), c.pg_type)
5523            }
5524        }
5525        IrExpr::IfElse(ie) => format!(
5526            "CASE WHEN {} THEN {} ELSE {} END",
5527            emit_expr(&ie.condition),
5528            emit_expr(&ie.if_),
5529            emit_expr(&ie.else_),
5530        ),
5531        IrExpr::Array(elems) => {
5532            if elems.is_empty() {
5533                "ARRAY[]::text[]".to_string()
5534            } else {
5535                let parts: Vec<String> = elems.iter().map(emit_expr).collect();
5536                format!("ARRAY[{}]", parts.join(", "))
5537            }
5538        }
5539        IrExpr::Null => "NULL".to_string(),
5540        IrExpr::AggOverSet {
5541            fn_name,
5542            schema: _,
5543            elems,
5544        } => {
5545            let union_all = elems
5546                .iter()
5547                .map(|e| format!("SELECT {}", emit_expr(e)))
5548                .collect::<Vec<_>>()
5549                .join(" UNION ALL ");
5550            format!("(SELECT {}(v) FROM ({}) AS _set(v))", fn_name, union_all)
5551        }
5552        IrExpr::AggOverCte { fn_name, cte, column } => format!(
5553            "(SELECT {}({}) FROM {})",
5554            fn_name,
5555            column.as_deref().map(qi).unwrap_or_else(|| "*".to_string()),
5556            qi(cte),
5557        ),
5558        IrExpr::ExistsOverCte { cte, column } => match column {
5559            Some(column) => format!("EXISTS(SELECT 1 FROM {} WHERE {} IS NOT NULL)", qi(cte), qi(column)),
5560            None => format!("EXISTS(SELECT 1 FROM {})", qi(cte)),
5561        },
5562        IrExpr::AggOverQuery { fn_name, inner } => {
5563            let inner_sql = emit_select_stmt(inner, &[]).sql;
5564            format!("(SELECT {}(*) FROM ({}) _agg)", fn_name, inner_sql)
5565        }
5566        IrExpr::ArrayFromSelect(src) => emit_array_source(src),
5567        IrExpr::ScalarSubquery(sel) => {
5568            format!(
5569                "(SELECT \"v\" FROM (\n{}\n) AS \"_scalar_sub\")",
5570                emit_select_stmt(sel, &[]).sql
5571            )
5572        }
5573
5574        IrExpr::SetOp { op, left, right, mode } => {
5575            let set = format!("({}) {} ({})", set_operand(left), op.sql(), set_operand(right));
5576            match mode {
5577                crate::ir::SetOpMode::Exists => format!("EXISTS({set})"),
5578                crate::ir::SetOpMode::Array => format!("ARRAY({set})"),
5579                crate::ir::SetOpMode::Aggregate(f) => {
5580                    format!("(SELECT {f}(\"_s\".\"v\") FROM ({set}) AS \"_s\"(\"v\"))")
5581                }
5582            }
5583        }
5584
5585        IrExpr::CteRef { name, scalar, .. } => {
5586            // scalar CTEs emit `ROW(expr) AS result, expr AS v`; use `v` for
5587            // expression context so we get the plain scalar type, not record.
5588            let col = if *scalar { "v" } else { "id" };
5589            format!("(SELECT \"{}\" FROM \"{}\")", col, name)
5590        }
5591
5592        IrExpr::CteFieldRef { name, field, .. } => {
5593            format!("(SELECT {} FROM {})", qi(field), qi(name))
5594        }
5595
5596        IrExpr::ForVar { name, .. } => format!("\"_for_{}\".\"v\"", name),
5597
5598        IrExpr::EnumLiteral { pg_type, variant } => {
5599            format!("'{}'::{}", variant.replace('\'', "''"), pg_type)
5600        }
5601
5602        IrExpr::GlobalParam { index, pg_type } => {
5603            format!("(${}::{})", index + 1, pg_type)
5604        }
5605
5606        IrExpr::GlobalRef { cte_name } => {
5607            format!("(SELECT \"value\" FROM \"{}\")", cte_name)
5608        }
5609
5610        IrExpr::NamedTuple { fields, .. } => {
5611            let pairs: Vec<String> = fields
5612                .iter()
5613                .flat_map(|(k, v)| [format!("'{}'", k.replace('\'', "''")), emit_expr(v)])
5614                .collect();
5615            format!("jsonb_build_object({})", pairs.join(", "))
5616        }
5617
5618        IrExpr::Tuple(elems) => {
5619            let items: Vec<String> = elems.iter().map(emit_expr).collect();
5620            format!("jsonb_build_array({})", items.join(", "))
5621        }
5622
5623        IrExpr::Row { elements, .. } => {
5624            let items: Vec<String> = elements.iter().map(emit_expr).collect();
5625            format!("ROW({})", items.join(", "))
5626        }
5627
5628        IrExpr::Subscript { expr, index, is_array } => {
5629            let e = emit_expr(expr);
5630            let i = emit_expr(index);
5631            if *is_array {
5632                format!("_pylon.array_subscript({}, ({})::bigint)", e, i)
5633            } else {
5634                format!("_pylon.str_subscript({}, ({})::bigint)", e, i)
5635            }
5636        }
5637
5638        IrExpr::Slice {
5639            expr,
5640            lower,
5641            upper,
5642            is_array,
5643        } => {
5644            let e = emit_expr(expr);
5645            if *is_array {
5646                let lo = lower
5647                    .as_deref()
5648                    .map(|x| format!("({}) + 1", emit_expr(x)))
5649                    .unwrap_or_else(|| "1".to_string());
5650                let hi = upper.as_deref().map(emit_expr).unwrap_or_default();
5651                if hi.is_empty() {
5652                    format!("({})[{}:]", e, lo)
5653                } else {
5654                    format!("({})[{}:{}]", e, lo, hi)
5655                }
5656            } else {
5657                // substr(expr, start, length) for text/bytea.
5658                let start = lower
5659                    .as_deref()
5660                    .map(|x| format!("({}) + 1", emit_expr(x)))
5661                    .unwrap_or_else(|| "1".to_string());
5662                match upper.as_deref() {
5663                    Some(hi_expr) => {
5664                        let lo_val = lower.as_deref().map(emit_expr).unwrap_or_else(|| "0".to_string());
5665                        // GREATEST(0, ...) so reversed bounds yield '' instead of a PG error.
5666                        format!(
5667                            "substr({}, {}, GREATEST(0, ({}) - ({})))",
5668                            e,
5669                            start,
5670                            emit_expr(hi_expr),
5671                            lo_val
5672                        )
5673                    }
5674                    None => format!("substr({}, {})", e, start),
5675                }
5676            }
5677        }
5678
5679        IrExpr::JsonbField { expr, field } => {
5680            format!("({}->{})", emit_expr(expr), sql_str(field))
5681        }
5682
5683        // The parentheses are required, not defensive: `a.b.c` without them
5684        // reads as a schema-qualified name.
5685        IrExpr::CompositeField { expr, field, .. } => {
5686            format!("({}).{}", emit_expr(expr), qi(field))
5687        }
5688
5689        IrExpr::JsonbIndex { expr, index } => {
5690            format!("({}->{})", emit_expr(expr), index)
5691        }
5692
5693        IrExpr::FnParam { name, .. } => qi(name),
5694
5695        IrExpr::FnSubquery(fs) => {
5696            let scalar = match fs.shape.as_slice() {
5697                [IrShapePointer::Computed(c)] => emit_expr(&c.expr),
5698                _ => unreachable!("a function scalar subquery always projects exactly one computed pointer"),
5699            };
5700            let args_sql = fs.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
5701            let mut sql = format!(
5702                "(SELECT {}{}\nFROM {}.{}({}) AS {}",
5703                if fs.distinct { "DISTINCT " } else { "" },
5704                scalar,
5705                pg_schema(&fs.fn_module),
5706                qi(&fs.fn_name),
5707                args_sql,
5708                qi(&fs.alias),
5709            );
5710            append_filter(&mut sql, &fs.filter);
5711            append_order_by(&mut sql, &fs.order_by);
5712            append_offset_limit(&mut sql, &fs.offset, &fs.limit);
5713            sql.push(')');
5714            sql
5715        }
5716
5717        IrExpr::PathSubquery(ps) => {
5718            let scalar = match &ps.result {
5719                IrPathResult::Scalar(e, _) => emit_expr(e),
5720                IrPathResult::Object { alias, .. } => format!("{}.\"id\"", qi(alias)),
5721            };
5722            let from_sql = emit_path_joins(&ps.root, &ps.joins);
5723            let mut sql = format!(
5724                "(SELECT {}{}\nFROM {}",
5725                if ps.distinct { "DISTINCT " } else { "" },
5726                scalar,
5727                from_sql
5728            );
5729            append_filter(&mut sql, &ps.filter);
5730            append_order_by(&mut sql, &ps.order_by);
5731            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
5732            sql.push(')');
5733            sql
5734        }
5735
5736        IrExpr::ObjectPathUnion { branches, limit, multi } => {
5737            let arms: Vec<String> = branches
5738                .iter()
5739                .map(|ps| {
5740                    let IrPathResult::Object {
5741                        alias,
5742                        type_name,
5743                        shape,
5744                    } = &ps.result
5745                    else {
5746                        unreachable!("an object path union's branches always land on objects")
5747                    };
5748                    let (pointer_exprs, _) = build_shape(shape, alias);
5749                    let mut parts = vec![result_type_disc(ps, alias, type_name)];
5750                    parts.extend(pointer_exprs);
5751                    let mut sql = format!(
5752                        "SELECT (\n    {}\n) AS \"r\"\nFROM {}",
5753                        parts.join(",\n    "),
5754                        emit_path_joins(&ps.root, &ps.joins),
5755                    );
5756                    append_filter(&mut sql, &ps.filter);
5757                    sql
5758                })
5759                .collect();
5760            let mut sql = format!(
5761                "{}(SELECT \"r\" FROM (\n{}\n) AS \"_u\"",
5762                if *multi { "ARRAY" } else { "" },
5763                arms.join("\nUNION ALL\n")
5764            );
5765            if let Some(limit) = limit {
5766                sql.push_str(&format!("\nLIMIT {}", emit_expr(limit)));
5767            }
5768            sql.push(')');
5769            sql
5770        }
5771
5772        IrExpr::ObjectPathSubquery(ps) => {
5773            let IrPathResult::Object {
5774                alias,
5775                type_name,
5776                shape,
5777            } = &ps.result
5778            else {
5779                unreachable!("an object path subquery always lands on an object")
5780            };
5781            let (pointer_exprs, _) = build_shape(shape, alias);
5782            let mut parts = vec![result_type_disc(ps, alias, type_name)];
5783            parts.extend(pointer_exprs);
5784            let mut sql = format!(
5785                "(SELECT (\n    {}\n)\nFROM {}",
5786                parts.join(",\n    "),
5787                emit_path_joins(&ps.root, &ps.joins),
5788            );
5789            append_filter(&mut sql, &ps.filter);
5790            append_order_by(&mut sql, &ps.order_by);
5791            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
5792            sql.push(')');
5793            sql
5794        }
5795
5796        IrExpr::ObjectSubquery(sel) => {
5797            let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
5798                unreachable!("an object subquery is always schema-bound")
5799            };
5800            let alias = &source.alias;
5801            let (sub_exprs, _) = build_shape(shape, alias);
5802            let mut row_parts = vec![source_type_disc(source)];
5803            row_parts.extend(sub_exprs);
5804            let mut sql = format!(
5805                "(SELECT (\n        {}\n    )\n    FROM {} AS {}",
5806                row_parts.join(",\n        "),
5807                source_ref(source),
5808                qi(alias),
5809            );
5810            append_filter(&mut sql, &sel.filter);
5811            append_order_by(&mut sql, &sel.order_by);
5812            append_offset_limit(&mut sql, &sel.offset, &sel.limit);
5813            sql.push(')');
5814            sql
5815        }
5816
5817        IrExpr::Subquery(sel) => {
5818            let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
5819                unreachable!("scalar/exists subquery is always schema-bound")
5820            };
5821            let alias = &source.alias;
5822            let mut sql = if shape.is_empty() {
5823                // EXISTS inner: SELECT 1 FROM …
5824                format!("(SELECT 1\nFROM {} AS {}", source_ref(source), qi(alias))
5825            } else if let Some(c) = shape.iter().find_map(|f| {
5826                if let IrShapePointer::Computed(c) = f {
5827                    Some(c)
5828                } else {
5829                    None
5830                }
5831            }) {
5832                // Computed subquery (e.g. a type-intersection splat's
5833                // computed pointer): select the compiled expression itself,
5834                // not a bare column — the expression already references
5835                // `alias`'s own columns via ColumnRef.
5836                format!(
5837                    "(SELECT {}\nFROM {} AS {}",
5838                    emit_expr(&c.expr),
5839                    source_ref(source),
5840                    qi(alias)
5841                )
5842            } else {
5843                // Scalar subquery: SELECT alias.col FROM …
5844                let pk_col = shape
5845                    .iter()
5846                    .find_map(|f| {
5847                        if let IrShapePointer::Scalar(s) = f {
5848                            Some(s.column.as_str())
5849                        } else {
5850                            None
5851                        }
5852                    })
5853                    .unwrap_or("id");
5854                format!(
5855                    "(SELECT {}.{}\nFROM {} AS {}",
5856                    qi(alias),
5857                    qi(pk_col),
5858                    source_ref(source),
5859                    qi(alias),
5860                )
5861            };
5862            append_filter(&mut sql, &sel.filter);
5863            append_order_by(&mut sql, &sel.order_by);
5864            append_offset_limit(&mut sql, &sel.offset, &sel.limit);
5865            sql.push(')');
5866            sql
5867        }
5868        IrExpr::RawSql(s) => format!("({s})"),
5869    }
5870}
5871
5872// ── Vector search ────────────────────────────────────────────────────────────
5873
5874fn emit_vector_search(vs: &IrVectorSearch) -> SqlOutput {
5875    let alias = &vs.source.alias;
5876    let dist_sql = format!(
5877        "{}.{} {} {}",
5878        qi(alias),
5879        qi(&vs.vector_col),
5880        vs.distance_op,
5881        emit_expr(&vs.query_expr),
5882    );
5883
5884    // Build the object sub-tuple.  If object_shape is empty, include all properties
5885    // (type disc + id implicitly come from build_shape when no elements provided;
5886    //  with no shape elements the shape is empty, so we fall back to "just id").
5887    // Use build_shape when there are explicit shape pointers; otherwise emit a minimal tuple.
5888    let (obj_tuple, object_shape_nodes) = if vs.object_shape.is_empty() {
5889        // No explicit shape: produce (type_disc, id) as minimum.
5890        let type_expr = type_disc(&vs.source.type_name);
5891        let id_expr = format!("{}.\"id\"", qi(alias));
5892        let tuple = format!("{},\n    {}", type_expr, id_expr);
5893        let id_node = ShapeNode::Scalar {
5894            name: "id".to_string(),
5895            position: 1,
5896        };
5897        (tuple, vec![id_node])
5898    } else {
5899        let (pointer_exprs, shape_pointers) = build_shape(&vs.object_shape, alias);
5900        let mut parts = vec![type_disc(&vs.source.type_name)];
5901        parts.extend(pointer_exprs);
5902        (parts.join(",\n    "), prepend_type(shape_pointers))
5903    };
5904
5905    // Outer tuple: NULL (type slot), object sub-tuple at pos 1, distance at pos 2.
5906    let outer = format!(
5907        "NULL::text,\n    ROW(\n    {}\n    )::record,\n    {}",
5908        obj_tuple, dist_sql,
5909    );
5910    let mut sql = format!(
5911        "SELECT (\n    {}\n) AS result\nFROM {} AS {}",
5912        outer,
5913        source_ref(&vs.source),
5914        qi(alias),
5915    );
5916    append_filter(&mut sql, &vs.filter);
5917
5918    // ORDER BY distance if requested.
5919    if let Some(dir) = &vs.order_by_distance {
5920        let dir_sql = match dir {
5921            IrSortDir::Asc => "ASC",
5922            IrSortDir::Desc => "DESC",
5923        };
5924        sql.push_str(&format!("\nORDER BY {} {}", dist_sql, dir_sql));
5925    }
5926    append_offset_limit(&mut sql, &vs.offset, &vs.limit);
5927
5928    let object_node = ShapeNode::Object {
5929        name: "object".to_string(),
5930        type_name: Some(vs.source.type_name.clone()),
5931        position: 1,
5932        cardinality: Cardinality::Many,
5933        pointers: object_shape_nodes,
5934        has_implicit_id: shape_has_implicit_id(&vs.object_shape),
5935    };
5936    let shape = ShapeDescriptor {
5937        root: ShapeNode::VectorSearch {
5938            object_position: 1,
5939            distance_position: 2,
5940            object_node: Box::new(object_node),
5941        },
5942    };
5943    let inference_plan = vs.inference_model.as_ref().map(|model_name| InferencePlan::Embedding {
5944        model_name: model_name.clone(),
5945        type_name: vs.inference_type_name.clone().unwrap_or_default(),
5946        index_name: vs.inference_index_name.clone().unwrap_or(None),
5947        query_param_name: vs.inference_query_param_name.clone().unwrap_or_default(),
5948        query_literal: vs.inference_query_literal.clone(),
5949    });
5950    SqlOutput {
5951        sql,
5952        shape,
5953        inference_plan,
5954    }
5955}
5956
5957// ── FTS search ───────────────────────────────────────────────────────────────
5958
5959fn emit_fts_search(fs: &IrFtsSearch) -> SqlOutput {
5960    use crate::schema::SearchBackend;
5961    if fs.backend != SearchBackend::Postgres {
5962        return emit_fts_search_deferred(fs);
5963    }
5964
5965    let alias = &fs.source.alias;
5966    let search_col = format!("{}.{}", qi(alias), qi(&fs.search_col));
5967    let query_sql = emit_expr(&fs.query_expr);
5968    let tsquery = format!("{}('english', {})", fs.tsquery_fn, query_sql);
5969    let rank_sql = format!("ts_rank({}, {})", search_col, tsquery);
5970
5971    let (obj_tuple, object_shape_nodes) = if fs.object_shape.is_empty() {
5972        let type_expr = type_disc(&fs.source.type_name);
5973        let id_expr = format!("{}.\"id\"", qi(alias));
5974        let tuple = format!("{},\n    {}", type_expr, id_expr);
5975        let id_node = ShapeNode::Scalar {
5976            name: "id".to_string(),
5977            position: 1,
5978        };
5979        (tuple, vec![id_node])
5980    } else {
5981        let (pointer_exprs, shape_pointers) = build_shape(&fs.object_shape, alias);
5982        let mut parts = vec![type_disc(&fs.source.type_name)];
5983        parts.extend(pointer_exprs);
5984        (parts.join(",\n    "), prepend_type(shape_pointers))
5985    };
5986
5987    let outer = format!(
5988        "NULL::text,\n    ROW(\n    {}\n    )::record,\n    {}",
5989        obj_tuple, rank_sql,
5990    );
5991    let mut sql = format!(
5992        "SELECT (\n    {}\n) AS result\nFROM {} AS {}\nWHERE {} @@ {}",
5993        outer,
5994        source_ref(&fs.source),
5995        qi(alias),
5996        search_col,
5997        tsquery,
5998    );
5999    if let Some(f) = &fs.filter {
6000        sql.push_str(&format!(" AND ({})", emit_expr(f)));
6001    }
6002    if let Some(dir) = &fs.order_by_rank {
6003        let dir_sql = match dir {
6004            IrSortDir::Asc => "ASC",
6005            IrSortDir::Desc => "DESC",
6006        };
6007        sql.push_str(&format!("\nORDER BY {} {}", rank_sql, dir_sql));
6008    }
6009    append_offset_limit(&mut sql, &fs.offset, &fs.limit);
6010
6011    let object_node = ShapeNode::Object {
6012        name: "object".to_string(),
6013        type_name: Some(fs.source.type_name.clone()),
6014        position: 1,
6015        cardinality: Cardinality::Many,
6016        pointers: object_shape_nodes,
6017        has_implicit_id: shape_has_implicit_id(&fs.object_shape),
6018    };
6019    let shape = ShapeDescriptor {
6020        root: ShapeNode::FtsSearch {
6021            object_position: 1,
6022            rank_position: 2,
6023            object_node: Box::new(object_node),
6024        },
6025    };
6026    SqlOutput {
6027        sql,
6028        shape,
6029        inference_plan: None,
6030    }
6031}
6032
6033fn emit_fts_search_deferred(fs: &IrFtsSearch) -> SqlOutput {
6034    let alias = &fs.source.alias;
6035    let ids_idx = fs
6036        .deferred_ids_param
6037        .expect("deferred_ids_param must be set for deferred backend");
6038    let scores_idx = fs
6039        .deferred_scores_param
6040        .expect("deferred_scores_param must be set for deferred backend");
6041    let ids_param = format!("${}", ids_idx + 1);
6042    let scores_param = format!("${}", scores_idx + 1);
6043
6044    let (obj_tuple, object_shape_nodes) = if fs.object_shape.is_empty() {
6045        let type_expr = type_disc(&fs.source.type_name);
6046        let id_expr = format!("{}.\"id\"", qi(alias));
6047        let tuple = format!("{},\n    {}", type_expr, id_expr);
6048        let id_node = ShapeNode::Scalar {
6049            name: "id".to_string(),
6050            position: 1,
6051        };
6052        (tuple, vec![id_node])
6053    } else {
6054        let (pointer_exprs, shape_pointers) = build_shape(&fs.object_shape, alias);
6055        let mut parts = vec![type_disc(&fs.source.type_name)];
6056        parts.extend(pointer_exprs);
6057        (parts.join(",\n    "), prepend_type(shape_pointers))
6058    };
6059
6060    let outer = format!(
6061        "NULL::text,\n    ROW(\n    {}\n    )::record,\n    \"_os\".\"score\"",
6062        obj_tuple,
6063    );
6064    let mut sql = format!(
6065        concat!(
6066            "SELECT (\n    {}\n) AS result\n",
6067            "FROM {} AS {}\n",
6068            "JOIN UNNEST({}::uuid[], {}::float8[]) AS \"_os\"(\"id\", \"score\")\n",
6069            "    ON \"_os\".\"id\" = {}.\"id\"",
6070        ),
6071        outer,
6072        source_ref(&fs.source),
6073        qi(alias),
6074        ids_param,
6075        scores_param,
6076        qi(alias),
6077    );
6078    if let Some(f) = &fs.filter {
6079        sql.push_str(&format!("\nWHERE ({})", emit_expr(f)));
6080    }
6081    if let Some(dir) = &fs.order_by_rank {
6082        let dir_sql = match dir {
6083            IrSortDir::Asc => "ASC",
6084            IrSortDir::Desc => "DESC",
6085        };
6086        sql.push_str(&format!("\nORDER BY \"_os\".\"score\" {}", dir_sql));
6087    }
6088    // limit/offset are passed to OpenSearch as size/from, not emitted in Postgres SQL.
6089    let size = fs.limit.as_ref().and_then(|lim| {
6090        if let IrExpr::Literal(IrLiteral::Int(n)) = lim {
6091            Some(*n as usize)
6092        } else {
6093            None
6094        }
6095    });
6096
6097    let object_node = ShapeNode::Object {
6098        name: "object".to_string(),
6099        type_name: Some(fs.source.type_name.clone()),
6100        position: 1,
6101        cardinality: Cardinality::Many,
6102        pointers: object_shape_nodes,
6103        has_implicit_id: shape_has_implicit_id(&fs.object_shape),
6104    };
6105    let shape = ShapeDescriptor {
6106        root: ShapeNode::FtsSearch {
6107            object_position: 1,
6108            rank_position: 2,
6109            object_node: Box::new(object_node),
6110        },
6111    };
6112    let backend_str = match fs.backend {
6113        crate::schema::SearchBackend::Meilisearch => "meilisearch",
6114        _ => "opensearch",
6115    };
6116    let inference_plan = Some(InferencePlan::Search {
6117        backend: backend_str.to_string(),
6118        index_name: fs.deferred_index_name.clone().unwrap_or_default(),
6119        query_param_name: fs.deferred_query_param_name.clone().unwrap_or_default(),
6120        query_literal: fs.deferred_query_literal.clone(),
6121        size,
6122    });
6123    SqlOutput {
6124        sql,
6125        shape,
6126        inference_plan,
6127    }
6128}
6129
6130// ── Function select ──────────────────────────────────────────────────────────
6131
6132fn emit_function_select(sel: &IrFunctionSelect) -> SqlOutput {
6133    let alias = &sel.alias;
6134    let (pointer_exprs, shape_pointers) = build_shape(&sel.shape, alias);
6135
6136    let type_expr = if sel.polymorphic {
6137        format!("{}.\"__type__\"", qi(alias))
6138    } else {
6139        type_disc(&sel.type_name)
6140    };
6141    let mut parts = vec![type_expr];
6142    parts.extend(pointer_exprs);
6143    let tuple = parts.join(",\n    ");
6144    let distinct = if sel.distinct { "DISTINCT " } else { "" };
6145
6146    let args_sql = sel.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
6147    let fn_call = format!("{}.{}({})", pg_schema(&sel.fn_module), qi(&sel.fn_name), args_sql);
6148
6149    let from_clause = if sel.polymorphic {
6150        // Polymorphic: we can't peek inside the function — assume it returns
6151        // a __type__ column since we control the DDL.  Use the fn call directly.
6152        format!("{} AS {}", fn_call, qi(alias))
6153    } else {
6154        format!("{} AS {}", fn_call, qi(alias))
6155    };
6156
6157    let mut sql = format!("SELECT {}(\n    {}\n) AS result\nFROM {}", distinct, tuple, from_clause,);
6158    append_filter(&mut sql, &sel.filter);
6159    append_order_by(&mut sql, &sel.order_by);
6160    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
6161
6162    let root_pointers = prepend_type(shape_pointers);
6163    SqlOutput {
6164        sql,
6165        shape: ShapeDescriptor {
6166            root: ShapeNode::Object {
6167                name: String::new(),
6168                type_name: Some(sel.type_name.clone()),
6169                position: 0,
6170                cardinality: Cardinality::Many,
6171                pointers: root_pointers,
6172                has_implicit_id: shape_has_implicit_id(&sel.shape),
6173            },
6174        },
6175        inference_plan: None,
6176    }
6177}
6178
6179/// Emit the SQL body expression for a user-defined function DDL.
6180///
6181/// For a bare scalar free select emits just the expression — e.g. `"a" + "b"`.
6182/// For everything else (object functions, non-scalar free selects) emits a
6183/// full `SELECT … FROM …` via `emit_dml_as_cte_source`.
6184pub fn emit_fn_body(ir: &crate::ir::IrOutput) -> String {
6185    with_subtype_fanouts(&ir.subtype_fanouts, || emit_output_fn_body(ir))
6186}
6187
6188fn emit_output_fn_body(ir: &crate::ir::IrOutput) -> String {
6189    let body = match &ir.stmt {
6190        IrStmt::Select(sel) if matches!(sel.rows.as_slice(), [IrRowSource::Free(IrFreeExpr::Scalar(_))]) => {
6191            let IrRowSource::Free(IrFreeExpr::Scalar(e)) = &sel.rows[0] else {
6192                unreachable!()
6193            };
6194            format!("SELECT {}", emit_expr(e))
6195        }
6196        other => emit_dml_as_cte_source(other),
6197    };
6198    if ir.ctes.is_empty() {
6199        body
6200    } else {
6201        let cte_prefix = emit_cte_prefix(&ir.ctes);
6202        format!("{}{}", cte_prefix, body)
6203    }
6204}
6205
6206fn emit_literal(lit: &IrLiteral) -> String {
6207    match lit {
6208        IrLiteral::Str(s) => sql_str(s),
6209        IrLiteral::Int(i) => i.to_string(),
6210        IrLiteral::Float(f) => {
6211            // Explicit ::float8 cast — a bare untyped numeral like `1.0`
6212            // defaults to Postgres `numeric`, but an un-cast PyQL float
6213            // literal means `float64`; only an explicit `<decimal>`/`123n`
6214            // literal should ever produce a real decimal. Without this,
6215            // `numeric`'s different wire OID
6216            // also broke decoding inside ROW() composites (see
6217            // _pg_decode_numeric in pylon/client.py, still needed for
6218            // genuine decimal casts).
6219            let s = f.to_string();
6220            let s = if s.contains('.') || s.contains('e') {
6221                s
6222            } else {
6223                format!("{}.0", s)
6224            };
6225            format!("({}::float8)", s)
6226        }
6227        IrLiteral::Bool(b) => {
6228            if *b {
6229                "TRUE".into()
6230            } else {
6231                "FALSE".into()
6232            }
6233        }
6234    }
6235}
6236
6237// ── Tests ────────────────────────────────────────────────────────────────────
6238
6239#[cfg(test)]
6240mod tests {
6241    use super::*;
6242    use crate::ir;
6243    use crate::parse;
6244    use crate::schema::{
6245        FunctionDescriptor, FunctionParamDescriptor, GlobalDescriptor, LinkDescriptor, MultiLinkDescriptor,
6246        NamedTupleDescriptor, PropertyDescriptor, SchemaDescriptor, TypeDescriptor,
6247    };
6248
6249    #[test]
6250    fn a_template_argument_keeps_the_parameters_it_carries() {
6251        // The first argument carries query parameter `$2`; filled one number
6252        // at a time, it would be rewritten into the second argument.
6253        let filled = fill_template("f($1, $2, $10)", &["($2)::int".to_string(), "b".to_string()]);
6254        assert_eq!(filled, "f(($2)::int, b, $10)");
6255    }
6256
6257    fn make_schema() -> SchemaDescriptor {
6258        SchemaDescriptor {
6259            types: vec![
6260                TypeDescriptor {
6261                    name: "Person".into(),
6262                    module: "default".into(),
6263                    table: "Person".into(),
6264                    abstract_: false,
6265                    materialized: false,
6266                    description: None,
6267                    parents: vec![],
6268                    interfaces: vec![],
6269                    bases: vec![],
6270                    properties: vec![
6271                        PropertyDescriptor {
6272                            name: "id".into(),
6273                            pg_type: "uuid".into(),
6274                            nullable: false,
6275                            default_sql: Some("uuidv7()".into()),
6276                            default_pyql: None,
6277                            description: None,
6278                            check_constraints: vec![],
6279                            is_exclusive: true,
6280                            is_pk: true,
6281                            is_readonly: true,
6282                            rewrites: vec![],
6283                            tuple_members: None,
6284                            column_type: None,
6285                        },
6286                        PropertyDescriptor {
6287                            name: "name".into(),
6288                            pg_type: "text".into(),
6289                            nullable: false,
6290                            default_sql: None,
6291                            default_pyql: None,
6292                            description: None,
6293                            check_constraints: vec![],
6294                            is_exclusive: false,
6295                            is_pk: false,
6296                            is_readonly: false,
6297                            rewrites: vec![],
6298                            tuple_members: None,
6299                            column_type: None,
6300                        },
6301                        PropertyDescriptor {
6302                            name: "age".into(),
6303                            pg_type: "int8".into(),
6304                            nullable: true,
6305                            default_sql: None,
6306                            default_pyql: None,
6307                            description: None,
6308                            check_constraints: vec![],
6309                            is_exclusive: false,
6310                            is_pk: false,
6311                            is_readonly: false,
6312                            rewrites: vec![],
6313                            tuple_members: None,
6314                            column_type: None,
6315                        },
6316                    ],
6317                    links: vec![LinkDescriptor {
6318                        name: "company".into(),
6319                        target: "default::Company".into(),
6320                        nullable: true,
6321                        through: None,
6322                        description: None,
6323                        default_pyql: None,
6324                        is_exclusive: false,
6325                        is_readonly: false,
6326                        rewrites: vec![],
6327                        on_delete: vec![],
6328                    }],
6329                    multilinks: vec![MultiLinkDescriptor {
6330                        name: "posts".into(),
6331                        target: "default::Post".into(),
6332                        through: None,
6333                        nullable: false,
6334                        description: None,
6335                        default_pyql: None,
6336                        on_delete: vec![],
6337                        is_exclusive: false,
6338                    }],
6339                    computed: vec![],
6340                    constraints: vec![],
6341                    indexes: vec![],
6342                    partition: None,
6343                    vector_indexes: vec![],
6344                    search_indexes: vec![],
6345                    triggers: vec![],
6346                    junction: false,
6347                    signals: vec![],
6348                },
6349                TypeDescriptor {
6350                    name: "Company".into(),
6351                    module: "default".into(),
6352                    table: "Company".into(),
6353                    abstract_: false,
6354                    materialized: false,
6355                    description: None,
6356                    parents: vec![],
6357                    interfaces: vec![],
6358                    bases: vec![],
6359                    properties: vec![PropertyDescriptor {
6360                        name: "name".into(),
6361                        pg_type: "text".into(),
6362                        nullable: false,
6363                        default_sql: None,
6364                        default_pyql: None,
6365                        description: None,
6366                        check_constraints: vec![],
6367                        is_exclusive: false,
6368                        is_pk: false,
6369                        is_readonly: false,
6370                        rewrites: vec![],
6371                        tuple_members: None,
6372                        column_type: None,
6373                    }],
6374                    links: vec![],
6375                    multilinks: vec![],
6376                    computed: vec![],
6377                    constraints: vec![],
6378                    indexes: vec![],
6379                    partition: None,
6380                    vector_indexes: vec![],
6381                    search_indexes: vec![],
6382                    triggers: vec![],
6383                    junction: false,
6384                    signals: vec![],
6385                },
6386                TypeDescriptor {
6387                    name: "Post".into(),
6388                    module: "default".into(),
6389                    table: "Post".into(),
6390                    abstract_: false,
6391                    materialized: false,
6392                    description: None,
6393                    parents: vec![],
6394                    interfaces: vec![],
6395                    bases: vec![],
6396                    properties: vec![PropertyDescriptor {
6397                        name: "title".into(),
6398                        pg_type: "text".into(),
6399                        nullable: false,
6400                        default_sql: None,
6401                        default_pyql: None,
6402                        description: None,
6403                        check_constraints: vec![],
6404                        is_exclusive: false,
6405                        is_pk: false,
6406                        is_readonly: false,
6407                        rewrites: vec![],
6408                        tuple_members: None,
6409                        column_type: None,
6410                    }],
6411                    links: vec![],
6412                    multilinks: vec![],
6413                    computed: vec![],
6414                    constraints: vec![],
6415                    indexes: vec![],
6416                    partition: None,
6417                    vector_indexes: vec![],
6418                    search_indexes: vec![],
6419                    triggers: vec![],
6420                    junction: false,
6421                    signals: vec![],
6422                },
6423            ],
6424            scalars: vec![],
6425            enums: vec![],
6426            named_tuples: vec![],
6427            globals: vec![],
6428            functions: vec![],
6429            aliases: vec![],
6430            channels: vec![],
6431            ..Default::default()
6432        }
6433    }
6434
6435    fn compile_and_emit(query: &str) -> SqlOutput {
6436        let schema = make_schema();
6437        compile_and_emit_with(query, &schema)
6438    }
6439
6440    fn compile_and_emit_with(query: &str, schema: &SchemaDescriptor) -> SqlOutput {
6441        let ast = parse::parse(query).expect("parse failed");
6442        let ir = ir::compile(&ast, schema).expect("IR compile failed");
6443        emit(&ir)
6444    }
6445
6446    /// The message a query the compiler refuses reports.
6447    fn compile_err_with(query: &str, schema: &SchemaDescriptor) -> String {
6448        let ast = parse::parse(query).expect("parse failed");
6449        match ir::compile(&ast, schema) {
6450            Ok(_) => panic!("expected this to be refused: {query}"),
6451            Err(e) => e.to_string(),
6452        }
6453    }
6454
6455    /// A set literal is the haystack `in` reads, so a walk that crosses a
6456    /// multi-link can be tested against one. Compiled as an ordinary operand
6457    /// it was refused outright — the walk reaches the EXISTS builder before
6458    /// the arm that knows what a set literal on the right of `in` means.
6459    #[test]
6460    fn a_multilink_walk_can_be_tested_against_a_set_literal() {
6461        let out = compile_and_emit("SELECT Person FILTER .posts.title IN {'a', 'b'}");
6462        assert!(out.sql.contains("= ANY(ARRAY['a', 'b'])"), "{}", out.sql);
6463    }
6464
6465    /// `all` is not `any`. Both used to fold into the same bare EXISTS over
6466    /// the junction, which answers "some element matches" — so `all` silently
6467    /// agreed with `any` on every one of these.
6468    #[test]
6469    fn all_over_a_multilink_is_not_the_same_question_as_any() {
6470        for condition in [
6471            ".posts.title IN {'a', 'b'}",
6472            ".posts IN ps",
6473            ".posts = ps",
6474            ".posts.title = 'x'",
6475        ] {
6476            let sql = |quantifier: &str| {
6477                compile_and_emit(&format!(
6478                    "WITH ps := (SELECT Post) SELECT Person FILTER {quantifier}({condition})"
6479                ))
6480                .sql
6481            };
6482            assert_ne!(
6483                sql("all"),
6484                sql("any"),
6485                "all({condition}) must not compile to what any({condition}) does"
6486            );
6487        }
6488    }
6489
6490    /// The two shapes `all` takes: a walk with a tail negates the test around
6491    /// each element inside one EXISTS, and a walk that *is* the link gathers
6492    /// the answers and reduces them.
6493    #[test]
6494    fn all_over_a_multilink_answers_for_every_element() {
6495        let out = compile_and_emit("SELECT Person FILTER all(.posts.title IN {'a', 'b'})");
6496        assert!(
6497            out.sql.contains("NOT EXISTS(") && out.sql.contains("NOT (\"t2\".\"title\" = ANY("),
6498            "no element fails the test:\n{}",
6499            out.sql
6500        );
6501        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person FILTER all(.posts IN ps)");
6502        assert!(
6503            out.sql
6504                .contains("coalesce(bool_and(\"_s\".\"v\"), true) FROM unnest(ARRAY(SELECT ("),
6505            "every answer, reduced:\n{}",
6506            out.sql
6507        );
6508    }
6509
6510    /// `any` keeps the EXISTS fold, which is exactly what it means and reads
6511    /// more directly than gathering the answers to reduce them.
6512    #[test]
6513    fn any_over_a_multilink_keeps_its_exists() {
6514        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person FILTER any(.posts IN ps)");
6515        assert!(out.sql.contains("WHERE EXISTS("), "{}", out.sql);
6516        assert!(!out.sql.contains("bool_or"), "{}", out.sql);
6517    }
6518
6519    /// Comparing a multi-link to a binding of more than one row is the same
6520    /// membership question `in` asks — the reading PyQL gives `=` against a
6521    /// set everywhere else. Compared against the binding whole it became
6522    /// `= (SELECT id FROM ps)`, which Postgres aborts on as soon as the
6523    /// binding holds two rows.
6524    #[test]
6525    fn comparing_a_multilink_to_a_multi_row_binding_is_membership() {
6526        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person FILTER .posts = ps");
6527        assert!(
6528            out.sql.contains("\"target\" = ANY((SELECT \"id\" FROM \"ps\"))"),
6529            "{}",
6530            out.sql
6531        );
6532        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person FILTER .posts != ps");
6533        assert!(
6534            out.sql
6535                .contains("NOT (\"t2\".\"target\" = ANY((SELECT \"id\" FROM \"ps\")))"),
6536            "{}",
6537            out.sql
6538        );
6539    }
6540
6541    /// A binding of one row is still one value, so the comparison stays one.
6542    #[test]
6543    fn comparing_a_multilink_to_a_single_row_binding_stays_an_equality() {
6544        let out = compile_and_emit("WITH p := (SELECT Post LIMIT 1) SELECT Person FILTER .posts = p");
6545        assert!(
6546            out.sql.contains("\"target\" = (SELECT \"id\" FROM \"p\")"),
6547            "{}",
6548            out.sql
6549        );
6550    }
6551
6552    /// `select a in b` over two bindings asks the question once per element of
6553    /// `a`, so `a` is the row source. Read as a plain value the left side
6554    /// became a scalar subquery over the whole binding, which Postgres aborts
6555    /// on the moment it holds more than one row ("more than one row returned
6556    /// by a subquery used as an expression").
6557    #[test]
6558    fn membership_of_one_set_in_another_answers_per_element() {
6559        let out = compile_and_emit("WITH a := (SELECT Person), b := (SELECT Person) SELECT a IN b");
6560        assert!(
6561            out.sql.contains("FROM \"a\""),
6562            "the left set is the source:\n{}",
6563            out.sql
6564        );
6565        assert!(
6566            out.sql.contains("\"id\" = ANY((SELECT \"id\" FROM \"b\"))"),
6567            "each row tests its own id:\n{}",
6568            out.sql
6569        );
6570        assert!(
6571            !out.sql.contains("(SELECT \"id\" FROM \"a\") = ANY"),
6572            "the left set must not collapse to one value:\n{}",
6573            out.sql
6574        );
6575    }
6576
6577    /// The same question asked of a type rather than a binding.
6578    #[test]
6579    fn membership_of_a_type_in_a_set_answers_per_row() {
6580        let out = compile_and_emit("WITH b := (SELECT Person) SELECT Person NOT IN b");
6581        assert!(
6582            out.sql.contains("FROM \"public\".\"Person\""),
6583            "the type is the source:\n{}",
6584            out.sql
6585        );
6586        assert!(
6587            out.sql.contains("\"id\" <> ALL((SELECT \"id\" FROM \"b\"))"),
6588            "{}",
6589            out.sql
6590        );
6591    }
6592
6593    /// A quantifier reduces those per-element answers to one, so the set it
6594    /// reduces still has to be built per element: the left binding must not
6595    /// collapse into the one value a scalar subquery reads.
6596    #[test]
6597    fn quantifying_membership_of_one_set_in_another_aggregates_the_elements() {
6598        for (query, aggregate) in [("SELECT all(a IN b)", "bool_and"), ("SELECT any(a IN b)", "bool_or")] {
6599            let out = compile_and_emit(&format!("WITH a := (SELECT Person), b := (SELECT Person) {query}"));
6600            assert!(
6601                out.sql.contains(&format!("{aggregate}(\"_s\".\"v\")")),
6602                "{query}:\n{}",
6603                out.sql
6604            );
6605            assert!(
6606                out.sql.contains("FROM unnest(ARRAY(SELECT ("),
6607                "one answer per element, then reduced — {query}:\n{}",
6608                out.sql
6609            );
6610            assert!(
6611                !out.sql.contains("(SELECT \"id\" FROM \"a\") = ANY"),
6612                "the left set must not collapse to one value — {query}:\n{}",
6613                out.sql
6614            );
6615        }
6616    }
6617
6618    /// `t := .posts in ps` is a set of booleans, one per post — the same array
6619    /// any other multi-valued computed comes back as. Collapsed to an EXISTS it
6620    /// answered once for the whole link, which is what `any(…)` spells.
6621    #[test]
6622    fn membership_of_a_multilink_is_a_boolean_per_element() {
6623        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person { t := .posts IN ps }");
6624        assert!(
6625            out.sql
6626                .contains("ARRAY(SELECT (\"t3\".\"id\" = ANY((SELECT \"id\" FROM \"ps\")))"),
6627            "one answer per post:\n{}",
6628            out.sql
6629        );
6630        assert!(
6631            !out.sql.contains("EXISTS("),
6632            "not one answer for the link:\n{}",
6633            out.sql
6634        );
6635    }
6636
6637    /// A FILTER wants a single boolean out of the whole set, which is the
6638    /// EXISTS the element-wise reading above must not take away.
6639    #[test]
6640    fn membership_of_a_multilink_in_a_filter_stays_one_answer() {
6641        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person FILTER .posts IN ps");
6642        assert!(out.sql.contains("WHERE EXISTS("), "{}", out.sql);
6643    }
6644
6645    /// And `all(…)`/`any(…)` are how a computed asks for that single boolean:
6646    /// the quantifier ranges over the set's elements, not over the rows of the
6647    /// query the array was built in.
6648    #[test]
6649    fn quantifying_membership_of_a_multilink_aggregates_its_elements() {
6650        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person { t := all(.posts IN ps) }");
6651        assert!(
6652            out.sql
6653                .contains("coalesce(bool_and(\"_s\".\"v\"), true) FROM unnest(ARRAY(SELECT ("),
6654            "{}",
6655            out.sql
6656        );
6657        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person { t := any(.posts IN ps) }");
6658        assert!(
6659            out.sql
6660                .contains("coalesce(bool_or(\"_s\".\"v\"), false) FROM unnest(ARRAY(SELECT ("),
6661            "{}",
6662            out.sql
6663        );
6664    }
6665
6666    /// A set *literal* on the right is still the array `= ANY(…)` wants, even
6667    /// once the left is walked element by element.
6668    #[test]
6669    fn membership_in_a_set_literal_answers_per_element() {
6670        let out = compile_and_emit("SELECT Person { t := .posts.title IN {'a', 'b'} }");
6671        assert!(
6672            out.sql
6673                .contains("ARRAY(SELECT (\"t2\".\"title\" = ANY(ARRAY['a', 'b']))"),
6674            "{}",
6675            out.sql
6676        );
6677    }
6678
6679    /// `select (for x in S union (insert T { … })) { … }` — a shape, or any
6680    /// clause of the select's own, stops the loop from simply *being* the
6681    /// statement, so the loop becomes this select's source. Emitted as one it
6682    /// landed in a `CROSS JOIN LATERAL`, where Postgres rejects a
6683    /// data-modifying statement outright ("syntax error at or near INSERT").
6684    /// It is bound to a CTE instead, exactly as the `with c := (for … union
6685    /// (insert …)) select c { … }` spelling of the same query already was.
6686    #[test]
6687    fn test_a_mutating_loop_as_a_select_source_is_bound_to_a_cte() {
6688        let out =
6689            compile_and_emit("SELECT (FOR p IN (SELECT Person) UNION (INSERT Post { title := p.name })) { title }");
6690        assert!(
6691            !out.sql.contains("LATERAL"),
6692            "the insert must not sit in a LATERAL, got:\n{}",
6693            out.sql
6694        );
6695        assert!(
6696            out.sql
6697                .contains("\"_nested_dml_0__ids\" AS (\nINSERT INTO \"public\".\"Post\""),
6698            "the loop's insert belongs in a top-level CTE, got:\n{}",
6699            out.sql
6700        );
6701        assert!(
6702            out.sql.contains("FROM \"_nested_dml_0\" AS "),
6703            "the select must read the bound loop back, got:\n{}",
6704            out.sql
6705        );
6706    }
6707
6708    /// The same binding also puts the loop variable in scope before the body's
6709    /// subject is resolved — `update p` named no type until then, so this
6710    /// spelling used to fail to compile at all while its `with` form worked.
6711    #[test]
6712    fn test_a_loop_updating_its_own_variable_compiles_as_a_select_source() {
6713        let out = compile_and_emit("SELECT (FOR p IN (SELECT Person) UNION (UPDATE p SET { age := 1 })) { name }");
6714        assert!(
6715            out.sql
6716                .contains("\"_nested_dml_0__ids\" AS (\nUPDATE \"public\".\"Person\""),
6717            "got:\n{}",
6718            out.sql
6719        );
6720        assert!(!out.sql.contains("LATERAL"), "got:\n{}", out.sql);
6721    }
6722
6723    /// `(insert Outer { link := (insert Inner …) }) if cond else {}` — the
6724    /// guard belongs to the insert the conditional names. The nested insert
6725    /// compiles first and used to claim it, leaving the outer one to write a
6726    /// row whatever the condition said. `compile_update`/`compile_delete`
6727    /// take theirs up front for the same reason.
6728    /// A loop's own bindings mutate too — an order line's attributes are
6729    /// bound inside the loop that writes the line, two levels under the
6730    /// insert the condition is on.
6731    #[test]
6732    fn test_a_guarded_insert_reaches_a_binding_inside_a_nested_loop() {
6733        let out = compile_and_emit(
6734            "WITH existing := (SELECT Company FILTER .name = 'x'), \
6735                  made := (INSERT Person { name := 'a', \
6736                             posts := (FOR s IN {'p','q'} UNION ( \
6737                               WITH extra := (INSERT Company { name := s }) \
6738                               INSERT Post { title := s })) }) \
6739                          IF NOT EXISTS existing ELSE {} \
6740             SELECT made",
6741        );
6742        let at = out
6743            .sql
6744            .find("INSERT INTO \"public\".\"Company\"")
6745            .unwrap_or_else(|| panic!("no Company insert in:\n{}", out.sql));
6746        let tail = &out.sql[at..];
6747        let stmt_end = tail.find("RETURNING").unwrap_or(tail.len());
6748        assert!(
6749            tail[..stmt_end].contains("NOT EXISTS(SELECT 1 FROM \"existing\")"),
6750            "a binding inside the loop must carry the condition too, got:\n{}",
6751            out.sql
6752        );
6753    }
6754
6755    /// Everything a guarded insert writes is guarded with it: the row, the
6756    /// values nested in it, and a multi-link's rows — which are hoisted
6757    /// beside the statement rather than inside it. Otherwise a false
6758    /// condition still leaves those behind, owned by nothing.
6759    #[test]
6760    fn test_a_guarded_insert_carries_its_condition_into_what_it_nests() {
6761        let out = compile_and_emit(
6762            "WITH existing := (SELECT Company FILTER .name = 'x'), \
6763                  made := (INSERT Person { name := 'a', company := (INSERT Company { name := 'b' }), \
6764                                           posts := (FOR s IN {'p','q'} UNION (INSERT Post { title := s })) }) \
6765                          IF NOT EXISTS existing ELSE {} \
6766             SELECT made",
6767        );
6768        for table in ["\"Person\"", "\"Company\" (", "\"Post\""] {
6769            let at = out
6770                .sql
6771                .find(&format!("INSERT INTO \"public\".{}", table))
6772                .unwrap_or_else(|| panic!("no insert into {} in:\n{}", table, out.sql));
6773            let tail = &out.sql[at..];
6774            let stmt_end = tail.find("RETURNING").unwrap_or(tail.len());
6775            assert!(
6776                tail[..stmt_end].contains("NOT EXISTS(SELECT 1 FROM \"existing\")"),
6777                "insert into {} writes whatever the condition says, got:\n{}",
6778                table,
6779                out.sql
6780            );
6781        }
6782    }
6783
6784    #[test]
6785    fn test_a_guarded_insert_keeps_its_guard_when_it_nests_another() {
6786        let out = compile_and_emit(
6787            "WITH existing := (SELECT Company FILTER .name = 'x'), \
6788                  made := (INSERT Person { name := 'a', company := (INSERT Company { name := 'b' }) }) \
6789                          IF NOT EXISTS existing ELSE {} \
6790             SELECT made",
6791        );
6792        let outer = out
6793            .sql
6794            .find("INSERT INTO \"public\".\"Person\"")
6795            .unwrap_or_else(|| panic!("no Person insert in:\n{}", out.sql));
6796        let tail = &out.sql[outer..];
6797        let stmt_end = tail.find("RETURNING").unwrap_or(tail.len());
6798        assert!(
6799            tail[..stmt_end].contains("NOT EXISTS(SELECT 1 FROM \"existing\")"),
6800            "the insert the conditional names must carry the condition, got:\n{}",
6801            out.sql
6802        );
6803    }
6804
6805    #[test]
6806    fn a_nested_read_inside_a_with_opens_no_second_with() {
6807        let out = compile_and_emit_with(
6808            "WITH a := (SELECT Person FILTER .age > 1), b := (SELECT Person LIMIT 1) SELECT (SELECT (a UNION b))",
6809            &make_schema(),
6810        );
6811        assert_eq!(out.sql.matches("WITH").count(), 1, "{}", out.sql);
6812    }
6813
6814    #[test]
6815    fn a_comparison_against_a_multi_row_with_binding_tests_membership() {
6816        // Read as a scalar subquery, the binding aborts the query on its
6817        // second row: "more than one row returned by a subquery used as an
6818        // expression".
6819        let out = compile_and_emit_with(
6820            "WITH companies := (SELECT Company FILTER .name = 'x') SELECT Person FILTER .company = companies",
6821            &make_schema(),
6822        );
6823        assert!(
6824            out.sql.contains("= ANY((SELECT \"id\" FROM \"companies\"))"),
6825            "{}",
6826            out.sql
6827        );
6828
6829        let out = compile_and_emit_with(
6830            "WITH companies := (SELECT Company) SELECT Person FILTER any(.company = companies)",
6831            &make_schema(),
6832        );
6833        assert!(
6834            out.sql.contains("= ANY((SELECT \"id\" FROM \"companies\"))"),
6835            "{}",
6836            out.sql
6837        );
6838
6839        let out = compile_and_emit_with(
6840            "WITH companies := (SELECT Company) SELECT Person FILTER .company != companies",
6841            &make_schema(),
6842        );
6843        assert!(out.sql.contains("NOT (\"t1\".\"company_id\" = ANY("), "{}", out.sql);
6844    }
6845
6846    #[test]
6847    fn a_comparison_against_a_single_row_with_binding_stays_a_scalar_read() {
6848        let out = compile_and_emit_with(
6849            "WITH company := (SELECT Company LIMIT 1) SELECT Person FILTER .company = company",
6850            &make_schema(),
6851        );
6852        assert!(out.sql.contains("= (SELECT \"id\" FROM \"company\")"), "{}", out.sql);
6853    }
6854
6855    #[test]
6856    fn an_aggregate_over_a_backlink_counts_inside_a_subquery() {
6857        let out = compile_and_emit_with("SELECT Post { n := count(.<posts) }", &make_schema());
6858        assert!(
6859            !out.sql.contains("count((SELECT"),
6860            "an aggregate over a scalar subquery:\n{}",
6861            out.sql
6862        );
6863        assert!(out.sql.contains("count("), "{}", out.sql);
6864    }
6865
6866    #[test]
6867    fn an_aggregate_over_a_relative_subselect_counts_inside_a_subquery() {
6868        let out = compile_and_emit_with(
6869            "SELECT Post { n := count((SELECT .<posts FILTER .age > 1)) }",
6870            &make_schema(),
6871        );
6872        assert!(
6873            !out.sql.contains("count((SELECT"),
6874            "an aggregate over a scalar subquery:\n{}",
6875            out.sql
6876        );
6877        assert!(
6878            out.sql.contains(r#"(SELECT count("_s"."v") FROM unnest(ARRAY("#),
6879            "{}",
6880            out.sql
6881        );
6882    }
6883
6884    #[test]
6885    fn any_over_a_link_type_check_tests_each_element() {
6886        let out = compile_and_emit_with(
6887            "SELECT Company { name } FILTER any(.<company.posts IS Post)",
6888            &make_schema(),
6889        );
6890        assert!(
6891            !out.sql.contains("bool_or(ARRAY"),
6892            "an aggregate over outer rows:\n{}",
6893            out.sql
6894        );
6895        assert!(
6896            out.sql
6897                .contains("FROM unnest(ARRAY(SELECT ('default::Post' = 'default::Post')"),
6898            "{}",
6899            out.sql
6900        );
6901    }
6902
6903    #[test]
6904    fn a_link_type_check_reads_the_linked_object() {
6905        let out = compile_and_emit_with("SELECT Person { name } FILTER .company IS Company", &make_schema());
6906        assert!(!out.sql.contains("WHERE FALSE"), "{}", out.sql);
6907        assert!(out.sql.contains("'default::Company'"), "{}", out.sql);
6908    }
6909
6910    /// `make_schema` with an `Admin` extending `Person`, in a table of its own.
6911    fn schema_with_a_subtype() -> SchemaDescriptor {
6912        let mut schema = make_schema();
6913        let mut admin = schema.types[0].clone();
6914        admin.name = "Admin".into();
6915        admin.table = "Admin".into();
6916        admin.bases = vec!["default::Person".into()];
6917        schema.types.push(admin);
6918        schema
6919    }
6920
6921    #[test]
6922    fn a_type_with_subtypes_reads_their_rows_too() {
6923        let out = compile_and_emit_with("SELECT Person { name }", &schema_with_a_subtype());
6924        assert!(
6925            out.sql.contains("'default::Admin'::text AS \"__type__\"") && out.sql.contains("FROM \"public\".\"Admin\""),
6926            "expected the subtype's table in the read:\n{}",
6927            out.sql
6928        );
6929    }
6930
6931    #[test]
6932    fn a_path_from_a_type_with_subtypes_reads_their_rows_too() {
6933        let out = compile_and_emit_with("SELECT Person.name", &schema_with_a_subtype());
6934        assert!(out.sql.contains("FROM \"public\".\"Admin\""), "{}", out.sql);
6935    }
6936
6937    #[test]
6938    fn a_multilink_of_a_type_with_subtypes_reads_their_junctions_too() {
6939        let out = compile_and_emit_with("SELECT Person { posts: { title } }", &schema_with_a_subtype());
6940        assert!(
6941            out.sql.contains(
6942                "(SELECT \"source\", \"target\" FROM \"public\".\"Person.posts\" UNION ALL \
6943                 SELECT \"source\", \"target\" FROM \"public\".\"Admin.posts\")"
6944            ),
6945            "expected the junctions unioned:\n{}",
6946            out.sql
6947        );
6948    }
6949
6950    #[test]
6951    fn is_a_supertype_matches_its_subtypes() {
6952        let out = compile_and_emit_with(
6953            "SELECT Person { name } FILTER Person IS Admin",
6954            &schema_with_a_subtype(),
6955        );
6956        assert!(out.sql.contains("\"__type__\" = 'default::Admin'"), "{}", out.sql);
6957    }
6958
6959    #[test]
6960    fn a_write_to_a_type_with_subtypes_reaches_their_tables() {
6961        let out = compile_and_emit_with("UPDATE Person SET { age := 1 }", &schema_with_a_subtype());
6962        assert!(
6963            out.sql.contains("UPDATE \"public\".\"Person\"") && out.sql.contains("UPDATE \"public\".\"Admin\""),
6964            "expected both tables updated:\n{}",
6965            out.sql
6966        );
6967        assert!(
6968            !out.sql.contains("UPDATE (\n"),
6969            "a write never targets the union:\n{}",
6970            out.sql
6971        );
6972    }
6973
6974    #[test]
6975    fn test_comparing_a_multilink_itself_to_an_object() {
6976        // `filter any(.posts = post)` — the multi-link with no tail step. It
6977        // used to reach `compile_path`, which only knows properties, links
6978        // and computeds, and reported "has no link or property 'posts'".
6979        let out = compile_and_emit_with(
6980            "WITH p := (SELECT Post LIMIT 1) SELECT Person { id } FILTER any(.posts = p)",
6981            &make_schema(),
6982        );
6983        assert!(
6984            out.sql.contains("EXISTS((SELECT 1\nFROM \"public\".\"Person.posts\""),
6985            "expected a junction EXISTS:\n{}",
6986            out.sql
6987        );
6988        assert!(
6989            out.sql.contains("\"target\" = (SELECT \"id\" FROM \"p\")"),
6990            "expected the junction target compared to the bound object's id:\n{}",
6991            out.sql
6992        );
6993    }
6994
6995    #[test]
6996    fn test_comparing_a_multilink_itself_warns_without_any() {
6997        let ast = parse::parse("WITH p := (SELECT Post LIMIT 1) SELECT Person { id } FILTER .posts = p").unwrap();
6998        let ir = ir::compile(&ast, &make_schema()).unwrap();
6999        assert!(
7000            ir.warnings.iter().any(|w| w.contains("multi-link '.posts'")),
7001            "expected the set-valued FILTER warning, got: {:?}",
7002            ir.warnings
7003        );
7004    }
7005
7006    #[test]
7007    fn test_detached_names_the_type_of_a_nested_select() {
7008        // `detached T` as an insert's link value: the prefix used to hide the
7009        // type name from the nested select's subject resolution, which then
7010        // reported "expected a type name as SELECT subject".
7011        let out = compile_and_emit_with(
7012            "INSERT Person { name := $n, company := (SELECT detached Company FILTER .name = $c LIMIT 1) }",
7013            &make_schema(),
7014        );
7015        assert!(
7016            out.sql.contains("INSERT INTO \"public\".\"Person\""),
7017            "expected the insert to compile:\n{}",
7018            out.sql
7019        );
7020        assert!(
7021            out.sql.contains("FROM \"public\".\"Company\""),
7022            "expected the detached select to read the Company table:\n{}",
7023            out.sql
7024        );
7025    }
7026
7027    #[test]
7028    fn test_select_over_an_update_on_a_with_binding() {
7029        // A bare `with` binding as an update's subject names rows, not a
7030        // type. The top-level form already knew that; wrapping it in a select
7031        // asked what type the statement yields and got the binding's name,
7032        // reported as "unknown type 'p'".
7033        let out = compile_and_emit_with(
7034            "WITH p := (SELECT Person FILTER .id = $a LIMIT 1) SELECT (UPDATE p SET { name := $n }) { id }",
7035            &make_schema(),
7036        );
7037        assert!(
7038            out.sql.contains("UPDATE \"public\".\"Person\""),
7039            "expected the update to resolve to the bound type's table:\n{}",
7040            out.sql
7041        );
7042    }
7043
7044    #[test]
7045    fn test_a_mutation_as_a_free_object_field() {
7046        // Already allowed as an item of a free *set*; a named field of a free
7047        // object is the same question and used to answer "an update cannot
7048        // stand in for a value".
7049        let out = compile_and_emit_with(
7050            "SELECT { renamed := (UPDATE Person FILTER .id = $a SET { name := $n }), n := 1 }",
7051            &make_schema(),
7052        );
7053        assert!(
7054            out.sql.contains("UPDATE \"public\".\"Person\""),
7055            "expected the mutation to become a data-modifying CTE:\n{}",
7056            out.sql
7057        );
7058        assert!(
7059            out.sql
7060                .contains("(SELECT \"t1\".\"id\"\nFROM \"_nested_dml_0\" AS \"t1\") AS \"_f0\""),
7061            "expected the field to carry the rows the mutation touched:\n{}",
7062            out.sql
7063        );
7064    }
7065
7066    #[test]
7067    fn test_a_sub_selects_filter_stays_on_its_own_subject_when_a_field_chain_projects_off_it() {
7068        // `(select Person filter .id = $a).company.name` filters People, not
7069        // Companies. The merge that splices the field chain onto the inner
7070        // select used to leave the filter to bind against the last step.
7071        let out = compile_and_emit_with("SELECT (SELECT Person FILTER .name = $n).company.name", &make_schema());
7072        assert!(
7073            out.sql.contains("WHERE (\"t0\".\"name\" = $1)"),
7074            "the inner filter belongs to the Person alias:\n{}",
7075            out.sql
7076        );
7077    }
7078
7079    #[test]
7080    fn test_reading_back_a_nested_insert_through_its_own_statement() {
7081        let out = compile_and_emit_with(
7082            "SELECT (INSERT Person { name := $n, posts := (INSERT Post { title := $t }) }) { id, posts: { title } }",
7083            &make_schema(),
7084        );
7085        assert!(out.sql.contains("__ml_add_0"), "got:\n{}", out.sql);
7086    }
7087
7088    #[test]
7089    fn test_reading_back_a_nested_insert_within_its_own_statement() {
7090        // Postgres does not show one statement's CTE writes to the rest of
7091        // that statement, so reading the link back off the base tables found
7092        // nothing and it hydrated as empty/None (confirmed live).
7093        let out = compile_and_emit_with(
7094            "SELECT (INSERT Person { name := $n, posts := (INSERT Post { title := $t }) }) \
7095             { id, posts: { title } }",
7096            &make_schema(),
7097        );
7098        assert!(
7099            out.sql.contains("FROM \"_dml__ml_add_0\" AS \"jt\""),
7100            "the junction rows must come from the CTE that wrote them:\n{}",
7101            out.sql
7102        );
7103        assert!(
7104            out.sql.contains("\"_nested_dml_0\""),
7105            "and the targets from the nested insert's own CTE:\n{}",
7106            out.sql
7107        );
7108    }
7109
7110    #[test]
7111    fn test_a_nested_single_link_insert_reads_back_from_its_cte() {
7112        let out = compile_and_emit_with(
7113            "SELECT (INSERT Person { name := $n, company := (INSERT Company { name := $c }) }) \
7114             { id, company: { name } }",
7115            &make_schema(),
7116        );
7117        assert!(
7118            out.sql.contains("FROM \"_nested_dml_0\""),
7119            "the linked row must be read from the CTE that inserted it:\n{}",
7120            out.sql
7121        );
7122    }
7123
7124    #[test]
7125    fn test_a_free_object_field_holding_an_object_keeps_it_an_object() {
7126        // A free shape is a real object type whose fields are real pointers,
7127        // so an object field stays an object. Pylon used to build the free
7128        // object as jsonb, which flattened it to the bare id.
7129        let out = compile_and_emit_with(
7130            "WITH c := (SELECT Company LIMIT 1) SELECT { co := c { name }, n := 1 }",
7131            &make_schema(),
7132        );
7133        assert!(
7134            out.sql.contains("'default::Company'::text"),
7135            "the field must carry the object's own row:\n{}",
7136            out.sql
7137        );
7138    }
7139
7140    #[test]
7141    fn test_projecting_an_object_off_a_free_object() {
7142        // `.co` is a path step through a pointer, not jsonb extraction, so
7143        // what comes back is the object with its shape.
7144        let out = compile_and_emit_with(
7145            "WITH c := (SELECT Company LIMIT 1) SELECT { co := c { name }, n := 1 }.co",
7146            &make_schema(),
7147        );
7148        assert!(
7149            !out.sql.contains("jsonb_build_object"),
7150            "projecting a pointer must not go through jsonb:\n{}",
7151            out.sql
7152        );
7153        assert!(
7154            out.sql.contains("SELECT v AS result, v FROM"),
7155            "the object row is already the result row:\n{}",
7156            out.sql
7157        );
7158    }
7159
7160    #[test]
7161    fn test_a_shape_on_a_single_valued_object_path() {
7162        // `c := .company { name }` — an object route existed only for walks
7163        // that crossed a multi step, so a single-valued one fell through to
7164        // expression position and reported "shapes and set literals are not
7165        // valid in expression context".
7166        let out = compile_and_emit_with("SELECT Person { c := .company { name } }", &make_schema());
7167        assert!(
7168            out.sql.contains("'default::Company'::text"),
7169            "the pointer must carry the object's own row:\n{}",
7170            out.sql
7171        );
7172        assert!(
7173            !out.sql.contains("ARRAY(SELECT"),
7174            "a single-valued walk is one object, not an array of one:\n{}",
7175            out.sql
7176        );
7177    }
7178
7179    #[test]
7180    fn test_a_shapeless_single_link_still_reads_as_its_value() {
7181        // The object route is taken only when a shape says an object was
7182        // meant; without one this stays what it always was.
7183        let out = compile_and_emit_with("SELECT Person { c := .company }", &make_schema());
7184        assert!(
7185            !out.sql.contains("'default::Company'::text"),
7186            "a bare link reference must not grow an object row:\n{}",
7187            out.sql
7188        );
7189    }
7190
7191    #[test]
7192    fn test_a_tuple_element_holding_a_shaped_object() {
7193        // `select (c { name }, 1)` — an element is the same question a free
7194        // object's field asks, and used to report "shapes and set literals
7195        // are not valid in expression context".
7196        let out = compile_and_emit_with(
7197            "WITH c := (SELECT Company LIMIT 1) SELECT (c { name }, 1)",
7198            &make_schema(),
7199        );
7200        assert!(
7201            out.sql.contains("'default::Company'::text"),
7202            "the element must carry the object's own row:\n{}",
7203            out.sql
7204        );
7205    }
7206
7207    #[test]
7208    fn test_an_object_returning_function_as_a_link_value() {
7209        // `created_by := account_of_transaction()` — the function names the
7210        // row to link to, so its key is what gets stored. The schema itself
7211        // writes this as a link default; written out in a query it used to be
7212        // refused as "part of a larger expression".
7213        let mut schema = make_schema();
7214        // make_schema's Company carries only a name; a link's value is its key.
7215        let company = schema
7216            .types
7217            .iter_mut()
7218            .find(|t| t.name == "Company")
7219            .expect("make_schema declares Company");
7220        company.properties.push(PropertyDescriptor {
7221            name: "id".into(),
7222            pg_type: "uuid".into(),
7223            nullable: false,
7224            default_sql: None,
7225            default_pyql: None,
7226            description: None,
7227            check_constraints: vec![],
7228            is_exclusive: true,
7229            is_pk: true,
7230            is_readonly: true,
7231            rewrites: vec![],
7232            tuple_members: None,
7233            column_type: None,
7234        });
7235        schema.functions.push(crate::schema::FunctionDescriptor {
7236            name: "current_company".into(),
7237            module: "default".into(),
7238            params: vec![],
7239            return_pg_type: "default::Company".into(),
7240            return_is_object: true,
7241            return_is_set: false,
7242            return_is_polymorphic: false,
7243            volatility: "stable".into(),
7244            body: "select Company limit 1".into(),
7245        });
7246        let out = compile_and_emit_with("INSERT Person { name := $n, company := current_company() }", &schema);
7247        assert!(
7248            out.sql.contains("FROM \"public\".\"current_company\"()"),
7249            "the function must supply the foreign key:\n{}",
7250            out.sql
7251        );
7252    }
7253
7254    /// `make_schema` plus a `Company`-returning function and a `Person.company`
7255    /// link defaulting to it — jaldis's `Creatable.created_by :=
7256    /// account_of_transaction()` in miniature.
7257    fn make_schema_with_an_object_returning_default() -> SchemaDescriptor {
7258        let mut schema = make_schema();
7259        let company = schema
7260            .types
7261            .iter_mut()
7262            .find(|t| t.name == "Company")
7263            .expect("make_schema declares Company");
7264        company.properties.push(PropertyDescriptor {
7265            name: "id".into(),
7266            pg_type: "uuid".into(),
7267            nullable: false,
7268            default_sql: None,
7269            default_pyql: None,
7270            description: None,
7271            check_constraints: vec![],
7272            is_exclusive: true,
7273            is_pk: true,
7274            is_readonly: true,
7275            rewrites: vec![],
7276            tuple_members: None,
7277            column_type: None,
7278        });
7279        schema.functions.push(crate::schema::FunctionDescriptor {
7280            name: "current_company".into(),
7281            module: "default".into(),
7282            params: vec![],
7283            return_pg_type: "default::Company".into(),
7284            return_is_object: true,
7285            return_is_set: false,
7286            return_is_polymorphic: false,
7287            volatility: "stable".into(),
7288            body: "select Company limit 1".into(),
7289        });
7290        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
7291        let company_link = person.links.iter_mut().find(|l| l.name == "company").unwrap();
7292        company_link.default_pyql = Some("current_company()".into());
7293        schema
7294    }
7295
7296    #[test]
7297    fn an_object_returning_link_default_is_applied_by_the_insert() {
7298        // A column DEFAULT cannot hold this one — PostgreSQL evaluates it with
7299        // no query in scope — so the insert has to carry it, expanded into
7300        // the shape.
7301        let schema = make_schema_with_an_object_returning_default();
7302        let out = compile_and_emit_with("INSERT Person { name := $n }", &schema);
7303        assert!(
7304            out.sql.contains("\"company_id\"") && out.sql.contains("current_company"),
7305            "the default must supply the foreign key:\n{}",
7306            out.sql
7307        );
7308    }
7309
7310    #[test]
7311    fn an_explicit_value_wins_over_an_inlined_default() {
7312        let schema = make_schema_with_an_object_returning_default();
7313        let out = compile_and_emit_with("INSERT Person { name := $n, company := {} }", &schema);
7314        assert!(
7315            !out.sql.contains("current_company"),
7316            "the shape named the pointer, so the default does not apply:\n{}",
7317            out.sql
7318        );
7319    }
7320
7321    #[test]
7322    fn an_object_returning_link_default_gets_no_column_default() {
7323        // Emitting one produces DDL PostgreSQL refuses to run; it used to be
7324        // dropped silently here, leaving the pointer with no default at all.
7325        let schema = make_schema_with_an_object_returning_default();
7326        let ddl = crate::export::export_schema(&schema).unwrap();
7327        assert!(
7328            !ddl.contains("\"company_id\" uuid DEFAULT"),
7329            "the column must carry no default:\n{ddl}"
7330        );
7331        crate::validate::validate_schema_types(&schema).expect("an inlined default is not an error");
7332    }
7333
7334    #[test]
7335    fn a_column_expressible_default_stays_in_the_ddl() {
7336        // Only what a column DEFAULT cannot hold moves into the insert.
7337        let mut schema = make_schema();
7338        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
7339        let age = person.properties.iter_mut().find(|p| p.name == "age").unwrap();
7340        age.default_pyql = Some("21".into());
7341        assert!(
7342            crate::ir::inlined_pointer_defaults(schema.types.iter().find(|t| t.name == "Person").unwrap(), &schema)
7343                .is_empty()
7344        );
7345        let ddl = crate::export::export_schema(&schema).unwrap();
7346        assert!(ddl.contains("DEFAULT 21"), "{ddl}");
7347        let out = compile_and_emit_with("INSERT Person { name := $n }", &schema);
7348        assert!(
7349            !out.sql.contains("21"),
7350            "the column DEFAULT still applies it:\n{}",
7351            out.sql
7352        );
7353    }
7354
7355    /// `make_schema` plus an `Employee` extending `Person`, where `Person`
7356    /// declares a computed naming itself — jaldis's
7357    /// `BrandAddon.bundle := (BrandAddon is BrandAddonBundle)` in miniature.
7358    fn make_schema_with_an_inherited_self_naming_computed() -> SchemaDescriptor {
7359        let mut schema = make_schema();
7360        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
7361        person.computed.push(crate::schema::ComputedDescriptor {
7362            name: "own_name".into(),
7363            expression: "default::Person.name".into(),
7364            return_type: Some("text".into()),
7365            link_target: None,
7366            link_multi: false,
7367        });
7368        let mut employee = person.clone();
7369        employee.name = "Employee".into();
7370        employee.table = "Employee".into();
7371        employee.bases = vec!["default::Person".into()];
7372        schema.types.push(employee);
7373        schema
7374    }
7375
7376    #[test]
7377    fn an_inherited_computed_naming_its_declaring_type_still_means_the_row() {
7378        // A computed is compiled once, against the type that declares it, so
7379        // the name reaches the subject on every subtype that inherits it.
7380        // Pylon compiles it again per subtype, where the name matched nothing
7381        // and the reference read the whole table instead.
7382        let schema = make_schema_with_an_inherited_self_naming_computed();
7383        let out = compile_and_emit_with("SELECT Employee { own_name }", &schema);
7384        assert!(
7385            !out.sql.contains("\"Person\""),
7386            "the computed must read the Employee row, not Person's table:\n{}",
7387            out.sql
7388        );
7389        crate::validate::validate_schema_types(&schema).expect("an inherited computed is single-valued");
7390    }
7391
7392    #[test]
7393    fn an_inherited_type_check_computed_tests_the_row_it_is_read_on() {
7394        // jaldis's own: `BrandAddon.bundle := (BrandAddon is BrandAddonBundle)`,
7395        // read on `BrandAddonBundle`. `is` had a same-scope check of its own
7396        // that only knew the two spellings of the type being compiled, so on
7397        // the subtype it read `BrandAddon` as a separate set — many values
7398        // behind a declared `bool`.
7399        let mut schema = make_schema();
7400        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
7401        person.computed.push(crate::schema::ComputedDescriptor {
7402            name: "employed".into(),
7403            expression: "(default::Person is default::Employee)".into(),
7404            return_type: Some("boolean".into()),
7405            link_target: None,
7406            link_multi: false,
7407        });
7408        let mut employee = person.clone();
7409        employee.name = "Employee".into();
7410        employee.table = "Employee".into();
7411        employee.bases = vec!["default::Person".into()];
7412        schema.types.push(employee);
7413
7414        crate::validate::validate_schema_types(&schema).expect("the check is single-valued on both types");
7415        let out = compile_and_emit_with("SELECT Employee { employed }", &schema);
7416        assert!(
7417            !out.sql.contains("\"Person\""),
7418            "the check must read the Employee row's own type:\n{}",
7419            out.sql
7420        );
7421    }
7422
7423    /// The interface schema plus a type outside its hierarchy, so a query
7424    /// over that type can name the interface and get one answer per row of
7425    /// it rather than a same-scope check. `Account` also gains a second
7426    /// implementor: with only `Individual` under it, `Account is Individual`
7427    /// holds for every row and is settled without reading any, which is the
7428    /// answer but not the case these tests are about.
7429    fn make_interface_schema_with_an_outsider() -> SchemaDescriptor {
7430        let mut schema = make_interface_schema();
7431        let individual = schema
7432            .types
7433            .iter()
7434            .find(|t| t.name == "Individual")
7435            .expect("Individual is in the interface schema")
7436            .clone();
7437        let mut company = individual.clone();
7438        company.name = "Company".into();
7439        company.table = "Company".into();
7440        company.interfaces = vec![];
7441        company.computed = vec![];
7442        schema.types.push(company);
7443        let mut organisation = individual;
7444        organisation.name = "Organisation".into();
7445        organisation.table = "Organisation".into();
7446        organisation.computed = vec![];
7447        schema.types.push(organisation);
7448        schema
7449    }
7450
7451    #[test]
7452    fn a_type_check_naming_another_type_reads_as_one_value_in_a_filter() {
7453        // jaldis's own: `filter (… if brand::Brand is brand::CustomBrief else
7454        // true)` inside `select brand::Brief`. Naming a type other than the
7455        // one being selected asks the question once per row of that type,
7456        // which came out as `ARRAY(SELECT …)` — a `boolean[]`, which Postgres
7457        // refuses outright as a CASE/WHEN condition. `Company` is not
7458        // polymorphic, so every row answers the same and the check is the
7459        // constant it reads as.
7460        let out = compile_and_emit(
7461            "SELECT Person { name } FILTER (.name = 'a' if default::Company is default::Post else FALSE)",
7462        );
7463        assert!(
7464            !out.sql.contains("ARRAY(SELECT"),
7465            "an answer that is the same for every row has nothing to iterate:\n{}",
7466            out.sql
7467        );
7468        assert!(
7469            out.sql.contains("CASE WHEN FALSE"),
7470            "a Company is never a Post:\n{}",
7471            out.sql
7472        );
7473    }
7474
7475    #[test]
7476    fn a_polymorphic_type_is_never_one_from_a_hierarchy_it_shares_nothing_with() {
7477        // jaldis's own again, and the half the first fix missed: their
7478        // `brand::Brand` is itself an interface, so it is polymorphic and the
7479        // check did *not* fold — it became one answer per Brand row, and with
7480        // three of them the filter failed the single-value check at runtime.
7481        // No row of one hierarchy can ever carry a `__type__` from a disjoint
7482        // one, so the answer is false without reading any.
7483        let schema = make_interface_schema_with_an_outsider();
7484        let out = compile_and_emit_with(
7485            "SELECT Company { id } FILTER (.first_name = 'a' if default::Account is default::Company else FALSE)",
7486            &schema,
7487        );
7488        assert!(
7489            !out.sql.contains("assert_single") && !out.sql.contains("ARRAY(SELECT"),
7490            "nothing to ask per row when the hierarchies are disjoint:\n{}",
7491            out.sql
7492        );
7493        assert!(
7494            out.sql.contains("CASE WHEN FALSE"),
7495            "an Account is never a Company:\n{}",
7496            out.sql
7497        );
7498    }
7499
7500    #[test]
7501    fn a_check_covering_every_implementor_holds_without_reading_a_row() {
7502        // The mirror of the disjoint case: `Individual` and `Organisation`
7503        // are all there is under `Account`, so naming both leaves nothing
7504        // that could answer differently.
7505        let mut schema = make_interface_schema_with_an_outsider();
7506        schema.types.retain(|t| t.name != "Organisation");
7507        let out = compile_and_emit_with(
7508            "SELECT Company { id } FILTER (default::Account is default::Individual)",
7509            &schema,
7510        );
7511        assert!(
7512            !out.sql.contains("assert_single"),
7513            "every Account is an Individual when it is the only implementor:\n{}",
7514            out.sql
7515        );
7516        assert!(out.sql.contains("WHERE TRUE"), "{}", out.sql);
7517    }
7518
7519    #[test]
7520    fn a_type_check_over_a_polymorphic_outsider_collapses_to_one_value() {
7521        // What is left after both folds: `Account` has two implementors, so
7522        // `is Individual` really does differ per row. A filter still takes one
7523        // value, and the message names the check rather than the internal
7524        // helper enforcing it.
7525        let schema = make_interface_schema_with_an_outsider();
7526        let out = compile_and_emit_with(
7527            "SELECT Company { id } FILTER (default::Account is default::Individual)",
7528            &schema,
7529        );
7530        assert!(
7531            out.sql.contains("\"_pylon\".\"assert_single\"(ARRAY(SELECT"),
7532            "one answer per Account row, read as a single value:\n{}",
7533            out.sql
7534        );
7535        assert!(
7536            out.sql
7537                .contains("is default::Individual'' is asked once for every default::Account object"),
7538            "the error has to name the check, not assert_single:\n{}",
7539            out.sql
7540        );
7541    }
7542
7543    #[test]
7544    fn a_type_check_naming_another_type_keeps_its_whole_set_in_a_shape() {
7545        // The other half of the same rule: a shape pointer holds a set, so
7546        // there the per-row answers stay a set rather than collapsing.
7547        let schema = make_interface_schema_with_an_outsider();
7548        let out = compile_and_emit_with(
7549            "SELECT Company { id, each := default::Account is default::Individual }",
7550            &schema,
7551        );
7552        assert!(
7553            out.sql.contains("array_agg") && !out.sql.contains("assert_single"),
7554            "a shape keeps every answer:\n{}",
7555            out.sql
7556        );
7557    }
7558
7559    #[test]
7560    fn a_shape_writing_the_computed_out_itself_does_not_get_the_anchor() {
7561        // The rule is narrow: only a *declared* computed is compiled against
7562        // its declaring type. Naming a supertype anywhere else is that
7563        // supertype's own set —
7564        // `select BrandAddonBundle { n := count(BrandAddon) }` is the full
7565        // count while the same shape naming `BrandAddonBundle` is 1. So the
7566        // same expression written out in a shape keeps resolving on its own,
7567        // even where it shadows the inherited computed's name.
7568        let schema = make_schema_with_an_inherited_self_naming_computed();
7569        let ast = parse::parse("SELECT Employee { own_name := default::Person.name }").expect("parse failed");
7570        let Err(error) = ir::compile(&ast, &schema) else {
7571            panic!("an absolute path has nothing to anchor to here")
7572        };
7573        assert!(
7574            format!("{error:?}").contains("absolute paths are not valid"),
7575            "{error:?}"
7576        );
7577    }
7578
7579    #[test]
7580    fn a_default_that_compiles_nowhere_is_still_an_error() {
7581        // jaldis carried `sequence_next(INTROSPECT marketplace::OrderNo)`,
7582        // which PyQL cannot parse. Inlining must not turn that into silence.
7583        let mut schema = make_schema();
7584        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
7585        let age = person.properties.iter_mut().find(|p| p.name == "age").unwrap();
7586        age.default_pyql = Some("sequence_next(INTROSPECT default::AgeSeq)".into());
7587        let errs = crate::validate::validate_schema_types(&schema).unwrap_err();
7588        let (_, msg, _) = errs[0].class_name_message_position();
7589        assert!(msg.contains("default"), "{msg}");
7590    }
7591
7592    #[test]
7593    fn test_comparing_a_link_with_an_object_returning_function() {
7594        // Comparing objects compares identity, so the call stands for the id
7595        // of the row it returns. Left whole it is a function call with nowhere
7596        // to go but the subject of a select.
7597        let mut schema = make_schema();
7598        // make_schema's Company carries only a name; identity needs its key.
7599        let company = schema
7600            .types
7601            .iter_mut()
7602            .find(|t| t.name == "Company")
7603            .expect("make_schema declares Company");
7604        company.properties.push(PropertyDescriptor {
7605            name: "id".into(),
7606            pg_type: "uuid".into(),
7607            nullable: false,
7608            default_sql: None,
7609            default_pyql: None,
7610            description: None,
7611            check_constraints: vec![],
7612            is_exclusive: true,
7613            is_pk: true,
7614            is_readonly: true,
7615            rewrites: vec![],
7616            tuple_members: None,
7617            column_type: None,
7618        });
7619        schema.functions.push(crate::schema::FunctionDescriptor {
7620            name: "current_company".into(),
7621            module: "default".into(),
7622            params: vec![],
7623            return_pg_type: "default::Company".into(),
7624            return_is_object: true,
7625            return_is_set: false,
7626            return_is_polymorphic: false,
7627            volatility: "stable".into(),
7628            body: "select Company limit 1".into(),
7629        });
7630        let out = compile_and_emit_with("SELECT Person FILTER .company = current_company()", &schema);
7631        assert!(
7632            out.sql.contains("\"company_id\"") && out.sql.contains("current_company"),
7633            "the link's key against the function's row:\n{}",
7634            out.sql
7635        );
7636    }
7637
7638    #[test]
7639    fn test_choosing_between_two_object_sets() {
7640        // `A if cond else B` over objects is not the scalar CASE an if/else
7641        // over values gives: it stands for A's rows when the condition holds
7642        // and B's when it does not.
7643        let out = compile_and_emit_with(
7644            "WITH a := (SELECT Person FILTER .name = $n), b := (SELECT Person) \
7645             SELECT (a IF EXISTS a ELSE b) { name }",
7646            &make_schema(),
7647        );
7648        assert!(
7649            out.sql.contains("UNION ALL"),
7650            "expected one branch per side:\n{}",
7651            out.sql
7652        );
7653    }
7654
7655    #[test]
7656    fn test_a_union_operand_written_inline() {
7657        // A union reads one relation per branch, so an operand that names no
7658        // relation yet -- a sub-select, or an insert in a get-or-create -- is
7659        // hoisted into a CTE of its own first.
7660        let out = compile_and_emit_with(
7661            "SELECT ((SELECT Person FILTER .name = $n) UNION (SELECT Person)) { name }",
7662            &make_schema(),
7663        );
7664        assert!(
7665            out.sql.starts_with("WITH"),
7666            "expected the operands hoisted:\n{}",
7667            out.sql
7668        );
7669        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
7670    }
7671
7672    #[test]
7673    fn test_a_for_loop_variable_over_objects() {
7674        // The variable holds the row's key, so a path off it reads that row
7675        // back. It used to be treated as a scalar throughout, and any path
7676        // rooted at one reported "unknown type".
7677        let out = compile_and_emit_with("FOR p IN (SELECT Person) UNION (SELECT p.name)", &make_schema());
7678        assert!(
7679            out.sql.contains("FROM \"public\".\"Person\"") && out.sql.contains("\"_for_p\".\"v\""),
7680            "the body must read the row the variable names:\n{}",
7681            out.sql
7682        );
7683    }
7684
7685    #[test]
7686    fn test_a_shape_on_a_for_loop_variable() {
7687        let out = compile_and_emit_with("FOR p IN (SELECT Person) UNION (SELECT p { name })", &make_schema());
7688        assert!(
7689            out.sql.contains("'default::Person'::text"),
7690            "a shape on the variable yields its object:\n{}",
7691            out.sql
7692        );
7693    }
7694
7695    #[test]
7696    fn test_a_for_loop_variable_over_values_is_still_a_scalar() {
7697        let out = compile_and_emit_with("FOR n IN {1, 2} UNION (SELECT n)", &make_schema());
7698        assert!(out.sql.contains("VALUES"), "{}", out.sql);
7699    }
7700
7701    #[test]
7702    fn test_exists_over_a_sub_select_on_a_relative_path() {
7703        // `exists ((select .posts filter …))` — the sub-select is relative to
7704        // the enclosing object, so it needs the same rooting and correlation
7705        // `count((select .posts))` gets. Compiled without them it resolved in
7706        // free context and called the pointer unknown.
7707        let out = compile_and_emit_with(
7708            "SELECT Person { id } FILTER EXISTS ((SELECT .posts FILTER .title = $t))",
7709            &make_schema(),
7710        );
7711        assert!(out.sql.contains("EXISTS("), "{}", out.sql);
7712        assert!(
7713            out.sql.contains("\"Person.posts\""),
7714            "the junction join must survive into the EXISTS:\n{}",
7715            out.sql
7716        );
7717        assert!(
7718            out.sql.contains("\"t0\".\"id\""),
7719            "and it must correlate to the enclosing row:\n{}",
7720            out.sql
7721        );
7722    }
7723
7724    #[test]
7725    fn test_counting_the_rows_a_mutation_touched() {
7726        // `count((delete X filter …))` — the mutation runs as its own
7727        // data-modifying CTE and the aggregate reads it back, rather than
7728        // being refused as "a delete cannot stand in for a value".
7729        let out = compile_and_emit_with("SELECT count((DELETE Person FILTER .name = $n))", &make_schema());
7730        assert!(out.sql.starts_with("WITH"), "{}", out.sql);
7731        assert!(out.sql.contains("DELETE FROM \"public\".\"Person\""), "{}", out.sql);
7732        assert!(out.sql.contains("count(*)"), "{}", out.sql);
7733    }
7734
7735    #[test]
7736    fn test_a_conflict_target_naming_several_columns() {
7737        // `unless conflict on (.a, .b)` names columns for Postgres to infer
7738        // the arbiter index from; emitted as one composite value it would
7739        // look for an expression index on the tuple instead.
7740        let out = compile_and_emit_with(
7741            "INSERT Person { name := $n } UNLESS CONFLICT ON (.name, .id) ELSE (UPDATE Person SET { name := $n })",
7742            &make_schema(),
7743        );
7744        assert!(
7745            out.sql.contains("ON CONFLICT (\"name\", \"id\")"),
7746            "expected a column list:\n{}",
7747            out.sql
7748        );
7749    }
7750
7751    #[test]
7752    fn test_a_for_loop_over_a_path() {
7753        // `for post in p.posts union (…)` — a walk off a binding names a set
7754        // to iterate just as a sub-select does, only written without the
7755        // parentheses.
7756        let out = compile_and_emit_with(
7757            "WITH p := (SELECT Person LIMIT 1) FOR post IN p.posts UNION (SELECT post.title)",
7758            &make_schema(),
7759        );
7760        assert!(out.sql.contains("CROSS JOIN LATERAL"), "{}", out.sql);
7761    }
7762
7763    #[test]
7764    fn test_a_relative_sub_select_keeps_its_enclosing_object() {
7765        // `assert_exists((select .posts { title }))` and `posts := (select
7766        // .posts filter …)` in an update both compiled their sub-select with
7767        // no enclosing object, so the relative path resolved in free context
7768        // and the pointer came back unknown.
7769        let out = compile_and_emit_with(
7770            "SELECT Person { ps := assert_exists((SELECT .posts { title })) }",
7771            &make_schema(),
7772        );
7773        assert!(out.sql.contains("assert_exists"), "{}", out.sql);
7774        assert!(
7775            out.sql.contains("\"Person.posts\""),
7776            "the walk must reach the junction:\n{}",
7777            out.sql
7778        );
7779    }
7780
7781    #[test]
7782    fn test_a_relative_sub_select_as_a_multi_link_value() {
7783        let out = compile_and_emit_with(
7784            "UPDATE Person FILTER .name = $n SET { posts := (SELECT .posts FILTER .title = $t) }",
7785            &make_schema(),
7786        );
7787        assert!(
7788            out.sql.contains("\"Person.posts\""),
7789            "the value must read the row's own posts:\n{}",
7790            out.sql
7791        );
7792    }
7793
7794    #[test]
7795    fn test_a_shape_written_after_a_sub_select() {
7796        // `(select Post filter …) { title }` means the same as putting the
7797        // shape inside the parentheses, so the shape is pushed onto the inner
7798        // statement's own result rather than refused.
7799        let out = compile_and_emit_with(
7800            "SELECT Person { ps := (SELECT Post FILTER .title = $t) { title } }",
7801            &make_schema(),
7802        );
7803        assert!(
7804            out.sql.contains("'default::Post'::text"),
7805            "the pointer must carry the object rows:\n{}",
7806            out.sql
7807        );
7808    }
7809
7810    #[test]
7811    fn test_a_shape_after_a_sub_select_with_a_with_block() {
7812        let out = compile_and_emit_with(
7813            "SELECT Person { ps := (WITH t := $t SELECT Post FILTER .title = t) { title } }",
7814            &make_schema(),
7815        );
7816        assert!(out.sql.contains("'default::Post'::text"), "{}", out.sql);
7817    }
7818
7819    #[test]
7820    fn test_a_computed_declared_as_an_object_returning_call() {
7821        // `recent := recent()` — the rows the function yields are the
7822        // pointer's objects. Read as a plain expression the call was "part of
7823        // a larger expression", which such a function refuses, so a splat over
7824        // a type carrying one could not compile at all.
7825        let mut schema = make_schema();
7826        schema.functions.push(crate::schema::FunctionDescriptor {
7827            name: "recent".into(),
7828            module: "default".into(),
7829            params: vec![],
7830            return_pg_type: "default::Post".into(),
7831            return_is_object: true,
7832            return_is_set: true,
7833            return_is_polymorphic: false,
7834            volatility: "stable".into(),
7835            body: "select Post".into(),
7836        });
7837        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
7838            name: "recent".into(),
7839            expression: "default::recent()".into(),
7840            return_type: None,
7841            link_target: None,
7842            link_multi: false,
7843        });
7844        let out = compile_and_emit_with("SELECT Person { recent }", &schema);
7845        assert!(
7846            out.sql.contains("ARRAY(SELECT") && out.sql.contains("'default::Post'::text"),
7847            "the pointer must carry the function's rows:\n{}",
7848            out.sql
7849        );
7850    }
7851
7852    #[test]
7853    fn test_one_branch_of_an_object_if_else_is_empty() {
7854        // `{}` contributes no rows, so the full rewrite (`SELECT A WHERE
7855        // Cond UNION ALL SELECT B WHERE NOT Cond`) reduces to the other
7856        // branch under its guard.
7857        let out = compile_and_emit_with(
7858            "WITH p := (SELECT Person LIMIT 1) SELECT p { name } IF EXISTS p ELSE {}",
7859            &make_schema(),
7860        );
7861        assert!(!out.sql.contains("UNION ALL"), "nothing to union with:\n{}", out.sql);
7862        assert!(out.sql.contains("'default::Person'::text"), "{}", out.sql);
7863    }
7864
7865    #[test]
7866    fn test_a_conditional_insert_is_guarded_by_its_condition() {
7867        // `(insert …) if cond else {}` — guarding a reader would not do: the
7868        // insert is a data-modifying CTE that Postgres runs regardless, so
7869        // the condition goes into the insert itself and it writes nothing
7870        // when false. Verified live: running it twice leaves one row.
7871        let out = compile_and_emit_with("SELECT (INSERT Person { name := $n }) IF FALSE ELSE {}", &make_schema());
7872        assert!(
7873            out.sql.contains("SELECT") && out.sql.contains("WHERE"),
7874            "a guarded insert selects its row rather than listing values:\n{}",
7875            out.sql
7876        );
7877        assert!(!out.sql.contains("VALUES"), "VALUES always yields a row:\n{}", out.sql);
7878    }
7879
7880    #[test]
7881    fn test_a_conditional_update_carries_its_condition() {
7882        // Guarding the branch only filters what is read back -- the mutation
7883        // is its own CTE and Postgres runs it regardless. An update has a
7884        // WHERE of its own to fold the condition into, which is what keeps it
7885        // from running when the condition is false.
7886        let out = compile_and_emit(
7887            "SELECT (UPDATE Person FILTER .name = $n SET { name := $m }) IF EXISTS (SELECT Company) ELSE {}",
7888        );
7889        let update = out.sql.find("UPDATE").expect("an update is emitted");
7890        let where_clause = out.sql[update..].find("WHERE").expect("the update is filtered");
7891        assert!(
7892            out.sql[update + where_clause..].contains("EXISTS"),
7893            "the condition narrows the rows the update touches:\n{}",
7894            out.sql
7895        );
7896    }
7897
7898    #[test]
7899    fn test_a_conditional_delete_carries_its_condition() {
7900        // Guarding the branch would only filter what is read back — Postgres
7901        // runs a data-modifying CTE regardless — so the condition has to be
7902        // ANDed into the DELETE's own WHERE, as it already is for an update.
7903        let out = compile_and_emit("SELECT (DELETE Person FILTER .name = $n) IF FALSE ELSE {}");
7904        let delete = out
7905            .sql
7906            .split("DELETE FROM")
7907            .nth(1)
7908            .expect("the delete should still be emitted");
7909        let where_clause = delete.split("WHERE").nth(1).expect("the delete should be filtered");
7910        assert!(
7911            where_clause.contains("FALSE") || where_clause.contains("false"),
7912            "the condition must narrow the delete itself:\n{}",
7913            out.sql
7914        );
7915    }
7916
7917    #[test]
7918    fn test_a_pointer_a_binding_declared_in_its_own_shape() {
7919        // `p := (select Person { boss := … })` then `p { boss }` — the
7920        // pointer is on no type, so reading it back has to find it through
7921        // the binding it was written on, and compile it against that row.
7922        let out = compile_and_emit_with(
7923            "WITH p := (SELECT Person { co := .company { name } } LIMIT 1) SELECT p { name, co: { name } }",
7924            &make_schema(),
7925        );
7926        assert!(
7927            out.sql.contains("'default::Company'::text"),
7928            "the declared pointer must be inlined:\n{}",
7929            out.sql
7930        );
7931    }
7932
7933    #[test]
7934    fn test_a_binding_declared_pointer_read_from_a_tuple_element() {
7935        let out = compile_and_emit_with(
7936            "WITH p := (SELECT Person { co := .company { name } } LIMIT 1) SELECT (p { co: { name } }, 1)",
7937            &make_schema(),
7938        );
7939        assert!(out.sql.contains("'default::Company'::text"), "{}", out.sql);
7940    }
7941
7942    #[test]
7943    fn test_a_for_loop_variable_shaped_inside_a_tuple() {
7944        // The variable holds one row's key, so the element has to be narrowed
7945        // to it. Unfiltered it read the whole table and answered with rows
7946        // the loop never named -- wrong, and silently so.
7947        let out = compile_and_emit_with(
7948            "WITH p := (SELECT Person LIMIT 1) FOR q IN p.posts UNION (SELECT (a := q { title }, b := 1))",
7949            &make_schema(),
7950        );
7951        assert!(
7952            out.sql.contains("\"_for_q\".\"v\""),
7953            "the element must be narrowed to the loop's row:\n{}",
7954            out.sql
7955        );
7956    }
7957
7958    #[test]
7959    fn test_choosing_between_two_walks() {
7960        // `(select p.posts …) if cond else (select p.posts …)` — a walk names
7961        // no type of its own, so neither branch could be resolved.
7962        let out = compile_and_emit_with(
7963            "WITH p := (SELECT Person LIMIT 1), q := (SELECT p.posts LIMIT 1) IF TRUE ELSE (SELECT p.posts) \
7964             SELECT q { title }",
7965            &make_schema(),
7966        );
7967        assert!(out.sql.contains("UNION ALL"), "one branch per side:\n{}", out.sql);
7968    }
7969
7970    #[test]
7971    fn test_a_bare_coalesce_of_correlated_walks_reaching_many_objects() {
7972        // Without a shape the walks stand for the ids they reach, which is
7973        // still a set -- SQL's COALESCE takes one value per side.
7974        let out = compile_and_emit_with(
7975            "SELECT Post { owners := .<posts[is Person] ?? .<posts[is Person] }",
7976            &make_schema(),
7977        );
7978        assert!(
7979            !out.sql.contains("COALESCE("),
7980            "not a value-by-value choice:\n{}",
7981            out.sql
7982        );
7983        assert!(
7984            out.sql.contains("ARRAY(SELECT \"r\" FROM ("),
7985            "the arms are aggregated:\n{}",
7986            out.sql
7987        );
7988    }
7989
7990    #[test]
7991    fn test_a_union_of_correlated_walks_reaching_many() {
7992        // Without the LIMIT the union stands for every object both walks
7993        // reach, so its arms have to be aggregated. Read as a lone record it
7994        // compiled fine and failed in PostgreSQL on the second row, with
7995        // "more than one row returned by a subquery used as an expression".
7996        let out = compile_and_emit_with(
7997            "SELECT Post { owners := (.<posts[is Person] UNION .<posts[is Person]) { name } }",
7998            &make_schema(),
7999        );
8000        assert!(
8001            out.sql.contains("ARRAY(SELECT \"r\" FROM ("),
8002            "the arms are aggregated:\n{}",
8003            out.sql
8004        );
8005    }
8006
8007    #[test]
8008    fn test_a_coalesce_of_correlated_walks_reaching_many() {
8009        // `([is A].links ?? [is B].links) { … }` — the same set a union of
8010        // them gives, so it aggregates the same way.
8011        let out = compile_and_emit_with(
8012            "SELECT Post { owners := (.<posts[is Person] ?? .<posts[is Person]) { name } }",
8013            &make_schema(),
8014        );
8015        assert!(
8016            out.sql.contains("ARRAY(SELECT \"r\" FROM ("),
8017            "the arms are aggregated:\n{}",
8018            out.sql
8019        );
8020    }
8021
8022    #[test]
8023    fn test_a_union_of_correlated_walks() {
8024        // `(.<a[is T] union .<b[is T]) { id }` — each operand hangs off the
8025        // enclosing row, so none can be hoisted into a CTE the way a
8026        // standalone union's operands are; they are read as one set instead.
8027        let out = compile_and_emit_with(
8028            "SELECT Post { owners := (SELECT (.<posts[is Person] UNION .<posts[is Person]) { name } LIMIT 1) }",
8029            &make_schema(),
8030        );
8031        assert!(out.sql.contains("UNION ALL"), "one arm per operand:\n{}", out.sql);
8032        assert!(
8033            out.sql.contains("AS \"_u\""),
8034            "the arms are read as one set:\n{}",
8035            out.sql
8036        );
8037    }
8038
8039    #[test]
8040    fn test_a_for_loop_whose_body_updates() {
8041        // Driven from the iteration with `UPDATE … FROM`: the select-bodied
8042        // form uses a LATERAL, which cannot hold DML. Verified live -- three
8043        // rows iterated, three rows updated.
8044        let out = compile_and_emit_with(
8045            "FOR p IN (SELECT Person) UNION (UPDATE Person FILTER .id = p.id SET { name := $n })",
8046            &make_schema(),
8047        );
8048        assert!(out.sql.contains("UPDATE \"public\".\"Person\""), "{}", out.sql);
8049        assert!(
8050            out.sql.contains("FROM \"_for_p\""),
8051            "driven from the iteration:\n{}",
8052            out.sql
8053        );
8054        assert!(
8055            !out.sql.contains("LATERAL"),
8056            "DML cannot sit in a LATERAL:\n{}",
8057            out.sql
8058        );
8059    }
8060
8061    #[test]
8062    fn test_a_for_loop_body_appends_the_row_it_is_iterating() {
8063        let out = compile_and_emit(
8064            "WITH t := (SELECT Post) FOR q IN t UNION (UPDATE Person FILTER .id = $i SET { posts += q })",
8065        );
8066        // One junction row per iteration, taken from the iteration itself --
8067        // not every Post, which an uncorrelated value subquery would append.
8068        assert!(
8069            out.sql.contains("\"_iter\"") && out.sql.contains("FROM \"_ids\""),
8070            "junction rows driven from the iteration:\n{}",
8071            out.sql
8072        );
8073        assert!(
8074            !out.sql.contains("LATERAL"),
8075            "DML cannot sit in a LATERAL:\n{}",
8076            out.sql
8077        );
8078    }
8079
8080    #[test]
8081    fn test_select_on_a_for_loop_variable_reads_only_its_row() {
8082        let out = compile_and_emit("FOR p IN (SELECT Person) UNION (SELECT (SELECT p) { name })");
8083        assert!(
8084            out.sql.contains("= \"_for_p\".\"v\""),
8085            "narrowed to the row the variable holds:\n{}",
8086            out.sql
8087        );
8088    }
8089
8090    #[test]
8091    fn test_a_walk_off_a_for_loop_variable_reads_the_binding() {
8092        // The rows a sibling CTE just wrote are not in the base table yet, so
8093        // a walk that re-reads the table finds nothing.
8094        let out = compile_and_emit("WITH ps := (SELECT Post) FOR q IN ps UNION (SELECT Person FILTER .name = q.title)");
8095        assert!(
8096            out.sql.contains("FROM \"ps\""),
8097            "the walk reads the binding:\n{}",
8098            out.sql
8099        );
8100    }
8101
8102    #[test]
8103    fn test_a_mutating_for_loop_binding_becomes_sibling_ctes() {
8104        let out = compile_and_emit(
8105            "WITH made := (FOR n IN {'a', 'b'} UNION (INSERT Post { title := n })) SELECT count(made)",
8106        );
8107        assert!(
8108            out.sql.contains("\"made__ids\" AS (\nINSERT INTO"),
8109            "the insert is its own top-level CTE:\n{}",
8110            out.sql
8111        );
8112        assert!(
8113            !out.sql.contains("LATERAL"),
8114            "DML cannot sit in a LATERAL:\n{}",
8115            out.sql
8116        );
8117    }
8118
8119    #[test]
8120    fn test_count_of_a_binding_counts_its_rows() {
8121        // Read as an ordinary expression a binding becomes a scalar subquery,
8122        // which aborts on the second row.
8123        let out = compile_and_emit("WITH people := (SELECT Person) SELECT count(people)");
8124        assert!(out.sql.contains("(SELECT count(*) FROM \"people\")"), "{}", out.sql);
8125    }
8126
8127    #[test]
8128    fn test_dividing_integers_yields_a_float() {
8129        for (query, expected) in [
8130            ("SELECT 100 / 133", "((100)::float8 / 133)"),
8131            ("SELECT Person { a := .age / 2 }", "::float8 / 2)"),
8132            ("SELECT Person.age / 2", "::float8 / 2)"),
8133            (
8134                "WITH total := max({133}), current := 38 - 1 SELECT 100 / total * current",
8135                "(((100)::float8 / (SELECT \"v\" FROM \"total\")) * (SELECT \"v\" FROM \"current\"))",
8136            ),
8137            (
8138                "WITH p := (SELECT Person LIMIT 1), index := p.age - 1, total := max(Person.age) SELECT 100 / total * index",
8139                "((100)::float8 / (SELECT \"v\" FROM \"total\"))",
8140            ),
8141        ] {
8142            let out = compile_and_emit(query);
8143            assert!(out.sql.contains(expected), "{query}:\n{}", out.sql);
8144        }
8145    }
8146
8147    #[test]
8148    fn test_dividing_anything_but_two_integers_is_left_to_postgres() {
8149        for query in [
8150            "SELECT 100 // 133",
8151            "SELECT 100 / 1.5",
8152            "SELECT <decimal>100 / <decimal>3",
8153            "SELECT 100 / <decimal>3",
8154        ] {
8155            let out = compile_and_emit(query);
8156            assert!(!out.sql.contains("::float8 /"), "{query}:\n{}", out.sql);
8157        }
8158    }
8159
8160    #[test]
8161    fn test_a_free_object_under_a_condition_stays_an_object() {
8162        for query in [
8163            "SELECT { a := 1 } IF true ELSE {}",
8164            "SELECT {} IF true ELSE { a := 1 }",
8165            "SELECT { a := 1 } IF true ELSE {} LIMIT 1",
8166        ] {
8167            let out = compile_and_emit(query);
8168            assert!(
8169                matches!(&out.shape.root, ShapeNode::Object { type_name: None, pointers, .. } if pointers.len() == 1),
8170                "{query}: {:?}\n{}",
8171                out.shape.root,
8172                out.sql
8173            );
8174        }
8175    }
8176
8177    #[test]
8178    fn test_exists_of_a_binding_asks_for_any_row() {
8179        let out = compile_and_emit("WITH people := (SELECT Person) SELECT exists people");
8180        assert!(out.sql.contains("EXISTS(SELECT 1 FROM \"people\")"), "{}", out.sql);
8181
8182        let out = compile_and_emit("WITH names := {'a', 'b'} SELECT exists names");
8183        assert!(
8184            out.sql
8185                .contains("EXISTS(SELECT 1 FROM \"names\" WHERE \"v\" IS NOT NULL)"),
8186            "{}",
8187            out.sql
8188        );
8189    }
8190
8191    #[test]
8192    fn test_a_conditional_insert_beside_a_read_branch_unions_both() {
8193        let out = compile_and_emit(
8194            "WITH existing := (SELECT Person FILTER .name = $n LIMIT 1) \
8195             SELECT (existing IF EXISTS existing ELSE (INSERT Person { name := $n })) { name }",
8196        );
8197        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
8198        assert!(
8199            out.sql.contains("INSERT INTO") && out.sql.contains("WHERE (NOT EXISTS(SELECT 1 FROM \"existing\"))"),
8200            "the insert carries the condition itself:\n{}",
8201            out.sql
8202        );
8203    }
8204
8205    /// `@pylon.abstract` — unlike an interface it is materialised as nothing
8206    /// at all, so nothing can read a relation under its name. Modelled on
8207    /// `Person`, the one test type carrying the `id` every implementor of a
8208    /// real abstract has.
8209    fn make_schema_with_a_plain_abstract() -> SchemaDescriptor {
8210        let mut schema = make_schema();
8211        let mut archivable = schema
8212            .types
8213            .iter()
8214            .find(|t| t.name == "Person")
8215            .expect("Person is in the test schema")
8216            .clone();
8217        archivable.name = "Archivable".into();
8218        archivable.table = "Archivable".into();
8219        archivable.abstract_ = true;
8220        archivable.materialized = false;
8221        archivable.links = vec![];
8222        archivable.multilinks = vec![];
8223        schema.types.push(archivable);
8224        let person = schema
8225            .types
8226            .iter_mut()
8227            .find(|t| t.name == "Person")
8228            .expect("just found");
8229        person.parents.push("default::Archivable".into());
8230        schema
8231    }
8232
8233    #[test]
8234    fn test_an_intersection_with_a_plain_abstract_fans_out() {
8235        let schema = make_schema_with_a_plain_abstract();
8236        let out = compile_and_emit_with("SELECT Post { who := [is default::Archivable].name }", &schema);
8237        assert!(
8238            !out.sql.contains("\"Archivable\""),
8239            "a plain abstract backs no relation:\n{}",
8240            out.sql
8241        );
8242        assert!(
8243            out.sql.contains("\"public\".\"Person\""),
8244            "read from the type that carries the column:\n{}",
8245            out.sql
8246        );
8247    }
8248
8249    /// The same plain abstract, but keeping `Person`'s `company` link so a
8250    /// backlink can be narrowed to it the way jaldis narrows one to
8251    /// `default::Creatable`.
8252    fn make_schema_with_a_linking_plain_abstract() -> SchemaDescriptor {
8253        let mut schema = make_schema_with_a_plain_abstract();
8254        let person_links = schema
8255            .types
8256            .iter()
8257            .find(|t| t.name == "Person")
8258            .expect("Person is in the test schema")
8259            .links
8260            .clone();
8261        let archivable = schema
8262            .types
8263            .iter_mut()
8264            .find(|t| t.name == "Archivable")
8265            .expect("just added");
8266        archivable.links = person_links;
8267        schema
8268    }
8269
8270    #[test]
8271    fn test_a_plain_abstract_in_a_subquery_fans_out() {
8272        let schema = make_schema_with_a_plain_abstract();
8273        let out = compile_and_emit_with(
8274            "SELECT Person FILTER .id IN (SELECT default::Archivable FILTER .age > 1).id",
8275            &schema,
8276        );
8277        assert!(
8278            !out.sql.contains("\"Archivable\""),
8279            "a plain abstract backs no relation:\n{}",
8280            out.sql
8281        );
8282    }
8283
8284    #[test]
8285    fn test_a_backlink_narrowed_to_a_plain_abstract_fans_out() {
8286        let schema = make_schema_with_a_linking_plain_abstract();
8287        let out = compile_and_emit_with(
8288            "SELECT Company FILTER .<company[is default::Archivable].name = 'a'",
8289            &schema,
8290        );
8291        assert!(
8292            !out.sql.contains("\"Archivable\""),
8293            "a plain abstract backs no relation:\n{}",
8294            out.sql
8295        );
8296    }
8297
8298    /// `Account` as a real interface (a view), with a plain abstract only its
8299    /// implementor carries — jaldis reads `[is default::Auditable].*` off
8300    /// `marketplace::IdentifiableListing` exactly this way.
8301    fn make_interface_schema_with_a_plain_abstract() -> SchemaDescriptor {
8302        let mut schema = make_interface_schema();
8303        let mut archivable = schema
8304            .types
8305            .iter()
8306            .find(|t| t.name == "Individual")
8307            .expect("Individual is in the interface schema")
8308            .clone();
8309        archivable.name = "Archivable".into();
8310        archivable.table = "Archivable".into();
8311        archivable.abstract_ = true;
8312        archivable.materialized = false;
8313        archivable.interfaces = vec![];
8314        archivable.links = vec![];
8315        archivable.multilinks = vec![];
8316        archivable.computed = vec![];
8317        schema.types.push(archivable);
8318        for t in schema.types.iter_mut() {
8319            match t.name.as_str() {
8320                // A view to read, so the splat cannot fall back to it.
8321                "Account" => t.materialized = true,
8322                "Individual" => t.parents.push("default::Archivable".into()),
8323                _ => {}
8324            }
8325        }
8326        schema
8327    }
8328
8329    #[test]
8330    fn test_a_splat_over_a_plain_abstract_fans_out() {
8331        let schema = make_interface_schema_with_a_plain_abstract();
8332        let out = compile_and_emit_with("SELECT Account { id, [is default::Archivable].* }", &schema);
8333        assert!(
8334            !out.sql.contains("\"Archivable\""),
8335            "a plain abstract backs no relation:\n{}",
8336            out.sql
8337        );
8338        assert!(
8339            out.sql.contains("first_name"),
8340            "the splat still reaches the mixin's own pointers:\n{}",
8341            out.sql
8342        );
8343    }
8344
8345    #[test]
8346    fn test_an_aggregate_over_a_relative_walk_runs_inside_the_subquery() {
8347        // Compiled as an expression the walk stands for the array of its
8348        // elements, leaving the aggregate with an array argument.
8349        let out = compile_and_emit("SELECT Person { latest := max(.posts.title) }");
8350        assert!(out.sql.contains("(SELECT max("), "{}", out.sql);
8351        assert!(!out.sql.contains("max(ARRAY("), "{}", out.sql);
8352    }
8353
8354    /// `Company.staff` — a computed standing for a narrowed backlink, the
8355    /// shape jaldis reaches for wherever one type owns rows of another.
8356    fn make_schema_with_a_computed_backlink() -> SchemaDescriptor {
8357        use crate::schema::ComputedDescriptor;
8358        let mut schema = make_schema();
8359        let company = schema
8360            .types
8361            .iter_mut()
8362            .find(|t| t.name == "Company")
8363            .expect("Company is in the test schema");
8364        company.computed = vec![ComputedDescriptor {
8365            name: "staff".into(),
8366            expression: ".<company[is default::Person]".into(),
8367            return_type: None,
8368            link_target: None,
8369            link_multi: false,
8370        }];
8371        schema
8372    }
8373
8374    #[test]
8375    fn test_a_computed_backlink_reads_the_objects_it_lands_on() {
8376        let schema = make_schema_with_a_computed_backlink();
8377        let out = compile_and_emit_with("SELECT Company { staff: { name } }", &schema);
8378        assert!(
8379            out.sql.contains("\"public\".\"Person\""),
8380            "the walk reaches Person:\n{}",
8381            out.sql
8382        );
8383    }
8384
8385    #[test]
8386    fn test_a_computed_backlinks_modifiers_belong_to_what_it_lands_on() {
8387        // Bound to the declaring type instead, `.name` is a field Company has
8388        // not got and the query is rejected outright.
8389        let schema = make_schema_with_a_computed_backlink();
8390        let out = compile_and_emit_with("SELECT Company.staff { name } FILTER .name = 'ada'", &schema);
8391        assert!(out.sql.contains("'ada'"), "{}", out.sql);
8392        assert!(
8393            out.sql.contains("\"public\".\"Person\""),
8394            "filtered on the Person the walk lands on:\n{}",
8395            out.sql
8396        );
8397    }
8398
8399    #[test]
8400    fn test_an_unreserved_keyword_names_a_free_shape_field() {
8401        // `last`, `first` and `order` are unreserved keywords, so a shape can
8402        // carry them. Read as a set literal, the `:=` has nowhere to go.
8403        let out = compile_and_emit("SELECT { last := 2, first := 1, order := 3 }");
8404        assert!(out.sql.contains("\"last\""), "{}", out.sql);
8405        assert!(out.sql.contains("\"first\""), "{}", out.sql);
8406        assert!(out.sql.contains("\"order\""), "{}", out.sql);
8407    }
8408
8409    #[test]
8410    fn test_a_shaped_sub_select_over_a_type_is_an_object() {
8411        // Shapeless it stands for the row's key; with a shape it is the object
8412        // the shape reads, which used to be refused outright.
8413        let out = compile_and_emit("SELECT Person { latest := (SELECT Post { title } LIMIT 1) }");
8414        assert!(out.sql.contains("\"title\""), "{}", out.sql);
8415    }
8416
8417    #[test]
8418    fn test_a_walk_inside_a_path_selects_shape_finds_its_row() {
8419        // The shape is compiled against the type the walk landed on, so a
8420        // relative path inside it has that row to resolve against.
8421        let out = compile_and_emit("SELECT Person.posts { mine := (SELECT Post FILTER .title = .title LIMIT 1) }");
8422        assert!(out.sql.contains("\"public\".\"Post\""), "{}", out.sql);
8423    }
8424
8425    #[test]
8426    fn test_a_walk_off_a_binding_in_a_free_select() {
8427        // A free select has no row of its own, so the walk is read back as one
8428        // subquery; resolved as a type name the binding is simply unknown.
8429        let out = compile_and_emit("WITH p := (SELECT Person) SELECT { n := p.company.name }");
8430        assert!(out.sql.contains("\"p\""), "{}", out.sql);
8431        assert!(out.sql.contains("\"public\".\"Company\""), "{}", out.sql);
8432    }
8433
8434    #[test]
8435    fn test_a_free_selects_filter_can_root_at_a_binding() {
8436        let out = compile_and_emit("WITH p := (SELECT Person) SELECT { n := 1 } FILTER p.company.name = 'Acme'");
8437        assert!(out.sql.contains("'Acme'"), "{}", out.sql);
8438    }
8439
8440    #[test]
8441    fn test_a_trailing_shape_on_a_field_access_select() {
8442        let out = compile_and_emit("SELECT (SELECT Person).company { name }");
8443        assert!(out.sql.contains("\"name\""), "{}", out.sql);
8444        assert!(out.sql.contains("\"public\".\"Company\""), "{}", out.sql);
8445    }
8446
8447    #[test]
8448    fn test_a_union_of_walks_names_each_branch() {
8449        // A walk names no relation, and a union is emitted one relation per
8450        // branch — so each is hoisted into a CTE of its own first.
8451        let out = compile_and_emit(
8452            "WITH p := (SELECT Person FILTER .name = $a), q := (SELECT Person FILTER .name = $b), \
8453             t := (SELECT p.posts UNION q.posts) SELECT t { title }",
8454        );
8455        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
8456        assert!(out.sql.contains("\"title\""), "{}", out.sql);
8457    }
8458
8459    #[test]
8460    fn test_sibling_for_loops_each_get_their_own_iterator() {
8461        // One WITH clause cannot hold `_for_p` twice, and two loops written
8462        // over the same variable name are ordinary.
8463        let out = compile_and_emit(
8464            "WITH a := (SELECT Post), b := (SELECT Post), \
8465             x := (FOR p IN a UNION (INSERT Person { name := p.title, age := 1 })), \
8466             y := (FOR p IN b UNION (INSERT Person { name := p.title, age := 2 })) \
8467             SELECT x",
8468        );
8469        assert!(out.sql.contains("\"_for_p\" AS"), "{}", out.sql);
8470        assert!(out.sql.contains("\"_for_p__1\" AS"), "{}", out.sql);
8471    }
8472
8473    #[test]
8474    fn test_a_nested_for_carries_the_outer_loops_key() {
8475        // The inner iterator has the outer one in its FROM, and the insert
8476        // joins the two back on the key it carries.
8477        let out = compile_and_emit(
8478            "WITH made := (FOR p IN (SELECT Person) UNION ( \
8479               FOR q IN p.posts UNION (INSERT Company { name := q.title }) \
8480             )) SELECT count(made)",
8481        );
8482        assert!(
8483            out.sql.contains("\"_outer\""),
8484            "the inner iterator carries the bond:\n{}",
8485            out.sql
8486        );
8487        assert!(
8488            out.sql.contains("JOIN \"_for_p\" ON"),
8489            "the insert reads both loop variables:\n{}",
8490            out.sql
8491        );
8492        assert!(
8493            !out.sql.contains("LATERAL (\nINSERT"),
8494            "DML cannot sit in a LATERAL:\n{}",
8495            out.sql
8496        );
8497    }
8498
8499    #[test]
8500    fn test_a_link_value_can_be_a_walk_off_a_binding() {
8501        // `(select placed.line_items limit 1)` — the walk names no type of its
8502        // own, so reading one off it used to report the path as no type at all.
8503        let mut schema = make_schema();
8504        let company = schema
8505            .types
8506            .iter_mut()
8507            .find(|t| t.name == "Company")
8508            .expect("make_schema declares Company");
8509        company.properties.push(PropertyDescriptor {
8510            name: "id".into(),
8511            pg_type: "uuid".into(),
8512            nullable: false,
8513            default_sql: None,
8514            default_pyql: None,
8515            description: None,
8516            check_constraints: vec![],
8517            is_exclusive: true,
8518            is_pk: true,
8519            is_readonly: true,
8520            rewrites: vec![],
8521            tuple_members: None,
8522            column_type: None,
8523        });
8524        let out = compile_and_emit_with(
8525            "WITH others := (SELECT Person) \
8526             SELECT (INSERT Person { name := $n, age := 1, company := (SELECT others.company LIMIT 1) })",
8527            &schema,
8528        );
8529        assert!(out.sql.contains("\"company_id\""), "{}", out.sql);
8530    }
8531
8532    #[test]
8533    fn test_a_shape_over_a_coalesce_of_walks() {
8534        // Each operand is a walk off the enclosing row, and every one after the
8535        // first only stands in when the ones before it are empty.
8536        let schema = make_schema_with_computed_links();
8537        let out = compile_and_emit_with("SELECT Person { x := (.published ?? .plain) { title } }", &schema);
8538        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
8539        assert!(
8540            out.sql.contains("NOT EXISTS"),
8541            "the later branch only stands in when the first is empty:\n{}",
8542            out.sql
8543        );
8544    }
8545
8546    #[test]
8547    fn test_an_aggregate_over_a_walk_onto_objects_counts_its_rows() {
8548        // Falling through instead put the aggregate back through the very
8549        // expression route that sent it here, and the recursion took the
8550        // process down with it.
8551        let out = compile_and_emit("WITH c := (SELECT Company) SELECT c { n := count(.<company[is default::Person]) }");
8552        assert!(out.sql.contains("count("), "{}", out.sql);
8553        assert!(out.sql.contains("\"public\".\"Person\""), "{}", out.sql);
8554    }
8555
8556    #[test]
8557    fn an_aggregate_over_a_computed_set_counts_inside_a_subquery() {
8558        // `count(.plain)` is one step, so the junction-only count a stored
8559        // multi-link gets cannot apply — it has no junction to read. Taking
8560        // the walk as a value instead made the whole select an aggregate
8561        // query, which Postgres rejects ("column must appear in the GROUP BY
8562        // clause").
8563        let schema = make_schema_with_computed_links();
8564        let out = compile_and_emit_with("SELECT Person { n := count(.plain) }", &schema);
8565        assert!(
8566            !out.sql.contains("count((SELECT"),
8567            "the aggregate belongs inside the walk's subquery, not around it:\n{}",
8568            out.sql
8569        );
8570        assert!(
8571            out.sql.contains("(SELECT count(") && out.sql.contains("\"public\".\"Post\""),
8572            "it counts the rows the computed's own path lands on:\n{}",
8573            out.sql
8574        );
8575    }
8576
8577    /// The specialized count for a stored multi-link reads its junction table
8578    /// alone — the computed case above must not have pulled it off that route.
8579    #[test]
8580    fn an_aggregate_over_a_stored_multi_link_still_counts_its_junction_rows() {
8581        let schema = make_schema_with_computed_links();
8582        let out = compile_and_emit_with("SELECT Person { n := count(.posts) }", &schema);
8583        assert!(out.sql.contains("\"public\".\"Person.posts\""), "{}", out.sql);
8584        assert!(
8585            !out.sql.contains("JOIN \"public\".\"Post\""),
8586            "counting junction rows needs no join onto the targets:\n{}",
8587            out.sql
8588        );
8589    }
8590
8591    #[test]
8592    fn test_a_select_can_name_its_own_result() {
8593        // A select may name its own result: the alias names it and the
8594        // select's own clauses may read it by that name.
8595        let out = compile_and_emit("SELECT oldest := max(Person.age)");
8596        assert!(out.sql.contains("max("), "{}", out.sql);
8597        let filtered = compile_and_emit("SELECT n := Person.age FILTER n > 18");
8598        assert!(
8599            filtered.sql.contains("18"),
8600            "the alias resolves to the result:\n{}",
8601            filtered.sql
8602        );
8603    }
8604
8605    #[test]
8606    fn test_exists_sees_a_computed_pointer() {
8607        // The suggester could see the name the resolver had just reported as
8608        // unknown — `has no link or property 'published'. Did you mean
8609        // 'published'?`
8610        let schema = make_schema_with_computed_links();
8611        let out = compile_and_emit_with("SELECT Person FILTER EXISTS .published", &schema);
8612        assert!(out.sql.contains("EXISTS"), "{}", out.sql);
8613        assert!(
8614            out.sql.contains("\"public\".\"Post\""),
8615            "the computed's own path:\n{}",
8616            out.sql
8617        );
8618    }
8619
8620    #[test]
8621    fn test_walking_through_a_computed_field_access() {
8622        use crate::schema::ComputedDescriptor;
8623        // `parent := (select .parents … limit 1).parent` — the modifiers belong
8624        // to the step before the field access, not to what it lands on.
8625        let mut schema = make_schema_with_computed_links();
8626        let person = schema.types.iter_mut().find(|t| t.name == "Person").expect("Person");
8627        person.computed.push(ComputedDescriptor {
8628            name: "latest_title".into(),
8629            expression: "(select .posts order by .title desc limit 1).title".into(),
8630            return_type: None,
8631            link_target: None,
8632            link_multi: false,
8633        });
8634        let out = compile_and_emit_with("SELECT Person { x := .latest_title }", &schema);
8635        assert!(out.sql.contains("\"title\""), "{}", out.sql);
8636        assert!(
8637            out.sql.contains("LIMIT"),
8638            "the computed's own limit survives:\n{}",
8639            out.sql
8640        );
8641    }
8642
8643    #[test]
8644    fn test_a_computed_field_access_onto_an_object_takes_a_shape() {
8645        use crate::schema::ComputedDescriptor;
8646        // Compiled as an expression, the pointer came back as the bare id of
8647        // the object it lands on, whatever shape was written on it.
8648        let mut schema = make_schema_with_computed_links();
8649        let post = schema.types.iter_mut().find(|t| t.name == "Post").expect("Post");
8650        post.links.push(LinkDescriptor {
8651            name: "author".into(),
8652            target: "default::Person".into(),
8653            nullable: true,
8654            through: None,
8655            description: None,
8656            default_pyql: None,
8657            is_exclusive: false,
8658            is_readonly: false,
8659            rewrites: vec![],
8660            on_delete: vec![],
8661        });
8662        let person = schema.types.iter_mut().find(|t| t.name == "Person").expect("Person");
8663        person.computed.retain(|c| c.name != "looper");
8664        person.computed.push(ComputedDescriptor {
8665            name: "latest_author".into(),
8666            expression: "((select .posts order by .title desc limit 1)).author".into(),
8667            return_type: None,
8668            link_target: None,
8669            link_multi: false,
8670        });
8671        let out = compile_and_emit_with("SELECT Person { latest_author: { name } }", &schema);
8672        assert!(out.sql.contains("\"name\""), "{}", out.sql);
8673        assert!(
8674            out.sql.contains("\"title\" DESC"),
8675            "the order stays on the posts:\n{}",
8676            out.sql
8677        );
8678        assert!(
8679            out.sql.contains("LIMIT"),
8680            "the computed's own limit survives:\n{}",
8681            out.sql
8682        );
8683        let splat = compile_and_emit_with("SELECT Person { * }", &schema);
8684        assert!(
8685            !splat.sql.contains("latest_author"),
8686            "`*` leaves links out:\n{}",
8687            splat.sql
8688        );
8689    }
8690
8691    #[test]
8692    fn test_a_narrowed_pointer_can_carry_a_shape() {
8693        // `[is T].posts: { title }` — the parser stopped at the `:`, and the
8694        // route behind it resolved a stored property and nothing else, so a
8695        // link reported itself as the name it had just been asked for.
8696        let out = compile_and_emit("SELECT Company { x := 1, [is default::Person].posts: { title } }");
8697        assert!(out.sql.contains("\"title\""), "{}", out.sql);
8698        assert!(out.sql.contains("\"public\".\"Post\""), "{}", out.sql);
8699    }
8700
8701    #[test]
8702    fn test_select_type_name_as_a_path_step() {
8703        let out = compile_and_emit("SELECT Person.__type__");
8704        assert!(out.sql.contains("ROW('default::Person')"), "{}", out.sql);
8705        assert!(out.sql.contains("\"Person\" AS \"t0\""), "{}", out.sql);
8706    }
8707
8708    #[test]
8709    fn test_aggregate_over_a_with_bound_object_set() {
8710        let out = compile_and_emit("WITH people := (SELECT Person) SELECT max(people.age)");
8711        assert!(out.sql.contains("\"people\" AS ("));
8712        assert!(out.sql.contains("max("));
8713        // Reads the binding, not the base table again.
8714        assert!(!out.sql.contains("FROM \"default\".\"Person\" AS \"t1\""));
8715    }
8716
8717    #[test]
8718    fn test_select_union_of_two_object_bindings() {
8719        let out = compile_and_emit("WITH a := (SELECT Person LIMIT 1), b := (SELECT Person) SELECT (a UNION b)");
8720        assert!(out.sql.contains("SELECT * FROM \"a\""));
8721        assert!(out.sql.contains("UNION ALL"));
8722        assert!(out.sql.contains("SELECT * FROM \"b\""));
8723    }
8724
8725    #[test]
8726    fn test_select_union_of_different_object_types_is_rejected() {
8727        let schema = make_schema();
8728        let ast = parse::parse("WITH a := (SELECT Person), b := (SELECT Company) SELECT (a UNION b)").unwrap();
8729        let Err(err) = ir::compile(&ast, &schema) else {
8730            panic!("union of unrelated types must be rejected")
8731        };
8732        assert!(format!("{err}").contains("UNION"));
8733    }
8734
8735    #[test]
8736    fn test_for_union_as_a_function_body() {
8737        let schema = make_schema();
8738        let descriptor = FunctionDescriptor {
8739            name: "recent".into(),
8740            module: "default".into(),
8741            params: vec![],
8742            return_pg_type: "default::Person".into(),
8743            return_is_object: true,
8744            return_is_set: true,
8745            return_is_polymorphic: false,
8746            volatility: "stable".into(),
8747            body: "for n in {1, 2} union (select Person filter .age = n)".into(),
8748        };
8749        let ir = ir::compile_fn_body(&descriptor, &schema).expect("function body must compile");
8750        let body = emit_fn_body(&ir);
8751        assert!(body.contains("VALUES"), "{body}");
8752        assert!(body.contains("CROSS JOIN LATERAL"), "{body}");
8753        assert!(body.contains("\"_body\".*"), "{body}");
8754    }
8755
8756    #[test]
8757    fn test_with_binding_in_a_computed_reads_the_enclosing_object() {
8758        let out = compile_and_emit("SELECT Person { n := (WITH own := .name SELECT own) }");
8759        assert!(out.sql.contains("\"name\""), "{}", out.sql);
8760    }
8761
8762    #[test]
8763    fn test_subject_path_update_names_its_own_alias() {
8764        let mut schema = make_schema();
8765        let company = schema.types.iter_mut().find(|t| t.name == "Company").unwrap();
8766        company.properties.insert(
8767            0,
8768            PropertyDescriptor {
8769                name: "id".into(),
8770                pg_type: "uuid".into(),
8771                nullable: false,
8772                default_sql: Some("uuidv7()".into()),
8773                default_pyql: None,
8774                description: None,
8775                check_constraints: vec![],
8776                is_exclusive: true,
8777                is_pk: true,
8778                is_readonly: true,
8779                rewrites: vec![],
8780                tuple_members: None,
8781                column_type: None,
8782            },
8783        );
8784        // The row set narrowing the update compares against *that update's*
8785        // alias; a bare `id` would be ambiguous once a nested statement's CTE
8786        // joins the FROM.
8787        let out = compile_and_emit_with("SELECT (UPDATE Person.company SET { name := 'x' }) { name }", &schema);
8788        assert!(out.sql.contains("\"t1\".\"id\" = ANY("), "{}", out.sql);
8789    }
8790
8791    #[test]
8792    fn test_an_update_past_a_from_returns_only_its_own_columns() {
8793        // `UPDATE … FROM cte … RETURNING *` returns the joined relation's
8794        // columns as well, so a CTE built that way carries two `id`s and any
8795        // later reference to one is ambiguous.
8796        let out = compile_and_emit(
8797            "SELECT { (UPDATE Person FILTER .name = 'a' SET { company := (INSERT Company { name := 'c' }) }) }",
8798        );
8799        assert!(out.sql.contains("FROM \"_nested_dml_0\""), "{}", out.sql);
8800        assert!(
8801            out.sql.contains("RETURNING \"t0\".*"),
8802            "the update names its own target:\n{}",
8803            out.sql
8804        );
8805    }
8806
8807    #[test]
8808    fn test_nested_insert_in_a_mutation_written_as_a_free_set() {
8809        let out = compile_and_emit(
8810            "SELECT { (UPDATE Person FILTER .name = 'a' SET { company := (INSERT Company { name := 'c' }) }) }",
8811        );
8812        assert!(
8813            out.sql.contains("INSERT INTO \"public\".\"Company\""),
8814            "the hoisted insert is defined, not just referenced:\n{}",
8815            out.sql
8816        );
8817    }
8818
8819    #[test]
8820    fn test_nested_insert_as_a_link_value_in_an_update() {
8821        // The hoisted insert sits beside the update rather than inside it, so
8822        // the update needs a FROM to read its id, and its own `id` has to name
8823        // the update's alias — a bare one could mean either relation.
8824        let out = compile_and_emit(
8825            "SELECT (UPDATE Person FILTER .name = 'a' SET { company := (INSERT Company { name := 'c' }) }) { name }",
8826        );
8827        assert!(out.sql.contains("INSERT INTO \"public\".\"Company\""), "{}", out.sql);
8828        assert!(out.sql.contains("_nested_dml_0"), "{}", out.sql);
8829        assert!(
8830            out.sql.contains("FROM \"_nested_dml_0\""),
8831            "the update reads the hoisted CTE:\n{}",
8832            out.sql
8833        );
8834    }
8835
8836    #[test]
8837    fn test_nested_insert_as_a_link_value() {
8838        let out = compile_and_emit(
8839            "SELECT (INSERT Person { name := 'a', company := (INSERT Company { name := 'c' }) }) { name }",
8840        );
8841        assert!(out.sql.contains("_nested_dml_0"), "{}", out.sql);
8842        assert!(out.sql.contains("INSERT INTO \"public\".\"Company\""), "{}", out.sql);
8843    }
8844
8845    #[test]
8846    fn test_nested_insert_in_a_one_element_set_is_that_insert() {
8847        let out = compile_and_emit(
8848            "SELECT (INSERT Person { name := 'a', company := { (INSERT Company { name := 'c' }) } }) { name }",
8849        );
8850        assert!(out.sql.contains("INSERT INTO \"public\".\"Company\""), "{}", out.sql);
8851    }
8852
8853    #[test]
8854    fn test_insert_with_no_assignments_uses_default_values() {
8855        let out = compile_and_emit("SELECT (INSERT Person { name := 'a', company := (INSERT Company {}) }) { name }");
8856        assert!(out.sql.contains("DEFAULT VALUES"), "{}", out.sql);
8857    }
8858
8859    #[test]
8860    fn test_backlink_narrowed_to_an_interface_its_implementor_declares() {
8861        // `notes` is declared on Individual; `[is Account]` is satisfied by an
8862        // Individual row, so the backlink resolves through the implementor.
8863        use crate::schema::{LinkDescriptor, TypeDescriptor};
8864        let mut schema = make_interface_schema();
8865        let individual = schema.types.iter().find(|t| t.name == "Individual").unwrap().clone();
8866        schema.types.push(TypeDescriptor {
8867            name: "Note".into(),
8868            module: "default".into(),
8869            table: "Note".into(),
8870            abstract_: false,
8871            materialized: true,
8872            description: None,
8873            parents: vec![],
8874            interfaces: vec![],
8875            bases: vec![],
8876            properties: individual.properties[..1].to_vec(),
8877            links: vec![],
8878            multilinks: vec![],
8879            computed: vec![],
8880            constraints: vec![],
8881            indexes: vec![],
8882            partition: None,
8883            vector_indexes: vec![],
8884            search_indexes: vec![],
8885            triggers: vec![],
8886            junction: false,
8887            signals: vec![],
8888        });
8889        schema
8890            .types
8891            .iter_mut()
8892            .find(|t| t.name == "Individual")
8893            .unwrap()
8894            .links
8895            .push(LinkDescriptor {
8896                name: "note".into(),
8897                target: "default::Note".into(),
8898                nullable: true,
8899                description: None,
8900                default_pyql: None,
8901                is_exclusive: false,
8902                is_readonly: false,
8903                rewrites: vec![],
8904                on_delete: vec![],
8905                through: None,
8906            });
8907        let out = compile_and_emit_with("SELECT Note FILTER EXISTS .<note[is Account]", &schema);
8908        assert!(out.sql.contains("\"Individual\""), "{}", out.sql);
8909    }
8910
8911    #[test]
8912    fn test_subject_reads_the_row_a_constraint_checks() {
8913        let schema = make_schema();
8914        let sql = ir::compile_constraint_expr("__subject__.age > 18", "default::Person", &schema)
8915            .expect("__subject__ names the row under check");
8916        assert!(sql.contains("\"age\""), "{sql}");
8917    }
8918
8919    #[test]
8920    fn test_free_select_filter_gates_the_result_and_warns() {
8921        let schema = make_schema();
8922        let ast = parse::parse("SELECT count(Person) FILTER (Person.age > 18)").unwrap();
8923        let ir = ir::compile(&ast, &schema).unwrap();
8924        let out = emit(&ir);
8925        assert!(
8926            out.sql.contains("EXISTS("),
8927            "the condition gates the count:\n{}",
8928            out.sql
8929        );
8930        assert!(
8931            ir.warnings.iter().any(|w| w.contains("FILTER clause")),
8932            "a set-valued filter warns: {:?}",
8933            ir.warnings
8934        );
8935    }
8936
8937    #[test]
8938    fn test_free_sub_select_with_modifiers_reads_inline() {
8939        // The inner statement reads the enclosing row, so it cannot become a
8940        // CTE ahead of the FROM clause that defines it.
8941        let out = compile_and_emit(
8942            "SELECT Person { n := (WITH mine := .name SELECT count(Post) FILTER (Post.title = mine)) }",
8943        );
8944        assert!(out.sql.contains("_scalar_sub"), "{}", out.sql);
8945        assert!(!out.sql.contains("WITH"), "nothing is hoisted:\n{}", out.sql);
8946    }
8947
8948    #[test]
8949    fn test_correlated_with_binding_is_not_hoisted() {
8950        let out = compile_and_emit("SELECT Person { n := (WITH own := .name SELECT own) }");
8951        assert!(
8952            !out.sql.contains("\"own\" AS ("),
8953            "the binding is inlined:\n{}",
8954            out.sql
8955        );
8956        assert!(out.sql.contains("\"name\""), "{}", out.sql);
8957    }
8958
8959    #[test]
8960    fn test_array_literal_binding_is_readable_and_typed() {
8961        let out = compile_and_emit("WITH order := ['a', 'b'] SELECT std::find(order, 'b')");
8962        assert!(out.sql.contains("array_position"), "its type is known:\n{}", out.sql);
8963        assert!(
8964            out.sql.contains("AS v"),
8965            "the binding exposes a value column:\n{}",
8966            out.sql
8967        );
8968    }
8969
8970    #[test]
8971    fn test_any_over_a_single_boolean_is_that_boolean() {
8972        let out = compile_and_emit("SELECT Person FILTER std::any(.age > 18)");
8973        assert!(!out.sql.contains("bool_or"), "no aggregate in WHERE:\n{}", out.sql);
8974        assert!(out.sql.contains("> 18"), "{}", out.sql);
8975    }
8976
8977    #[test]
8978    fn test_comparing_a_value_to_a_set_is_membership() {
8979        let schema = make_schema_with_computed_links();
8980        let out = compile_and_emit_with("SELECT Person FILTER .name = Person.posts.title", &schema);
8981        assert!(out.sql.contains("= ANY("), "{}", out.sql);
8982    }
8983
8984    #[test]
8985    fn test_for_over_a_with_binding_iterates_every_row() {
8986        let out = compile_and_emit(
8987            "WITH names := (SELECT Person.name) FOR n IN names UNION (SELECT Person FILTER .name = n)",
8988        );
8989        assert!(out.sql.contains("FROM \"names\""), "{}", out.sql);
8990        assert!(!out.sql.contains("VALUES"), "the whole set is iterated:\n{}", out.sql);
8991    }
8992
8993    #[test]
8994    fn test_for_body_bindings_stay_inside_the_body() {
8995        // `matching` reads the loop variable, so it cannot be hoisted to the
8996        // statement's own WITH — nothing there can see `_for_n`.
8997        let out = compile_and_emit(
8998            "WITH names := (SELECT Person.name) \
8999             FOR n IN names UNION (WITH matching := (SELECT Person FILTER .name = n) SELECT matching)",
9000        );
9001        let lateral = out.sql.find("CROSS JOIN LATERAL").expect("a lateral body");
9002        let binding = out.sql.find("\"matching\" AS (").expect("the body's binding");
9003        assert!(binding > lateral, "the binding sits inside the body:\n{}", out.sql);
9004    }
9005
9006    #[test]
9007    fn test_array_valued_binding_picks_the_array_overload() {
9008        // The binding carries `text[]`, so the call resolves to the array
9009        // overload even though the value itself is no longer an array literal.
9010        let out = compile_and_emit("WITH l := <array<std::str>>$0 SELECT std::find(l, 'a')");
9011        assert!(out.sql.contains("array_position"), "{}", out.sql);
9012        assert!(!out.sql.contains("strpos"), "{}", out.sql);
9013    }
9014
9015    #[test]
9016    fn test_aggregate_over_a_path_in_a_filter_is_a_subquery() {
9017        let out = compile_and_emit("SELECT Person FILTER .age = std::max(Person.age)");
9018        assert!(
9019            out.sql.contains("(SELECT max("),
9020            "an aggregate in WHERE needs its own query:\n{}",
9021            out.sql
9022        );
9023    }
9024
9025    #[test]
9026    fn test_distinct_over_a_single_value_is_that_value() {
9027        let out = compile_and_emit("SELECT Person { n := (DISTINCT .name) }");
9028        assert!(out.sql.contains("\"name\""), "{}", out.sql);
9029        assert!(!out.sql.contains("DISTINCT \""), "{}", out.sql);
9030    }
9031
9032    #[test]
9033    fn test_narrowing_an_interface_joins_the_implementor_table() {
9034        // `first_name` lives on Individual's own table; the Account view has
9035        // only the columns every implementor shares.
9036        let mut schema = make_interface_schema();
9037        let account = schema.types.iter_mut().find(|t| t.name == "Account").unwrap();
9038        account.materialized = true;
9039        let out = compile_and_emit_with("SELECT Account[is Individual].first_name", &schema);
9040        assert!(
9041            out.sql.contains("JOIN \"public\".\"Individual\""),
9042            "the implementor's own table is joined:\n{}",
9043            out.sql
9044        );
9045        assert!(out.sql.contains("\"first_name\""), "{}", out.sql);
9046    }
9047
9048    #[test]
9049    fn test_sub_select_shape_declares_names_its_filter_and_projection_read() {
9050        let out = compile_and_emit("SELECT Person { n := (SELECT .posts { t := .title } FILTER .t = 'x' LIMIT 1).t }");
9051        assert!(out.sql.contains("'x'"), "{}", out.sql);
9052        assert!(out.sql.contains("\"title\""), "{}", out.sql);
9053    }
9054
9055    #[test]
9056    fn test_assert_over_an_object_set_returns_rows() {
9057        let out = compile_and_emit("SELECT std::assert_distinct((SELECT Person))");
9058        assert!(
9059            out.sql.contains("\"assert_distinct\"(ARRAY(SELECT \"id\""),
9060            "{}",
9061            out.sql
9062        );
9063        assert!(out.sql.contains("= ANY("), "{}", out.sql);
9064        assert!(out.sql.contains("\"Person\" AS \"t1\""), "{}", out.sql);
9065        assert!(!out.sql.contains("unnest("), "{}", out.sql);
9066    }
9067
9068    #[test]
9069    fn test_assert_over_a_for_union_reads_its_rows() {
9070        let out =
9071            compile_and_emit("SELECT std::assert_distinct((FOR n IN {1, 2} UNION (SELECT Person FILTER .age = n)))");
9072        assert!(out.sql.contains("CROSS JOIN LATERAL"), "{}", out.sql);
9073        assert!(out.sql.contains("= ANY("), "{}", out.sql);
9074    }
9075
9076    #[test]
9077    fn test_declared_computed_with_binding_reads_its_own_object() {
9078        // Validation compiles a declared computed on its own, with no
9079        // enclosing SELECT — `.name` in the binding still means this object's.
9080        use crate::schema::ComputedDescriptor;
9081        let schema = make_schema();
9082        let cd = ComputedDescriptor {
9083            name: "shouted".into(),
9084            expression: "(with own := .name select std::str_upper(own))".into(),
9085            return_type: None,
9086            link_target: None,
9087            link_multi: false,
9088        };
9089        let ir = ir::compile_computed_in_type(&cd, "default::Person", &schema)
9090            .expect("a computed may read the object it is declared on");
9091        assert!(ir.is_some());
9092    }
9093
9094    #[test]
9095    fn test_sub_select_modifiers_scope_to_its_own_subject() {
9096        // `.title` belongs to Post, the sub-select's subject — not to Person,
9097        // where the projection `.author.name` lands.
9098        let mut schema = make_schema();
9099        let post = schema
9100            .types
9101            .iter_mut()
9102            .find(|t| t.name == "Post")
9103            .expect("Post is in the test schema");
9104        post.links.push(LinkDescriptor {
9105            name: "author".into(),
9106            target: "default::Person".into(),
9107            nullable: true,
9108            description: None,
9109            default_pyql: None,
9110            is_exclusive: false,
9111            is_readonly: false,
9112            rewrites: vec![],
9113            on_delete: vec![],
9114            through: None,
9115        });
9116        let out = compile_and_emit_with(
9117            "SELECT Person { a := (SELECT .posts FILTER .title = 'x' LIMIT 1).author.name }",
9118            &schema,
9119        );
9120        assert!(out.sql.contains("'x'"), "{}", out.sql);
9121    }
9122
9123    #[test]
9124    fn test_free_select_set_literal() {
9125        let schema = make_schema();
9126        let ast = parse::parse("SELECT {1, 2, 3}").unwrap();
9127        let ir = ir::compile(&ast, &schema).unwrap();
9128        let out = emit(&ir);
9129        // Three UNION ALL branches
9130        assert_eq!(out.sql.matches("UNION ALL").count(), 2);
9131        assert!(out.sql.contains("1 AS v"));
9132        assert!(out.sql.contains("2 AS v"));
9133        assert!(out.sql.contains("3 AS v"));
9134        assert!(out.sql.contains("ROW(v) AS result"));
9135        assert!(matches!(out.shape.root, crate::query::ShapeNode::Scalar { .. }));
9136    }
9137
9138    #[test]
9139    fn test_free_select_free_object() {
9140        let schema = make_schema();
9141        let ast = parse::parse("SELECT { foo := 'bar', n := 42 }").unwrap();
9142        let ir = ir::compile(&ast, &schema).unwrap();
9143        let out = emit(&ir);
9144        assert!(out.sql.contains("'bar'"));
9145        assert!(out.sql.contains("42"));
9146        assert!(out.sql.contains("AS result"));
9147        // Shape should describe an object with pointers foo and n
9148        let crate::query::ShapeNode::Object {
9149            pointers, type_name, ..
9150        } = &out.shape.root
9151        else {
9152            panic!("expected Object shape")
9153        };
9154        assert!(type_name.is_none());
9155        assert_eq!(pointers.len(), 2);
9156        assert!(matches!(&pointers[0], crate::query::ShapeNode::Scalar { name, position: 0 } if name == "foo"));
9157        assert!(matches!(&pointers[1], crate::query::ShapeNode::Scalar { name, position: 1 } if name == "n"));
9158    }
9159
9160    #[test]
9161    fn test_free_select_object_with_enum_field_casts_to_text_and_tags_shape() {
9162        // Regression: `select { gender := default::Gender.Male }` failed at
9163        // runtime with "no decoder for composite type element ... " —
9164        // A custom enum's OID is database-assigned, so inside an anonymous ROW()
9165        // composite. free_item_shape/emit_free_select always treated every
9166        // free-object field as a plain untyped Scalar, never casting an
9167        // enum-valued field to ::text (unlike a schema object's emit_scalar,
9168        // which already does this for a real enum-typed property column).
9169        let mut schema = make_schema();
9170        schema.enums.push(crate::schema::EnumDescriptor {
9171            name: "Gender".into(),
9172            module: "default".into(),
9173            members: vec!["Male".into(), "Female".into()],
9174        });
9175        let out = compile_and_emit_with("select { gender := default::Gender.Male }", &schema);
9176        // The enum value is computed once (`'Male'::"public"."Gender" AS
9177        // "_f0"`) and reused for both the `result` composite (cast to
9178        // `::text` there, since an enum's OID is database-assigned inside an
9179        // anonymous ROW()) and the plain per-field column exposed for
9180        // `IrExpr::CteFieldRef` access — so the `::text` cast now applies
9181        // to that computed column, not inline on the enum literal itself.
9182        assert!(
9183            out.sql.contains("'Male'::\"public\".\"Gender\""),
9184            "expected the enum literal, got:\n{}",
9185            out.sql
9186        );
9187        assert!(
9188            out.sql.contains("ROW(\"_f0\"::text) AS result"),
9189            "expected the ROW composite to cast the enum field to text, got:\n{}",
9190            out.sql
9191        );
9192        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
9193            panic!("expected Object shape")
9194        };
9195        assert_eq!(pointers.len(), 1);
9196        assert!(
9197            matches!(
9198                &pointers[0],
9199                crate::query::ShapeNode::Enum { name, position: 0, enum_type }
9200                    if name == "gender" && enum_type == "public::Gender"
9201            ),
9202            "expected Enum-tagged shape, got: {:?}",
9203            pointers[0]
9204        );
9205    }
9206
9207    #[test]
9208    fn test_free_select_bare_enum_literal_casts_to_text_inside_row() {
9209        // Same bug, bare scalar form: `select default::Gender.Male;` (no
9210        // shape/object wrapper) also wraps the value in ROW() for top-level
9211        // top-level decoding.
9212        let mut schema = make_schema();
9213        schema.enums.push(crate::schema::EnumDescriptor {
9214            name: "Gender".into(),
9215            module: "default".into(),
9216            members: vec!["Male".into(), "Female".into()],
9217        });
9218        let out = compile_and_emit_with("select default::Gender.Male", &schema);
9219        assert!(
9220            out.sql.contains("ROW(v::text) AS result"),
9221            "expected ROW(v::text), got:\n{}",
9222            out.sql
9223        );
9224        assert!(
9225            matches!(
9226                &out.shape.root,
9227                crate::query::ShapeNode::Enum { enum_type, .. } if enum_type == "public::Gender"
9228            ),
9229            "expected Enum-tagged shape, got: {:?}",
9230            out.shape.root
9231        );
9232    }
9233
9234    // ── FOR UPDATE / FOR SHARE row-locking clause ──────────────────────────────
9235
9236    #[test]
9237    fn test_for_update_defaults_to_blocking() {
9238        let out = compile_and_emit("SELECT Person FOR UPDATE");
9239        assert!(out.sql.trim_end().ends_with("FOR UPDATE"), "got:\n{}", out.sql);
9240    }
9241
9242    #[test]
9243    fn test_for_update_skip_locked() {
9244        let out = compile_and_emit("SELECT Person FOR UPDATE SKIP LOCKED");
9245        assert!(
9246            out.sql.trim_end().ends_with("FOR UPDATE SKIP LOCKED"),
9247            "got:\n{}",
9248            out.sql
9249        );
9250    }
9251
9252    #[test]
9253    fn test_for_update_nowait() {
9254        let out = compile_and_emit("SELECT Person FOR UPDATE NOWAIT");
9255        assert!(out.sql.trim_end().ends_with("FOR UPDATE NOWAIT"), "got:\n{}", out.sql);
9256    }
9257
9258    #[test]
9259    fn test_for_no_key_update_skip_locked() {
9260        let out = compile_and_emit("SELECT Person FOR NO KEY UPDATE SKIP LOCKED");
9261        assert!(
9262            out.sql.trim_end().ends_with("FOR NO KEY UPDATE SKIP LOCKED"),
9263            "got:\n{}",
9264            out.sql
9265        );
9266    }
9267
9268    #[test]
9269    fn test_for_share() {
9270        let out = compile_and_emit("SELECT Person FOR SHARE");
9271        assert!(out.sql.trim_end().ends_with("FOR SHARE"), "got:\n{}", out.sql);
9272    }
9273
9274    #[test]
9275    fn test_for_key_share_nowait() {
9276        let out = compile_and_emit("SELECT Person FOR KEY SHARE NOWAIT");
9277        assert!(
9278            out.sql.trim_end().ends_with("FOR KEY SHARE NOWAIT"),
9279            "got:\n{}",
9280            out.sql
9281        );
9282    }
9283
9284    #[test]
9285    fn test_for_update_comes_after_order_by_limit_offset_in_emitted_sql() {
9286        // Postgres's own grammar places the locking clause last — confirm
9287        // the emitter matches, not just that all the pieces are present.
9288        let out = compile_and_emit("SELECT Person { name } ORDER BY .name OFFSET 1 LIMIT 5 FOR UPDATE SKIP LOCKED");
9289        let order_pos = out.sql.find("ORDER BY").unwrap();
9290        let offset_pos = out.sql.find("OFFSET").unwrap();
9291        let limit_pos = out.sql.find("LIMIT").unwrap();
9292        let for_pos = out.sql.find("FOR UPDATE").unwrap();
9293        assert!(
9294            order_pos < offset_pos && offset_pos < limit_pos && limit_pos < for_pos,
9295            "got:\n{}",
9296            out.sql
9297        );
9298    }
9299
9300    #[test]
9301    fn test_for_update_combined_with_distinct_is_rejected() {
9302        let ast = parse::parse("SELECT DISTINCT Person FOR UPDATE").expect("parse failed");
9303        let err = ir::compile(&ast, &make_schema())
9304            .err()
9305            .expect("expected a compile error");
9306        assert!(err.to_string().contains("DISTINCT"), "unexpected: {err}");
9307    }
9308
9309    #[test]
9310    fn test_for_update_combined_with_select_over_insert_is_rejected() {
9311        let ast = parse::parse("SELECT (INSERT Person { name := 'Alice' }) { name } FOR UPDATE").expect("parse failed");
9312        let err = ir::compile(&ast, &make_schema())
9313            .err()
9314            .expect("expected a compile error");
9315        assert!(err.to_string().contains("INSERT"), "unexpected: {err}");
9316    }
9317
9318    #[test]
9319    fn test_for_update_on_an_interface_type_is_rejected() {
9320        fn id_prop() -> PropertyDescriptor {
9321            PropertyDescriptor {
9322                name: "id".into(),
9323                pg_type: "uuid".into(),
9324                nullable: false,
9325                default_sql: Some("uuidv7()".into()),
9326                default_pyql: None,
9327                description: None,
9328                check_constraints: vec![],
9329                is_exclusive: true,
9330                is_pk: true,
9331                is_readonly: true,
9332                rewrites: vec![],
9333                tuple_members: None,
9334                column_type: None,
9335            }
9336        }
9337        let schema = SchemaDescriptor {
9338            types: vec![
9339                TypeDescriptor {
9340                    name: "Account".into(),
9341                    module: "default".into(),
9342                    table: "Account".into(),
9343                    abstract_: true,
9344                    materialized: true,
9345                    description: None,
9346                    parents: vec![],
9347                    interfaces: vec![],
9348                    bases: vec![],
9349                    properties: vec![id_prop()],
9350                    links: vec![],
9351                    multilinks: vec![],
9352                    computed: vec![],
9353                    constraints: vec![],
9354                    indexes: vec![],
9355                    partition: None,
9356                    vector_indexes: vec![],
9357                    search_indexes: vec![],
9358                    triggers: vec![],
9359                    junction: false,
9360                    signals: vec![],
9361                },
9362                TypeDescriptor {
9363                    name: "Individual".into(),
9364                    module: "default".into(),
9365                    table: "Individual".into(),
9366                    abstract_: false,
9367                    materialized: true,
9368                    description: None,
9369                    parents: vec![],
9370                    interfaces: vec!["default::Account".into()],
9371                    bases: vec![],
9372                    properties: vec![id_prop()],
9373                    links: vec![],
9374                    multilinks: vec![],
9375                    computed: vec![],
9376                    constraints: vec![],
9377                    indexes: vec![],
9378                    partition: None,
9379                    vector_indexes: vec![],
9380                    search_indexes: vec![],
9381                    triggers: vec![],
9382                    junction: false,
9383                    signals: vec![],
9384                },
9385            ],
9386            scalars: vec![],
9387            enums: vec![],
9388            named_tuples: vec![],
9389            globals: vec![],
9390            functions: vec![],
9391            aliases: vec![],
9392            channels: vec![],
9393            ..Default::default()
9394        };
9395        let ast = parse::parse("SELECT Account FOR UPDATE").expect("parse failed");
9396        let err = ir::compile(&ast, &schema).err().expect("expected a compile error");
9397        assert!(err.to_string().contains("interface"), "unexpected: {err}");
9398    }
9399
9400    #[test]
9401    fn test_schema_select_distinct_emits_distinct_keyword() {
9402        // Regression: compile_stmt's Distinct/Detached unwrap and
9403        // compile_select's own re-derivation of the same unwrap were merged
9404        // into a single pass-through (Phase 2 of the compile_expr merge) —
9405        // confirm `select distinct` still compiles and emits DISTINCT.
9406        let out = compile_and_emit("SELECT DISTINCT Person { name }");
9407        assert!(out.sql.contains("DISTINCT"), "expected DISTINCT in SQL:\n{}", out.sql);
9408    }
9409
9410    #[test]
9411    fn test_schema_select_detached_compiles_as_ordinary_select() {
9412        // Same merge — `detached` at the top level of a schema select is a
9413        // no-op (already independent); confirm it still compiles cleanly
9414        // instead of erroring or double-unwrapping.
9415        let out = compile_and_emit("SELECT DETACHED Person { name }");
9416        assert!(
9417            out.sql.contains("\"name\""),
9418            "expected name column in SQL:\n{}",
9419            out.sql
9420        );
9421    }
9422
9423    #[test]
9424    fn test_detached_subquery_correlates_against_the_outer_row() {
9425        // The anti-join idiom: "the Person with no other Person of the same
9426        // name that is younger". `detached` is what makes the inner `Person` a
9427        // different row from the outer one, so the bare `Person.name` inside
9428        // has to resolve to the *outer* alias. It used to be stripped as a
9429        // no-op, which compiled to `t1.name = t1.name` — a tautology that
9430        // quietly matched every row instead of erroring.
9431        let out = compile_and_emit(
9432            "SELECT Person { name } FILTER NOT EXISTS (\
9433             SELECT DETACHED Person FILTER .name = Person.name AND .age > Person.age)",
9434        );
9435        assert!(
9436            out.sql.contains("\"t1\".\"name\" = \"t0\".\"name\""),
9437            "the inner row should be compared against the outer one:\n{}",
9438            out.sql
9439        );
9440        assert!(
9441            !out.sql.contains("\"t1\".\"name\" = \"t1\".\"name\""),
9442            "self-comparison means the correlation was lost:\n{}",
9443            out.sql
9444        );
9445    }
9446
9447    #[test]
9448    fn test_an_assert_on_a_pointer_checks_its_rows() {
9449        // The pointer's own subquery is bound once and read back through the
9450        // check, rather than appearing in both — and the cast is what makes
9451        // the call legal at all, since PL/pgSQL cannot take a `record[]`.
9452        let out = compile_and_emit("SELECT Person { name, p := assert_exists(.posts { title }) }");
9453        assert!(
9454            out.sql
9455                .contains(r#"cardinality("_pylon"."assert_exists"("_a"."v"::text[]))"#),
9456            "the assert should read the aggregated rows:\n{}",
9457            out.sql
9458        );
9459        assert_eq!(
9460            out.sql.matches("array_agg(ROW(").count(),
9461            1,
9462            "the pointer's subquery should be evaluated once, not once per use:\n{}",
9463            out.sql
9464        );
9465    }
9466
9467    #[test]
9468    fn test_an_assert_on_a_scalar_stays_an_ordinary_call() {
9469        // Only an object pointer takes the wrapper; `assert_exists(.name)` is
9470        // a value expression and must keep compiling as the function call it is.
9471        let out = compile_and_emit("SELECT Person { n := assert_exists(.name) }");
9472        assert!(
9473            !out.sql.contains(r#""_a"."v""#),
9474            "a scalar assert should not be wrapped as a pointer check:\n{}",
9475            out.sql
9476        );
9477    }
9478
9479    #[test]
9480    fn test_a_backlink_tail_walks_past_the_first_step() {
9481        // `.<link[is T].multilink.prop` — the tail after a backlink was read
9482        // one arity at a time (property, or link-then-property), so anything
9483        // crossing a multi-link had no single column to read and was refused.
9484        // Counted against the migrated data the answer is 4 and 4.
9485        let out = compile_and_emit("SELECT Post FILTER any(.<posts[is Person].posts.title = $t)");
9486        assert!(
9487            out.sql.contains(r#""public"."Person.posts""#),
9488            "the tail should walk the multi-link's junction:\n{}",
9489            out.sql
9490        );
9491    }
9492
9493    #[test]
9494    fn test_an_intersection_narrows_the_rest_of_a_multilink_walk() {
9495        // `.posts[is Post].title` — the narrowed type's own table is what the
9496        // remaining steps join to, so a row of any other type drops out
9497        // without a separate check. On real data the answer is 1 and 1.
9498        let out = compile_and_emit("SELECT Person FILTER any(.posts[is Post].title = $t)");
9499        assert!(
9500            out.sql.contains(r#""public"."Post""#),
9501            "the walk should continue from the narrowed type:\n{}",
9502            out.sql
9503        );
9504    }
9505
9506    #[test]
9507    fn test_a_union_of_bindings_emits_selects_not_bare_names() {
9508        // A CTE reference normally emits just its quoted name, which works
9509        // where the caller aliases it into a FROM. As a `UNION ALL` operand it
9510        // has to be a SELECT of its own — `("a" UNION ALL "b")` is not SQL.
9511        let out = compile_and_emit_with(
9512            "WITH a := (INSERT Post { title := $t1 }), b := (INSERT Post { title := $t2 }) \
9513             SELECT (INSERT Person { name := $n, posts := (a UNION b) })",
9514            &make_schema(),
9515        );
9516        assert!(
9517            out.sql.contains(r#"(SELECT "_s"."id" FROM "a" AS "_s")"#),
9518            "each union operand should be a select:\n{}",
9519            out.sql
9520        );
9521    }
9522
9523    #[test]
9524    fn test_a_bare_narrowing_reads_as_a_type_check_and_as_an_object() {
9525        // `exists [is Sub]` is whether the row *is* one; `x := [is Sub] { … }`
9526        // is that row read as it. Both used to fall off the end of the path
9527        // walk ("invalid path step" / "empty path traversal").
9528        for query in [
9529            "SELECT Person FILTER EXISTS [is Person]",
9530            "SELECT Person { p := [is Person] { name } }",
9531        ] {
9532            let ast = parse::parse(query).unwrap_or_else(|e| panic!("{query}: {e}"));
9533            assert!(ir::compile(&ast, &make_schema()).is_ok(), "{query} should compile");
9534        }
9535    }
9536
9537    #[test]
9538    fn test_a_multilink_takes_a_set_wrapped_in_a_select() {
9539        // `translations := (select { a, b })` — a select with no clauses of its
9540        // own is the expression it wraps. Left wrapped, a free row source (a
9541        // set literal, a union) reached the generic subquery arm and was
9542        // refused, while the identical unwrapped `{ a, b }` compiled.
9543        let out = compile_and_emit_with(
9544            "WITH a := (INSERT Post { title := $t1 }), b := (INSERT Post { title := $t2 }) \
9545             SELECT (INSERT Person { name := $n, posts := (SELECT { a, b }) })",
9546            &make_schema(),
9547        );
9548        assert!(
9549            out.sql.contains(r#""public"."Person.posts""#),
9550            "both targets should reach the junction:\n{}",
9551            out.sql
9552        );
9553    }
9554
9555    #[test]
9556    fn test_an_aggregate_over_a_subselected_path() {
9557        // `array_agg((select Person.name))` — the sub-select walks to a scalar,
9558        // which has no type name to be a schema select's subject. It means the
9559        // same as `array_agg(Person.name)` with the select's own modifiers on
9560        // the walk, which the path-select builder already handles. Checked
9561        // element for element against the unwrapped spelling.
9562        let out = compile_and_emit("SELECT array_agg((SELECT Person.name))");
9563        assert!(
9564            out.sql.contains("array_agg") && out.sql.contains(r#""public"."Person""#),
9565            "the aggregate should wrap the walk's column:\n{}",
9566            out.sql
9567        );
9568    }
9569
9570    #[test]
9571    fn test_a_distinct_aggregate_argument_reaches_the_aggregate() {
9572        // `array_agg(distinct x)` used to emit `array_agg(x)`: the unary
9573        // `distinct` emits its operand unchanged, because it is normally
9574        // applied where the set is built — which an aggregate argument is not.
9575        // Both spellings mean the same thing, and both have to return one
9576        // element where the old output returned two.
9577        for query in [
9578            "SELECT array_agg(DISTINCT Person.name)",
9579            "SELECT array_agg((SELECT DISTINCT Person.name))",
9580        ] {
9581            let out = compile_and_emit(query);
9582            assert!(
9583                out.sql.contains("array_agg(DISTINCT "),
9584                "{query} should deduplicate inside the aggregate:\n{}",
9585                out.sql
9586            );
9587        }
9588    }
9589
9590    #[test]
9591    fn test_is_not_negates_the_type_check() {
9592        // `x IS NOT T` is `Expr IS NOT TypeExpr` — the negation of the check,
9593        // not a comparison against some type `not T`. Counted against the
9594        // migrated data it came out as 3 of 3.
9595        let positive = compile_and_emit("SELECT Person FILTER Person IS Person");
9596        let negative = compile_and_emit("SELECT Person FILTER Person IS NOT Person");
9597        assert!(
9598            negative.sql.contains("NOT") && !positive.sql.contains("NOT"),
9599            "the negation should reach the SQL:\npositive:\n{}\nnegative:\n{}",
9600            positive.sql,
9601            negative.sql
9602        );
9603    }
9604
9605    #[test]
9606    fn test_a_cast_takes_a_prefix_operator_as_its_operand() {
9607        // `<bool>exists x` is legal — a cast takes a whole `Expr` at CAST
9608        // precedence. Pylon's cast went straight to a postfix expression, so
9609        // any prefix operator after one failed to parse.
9610        for query in ["SELECT <bool>EXISTS (SELECT Person LIMIT 1)", "SELECT <int64>-1"] {
9611            let ast = parse::parse(query).unwrap_or_else(|e| panic!("{query} should parse: {e}"));
9612            assert!(ir::compile(&ast, &make_schema()).is_ok(), "{query} should compile");
9613        }
9614    }
9615
9616    #[test]
9617    fn test_a_bare_multilink_reads_as_a_set() {
9618        // `p in .posts` — a multi-link read as a value, not traversed through.
9619        // The single-step resolver knew properties, links and computeds but
9620        // never multi-links, so the pointer reported itself as unknown while
9621        // the suggester, which does know them, offered the name straight back.
9622        let out =
9623            compile_and_emit("WITH p := (SELECT DETACHED Post LIMIT 1) SELECT Person { name } FILTER p IN .posts");
9624        assert!(
9625            out.sql.contains(r#""public"."Person.posts""#),
9626            "the membership test should read the link's junction:\n{}",
9627            out.sql
9628        );
9629    }
9630
9631    #[test]
9632    fn test_a_select_reads_its_own_shape_computed_in_its_clauses() {
9633        // `select T { x := … } order by .x` — a shape's computeds are in scope
9634        // for the select's own clauses, but not for a sibling pointer, so the
9635        // scope stops at the clauses.
9636        let out = compile_and_emit("SELECT Person { n := .name } ORDER BY .n ASC");
9637        assert!(
9638            out.sql.contains("ORDER BY"),
9639            "the order by should compile against the declared pointer:\n{}",
9640            out.sql
9641        );
9642    }
9643
9644    #[test]
9645    fn test_a_declared_pointer_can_be_walked_through() {
9646        // `p { boss := … }` read back as `.boss.name` — the walk builder
9647        // resolved a *schema* computed as a step but not one a shape in scope
9648        // declared, so anything past the first step reported it as unknown.
9649        let out = compile_and_emit_with(
9650            "WITH p := (SELECT Person { boss := .company } LIMIT 1) SELECT p { name, boss_name := .boss.name }",
9651            &make_schema(),
9652        );
9653        assert!(
9654            out.sql.contains(r#""public"."Company""#),
9655            "the walk should reach the declared pointer's own target table:\n{}",
9656            out.sql
9657        );
9658    }
9659
9660    #[test]
9661    fn test_a_replaced_subject_shape_stays_in_scope() {
9662        // `(select .company { n := .name }) { n }` — a shape at the point of
9663        // use replaces the subject's own, so what that one declared has to
9664        // stay readable or the replacement cannot see it. Before, this was an
9665        // outright compile error.
9666        let out = compile_and_emit("SELECT Person { c := (SELECT .company { n := .name }) { n } }");
9667        assert!(
9668            out.sql.contains(r#"'default::Company'::text"#) && out.sql.contains(r#""t2"."name""#),
9669            "`n` should resolve to the company's own column:\n{}",
9670            out.sql
9671        );
9672    }
9673
9674    #[test]
9675    fn test_a_sibling_shape_pointer_is_not_in_scope() {
9676        // One shape pointer reading another is refused, which is what keeps
9677        // four graph queries reported as the application bugs they are.
9678        let schema = make_schema();
9679        let ast = parse::parse("SELECT Person { n := .name, copy := .n }").unwrap();
9680        assert!(ir::compile(&ast, &schema).is_err());
9681    }
9682
9683    #[test]
9684    fn test_shape_over_a_with_binding_reads_the_binding() {
9685        // `metadata := metadata { … }` — a binding names a row source, so a
9686        // shape on it projects that source. The pointer route only took
9687        // relative paths, so this fell through to the expression route and was
9688        // rejected as "shapes are not valid in expression context".
9689        let out = compile_and_emit("WITH c := (SELECT Company LIMIT 1) SELECT Person { name, employer := c { name } }");
9690        assert!(
9691            out.sql.contains("\"c\""),
9692            "the pointer should read the binding's CTE:\n{}",
9693            out.sql
9694        );
9695    }
9696
9697    #[test]
9698    fn test_shape_over_a_bare_type_name_is_still_rejected() {
9699        // Unlike a binding, a type name in an enclosing scope is a factored
9700        // prefix, so a shape on one does not mean "every row of the table".
9701        let schema = make_schema();
9702        let ast = parse::parse("SELECT Person { name, other := Company { name } }").unwrap();
9703        assert!(ir::compile(&ast, &schema).is_err());
9704    }
9705
9706    #[test]
9707    fn test_nested_shape_reads_a_prefix_bound_by_the_outer_select() {
9708        // `Person.name` inside the `posts` shape names a type the enclosing
9709        // select binds, so it reads the outer row — not every Person. The
9710        // prefix used to be rejected outright because only the *innermost*
9711        // subject was checked.
9712        let out = compile_and_emit("SELECT Person { name, posts: { title, who := Person.name } }");
9713        assert!(
9714            out.sql.contains(r#""t0"."name""#),
9715            "the nested computed should read the outer Person alias:\n{}",
9716            out.sql
9717        );
9718        assert!(
9719            !out.sql.contains(r#"FROM "public"."Person" "t2""#),
9720            "the prefix should not open a second Person source:\n{}",
9721            out.sql
9722        );
9723    }
9724
9725    #[test]
9726    fn test_prefix_naming_a_type_out_of_scope_is_rejected() {
9727        // Nothing binds `Company` here, so the prefix stays an absolute path —
9728        // Pylon must not silently read the whole table.
9729        let schema = make_schema();
9730        let ast = parse::parse("SELECT Person { name, who := Company.name }").unwrap();
9731        assert!(ir::compile(&ast, &schema).is_err());
9732    }
9733
9734    #[test]
9735    fn test_free_select_tuple() {
9736        let schema = make_schema();
9737        let ast = parse::parse("SELECT (1, 2)").unwrap();
9738        let ir = ir::compile(&ast, &schema).unwrap();
9739        let out = emit(&ir);
9740        assert!(out.sql.contains("1"));
9741        assert!(out.sql.contains("2"));
9742        assert!(out.sql.contains("AS result"));
9743        assert!(matches!(out.shape.root, crate::query::ShapeNode::Tuple { .. }));
9744    }
9745
9746    #[test]
9747    fn test_free_select_scalar_literal() {
9748        let schema = make_schema();
9749        let ast = parse::parse("SELECT 'hello'").unwrap();
9750        let ir = ir::compile(&ast, &schema).unwrap();
9751        let out = emit(&ir);
9752        assert!(out.sql.contains("SELECT 'hello' AS v"));
9753        assert!(out.sql.contains("ROW(v) AS result"));
9754        assert!(matches!(out.shape.root, crate::query::ShapeNode::Scalar { .. }));
9755    }
9756
9757    #[test]
9758    fn test_float_literal_casts_to_float8() {
9759        // Regression: an un-cast float literal like `1.0` is untyped in
9760        // Postgres and defaults to `numeric`, not `float64` — silently
9761        // changing PyQL's semantics (only an explicit `<decimal>`/`123n`
9762        // literal should ever produce a real decimal) and breaking decode
9763        // inside ROW() composites (numeric's wire format differs from
9764        // float8's). An explicit ::float8 cast keeps it a real float.
9765        let out = compile_and_emit("SELECT 1.0");
9766        assert!(
9767            out.sql.contains("(1.0::float8)"),
9768            "expected explicit float8 cast, got:\n{}",
9769            out.sql
9770        );
9771    }
9772
9773    #[test]
9774    fn test_free_select_array_literal() {
9775        let schema = make_schema();
9776        let ast = parse::parse("SELECT [1, 2, 3]").unwrap();
9777        let ir = ir::compile(&ast, &schema).unwrap();
9778        let out = emit(&ir);
9779        assert!(out.sql.contains("SELECT ARRAY[1, 2, 3] AS v"), "{}", out.sql);
9780        assert!(out.sql.contains("SELECT v AS result, v FROM"), "{}", out.sql);
9781        assert!(matches!(out.shape.root, crate::query::ShapeNode::RawScalar));
9782    }
9783
9784    #[test]
9785    fn test_select_scalars() {
9786        let out = compile_and_emit("SELECT Person { name, age }");
9787        assert!(out.sql.contains("'default::Person'::text"));
9788        assert!(out.sql.contains("\"name\"::text"));
9789        assert!(out.sql.contains("\"age\"::int8"));
9790        assert!(out.sql.contains("FROM \"public\".\"Person\""));
9791        assert!(out.sql.contains(") AS result"));
9792    }
9793
9794    #[test]
9795    fn test_select_filter_param() {
9796        let out = compile_and_emit("SELECT Person { name } FILTER .name = $name");
9797        assert!(out.sql.contains("WHERE"));
9798        assert!(out.sql.contains("$1"));
9799    }
9800
9801    #[test]
9802    fn test_filter_in_set_literal_compiles_to_any_array() {
9803        let out = compile_and_emit("SELECT Person { name } FILTER .name IN {'Carol'}");
9804        assert!(out.sql.contains("= ANY(ARRAY['Carol'])"), "got:\n{}", out.sql);
9805    }
9806
9807    #[test]
9808    fn test_filter_in_multi_element_set_literal_compiles_to_any_array() {
9809        let out = compile_and_emit("SELECT Person { name } FILTER .name IN {'Carol', 'Bob'}");
9810        assert!(out.sql.contains("= ANY(ARRAY['Carol', 'Bob'])"), "got:\n{}", out.sql);
9811    }
9812
9813    /// `x IN std::array_unpack(arr)` is the documented way to test membership
9814    /// of an array parameter. It used to emit `= ANY(unnest(arr))` — a
9815    /// set-returning function inside `WHERE`, which PostgreSQL rejects at
9816    /// execution time (`set-returning functions are not allowed in WHERE`)
9817    /// even though both the PyQL and the SQL parse cleanly. `ANY` takes the
9818    /// array directly, so the `unnest` has to go.
9819    #[test]
9820    fn test_filter_in_array_unpack_passes_the_array_straight_to_any() {
9821        let out = compile_and_emit("SELECT Person { name } FILTER .name IN std::array_unpack(<array<str>>$names)");
9822        assert!(
9823            !out.sql.contains("ANY(unnest("),
9824            "unnest inside ANY is invalid in WHERE:\n{}",
9825            out.sql
9826        );
9827        assert!(out.sql.contains("= ANY("), "got:\n{}", out.sql);
9828    }
9829
9830    #[test]
9831    fn test_filter_not_in_array_unpack_passes_the_array_straight_to_all() {
9832        let out = compile_and_emit("SELECT Person { name } FILTER .name NOT IN std::array_unpack(<array<str>>$names)");
9833        assert!(
9834            !out.sql.contains("ALL(unnest("),
9835            "unnest inside ALL is invalid in WHERE:\n{}",
9836            out.sql
9837        );
9838        assert!(out.sql.contains("<> ALL("), "got:\n{}", out.sql);
9839    }
9840
9841    /// `all(array_unpack($t) in .posts.title)` quantifies over the unpacked
9842    /// array, not over `.posts`. Read as a condition on one `.posts` element
9843    /// it would both ask the opposite question and leave the `unnest()` in a
9844    /// `WHERE`, which PostgreSQL rejects at execution time.
9845    #[test]
9846    fn test_all_over_unpacked_array_aggregates_the_array_not_the_multilink() {
9847        let out = compile_and_emit(
9848            "SELECT Person { name } FILTER all(std::array_unpack(<array<str>>$titles) IN .posts.title)",
9849        );
9850        assert!(out.sql.contains("bool_and("), "got:\n{}", out.sql);
9851        assert!(out.sql.contains("_unnested"), "got:\n{}", out.sql);
9852    }
9853
9854    /// The mirror of the above: the multilink is what is quantified over and
9855    /// the array is only the haystack, so `IN` still folds into `= ANY(array)`
9856    /// and nothing unnests.
9857    #[test]
9858    fn test_any_over_a_multilink_against_an_unpacked_array_still_folds_into_any() {
9859        let out = compile_and_emit(
9860            "SELECT Person { name } FILTER any(.posts.title IN std::array_unpack(<array<str>>$titles))",
9861        );
9862        assert!(!out.sql.contains("unnest("), "got:\n{}", out.sql);
9863        assert!(out.sql.contains("= ANY("), "got:\n{}", out.sql);
9864    }
9865
9866    #[test]
9867    fn test_any_over_unpacked_array_aggregates_the_array_not_the_multilink() {
9868        let out = compile_and_emit(
9869            "SELECT Person { name } FILTER any(std::array_unpack(<array<str>>$titles) IN .posts.title)",
9870        );
9871        assert!(out.sql.contains("bool_or("), "got:\n{}", out.sql);
9872        assert!(out.sql.contains("_unnested"), "got:\n{}", out.sql);
9873    }
9874
9875    /// A schema with a `text[]` property, which `make_schema` has none of.
9876    fn schema_with_array_property() -> SchemaDescriptor {
9877        let mut schema = make_schema();
9878        schema.types[0].properties.push(crate::schema::PropertyDescriptor {
9879            name: "perms".into(),
9880            pg_type: "text[]".into(),
9881            nullable: true,
9882            default_sql: None,
9883            default_pyql: None,
9884            description: None,
9885            check_constraints: vec![],
9886            is_exclusive: false,
9887            is_pk: false,
9888            is_readonly: false,
9889            rewrites: vec![],
9890            tuple_members: None,
9891            column_type: None,
9892        });
9893        schema
9894    }
9895
9896    /// `array_agg(array_unpack(teams.perms))` flattens every row's array into
9897    /// one. Compiled as written it emitted `array_agg(unnest(...))`, which
9898    /// PostgreSQL rejects at execution time ("aggregate function calls cannot
9899    /// contain set-returning function calls"), so the unpacking has to happen
9900    /// in a row source the aggregate reads from outside.
9901    #[test]
9902    fn test_aggregate_over_an_unpacked_walk_unnests_in_a_row_source() {
9903        let out = compile_and_emit_with(
9904            "WITH teams := (SELECT Person { perms } FILTER .age > 1) SELECT std::array_agg(std::array_unpack(teams.perms))",
9905            &schema_with_array_property(),
9906        );
9907        assert!(!out.sql.contains("array_agg(unnest("), "got:\n{}", out.sql);
9908        assert!(
9909            out.sql.contains(
9910                r#"coalesce(array_agg("_s"."v"), '{}') FROM unnest(ARRAY(SELECT unnest("t1"."perms") FROM "teams" AS "t1"#
9911            ),
9912            "got:\n{}",
9913            out.sql
9914        );
9915    }
9916
9917    #[test]
9918    fn test_aggregate_over_an_unpacked_type_rooted_path_unnests_in_a_row_source() {
9919        let out = compile_and_emit_with(
9920            "SELECT std::count(std::array_unpack(Person.perms))",
9921            &schema_with_array_property(),
9922        );
9923        assert!(!out.sql.contains("count(unnest("), "got:\n{}", out.sql);
9924        assert!(
9925            out.sql
9926                .contains(r#"FROM unnest(ARRAY(SELECT unnest("t0"."perms") FROM "public"."Person" AS "t0"#),
9927            "got:\n{}",
9928            out.sql
9929        );
9930    }
9931
9932    /// One array standing on its own — a parameter, or one row's property —
9933    /// is already the row source, so it needs no walk gathered around it.
9934    #[test]
9935    fn test_aggregate_over_an_unpacked_parameter_unnests_the_array_itself() {
9936        let out = compile_and_emit("SELECT std::array_agg(std::array_unpack(<array<str>>$names))");
9937        assert!(!out.sql.contains("array_agg(unnest("), "got:\n{}", out.sql);
9938        assert!(
9939            out.sql.contains(r#"FROM unnest(($1)::text[]) AS "_s"("v")"#),
9940            "got:\n{}",
9941            out.sql
9942        );
9943    }
9944
9945    #[test]
9946    fn test_aggregate_over_an_unpacked_property_unnests_that_row_s_array() {
9947        let out = compile_and_emit_with(
9948            "SELECT Person { n := std::array_agg(std::array_unpack(.perms)) }",
9949            &schema_with_array_property(),
9950        );
9951        assert!(!out.sql.contains("array_agg(unnest("), "got:\n{}", out.sql);
9952        assert!(
9953            out.sql.contains(r#"FROM unnest("t0"."perms") AS "_s"("v")"#),
9954            "got:\n{}",
9955            out.sql
9956        );
9957    }
9958
9959    /// `array_agg` over no rows is `[]` in PyQL and NULL in SQL, which reaches
9960    /// a caller as a missing value where a list was promised.
9961    #[test]
9962    fn test_array_agg_over_nothing_is_an_empty_array_not_null() {
9963        for query in [
9964            "SELECT std::array_agg(Person.name)",
9965            "SELECT std::array_agg((SELECT Person).name)",
9966        ] {
9967            let out = compile_and_emit(query);
9968            assert!(
9969                out.sql.contains("coalesce(") && out.sql.contains("'{}'"),
9970                "{query} got:\n{}",
9971                out.sql
9972            );
9973        }
9974    }
9975
9976    /// The `coalesce` that empty array wears must not hide what the binding
9977    /// holds: `contains(ids, .id)` picks its overload off the binding's type,
9978    /// and an array read as text silently becomes a substring search.
9979    #[test]
9980    fn test_an_array_agg_binding_stays_an_array_through_its_coalesce() {
9981        let out = compile_and_emit(
9982            "WITH ids := std::array_agg(Person.id) SELECT Person { name } FILTER std::contains(ids, .id)",
9983        );
9984        assert!(
9985            out.sql.contains("@> ARRAY["),
9986            "expected the array overload, got:\n{}",
9987            out.sql
9988        );
9989        assert!(
9990            !out.sql.contains("strpos("),
9991            "the string overload must not win, got:\n{}",
9992            out.sql
9993        );
9994    }
9995
9996    /// An aggregate over a walk that is *not* unpacked keeps its own row
9997    /// source — the rewrite above must not reach it.
9998    #[test]
9999    fn test_aggregate_over_a_plain_walk_keeps_its_row_source() {
10000        let out = compile_and_emit("SELECT std::array_agg(Person.name)");
10001        assert!(
10002            out.sql.contains(r#"array_agg("t0"."name")"#) && out.sql.contains(r#"FROM "public"."Person""#),
10003            "got:\n{}",
10004            out.sql
10005        );
10006    }
10007
10008    /// The order check is a safety net for every emitter that assembles a
10009    /// WITH clause, so it has to actually catch one.
10010    #[test]
10011    fn test_the_cte_order_check_catches_a_forward_reference() {
10012        let bad = "WITH \"b\" AS (\n SELECT * FROM \"a\"\n),\n\"a\" AS (\n SELECT 1\n)\nSELECT 1";
10013        assert!(
10014            super::forward_cte_reference(bad).is_some(),
10015            "expected a forward reference"
10016        );
10017        let good = "WITH \"a\" AS (\n SELECT 1\n),\n\"b\" AS (\n SELECT * FROM \"a\"\n)\nSELECT 1";
10018        assert_eq!(super::forward_cte_reference(good), None, "ordered CTEs must pass");
10019    }
10020
10021    /// `.status IN <Enum>array_unpack($1)` — the cast sits between `IN` and
10022    /// the unpack, and used to hide it, leaving a set-returning function in a
10023    /// `WHERE` that PostgreSQL rejects at execution time.
10024    #[test]
10025    fn test_a_cast_unpacked_array_still_folds_into_any() {
10026        let out = compile_and_emit("SELECT Person { name } FILTER .name IN <str>std::array_unpack(<array<str>>$names)");
10027        assert!(
10028            !out.sql.contains("unnest("),
10029            "the unpack must fold into ANY, got:\n{}",
10030            out.sql
10031        );
10032        assert!(out.sql.contains("= ANY("), "got:\n{}", out.sql);
10033    }
10034
10035    /// A bare `array_unpack` outside `IN` still has to unnest — the unwrap is
10036    /// specific to `ANY`/`ALL`, which take an array rather than a set.
10037    #[test]
10038    fn test_array_unpack_outside_in_still_unnests() {
10039        let out = compile_and_emit("SELECT std::array_unpack(<array<str>>$names)");
10040        assert!(out.sql.contains("unnest("), "got:\n{}", out.sql);
10041    }
10042
10043    #[test]
10044    fn test_filter_not_in_set_literal_compiles_to_all_array() {
10045        let out = compile_and_emit("SELECT Person { name } FILTER .name NOT IN {'Carol'}");
10046        assert!(out.sql.contains("<> ALL(ARRAY['Carol'])"), "got:\n{}", out.sql);
10047    }
10048
10049    #[test]
10050    fn test_deep_splat_fetches_single_link_target_properties_not_just_id() {
10051        // `**` on a single link must expand to the target type's own
10052        // properties (one level of `*`, not `**` again — recursing into
10053        // the target's own links would never terminate for a cyclic link
10054        // graph), not just an implicit `{ id }`.
10055        let out = compile_and_emit("SELECT Person { ** }");
10056        assert!(
10057            out.sql.contains("\"name\""),
10058            "expected Company.name pulled in via .company's ** expansion, got:\n{}",
10059            out.sql
10060        );
10061    }
10062
10063    #[test]
10064    fn test_deep_splat_fetches_multilink_target_properties_not_just_id() {
10065        let out = compile_and_emit("SELECT Person { ** }");
10066        assert!(
10067            out.sql.contains("\"title\""),
10068            "expected Post.title pulled in via .posts' ** expansion, got:\n{}",
10069            out.sql
10070        );
10071    }
10072
10073    #[test]
10074    fn test_schema_type_cast_select() {
10075        let out = compile_and_emit("SELECT <default::Person><uuid>'019ef1bb-0d42-7a9f-8f6b-b38d028a49ba'");
10076        assert!(out.sql.contains("FROM \"public\".\"Person\""));
10077        assert!(out.sql.contains("WHERE"));
10078        assert!(out.sql.contains("'019ef1bb-0d42-7a9f-8f6b-b38d028a49ba'"));
10079    }
10080
10081    #[test]
10082    fn test_select_single_link() {
10083        let out = compile_and_emit("SELECT Person { name, company { name } }");
10084        assert!(out.sql.contains("'default::Company'::text"));
10085        assert!(out.sql.contains("FROM \"public\".\"Company\""));
10086        // join condition: parent FK column = target PK
10087        assert!(out.sql.contains("\"company_id\" = "));
10088    }
10089
10090    #[test]
10091    fn test_select_multi_link() {
10092        let out = compile_and_emit("SELECT Person { name, posts { title } }");
10093        assert!(out.sql.contains("array_agg(ROW("));
10094        assert!(out.sql.contains("ARRAY[]::record[]"));
10095        assert!(out.sql.contains("'default::Post'::text"));
10096        assert!(out.sql.contains("\"Person.posts\""));
10097    }
10098
10099    fn compile_err(query: &str) -> String {
10100        let schema = make_schema();
10101        let ast = parse::parse(query).expect("parse failed");
10102        match ir::compile(&ast, &schema) {
10103            Ok(_) => panic!("expected a compile error"),
10104            Err(e) => format!("{e}"),
10105        }
10106    }
10107
10108    #[test]
10109    fn test_multi_link_limit_cuts_rows_before_aggregating() {
10110        let out = compile_and_emit("SELECT Person { posts: { title } filter .title = 'x' limit 1 }");
10111        // The LIMIT has to sit on the row source, not next to array_agg —
10112        // the aggregate has already collapsed the rows by then.
10113        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10114        assert!(out.sql.contains("AS \"__lim\""), "{}", out.sql);
10115        let agg = out.sql.find("array_agg").unwrap();
10116        let limit = out.sql.find("LIMIT 1").unwrap();
10117        assert!(agg < limit, "{}", out.sql);
10118        assert!(out.sql.contains("\"t1\".\"title\"::text AS \"c1\""), "{}", out.sql);
10119    }
10120
10121    #[test]
10122    fn test_multi_link_order_by_with_limit_orders_the_aggregate_too() {
10123        let out = compile_and_emit("SELECT Person { posts: { title } order by .title desc offset 1 limit 2 }");
10124        // The sort key rides along as its own column so the array keeps the
10125        // derived table's order rather than relying on aggregation order.
10126        assert!(out.sql.contains("\"t1\".\"title\" AS \"s0\""), "{}", out.sql);
10127        assert!(
10128            out.sql.contains("ORDER BY \"__lim\".\"s0\" DESC NULLS LAST"),
10129            "{}",
10130            out.sql
10131        );
10132        assert!(out.sql.contains("OFFSET 1"), "{}", out.sql);
10133        assert!(out.sql.contains("LIMIT 2"), "{}", out.sql);
10134    }
10135
10136    #[test]
10137    fn test_multi_link_without_limit_keeps_the_flat_aggregate() {
10138        let out = compile_and_emit("SELECT Person { posts: { title } filter .title = 'x' }");
10139        assert!(!out.sql.contains("__lim"), "{}", out.sql);
10140        assert!(out.sql.contains("array_agg(ROW("), "{}", out.sql);
10141    }
10142
10143    #[test]
10144    fn test_sub_select_as_computed_pointer_is_a_link_pointer() {
10145        let out = compile_and_emit("SELECT Person { recent := (select .posts filter .title = 'x' limit 1) { title } }");
10146        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10147            panic!()
10148        };
10149        // Named after the pointer, not after the link it selects from, and
10150        // one object: `limit 1` makes the sub-select at most one row.
10151        let ShapeNode::Object {
10152            name,
10153            pointers: elem_pointers,
10154            ..
10155        } = &pointers[2]
10156        else {
10157            panic!("{:?}", pointers[2])
10158        };
10159        assert_eq!(name, "recent");
10160        assert!(matches!(&elem_pointers[1], ShapeNode::Scalar { name, .. } if name == "title"));
10161        assert!(out.sql.contains("\"jt\".source = \"t0\".id"), "{}", out.sql);
10162        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10163    }
10164
10165    #[test]
10166    fn test_sub_select_shape_inside_the_parens_is_equivalent() {
10167        let outer = compile_and_emit("SELECT Person { recent := (select .posts limit 1) { title } }");
10168        let inner = compile_and_emit("SELECT Person { recent := (select .posts { title } limit 1) }");
10169        assert_eq!(outer.sql, inner.sql);
10170    }
10171
10172    #[test]
10173    fn test_sub_select_field_access_is_a_correlated_scalar_subquery() {
10174        let out = compile_and_emit("SELECT Person { t := (select .posts filter .title = 'x' limit 1).title }");
10175        // Rooted at the enclosing type and correlated back to its row, so
10176        // the subquery only ever sees this Person's posts.
10177        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
10178        assert!(out.sql.contains("\"t2\".\"title\""), "{}", out.sql);
10179        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10180        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10181            panic!()
10182        };
10183        assert!(matches!(&pointers[2], ShapeNode::Scalar { name, .. } if name == "t"));
10184    }
10185
10186    #[test]
10187    fn test_sub_select_field_access_over_a_single_link() {
10188        let out = compile_and_emit("SELECT Person { c := (select .company).name }");
10189        assert!(out.sql.contains("\"t1\".\"company_id\" = \"t2\".\"id\""), "{}", out.sql);
10190        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
10191    }
10192
10193    #[test]
10194    fn test_sub_select_field_access_in_a_filter() {
10195        let out = compile_and_emit("SELECT Person { name } filter (select .posts limit 1).title = 'x'");
10196        assert!(out.sql.contains("WHERE ((SELECT \"t2\".\"title\""), "{}", out.sql);
10197        assert!(out.sql.contains("LIMIT 1) = 'x')"), "{}", out.sql);
10198    }
10199
10200    #[test]
10201    fn test_sub_select_over_an_absolute_path_is_not_correlated() {
10202        let out = compile_and_emit("SELECT Person { n := (select Company.name limit 1) }");
10203        assert!(out.sql.contains("FROM \"public\".\"Company\""), "{}", out.sql);
10204        assert!(!out.sql.contains("= \"t0\".\"id\""), "{}", out.sql);
10205    }
10206
10207    #[test]
10208    fn test_backlink_sub_select_carries_its_modifiers() {
10209        let out = compile_and_emit("SELECT Post { authors := (select .<posts[is Person] { name } limit 2) }");
10210        assert!(out.sql.contains("LIMIT 2"), "{}", out.sql);
10211        assert!(out.sql.contains("AS \"__lim\""), "{}", out.sql);
10212        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10213            panic!()
10214        };
10215        assert!(matches!(&pointers[1], ShapeNode::Array { name, .. } if name == "authors"));
10216    }
10217
10218    #[test]
10219    fn test_with_bound_scalar_is_typed_for_overload_resolution() {
10220        // A `with`-bound value had no inferred type, so a call over one fell
10221        // back to the first registered overload and needed an explicit cast.
10222        let out = compile_and_emit("WITH xs := (select Person.name) SELECT contains(xs, 'a')");
10223        assert!(
10224            out.sql.contains("strpos((SELECT \"v\" FROM \"xs\"), 'a')"),
10225            "{}",
10226            out.sql
10227        );
10228    }
10229
10230    /// `Person` with a translation-style computed model: one computed
10231    /// resolves a filtered subset of a multi-link, others read through it.
10232    fn make_schema_with_computed_links() -> SchemaDescriptor {
10233        use crate::schema::ComputedDescriptor;
10234        let mut schema = make_schema();
10235        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
10236        person.computed = vec![
10237            ComputedDescriptor {
10238                name: "published".into(),
10239                expression: "(select .posts filter .title != '')".into(),
10240                return_type: None,
10241                link_target: None,
10242                link_multi: false,
10243            },
10244            ComputedDescriptor {
10245                name: "plain".into(),
10246                expression: ".posts".into(),
10247                return_type: None,
10248                link_target: None,
10249                link_multi: false,
10250            },
10251            ComputedDescriptor {
10252                name: "featured".into(),
10253                expression: "(select .published filter .title != 'draft')".into(),
10254                return_type: None,
10255                link_target: None,
10256                link_multi: false,
10257            },
10258            ComputedDescriptor {
10259                name: "looper".into(),
10260                expression: "(select .looper)".into(),
10261                return_type: None,
10262                link_target: None,
10263                link_multi: false,
10264            },
10265            ComputedDescriptor {
10266                name: "capped".into(),
10267                expression: "(select .posts limit 1)".into(),
10268                return_type: None,
10269                link_target: None,
10270                link_multi: false,
10271            },
10272        ];
10273        schema
10274    }
10275
10276    /// `Person.recent` computed from an object-returning function call, the
10277    /// shape jaldis's translation model uses.
10278    fn make_schema_with_object_fn_computed() -> SchemaDescriptor {
10279        use crate::schema::{ComputedDescriptor, FunctionDescriptor, FunctionParamDescriptor};
10280        let mut schema = make_schema();
10281        schema.functions = vec![FunctionDescriptor {
10282            name: "latest".into(),
10283            module: "default".into(),
10284            params: vec![FunctionParamDescriptor {
10285                name: "owner".into(),
10286                pg_type: "uuid".into(),
10287            }],
10288            return_pg_type: "default::Post".into(),
10289            return_is_object: true,
10290            return_is_set: true,
10291            return_is_polymorphic: false,
10292            volatility: "stable".into(),
10293            body: String::new(),
10294        }];
10295        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
10296        person.computed = vec![
10297            ComputedDescriptor {
10298                name: "recent".into(),
10299                expression: "latest(.id)".into(),
10300                return_type: None,
10301                link_target: None,
10302                link_multi: false,
10303            },
10304            ComputedDescriptor {
10305                name: "vetted".into(),
10306                expression: "(select latest(.id) filter .title != '')".into(),
10307                return_type: None,
10308                link_target: None,
10309                link_multi: false,
10310            },
10311        ];
10312        schema
10313    }
10314
10315    #[test]
10316    fn test_path_traverses_through_a_function_backed_computed() {
10317        // `recent := latest(.id)` has no path to splice in, so the call
10318        // itself becomes the next row source — LATERAL, because its
10319        // arguments read the alias the traversal has reached.
10320        let schema = make_schema_with_object_fn_computed();
10321        let out = compile_and_emit_with("SELECT Person { t := .recent.title }", &schema);
10322        assert!(
10323            out.sql
10324                .contains("CROSS JOIN LATERAL \"public\".\"latest\"(\"t1\".\"id\") AS \"t2\""),
10325            "{}",
10326            out.sql
10327        );
10328        // The function returns a set, so the traversal is multi-valued.
10329        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"title\""), "{}", out.sql);
10330    }
10331
10332    #[test]
10333    fn test_sub_select_over_a_function_backed_computed() {
10334        // The jaldis translation model, minimal: a function-backed computed
10335        // resolves the set, a scalar computed picks an attribute out of it.
10336        let schema = make_schema_with_object_fn_computed();
10337        let out = compile_and_emit_with(
10338            "SELECT Person { t := (select .recent filter .title = 'x' limit 1).title }",
10339            &schema,
10340        );
10341        assert!(
10342            out.sql.contains("CROSS JOIN LATERAL \"public\".\"latest\""),
10343            "{}",
10344            out.sql
10345        );
10346        assert!(out.sql.contains("\"t2\".\"title\" = 'x'"), "{}", out.sql);
10347        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10348        assert!(!out.sql.contains("ARRAY("), "{}", out.sql);
10349    }
10350
10351    #[test]
10352    fn test_function_backed_computed_carries_its_own_filter() {
10353        let schema = make_schema_with_object_fn_computed();
10354        let out = compile_and_emit_with(
10355            "SELECT Person { t := (select .vetted filter .title = 'x' limit 1).title }",
10356            &schema,
10357        );
10358        assert!(
10359            out.sql
10360                .contains("(\"t2\".\"title\" = 'x') AND (\"t2\".\"title\" <> '')"),
10361            "{}",
10362            out.sql
10363        );
10364    }
10365
10366    #[test]
10367    fn test_function_backed_computed_checks_its_argument_count() {
10368        use crate::schema::ComputedDescriptor;
10369        let mut schema = make_schema_with_object_fn_computed();
10370        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
10371        person.computed = vec![ComputedDescriptor {
10372            name: "recent".into(),
10373            expression: "latest()".into(),
10374            return_type: None,
10375            link_target: None,
10376            link_multi: false,
10377        }];
10378        let ast = parse::parse("SELECT Person { t := .recent.title }").unwrap();
10379        let err = match ir::compile(&ast, &schema) {
10380            Ok(_) => panic!("expected a compile error"),
10381            Err(e) => format!("{e}"),
10382        };
10383        assert!(err.contains("expects 1 argument(s), got 0"), "{err}");
10384    }
10385
10386    #[test]
10387    fn test_path_traverses_through_a_computed_pointer() {
10388        // `.published` is itself computed, so it has no column to join on —
10389        // its own path takes the step's place and its filter rides along.
10390        let schema = make_schema_with_computed_links();
10391        let out = compile_and_emit_with("SELECT Person { t := .published.title }", &schema);
10392        assert!(out.sql.contains("\"public\".\"Person.posts\""), "{}", out.sql);
10393        assert!(
10394            out.sql
10395                .contains("(\"t1\".\"id\" = \"t0\".\"id\") AND (\"t2\".\"title\" <> '')"),
10396            "{}",
10397            out.sql
10398        );
10399    }
10400
10401    #[test]
10402    fn test_sub_select_over_a_computed_pointer_projects_a_property() {
10403        // The shape the jaldis translation model is built on:
10404        // `(select .translation filter .attribute = X limit 1).value`.
10405        let schema = make_schema_with_computed_links();
10406        let out = compile_and_emit_with(
10407            "SELECT Person { t := (select .published filter .title = 'a' limit 1).title }",
10408            &schema,
10409        );
10410        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10411        // Both the computed's own filter and the use-site one apply.
10412        assert!(out.sql.contains("\"t2\".\"title\" = 'a'"), "{}", out.sql);
10413        assert!(out.sql.contains("\"t2\".\"title\" <> ''"), "{}", out.sql);
10414    }
10415
10416    #[test]
10417    fn test_chained_computed_pointers_each_contribute_their_filter() {
10418        let schema = make_schema_with_computed_links();
10419        let out = compile_and_emit_with("SELECT Person { t := .featured.title }", &schema);
10420        assert_eq!(out.sql.matches("\"public\".\"Person.posts\"").count(), 1, "{}", out.sql);
10421        assert!(out.sql.contains("\"t2\".\"title\" <> 'draft'"), "{}", out.sql);
10422        assert!(out.sql.contains("\"t2\".\"title\" <> ''"), "{}", out.sql);
10423    }
10424
10425    #[test]
10426    fn test_computed_pointer_naming_a_bare_link_is_traversable() {
10427        let schema = make_schema_with_computed_links();
10428        let out = compile_and_emit_with("SELECT Person { t := .plain.title }", &schema);
10429        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"title\""), "{}", out.sql);
10430    }
10431
10432    #[test]
10433    fn test_a_cycle_of_computed_pointers_is_reported_not_hung_on() {
10434        let schema = make_schema_with_computed_links();
10435        let ast = parse::parse("SELECT Person { t := .looper.title }").unwrap();
10436        let err = match ir::compile(&ast, &schema) {
10437            Ok(_) => panic!("expected a compile error"),
10438            Err(e) => format!("{e}"),
10439        };
10440        assert!(err.contains("expands into itself"), "{err}");
10441    }
10442
10443    #[test]
10444    fn test_a_cycle_of_computed_pointers_read_as_a_value_is_reported_not_hung_on() {
10445        // Read as the last step, the computed was compiled as an expression,
10446        // which walked the same path again with no count of how often.
10447        let schema = make_schema_with_computed_links();
10448        for query in ["SELECT Person { looper }", "SELECT Person { * }"] {
10449            let ast = parse::parse(query).unwrap();
10450            let err = match ir::compile(&ast, &schema) {
10451                Ok(_) => panic!("expected a compile error for {query}"),
10452                Err(e) => format!("{e}"),
10453            };
10454            assert!(err.contains("expands into itself"), "{query}: {err}");
10455        }
10456    }
10457
10458    #[test]
10459    fn test_traversing_through_a_limited_computed_uses_a_lateral() {
10460        // `capped` picks one row per Person, so the traversal correlates
10461        // through a LATERAL rather than a plain join — and its `limit 1`
10462        // means the result is one title, not an array of them.
10463        let schema = make_schema_with_computed_links();
10464        let out = compile_and_emit_with("SELECT Person { t := .capped.title }", &schema);
10465        assert!(out.sql.contains("JOIN LATERAL ("), "{}", out.sql);
10466        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10467        assert!(!out.sql.contains("ARRAY(SELECT"), "{}", out.sql);
10468    }
10469
10470    #[test]
10471    fn test_backlink_from_a_type_whose_interface_the_link_targets() {
10472        use crate::schema::{LinkDescriptor, TypeDescriptor};
10473        let mut schema = make_interface_schema();
10474        let individual = schema.types.iter().find(|t| t.name == "Individual").unwrap().clone();
10475        schema.types.push(TypeDescriptor {
10476            name: "Note".into(),
10477            module: "default".into(),
10478            table: "Note".into(),
10479            abstract_: false,
10480            materialized: true,
10481            description: None,
10482            parents: vec![],
10483            interfaces: vec![],
10484            bases: vec![],
10485            properties: individual.properties[..1].to_vec(),
10486            // Targets the *interface*, which `Individual` implements.
10487            links: vec![LinkDescriptor {
10488                name: "owner".into(),
10489                target: "default::Account".into(),
10490                nullable: true,
10491                through: None,
10492                description: None,
10493                default_pyql: None,
10494                is_exclusive: false,
10495                is_readonly: false,
10496                rewrites: vec![],
10497                on_delete: vec![],
10498            }],
10499            multilinks: vec![],
10500            computed: vec![],
10501            constraints: vec![],
10502            indexes: vec![],
10503            partition: None,
10504            vector_indexes: vec![],
10505            search_indexes: vec![],
10506            triggers: vec![],
10507            junction: false,
10508            signals: vec![],
10509        });
10510        // Reported "link 'Note::owner' does not target 'Individual'" — but a
10511        // link to an interface accepts every implementor, and they share ids.
10512        let out = compile_and_emit_with("SELECT Individual { notes := .<owner[is Note] }", &schema);
10513        assert!(out.sql.contains("\"public\".\"Note\""), "{}", out.sql);
10514        assert!(out.sql.contains("\"owner_id\" = \"t0\".id"), "{}", out.sql);
10515    }
10516
10517    #[test]
10518    fn test_with_block_in_expression_position_hoists_its_bindings() {
10519        // A `with` has nowhere to put its CTEs mid-expression, so they move
10520        // to the enclosing statement's own WITH clause.
10521        let out = compile_and_emit("SELECT Person { t := (with c := (select Company.name limit 1) select c) }");
10522        assert!(out.sql.starts_with("WITH\n\"c\" AS ("), "{}", out.sql);
10523        assert!(out.sql.contains("(SELECT \"v\" FROM \"c\")"), "{}", out.sql);
10524    }
10525
10526    #[test]
10527    fn test_hoisted_binding_is_usable_in_the_expression_around_it() {
10528        let out =
10529            compile_and_emit("SELECT Person { t := (with c := (select Company.name limit 1) select .name ++ c) }");
10530        assert!(
10531            out.sql.contains("(\"t0\".\"name\" || (SELECT \"v\" FROM \"c\"))"),
10532            "{}",
10533            out.sql
10534        );
10535    }
10536
10537    #[test]
10538    fn test_hoisted_binding_joins_the_statements_own_with_clause() {
10539        let out = compile_and_emit(
10540            "WITH top := (select Person.name limit 1) \
10541             SELECT Person { t := (with c := (select Company.name limit 1) select c) }",
10542        );
10543        let top = out.sql.find("\"top\" AS (").expect("top-level binding");
10544        let inner = out.sql.find("\"c\" AS (").expect("hoisted binding");
10545        assert!(top < inner, "the statement's own bindings come first:\n{}", out.sql);
10546    }
10547
10548    #[test]
10549    fn test_a_binding_named_after_a_keyword_is_usable() {
10550        // The lexer is case-insensitive, so `order` arrives as the ORDER
10551        // token and read as "expected an expression, found 'order'" — and the
10552        // binding keeps the casing it was written with, not the keyword
10553        // table's.
10554        let out = compile_and_emit("WITH order := (select Person.name) SELECT Person { name } FILTER .name = order");
10555        assert!(out.sql.contains("(SELECT \"v\" FROM \"order\")"), "{}", out.sql);
10556    }
10557
10558    #[test]
10559    fn test_computed_declared_on_an_interface_is_visible_from_an_implementor() {
10560        use crate::schema::ComputedDescriptor;
10561        let mut schema = make_interface_schema();
10562        let account = schema.types.iter_mut().find(|t| t.name == "Account").unwrap();
10563        account.computed = vec![ComputedDescriptor {
10564            name: "tier".into(),
10565            expression: "'gold'".into(),
10566            return_type: None,
10567            link_target: None,
10568            link_multi: false,
10569        }];
10570        let individual = schema.types.iter_mut().find(|t| t.name == "Individual").unwrap();
10571        individual.computed = vec![];
10572        // Reported as "has no link or property 'tier'. Did you mean 'tier'?"
10573        // — the suggester searched interfaces, the resolver didn't.
10574        for q in ["SELECT Individual { tier }", "SELECT Individual { t := .tier }"] {
10575            let out = compile_and_emit_with(q, &schema);
10576            assert!(out.sql.contains("'gold'"), "{q}: {}", out.sql);
10577        }
10578    }
10579
10580    #[test]
10581    fn test_computed_may_carry_trailing_modifiers_without_a_select() {
10582        use crate::schema::ComputedDescriptor;
10583        let mut schema = make_schema();
10584        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
10585        person.computed = vec![ComputedDescriptor {
10586            name: "recent".into(),
10587            expression: ".posts order by .title desc limit 5".into(),
10588            return_type: None,
10589            link_target: None,
10590            link_multi: false,
10591        }];
10592        // A schema fragment has no statement around it to hang modifiers
10593        // off, so this used to be "expected an expression, found 'order'".
10594        let out = compile_and_emit_with("SELECT Person { recent { title } }", &schema);
10595        assert!(out.sql.contains("LIMIT 5"), "{}", out.sql);
10596        assert!(out.sql.contains("ORDER BY \"t1\".\"title\" DESC"), "{}", out.sql);
10597    }
10598
10599    #[test]
10600    fn test_computed_may_lead_with_a_bare_select() {
10601        use crate::schema::ComputedDescriptor;
10602        let mut schema = make_schema();
10603        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
10604        person.computed = vec![ComputedDescriptor {
10605            name: "recent".into(),
10606            expression: "select .posts order by .title desc limit 5".into(),
10607            return_type: None,
10608            link_target: None,
10609            link_multi: false,
10610        }];
10611        let out = compile_and_emit_with("SELECT Person { recent { title } }", &schema);
10612        assert!(out.sql.contains("LIMIT 5"), "{}", out.sql);
10613    }
10614
10615    #[test]
10616    fn test_sub_select_with_a_shape_may_project_a_property_off_it() {
10617        let out = compile_and_emit("SELECT Person { t := (select .posts { title } limit 1).title }");
10618        assert!(out.sql.contains("\"t2\".\"title\""), "{}", out.sql);
10619        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10620    }
10621
10622    #[test]
10623    fn test_sub_statement_error_names_what_it_got() {
10624        let err = compile_err("SELECT Person { t := (insert Company { name := 'a' }) }");
10625        assert!(err.contains("an insert cannot stand in for a value"), "{err}");
10626    }
10627
10628    #[test]
10629    fn test_with_bound_scalar_path_select_emits_a_real_select() {
10630        // The CTE body used to be just the FROM clause — `"xs" AS (
10631        // "public"."Person" AS "t0" )` — which is not SQL at all.
10632        let out = compile_and_emit("WITH xs := (select Person.name) SELECT Person { name }");
10633        assert!(
10634            out.sql
10635                .contains("SELECT ROW(v) AS result, v FROM (SELECT \"t0\".\"name\" AS v"),
10636            "{}",
10637            out.sql
10638        );
10639    }
10640
10641    #[test]
10642    fn test_with_bound_scalar_path_select_is_typed_as_its_result() {
10643        // Binding `Person.name` binds text, not `default::Person`, so a
10644        // reference to it reads the CTE's value column rather than an `id`.
10645        let out = compile_and_emit("WITH xs := (select Person.name) SELECT Person { name } FILTER .name IN xs");
10646        assert!(out.sql.contains("ANY((SELECT \"v\" FROM \"xs\"))"), "{}", out.sql);
10647    }
10648
10649    #[test]
10650    fn test_distinct_survives_a_with_binding() {
10651        let out = compile_and_emit("WITH xs := (select distinct Person.name) SELECT Person { name }");
10652        assert!(out.sql.contains("SELECT DISTINCT \"t0\".\"name\" AS v"), "{}", out.sql);
10653    }
10654
10655    #[test]
10656    fn test_for_over_a_derived_set_iterates_every_row() {
10657        // A non-literal iterator used to be wrapped in a one-row VALUES, so
10658        // the body ran once against a scalar subquery instead of per row.
10659        let out = compile_and_emit("FOR x IN (select Person.name) UNION (SELECT Person { name } FILTER .name = x)");
10660        assert!(!out.sql.contains("VALUES"), "{}", out.sql);
10661        assert!(out.sql.contains(") AS \"_for_x\""), "{}", out.sql);
10662        assert!(out.sql.contains("CROSS JOIN LATERAL"), "{}", out.sql);
10663        assert!(out.sql.contains("\"t1\".\"name\" = \"_for_x\".\"v\""), "{}", out.sql);
10664    }
10665
10666    #[test]
10667    fn test_for_insert_over_a_derived_set_uses_it_as_the_cte() {
10668        let out = compile_and_emit("FOR x IN (select Person.name) UNION (INSERT Company { name := x })");
10669        assert!(out.sql.starts_with("WITH \"_for_x\" AS ("), "{}", out.sql);
10670        assert!(
10671            out.sql.contains("SELECT \"_for_x\".\"v\" FROM \"_for_x\""),
10672            "{}",
10673            out.sql
10674        );
10675    }
10676
10677    #[test]
10678    fn test_for_over_objects_binds_their_id() {
10679        let out = compile_and_emit("FOR p IN (select Person) UNION (INSERT Company { name := <str>p })");
10680        assert!(out.sql.contains("SELECT \"id\" AS v FROM ("), "{}", out.sql);
10681    }
10682
10683    #[test]
10684    fn test_for_over_a_non_select_statement_is_rejected() {
10685        let ast = parse::parse("FOR x IN (INSERT Company { name := 'a' }) UNION (SELECT Person { name })").unwrap();
10686        let schema = make_schema();
10687        let err = match ir::compile(&ast, &schema) {
10688            Ok(_) => panic!("expected a compile error"),
10689            Err(e) => format!("{e}"),
10690        };
10691        assert!(err.contains("only a select can be iterated"), "{err}");
10692    }
10693
10694    #[test]
10695    fn test_multi_link_path_outside_a_comparison_is_an_array() {
10696        // It stands for a set of values — as a scalar subquery Postgres
10697        // would reject it the moment a Person had two posts.
10698        let out = compile_and_emit("SELECT Person { t := .posts.title }");
10699        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"title\""), "{}", out.sql);
10700        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
10701    }
10702
10703    #[test]
10704    fn test_multi_link_path_inside_a_comparison_is_still_exists() {
10705        let out = compile_and_emit("SELECT Person { name } filter .posts.title = 'x'");
10706        assert!(out.sql.contains("WHERE EXISTS("), "{}", out.sql);
10707        assert!(!out.sql.contains("ARRAY("), "{}", out.sql);
10708    }
10709
10710    #[test]
10711    fn test_path_traversal_deeper_than_two_steps() {
10712        let schema = make_schema_with_through();
10713        let out = compile_and_emit_with("SELECT Person { t := .friends.friends.name }", &schema);
10714        // Two junction hops, then the property — one subquery, not an error.
10715        assert_eq!(out.sql.matches("\"public\".\"PersonFriend\"").count(), 2, "{}", out.sql);
10716        assert!(out.sql.contains("ARRAY(SELECT \"t4\".\"name\""), "{}", out.sql);
10717    }
10718
10719    #[test]
10720    fn test_coalesce_over_backlinked_values_is_a_set_choice() {
10721        // The backlink was taken for a comparison and rewritten to an EXISTS,
10722        // with a COALESCE of a value and an array inside it.
10723        let out = compile_and_emit("SELECT Post ORDER BY max(.<posts[is Person].age ?? .<posts[is Person].age)");
10724        assert!(!out.sql.contains("EXISTS"), "{}", out.sql);
10725        assert!(!out.sql.contains("COALESCE("), "{}", out.sql);
10726        assert!(out.sql.contains("CASE WHEN (cardinality("), "{}", out.sql);
10727        assert!(
10728            out.sql
10729                .contains("(SELECT coalesce(max(\"_s\".\"v\"), NULL) FROM unnest("),
10730            "{}",
10731            out.sql
10732        );
10733    }
10734
10735    #[test]
10736    fn test_coalesce_over_multilink_values_is_a_set_choice() {
10737        let out = compile_and_emit("SELECT Person { t := .posts.title ?? .posts.title }");
10738        assert!(!out.sql.contains("EXISTS"), "{}", out.sql);
10739        assert!(out.sql.contains("CASE WHEN (cardinality("), "{}", out.sql);
10740    }
10741
10742    #[test]
10743    fn test_coalesce_of_a_set_with_a_single_value_falls_back_to_it() {
10744        let out = compile_and_emit("SELECT Post { a := .<posts[is Person].age ?? 0 }");
10745        assert!(out.sql.contains("ELSE array_remove(ARRAY[0], NULL) END"), "{}", out.sql);
10746    }
10747
10748    #[test]
10749    fn test_traversal_after_a_backlink() {
10750        let out = compile_and_emit("SELECT Post { t := .<posts[is Person].name }");
10751        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"name\""), "{}", out.sql);
10752        assert!(out.sql.contains("\"t3\".\"target\" = \"t1\".\"id\""), "{}", out.sql);
10753    }
10754
10755    #[test]
10756    fn test_computed_pointer_on_a_linked_type_is_reachable() {
10757        use crate::schema::ComputedDescriptor;
10758        let mut schema = make_schema();
10759        let company = schema.types.iter_mut().find(|t| t.name == "Company").unwrap();
10760        company.computed = vec![ComputedDescriptor {
10761            name: "shout".into(),
10762            expression: ".name ++ '!'".into(),
10763            return_type: Some("text".into()),
10764            link_target: None,
10765            link_multi: false,
10766        }];
10767        // `.company.shout` used to report "has no link or property 'shout'"
10768        // while helpfully suggesting 'shout' — path traversal never looked
10769        // at the target type's computed pointers.
10770        let out = compile_and_emit_with("SELECT Person { t := .company.shout }", &schema);
10771        assert!(out.sql.contains("(\"t2\".\"name\" || '!')"), "{}", out.sql);
10772    }
10773
10774    #[test]
10775    fn test_computed_pointer_behind_a_type_intersection() {
10776        let schema = make_interface_schema();
10777        let out = compile_and_emit_with("SELECT Account { n := [is Individual].full_name }", &schema);
10778        assert!(out.sql.contains("upper(\"t1\".\"first_name\")"), "{}", out.sql);
10779        assert!(out.sql.contains("FROM \"public\".\"Individual\""), "{}", out.sql);
10780        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
10781    }
10782
10783    #[test]
10784    fn test_computed_pointer_cannot_be_traversed_through() {
10785        use crate::schema::ComputedDescriptor;
10786        let mut schema = make_schema();
10787        let company = schema.types.iter_mut().find(|t| t.name == "Company").unwrap();
10788        company.computed = vec![ComputedDescriptor {
10789            name: "shout".into(),
10790            expression: ".name ++ '!'".into(),
10791            return_type: Some("text".into()),
10792            link_target: None,
10793            link_multi: false,
10794        }];
10795        let ast = parse::parse("SELECT Person { t := .company.shout.nope }").unwrap();
10796        let err = match ir::compile(&ast, &schema) {
10797            Ok(_) => panic!("expected a compile error"),
10798            Err(e) => format!("{e}"),
10799        };
10800        assert!(err.contains("is a computed pointer"), "{err}");
10801    }
10802
10803    #[test]
10804    fn test_sub_select_over_a_multi_link_path_keeps_its_modifiers() {
10805        let out = compile_and_emit("SELECT Person { t := (select .posts.title order by .title desc limit 2) }");
10806        assert!(out.sql.contains("ARRAY(SELECT"), "{}", out.sql);
10807        assert!(out.sql.contains("ORDER BY \"t2\".\"title\" DESC"), "{}", out.sql);
10808        assert!(out.sql.contains("LIMIT 2"), "{}", out.sql);
10809    }
10810
10811    #[test]
10812    fn test_limit_one_over_a_multi_link_path_stays_a_scalar() {
10813        // `limit 1` is what makes the set single-valued, so this one is a
10814        // scalar subquery rather than a one-element array.
10815        let out = compile_and_emit("SELECT Person { t := (select .posts.title limit 1) }");
10816        assert!(!out.sql.contains("ARRAY("), "{}", out.sql);
10817        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10818    }
10819
10820    #[test]
10821    fn test_computed_multilink_can_carry_a_nested_shape() {
10822        let out = compile_and_emit("SELECT Person { p := .posts { title } }");
10823        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10824            panic!()
10825        };
10826        let ShapeNode::Array { name, element, .. } = &pointers[2] else {
10827            panic!("{:?}", pointers[2])
10828        };
10829        assert_eq!(name, "p");
10830        let ShapeNode::Object {
10831            pointers: elem_pointers,
10832            ..
10833        } = element.as_ref()
10834        else {
10835            panic!()
10836        };
10837        assert!(matches!(&elem_pointers[1], ShapeNode::Scalar { name, .. } if name == "title"));
10838    }
10839
10840    #[test]
10841    fn test_schema_declared_backlink_computed_is_an_object_pointer() {
10842        use crate::schema::ComputedDescriptor;
10843        let mut schema = make_schema();
10844        let post = schema.types.iter_mut().find(|t| t.name == "Post").unwrap();
10845        post.computed = vec![ComputedDescriptor {
10846            name: "authors".into(),
10847            expression: ".<posts[is Person]".into(),
10848            return_type: None,
10849            link_target: None,
10850            link_multi: false,
10851        }];
10852        // A backlink computed used to compile as an EXISTS boolean here —
10853        // the pointer builders were only reachable from an inline `:=`.
10854        let out = compile_and_emit_with("SELECT Post { authors { name } }", &schema);
10855        assert!(out.sql.contains("array_agg(ROW("), "{}", out.sql);
10856        assert!(out.sql.contains("\"t1\".\"name\"::text"), "{}", out.sql);
10857        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10858            panic!()
10859        };
10860        assert!(matches!(&pointers[1], ShapeNode::Array { name, .. } if name == "authors"));
10861    }
10862
10863    #[test]
10864    fn test_schema_declared_multilink_computed_defaults_to_ids_without_a_shape() {
10865        use crate::schema::ComputedDescriptor;
10866        let mut schema = make_schema();
10867        schema.types[0].computed = vec![ComputedDescriptor {
10868            name: "everything".into(),
10869            expression: ".posts".into(),
10870            return_type: None,
10871            link_target: None,
10872            link_multi: false,
10873        }];
10874        let out = compile_and_emit_with("SELECT Person { everything }", &schema);
10875        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10876            panic!()
10877        };
10878        assert!(matches!(&pointers[2], ShapeNode::Array { name, .. } if name == "everything"));
10879    }
10880
10881    #[test]
10882    fn test_schema_declared_computed_sub_select_is_a_link_pointer() {
10883        use crate::schema::ComputedDescriptor;
10884        let mut schema = make_schema();
10885        schema.types[0].computed = vec![
10886            ComputedDescriptor {
10887                name: "recent".into(),
10888                expression: "(select .posts order by .title desc limit 1)".into(),
10889                return_type: None,
10890                link_target: None,
10891                link_multi: false,
10892            },
10893            ComputedDescriptor {
10894                name: "recent_title".into(),
10895                expression: "(select .posts order by .title desc limit 1).title".into(),
10896                return_type: Some("text".into()),
10897                link_target: None,
10898                link_multi: false,
10899            },
10900        ];
10901        let out = compile_and_emit_with("SELECT Person { recent { title }, recent_title }", &schema);
10902        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10903            panic!()
10904        };
10905        // A declared computed gets the same treatment as one written inline:
10906        // object-valued when it selects a link, scalar when it projects one.
10907        assert!(matches!(&pointers[2], ShapeNode::Object { name, .. } if name == "recent"));
10908        assert!(matches!(&pointers[3], ShapeNode::Scalar { name, .. } if name == "recent_title"));
10909        assert!(out.sql.contains("\"jt\".source = \"t0\".id"), "{}", out.sql);
10910        assert_eq!(out.sql.matches("LIMIT 1").count(), 2, "{}", out.sql);
10911        crate::validate::validate_schema_types(&schema).expect("schema should validate");
10912    }
10913
10914    /// `select (insert T { … }).id` — the mutation is hoisted into the
10915    /// statement's own WITH and the field read off it, which is what the
10916    /// `with x := (insert …) select x.id` spelling already produced. Without
10917    /// the CTE the projection would read the whole table instead.
10918    #[test]
10919    fn test_a_mutation_read_through_a_path_runs_as_a_hoisted_cte() {
10920        let out = compile_and_emit("SELECT (INSERT Person { name := 'x' }).name");
10921        assert!(
10922            out.sql.contains("\"_nested_dml_0\" AS (") && out.sql.contains("INSERT INTO"),
10923            "the mutation must run as a CTE:\n{}",
10924            out.sql
10925        );
10926        assert!(
10927            out.sql.contains("FROM \"_nested_dml_0\""),
10928            "the projection must read the mutation's own rows:\n{}",
10929            out.sql
10930        );
10931    }
10932
10933    #[test]
10934    fn test_dml_sub_statement_in_expression_position_still_rejected() {
10935        let err = compile_err("SELECT Person { x := (insert Company { name := 'c' }).name }");
10936        assert!(err.contains("an insert cannot stand in for a value"), "{err}");
10937    }
10938
10939    #[test]
10940    fn test_sub_select_with_a_shape_in_expression_position_is_rejected() {
10941        // Only when the shape *is* the result — projecting a property off
10942        // it is fine, since the shape then says nothing the projection
10943        // doesn't (see test_sub_select_with_a_shape_may_project_a_property_off_it).
10944        let err = compile_err("SELECT Person { name } filter (select .posts { title }) = 1");
10945        assert!(err.contains("sub-select with a shape"), "{err}");
10946    }
10947
10948    fn make_schema_with_through() -> SchemaDescriptor {
10949        let id_prop = || PropertyDescriptor {
10950            name: "id".into(),
10951            pg_type: "uuid".into(),
10952            nullable: false,
10953            default_sql: Some("gen_random_uuid()".into()),
10954            description: None,
10955            default_pyql: None,
10956            check_constraints: vec![],
10957            is_exclusive: true,
10958            is_pk: true,
10959            is_readonly: true,
10960            rewrites: vec![],
10961            tuple_members: None,
10962            column_type: None,
10963        };
10964        let name_prop = || PropertyDescriptor {
10965            name: "name".into(),
10966            pg_type: "text".into(),
10967            nullable: false,
10968            default_sql: None,
10969            description: None,
10970            check_constraints: vec![],
10971            default_pyql: None,
10972            is_exclusive: false,
10973            is_pk: false,
10974            is_readonly: false,
10975            rewrites: vec![],
10976            tuple_members: None,
10977            column_type: None,
10978        };
10979        SchemaDescriptor {
10980            types: vec![
10981                TypeDescriptor {
10982                    name: "Person".into(),
10983                    module: "default".into(),
10984                    table: "Person".into(),
10985                    abstract_: false,
10986                    materialized: false,
10987                    description: None,
10988                    parents: vec![],
10989                    interfaces: vec![],
10990                    bases: vec![],
10991                    properties: vec![id_prop(), name_prop()],
10992                    links: vec![],
10993                    multilinks: vec![MultiLinkDescriptor {
10994                        name: "friends".into(),
10995                        target: "default::Person".into(),
10996                        through: Some("default::PersonFriend".into()),
10997                        nullable: false,
10998                        description: None,
10999                        default_pyql: None,
11000                        on_delete: vec![],
11001                        is_exclusive: false,
11002                    }],
11003                    computed: vec![],
11004                    constraints: vec![],
11005                    indexes: vec![],
11006                    partition: None,
11007                    vector_indexes: vec![],
11008                    search_indexes: vec![],
11009                    triggers: vec![],
11010                    junction: false,
11011                    signals: vec![],
11012                },
11013                TypeDescriptor {
11014                    name: "PersonFriend".into(),
11015                    module: "default".into(),
11016                    table: "PersonFriend".into(),
11017                    abstract_: false,
11018                    materialized: false,
11019                    description: None,
11020                    parents: vec![],
11021                    interfaces: vec![],
11022                    bases: vec![],
11023                    properties: vec![id_prop()],
11024                    links: vec![
11025                        LinkDescriptor {
11026                            name: "person".into(),
11027                            target: "default::Person".into(),
11028                            nullable: false,
11029                            through: None,
11030                            description: None,
11031                            default_pyql: None,
11032                            is_exclusive: false,
11033                            is_readonly: false,
11034                            rewrites: vec![],
11035                            on_delete: vec![],
11036                        },
11037                        LinkDescriptor {
11038                            name: "friend".into(),
11039                            target: "default::Person".into(),
11040                            nullable: false,
11041                            through: None,
11042                            description: None,
11043                            default_pyql: None,
11044                            is_exclusive: false,
11045                            is_readonly: false,
11046                            rewrites: vec![],
11047                            on_delete: vec![],
11048                        },
11049                    ],
11050                    multilinks: vec![],
11051                    computed: vec![],
11052                    constraints: vec![],
11053                    indexes: vec![],
11054                    partition: None,
11055                    vector_indexes: vec![],
11056                    search_indexes: vec![],
11057                    triggers: vec![],
11058                    junction: false,
11059                    signals: vec![],
11060                },
11061            ],
11062            scalars: vec![],
11063            enums: vec![],
11064            named_tuples: vec![],
11065            globals: vec![],
11066            functions: vec![],
11067            aliases: vec![],
11068            channels: vec![],
11069            ..Default::default()
11070        }
11071    }
11072
11073    #[test]
11074    fn test_select_through_multi_link() {
11075        let schema = make_schema_with_through();
11076        let ast = crate::parse::parse("SELECT Person { name, friends { name } }").unwrap();
11077        let ir = crate::ir::compile(&ast, &schema).unwrap();
11078        let out = emit(&ir);
11079        // Junction table is the PersonFriend table, not the standard dotted name
11080        assert!(out.sql.contains("\"public\".\"PersonFriend\""));
11081        // Source FK column (person → Person) and target FK column (friend → Person)
11082        assert!(out.sql.contains("\"friend\""));
11083        assert!(out.sql.contains("\"person\""));
11084        // Still emits array_agg pattern
11085        assert!(out.sql.contains("array_agg(ROW("));
11086    }
11087
11088    /// Person/Org — a junction-backed single link (`Person.spouse: Link[Org,
11089    /// through(Marriage)]`, exclusive). Deliberately targets a different
11090    /// type (Org) rather than a self-link, for the same reason
11091    /// `test_select_through_multi_link` avoids Person-to-Person — see the
11092    /// comment on `make_schema_with_through` above.
11093    #[test]
11094    fn test_limited_multi_link_keeps_a_nested_multi_link_in_scope() {
11095        // The nested pointer's own correlated subquery moves inside the
11096        // derived table the LIMIT sits on, where its alias is still bound.
11097        let schema = make_schema_with_through();
11098        let ast = crate::parse::parse("SELECT Person { friends: { name, friends { name } } limit 1 }").unwrap();
11099        let ir = crate::ir::compile(&ast, &schema).unwrap();
11100        let out = emit(&ir);
11101        assert_eq!(out.sql.matches("array_agg(ROW(").count(), 2, "{}", out.sql);
11102        let derived = out.sql.find("AS \"__lim\"").unwrap();
11103        let nested = out.sql.rfind("array_agg(ROW(").unwrap();
11104        assert!(
11105            nested < derived,
11106            "nested aggregate must sit inside the derived table:\n{}",
11107            out.sql
11108        );
11109    }
11110
11111    fn make_schema_with_junction_backed_link() -> SchemaDescriptor {
11112        let id_prop = || PropertyDescriptor {
11113            name: "id".into(),
11114            pg_type: "uuid".into(),
11115            nullable: false,
11116            default_sql: Some("gen_random_uuid()".into()),
11117            description: None,
11118            default_pyql: None,
11119            check_constraints: vec![],
11120            is_exclusive: true,
11121            is_pk: true,
11122            is_readonly: true,
11123            rewrites: vec![],
11124            tuple_members: None,
11125            column_type: None,
11126        };
11127        let name_prop = || PropertyDescriptor {
11128            name: "name".into(),
11129            pg_type: "text".into(),
11130            nullable: false,
11131            default_sql: None,
11132            description: None,
11133            check_constraints: vec![],
11134            default_pyql: None,
11135            is_exclusive: false,
11136            is_pk: false,
11137            is_readonly: false,
11138            rewrites: vec![],
11139            tuple_members: None,
11140            column_type: None,
11141        };
11142        SchemaDescriptor {
11143            types: vec![
11144                TypeDescriptor {
11145                    name: "Person".into(),
11146                    module: "default".into(),
11147                    table: "Person".into(),
11148                    abstract_: false,
11149                    materialized: false,
11150                    description: None,
11151                    parents: vec![],
11152                    interfaces: vec![],
11153                    bases: vec![],
11154                    properties: vec![id_prop(), name_prop()],
11155                    links: vec![LinkDescriptor {
11156                        name: "spouse".into(),
11157                        target: "default::Org".into(),
11158                        nullable: true,
11159                        through: Some("default::Marriage".into()),
11160                        description: None,
11161                        default_pyql: None,
11162                        is_exclusive: true,
11163                        is_readonly: false,
11164                        rewrites: vec![],
11165                        on_delete: vec![],
11166                    }],
11167                    multilinks: vec![],
11168                    computed: vec![],
11169                    constraints: vec![],
11170                    indexes: vec![],
11171                    partition: None,
11172                    vector_indexes: vec![],
11173                    search_indexes: vec![],
11174                    triggers: vec![],
11175                    junction: false,
11176                    signals: vec![],
11177                },
11178                TypeDescriptor {
11179                    name: "Org".into(),
11180                    module: "default".into(),
11181                    table: "Org".into(),
11182                    abstract_: false,
11183                    materialized: false,
11184                    description: None,
11185                    parents: vec![],
11186                    interfaces: vec![],
11187                    bases: vec![],
11188                    properties: vec![id_prop(), name_prop()],
11189                    links: vec![],
11190                    multilinks: vec![],
11191                    computed: vec![],
11192                    constraints: vec![],
11193                    indexes: vec![],
11194                    partition: None,
11195                    vector_indexes: vec![],
11196                    search_indexes: vec![],
11197                    triggers: vec![],
11198                    junction: false,
11199                    signals: vec![],
11200                },
11201                TypeDescriptor {
11202                    name: "Marriage".into(),
11203                    module: "default".into(),
11204                    table: "Person.spouse".into(),
11205                    abstract_: false,
11206                    materialized: false,
11207                    description: None,
11208                    parents: vec![],
11209                    interfaces: vec![],
11210                    bases: vec![],
11211                    properties: vec![
11212                        id_prop(),
11213                        PropertyDescriptor {
11214                            name: "since".into(),
11215                            pg_type: "int8".into(),
11216                            nullable: true,
11217                            default_sql: None,
11218                            description: None,
11219                            check_constraints: vec![],
11220                            default_pyql: None,
11221                            is_exclusive: false,
11222                            is_pk: false,
11223                            is_readonly: false,
11224                            rewrites: vec![],
11225                            tuple_members: None,
11226                            column_type: None,
11227                        },
11228                    ],
11229                    links: vec![
11230                        LinkDescriptor {
11231                            name: "source".into(),
11232                            target: "default::Person".into(),
11233                            nullable: false,
11234                            through: None,
11235                            description: None,
11236                            default_pyql: None,
11237                            is_exclusive: false,
11238                            is_readonly: false,
11239                            rewrites: vec![],
11240                            on_delete: vec![],
11241                        },
11242                        LinkDescriptor {
11243                            name: "target".into(),
11244                            target: "default::Org".into(),
11245                            nullable: false,
11246                            through: None,
11247                            description: None,
11248                            default_pyql: None,
11249                            is_exclusive: false,
11250                            is_readonly: false,
11251                            rewrites: vec![],
11252                            on_delete: vec![],
11253                        },
11254                    ],
11255                    multilinks: vec![],
11256                    computed: vec![],
11257                    constraints: vec![],
11258                    indexes: vec![],
11259                    partition: None,
11260                    vector_indexes: vec![],
11261                    search_indexes: vec![],
11262                    triggers: vec![],
11263                    junction: true,
11264                    signals: vec![],
11265                },
11266            ],
11267            scalars: vec![],
11268            enums: vec![],
11269            named_tuples: vec![],
11270            globals: vec![],
11271            functions: vec![],
11272            aliases: vec![],
11273            channels: vec![],
11274            ..Default::default()
11275        }
11276    }
11277
11278    #[test]
11279    fn test_select_shape_over_junction_backed_single_link() {
11280        let schema = make_schema_with_junction_backed_link();
11281        let ast = crate::parse::parse("SELECT Person { name, spouse { name } }").unwrap();
11282        let ir = crate::ir::compile(&ast, &schema).unwrap();
11283        let out = emit(&ir);
11284        // Junction table is the through type's own table, joined like a multi-link's.
11285        assert!(out.sql.contains("\"public\".\"Person.spouse\""), "got:\n{}", out.sql);
11286        assert!(out.sql.contains("\"jt\".source"), "got:\n{}", out.sql);
11287        assert!(out.sql.contains("\"jt\".target"), "got:\n{}", out.sql);
11288        // Cardinality-one: a scalar correlated subquery, never array_agg.
11289        assert!(!out.sql.contains("array_agg"), "got:\n{}", out.sql);
11290        assert!(!out.sql.contains("spouse_id"), "got:\n{}", out.sql);
11291    }
11292
11293    #[test]
11294    fn test_select_shape_over_junction_backed_single_link_with_link_property() {
11295        // A junction-backed single link can read its own link properties
11296        // (`@since`) in a nested shape, same as a multi-link's `@weight` —
11297        // this is the one read site with no reusable FK-based form
11298        // (`IrSingleLinkPointer` needed its own `link_properties`, mirroring
11299        // `IrMultiLinkPointer`'s).
11300        let schema = make_schema_with_junction_backed_link();
11301        let ast = crate::parse::parse("SELECT Person { name, spouse { name, @since } }").unwrap();
11302        let ir = crate::ir::compile(&ast, &schema).unwrap();
11303        let out = emit(&ir);
11304        assert!(out.sql.contains("\"jt\".\"since\""), "got:\n{}", out.sql);
11305        assert!(!out.sql.contains("array_agg"), "got:\n{}", out.sql);
11306    }
11307
11308    #[test]
11309    fn test_select_path_over_junction_backed_single_link() {
11310        let schema = make_schema_with_junction_backed_link();
11311        let ast = crate::parse::parse("SELECT Person.spouse { name }").unwrap();
11312        let ir = crate::ir::compile(&ast, &schema).unwrap();
11313        let out = emit(&ir);
11314        assert!(out.sql.contains("\"public\".\"Person.spouse\""), "got:\n{}", out.sql);
11315        assert!(out.sql.contains("\"source\""), "got:\n{}", out.sql);
11316        assert!(out.sql.contains("\"target\""), "got:\n{}", out.sql);
11317        assert!(!out.sql.contains("spouse_id"), "got:\n{}", out.sql);
11318    }
11319
11320    #[test]
11321    fn test_insert_with_junction_backed_single_link() {
11322        let schema = make_schema_with_junction_backed_link();
11323        let out = compile_and_emit_with(
11324            "INSERT Person { name := $name, \
11325                spouse := (SELECT Org FILTER .id = $oid) { @since := <int64>$since } }",
11326            &schema,
11327        );
11328        assert!(out.sql.contains("\"public\".\"Person.spouse\""), "got:\n{}", out.sql);
11329        assert!(out.sql.contains("\"since\""), "got:\n{}", out.sql);
11330        assert!(!out.sql.contains("spouse_id"), "got:\n{}", out.sql);
11331        // A fresh row has no prior junction entry, but the emitted ON
11332        // CONFLICT target must still name only `source` — no `(source,
11333        // target)` composite unique constraint exists on this table (D3).
11334        // The conflict action is always an upsert (not DO NOTHING) since a
11335        // same-statement reassignment's paired DELETE shares this
11336        // statement's snapshot and can still "conflict" with the old row.
11337        assert!(out.sql.contains("ON CONFLICT (\"source\") DO UPDATE SET \"target\" = EXCLUDED.\"target\", \"since\" = EXCLUDED.\"since\""), "got:\n{}", out.sql);
11338    }
11339
11340    #[test]
11341    fn test_update_replace_junction_backed_single_link() {
11342        let schema = make_schema_with_junction_backed_link();
11343        let out = compile_and_emit_with(
11344            "UPDATE Person FILTER .id = $id SET { \
11345                spouse := (SELECT Org FILTER .id = $oid) { @since := <int64>$since } }",
11346            &schema,
11347        );
11348        // Replace = clear the existing junction row, then insert the new one.
11349        assert!(
11350            out.sql.contains("DELETE FROM \"public\".\"Person.spouse\""),
11351            "got:\n{}",
11352            out.sql
11353        );
11354        assert!(
11355            out.sql.contains("INSERT INTO \"public\".\"Person.spouse\""),
11356            "got:\n{}",
11357            out.sql
11358        );
11359        assert!(out.sql.contains("ON CONFLICT (\"source\") DO UPDATE SET \"target\" = EXCLUDED.\"target\", \"since\" = EXCLUDED.\"since\""), "got:\n{}", out.sql);
11360        // The paired DELETE must not race the new INSERT for a target that's
11361        // being kept, but for a single link (PK is `source` alone) ANY
11362        // existing row conflicts regardless of target, so the upsert above
11363        // is what actually makes reassignment work, not the exclusion here.
11364        assert!(out.sql.contains("NOT IN"), "got:\n{}", out.sql);
11365    }
11366
11367    /// `unless conflict on .k else (update T filter … set { … })` — the ON
11368    /// CONFLICT target says which row conflicts, the filter says whether to
11369    /// touch it. Dropping the filter turned automator's advisory lock into an
11370    /// unconditional steal of a live holder's row.
11371    #[test]
11372    fn test_unless_conflict_else_update_keeps_its_filter() {
11373        let out = compile_and_emit(
11374            "INSERT Person { name := 'a' } UNLESS CONFLICT ON .name \
11375             ELSE (UPDATE Person FILTER .age < 30 SET { name := 'b' })",
11376        );
11377        assert!(
11378            out.sql.contains("DO UPDATE SET") && out.sql.contains("WHERE"),
11379            "the ELSE UPDATE's filter must survive as a DO UPDATE predicate:\n{}",
11380            out.sql
11381        );
11382        assert!(
11383            out.sql.contains("\"Person\".\"age\""),
11384            "the predicate must read the existing row, not `excluded`:\n{}",
11385            out.sql
11386        );
11387    }
11388
11389    /// No filter, no predicate — an unconditional ELSE UPDATE still updates.
11390    #[test]
11391    fn test_unless_conflict_else_update_without_a_filter_has_no_predicate() {
11392        let out = compile_and_emit(
11393            "INSERT Person { name := 'a' } UNLESS CONFLICT ON .name ELSE (UPDATE Person SET { name := 'b' })",
11394        );
11395        let after_set = out.sql.split("DO UPDATE SET").nth(1).unwrap_or_default();
11396        assert!(
11397            !after_set.contains("WHERE"),
11398            "got a predicate we never asked for:\n{}",
11399            out.sql
11400        );
11401    }
11402
11403    #[test]
11404    fn test_junction_backed_single_link_rejected_in_unless_conflict_else() {
11405        // `compile_conflict_else` calls `compile_assignments_for_update`
11406        // directly on the raw ELSE-clause shape, bypassing the
11407        // shape-classification loop `compile_insert`/`compile_update` use to
11408        // intercept a junction-backed link before it reaches the plain
11409        // FK-column assignment path — must fail loudly here instead of
11410        // emitting a `spouse_id` column that doesn't exist.
11411        let schema = make_schema_with_junction_backed_link();
11412        let ast = parse::parse(
11413            "INSERT Person { name := $name } \
11414             UNLESS CONFLICT ON .name ELSE (UPDATE Person SET { \
11415                spouse := (SELECT Org FILTER .id = $oid) })",
11416        )
11417        .unwrap();
11418        assert!(ir::compile(&ast, &schema).is_err());
11419    }
11420
11421    #[test]
11422    fn test_update_clear_junction_backed_single_link() {
11423        let schema = make_schema_with_junction_backed_link();
11424        let out = compile_and_emit_with("UPDATE Person FILTER .id = $id SET { spouse := {} }", &schema);
11425        assert!(
11426            out.sql.contains("DELETE FROM \"public\".\"Person.spouse\""),
11427            "got:\n{}",
11428            out.sql
11429        );
11430        assert!(
11431            !out.sql.contains("INSERT INTO \"public\".\"Person.spouse\""),
11432            "clearing must not also insert:\n{}",
11433            out.sql
11434        );
11435    }
11436
11437    #[test]
11438    fn test_update_clear_junction_backed_single_link_with_cast_empty_set() {
11439        // Regression: the frontend (generateStatements.ts's `setNull`
11440        // handling) always emits the cast form `<Type>{}`, never the bare
11441        // `{}` — the junction-backed clear path originally only recognized
11442        // bare `Expr::Set([])`, so this form fell through to
11443        // `compile_multilink_values` and errored with "multilink value must
11444        // be a CTE reference, parenthesised subquery, or type path" instead
11445        // of clearing (confirmed live via the Data Explorer).
11446        let schema = make_schema_with_junction_backed_link();
11447        let out = compile_and_emit_with("UPDATE Person FILTER .id = $id SET { spouse := <Org>{} }", &schema);
11448        assert!(
11449            out.sql.contains("DELETE FROM \"public\".\"Person.spouse\""),
11450            "got:\n{}",
11451            out.sql
11452        );
11453        assert!(
11454            !out.sql.contains("INSERT INTO \"public\".\"Person.spouse\""),
11455            "clearing must not also insert:\n{}",
11456            out.sql
11457        );
11458    }
11459
11460    /// Product/Tag/ProductTag — the actual real-world shape link properties
11461    /// were built for (mirrors pylon-demo). Deliberately *not* built off
11462    /// `make_schema_with_through` (Person self-linking to Person): a
11463    /// same-type through-link hits a pre-existing, unrelated column-
11464    /// resolution bug in `multilink_junction_info` (both source_col and
11465    /// target_col resolve to the same link name when source type == target
11466    /// type) — tracked separately, not fixed here.
11467    fn make_schema_with_through_and_prop() -> SchemaDescriptor {
11468        let id_prop = || PropertyDescriptor {
11469            name: "id".into(),
11470            pg_type: "uuid".into(),
11471            nullable: false,
11472            default_sql: Some("gen_random_uuid()".into()),
11473            description: None,
11474            default_pyql: None,
11475            check_constraints: vec![],
11476            is_exclusive: true,
11477            is_pk: true,
11478            is_readonly: true,
11479            rewrites: vec![],
11480            tuple_members: None,
11481            column_type: None,
11482        };
11483        let name_prop = || PropertyDescriptor {
11484            name: "name".into(),
11485            pg_type: "text".into(),
11486            nullable: false,
11487            default_sql: None,
11488            description: None,
11489            check_constraints: vec![],
11490            default_pyql: None,
11491            is_exclusive: false,
11492            is_pk: false,
11493            is_readonly: false,
11494            rewrites: vec![],
11495            tuple_members: None,
11496            column_type: None,
11497        };
11498        SchemaDescriptor {
11499            types: vec![
11500                TypeDescriptor {
11501                    name: "Product".into(),
11502                    module: "default".into(),
11503                    table: "Product".into(),
11504                    abstract_: false,
11505                    materialized: false,
11506                    description: None,
11507                    parents: vec![],
11508                    interfaces: vec![],
11509                    bases: vec![],
11510                    properties: vec![id_prop(), name_prop()],
11511                    links: vec![],
11512                    multilinks: vec![MultiLinkDescriptor {
11513                        name: "tags".into(),
11514                        target: "default::Tag".into(),
11515                        through: Some("default::ProductTag".into()),
11516                        nullable: false,
11517                        description: None,
11518                        default_pyql: None,
11519                        on_delete: vec![],
11520                        is_exclusive: false,
11521                    }],
11522                    computed: vec![],
11523                    constraints: vec![],
11524                    indexes: vec![],
11525                    partition: None,
11526                    vector_indexes: vec![],
11527                    search_indexes: vec![],
11528                    triggers: vec![],
11529                    junction: false,
11530                    signals: vec![],
11531                },
11532                TypeDescriptor {
11533                    name: "Tag".into(),
11534                    module: "default".into(),
11535                    table: "Tag".into(),
11536                    abstract_: false,
11537                    materialized: false,
11538                    description: None,
11539                    parents: vec![],
11540                    interfaces: vec![],
11541                    bases: vec![],
11542                    properties: vec![id_prop(), name_prop()],
11543                    links: vec![],
11544                    multilinks: vec![],
11545                    computed: vec![],
11546                    constraints: vec![],
11547                    indexes: vec![],
11548                    partition: None,
11549                    vector_indexes: vec![],
11550                    search_indexes: vec![],
11551                    triggers: vec![],
11552                    junction: false,
11553                    signals: vec![],
11554                },
11555                TypeDescriptor {
11556                    name: "ProductTag".into(),
11557                    module: "default".into(),
11558                    table: "Product.tags".into(),
11559                    abstract_: false,
11560                    materialized: false,
11561                    description: None,
11562                    parents: vec![],
11563                    interfaces: vec![],
11564                    bases: vec![],
11565                    properties: vec![
11566                        id_prop(),
11567                        PropertyDescriptor {
11568                            name: "weight".into(),
11569                            pg_type: "float8".into(),
11570                            nullable: false,
11571                            default_sql: None,
11572                            default_pyql: None,
11573                            description: None,
11574                            check_constraints: vec![],
11575                            is_exclusive: false,
11576                            is_pk: false,
11577                            is_readonly: false,
11578                            rewrites: vec![],
11579                            tuple_members: None,
11580                            column_type: None,
11581                        },
11582                    ],
11583                    // No declared Link pointers — matches pylon-demo's actual
11584                    // ProductTag, which relies on multilink_junction_info's
11585                    // "source"/"target" defaults.
11586                    links: vec![],
11587                    multilinks: vec![],
11588                    computed: vec![],
11589                    constraints: vec![],
11590                    indexes: vec![],
11591                    partition: None,
11592                    vector_indexes: vec![],
11593                    search_indexes: vec![],
11594                    triggers: vec![],
11595                    junction: true,
11596                    signals: vec![],
11597                },
11598            ],
11599            scalars: vec![],
11600            enums: vec![],
11601            named_tuples: vec![],
11602            globals: vec![],
11603            functions: vec![],
11604            aliases: vec![],
11605            channels: vec![],
11606            ..Default::default()
11607        }
11608    }
11609
11610    #[test]
11611    fn test_link_property_in_a_links_own_filter() {
11612        // `filter @weight > x` reads the junction row of the link being
11613        // filtered — `@prop` used to parse only as a postfix on a named
11614        // path, so a bare one was "expected an expression, found '@'".
11615        let schema = make_schema_with_through_and_prop();
11616        let out = compile_and_emit_with("SELECT Product { tags: { } filter @weight > 0.5 }", &schema);
11617        assert!(out.sql.contains("\"jt\".\"weight\" > (0.5::float8)"), "{}", out.sql);
11618    }
11619
11620    #[test]
11621    fn test_exists_on_a_link_property() {
11622        // `exists @weight` — the junction column the link's own modifiers are
11623        // compiled against. `exists` only knew how to take a pointer name.
11624        let schema = make_schema_with_through_and_prop();
11625        let out = compile_and_emit_with("SELECT Product { tags: { } filter exists @weight }", &schema);
11626        assert!(out.sql.contains("\"jt\".\"weight\" IS NOT NULL"), "{}", out.sql);
11627    }
11628
11629    #[test]
11630    fn test_link_property_in_a_computed_link_filter() {
11631        let schema = make_schema_with_through_and_prop();
11632        let out = compile_and_emit_with(
11633            "SELECT Product { best := (select .tags filter @weight > 0.5 limit 1) { @weight } }",
11634            &schema,
11635        );
11636        assert!(out.sql.contains("\"jt\".\"weight\" > (0.5::float8)"), "{}", out.sql);
11637        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
11638    }
11639
11640    #[test]
11641    fn test_link_property_in_a_projected_sub_select_reads_the_junction() {
11642        // The flat-join form names the junction with an ordinary alias
11643        // rather than "jt", so the scope has to carry the alias too.
11644        let schema = make_schema_with_through_and_prop();
11645        let out = compile_and_emit_with(
11646            "SELECT Product { t := (select .tags filter @weight > 0.5 limit 1).id }",
11647            &schema,
11648        );
11649        assert!(out.sql.contains("\"t3\".\"weight\" > (0.5::float8)"), "{}", out.sql);
11650    }
11651
11652    #[test]
11653    fn test_unknown_link_property_names_the_through_type() {
11654        let schema = make_schema_with_through_and_prop();
11655        let ast = parse::parse("SELECT Product { tags: { } filter @nope > 0.5 }").unwrap();
11656        let err = match ir::compile(&ast, &schema) {
11657            Ok(_) => panic!("expected a compile error"),
11658            Err(e) => format!("{e}"),
11659        };
11660        assert!(err.contains("default::ProductTag") && err.contains("nope"), "{err}");
11661    }
11662
11663    #[test]
11664    fn test_link_property_outside_a_link_says_where_it_belongs() {
11665        let schema = make_schema_with_through_and_prop();
11666        let ast = parse::parse("SELECT Product { t := @weight }").unwrap();
11667        let err = match ir::compile(&ast, &schema) {
11668            Ok(_) => panic!("expected a compile error"),
11669            Err(e) => format!("{e}"),
11670        };
11671        assert!(err.contains("is a link property"), "{err}");
11672    }
11673
11674    #[test]
11675    fn test_multilink_append_with_link_property() {
11676        let schema = make_schema_with_through_and_prop();
11677        let out = compile_and_emit_with(
11678            "UPDATE Product FILTER .id = $id SET { tags += (SELECT Tag FILTER .id = $tid) { @weight := <float64>$w } }",
11679            &schema,
11680        );
11681        // Extra junction column present in both the INSERT column list and the SELECT list.
11682        assert!(out.sql.contains("\"weight\""), "missing weight column:\n{}", out.sql);
11683        // Re-linking an existing pair with a new weight must update in place,
11684        // not silently keep the old value (a bare `DO NOTHING` would).
11685        assert!(
11686            out.sql
11687                .contains("ON CONFLICT (\"source\", \"target\") DO UPDATE SET \"weight\" = EXCLUDED.\"weight\""),
11688            "missing upsert conflict clause:\n{}",
11689            out.sql
11690        );
11691    }
11692
11693    #[test]
11694    fn test_multilink_append_union_with_different_link_property_values() {
11695        // The realistic Data Explorer scenario: multiple checked targets in
11696        // one `+=`, each with its own distinct property value — expressed as
11697        // a `union` of individually-shaped target selects.
11698        let schema = make_schema_with_through_and_prop();
11699        let out = compile_and_emit_with(
11700            "UPDATE Product FILTER .id = $id SET { \
11701                tags += (SELECT Tag FILTER .id = $aid) { @weight := <float64>$w1 } \
11702                    union (SELECT Tag FILTER .id = $bid) { @weight := <float64>$w2 } \
11703            }",
11704            &schema,
11705        );
11706        assert!(
11707            out.sql.contains("UNION ALL"),
11708            "expected a UNION ALL between the two shaped targets:\n{}",
11709            out.sql
11710        );
11711        // Both branches must select the same "weight" column (with their own
11712        // value) so the union has a consistent column list.
11713        assert_eq!(
11714            out.sql.matches("AS \"weight\"").count(),
11715            2,
11716            "each union branch must project its own weight:\n{}",
11717            out.sql
11718        );
11719    }
11720
11721    #[test]
11722    fn test_multilink_append_without_link_property_keeps_do_nothing() {
11723        // No `@prop := ...` anywhere — must keep the original DO NOTHING
11724        // behavior (no spurious upsert/extra columns for the common case).
11725        let schema = make_schema_with_through_and_prop();
11726        let out = compile_and_emit_with(
11727            "UPDATE Product FILTER .id = $id SET { tags += (SELECT Tag FILTER .id = $tid) }",
11728            &schema,
11729        );
11730        assert!(
11731            out.sql.contains("ON CONFLICT DO NOTHING"),
11732            "expected plain DO NOTHING when no link properties are set:\n{}",
11733            out.sql
11734        );
11735        assert!(
11736            !out.sql.contains("\"weight\""),
11737            "unexpected weight column with no link property assignment:\n{}",
11738            out.sql
11739        );
11740    }
11741
11742    #[test]
11743    fn test_multilink_link_property_rejected_on_standard_junction() {
11744        // Person.posts is a Standard (implicit) junction — no user-declared
11745        // properties, so `@prop := ...` must be rejected at compile time.
11746        let ast = crate::parse::parse(
11747            "UPDATE Person FILTER .id = $id SET { posts += (SELECT Post FILTER .title = $t) { @weight := <float64>$w } }",
11748        ).unwrap();
11749        match crate::ir::compile(&ast, &make_schema()) {
11750            Ok(_) => panic!("expected a compile error for link property on a Standard junction"),
11751            Err(e) => assert!(
11752                e.to_string().contains("Through"),
11753                "expected a Through[...]-related error, got: {e}"
11754            ),
11755        }
11756    }
11757
11758    #[test]
11759    fn test_multilink_link_property_rejected_on_remove() {
11760        let schema = make_schema_with_through_and_prop();
11761        let ast = crate::parse::parse(
11762            "UPDATE Product FILTER .id = $id SET { tags -= (SELECT Tag FILTER .id = $tid) { @weight := <float64>$w } }",
11763        )
11764        .unwrap();
11765        match crate::ir::compile(&ast, &schema) {
11766            Ok(_) => panic!("expected a compile error for link property on a remove (-=)"),
11767            Err(e) => assert!(
11768                e.to_string().contains("removing"),
11769                "expected a remove-related error, got: {e}"
11770            ),
11771        }
11772    }
11773
11774    #[test]
11775    fn test_insert_with_multilink_assignment() {
11776        // Regression test: `insert Product { tags := ... }` used to fail with
11777        // "object type 'default::Product' has no link or property 'tags'" —
11778        // compile_assignments_inner never checked resolve_multilink, and
11779        // IrInsert had no multi-link handling at all.
11780        let schema = make_schema_with_through_and_prop();
11781        let out = compile_and_emit_with(
11782            "INSERT Product { name := $name, tags := (SELECT Tag FILTER .id = $tid) { @weight := <float64>$w } }",
11783            &schema,
11784        );
11785        // The row insert must happen before the junction insert references its id.
11786        assert!(
11787            out.sql.contains("\"_w__ids\" AS (\nINSERT INTO"),
11788            "missing row-insert CTE:\n{}",
11789            out.sql
11790        );
11791        assert!(
11792            out.sql.contains("\"_w__ml_add_0\" AS ("),
11793            "missing junction-append CTE:\n{}",
11794            out.sql
11795        );
11796        assert!(out.sql.contains("\"weight\""), "missing weight column:\n{}", out.sql);
11797        assert!(
11798            out.sql.contains("\"_w\" AS (\n    SELECT * FROM \"_w__ids\"\n)"),
11799            "missing _w passthrough:\n{}",
11800            out.sql
11801        );
11802        assert_eq!(
11803            out.sql.matches("WITH\n").count(),
11804            1,
11805            "must be a single flat top-level WITH block:\n{}",
11806            out.sql
11807        );
11808    }
11809
11810    /// A user `with` binding *consumed by* a statement that builds CTEs of
11811    /// its own. The binding is emitted by the generic `ir.ctes` path while
11812    /// the multi-link/junction machinery has already opened its own `WITH`;
11813    /// prefixing a second one produced `WITH ... WITH ...`, which compiles
11814    /// cleanly and then fails at execution with `syntax error at or near
11815    /// "WITH"`. Covers both link kinds, since each reaches the junction
11816    /// emitter by a different route.
11817    #[test]
11818    fn test_user_with_binding_merges_into_a_statements_own_ctes() {
11819        let schema = make_schema_with_through_and_prop();
11820        for query in [
11821            "with t := (select Tag filter .id = $tid) insert Product { name := $name, tags := t }",
11822            "with t := (select Tag filter .id = $tid) \
11823             update Product filter .name = $name set { tags += t }",
11824        ] {
11825            let out = compile_and_emit_with(query, &schema);
11826            let flat: String = out.sql.split_whitespace().collect::<Vec<_>>().join(" ");
11827            assert!(
11828                !flat.contains(") WITH "),
11829                "a second WITH clause is invalid SQL:\n{}",
11830                out.sql
11831            );
11832            assert_eq!(
11833                out.sql.matches("WITH").count(),
11834                1,
11835                "exactly one WITH keyword expected:\n{}",
11836                out.sql
11837            );
11838        }
11839    }
11840
11841    #[test]
11842    fn test_with_bound_insert_with_multilink_assignment() {
11843        // Same as above, but bound via WITH (the Data Explorer's actual
11844        // shape) — exercises emit_user_cte_parts's insert_has_any_multilink
11845        // branch instead of emit_insert_stmt's bare-statement path.
11846        let schema = make_schema_with_through_and_prop();
11847        let out = compile_and_emit_with(
11848            "with insert0 := (insert Product { name := $name, tags := (select Tag filter .id = $tid) }) select insert0",
11849            &schema,
11850        );
11851        assert!(
11852            out.sql.contains("\"insert0__ids\" AS (\nINSERT INTO"),
11853            "missing row-insert CTE:\n{}",
11854            out.sql
11855        );
11856        assert!(
11857            out.sql.contains("\"insert0__ml_add_0\" AS ("),
11858            "missing junction-append CTE:\n{}",
11859            out.sql
11860        );
11861        assert!(
11862            out.sql
11863                .contains("\"insert0\" AS (\n    SELECT * FROM \"insert0__ids\"\n)"),
11864            "missing insert0 passthrough:\n{}",
11865            out.sql
11866        );
11867        assert_eq!(
11868            out.sql.matches("WITH\n").count(),
11869            1,
11870            "must be a single flat top-level WITH block:\n{}",
11871            out.sql
11872        );
11873    }
11874
11875    #[test]
11876    fn test_with_block_cte_over_computed_global_merges_into_single_with_clause() {
11877        // Regression: `with user := (select global current_user) select user;`
11878        // produced two separate top-level `WITH` keywords ("syntax error at
11879        // or near WITH" from Postgres) whenever the computed global's own
11880        // expression referenced a session global (registering a global CTE
11881        // in addition to the user-defined "user" CTE). The merge check in
11882        // emit() only recognized the "WITH " (space) prefix convention, not
11883        // emit_cte_prefix's "WITH\n" (newline) convention used here, so it
11884        // prepended a second WITH block instead of merging into the first.
11885        let mut schema = make_schema();
11886        schema.globals.push(GlobalDescriptor {
11887            name: "current_user_id".into(),
11888            module: "default".into(),
11889            scalar_type: "std::uuid".into(),
11890            required: false,
11891            default_expr: None,
11892            computed_expr: None,
11893        });
11894        schema.globals.push(GlobalDescriptor {
11895            name: "current_user".into(),
11896            module: "default".into(),
11897            scalar_type: "Person".into(),
11898            required: false,
11899            default_expr: None,
11900            computed_expr: Some("select default::Person filter .id = global current_user_id".into()),
11901        });
11902        let out = compile_and_emit_with("with\n  user := (select global current_user)\nselect user;", &schema);
11903        assert_eq!(
11904            out.sql.matches("WITH").count(),
11905            1,
11906            "must be a single WITH clause, got:\n{}",
11907            out.sql
11908        );
11909    }
11910
11911    #[test]
11912    fn test_several_mutations_in_a_free_set_each_become_a_cte() {
11913        let schema = make_schema();
11914        let out = compile_and_emit_with(
11915            "select { (update Person filter .name = 'a' set { age := 1 }), \
11916                      (update Company filter .name = 'b' set { name := 'c' }) }",
11917            &schema,
11918        );
11919        assert_eq!(
11920            out.sql.matches("UPDATE").count(),
11921            2,
11922            "both mutations have to run, got:\n{}",
11923            out.sql
11924        );
11925        assert!(out.sql.contains("WITH"), "each becomes a CTE, got:\n{}", out.sql);
11926    }
11927
11928    #[test]
11929    fn test_updating_a_binding_touches_only_its_own_rows() {
11930        // The binding names the rows to update, so resolving it to its type
11931        // without narrowing would rewrite every row in the table.
11932        let schema = make_schema();
11933        let out = compile_and_emit_with(
11934            "with mine := (select Person filter .name = 'a') select { (update mine set { age := 1 }) }",
11935            &schema,
11936        );
11937        assert!(
11938            out.sql.contains("UPDATE") && out.sql.contains("= ANY(ARRAY(SELECT"),
11939            "expected the update narrowed to the binding's rows, got:\n{}",
11940            out.sql
11941        );
11942    }
11943
11944    #[test]
11945    fn test_updating_a_traversal_touches_only_the_rows_it_lands_on() {
11946        let mut schema = make_schema();
11947        // The traversal's target needs an id to narrow against; every real
11948        // schema declares one, this one leaves it off Company.
11949        let company = schema
11950            .types
11951            .iter_mut()
11952            .find(|t| t.name == "Company")
11953            .expect("test schema has a Company type");
11954        company.properties.push(crate::schema::PropertyDescriptor {
11955            name: "id".into(),
11956            pg_type: "uuid".into(),
11957            nullable: false,
11958            default_sql: Some("uuidv7()".into()),
11959            default_pyql: None,
11960            description: None,
11961            check_constraints: vec![],
11962            is_exclusive: true,
11963            is_pk: true,
11964            is_readonly: true,
11965            rewrites: vec![],
11966            tuple_members: None,
11967            column_type: None,
11968        });
11969        let out = compile_and_emit_with(
11970            "with mine := (select Person filter .name = 'a') select { (update mine.company set { name := 'x' }) }",
11971            &schema,
11972        );
11973        assert!(
11974            out.sql.contains("= ANY(ARRAY(SELECT"),
11975            "expected the update narrowed to the traversal's rows, got:\n{}",
11976            out.sql
11977        );
11978        assert!(
11979            out.sql.contains("\"Company\""),
11980            "expected the table the traversal ends on, got:\n{}",
11981            out.sql
11982        );
11983    }
11984
11985    #[test]
11986    fn test_aggregate_over_a_multi_valued_path_keeps_the_set_flat() {
11987        // Regression: `array_agg(a.posts.title)` compiled the path as an
11988        // expression, where a multi-valued path stands for the array of its
11989        // elements — so the aggregate wrapped that array and the result came
11990        // back one level deep, `[[t1, t2]]` rather than `[t1, t2]`.
11991        let schema = make_schema();
11992        let out = compile_and_emit_with(
11993            "with a := (select Person limit 1) select { titles := array_agg(a.posts.title) }",
11994            &schema,
11995        );
11996        assert!(
11997            !out.sql.contains("array_agg(ARRAY("),
11998            "the aggregate must take the set, not the array standing for it, got:\n{}",
11999            out.sql
12000        );
12001        assert!(
12002            out.sql.contains("array_agg(") && out.sql.contains("\"title\""),
12003            "expected the aggregate over the traversal's own column, got:\n{}",
12004            out.sql
12005        );
12006        // The free object keeps its composite emission, so values stay typed
12007        // rather than passing through jsonb as strings.
12008        assert!(
12009            out.sql.contains("ROW(") && !out.sql.contains("jsonb_build_object"),
12010            "expected a composite row, got:\n{}",
12011            out.sql
12012        );
12013    }
12014
12015    #[test]
12016    fn test_with_bound_root_read_by_name_inside_a_nested_select() {
12017        // Regression: `compile_path_select` resolved a `with`-bound root
12018        // correctly, but the multi-valued check straight after it re-resolved
12019        // the same root as a plain type, so reading the binding by name from a
12020        // nested sub-select failed with "unknown type 'owner'".
12021        let schema = make_schema();
12022        let out = compile_and_emit_with(
12023            "with
12024  owner := (select Person limit 1)
12025select owner { posts := (select owner.posts.title) };",
12026            &schema,
12027        );
12028        assert!(
12029            out.sql.contains("WITH") && out.sql.contains("\"title\""),
12030            "expected the nested select to traverse the binding, got:\n{}",
12031            out.sql
12032        );
12033    }
12034
12035    #[test]
12036    fn test_path_traversal_into_with_bound_cte_of_object_type() {
12037        // Regression: `with user := (select global current_user) select
12038        // user.name;` failed with "unknown type 'user'" — compile_path_select
12039        // (used for multi-step absolute paths like `user.name`) only ever
12040        // tried resolve_type(root_name), never checking whether the root
12041        // name is a WITH-block CTE bound to an object type.
12042        let mut schema = make_schema();
12043        schema.globals.push(GlobalDescriptor {
12044            name: "current_user_id".into(),
12045            module: "default".into(),
12046            scalar_type: "std::uuid".into(),
12047            required: false,
12048            default_expr: None,
12049            computed_expr: None,
12050        });
12051        schema.globals.push(GlobalDescriptor {
12052            name: "current_user".into(),
12053            module: "default".into(),
12054            scalar_type: "Person".into(),
12055            required: false,
12056            default_expr: None,
12057            computed_expr: Some("select default::Person filter .id = global current_user_id".into()),
12058        });
12059        let out = compile_and_emit_with(
12060            "with\n  user := (select global current_user)\nselect user.name;",
12061            &schema,
12062        );
12063        assert!(
12064            out.sql.contains("FROM \"user\""),
12065            "expected path traversal from the CTE, got:\n{}",
12066            out.sql
12067        );
12068        assert_eq!(
12069            out.sql.matches("WITH").count(),
12070            1,
12071            "must be a single WITH clause, got:\n{}",
12072            out.sql
12073        );
12074    }
12075
12076    #[test]
12077    fn test_with_bound_schema_object_property_access_in_filter_expression() {
12078        // Regression: `with person := (select detached Person filter ...)
12079        // select Person filter .id not in person.id;` failed with "absolute
12080        // paths are not valid in expression context" — compile_path's
12081        // schema-bound-CTE-in-expression-position case only ever handled a
12082        // WITH-bound *free object* (resolve_cte_path), never a WITH-bound
12083        // *schema object* CTE, even though its own SELECT * CTE source
12084        // (emit_dml_as_cte_source) always exposes every raw column.
12085        let schema = make_schema();
12086        let out = compile_and_emit_with(
12087            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
12088             select Person filter .id not in person.id;",
12089            &schema,
12090        );
12091        assert!(
12092            out.sql.contains("<> ALL((SELECT") && out.sql.contains(".\"id\"") && out.sql.contains("FROM \"person\""),
12093            "got:\n{}",
12094            out.sql
12095        );
12096    }
12097
12098    #[test]
12099    fn test_with_bound_schema_object_scalar_property_access() {
12100        // Not just `.id` — any scalar property of the bound type is
12101        // reachable, since the CTE source is `SELECT *` regardless of the
12102        // binding's own declared shape.
12103        let schema = make_schema();
12104        let out = compile_and_emit_with(
12105            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
12106             select Person filter .name = person.name;",
12107            &schema,
12108        );
12109        assert!(
12110            out.sql.contains("(SELECT") && out.sql.contains(".\"name\"") && out.sql.contains("FROM \"person\""),
12111            "got:\n{}",
12112            out.sql
12113        );
12114    }
12115
12116    #[test]
12117    fn test_with_bound_schema_object_link_traversal_reaches_nested_property() {
12118        // Generalization: a with-bound schema object's CTE source is
12119        // `SELECT *`, so it's traversable exactly like a real type name —
12120        // not just its own scalar properties, but a *link's* properties too
12121        // (`person.company.name`), via the same path-traversal machinery
12122        // `TypeName.a.b.c` already uses (compile_path_select), wrapped as a
12123        // correlated subquery (IrExpr::PathSubquery).
12124        let schema = make_schema();
12125        let out = compile_and_emit_with(
12126            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
12127             select Company filter .name = person.company.name;",
12128            &schema,
12129        );
12130        assert!(
12131            out.sql.contains("(SELECT") && out.sql.contains("\"name\"") && out.sql.contains("\"Company\""),
12132            "got:\n{}",
12133            out.sql
12134        );
12135    }
12136
12137    #[test]
12138    fn test_with_bound_schema_object_bare_link_reduces_to_id() {
12139        // A bare link at the end of the chain (`person.company`, no further
12140        // traversal) reduces to the linked object's id, matching the
12141        // existing bare-CTE-reference convention (IrExpr::CteRef).
12142        let schema = make_schema();
12143        let out = compile_and_emit_with(
12144            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
12145             select Person filter .id = person.company;",
12146            &schema,
12147        );
12148        assert!(
12149            out.sql.contains("(SELECT") && out.sql.contains("\"id\""),
12150            "got:\n{}",
12151            out.sql
12152        );
12153    }
12154
12155    #[test]
12156    fn test_with_bound_schema_object_unknown_property_suggests_a_close_match() {
12157        let schema = make_schema();
12158        let ast = parse::parse(
12159            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
12160             select Person filter .name = person.nam;",
12161        )
12162        .unwrap();
12163        match ir::compile(&ast, &schema) {
12164            Err(err) => assert!(format!("{err}").contains("Did you mean 'name'"), "got: {err}"),
12165            Ok(_) => panic!("expected a compile error"),
12166        }
12167    }
12168
12169    #[test]
12170    fn test_with_bound_free_object_passthrough_preserves_all_fields() {
12171        // Regression: `with test := { test2 := 1.0, test3 := 'str' } select
12172        // test;` decoded as just `1.0` (the CTE's first field) — a bare
12173        // reference to a free-object CTE (`IrFreeExpr::CtePassthrough`) had
12174        // its shape hardcoded to `ShapeNode::Scalar` regardless of what the
12175        // CTE actually held.
12176        let out = compile_and_emit("with\n  test := { test2 := 1.0, test3 := 'str' }\nselect test;");
12177        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
12178            panic!("expected Object shape, got {:?}", out.shape.root)
12179        };
12180        assert_eq!(pointers.len(), 2);
12181        assert!(matches!(&pointers[0], ShapeNode::Scalar { name, .. } if name == "test2"));
12182        assert!(matches!(&pointers[1], ShapeNode::Scalar { name, .. } if name == "test3"));
12183    }
12184
12185    #[test]
12186    fn test_with_bound_free_object_field_access() {
12187        // Regression: `with test := {...} select test.test2;` failed with
12188        // "unknown type 'test'" — any absolute path with 2+ steps
12189        // unconditionally routed to compile_path_select, which only knows
12190        // how to resolve real schema types, never a free-value CTE.
12191        let out = compile_and_emit("with\n  test := { test2 := 1.0, test3 := 'str' }\nselect test.test2;");
12192        assert!(out.sql.contains("\"test2\" FROM \"test\""), "got:\n{}", out.sql);
12193    }
12194
12195    #[test]
12196    fn test_with_bound_free_object_nested_field_access_chain() {
12197        // A field-access chain through a *nested* free object literal
12198        // (`test.test3.foo`) must resolve the first hop via the CTE's own
12199        // materialized column, then extract `foo` as jsonb from that value
12200        // — the fix must not be hardcoded to exactly 2 path steps.
12201        let out =
12202            compile_and_emit("with\n  test := { test2 := 1.0, test3 := { foo := 'bar' } }\nselect test.test3.foo;");
12203        assert!(out.sql.contains("\"test3\" FROM \"test\""), "got:\n{}", out.sql);
12204        assert!(
12205            out.sql.contains("->'foo'"),
12206            "expected jsonb field extraction, got:\n{}",
12207            out.sql
12208        );
12209    }
12210
12211    #[test]
12212    fn test_with_bound_free_object_nested_field_access_wrong_field_errors() {
12213        // A typo'd field name anywhere in the chain must still be a compile
12214        // error, not silently emit SQL that returns NULL at runtime.
12215        let schema = make_schema();
12216        let ast = parse::parse("with\n  test := { test2 := 1.0, test3 := { foo := 'bar' } }\nselect test.test3.nope;")
12217            .unwrap();
12218        assert!(ir::compile(&ast, &schema).is_err());
12219    }
12220
12221    #[test]
12222    fn test_nested_free_object_literal_in_computed_shape_element() {
12223        // Regression: a free object literal (`{ foo := 'bar' }`) nested
12224        // inside a computed shape element — not the top-level SELECT
12225        // result — hit "shapes and set literals are not valid in
12226        // expression context"; free object literals were only ever handled
12227        // at the statement level.
12228        let out = compile_and_emit("select default::Person { id, test := { foo := 'bar' } };");
12229        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
12230            panic!("expected Object shape")
12231        };
12232        let test_node = pointers
12233            .iter()
12234            .find(|p| matches!(p, ShapeNode::Object { name, .. } if name == "test"))
12235            .unwrap_or_else(|| panic!("expected an Object shape node for 'test', got {:?}", pointers));
12236        // A free object reads as an object, and its one field keeps its own
12237        // type rather than being read back out of jsonb.
12238        let ShapeNode::Object {
12239            type_name, pointers, ..
12240        } = test_node
12241        else {
12242            unreachable!("matched as an object just above")
12243        };
12244        assert_eq!(*type_name, None);
12245        assert!(
12246            matches!(&pointers[..], [ShapeNode::Scalar { name, .. }] if name == "foo"),
12247            "got {pointers:?}"
12248        );
12249    }
12250
12251    #[test]
12252    fn test_bare_free_cte_reference_in_computed_shape_collapses_to_empty() {
12253        // A free-object CTE referenced bare (no shape) as a computed shape
12254        // element's value has nothing to project — a free object needs an
12255        // explicit shape to know what to expose (unlike a tuple, which is
12256        // a plain value with no such requirement).
12257        let out = compile_and_emit(
12258            "with\n  test := { test2 := 1.0, test3 := 'str' }\n\
12259             select default::Person { id, test := test };",
12260        );
12261        assert!(
12262            out.sql.contains("ROW()"),
12263            "expected an empty free object, got:\n{}",
12264            out.sql
12265        );
12266    }
12267
12268    #[test]
12269    fn test_shaped_free_cte_reference_projects_fields() {
12270        // `test := test { test2 }` must project just the named field out
12271        // of the underlying free object — neither collapsing to empty nor
12272        // returning every field.
12273        let out = compile_and_emit(
12274            "with\n  test := { test2 := 1.0, test3 := 'str' }\n\
12275             select default::Person { id, test := test { test2 } };",
12276        );
12277        // Projected out of the row, so the field keeps its own type.
12278        assert!(
12279            out.sql.contains("ROW((SELECT \"test2\" FROM \"test\"))"),
12280            "got:\n{}",
12281            out.sql
12282        );
12283        assert!(
12284            !out.sql.contains("'test3'"),
12285            "test3 should not be projected, got:\n{}",
12286            out.sql
12287        );
12288    }
12289
12290    #[test]
12291    fn test_insert_multilink_remove_rejected() {
12292        let schema = make_schema_with_through_and_prop();
12293        let ast =
12294            crate::parse::parse("INSERT Product { name := $name, tags -= (SELECT Tag FILTER .id = $tid) }").unwrap();
12295        match crate::ir::compile(&ast, &schema) {
12296            Ok(_) => panic!("expected a compile error for `-=` on a multi-link at insert time"),
12297            Err(e) => assert!(
12298                e.to_string().contains("nothing to remove"),
12299                "expected a 'nothing to remove yet' error, got: {e}"
12300            ),
12301        }
12302    }
12303
12304    #[test]
12305    fn test_multilink_junction_info_disambiguates_self_referencing_through_type() {
12306        // Regression test for a self-referencing through-link (Person.friends
12307        // via PersonFriend, which declares two Person-typed links: "person"
12308        // and "friend"). Naively matching purely by target type resolves
12309        // *both* source_col and target_col to the same first-matching link
12310        // ("person"), silently collapsing the junction to a single column
12311        // and losing the other side entirely.
12312        let schema = make_schema_with_through();
12313        let out = compile_and_emit_with(
12314            "UPDATE Person FILTER .id = $id SET { friends += (SELECT Person FILTER .id = $fid) }",
12315            &schema,
12316        );
12317        assert!(
12318            out.sql.contains("(\"person\", \"friend\")"),
12319            "expected two distinct FK columns:\n{}",
12320            out.sql
12321        );
12322        assert!(
12323            !out.sql.contains("(\"person\", \"person\")"),
12324            "source/target collapsed to the same column:\n{}",
12325            out.sql
12326        );
12327    }
12328
12329    #[test]
12330    fn test_shape_descriptor_scalars() {
12331        let out = compile_and_emit("SELECT Person { name, age }");
12332        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
12333            panic!()
12334        };
12335        assert_eq!(pointers.len(), 4); // __type__, the implicit id, name, age
12336        assert!(matches!(&pointers[0], ShapeNode::Scalar { name, position: 0 } if name == "__type__"));
12337        assert!(matches!(&pointers[1], ShapeNode::Scalar { name, position: 1 } if name == "id"));
12338        assert!(matches!(&pointers[2], ShapeNode::Scalar { name, position: 2 } if name == "name"));
12339        assert!(matches!(&pointers[3], ShapeNode::Scalar { name, position: 3 } if name == "age"));
12340    }
12341
12342    #[test]
12343    fn test_shape_descriptor_multi_link() {
12344        let out = compile_and_emit("SELECT Person { name, posts { title } }");
12345        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
12346            panic!()
12347        };
12348        // pointers: [__type__, id, name, posts]
12349        assert_eq!(pointers.len(), 4);
12350        let ShapeNode::Array {
12351            name,
12352            position,
12353            element,
12354        } = &pointers[3]
12355        else {
12356            panic!()
12357        };
12358        assert_eq!(name, "posts");
12359        assert_eq!(*position, 3);
12360        let ShapeNode::Object {
12361            pointers: elem_pointers,
12362            ..
12363        } = element.as_ref()
12364        else {
12365            panic!()
12366        };
12367        // element pointers: [__type__, title] — `Post` has no pk property in
12368        // this fixture, so nothing is injected into its shape.
12369        assert_eq!(elem_pointers.len(), 2);
12370    }
12371
12372    #[test]
12373    fn test_select_order_by_limit() {
12374        let out = compile_and_emit("SELECT Person { name } ORDER BY .name ASC LIMIT 10");
12375        assert!(out.sql.contains("ORDER BY"));
12376        assert!(out.sql.contains("LIMIT 10"));
12377    }
12378
12379    #[test]
12380    fn test_insert_returning() {
12381        let out = compile_and_emit("INSERT Person { name := 'Alice', age := 30 }");
12382        assert!(out.sql.contains("INSERT INTO \"public\".\"Person\""));
12383        assert!(out.sql.contains("RETURNING"));
12384        assert!(out.sql.contains("'default::Person'::text"));
12385        assert!(out.sql.contains(") AS result"));
12386        // Bare INSERT returns pk only
12387        let ShapeNode::Object {
12388            cardinality, pointers, ..
12389        } = &out.shape.root
12390        else {
12391            panic!()
12392        };
12393        assert_eq!(*cardinality, Cardinality::Required);
12394        // Only __type__ and id — not name or age
12395        assert!(
12396            pointers
12397                .iter()
12398                .any(|f| matches!(f, ShapeNode::Scalar { name, .. } if name == "id"))
12399        );
12400        assert!(
12401            !pointers
12402                .iter()
12403                .any(|f| matches!(f, ShapeNode::Scalar { name, .. } if name == "name"))
12404        );
12405    }
12406
12407    #[test]
12408    fn test_update_returning() {
12409        let out = compile_and_emit("UPDATE Person FILTER .name = $name SET { age := 31 }");
12410        assert!(out.sql.contains("UPDATE \"public\".\"Person\""));
12411        assert!(out.sql.contains("SET"));
12412        // Bare UPDATE returns pk only
12413        assert!(out.sql.contains("RETURNING"));
12414        assert!(out.sql.contains("'default::Person'::text"));
12415        assert!(!out.sql.contains("\"name\"::text"), "bare UPDATE must not return name");
12416    }
12417
12418    #[test]
12419    fn test_update_set_tuple_param_cast_uses_direct_jsonb_cast_not_to_jsonb() {
12420        // Regression: to_jsonb($N) on a bare, still-untyped parameter fails at
12421        // execution time with "could not determine polymorphic type because
12422        // input has type unknown" — Postgres can't dispatch a polymorphic
12423        // function against an unknown-typed placeholder. A direct
12424        // ($N)::jsonb cast resolves the parameter's type from the cast
12425        // itself instead, matching how e.g. `$1::uuid` already works.
12426        let out = compile_and_emit("UPDATE Person FILTER .id = $id SET { age := <tuple<x: float64>>$val }");
12427        assert!(
12428            out.sql.contains(")::jsonb"),
12429            "expected a direct ::jsonb cast, got:\n{}",
12430            out.sql
12431        );
12432        assert!(
12433            !out.sql.contains("to_jsonb($"),
12434            "must not pass a bare param straight into to_jsonb(): got:\n{}",
12435            out.sql
12436        );
12437    }
12438
12439    #[test]
12440    fn test_empty_set_cast_to_object_type_clears_optional_link() {
12441        // Regression: unsetting an optional single-link generates
12442        // `<TargetType>{}` (a cast of the empty set, not the bare `{}` the
12443        // assignment-position special case in compile_assignments_inner
12444        // already handled) — that TypeCast wraps the empty set, so it fell
12445        // through to the generic Set/Shape rejection instead ("shapes and
12446        // set literals are not valid in expression context"). Must compile
12447        // to a plain NULL for the link's FK column: `<AnyType>{}` is always
12448        // NULL regardless of context.
12449        let out = compile_and_emit("UPDATE Person FILTER .id = $id SET { company := <default::Company>{} }");
12450        assert!(out.sql.contains("\"company_id\" = NULL"), "got:\n{}", out.sql);
12451    }
12452
12453    #[test]
12454    fn test_an_aggregate_over_a_set_literal_inside_a_shape() {
12455        // `min({cap, .age})` is one value read from a two-element set, and the
12456        // set is the aggregate's argument wherever the call is written. In a
12457        // schema-bound shape it used to report "shapes and set literals are not
12458        // valid in expression context" while the same call compiled at the top
12459        // level, so a saturating cap had no spelling here at all.
12460        let out = compile_and_emit("UPDATE Person FILTER .id = $id SET { age := min({<int64>100, .age}) }");
12461        assert!(out.sql.contains("min(v)"), "got:\n{}", out.sql);
12462        assert!(
12463            out.sql.contains("UNION ALL"),
12464            "both operands must reach the aggregate:\n{}",
12465            out.sql
12466        );
12467    }
12468
12469    #[test]
12470    fn test_an_aggregate_over_a_set_literal_reads_the_subject() {
12471        // The operands compile with the shape's own context, so a relative path
12472        // among them still resolves to the subject's column.
12473        let out = compile_and_emit("SELECT Person { capped := min({<int64>100, .age}) }");
12474        assert!(out.sql.contains("min(v)"), "got:\n{}", out.sql);
12475        assert!(
12476            out.sql.contains("\"age\""),
12477            "the relative path must reach the column:\n{}",
12478            out.sql
12479        );
12480    }
12481
12482    #[test]
12483    fn test_an_aggregate_over_a_union_inside_a_shape() {
12484        // `min((a union b))` says the same thing as `min({a, b})`, and used to
12485        // report "union is not valid in expression context".
12486        let out = compile_and_emit("SELECT Person { capped := min((<int64>100 union .age)) }");
12487        assert!(out.sql.contains("min(v)"), "got:\n{}", out.sql);
12488        assert!(
12489            out.sql.contains("UNION ALL"),
12490            "both arms must reach the aggregate:\n{}",
12491            out.sql
12492        );
12493    }
12494
12495    #[test]
12496    fn test_an_aggregate_over_a_union_of_three_arms_flattens() {
12497        let out = compile_and_emit("SELECT Person { capped := min((<int64>1 union <int64>2 union .age)) }");
12498        assert_eq!(
12499            out.sql.matches("UNION ALL").count(),
12500            2,
12501            "three arms means two joins:\n{}",
12502            out.sql
12503        );
12504    }
12505
12506    #[test]
12507    fn test_an_aggregate_over_an_empty_set_emits_no_row_source() {
12508        // An empty set has nothing to aggregate, and the row source it would
12509        // emit (`FROM () AS _set(v)`) is not valid SQL -- so `{}` must not be
12510        // taken for an aggregate's operands, in either context.
12511        for query in ["SELECT Person { capped := min({}) }", "SELECT min({})"] {
12512            let ast = parse::parse(query).expect("parse failed");
12513            let sql = ir::compile(&ast, &make_schema())
12514                .map(|ir| emit(&ir).sql)
12515                .unwrap_or_default();
12516            assert!(
12517                !sql.contains("FROM () AS"),
12518                "{query} emitted an empty row source:\n{sql}"
12519            );
12520        }
12521    }
12522
12523    #[test]
12524    fn test_delete_returning() {
12525        let out = compile_and_emit("DELETE Person FILTER .id = $id");
12526        assert!(out.sql.contains("DELETE FROM \"public\".\"Person\""));
12527        // Bare DELETE returns pk only
12528        assert!(out.sql.contains("RETURNING"));
12529        assert!(out.sql.contains("'default::Person'::text"));
12530        assert!(!out.sql.contains("\"name\"::text"), "bare DELETE must not return name");
12531    }
12532
12533    #[test]
12534    fn test_select_over_insert() {
12535        let out = compile_and_emit("SELECT (INSERT Person { name := $name, age := $age }) { id, name }");
12536        // Must use a CTE
12537        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
12538        assert!(out.sql.contains("INSERT INTO"));
12539        assert!(out.sql.contains("RETURNING *"));
12540        // Outer SELECT shapes the result
12541        assert!(out.sql.contains("'default::Person'::text"));
12542        assert!(out.sql.contains("\"name\"::text"));
12543    }
12544
12545    #[test]
12546    fn test_select_over_update() {
12547        let out = compile_and_emit("SELECT (UPDATE Person FILTER .id = $id SET { name := $name }) { id, name }");
12548        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
12549        assert!(out.sql.contains("UPDATE"));
12550        assert!(out.sql.contains("RETURNING \"t1\".*"), "{}", out.sql);
12551        assert!(out.sql.contains("\"name\"::text"));
12552    }
12553
12554    #[test]
12555    fn test_insert_user_specified_id_denied_by_default() {
12556        let schema = make_schema();
12557        let ast = parse::parse("INSERT Person { id := <uuid>$id, name := $name, age := $age }").unwrap();
12558        match ir::compile_with_config(&ast, &schema, &ir::SessionConfig::default()) {
12559            Err(err) => assert!(err.to_string().contains("cannot assign to property 'id'"), "got: {err}"),
12560            Ok(_) => panic!("expected id assignment to be denied by default"),
12561        }
12562    }
12563
12564    #[test]
12565    fn test_insert_user_specified_id_allowed_when_configured() {
12566        let schema = make_schema();
12567        let ast = parse::parse("INSERT Person { id := <uuid>$id, name := $name, age := $age }").unwrap();
12568        let config = ir::SessionConfig {
12569            allow_user_specified_id: true,
12570        };
12571        let ir_out = ir::compile_with_config(&ast, &schema, &config)
12572            .expect("expected id assignment to be allowed with allow_user_specified_id");
12573        let out = emit(&ir_out);
12574        assert!(out.sql.contains("INSERT INTO"));
12575    }
12576
12577    #[test]
12578    fn test_update_user_specified_id_denied_even_when_configured() {
12579        let schema = make_schema();
12580        let ast = parse::parse("UPDATE Person FILTER .name = $name SET { id := <uuid>$id }").unwrap();
12581        let config = ir::SessionConfig {
12582            allow_user_specified_id: true,
12583        };
12584        match ir::compile_with_config(&ast, &schema, &config) {
12585            Err(err) => assert!(err.to_string().contains("cannot assign to property 'id'"), "got: {err}"),
12586            Ok(_) => panic!("expected UPDATE to always deny reassigning id"),
12587        }
12588    }
12589
12590    #[test]
12591    fn test_select_over_update_multilink_only() {
12592        // Regression test: an UPDATE bound to a single external name (here,
12593        // the implicit "_dml" wrapper for `SELECT (UPDATE ...)`) whose SET
12594        // clause is *only* a multi-link mutation, with no scalar/single-link
12595        // assignment. This used to either emit an empty `SET` clause
12596        // ("UPDATE ... SET  WHERE ..." — invalid SQL) or, in an earlier fix
12597        // attempt, nest the junction INSERT inside "_dml"'s own CTE body —
12598        // which Postgres rejects outright ("WITH clause containing a
12599        // data-modifying statement must be at the top level"). The junction
12600        // CTEs must instead be hoisted out as top-level siblings of "_dml".
12601        let out = compile_and_emit(
12602            "SELECT (UPDATE Person FILTER .id = $id SET { posts += (SELECT Post FILTER .title = $title) }) { id, name }",
12603        );
12604        assert!(
12605            out.sql.contains("\"_dml__ml_add_0\""),
12606            "missing junction-append CTE:\n{}",
12607            out.sql
12608        );
12609        assert!(out.sql.contains("INSERT INTO"), "missing junction INSERT:\n{}", out.sql);
12610        // No scalar changes -> the row-source CTE must be a SELECT, not an UPDATE
12611        // with an empty SET clause.
12612        assert!(
12613            out.sql.contains("\"_dml__ids\" AS (\nSELECT"),
12614            "expected SELECT-based _ids CTE:\n{}",
12615            out.sql
12616        );
12617        assert!(
12618            !out.sql.contains("SET\n\nWHERE") && !out.sql.contains("SET \nWHERE"),
12619            "empty SET clause regression:\n{}",
12620            out.sql
12621        );
12622        // The junction CTE must be a *sibling* at the top-level WITH, not
12623        // nested inside another CTE's body — only one "WITH" keyword total.
12624        assert_eq!(
12625            out.sql.matches("WITH\n").count(),
12626            1,
12627            "junction CTE must not be nested in a second WITH:\n{}",
12628            out.sql
12629        );
12630        // "_dml" itself must still resolve (as a passthrough) for the outer SELECT.
12631        assert!(
12632            out.sql.contains("\"_dml\" AS (\n    SELECT * FROM \"_dml__ids\"\n)"),
12633            "missing _dml passthrough:\n{}",
12634            out.sql
12635        );
12636    }
12637
12638    #[test]
12639    fn test_select_over_update_scalar_and_multilink() {
12640        // Mixed case: a scalar assignment alongside a multi-link mutation, both
12641        // bound to the same external name — the scalar SET must survive *and*
12642        // the junction mutation must still be emitted as its own top-level CTE.
12643        let out = compile_and_emit(
12644            "SELECT (UPDATE Person FILTER .id = $id SET { name := $name, posts += (SELECT Post FILTER .title = $title) }) { id, name }",
12645        );
12646        assert!(
12647            out.sql.contains("\"_dml__ml_add_0\""),
12648            "missing junction-append CTE:\n{}",
12649            out.sql
12650        );
12651        assert!(
12652            out.sql.contains("\"_dml__ids\" AS (\nUPDATE"),
12653            "expected UPDATE-based _ids CTE:\n{}",
12654            out.sql
12655        );
12656        assert!(
12657            out.sql.contains("\"name\" = "),
12658            "missing scalar SET assignment:\n{}",
12659            out.sql
12660        );
12661        assert_eq!(
12662            out.sql.matches("WITH\n").count(),
12663            1,
12664            "junction CTE must not be nested in a second WITH:\n{}",
12665            out.sql
12666        );
12667    }
12668
12669    #[test]
12670    fn test_with_bound_insert_and_multilink_update_forward_ref() {
12671        // The actual shape pylon-ui's Data Explorer generates for its
12672        // flagship "insert + link in one batch" scenario: a same-batch
12673        // forward-reference (Person.posts += a not-yet-existing Post, bound
12674        // to insert0) inside a multi-statement WITH. This is the exact query
12675        // that failed live against a real Postgres before this fix ("WITH
12676        // clause containing a data-modifying statement must be at the top
12677        // level"), so it's the most important regression to pin down.
12678        let out = compile_and_emit(
12679            "with insert0 := (insert Post { title := $title }), update0 := (update Person filter .id = $id set { posts += (select insert0) }) select { insert0, update0 }",
12680        );
12681        assert!(
12682            out.sql.contains("\"insert0\" AS (\n    INSERT INTO"),
12683            "missing insert0 CTE:\n{}",
12684            out.sql
12685        );
12686        assert!(
12687            out.sql.contains("\"update0__ml_add_0\""),
12688            "missing junction-append CTE for update0:\n{}",
12689            out.sql
12690        );
12691        assert!(
12692            out.sql.contains("\"update0__ids\" AS (\nSELECT"),
12693            "expected SELECT-based update0 ids CTE (no scalar changes):\n{}",
12694            out.sql
12695        );
12696        assert!(
12697            out.sql
12698                .contains("\"update0\" AS (\n    SELECT * FROM \"update0__ids\"\n)"),
12699            "missing update0 passthrough:\n{}",
12700            out.sql
12701        );
12702        // Exactly one WITH keyword — every CTE (insert0, update0__ids,
12703        // update0__ml_add_0, update0) must be a top-level sibling.
12704        assert_eq!(
12705            out.sql.matches("WITH\n").count(),
12706            1,
12707            "must be a single flat top-level WITH block:\n{}",
12708            out.sql
12709        );
12710    }
12711
12712    #[test]
12713    fn test_select_over_delete() {
12714        let out = compile_and_emit("SELECT (DELETE Person FILTER .id = $id) { id, name }");
12715        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
12716        assert!(out.sql.contains("DELETE FROM"));
12717        assert!(out.sql.contains("RETURNING *"));
12718        assert!(out.sql.contains("\"name\"::text"));
12719    }
12720
12721    fn make_schema_with_rewrite() -> SchemaDescriptor {
12722        use crate::schema::RewriteEntry;
12723        let mut schema = make_schema();
12724        // Add a `slug` property to Person with an INSERT+UPDATE rewrite: lower(.name)
12725        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
12726        person.properties.push(PropertyDescriptor {
12727            name: "slug".into(),
12728            pg_type: "text".into(),
12729            nullable: true,
12730            default_sql: None,
12731            default_pyql: None,
12732            description: None,
12733            check_constraints: vec![],
12734            is_exclusive: false,
12735            is_pk: false,
12736            is_readonly: false,
12737            rewrites: vec![
12738                RewriteEntry {
12739                    on: 1,
12740                    handler: "str_lower(.name)".into(),
12741                }, // INSERT
12742                RewriteEntry {
12743                    on: 2,
12744                    handler: "str_lower(.name)".into(),
12745                }, // UPDATE
12746            ],
12747            tuple_members: None,
12748            column_type: None,
12749        });
12750        schema
12751    }
12752
12753    #[test]
12754    fn a_link_assigned_from_a_select_keeps_its_clauses() {
12755        let out = compile_and_emit(
12756            "INSERT Person { name := 'a', age := 1, company := (SELECT Company ORDER BY .name LIMIT 1) }",
12757        );
12758        assert!(
12759            out.sql.contains("ORDER BY") && out.sql.contains("LIMIT 1"),
12760            "{}",
12761            out.sql
12762        );
12763    }
12764
12765    #[test]
12766    fn a_rewrite_leaves_the_statement_alone() {
12767        // It runs in the table's own `BEFORE` trigger, on the row as written.
12768        let schema = make_schema_with_rewrite();
12769        for query in [
12770            "INSERT Person { name := $name, age := 30 }",
12771            "UPDATE Person FILTER .id = $id SET { name := $name }",
12772        ] {
12773            let out = compile_and_emit_with(query, &schema);
12774            assert!(!out.sql.contains("\"slug\""), "{}", out.sql);
12775        }
12776    }
12777
12778    #[test]
12779    fn a_rewrite_reads_the_row_being_written() {
12780        let schema = make_schema_with_rewrite();
12781        let assignments = crate::ir::compile_rewrite_assignments("default::Person", 1, &schema).unwrap();
12782        let [assignment] = assignments.as_slice() else {
12783            panic!("one rewrite on insert")
12784        };
12785        assert_eq!(assignment.column, "slug");
12786        assert!(assignment.sql.contains("NEW.\"name\""), "{}", assignment.sql);
12787    }
12788
12789    #[test]
12790    fn rewrites_become_before_triggers() {
12791        let ddl = crate::export::export_schema(&make_schema_with_rewrite()).unwrap();
12792        for event in ["BEFORE INSERT", "BEFORE UPDATE"] {
12793            assert!(
12794                ddl.contains(&format!("{event} ON \"public\".\"Person\"")),
12795                "{event}:\n{ddl}"
12796            );
12797        }
12798        assert!(ddl.contains("NEW.\"slug\" := _pylon_rewrites.\"v0\";"), "{ddl}");
12799    }
12800
12801    #[test]
12802    fn a_rewrite_walking_a_link_starts_from_the_new_row() {
12803        // The row is not in its table yet when a `BEFORE INSERT` trigger runs.
12804        use crate::schema::RewriteEntry;
12805        let mut schema = make_schema_with_rewrite();
12806        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
12807        let slug = person.properties.iter_mut().find(|p| p.name == "slug").unwrap();
12808        slug.rewrites = vec![RewriteEntry {
12809            on: 1,
12810            handler: "<str>(.company is Company) ++ .company.name".into(),
12811        }];
12812        let assignments = crate::ir::compile_rewrite_assignments("default::Person", 1, &schema).unwrap();
12813        let sql = &assignments[0].sql;
12814        assert!(!sql.contains("\"public\".\"Person\""), "{sql}");
12815        assert!(
12816            sql.contains("(SELECT (NEW).*)") && sql.contains("NEW.\"company_id\""),
12817            "{sql}"
12818        );
12819    }
12820
12821    #[test]
12822    fn test_unless_conflict_do_nothing() {
12823        let out = compile_and_emit("INSERT Person { name := $name } UNLESS CONFLICT");
12824        assert!(out.sql.contains("ON CONFLICT DO NOTHING"));
12825    }
12826
12827    #[test]
12828    fn test_unless_conflict_on_do_nothing() {
12829        let out = compile_and_emit("INSERT Person { name := $name } UNLESS CONFLICT ON .name");
12830        assert!(out.sql.contains("ON CONFLICT (\"name\") DO NOTHING"));
12831    }
12832
12833    #[test]
12834    fn test_unless_conflict_do_update() {
12835        let out = compile_and_emit(
12836            "INSERT Person { name := $name, age := $age } \
12837             UNLESS CONFLICT ON .name \
12838             ELSE (UPDATE Person SET { age := $age })",
12839        );
12840        assert!(out.sql.contains("ON CONFLICT (\"name\") DO UPDATE SET"));
12841        assert!(out.sql.contains("\"age\" = $2"));
12842        // The param $age is shared — same index as in the INSERT VALUES
12843        assert!(!out.sql.contains("DO NOTHING"));
12844    }
12845
12846    #[test]
12847    fn test_unless_conflict_else_update_self_reference_is_qualified() {
12848        // Regression: a self-referencing ELSE-update assignment (`age :=
12849        // .age + 1`) used to compile its RHS with an empty (unqualified)
12850        // alias, emitting a bare `"age"` — ambiguous in Postgres's `ON
12851        // CONFLICT DO UPDATE SET` context between the existing row and the
12852        // `excluded` pseudo-row (confirmed live: "column reference ... is
12853        // ambiguous"). It must be qualified with the target table's own
12854        // bare name instead.
12855        let out = compile_and_emit(
12856            "INSERT Person { name := $name, age := 0 } \
12857             UNLESS CONFLICT ON .name \
12858             ELSE (UPDATE Person SET { age := .age + 1 })",
12859        );
12860        assert!(
12861            out.sql.contains("\"Person\".\"age\""),
12862            "self-reference must be qualified with the table's own name, got:\n{}",
12863            out.sql
12864        );
12865        assert!(
12866            !out.sql.contains("SET \"age\" = (\"age\""),
12867            "must not emit an unqualified (ambiguous) self-reference, got:\n{}",
12868            out.sql
12869        );
12870    }
12871
12872    #[test]
12873    fn test_unless_conflict_do_update_no_on() {
12874        let out = compile_and_emit(
12875            "INSERT Person { name := $name } \
12876             UNLESS CONFLICT \
12877             ELSE (UPDATE Person SET { age := 0 })",
12878        );
12879        assert!(out.sql.contains("ON CONFLICT DO UPDATE SET"));
12880        assert!(out.sql.contains("\"age\" = 0"));
12881    }
12882
12883    #[test]
12884    fn test_select_over_select() {
12885        let out = compile_and_emit("SELECT (SELECT Person FILTER .age > 18) { name }");
12886        // Must use a CTE
12887        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
12888        // CTE exposes raw columns via SELECT *
12889        assert!(out.sql.contains("SELECT *"));
12890        assert!(out.sql.contains("FROM \"public\".\"Person\""));
12891        // CTE carries the inner filter
12892        assert!(out.sql.contains("WHERE"));
12893        // Outer SELECT projects its own shape
12894        assert!(out.sql.contains("'default::Person'::text"));
12895        assert!(out.sql.contains("\"name\"::text"));
12896    }
12897
12898    #[test]
12899    fn test_select_over_select_with_outer_filter() {
12900        let out = compile_and_emit("SELECT (SELECT Person FILTER .age > 18) { name } FILTER .name = $name");
12901        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
12902        assert!(out.sql.contains("SELECT *"));
12903        // Both filters present: one inside CTE, one in outer SELECT
12904        assert_eq!(out.sql.matches("WHERE").count(), 2);
12905        assert!(out.sql.contains("$1"));
12906    }
12907
12908    #[test]
12909    fn test_insert_link_subquery() {
12910        let out = compile_and_emit("INSERT Person { name := $name, company := (SELECT Company FILTER .name = $co) }");
12911        // The company FK column should be assigned via a scalar subquery
12912        assert!(out.sql.contains("\"company_id\""));
12913        assert!(out.sql.contains("SELECT"));
12914        // The subquery must select the pk (id) of Company
12915        assert!(out.sql.contains("\"id\""));
12916        assert!(out.sql.contains("FROM \"public\".\"Company\""));
12917        // Filter param must appear
12918        assert!(out.sql.contains("$2")); // $1 = name, $2 = co
12919    }
12920
12921    #[test]
12922    fn test_update_link_subquery() {
12923        let out =
12924            compile_and_emit("UPDATE Person FILTER .id = $id SET { company := (SELECT Company FILTER .name = $co) }");
12925        assert!(out.sql.contains("\"company_id\""));
12926        assert!(out.sql.contains("SELECT"));
12927        assert!(out.sql.contains("FROM \"public\".\"Company\""));
12928    }
12929
12930    #[test]
12931    fn test_insert_link_value_from_nested_insert_hoists_a_with_cte() {
12932        // `company := (select (insert Company {...}) { id })` — Postgres has
12933        // no way to run a nested INSERT inside another statement's VALUES
12934        // list without hoisting it into its own WITH CTE first (confirmed
12935        // live against real Postgres — see live_execution_insert_nested.rs).
12936        // Regression: an earlier attempt at this compiled cleanly but
12937        // silently emitted a subquery that dropped the nested INSERT
12938        // entirely and selected an arbitrary unrelated row instead.
12939        let out = compile_and_emit(
12940            "INSERT Person { name := 'Alice', company := (select (insert Company { name := 'Acme' }) { id }) }",
12941        );
12942        assert!(
12943            out.sql.starts_with("WITH"),
12944            "expected a WITH-hoisted CTE, got:\n{}",
12945            out.sql
12946        );
12947        assert!(
12948            out.sql.contains("INSERT INTO \"public\".\"Company\""),
12949            "expected the nested insert to be its own CTE, got:\n{}",
12950            out.sql,
12951        );
12952        // The outer insert must reference the nested CTE's own id, not a
12953        // freestanding subquery against the Company table.
12954        assert!(
12955            out.sql.contains("\"company_id\") SELECT") && out.sql.contains(".\"id\" FROM"),
12956            "expected the outer insert to switch from VALUES to SELECT ... FROM <cte>, got:\n{}",
12957            out.sql,
12958        );
12959        assert!(
12960            !out.sql.contains("FROM \"public\".\"Company\" AS"),
12961            "must not select from the real Company table, got:\n{}",
12962            out.sql
12963        );
12964    }
12965
12966    #[test]
12967    fn test_update_link_value_from_nested_insert_hoists_a_with_cte_and_adds_from_clause() {
12968        let out = compile_and_emit(
12969            "UPDATE Person FILTER .id = $id SET { company := (select (insert Company { name := 'Acme' }) { id }) }",
12970        );
12971        assert!(
12972            out.sql.starts_with("WITH"),
12973            "expected a WITH-hoisted CTE, got:\n{}",
12974            out.sql
12975        );
12976        assert!(
12977            out.sql.contains("INSERT INTO \"public\".\"Company\""),
12978            "expected the nested insert to be its own CTE, got:\n{}",
12979            out.sql,
12980        );
12981        // Plain `UPDATE ... SET ...` has no FROM clause of its own — one
12982        // must be added so the SET clause can reference the CTE's column.
12983        assert!(
12984            out.sql.contains("\nFROM \""),
12985            "expected a FROM clause referencing the nested CTE, got:\n{}",
12986            out.sql
12987        );
12988        assert!(out.sql.contains("SET \"company_id\" = "), "got:\n{}", out.sql);
12989    }
12990
12991    #[test]
12992    fn test_update_link_value_from_nested_insert_combines_with_multilink_mutation() {
12993        // A nested-insert link value and a `+=` multi-link mutation in the
12994        // same UPDATE route through a completely different emitter branch
12995        // (the junction-CTE "_ids" path, not the plain-UPDATE path the
12996        // previous test covers) — both must still see the hoisted CTE.
12997        let out = compile_and_emit(
12998            "UPDATE Person FILTER .id = $id SET { \
12999                 company := (select (insert Company { name := 'Acme' }) { id }), \
13000                 posts += (SELECT Post FILTER .title = $t) \
13001             }",
13002        );
13003        assert!(out.sql.starts_with("WITH"), "got:\n{}", out.sql);
13004        assert!(
13005            out.sql.contains("INSERT INTO \"public\".\"Company\""),
13006            "expected the nested insert to be its own CTE, got:\n{}",
13007            out.sql,
13008        );
13009        assert!(
13010            out.sql.contains("\"_ids\" AS (\nUPDATE") && out.sql.contains("\nFROM \""),
13011            "expected the _ids UPDATE to gain a FROM clause referencing the nested CTE, got:\n{}",
13012            out.sql,
13013        );
13014        assert!(
13015            out.sql.contains("\"_ml_add_0\""),
13016            "expected the junction-append CTE to still be present, got:\n{}",
13017            out.sql
13018        );
13019    }
13020
13021    #[test]
13022    fn test_update_link_value_from_nested_insert_combines_with_interface_target() {
13023        // A nested-insert link value on an UPDATE targeting an interface
13024        // (abstract + materialized) type routes through the poly fan-out
13025        // branch (emit_poly_update_stmt) — a third, independent emitter path
13026        // from the previous two tests. The hoisted CTE must be materialized
13027        // once and referenced from every per-implementor UPDATE branch.
13028        fn id_prop() -> PropertyDescriptor {
13029            PropertyDescriptor {
13030                name: "id".into(),
13031                pg_type: "uuid".into(),
13032                nullable: false,
13033                default_sql: Some("uuidv7()".into()),
13034                default_pyql: None,
13035                description: None,
13036                check_constraints: vec![],
13037                is_exclusive: true,
13038                is_pk: true,
13039                is_readonly: true,
13040                rewrites: vec![],
13041                tuple_members: None,
13042                column_type: None,
13043            }
13044        }
13045        fn text_prop(name: &str) -> PropertyDescriptor {
13046            PropertyDescriptor {
13047                name: name.into(),
13048                pg_type: "text".into(),
13049                nullable: false,
13050                default_sql: None,
13051                default_pyql: None,
13052                description: None,
13053                check_constraints: vec![],
13054                is_exclusive: false,
13055                is_pk: false,
13056                is_readonly: false,
13057                rewrites: vec![],
13058                tuple_members: None,
13059                column_type: None,
13060            }
13061        }
13062        fn company_link() -> LinkDescriptor {
13063            LinkDescriptor {
13064                name: "company".into(),
13065                target: "default::Company".into(),
13066                nullable: true,
13067                through: None,
13068                description: None,
13069                default_pyql: None,
13070                is_exclusive: false,
13071                is_readonly: false,
13072                rewrites: vec![],
13073                on_delete: vec![],
13074            }
13075        }
13076        let schema = SchemaDescriptor {
13077            types: vec![
13078                TypeDescriptor {
13079                    name: "Company".into(),
13080                    module: "default".into(),
13081                    table: "Company".into(),
13082                    abstract_: false,
13083                    materialized: true,
13084                    description: None,
13085                    parents: vec![],
13086                    interfaces: vec![],
13087                    bases: vec![],
13088                    properties: vec![id_prop(), text_prop("name")],
13089                    links: vec![],
13090                    multilinks: vec![],
13091                    computed: vec![],
13092                    constraints: vec![],
13093                    indexes: vec![],
13094                    partition: None,
13095                    vector_indexes: vec![],
13096                    search_indexes: vec![],
13097                    triggers: vec![],
13098                    junction: false,
13099                    signals: vec![],
13100                },
13101                TypeDescriptor {
13102                    name: "Account".into(),
13103                    module: "default".into(),
13104                    table: "Account".into(),
13105                    abstract_: true,
13106                    materialized: true,
13107                    description: None,
13108                    parents: vec![],
13109                    interfaces: vec![],
13110                    bases: vec![],
13111                    properties: vec![id_prop(), text_prop("email")],
13112                    links: vec![company_link()],
13113                    multilinks: vec![],
13114                    computed: vec![],
13115                    constraints: vec![],
13116                    indexes: vec![],
13117                    partition: None,
13118                    vector_indexes: vec![],
13119                    search_indexes: vec![],
13120                    triggers: vec![],
13121                    junction: false,
13122                    signals: vec![],
13123                },
13124                TypeDescriptor {
13125                    name: "Individual".into(),
13126                    module: "default".into(),
13127                    table: "Individual".into(),
13128                    abstract_: false,
13129                    materialized: true,
13130                    description: None,
13131                    parents: vec![],
13132                    interfaces: vec!["default::Account".into()],
13133                    bases: vec![],
13134                    properties: vec![id_prop(), text_prop("email"), text_prop("first_name")],
13135                    links: vec![company_link()],
13136                    multilinks: vec![],
13137                    computed: vec![],
13138                    constraints: vec![],
13139                    indexes: vec![],
13140                    partition: None,
13141                    vector_indexes: vec![],
13142                    search_indexes: vec![],
13143                    triggers: vec![],
13144                    junction: false,
13145                    signals: vec![],
13146                },
13147            ],
13148            scalars: vec![],
13149            enums: vec![],
13150            named_tuples: vec![],
13151            globals: vec![],
13152            functions: vec![],
13153            aliases: vec![],
13154            channels: vec![],
13155            ..Default::default()
13156        };
13157        let out = compile_and_emit_with(
13158            "UPDATE Account FILTER .email = $email \
13159             SET { company := (select (insert Company { name := 'Acme' }) { id }) }",
13160            &schema,
13161        );
13162        assert!(out.sql.starts_with("WITH"), "got:\n{}", out.sql);
13163        assert!(
13164            out.sql.contains("INSERT INTO \"public\".\"Company\""),
13165            "expected the nested insert to be its own CTE, got:\n{}",
13166            out.sql,
13167        );
13168        assert!(
13169            out.sql.contains("UPDATE \"public\".\"Individual\"") && out.sql.contains("\nFROM \"_nested_dml_0\""),
13170            "expected the per-implementor UPDATE to gain a FROM clause referencing the nested CTE, got:\n{}",
13171            out.sql,
13172        );
13173    }
13174
13175    #[test]
13176    fn test_a_backlink_narrowed_to_a_supertype() {
13177        // `.<company[is Account]` — the intersection narrows what comes back,
13178        // while the link itself is declared further down. Pylon looked for it
13179        // on the narrowed type alone and reported it had no such link.
13180        let mut schema = interface_link_schema();
13181        // Leave `company` declared only on the implementor, so the narrowed
13182        // type genuinely does not have it.
13183        schema
13184            .types
13185            .iter_mut()
13186            .find(|t| t.name == "Account")
13187            .expect("the helper declares Account")
13188            .links
13189            .clear();
13190        let out = compile_and_emit_with("SELECT Company { owners := .<company[is Account] { email } }", &schema);
13191        assert!(
13192            out.sql.contains("\"public\".\"Individual\""),
13193            "the backlink must read the type that declares it:\n{}",
13194            out.sql
13195        );
13196    }
13197
13198    #[test]
13199    fn a_path_through_a_backlink_narrowed_to_a_supertype() {
13200        let mut schema = interface_link_schema();
13201        schema
13202            .types
13203            .iter_mut()
13204            .find(|t| t.name == "Account")
13205            .expect("the helper declares Account")
13206            .links
13207            .clear();
13208        let out = compile_and_emit_with(
13209            "SELECT Company { name } FILTER EXISTS (SELECT .<company[is Account])",
13210            &schema,
13211        );
13212        assert!(out.sql.contains("\"public\".\"Individual\""), "{}", out.sql);
13213    }
13214
13215    /// Company, the Account interface, its Individual implementor and a
13216    /// Token linking to the interface -- enough to exercise reading and
13217    /// backlinking across one.
13218    fn interface_link_schema() -> SchemaDescriptor {
13219        // A link whose target is an interface is read by fanning the
13220        // implementors out inline. That union used to project only the
13221        // interface's *properties*, so a shape that then followed one of the
13222        // interface's own links failed with "column t1.company_id does not
13223        // exist".
13224        fn id_prop() -> PropertyDescriptor {
13225            PropertyDescriptor {
13226                name: "id".into(),
13227                pg_type: "uuid".into(),
13228                nullable: false,
13229                default_sql: None,
13230                default_pyql: None,
13231                description: None,
13232                check_constraints: vec![],
13233                is_exclusive: true,
13234                is_pk: true,
13235                is_readonly: true,
13236                rewrites: vec![],
13237                tuple_members: None,
13238                column_type: None,
13239            }
13240        }
13241        fn text_prop(name: &str) -> PropertyDescriptor {
13242            PropertyDescriptor {
13243                name: name.into(),
13244                pg_type: "text".into(),
13245                nullable: false,
13246                default_sql: None,
13247                default_pyql: None,
13248                description: None,
13249                check_constraints: vec![],
13250                is_exclusive: false,
13251                is_pk: false,
13252                is_readonly: false,
13253                rewrites: vec![],
13254                tuple_members: None,
13255                column_type: None,
13256            }
13257        }
13258        fn link(name: &str, target: &str) -> LinkDescriptor {
13259            LinkDescriptor {
13260                name: name.into(),
13261                target: target.into(),
13262                nullable: true,
13263                through: None,
13264                description: None,
13265                default_pyql: None,
13266                is_exclusive: false,
13267                is_readonly: false,
13268                rewrites: vec![],
13269                on_delete: vec![],
13270            }
13271        }
13272        fn ty(
13273            name: &str,
13274            abstract_: bool,
13275            interfaces: Vec<String>,
13276            properties: Vec<PropertyDescriptor>,
13277            links: Vec<LinkDescriptor>,
13278        ) -> TypeDescriptor {
13279            TypeDescriptor {
13280                name: name.into(),
13281                module: "default".into(),
13282                table: name.into(),
13283                abstract_,
13284                materialized: true,
13285                description: None,
13286                parents: vec![],
13287                interfaces,
13288                bases: vec![],
13289                properties,
13290                links,
13291                multilinks: vec![],
13292                computed: vec![],
13293                constraints: vec![],
13294                indexes: vec![],
13295                partition: None,
13296                vector_indexes: vec![],
13297                search_indexes: vec![],
13298                triggers: vec![],
13299                junction: false,
13300                signals: vec![],
13301            }
13302        }
13303        SchemaDescriptor {
13304            types: vec![
13305                ty("Company", false, vec![], vec![id_prop(), text_prop("name")], vec![]),
13306                ty(
13307                    "Account",
13308                    true,
13309                    vec![],
13310                    vec![id_prop(), text_prop("email")],
13311                    vec![link("company", "default::Company")],
13312                ),
13313                ty(
13314                    "Individual",
13315                    false,
13316                    vec!["default::Account".into()],
13317                    vec![id_prop(), text_prop("email")],
13318                    vec![link("company", "default::Company")],
13319                ),
13320                ty(
13321                    "Token",
13322                    false,
13323                    vec![],
13324                    vec![id_prop(), text_prop("value")],
13325                    vec![link("account", "default::Account")],
13326                ),
13327            ],
13328            scalars: vec![],
13329            enums: vec![],
13330            named_tuples: vec![],
13331            globals: vec![],
13332            functions: vec![],
13333            aliases: vec![],
13334            channels: vec![],
13335            ..Default::default()
13336        }
13337    }
13338
13339    /// `Token`, but reached through a second single link and narrowed to the
13340    /// interface's implementor -- conduit's trust-tier walk,
13341    /// `installation.connector.provider[is Individual].staff`.
13342    fn two_hop_interface_schema() -> SchemaDescriptor {
13343        let mut schema = interface_link_schema();
13344        let token = schema
13345            .types
13346            .iter()
13347            .find(|t| t.name == "Token")
13348            .expect("the helper declares Token")
13349            .clone();
13350        let mut connector = token.clone();
13351        connector.name = "Connector".into();
13352        connector.table = "Connector".into();
13353        connector.links[0].name = "provider".into();
13354        let mut installation = token;
13355        installation.name = "Installation".into();
13356        installation.table = "Installation".into();
13357        installation.links[0].name = "connector".into();
13358        installation.links[0].target = "default::Connector".into();
13359        schema.types.push(connector);
13360        schema.types.push(installation);
13361        schema
13362    }
13363
13364    #[test]
13365    fn a_narrowed_two_hop_walk_off_a_pinned_row_is_one_value() {
13366        // Every step is a forward single link and the filter pins the id, so
13367        // the `[is Individual]` narrowing does not make the walk set-valued.
13368        // Gathered as an array the `??` default became `array_remove(ARRAY[…])`
13369        // and the result decoded as a one-element set rather than as the value
13370        // itself.
13371        let out = compile_and_emit_with(
13372            "SELECT { s := ((SELECT Installation FILTER .id = <uuid>$0).connector.provider[is Individual].email) ?? 'y' }",
13373            &two_hop_interface_schema(),
13374        );
13375        assert!(
13376            !out.sql.contains("ARRAY(SELECT"),
13377            "expected a value, not a set:\n{}",
13378            out.sql
13379        );
13380        assert!(
13381            out.sql.contains("COALESCE("),
13382            "the default must stay a scalar one:\n{}",
13383            out.sql
13384        );
13385    }
13386
13387    #[test]
13388    fn a_narrowed_two_hop_walk_off_an_unpinned_row_stays_a_set() {
13389        let out = compile_and_emit_with(
13390            "SELECT { s := (SELECT Installation FILTER .value = 'x').connector.provider[is Individual].email }",
13391            &two_hop_interface_schema(),
13392        );
13393        assert!(
13394            out.sql.contains("ARRAY(SELECT"),
13395            "a walk off many rows is a set:\n{}",
13396            out.sql
13397        );
13398    }
13399
13400    /// `exists .account[is Individual]` — a walk narrowed to an implementor
13401    /// with no pointer read after it. The two-step form needs a trailing
13402    /// column to read, so it rejected the intersection outright ("type
13403    /// intersections are not valid in expression context") even though the
13404    /// same walk with a pointer on the end (`[is Individual].email`) has
13405    /// always compiled.
13406    #[test]
13407    fn a_walk_ending_in_a_type_intersection_is_valid_in_a_filter() {
13408        let out = compile_and_emit_with(
13409            "SELECT Token { id } FILTER EXISTS .account[is Individual]",
13410            &interface_link_schema(),
13411        );
13412        assert!(
13413            out.sql.contains("\"public\".\"Individual\""),
13414            "the narrowing must reach the implementor's table:\n{}",
13415            out.sql
13416        );
13417        assert!(
13418            out.sql.contains("IS NOT NULL"),
13419            "exists over the narrowed walk is a nullness test:\n{}",
13420            out.sql
13421        );
13422    }
13423
13424    #[test]
13425    fn a_walk_ending_in_a_type_intersection_is_valid_off_a_binding() {
13426        let out = compile_and_emit_with(
13427            "WITH t := (SELECT Token LIMIT 1) SELECT { e := EXISTS t.account[is Individual] }",
13428            &interface_link_schema(),
13429        );
13430        assert!(
13431            out.sql.contains("\"public\".\"Individual\""),
13432            "the narrowing must reach the implementor's table:\n{}",
13433            out.sql
13434        );
13435    }
13436
13437    #[test]
13438    fn test_reading_a_link_through_an_interface_target() {
13439        let schema = interface_link_schema();
13440        let out = compile_and_emit_with("SELECT Token { account: { email, company: { name } } }", &schema);
13441        assert!(
13442            out.sql
13443                .contains("\"email\", \"company_id\" FROM \"public\".\"Individual\""),
13444            "the fanned-out interface must carry its own link columns:\n{}",
13445            out.sql
13446        );
13447    }
13448
13449    #[test]
13450    fn test_computed_pointer_in_shape_emits_expression() {
13451        let mut schema = make_schema();
13452        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
13453            name: "upper_name".into(),
13454            expression: "str_upper(.name)".into(),
13455            return_type: Some("text".into()),
13456            link_target: None,
13457            link_multi: false,
13458        });
13459        let out = compile_and_emit_with("SELECT Person { upper_name }", &schema);
13460        assert!(
13461            out.sql.to_lowercase().contains("upper"),
13462            "expected upper() in SQL, got:\n{}",
13463            out.sql
13464        );
13465    }
13466
13467    /// Interface `Account` (non-materialized) with one implementor,
13468    /// `Individual`, carrying a stored property and a computed pointer.
13469    fn make_interface_schema() -> SchemaDescriptor {
13470        fn id_prop() -> PropertyDescriptor {
13471            PropertyDescriptor {
13472                name: "id".into(),
13473                pg_type: "uuid".into(),
13474                nullable: false,
13475                default_sql: Some("uuidv7()".into()),
13476                default_pyql: None,
13477                description: None,
13478                check_constraints: vec![],
13479                is_exclusive: true,
13480                is_pk: true,
13481                is_readonly: true,
13482                rewrites: vec![],
13483                tuple_members: None,
13484                column_type: None,
13485            }
13486        }
13487        SchemaDescriptor {
13488            types: vec![
13489                TypeDescriptor {
13490                    name: "Account".into(),
13491                    module: "default".into(),
13492                    table: "Account".into(),
13493                    abstract_: true,
13494                    materialized: false,
13495                    description: None,
13496                    parents: vec![],
13497                    interfaces: vec![],
13498                    bases: vec![],
13499                    properties: vec![
13500                        id_prop(),
13501                        PropertyDescriptor {
13502                            name: "email".into(),
13503                            pg_type: "text".into(),
13504                            nullable: false,
13505                            default_sql: None,
13506                            default_pyql: None,
13507                            description: None,
13508                            check_constraints: vec![],
13509                            is_exclusive: false,
13510                            is_pk: false,
13511                            is_readonly: false,
13512                            rewrites: vec![],
13513                            tuple_members: None,
13514                            column_type: None,
13515                        },
13516                    ],
13517                    links: vec![],
13518                    multilinks: vec![],
13519                    computed: vec![],
13520                    constraints: vec![],
13521                    indexes: vec![],
13522                    partition: None,
13523                    vector_indexes: vec![],
13524                    search_indexes: vec![],
13525                    triggers: vec![],
13526                    junction: false,
13527                    signals: vec![],
13528                },
13529                TypeDescriptor {
13530                    name: "Individual".into(),
13531                    module: "default".into(),
13532                    table: "Individual".into(),
13533                    abstract_: false,
13534                    materialized: true,
13535                    description: None,
13536                    parents: vec![],
13537                    interfaces: vec!["default::Account".into()],
13538                    bases: vec![],
13539                    properties: vec![
13540                        id_prop(),
13541                        PropertyDescriptor {
13542                            name: "first_name".into(),
13543                            pg_type: "text".into(),
13544                            nullable: false,
13545                            default_sql: None,
13546                            default_pyql: None,
13547                            description: None,
13548                            check_constraints: vec![],
13549                            is_exclusive: false,
13550                            is_pk: false,
13551                            is_readonly: false,
13552                            rewrites: vec![],
13553                            tuple_members: None,
13554                            column_type: None,
13555                        },
13556                    ],
13557                    links: vec![],
13558                    multilinks: vec![],
13559                    computed: vec![crate::schema::ComputedDescriptor {
13560                        name: "full_name".into(),
13561                        expression: "str_upper(.first_name)".into(),
13562                        return_type: Some("text".into()),
13563                        link_target: None,
13564                        link_multi: false,
13565                    }],
13566                    constraints: vec![],
13567                    indexes: vec![],
13568                    partition: None,
13569                    vector_indexes: vec![],
13570                    search_indexes: vec![],
13571                    triggers: vec![],
13572                    junction: false,
13573                    signals: vec![],
13574                },
13575            ],
13576            scalars: vec![],
13577            enums: vec![],
13578            named_tuples: vec![],
13579            globals: vec![],
13580            functions: vec![],
13581            aliases: vec![],
13582            channels: vec![],
13583            ..Default::default()
13584        }
13585    }
13586
13587    #[test]
13588    fn test_type_intersection_splat_includes_concrete_computed_pointers() {
13589        // `[is Concrete].*` must expand to the concrete type's own computed
13590        // pointers too (e.g. `full_name`), not just its stored properties —
13591        // querying `Individual` directly with `*` already included them, but
13592        // the interface-splat path never even looked at `td.computed`.
13593        let schema = make_interface_schema();
13594        let out = compile_and_emit_with("SELECT Account { *, [is Individual].* }", &schema);
13595        assert!(
13596            out.sql.to_lowercase().contains("upper"),
13597            "expected the concrete type's computed pointer (str_upper(...)) in the shape, got:\n{}",
13598            out.sql
13599        );
13600        assert!(
13601            out.sql.contains("\"first_name\""),
13602            "expected the concrete type's stored property too, got:\n{}",
13603            out.sql
13604        );
13605    }
13606
13607    #[test]
13608    fn test_enum_array_property_casts_to_text_array_and_shapes_as_a_list() {
13609        // Regression: an `array<enum>` property's pg_type is
13610        // `"module"."Type"[]`, which starts with a quote exactly as a scalar
13611        // enum's does. Read as a scalar it was cast to a single `::text`,
13612        // so `{MagicLink}` -- Postgres's text rendering of the whole array --
13613        // came back as one enum value, and the polymorphic path, which casts
13614        // nothing, failed outright with "no decoder for PostgreSQL type OID".
13615        let mut schema = make_schema();
13616        schema.enums.push(crate::schema::EnumDescriptor {
13617            name: "Gender".into(),
13618            module: "default".into(),
13619            members: vec!["Male".into(), "Female".into()],
13620        });
13621        schema.types[0].properties.push(crate::schema::PropertyDescriptor {
13622            name: "genders".into(),
13623            pg_type: r#""default"."Gender"[]"#.into(),
13624            nullable: true,
13625            default_sql: None,
13626            default_pyql: None,
13627            description: None,
13628            check_constraints: vec![],
13629            is_exclusive: false,
13630            is_pk: false,
13631            is_readonly: false,
13632            rewrites: vec![],
13633            tuple_members: None,
13634            column_type: None,
13635        });
13636
13637        let out = compile_and_emit_with("SELECT Person { genders }", &schema);
13638        assert!(
13639            out.sql.contains(r#""genders"::text[]"#),
13640            "expected a text[] cast, got:\n{}",
13641            out.sql
13642        );
13643
13644        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
13645            panic!("expected Object shape, got {:?}", out.shape.root)
13646        };
13647        let genders = pointers
13648            .iter()
13649            .find(|node| matches!(node, crate::query::ShapeNode::Array { name, .. } if name == "genders"))
13650            .unwrap_or_else(|| panic!("expected an Array-shaped pointer, got {pointers:?}"));
13651        let crate::query::ShapeNode::Array { element, .. } = genders else {
13652            unreachable!()
13653        };
13654        assert!(
13655            matches!(
13656                element.as_ref(),
13657                crate::query::ShapeNode::Enum { enum_type, position: 0, .. }
13658                    if enum_type == "default::Gender"
13659            ),
13660            "expected the elements to be enum-shaped, got {element:?}",
13661        );
13662    }
13663
13664    #[test]
13665    fn test_scalar_enum_property_still_casts_to_a_single_text() {
13666        let mut schema = make_schema();
13667        schema.enums.push(crate::schema::EnumDescriptor {
13668            name: "Gender".into(),
13669            module: "default".into(),
13670            members: vec!["Male".into(), "Female".into()],
13671        });
13672        schema.types[0].properties.push(crate::schema::PropertyDescriptor {
13673            name: "gender".into(),
13674            pg_type: r#""default"."Gender""#.into(),
13675            nullable: true,
13676            default_sql: None,
13677            default_pyql: None,
13678            description: None,
13679            check_constraints: vec![],
13680            is_exclusive: false,
13681            is_pk: false,
13682            is_readonly: false,
13683            rewrites: vec![],
13684            tuple_members: None,
13685            column_type: None,
13686        });
13687
13688        let out = compile_and_emit_with("SELECT Person { gender }", &schema);
13689        assert!(
13690            out.sql.contains(r#""gender"::text"#) && !out.sql.contains(r#""gender"::text[]"#),
13691            "expected a plain text cast, got:\n{}",
13692            out.sql
13693        );
13694        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
13695            panic!("expected Object shape")
13696        };
13697        assert!(
13698            pointers.iter().any(|node| matches!(
13699                node,
13700                crate::query::ShapeNode::Enum { name, enum_type, .. }
13701                    if name == "gender" && enum_type == "default::Gender"
13702            )),
13703            "expected an Enum-shaped pointer, got {pointers:?}",
13704        );
13705    }
13706
13707    #[test]
13708    fn test_type_intersection_splat_keeps_enum_pointers_enum_shaped() {
13709        // Regression: `[is Concrete].*` wraps each property in a correlated
13710        // subquery, so the outer shape saw an `IrExpr::Subquery` rather than a
13711        // column and fell back to a plain Scalar — handing back `'System'`
13712        // where a direct select gave `InterfaceAppearance.System`.
13713        let mut schema = make_interface_schema();
13714        schema.enums.push(crate::schema::EnumDescriptor {
13715            name: "Gender".into(),
13716            module: "default".into(),
13717            members: vec!["Male".into(), "Female".into()],
13718        });
13719        // An enum that lives only on the concrete type, so it can only be
13720        // reached through the intersection.
13721        let individual = schema
13722            .types
13723            .iter_mut()
13724            .find(|t| t.name == "Individual")
13725            .expect("the interface schema has an Individual type");
13726        individual.properties.push(crate::schema::PropertyDescriptor {
13727            name: "gender".into(),
13728            pg_type: r#""default"."Gender""#.into(),
13729            nullable: true,
13730            default_sql: None,
13731            default_pyql: None,
13732            description: None,
13733            check_constraints: vec![],
13734            is_exclusive: false,
13735            is_pk: false,
13736            is_readonly: false,
13737            rewrites: vec![],
13738            tuple_members: None,
13739            column_type: None,
13740        });
13741
13742        let out = compile_and_emit_with("SELECT Account { [is Individual].* }", &schema);
13743        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
13744            panic!("expected Object shape, got {:?}", out.shape.root)
13745        };
13746        assert!(
13747            pointers.iter().any(|node| matches!(
13748                node,
13749                crate::query::ShapeNode::Enum { name, enum_type, .. }
13750                    if name == "gender" && enum_type == "default::Gender"
13751            )),
13752            "expected the enum property to stay enum-shaped through the intersection, got {pointers:?}",
13753        );
13754    }
13755
13756    #[test]
13757    fn test_shallow_splat_leaves_object_valued_computeds_to_the_deep_form() {
13758        // Regression: `*` expanded every computed, link-valued ones included.
13759        // `*` is properties only; `**` adds links.
13760        // On jaldis's schema that pulled a computed multi-link
13761        // (`members := .memberships.member`) into every splat query.
13762        let mut schema = make_schema();
13763        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
13764            name: "authors".into(),
13765            expression: ".posts".into(),
13766            return_type: None,
13767            link_target: None,
13768            link_multi: false,
13769        });
13770        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
13771            name: "age_next".into(),
13772            expression: ".age + 1".into(),
13773            return_type: Some("int8".into()),
13774            link_target: None,
13775            link_multi: false,
13776        });
13777
13778        let shallow = compile_and_emit_with("SELECT Person { * }", &schema);
13779        let crate::query::ShapeNode::Object { pointers, .. } = &shallow.shape.root else {
13780            panic!("expected Object shape")
13781        };
13782        let names: Vec<&str> = pointers.iter().map(shape_pointer_name).collect();
13783        assert!(
13784            names.contains(&"age_next"),
13785            "a computed property belongs in `*`: {names:?}"
13786        );
13787        assert!(!names.contains(&"authors"), "a computed link does not: {names:?}");
13788
13789        let deep = compile_and_emit_with("SELECT Person { ** }", &schema);
13790        let crate::query::ShapeNode::Object { pointers, .. } = &deep.shape.root else {
13791            panic!("expected Object shape")
13792        };
13793        let names: Vec<&str> = pointers.iter().map(shape_pointer_name).collect();
13794        assert!(
13795            names.contains(&"authors"),
13796            "`**` includes links, computed ones included: {names:?}"
13797        );
13798    }
13799
13800    fn shape_pointer_name(node: &crate::query::ShapeNode) -> &str {
13801        use crate::query::ShapeNode;
13802        match node {
13803            ShapeNode::Scalar { name, .. }
13804            | ShapeNode::Enum { name, .. }
13805            | ShapeNode::NamedTuple { name, .. }
13806            | ShapeNode::Object { name, .. }
13807            | ShapeNode::Array { name, .. } => name,
13808            _ => "",
13809        }
13810    }
13811
13812    #[test]
13813    fn test_any_over_a_multilink_comparison_does_not_warn() {
13814        // Regression: the warning fired whether or not the comparison was
13815        // wrapped, so `any()` -- the very thing it asks for -- did not silence
13816        // it. The argument is compiled before the call is resolved, so the
13817        // suppression has to be in place while the argument is compiled.
13818        let schema = make_schema();
13819        let ast = parse::parse("SELECT Person FILTER any(.posts.title = 'x')").unwrap();
13820        let ir = ir::compile(&ast, &schema).unwrap();
13821        assert!(
13822            !ir.warnings.iter().any(|w| w.contains("FILTER clause")),
13823            "any() states the intent, so nothing should warn: {:?}",
13824            ir.warnings
13825        );
13826    }
13827
13828    #[test]
13829    fn test_bare_multilink_comparison_still_warns() {
13830        let schema = make_schema();
13831        let ast = parse::parse("SELECT Person FILTER .posts.title = 'x'").unwrap();
13832        let ir = ir::compile(&ast, &schema).unwrap();
13833        assert!(
13834            ir.warnings.iter().any(|w| w.contains("FILTER clause")),
13835            "an unwrapped set-valued comparison still warns: {:?}",
13836            ir.warnings
13837        );
13838    }
13839
13840    #[test]
13841    fn test_link_to_an_interface_expands_over_its_implementors() {
13842        // Regression: an interface's view carries only the interface's own
13843        // columns, so reading a link through it tagged every row as the
13844        // interface and hydrated `Profile` where the row was an
13845        // `IndividualProfile`. The root of a query already expands inline;
13846        // a nested link did not.
13847        let mut schema = make_interface_schema();
13848        let account = schema
13849            .types
13850            .iter_mut()
13851            .find(|t| t.name == "Account")
13852            .expect("the interface schema has an Account type");
13853        account.materialized = true;
13854        // A second implementor, so the fan-out is a real union.
13855        let mut organization = schema
13856            .types
13857            .iter()
13858            .find(|t| t.name == "Individual")
13859            .expect("the interface schema has an Individual type")
13860            .clone();
13861        organization.name = "Organization".into();
13862        organization.table = "Organization".into();
13863        organization.computed.clear();
13864        schema.types.push(organization);
13865
13866        let owner = TypeDescriptor {
13867            name: "Note".into(),
13868            module: "default".into(),
13869            table: "Note".into(),
13870            abstract_: false,
13871            materialized: false,
13872            description: None,
13873            parents: vec![],
13874            interfaces: vec![],
13875            bases: vec![],
13876            properties: vec![],
13877            links: vec![LinkDescriptor {
13878                name: "owner".into(),
13879                target: "default::Account".into(),
13880                nullable: true,
13881                description: None,
13882                default_pyql: None,
13883                is_exclusive: false,
13884                is_readonly: false,
13885                rewrites: vec![],
13886                on_delete: vec![],
13887                through: None,
13888            }],
13889            multilinks: vec![],
13890            computed: vec![],
13891            constraints: vec![],
13892            indexes: vec![],
13893            partition: None,
13894            vector_indexes: vec![],
13895            search_indexes: vec![],
13896            triggers: vec![],
13897            junction: false,
13898            signals: vec![],
13899        };
13900        schema.types.push(owner);
13901
13902        let out = compile_and_emit_with("SELECT Note { owner: { id } }", &schema);
13903        assert!(
13904            out.sql.contains("UNION ALL"),
13905            "the link's target should expand over its implementors, got:\n{}",
13906            out.sql
13907        );
13908        assert!(
13909            out.sql.contains("'default::Individual'::text AS \"__type__\""),
13910            "each branch should carry its own discriminator, got:\n{}",
13911            out.sql
13912        );
13913        assert!(
13914            !out.sql.contains("FROM \"default\".\"Account\" AS"),
13915            "the interface's own view should no longer be read directly, got:\n{}",
13916            out.sql
13917        );
13918    }
13919
13920    /// `make_interface_schema` with a second implementor of `Account`, so a
13921    /// read of it is a real union of two concrete types.
13922    fn make_two_implementor_schema() -> SchemaDescriptor {
13923        let mut schema = make_interface_schema();
13924        let mut organization = schema
13925            .types
13926            .iter()
13927            .find(|t| t.name == "Individual")
13928            .expect("the interface schema has an Individual type")
13929            .clone();
13930        organization.name = "Organization".into();
13931        organization.table = "Organization".into();
13932        organization.computed.clear();
13933        schema.types.push(organization);
13934        schema
13935    }
13936
13937    /// Every row read of an interface names its own concrete type, so none may
13938    /// be labelled with the interface's.
13939    fn assert_rows_carry_their_concrete_type(query: &str) {
13940        let out = compile_and_emit_with(query, &make_two_implementor_schema());
13941        assert!(
13942            !out.sql.contains("'default::Account'::text"),
13943            "rows are labelled with the interface instead of their own type:\n{}",
13944            out.sql
13945        );
13946        assert!(
13947            out.sql.contains("'default::Individual'::text AS \"__type__\""),
13948            "the rows should come from the implementors, each tagging its own:\n{}",
13949            out.sql
13950        );
13951    }
13952
13953    #[test]
13954    fn an_aggregated_select_of_an_interface_carries_the_concrete_type() {
13955        assert_rows_carry_their_concrete_type("SELECT (xs := array_agg((SELECT Account { id })))");
13956    }
13957
13958    #[test]
13959    fn an_aggregated_binding_of_an_interface_carries_the_concrete_type() {
13960        assert_rows_carry_their_concrete_type("WITH a := (SELECT Account) SELECT (xs := array_agg((SELECT a { id })))");
13961    }
13962
13963    #[test]
13964    fn a_single_object_read_off_a_binding_carries_the_concrete_type() {
13965        assert_rows_carry_their_concrete_type("WITH a := (SELECT Account) SELECT (x := (SELECT a { id } LIMIT 1))");
13966    }
13967
13968    #[test]
13969    fn grouped_elements_of_an_interface_carry_the_concrete_type() {
13970        assert_rows_carry_their_concrete_type("group Account { id } by .email");
13971    }
13972
13973    /// `make_two_implementor_schema` with a `Note` that links to an `Account`,
13974    /// and an `Account` that links back to a `Note`.
13975    fn make_note_schema() -> SchemaDescriptor {
13976        fn link_to(name: &str, target: &str) -> LinkDescriptor {
13977            LinkDescriptor {
13978                name: name.into(),
13979                target: target.into(),
13980                nullable: true,
13981                description: None,
13982                default_pyql: None,
13983                is_exclusive: false,
13984                is_readonly: false,
13985                rewrites: vec![],
13986                on_delete: vec![],
13987                through: None,
13988            }
13989        }
13990        let mut schema = make_two_implementor_schema();
13991        for account in schema
13992            .types
13993            .iter_mut()
13994            .filter(|t| ["Account", "Individual", "Organization"].contains(&t.name.as_str()))
13995        {
13996            account.links.push(link_to("pinned", "default::Note"));
13997        }
13998        schema.types.push(TypeDescriptor {
13999            name: "Note".into(),
14000            module: "default".into(),
14001            table: "Note".into(),
14002            abstract_: false,
14003            materialized: false,
14004            description: None,
14005            parents: vec![],
14006            interfaces: vec![],
14007            bases: vec![],
14008            properties: vec![],
14009            links: vec![link_to("owner", "default::Account")],
14010            multilinks: vec![],
14011            computed: vec![],
14012            constraints: vec![],
14013            indexes: vec![],
14014            partition: None,
14015            vector_indexes: vec![],
14016            search_indexes: vec![],
14017            triggers: vec![],
14018            junction: false,
14019            signals: vec![],
14020        });
14021        schema
14022    }
14023
14024    #[test]
14025    fn a_backlink_to_an_interface_carries_the_concrete_type() {
14026        let out = compile_and_emit_with(
14027            "SELECT Note { pinners := .<pinned[is Account] { id } }",
14028            &make_note_schema(),
14029        );
14030        assert!(
14031            !out.sql.contains("'default::Account'::text"),
14032            "the backlink's rows are labelled with the interface instead of their own type:\n{}",
14033            out.sql
14034        );
14035    }
14036
14037    #[test]
14038    fn a_link_reached_through_a_deep_splat_carries_the_concrete_type() {
14039        let out = compile_and_emit_with("SELECT Note { ** }", &make_note_schema());
14040        assert!(
14041            !out.sql.contains("'default::Account'::text"),
14042            "the splatted link's rows are labelled with the interface instead of their own type:\n{}",
14043            out.sql
14044        );
14045    }
14046
14047    #[test]
14048    fn test_path_traversal_onto_an_interface_carries_the_concrete_type() {
14049        // The hole the chained-pointer fix landed on: a *path join* onto an
14050        // interface still read the interface's view, so the rows a traversal
14051        // returned were tagged with the interface and hydrated its class. Links
14052        // fan out; this is the traversal route they share with computed chains.
14053        let mut schema = make_interface_schema();
14054        let account = schema
14055            .types
14056            .iter_mut()
14057            .find(|t| t.name == "Account")
14058            .expect("the interface schema has an Account type");
14059        account.materialized = true;
14060        let mut organization = schema
14061            .types
14062            .iter()
14063            .find(|t| t.name == "Individual")
14064            .expect("the interface schema has an Individual type")
14065            .clone();
14066        organization.name = "Organization".into();
14067        organization.table = "Organization".into();
14068        organization.computed.clear();
14069        schema.types.push(organization);
14070        schema.types.push(TypeDescriptor {
14071            name: "Note".into(),
14072            module: "default".into(),
14073            table: "Note".into(),
14074            abstract_: false,
14075            materialized: false,
14076            description: None,
14077            parents: vec![],
14078            interfaces: vec![],
14079            bases: vec![],
14080            properties: vec![],
14081            links: vec![LinkDescriptor {
14082                name: "owner".into(),
14083                target: "default::Account".into(),
14084                nullable: true,
14085                description: None,
14086                default_pyql: None,
14087                is_exclusive: false,
14088                is_readonly: false,
14089                rewrites: vec![],
14090                on_delete: vec![],
14091                through: None,
14092            }],
14093            multilinks: vec![],
14094            computed: vec![],
14095            constraints: vec![],
14096            indexes: vec![],
14097            partition: None,
14098            vector_indexes: vec![],
14099            search_indexes: vec![],
14100            triggers: vec![],
14101            junction: false,
14102            signals: vec![],
14103        });
14104
14105        let out = compile_and_emit_with("SELECT Note.owner { id }", &schema);
14106        assert!(
14107            out.sql.contains("UNION ALL") && out.sql.contains("'default::Individual'::text AS \"__type__\""),
14108            "the traversal's target should fan out over implementors, got:\n{}",
14109            out.sql
14110        );
14111        assert!(
14112            !out.sql.contains("'default::Account'::text,"),
14113            "the row's type should be read off the row, not fixed to the interface, got:\n{}",
14114            out.sql
14115        );
14116    }
14117
14118    #[test]
14119    fn test_computed_multilink_chain_comes_back_as_rows() {
14120        // Regression: `members := .memberships.member` is a chain, and
14121        // try_compile_pointer_expr only recognised a single-step `.multilink`,
14122        // so it fell through to expression position and became a scalar
14123        // subquery — which Postgres rejects the moment a second row matches.
14124        let mut schema = make_schema();
14125        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
14126            name: "coauthors".into(),
14127            expression: ".posts.author".into(),
14128            return_type: None,
14129            link_target: None,
14130            link_multi: false,
14131        });
14132        let post = schema
14133            .types
14134            .iter_mut()
14135            .find(|t| t.name == "Post")
14136            .expect("test schema has a Post type");
14137        post.links.push(LinkDescriptor {
14138            name: "author".into(),
14139            target: "default::Person".into(),
14140            nullable: true,
14141            description: None,
14142            default_pyql: None,
14143            is_exclusive: false,
14144            is_readonly: false,
14145            rewrites: vec![],
14146            on_delete: vec![],
14147            through: None,
14148        });
14149
14150        let out = compile_and_emit_with("SELECT Person { coauthors: { name } }", &schema);
14151        assert!(
14152            out.sql.contains("ARRAY(SELECT"),
14153            "the chain should aggregate into an array, got:\n{}",
14154            out.sql
14155        );
14156        assert!(
14157            out.sql.contains("\"name\""),
14158            "the requested sub-shape should survive, got:\n{}",
14159            out.sql
14160        );
14161
14162        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
14163            panic!("expected Object shape, got {:?}", out.shape.root)
14164        };
14165        let coauthors = pointers
14166            .iter()
14167            .find(|node| matches!(node, crate::query::ShapeNode::Array { name, .. } if name == "coauthors"))
14168            .unwrap_or_else(|| panic!("expected an Array-shaped pointer, got {pointers:?}"));
14169        let crate::query::ShapeNode::Array { element, .. } = coauthors else {
14170            unreachable!()
14171        };
14172        assert!(
14173            matches!(
14174                element.as_ref(),
14175                crate::query::ShapeNode::Object { type_name: Some(t), .. } if t == "default::Person"
14176            ),
14177            "the elements should hydrate as objects, got {element:?}",
14178        );
14179    }
14180
14181    #[test]
14182    fn test_multi_sort_with_then_emits_two_order_keys() {
14183        let out = compile_and_emit("SELECT Person { name } ORDER BY .name THEN .age DESC");
14184        assert!(out.sql.contains("ORDER BY"), "expected ORDER BY");
14185        // Both columns should appear in the ORDER BY clause
14186        assert!(out.sql.contains("\"name\""));
14187        assert!(out.sql.contains("\"age\""));
14188        assert!(out.sql.contains("DESC"));
14189    }
14190
14191    #[test]
14192    fn test_string_index_emits_str_subscript() {
14193        let out = compile_and_emit("SELECT 'hello'[1]");
14194        assert!(
14195            out.sql.contains("_pylon.str_subscript"),
14196            "expected _pylon.str_subscript() for string index, got:\n{}",
14197            out.sql
14198        );
14199    }
14200
14201    /// PostgreSQL forbids a set-returning function inside `VALUES`, so a loop
14202    /// over `array_unpack` has to iterate a select instead.
14203    #[test]
14204    fn test_for_over_a_set_returning_call_is_not_a_values_clause() {
14205        let out = compile_and_emit("FOR n IN array_unpack(<array<int64>>$ns) UNION (SELECT n)");
14206        assert!(
14207            !out.sql.contains("VALUES (unnest"),
14208            "a set-returning iterator must not land in VALUES, got:\n{}",
14209            out.sql
14210        );
14211        assert!(
14212            out.sql.contains("SELECT unnest"),
14213            "expected the iterator in a select list, got:\n{}",
14214            out.sql
14215        );
14216    }
14217
14218    /// The loop variable's type is the unpacked array's element type, not
14219    /// whatever the generically-declared call says.
14220    #[test]
14221    fn test_for_over_array_unpack_binds_the_element_type() {
14222        let out = compile_and_emit("FOR j IN array_unpack(<array<json>>$rows) UNION (SELECT <str>j['k'])");
14223        assert!(
14224            out.sql.contains("::jsonb AS v"),
14225            "the loop variable should carry the element type, got:\n{}",
14226            out.sql
14227        );
14228    }
14229
14230    /// A binding that holds an array has to say so, or no overload of
14231    /// `contains` accepts it and the call is reported as one nobody wrote.
14232    #[test]
14233    fn test_a_binding_keeps_its_array_type_through_an_aggregate_and_a_condition() {
14234        for query in [
14235            "WITH ids := ([<uuid>$a] IF true ELSE [<uuid>$b]) SELECT Person FILTER contains(ids, .id)",
14236            "WITH ns := array_agg((SELECT Person.name)) SELECT Person FILTER contains(ns, .name)",
14237        ] {
14238            let out = compile_and_emit(query);
14239            assert!(
14240                out.sql.contains("@> ARRAY["),
14241                "expected the array overload of contains for `{query}`, got:\n{}",
14242                out.sql
14243            );
14244        }
14245    }
14246
14247    /// Each element of an array literal is one value. A walk gathered into an
14248    /// array of its own leaves `ARRAY[uuid, uuid[]]`, which PostgreSQL
14249    /// refuses: "ARRAY types uuid and uuid[] cannot be matched".
14250    #[test]
14251    fn test_an_array_literals_walk_element_is_read_as_one_value() {
14252        let out = compile_and_emit("WITH p := (SELECT Person LIMIT 1) SELECT [<uuid>$a, p.id]");
14253        assert!(
14254            !out.sql.contains("ARRAY(SELECT"),
14255            "the element should be a scalar subquery, not an array of its own:\n{}",
14256            out.sql
14257        );
14258    }
14259
14260    /// A sub-select's own filter speaks about its subject however the outer
14261    /// select is written: ordering the walk must not re-read `.name` off what
14262    /// the walk lands on, which silently filtered the wrong table.
14263    #[test]
14264    fn test_ordering_a_walk_leaves_the_inner_filter_on_its_own_subject() {
14265        let out = compile_and_emit("SELECT (SELECT Person FILTER .name = 'x' LIMIT 1).posts { title } ORDER BY .title");
14266        assert!(
14267            out.sql.contains("WHERE (\"t0\".\"name\" = 'x')"),
14268            "the filter belongs to the Person the walk starts from, got:\n{}",
14269            out.sql
14270        );
14271        assert!(
14272            out.sql.contains("\"title\" ASC"),
14273            "the ordering belongs to the Post the walk lands on, got:\n{}",
14274            out.sql
14275        );
14276    }
14277
14278    /// A sub-select's `limit` counts its own rows. Left on the joined result
14279    /// it took one row of the walk instead — one promotion of a cart that has
14280    /// two, with nothing to say it had dropped any.
14281    #[test]
14282    fn test_a_walks_head_keeps_its_own_limit() {
14283        let out = compile_and_emit("SELECT (SELECT Person FILTER .name = 'x' LIMIT 1).posts { title }");
14284        let (head, walk) = out.sql.split_once(")\nSELECT").expect("expected a bound head");
14285        assert!(
14286            head.contains("LIMIT 1"),
14287            "the head keeps its own row count, got:\n{}",
14288            out.sql
14289        );
14290        assert!(
14291            !walk.contains("LIMIT"),
14292            "the walk keeps every row it reaches, got:\n{}",
14293            out.sql
14294        );
14295    }
14296
14297    /// An overload that declares its element type carries it whatever it was
14298    /// handed, so the body reads the loop variable as json rather than as the
14299    /// text `jsonb_array_elements` would be cast to by default.
14300    #[test]
14301    fn test_for_over_json_array_unpack_binds_json() {
14302        let out = compile_and_emit(
14303            "FOR entry IN json_array_unpack(to_json(<str>$rows)) UNION (SELECT <str>json_get(entry, 'k'))",
14304        );
14305        assert!(
14306            out.sql.contains("::jsonb AS v"),
14307            "the loop variable should carry json, got:\n{}",
14308            out.sql
14309        );
14310    }
14311
14312    /// `json_get` yields json, so casting its result to a scalar reads the
14313    /// value out of it. `::text` on its own would keep a json string's quotes.
14314    #[test]
14315    fn test_casting_json_get_reads_the_value_not_its_json_spelling() {
14316        let out = compile_and_emit("SELECT <uuid><str>json_get(<json>$p, 'id')");
14317        assert!(
14318            out.sql.contains("#>> '{}'"),
14319            "expected the json value to be read out, got:\n{}",
14320            out.sql
14321        );
14322    }
14323
14324    /// Indexing json reaches into it with `->`; neither string nor array
14325    /// subscripting applies.
14326    #[test]
14327    fn test_indexing_json_emits_a_jsonb_accessor() {
14328        let out = compile_and_emit("SELECT (<json>$payload)['label']");
14329        assert!(
14330            out.sql.contains("->'label'"),
14331            "expected a jsonb field access, got:\n{}",
14332            out.sql
14333        );
14334        assert!(
14335            !out.sql.contains("str_subscript"),
14336            "json must not be subscripted as a string, got:\n{}",
14337            out.sql
14338        );
14339    }
14340
14341    /// Casting json to a scalar yields the value, not its JSON spelling —
14342    /// `<str>` of a json string has no quotes around it.
14343    #[test]
14344    fn test_casting_json_to_a_scalar_unwraps_it() {
14345        let out = compile_and_emit("SELECT <str>to_json(<str>$s)");
14346        assert!(
14347            out.sql.contains("#>> '{}'"),
14348            "expected the json value to be extracted, got:\n{}",
14349            out.sql
14350        );
14351    }
14352
14353    /// A `with` binding that names the loop variable holds a different value
14354    /// each iteration. Evaluated once for the statement it reads an iterator
14355    /// that is not in its FROM; every read of it belongs to one iteration.
14356    #[test]
14357    fn test_a_binding_that_reads_the_loop_variable_is_keyed_by_iteration() {
14358        let out = compile_and_emit(
14359            "WITH made := (FOR p IN (SELECT Person) UNION ( \
14360               WITH mine := (SELECT p.posts LIMIT 1) \
14361               INSERT Company { name := mine.title } \
14362             )) SELECT count(made)",
14363        );
14364        assert!(
14365            out.sql
14366                .contains("\"mine\" AS (\nSELECT \"_for_p\".\"v\" AS \"_outer__mine\""),
14367            "the binding must be evaluated per iteration, got:\n{}",
14368            out.sql
14369        );
14370        assert!(
14371            out.sql.contains("\"mine\".\"_outer__mine\" = \"_for_p\".\"v\""),
14372            "a read of it must pin to the iteration in scope, got:\n{}",
14373            out.sql
14374        );
14375    }
14376
14377    /// A `for … union (insert …)` assigned to a multi link *inside another
14378    /// loop* writes rows per iteration. They used to be cross-joined onto
14379    /// every row the outer loop wrote; they belong to one iteration of it.
14380    #[test]
14381    fn test_a_loop_nested_in_a_loop_pairs_its_rows_by_iteration() {
14382        let out = compile_and_emit(
14383            "FOR n IN {'a', 'b'} UNION (INSERT Person { name := n, posts := (FOR t IN {n} UNION (INSERT Post { title := t })) })",
14384        );
14385        assert!(
14386            out.sql.contains("\"_outer\""),
14387            "the nested rows must carry an iteration key, got:\n{}",
14388            out.sql
14389        );
14390        assert!(
14391            !out.sql.contains("CROSS JOIN \"_nested_dml_0\""),
14392            "they must not be cross-joined onto every row, got:\n{}",
14393            out.sql
14394        );
14395    }
14396
14397    /// A multi link assigned in a `for`-bodied insert used to be dropped: the
14398    /// rows went in, the junction rows never did, and nothing reported it.
14399    #[test]
14400    fn test_for_bodied_insert_writes_its_multi_link() {
14401        let out = compile_and_emit("FOR n IN {'a', 'b'} UNION (INSERT Person { name := n, posts := (SELECT Post) })");
14402        assert!(
14403            out.sql.contains("INSERT INTO \"public\".\"Person.posts\""),
14404            "the junction rows must be written too, got:\n{}",
14405            out.sql
14406        );
14407    }
14408
14409    #[test]
14410    fn test_string_slice_emits_substr() {
14411        let out = compile_and_emit("SELECT 'hello'[1:3]");
14412        assert!(
14413            out.sql.contains("substr"),
14414            "expected substr() for string slice, got:\n{}",
14415            out.sql
14416        );
14417    }
14418
14419    #[test]
14420    fn test_array_index_emits_subscript() {
14421        let out = compile_and_emit("SELECT [1, 2, 3][1]");
14422        assert!(
14423            out.sql.contains("_pylon.array_subscript"),
14424            "expected _pylon.array_subscript() for array index, got:\n{}",
14425            out.sql
14426        );
14427    }
14428
14429    #[test]
14430    fn test_index_into_array_returning_function_emits_array_subscript() {
14431        let out = compile_and_emit("SELECT str_split('a::b', '::')[-1]");
14432        assert!(
14433            out.sql.contains("_pylon.array_subscript(string_to_array"),
14434            "expected _pylon.array_subscript() over str_split, got:\n{}",
14435            out.sql
14436        );
14437    }
14438
14439    #[test]
14440    fn test_array_slice_emits_subscript() {
14441        let out = compile_and_emit("SELECT [1, 2, 3][0:2]");
14442        assert!(
14443            !out.sql.contains("substr"),
14444            "should not use substr for array, got:\n{}",
14445            out.sql
14446        );
14447        assert!(out.sql.contains(")["), "expected array slice syntax, got:\n{}", out.sql);
14448    }
14449
14450    #[test]
14451    fn test_open_ended_string_slice_emits_substr_no_length() {
14452        let out = compile_and_emit("SELECT 'hello'[2:]");
14453        // substr(expr, start) without length argument
14454        assert!(out.sql.contains("substr"), "expected substr(), got:\n{}", out.sql);
14455        // Should NOT have a third argument (length)
14456        let substr_idx = out.sql.find("substr").unwrap();
14457        let after = &out.sql[substr_idx..];
14458        let commas = after.chars().take_while(|&c| c != ')').filter(|&c| c == ',').count();
14459        assert_eq!(commas, 1, "open-ended slice should use 2-arg substr, got:\n{}", out.sql);
14460    }
14461
14462    #[test]
14463    fn test_group_by_single_key() {
14464        let out = compile_and_emit("group Person { name } by .age");
14465        // GROUP BY clause present
14466        assert!(out.sql.contains("GROUP BY"), "expected GROUP BY, got:\n{}", out.sql);
14467        // Key column referenced
14468        assert!(out.sql.contains("\"age\""), "expected age column, got:\n{}", out.sql);
14469        // array_agg for elements
14470        assert!(
14471            out.sql.contains("array_agg(ROW("),
14472            "expected array_agg, got:\n{}",
14473            out.sql
14474        );
14475        // grouping names array
14476        assert!(
14477            out.sql.contains("ARRAY['age']"),
14478            "expected grouping array, got:\n{}",
14479            out.sql
14480        );
14481        // shape node is Group
14482        assert!(matches!(out.shape.root, crate::query::ShapeNode::Group { .. }));
14483        if let crate::query::ShapeNode::Group {
14484            key_nodes,
14485            grouping_position,
14486            elements_position,
14487            ..
14488        } = &out.shape.root
14489        {
14490            assert_eq!(key_nodes.len(), 1);
14491            assert!(matches!(&key_nodes[0], crate::query::ShapeNode::Scalar { name, position: 1 } if name == "age"));
14492            assert_eq!(*grouping_position, 2);
14493            assert_eq!(*elements_position, 3);
14494        }
14495    }
14496
14497    #[test]
14498    fn test_group_using_alias() {
14499        let out = compile_and_emit("group Person using decade := .age // 10 by decade");
14500        assert!(out.sql.contains("GROUP BY"), "expected GROUP BY, got:\n{}", out.sql);
14501        assert!(
14502            out.sql.contains("ARRAY['decade']"),
14503            "expected grouping array, got:\n{}",
14504            out.sql
14505        );
14506        if let crate::query::ShapeNode::Group { key_nodes, .. } = &out.shape.root {
14507            assert_eq!(key_nodes.len(), 1);
14508            assert!(matches!(&key_nodes[0], crate::query::ShapeNode::Scalar { name, .. } if name == "decade"));
14509        }
14510    }
14511
14512    #[test]
14513    fn test_group_orders_elements_within_each_group() {
14514        let out = compile_and_emit("group Person { name } by .age order by .name desc");
14515        assert!(
14516            out.sql.contains(")::record ORDER BY \"t0\".\"name\" DESC NULLS LAST)"),
14517            "{}",
14518            out.sql
14519        );
14520        assert!(!out.sql.contains("row_number()"), "{}", out.sql);
14521    }
14522
14523    #[test]
14524    fn test_group_limit_trims_each_group_not_the_result() {
14525        // The newest row per key: a trailing LIMIT would drop whole groups,
14526        // so the rows are ranked within each key instead.
14527        let out = compile_and_emit("group Person { name } by .age order by .name desc limit 1");
14528        assert!(
14529            out.sql
14530                .contains("row_number() OVER (PARTITION BY \"t0\".\"age\" ORDER BY \"t0\".\"name\" DESC NULLS LAST)"),
14531            "{}",
14532            out.sql
14533        );
14534        assert!(
14535            out.sql.contains("WHERE \"__rk\" > 0 AND \"__rk\" <= 0 + 1"),
14536            "{}",
14537            out.sql
14538        );
14539        assert!(!out.sql.contains("\nLIMIT"), "{}", out.sql);
14540    }
14541
14542    #[test]
14543    fn test_group_by_two_keys_with_a_per_group_limit() {
14544        let out = compile_and_emit(
14545            "group Person { name } by .age, .name filter .age > 18 order by .name desc offset 1 limit 2",
14546        );
14547        assert!(
14548            out.sql.contains("PARTITION BY \"t0\".\"age\", \"t0\".\"name\""),
14549            "{}",
14550            out.sql
14551        );
14552        // The filter picks which rows are grouped, so it sits inside the
14553        // ranking subquery — ranking must not see rows the filter excluded.
14554        let where_pos = out.sql.find("WHERE (\"t0\".\"age\" > 18)").expect("filter");
14555        let rank_pos = out.sql.find("WHERE \"__rk\"").expect("rank filter");
14556        assert!(where_pos < rank_pos, "{}", out.sql);
14557        assert!(
14558            out.sql.contains("GROUP BY \"t0\".\"age\", \"t0\".\"name\""),
14559            "{}",
14560            out.sql
14561        );
14562    }
14563
14564    #[test]
14565    fn test_group_filter_without_a_limit_is_a_plain_where() {
14566        let out = compile_and_emit("group Person { name } by .age filter .age > 18");
14567        assert!(out.sql.contains("WHERE (\"t0\".\"age\" > 18)"), "{}", out.sql);
14568        assert!(!out.sql.contains("row_number()"), "{}", out.sql);
14569    }
14570
14571    #[test]
14572    fn test_abs_path_concat_same_type() {
14573        let out = compile_and_emit("SELECT Person.name ++ ' ' ++ Person.name");
14574        assert!(out.sql.contains("\"name\""), "expected name column, got:\n{}", out.sql);
14575        assert!(out.sql.contains("||"), "expected concat operator, got:\n{}", out.sql);
14576        assert!(out.sql.contains("FROM"), "expected FROM clause, got:\n{}", out.sql);
14577    }
14578
14579    #[test]
14580    fn test_abs_path_single_property() {
14581        let out = compile_and_emit("SELECT Person.name");
14582        assert!(out.sql.contains("\"name\""), "expected name column, got:\n{}", out.sql);
14583        assert!(out.sql.contains("FROM"), "expected FROM clause, got:\n{}", out.sql);
14584    }
14585
14586    #[test]
14587    fn test_pgvector_cast_emits_vector_type() {
14588        let out = compile_and_emit("SELECT <pgvector::vector>[1.0, 2.0, 3.0]");
14589        assert!(
14590            out.sql.contains("::vector"),
14591            "expected ::vector cast, got:\n{}",
14592            out.sql
14593        );
14594        assert!(out.sql.contains("ARRAY["), "expected ARRAY literal, got:\n{}", out.sql);
14595    }
14596
14597    #[test]
14598    fn test_pgvector_euclidean_distance_emits_l2_operator() {
14599        let out = compile_and_emit(
14600            "SELECT pgvector::euclidean_distance(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
14601        );
14602        assert!(out.sql.contains("<->"), "expected <-> operator, got:\n{}", out.sql);
14603    }
14604
14605    #[test]
14606    fn test_pgvector_cosine_distance_emits_cosine_operator() {
14607        let out = compile_and_emit(
14608            "SELECT pgvector::cosine_distance(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
14609        );
14610        assert!(out.sql.contains("<=>"), "expected <=> operator, got:\n{}", out.sql);
14611    }
14612
14613    #[test]
14614    fn test_pgvector_neg_inner_product_emits_ip_operator() {
14615        let out = compile_and_emit(
14616            "SELECT pgvector::neg_inner_product(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
14617        );
14618        assert!(out.sql.contains("<#>"), "expected <#> operator, got:\n{}", out.sql);
14619    }
14620
14621    #[test]
14622    fn test_pgvector_inner_product_negates_ip_operator() {
14623        let out = compile_and_emit(
14624            "SELECT pgvector::inner_product(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
14625        );
14626        assert!(out.sql.contains("<#>"), "expected <#> operator, got:\n{}", out.sql);
14627        assert!(out.sql.contains("0.0"), "expected negation of <#>, got:\n{}", out.sql);
14628    }
14629
14630    #[test]
14631    fn test_crypto_digest_str_and_bytes_overloads_both_use_pgcrypto_digest() {
14632        let out = compile_and_emit("SELECT crypto::digest('hello', 'sha256')");
14633        assert!(
14634            out.sql.contains("digest("),
14635            "expected pgcrypto's digest(), got:\n{}",
14636            out.sql
14637        );
14638
14639        let out = compile_and_emit("SELECT crypto::digest(std::from_hex('68656c6c6f'), 'sha256')");
14640        assert!(
14641            out.sql.contains("digest("),
14642            "expected pgcrypto's digest(), got:\n{}",
14643            out.sql
14644        );
14645    }
14646
14647    #[test]
14648    fn test_crypto_hmac_str_and_bytes_overloads_both_use_pgcrypto_hmac() {
14649        let out = compile_and_emit("SELECT crypto::hmac('hello', 'key', 'sha256')");
14650        assert!(
14651            out.sql.contains("hmac("),
14652            "expected pgcrypto's hmac(), got:\n{}",
14653            out.sql
14654        );
14655
14656        let out =
14657            compile_and_emit("SELECT crypto::hmac(std::from_hex('68656c6c6f'), std::from_hex('6b6579'), 'sha256')");
14658        assert!(
14659            out.sql.contains("hmac("),
14660            "expected pgcrypto's hmac(), got:\n{}",
14661            out.sql
14662        );
14663    }
14664
14665    #[test]
14666    fn test_crypto_gen_salt_zero_arg_defaults_to_blowfish() {
14667        let out = compile_and_emit("SELECT crypto::gen_salt()");
14668        assert!(
14669            out.sql.contains("gen_salt('bf')"),
14670            "expected default 'bf' salt type, got:\n{}",
14671            out.sql
14672        );
14673    }
14674
14675    #[test]
14676    fn test_crypto_gen_salt_one_arg_passes_type_through() {
14677        let out = compile_and_emit("SELECT crypto::gen_salt('xdes')");
14678        assert!(
14679            out.sql.contains("gen_salt("),
14680            "expected gen_salt() call, got:\n{}",
14681            out.sql
14682        );
14683    }
14684
14685    #[test]
14686    fn test_crypto_gen_salt_iter_count_casts_to_int4() {
14687        let out = compile_and_emit("SELECT crypto::gen_salt('xdes', 5)");
14688        assert!(
14689            out.sql.contains("::int4"),
14690            "expected int8 -> int4 narrowing cast, got:\n{}",
14691            out.sql
14692        );
14693    }
14694
14695    #[test]
14696    fn test_crypto_crypt_uses_pgcrypto_crypt() {
14697        let out = compile_and_emit("SELECT crypto::crypt('hunter2', crypto::gen_salt())");
14698        assert!(
14699            out.sql.contains("crypt("),
14700            "expected pgcrypto's crypt(), got:\n{}",
14701            out.sql
14702        );
14703    }
14704
14705    #[test]
14706    fn test_postgis_cast_emits_geometry_type() {
14707        let out = compile_and_emit("SELECT <postgis::geometry>'POINT(1 2)'");
14708        assert!(
14709            out.sql.contains("::geometry"),
14710            "expected ::geometry cast, got:\n{}",
14711            out.sql
14712        );
14713    }
14714
14715    #[test]
14716    fn test_postgis_x_uses_st_x_builtin() {
14717        let out = compile_and_emit("SELECT postgis::x(<postgis::geometry>'POINT(1 2)')");
14718        assert!(out.sql.contains("st_x("), "expected st_x() call, got:\n{}", out.sql);
14719    }
14720
14721    #[test]
14722    fn test_postgis_area_geometry_and_geography_overloads() {
14723        let out = compile_and_emit("SELECT postgis::area(<postgis::geometry>'POINT(1 2)')");
14724        assert!(
14725            out.sql.contains("st_area("),
14726            "expected st_area() call, got:\n{}",
14727            out.sql
14728        );
14729
14730        let out = compile_and_emit("SELECT postgis::area(<postgis::geography>'POINT(1 2)', true)");
14731        assert!(
14732            out.sql.contains("st_area("),
14733            "expected st_area() call, got:\n{}",
14734            out.sql
14735        );
14736    }
14737
14738    #[test]
14739    fn test_postgis_setsrid_casts_int64_arg_to_int4() {
14740        let out = compile_and_emit("SELECT postgis::setsrid(<postgis::geometry>'POINT(1 2)', 4326)");
14741        assert!(
14742            out.sql.contains("st_setsrid("),
14743            "expected st_setsrid() call, got:\n{}",
14744            out.sql
14745        );
14746        assert!(
14747            out.sql.contains("::int4"),
14748            "expected int8 -> int4 narrowing cast, got:\n{}",
14749            out.sql
14750        );
14751    }
14752
14753    #[test]
14754    fn test_postgis_quantizecoordinates_default_arity_variants_compile() {
14755        // The reference binding documents this with 3 trailing optional
14756        // params; Pylon has no notion of default args, so each arity is
14757        // its own registered overload — confirm both the 2-arg and 4-arg
14758        // forms resolve.
14759        let out = compile_and_emit("SELECT postgis::quantizecoordinates(<postgis::geometry>'POINT(1 2)', 5)");
14760        assert!(out.sql.contains("st_quantizecoordinates("), "got:\n{}", out.sql);
14761
14762        let out = compile_and_emit("SELECT postgis::quantizecoordinates(<postgis::geometry>'POINT(1 2)', 5, 5, 5)");
14763        assert!(out.sql.contains("st_quantizecoordinates("), "got:\n{}", out.sql);
14764    }
14765
14766    #[test]
14767    fn test_postgis_op_contains_emits_infix_operator_not_function_call() {
14768        // Regression: ImplStrategy::SqlOperator was never actually consulted
14769        // by resolve_fn_call — it fell through to the generic "schema.name(args)"
14770        // FunctionCall path, which would have emitted a nonexistent
14771        // `"postgis".op_contains(...)` call instead of the `~` operator.
14772        let out = compile_and_emit(
14773            "SELECT postgis::op_contains(<postgis::geometry>'POINT(1 2)', <postgis::geometry>'POINT(3 4)')",
14774        );
14775        assert!(out.sql.contains(" ~ "), "expected infix ~ operator, got:\n{}", out.sql);
14776        assert!(
14777            !out.sql.contains("op_contains("),
14778            "must not call a literal op_contains function, got:\n{}",
14779            out.sql
14780        );
14781    }
14782
14783    #[test]
14784    fn test_postgis_op_overlaps_geometry_and_geography_overloads() {
14785        let out = compile_and_emit(
14786            "SELECT postgis::op_overlaps(<postgis::geometry>'POINT(1 2)', <postgis::geometry>'POINT(3 4)')",
14787        );
14788        assert!(
14789            out.sql.contains(" && "),
14790            "expected infix && operator, got:\n{}",
14791            out.sql
14792        );
14793
14794        let out = compile_and_emit(
14795            "SELECT postgis::op_overlaps(<postgis::geography>'POINT(1 2)', <postgis::geography>'POINT(3 4)')",
14796        );
14797        assert!(
14798            out.sql.contains(" && "),
14799            "expected infix && operator, got:\n{}",
14800            out.sql
14801        );
14802    }
14803
14804    // ── User-defined function tests ───────────────────────────────────────────
14805
14806    fn make_schema_with_fns() -> SchemaDescriptor {
14807        let mut s = make_schema();
14808        s.functions = vec![
14809            FunctionDescriptor {
14810                name: "mysum".into(),
14811                module: "default".into(),
14812                params: vec![
14813                    FunctionParamDescriptor {
14814                        name: "a".into(),
14815                        pg_type: "int8".into(),
14816                    },
14817                    FunctionParamDescriptor {
14818                        name: "b".into(),
14819                        pg_type: "int8".into(),
14820                    },
14821                ],
14822                return_pg_type: "int8".into(),
14823                return_is_object: false,
14824                return_is_set: false,
14825                return_is_polymorphic: false,
14826                volatility: "immutable".into(),
14827                body: "a + b".into(),
14828            },
14829            FunctionDescriptor {
14830                name: "adults".into(),
14831                module: "default".into(),
14832                params: vec![],
14833                return_pg_type: "default::Person".into(),
14834                return_is_object: true,
14835                return_is_set: true,
14836                return_is_polymorphic: false,
14837                volatility: "stable".into(),
14838                body: "select Person filter .age > 18".into(),
14839            },
14840            // A second, 3-arg overload of `mysum`, declared *before* the
14841            // 2-arg one is looked up by resolution below — regression for a
14842            // bug where call resolution grabbed the first name-matching
14843            // overload regardless of arity, instead of searching all
14844            // same-named overloads for one whose param count actually
14845            // matches the call site.
14846            FunctionDescriptor {
14847                name: "mysum".into(),
14848                module: "default".into(),
14849                params: vec![
14850                    FunctionParamDescriptor {
14851                        name: "a".into(),
14852                        pg_type: "int8".into(),
14853                    },
14854                    FunctionParamDescriptor {
14855                        name: "b".into(),
14856                        pg_type: "int8".into(),
14857                    },
14858                    FunctionParamDescriptor {
14859                        name: "c".into(),
14860                        pg_type: "int8".into(),
14861                    },
14862                ],
14863                return_pg_type: "int8".into(),
14864                return_is_object: false,
14865                return_is_set: false,
14866                return_is_polymorphic: false,
14867                volatility: "immutable".into(),
14868                body: "a + b + c".into(),
14869            },
14870        ];
14871        s
14872    }
14873
14874    #[test]
14875    fn test_user_fn_scalar_call() {
14876        let schema = make_schema_with_fns();
14877        let out = compile_and_emit_with("SELECT mysum(1, 2)", &schema);
14878        assert!(out.sql.contains("\"public\".\"mysum\""), "got:\n{}", out.sql);
14879    }
14880
14881    #[test]
14882    fn test_user_fn_overload_resolved_by_argument_count() {
14883        // Regression: with a 2-arg and a 3-arg `mysum` overload both
14884        // declared, a 3-arg call must resolve to the 3-arg overload rather
14885        // than erroring against whichever overload happens to be first in
14886        // `schema.functions`.
14887        let schema = make_schema_with_fns();
14888        let out = compile_and_emit_with("SELECT mysum(1, 2, 3)", &schema);
14889        assert!(
14890            out.sql
14891                .contains("\"public\".\"mysum\"((1)::int8, (2)::int8, (3)::int8)"),
14892            "got:\n{}",
14893            out.sql,
14894        );
14895
14896        // The 2-arg call must still resolve to the 2-arg overload.
14897        let out = compile_and_emit_with("SELECT mysum(1, 2)", &schema);
14898        assert!(
14899            out.sql.contains("\"public\".\"mysum\"((1)::int8, (2)::int8)"),
14900            "got:\n{}",
14901            out.sql,
14902        );
14903    }
14904
14905    #[test]
14906    fn test_user_fn_object_select_no_shape() {
14907        let schema = make_schema_with_fns();
14908        let out = compile_and_emit_with("SELECT adults()", &schema);
14909        assert!(out.sql.contains("\"public\".\"adults\"()"), "got:\n{}", out.sql);
14910        assert!(out.sql.contains("FROM"), "got:\n{}", out.sql);
14911    }
14912
14913    #[test]
14914    fn test_user_fn_object_select_with_shape() {
14915        let schema = make_schema_with_fns();
14916        let out = compile_and_emit_with("SELECT adults() { name }", &schema);
14917        assert!(out.sql.contains("\"public\".\"adults\"()"), "got:\n{}", out.sql);
14918        assert!(out.sql.contains("\"name\""), "got:\n{}", out.sql);
14919    }
14920
14921    #[test]
14922    fn test_object_fn_projected_to_a_property_inside_an_expression() {
14923        let schema = make_schema_with_fns();
14924        let out = compile_and_emit_with("SELECT Person { n := adults().name }", &schema);
14925        assert!(out.sql.contains("FROM \"public\".\"adults\"() AS"), "got:\n{}", out.sql);
14926        assert!(out.sql.contains("\".\"name\"\n"), "got:\n{}", out.sql);
14927    }
14928
14929    #[test]
14930    fn test_object_fn_sub_select_carries_its_modifiers() {
14931        let schema = make_schema_with_fns();
14932        let out = compile_and_emit_with(
14933            "SELECT Person { n := (select adults() filter .age > 21 limit 1).name }",
14934            &schema,
14935        );
14936        assert!(out.sql.contains("FROM \"public\".\"adults\"() AS"), "got:\n{}", out.sql);
14937        assert!(out.sql.contains("\"age\" > 21"), "got:\n{}", out.sql);
14938        assert!(out.sql.contains("LIMIT 1"), "got:\n{}", out.sql);
14939    }
14940
14941    #[test]
14942    fn test_object_fn_without_a_property_still_explains_the_restriction() {
14943        let schema = make_schema_with_fns();
14944        let ast = parse::parse("SELECT Person { n := adults() + 1 }").unwrap();
14945        let err = match ir::compile(&ast, &schema) {
14946            Ok(_) => panic!("expected a compile error"),
14947            Err(e) => format!("{e}"),
14948        };
14949        assert!(err.contains("returns objects"), "{err}");
14950    }
14951
14952    #[test]
14953    fn test_object_fn_projected_to_an_unknown_property_is_rejected() {
14954        let schema = make_schema_with_fns();
14955        let ast = parse::parse("SELECT Person { n := adults().nope }").unwrap();
14956        let err = match ir::compile(&ast, &schema) {
14957            Ok(_) => panic!("expected a compile error"),
14958            Err(e) => format!("{e}"),
14959        };
14960        assert!(err.contains("nope"), "{err}");
14961    }
14962
14963    #[test]
14964    fn test_user_fn_in_cte_exposes_raw_columns() {
14965        // Regression: FunctionSelect as a CTE source must emit SELECT * FROM fn()
14966        // so the outer query can reference raw columns like t1.age.
14967        let schema = make_schema_with_fns();
14968        let out = compile_and_emit_with("WITH persons := adults() SELECT persons FILTER .age > 25", &schema);
14969        assert!(
14970            out.sql.contains("SELECT * FROM \"public\".\"adults\"()"),
14971            "CTE source must be SELECT * FROM fn(), got:\n{}",
14972            out.sql,
14973        );
14974        assert!(
14975            out.sql.contains("\"age\""),
14976            "outer filter must reference raw column, got:\n{}",
14977            out.sql
14978        );
14979    }
14980
14981    // ── vector::search tests ──────────────────────────────────────────────────
14982
14983    fn make_schema_with_vector() -> SchemaDescriptor {
14984        use crate::schema::VectorIndexDescriptor;
14985        let mut s = make_schema();
14986        if let Some(td) = s.types.iter_mut().find(|t| t.name == "Person") {
14987            td.vector_indexes.push(VectorIndexDescriptor {
14988                index_name: None,
14989                pointers: vec!["name".into()],
14990                model: "test-embed".into(),
14991                metric: "cosine".into(),
14992                dimensions: 4,
14993            });
14994        }
14995        s
14996    }
14997
14998    #[test]
14999    fn test_vector_search_bare_type_name() {
15000        let schema = make_schema_with_vector();
15001        let out = compile_and_emit_with(
15002            "WITH search := vector::search(Person, <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
15003             SELECT search { object { name }, distance }",
15004            &schema,
15005        );
15006        assert!(
15007            out.sql.contains("\"Person\""),
15008            "expected Person table, got:\n{}",
15009            out.sql
15010        );
15011        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
15012    }
15013
15014    #[test]
15015    fn test_vector_search_qualified_type_name() {
15016        let schema = make_schema_with_vector();
15017        let out = compile_and_emit_with(
15018            "WITH search := vector::search(default::Person, <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
15019             SELECT search { object { name }, distance }",
15020            &schema,
15021        );
15022        assert!(
15023            out.sql.contains("\"Person\""),
15024            "expected Person table, got:\n{}",
15025            out.sql
15026        );
15027        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
15028    }
15029
15030    #[test]
15031    fn test_vector_search_subquery_filter_included_in_where() {
15032        let schema = make_schema_with_vector();
15033        let out = compile_and_emit_with(
15034            "WITH search := vector::search((select Person filter .name = 'Alice'), <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
15035             SELECT search { object { name }, distance }",
15036            &schema,
15037        );
15038        assert!(
15039            out.sql.contains("\"Person\""),
15040            "expected Person table, got:\n{}",
15041            out.sql
15042        );
15043        assert!(out.sql.contains("\"name\""), "expected name filter, got:\n{}", out.sql);
15044        assert!(out.sql.contains("Alice"), "expected filter value, got:\n{}", out.sql);
15045        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
15046    }
15047
15048    #[test]
15049    fn test_vector_search_subquery_filter_combined_with_outer_property_filter() {
15050        let schema = make_schema_with_vector();
15051        let out = compile_and_emit_with(
15052            "WITH search := vector::search((select Person filter .age > 18), <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
15053             SELECT search { object { name }, distance }",
15054            &schema,
15055        );
15056        assert!(
15057            out.sql.contains("\"age\""),
15058            "expected age pre-filter, got:\n{}",
15059            out.sql
15060        );
15061        assert!(out.sql.contains("18"), "expected filter value 18, got:\n{}", out.sql);
15062        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
15063    }
15064
15065    #[test]
15066    fn test_vector_search_text_overload_with_subquery_filter() {
15067        let schema = make_schema_with_vector();
15068        let out = compile_and_emit_with(
15069            "WITH search := vector::search((select Person filter .name = 'Alice'), query := $q) \
15070             SELECT search { object { name }, distance }",
15071            &schema,
15072        );
15073        assert!(
15074            out.sql.contains("\"Person\""),
15075            "expected Person table, got:\n{}",
15076            out.sql
15077        );
15078        assert!(
15079            out.sql.contains("Alice"),
15080            "expected pre-filter value, got:\n{}",
15081            out.sql
15082        );
15083        assert!(
15084            out.sql.contains("float8[]"),
15085            "expected float8[] cast for deferred vec param, got:\n{}",
15086            out.sql
15087        );
15088        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
15089    }
15090
15091    #[test]
15092    fn test_count_type_ref_compiles_to_agg_over_query() {
15093        let out = compile_and_emit("SELECT count(Person)");
15094        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
15095        assert!(
15096            out.sql.contains("\"Person\""),
15097            "expected Person table, got:\n{}",
15098            out.sql
15099        );
15100    }
15101
15102    #[test]
15103    fn test_count_qualified_type_ref_compiles_to_agg_over_query() {
15104        let out = compile_and_emit("SELECT count(default::Person)");
15105        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
15106        assert!(
15107            out.sql.contains("\"Person\""),
15108            "expected Person table, got:\n{}",
15109            out.sql
15110        );
15111    }
15112
15113    #[test]
15114    fn test_count_subquery_compiles_to_agg_over_query() {
15115        let out = compile_and_emit("SELECT count((select Person))");
15116        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
15117        assert!(
15118            out.sql.contains("\"Person\""),
15119            "expected Person table, got:\n{}",
15120            out.sql
15121        );
15122    }
15123
15124    #[test]
15125    fn test_count_subquery_with_filter() {
15126        let out = compile_and_emit("SELECT count((select Person filter .name = 'Alice'))");
15127        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
15128        assert!(
15129            out.sql.contains("\"name\""),
15130            "expected filter on name, got:\n{}",
15131            out.sql
15132        );
15133    }
15134
15135    #[test]
15136    fn test_positional_param_compiles_to_dollar_n() {
15137        let out = compile_and_emit("SELECT Person FILTER .name = $0");
15138        assert!(out.sql.contains("$1"), "expected $1 placeholder, got:\n{}", out.sql);
15139    }
15140
15141    #[test]
15142    fn test_multiple_positional_params_compile_in_order() {
15143        let out = compile_and_emit("SELECT Person FILTER .name = $0 AND .age > $1");
15144        assert!(out.sql.contains("$1"), "expected $1, got:\n{}", out.sql);
15145        assert!(out.sql.contains("$2"), "expected $2, got:\n{}", out.sql);
15146    }
15147
15148    #[test]
15149    fn test_repeated_positional_param_reuses_slot() {
15150        let out = compile_and_emit("SELECT Person FILTER .name = $0 OR .name = $0");
15151        assert_eq!(
15152            out.sql.matches("$1").count(),
15153            2,
15154            "both uses must reference $1, got:\n{}",
15155            out.sql
15156        );
15157    }
15158
15159    #[test]
15160    fn test_cast_to_nonexistent_type_names_full_type() {
15161        let schema = make_schema();
15162        let ast = parse::parse("SELECT Person FILTER .name = <default::Ghost>$name").unwrap();
15163        match ir::compile(&ast, &schema) {
15164            Ok(_) => panic!("expected compile error for unknown type"),
15165            Err(e) => {
15166                let msg = e.to_string();
15167                assert!(
15168                    msg.contains("unknown type 'default::Ghost'"),
15169                    "expected full type name in error, got: {msg}",
15170                );
15171            }
15172        }
15173    }
15174
15175    #[test]
15176    fn test_cast_to_nonexistent_unqualified_type_names_type() {
15177        let schema = make_schema();
15178        let ast = parse::parse("SELECT Person FILTER .name = <Ghost>$name").unwrap();
15179        match ir::compile(&ast, &schema) {
15180            Ok(_) => panic!("expected compile error for unknown type"),
15181            Err(e) => {
15182                let msg = e.to_string();
15183                assert!(
15184                    msg.contains("unknown type 'Ghost'"),
15185                    "expected type name in error, got: {msg}",
15186                );
15187            }
15188        }
15189    }
15190
15191    #[test]
15192    fn test_top_level_cast_to_registered_scalar_uses_its_own_domain() {
15193        // A bare top-level `SELECT <module::Scalar>expr` must compile as a
15194        // plain scalar cast to that scalar's own DOMAIN — not get misrouted
15195        // to `compile_schema_cast_select`'s object-by-id lookup (which
15196        // treated any non-stdlib-module named cast target as an object type,
15197        // since it never checked `resolve_scalar` before falling through).
15198        use crate::schema::ScalarDescriptor;
15199        let mut schema = make_schema();
15200        schema.scalars.push(ScalarDescriptor {
15201            name: "Email".into(),
15202            module: "default".into(),
15203            base: "Str".into(),
15204            pg_type: "text".into(),
15205            check_constraints: vec!["value ~ '^[^@]+@[^@]+\\.[^@]+$'".into()],
15206            is_sequence: false,
15207        });
15208        let out = compile_and_emit_with("SELECT <default::Email>'test@test.de'", &schema);
15209        assert!(
15210            out.sql.contains("\"public\".\"Email\""),
15211            "expected a cast to the scalar's own domain, got:\n{}",
15212            out.sql
15213        );
15214        assert!(
15215            !out.sql.to_lowercase().contains("\"person\""),
15216            "must not be misrouted to an object-type lookup, got:\n{}",
15217            out.sql
15218        );
15219    }
15220
15221    #[test]
15222    fn test_top_level_cast_to_unknown_type_names_full_type() {
15223        // The object-cast fallback (`compile_schema_cast_select`) used to
15224        // discard the module before building its synthetic path, so an
15225        // unknown top-level cast target reported a bare name even when
15226        // written with an explicit module.
15227        let schema = make_schema();
15228        let ast = parse::parse("SELECT <default::Ghost>$name").unwrap();
15229        match ir::compile(&ast, &schema) {
15230            Ok(_) => panic!("expected compile error for unknown type"),
15231            Err(e) => {
15232                let msg = e.to_string();
15233                assert!(
15234                    msg.contains("unknown type 'default::Ghost'"),
15235                    "expected full type name in error, got: {msg}",
15236                );
15237            }
15238        }
15239    }
15240
15241    #[test]
15242    fn test_structural_tuple_cast_unnamed_resolves_to_jsonb() {
15243        let out = compile_and_emit("SELECT <tuple<str, bool>>$p");
15244        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
15245    }
15246
15247    #[test]
15248    fn test_jsonb_to_uuid_cast_extracts_via_text() {
15249        // PostgreSQL has no native jsonb -> uuid cast; extract raw text then cast.
15250        let out = compile_and_emit("SELECT <uuid>(<json>$p)");
15251        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
15252        assert!(out.sql.contains("::uuid"), "got:\n{}", out.sql);
15253    }
15254
15255    #[test]
15256    fn test_jsonb_to_datetime_cast_extracts_via_text() {
15257        let out = compile_and_emit("SELECT <datetime>(<json>$p)");
15258        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
15259        assert!(out.sql.contains("::timestamptz"), "got:\n{}", out.sql);
15260    }
15261
15262    #[test]
15263    fn test_jsonb_to_duration_cast_extracts_via_text() {
15264        let out = compile_and_emit("SELECT <duration>(<json>$p)");
15265        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
15266        assert!(out.sql.contains("::interval"), "got:\n{}", out.sql);
15267    }
15268
15269    #[test]
15270    fn test_jsonb_to_array_cast_unpacks_each_element() {
15271        let out = compile_and_emit("SELECT <array<int64>>(<json>$p)");
15272        assert!(out.sql.contains("jsonb_array_elements("), "got:\n{}", out.sql);
15273        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
15274        assert!(out.sql.contains("::int8"), "got:\n{}", out.sql);
15275    }
15276
15277    #[test]
15278    fn test_non_jsonb_cast_is_unaffected_by_jsonb_extraction() {
15279        // A plain str -> uuid cast (source isn't jsonb) must still use the
15280        // ordinary `::pg_type` path, not the jsonb-extraction template.
15281        let out = compile_and_emit("SELECT <uuid>$p");
15282        assert!(!out.sql.contains("#>>"), "got:\n{}", out.sql);
15283        assert!(out.sql.contains("::uuid"), "got:\n{}", out.sql);
15284    }
15285
15286    #[test]
15287    fn test_datetime_plus_duration_type_checks() {
15288        // Regression: types_compatible's bucket-matching (same type, or
15289        // both-int, or both-float) rejected this outright, even though
15290        // Postgres supports `timestamptz + interval` natively and this is
15291        // a legitimate operator overload.
15292        let out = compile_and_emit("SELECT <datetime>$p + <duration>$d");
15293        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
15294    }
15295
15296    #[test]
15297    fn test_duration_plus_datetime_type_checks_either_order() {
15298        let out = compile_and_emit("SELECT <duration>$d + <datetime>$p");
15299        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
15300    }
15301
15302    #[test]
15303    fn test_local_date_plus_duration_type_checks() {
15304        let out = compile_and_emit("SELECT <cal::local_date>$p + <duration>$d");
15305        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
15306    }
15307
15308    #[test]
15309    fn test_datetime_minus_duration_type_checks() {
15310        let out = compile_and_emit("SELECT <datetime>$p - <duration>$d");
15311        assert!(out.sql.contains(" - "), "got:\n{}", out.sql);
15312    }
15313
15314    #[test]
15315    fn test_local_time_plus_duration_type_checks() {
15316        let out = compile_and_emit("SELECT <cal::local_time>$p + <duration>$d");
15317        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
15318    }
15319
15320    #[test]
15321    fn test_datetime_plus_duration_only_allowed_for_add_and_sub() {
15322        // Comparing (or otherwise combining) a datetime and a duration with
15323        // anything other than +/- still isn't meaningful and must still error.
15324        let schema = make_schema();
15325        let ast = parse::parse("SELECT <datetime>$p = <duration>$d").unwrap();
15326        match ir::compile(&ast, &schema) {
15327            Err(err) => assert!(format!("{err}").contains("cannot be applied"), "got: {err}"),
15328            Ok(_) => panic!("expected a compile error"),
15329        }
15330    }
15331
15332    #[test]
15333    fn test_int_and_float_mix_is_allowed() {
15334        // Every int width implicitly casts to every float width, so mixed
15335        // int/float arithmetic type-checks without an explicit cast —
15336        // Postgres's own operator resolution handles the actual promotion.
15337        let out = compile_and_emit("SELECT <int16>1 + <float32>2.0");
15338        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
15339    }
15340
15341    #[test]
15342    fn test_int_and_decimal_mix_is_allowed() {
15343        // int64 -> bigint -> decimal is a separate implicit-cast branch
15344        // from int64 -> float32 -> float64, but every int width still
15345        // mixes with numeric/decimal directly.
15346        let out = compile_and_emit("SELECT <int64>1 + <decimal>2.0");
15347        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
15348    }
15349
15350    #[test]
15351    fn test_float_and_decimal_mix_still_rejected() {
15352        // float and numeric/decimal are separate branches past int64 in
15353        // the implicit-cast graph and don't cast to each other — this
15354        // still needs an explicit cast.
15355        let schema = make_schema();
15356        let ast = parse::parse("SELECT <decimal>$p + <float64>$n").unwrap();
15357        match ir::compile(&ast, &schema) {
15358            Err(err) => assert!(format!("{err}").contains("cannot be applied"), "got: {err}"),
15359            Ok(_) => panic!("expected a compile error"),
15360        }
15361    }
15362
15363    #[test]
15364    fn test_unknown_pointer_suggests_a_close_match() {
15365        // `Person` has a `name` property (see make_schema) — `nam` is close
15366        // enough (missing one trailing character) that a "Did you mean"
15367        // suggestion should fire.
15368        let schema = make_schema();
15369        let ast = parse::parse("SELECT Person { nam }").unwrap();
15370        match ir::compile(&ast, &schema) {
15371            Err(err) => assert_eq!(
15372                format!("{err}"),
15373                "object type 'default::Person' has no link or property 'nam'. Did you mean 'name'?"
15374            ),
15375            Ok(_) => panic!("expected a compile error"),
15376        }
15377    }
15378
15379    #[test]
15380    fn test_unrelated_unknown_pointer_gets_no_suggestion() {
15381        // A typo with no plausible match on the type shouldn't force a
15382        // confusing, unrelated suggestion onto the user.
15383        let schema = make_schema();
15384        let ast = parse::parse("SELECT Person { xyzxyzxyz }").unwrap();
15385        match ir::compile(&ast, &schema) {
15386            Err(err) => {
15387                let msg = format!("{err}");
15388                assert!(msg.contains("has no link or property 'xyzxyzxyz'"), "got: {msg}");
15389                assert!(!msg.contains("Did you mean"), "got: {msg}");
15390            }
15391            Ok(_) => panic!("expected a compile error"),
15392        }
15393    }
15394
15395    #[test]
15396    fn test_multilink_sourced_backlink_exists_filter_compiles() {
15397        // Regression for a gap live-execution testing caught: `filter exists
15398        // .<multilink[is Type]` (a backlink whose source pointer on the
15399        // target type is a multi-link, e.g. `Person.posts`, not a single
15400        // FK link) used to fail to compile with "has no link ... pointing
15401        // to" — `compile_backlink_as_exists` only ever checked
15402        // `target_td.links`, never `target_td.multilinks`. Fast SQL-text
15403        // companion to the live-execution test in
15404        // `tests/live_execution_backlinks.rs`, which additionally confirms
15405        // the emitted SQL returns the right rows.
15406        let out = compile_and_emit("SELECT Post filter exists .<posts[is Person]");
15407        assert!(out.sql.contains("EXISTS"), "got:\n{}", out.sql);
15408    }
15409
15410    #[test]
15411    fn test_backlink_as_computed_shape_pointer_compiles() {
15412        // Regression for the bigger gap live-execution testing caught: a
15413        // backlink could only be used in `filter exists .<...>` — using one
15414        // to select/project actual objects as a computed pointer's value
15415        // (`authors := .<posts[is Person] { name }`) failed to compile at
15416        // all ("shapes and set literals are not valid in expression
15417        // context"). `compile_shape_element`'s computed-pointer dispatch
15418        // now recognizes a backlink-rooted `Expr::Shape`/`Expr::Path` RHS
15419        // and routes it through `compile_backlink_pointer`, mirroring
15420        // `compile_multilink_pointer` for the forward direction. Fast
15421        // SQL-text companion to the live-execution tests in
15422        // `tests/live_execution_backlinks.rs`, which additionally confirm
15423        // the emitted SQL returns the right rows, nested 2 levels deep.
15424        let out = compile_and_emit("SELECT Post { title, authors := .<posts[is Person] { name } }");
15425        assert!(out.sql.contains("array_agg(ROW("), "got:\n{}", out.sql);
15426    }
15427
15428    fn schema_with_exclusive_posts() -> SchemaDescriptor {
15429        let mut schema = make_schema();
15430        for td in schema.types.iter_mut().filter(|td| td.name == "Person") {
15431            for ml in td.multilinks.iter_mut().filter(|ml| ml.name == "posts") {
15432                ml.is_exclusive = true;
15433            }
15434        }
15435        schema
15436    }
15437
15438    #[test]
15439    fn test_backlink_through_an_exclusive_multilink_is_single() {
15440        let out = compile_and_emit_with(
15441            "SELECT Post { title, author := .<posts[is Person] { name } }",
15442            &schema_with_exclusive_posts(),
15443        );
15444        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
15445            panic!("expected an object root, got {:?}", out.shape.root)
15446        };
15447        assert!(
15448            matches!(pointers.last(), Some(ShapeNode::Object { name, .. }) if name == "author"),
15449            "got {pointers:?}"
15450        );
15451    }
15452
15453    #[test]
15454    fn test_a_path_through_an_exclusive_backlink_is_single() {
15455        let out = compile_and_emit_with(
15456            "SELECT Post { title, author_name := .<posts[is Person].name }",
15457            &schema_with_exclusive_posts(),
15458        );
15459        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
15460            panic!("expected an object root, got {:?}", out.shape.root)
15461        };
15462        assert!(
15463            matches!(pointers.last(), Some(ShapeNode::Scalar { name, .. }) if name == "author_name"),
15464            "got {pointers:?}"
15465        );
15466        assert!(!out.sql.contains("ARRAY("), "got:\n{}", out.sql);
15467    }
15468
15469    #[test]
15470    fn test_structural_tuple_cast_named_resolves_to_jsonb() {
15471        let out = compile_and_emit("SELECT <tuple<x: float64, y: float64>>$p");
15472        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
15473    }
15474
15475    #[test]
15476    fn test_structural_tuple_cast_nested_resolves_to_jsonb() {
15477        let out = compile_and_emit("SELECT <tuple<point: tuple<x: float64, y: float64>, label: str>>$p");
15478        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
15479    }
15480
15481    #[test]
15482    fn test_a_tuple_parameter_binds_as_the_shape_a_property_declares() {
15483        // A structural tuple has no name, so the composite it binds against
15484        // is the one a property already declares for that shape.
15485        let schema = make_schema_with_a_structural_tuple_property();
15486        let name =
15487            crate::schema::tuple_type::structural_name(schema.types[0].properties[0].tuple_members.as_ref().unwrap());
15488        let out = compile_and_emit_with("SELECT <tuple<street: str, zip: str>>$p", &schema);
15489        assert!(
15490            out.sql.contains(&format!("($1)::\"public\".\"{name}\"")),
15491            "got:\n{}",
15492            out.sql
15493        );
15494    }
15495
15496    #[test]
15497    fn test_a_tuple_parameter_no_one_declares_keeps_the_json_it_travelled_as() {
15498        // Nothing declares this shape, so there is no composite to bind —
15499        // and every member here survives json, so it still travels that way.
15500        let schema = make_schema_with_a_structural_tuple_property();
15501        let out = compile_and_emit_with("SELECT <tuple<other: str, n: int64>>$p", &schema);
15502        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
15503    }
15504
15505    #[test]
15506    fn test_a_tuple_parameter_json_cannot_carry_is_refused() {
15507        // Refused rather than silently arriving as text: there is no
15508        // composite to bind against, and jsonb has no `bytes`.
15509        let schema = make_schema_with_a_structural_tuple_property();
15510        let err = compile_err_with("SELECT <tuple<raw: bytes, n: int64>>$p", &schema);
15511        assert!(err.contains("std::bytes"), "got: {err}");
15512        assert!(err.contains("nothing declares it"), "got: {err}");
15513        assert!(err.contains("named_tuple"), "the error has to say what to do: {err}");
15514    }
15515
15516    #[test]
15517    fn test_nominal_named_tuple_cast_resolves_to_the_declared_composite() {
15518        // Bound as a value of the type's own composite, not as jsonb: that is
15519        // what lets a `bytes` member stay bytes and a non-finite `float64`
15520        // arrive at all.
15521        let mut schema = make_schema();
15522        schema.named_tuples.push(NamedTupleDescriptor {
15523            name: "Point".into(),
15524            module: "default".into(),
15525            members: vec![],
15526        });
15527        let out = compile_and_emit_with("SELECT <default::Point>$p", &schema);
15528        assert!(out.sql.contains("($1)::\"public\".\"Point_t\""), "got:\n{}", out.sql);
15529        assert!(!out.sql.contains("jsonb"), "got:\n{}", out.sql);
15530    }
15531
15532    #[test]
15533    fn test_array_literal_cast_resolves_to_native_pg_array_not_jsonb() {
15534        // The exact query reported as failing — must compile now, and must
15535        // resolve to a real Postgres array (text[]), never jsonb (arrays
15536        // decode natively, unlike tuples).
15537        let out = compile_and_emit("SELECT <array<str>>['foo', 'bar']");
15538        assert!(
15539            out.sql.contains("::text[]") || out.sql.contains("ARRAY["),
15540            "got:\n{}",
15541            out.sql
15542        );
15543        assert!(
15544            !out.sql.contains("jsonb"),
15545            "arrays must not use jsonb, got:\n{}",
15546            out.sql
15547        );
15548    }
15549
15550    #[test]
15551    fn test_array_literal_cast_applies_per_element_cast() {
15552        // Each element gets its own real cast, not a raw untyped ARRAY[...] —
15553        // '1' and '2' must actually coerce to int8 (mirrors the equivalent
15554        // tuple test).
15555        let out = compile_and_emit("SELECT <array<int64>>['1', '2']");
15556        assert!(out.sql.contains("ARRAY[('1')::int8, ('2')::int8]"), "got:\n{}", out.sql);
15557    }
15558
15559    #[test]
15560    fn test_array_param_cast_uses_direct_suffix_cast() {
15561        let out = compile_and_emit("SELECT <array<int64>>$p");
15562        assert!(out.sql.contains("::int8[]"), "got:\n{}", out.sql);
15563    }
15564
15565    #[test]
15566    fn test_array_of_named_tuple_element_casts_to_jsonb_array() {
15567        // An array's element type can be anything except another array —
15568        // including a structural tuple, which still resolves that one
15569        // element to jsonb while the array itself stays a native pg array.
15570        let out = compile_and_emit("SELECT <array<tuple<x: float64, y: float64>>>$p");
15571        assert!(out.sql.contains("::jsonb[]"), "got:\n{}", out.sql);
15572    }
15573
15574    #[test]
15575    fn test_contains_on_array_literal_cast_uses_array_overload_not_strpos() {
15576        // Regression: contains()'s array<Any> overload only matched a bare
15577        // IrExpr::Array node, never a TypeCast-wrapped one — which is what
15578        // every `<array<T>>[...]` cast actually compiles to. Overload
15579        // resolution silently fell back to the *first* registered `contains`
15580        // overload (str, str), emitting a bogus `strpos(text[], text)` call.
15581        let out = compile_and_emit("SELECT contains(<array<str>>[1, 2], '2')");
15582        assert!(out.sql.contains("@> ARRAY["), "got:\n{}", out.sql);
15583        assert!(
15584            !out.sql.contains("strpos"),
15585            "must not fall back to the str/str overload, got:\n{}",
15586            out.sql
15587        );
15588    }
15589
15590    #[test]
15591    fn test_nested_array_type_rejected_at_parse_time() {
15592        match parse::parse("SELECT <array<array<str>>>$p") {
15593            Ok(_) => panic!("expected parse error for nested array type"),
15594            Err(e) => assert!(e.to_string().contains("nested arrays are not supported"), "got: {}", e),
15595        }
15596    }
15597
15598    #[test]
15599    fn test_array_cast_in_computed_shape_field_schema_bound_context() {
15600        // Schema-bound counterpart (compile_expr, not compile_free_expr) — a
15601        // computed shape field casting an array literal.
15602        let out = compile_and_emit("SELECT Person { name, tags := <array<str>>['a', 'b'] }");
15603        assert!(out.sql.contains("ARRAY[('a')::text, ('b')::text]"), "got:\n{}", out.sql);
15604    }
15605
15606    #[test]
15607    fn test_nominal_named_tuple_cast_shape_carries_real_members() {
15608        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
15609        let mut schema = make_schema();
15610        schema.named_tuples.push(NamedTupleDescriptor {
15611            name: "Point".into(),
15612            module: "default".into(),
15613            members: vec![
15614                TupleMemberDescriptor {
15615                    name: Some("x".into()),
15616                    kind: TupleMemberKind::Scalar {
15617                        pg_type: "float8".into(),
15618                    },
15619                },
15620                TupleMemberDescriptor {
15621                    name: Some("y".into()),
15622                    kind: TupleMemberKind::Scalar {
15623                        pg_type: "float8".into(),
15624                    },
15625                },
15626            ],
15627        });
15628        let out = compile_and_emit_with("SELECT <default::Point>$p", &schema);
15629        match &out.shape.root {
15630            crate::query::ShapeNode::Tuple { type_name, names, .. } => {
15631                assert_eq!(type_name.as_deref(), Some("default::Point"));
15632                assert_eq!(names.as_ref().unwrap(), &vec!["x".to_string(), "y".to_string()]);
15633            }
15634            other => panic!("expected ShapeNode::Tuple, got {other:?}"),
15635        }
15636    }
15637
15638    #[test]
15639    fn test_structural_tuple_property_read_shape_carries_real_members() {
15640        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
15641        let schema = SchemaDescriptor {
15642            types: vec![TypeDescriptor {
15643                name: "Person".into(),
15644                module: "default".into(),
15645                table: "Person".into(),
15646                abstract_: false,
15647                materialized: false,
15648                description: None,
15649                parents: vec![],
15650                interfaces: vec![],
15651                bases: vec![],
15652                properties: vec![PropertyDescriptor {
15653                    name: "address".into(),
15654                    pg_type: "jsonb".into(),
15655                    nullable: true,
15656                    default_sql: None,
15657                    default_pyql: None,
15658                    description: None,
15659                    check_constraints: vec![],
15660                    is_exclusive: false,
15661                    is_pk: false,
15662                    is_readonly: false,
15663                    rewrites: vec![],
15664                    tuple_members: Some(vec![
15665                        TupleMemberDescriptor {
15666                            name: Some("street".into()),
15667                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15668                        },
15669                        TupleMemberDescriptor {
15670                            name: Some("zip".into()),
15671                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15672                        },
15673                    ]),
15674                    column_type: None,
15675                }],
15676                links: vec![],
15677                multilinks: vec![],
15678                computed: vec![],
15679                constraints: vec![],
15680                indexes: vec![],
15681                partition: None,
15682                vector_indexes: vec![],
15683                search_indexes: vec![],
15684                triggers: vec![],
15685                junction: false,
15686                signals: vec![],
15687            }],
15688            scalars: vec![],
15689            enums: vec![],
15690            named_tuples: vec![],
15691            globals: vec![],
15692            functions: vec![],
15693            aliases: vec![],
15694            channels: vec![],
15695            ..Default::default()
15696        };
15697        let out = compile_and_emit_with("SELECT Person { address }", &schema);
15698        // Read as the composite column it is, with no cast at all: a cast to
15699        // jsonb is what used to collapse every member onto one number type.
15700        assert!(!out.sql.contains("::jsonb"), "got:\n{}", out.sql);
15701        match &out.shape.root {
15702            crate::query::ShapeNode::Object { pointers, .. } => {
15703                let address = pointers
15704                    .iter()
15705                    .find(|p| matches!(p, crate::query::ShapeNode::Tuple { name, .. } if name == "address"))
15706                    .expect("expected address pointer in shape");
15707                match address {
15708                    crate::query::ShapeNode::Tuple {
15709                        type_name,
15710                        names,
15711                        elements,
15712                        ..
15713                    } => {
15714                        assert_eq!(*type_name, None);
15715                        let names = names.as_ref().expect("a named tuple's members name themselves");
15716                        assert_eq!(names, &vec!["street".to_string(), "zip".to_string()]);
15717                        // One element per member, each at the position the
15718                        // composite row holds it.
15719                        assert_eq!(elements.len(), 2);
15720                        assert!(
15721                            matches!(&elements[0], crate::query::ShapeNode::Scalar { position: 0, .. })
15722                                && matches!(&elements[1], crate::query::ShapeNode::Scalar { position: 1, .. }),
15723                            "got {elements:?}"
15724                        );
15725                    }
15726                    other => panic!("expected Tuple, got {other:?}"),
15727                }
15728            }
15729            other => panic!("expected ShapeNode::Object, got {other:?}"),
15730        }
15731    }
15732
15733    #[test]
15734    fn test_bare_path_select_structural_tuple_property_shape_carries_real_members() {
15735        // A bare `select Type.property` path traversal (no `{ }` shape) used
15736        // to fall back to `ShapeNode::Scalar` for a *structural* tuple
15737        // property — `emit_path_select`'s IrPathResult::Scalar branch only
15738        // detected a *nominal* named tuple (via the "__nt__:" pg_type
15739        // marker), never consulting the property's own `tuple_members`. This
15740        // decoded every row as an opaque blob instead of a proper tuple
15741        // literal (reported as `select default::Person.address` rendering
15742        // wrong in both pylon-ui and the CLI REPL, while a `{ address }`
15743        // shape query — the test above — already worked).
15744        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
15745        let schema = SchemaDescriptor {
15746            types: vec![TypeDescriptor {
15747                name: "Person".into(),
15748                module: "default".into(),
15749                table: "Person".into(),
15750                abstract_: false,
15751                materialized: false,
15752                description: None,
15753                parents: vec![],
15754                interfaces: vec![],
15755                bases: vec![],
15756                properties: vec![PropertyDescriptor {
15757                    name: "address".into(),
15758                    pg_type: "jsonb".into(),
15759                    nullable: true,
15760                    default_sql: None,
15761                    default_pyql: None,
15762                    description: None,
15763                    check_constraints: vec![],
15764                    is_exclusive: false,
15765                    is_pk: false,
15766                    is_readonly: false,
15767                    rewrites: vec![],
15768                    tuple_members: Some(vec![
15769                        TupleMemberDescriptor {
15770                            name: Some("street".into()),
15771                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15772                        },
15773                        TupleMemberDescriptor {
15774                            name: Some("zip".into()),
15775                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15776                        },
15777                    ]),
15778                    column_type: None,
15779                }],
15780                links: vec![],
15781                multilinks: vec![],
15782                computed: vec![],
15783                constraints: vec![],
15784                indexes: vec![],
15785                partition: None,
15786                vector_indexes: vec![],
15787                search_indexes: vec![],
15788                triggers: vec![],
15789                junction: false,
15790                signals: vec![],
15791            }],
15792            scalars: vec![],
15793            enums: vec![],
15794            named_tuples: vec![],
15795            globals: vec![],
15796            functions: vec![],
15797            aliases: vec![],
15798            channels: vec![],
15799            ..Default::default()
15800        };
15801        let out = compile_and_emit_with("SELECT Person.address", &schema);
15802        match &out.shape.root {
15803            crate::query::ShapeNode::Tuple { type_name, names, .. } => {
15804                assert_eq!(*type_name, None);
15805                let names = names.as_ref().expect("a named tuple's members name themselves");
15806                assert_eq!(names, &vec!["street".to_string(), "zip".to_string()]);
15807            }
15808            other => panic!("expected ShapeNode::Tuple, got {other:?}"),
15809        }
15810    }
15811
15812    #[test]
15813    fn test_path_traversal_into_structural_tuple_property() {
15814        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
15815        let schema = SchemaDescriptor {
15816            types: vec![TypeDescriptor {
15817                name: "Person".into(),
15818                module: "default".into(),
15819                table: "Person".into(),
15820                abstract_: false,
15821                materialized: false,
15822                description: None,
15823                parents: vec![],
15824                interfaces: vec![],
15825                bases: vec![],
15826                properties: vec![PropertyDescriptor {
15827                    name: "address".into(),
15828                    pg_type: "jsonb".into(),
15829                    nullable: true,
15830                    default_sql: None,
15831                    default_pyql: None,
15832                    description: None,
15833                    check_constraints: vec![],
15834                    is_exclusive: false,
15835                    is_pk: false,
15836                    is_readonly: false,
15837                    rewrites: vec![],
15838                    tuple_members: Some(vec![
15839                        TupleMemberDescriptor {
15840                            name: Some("street".into()),
15841                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15842                        },
15843                        TupleMemberDescriptor {
15844                            name: Some("zip".into()),
15845                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15846                        },
15847                    ]),
15848                    column_type: None,
15849                }],
15850                links: vec![],
15851                multilinks: vec![],
15852                computed: vec![],
15853                constraints: vec![],
15854                indexes: vec![],
15855                partition: None,
15856                vector_indexes: vec![],
15857                search_indexes: vec![],
15858                triggers: vec![],
15859                junction: false,
15860                signals: vec![],
15861            }],
15862            scalars: vec![],
15863            enums: vec![],
15864            named_tuples: vec![],
15865            globals: vec![],
15866            functions: vec![],
15867            aliases: vec![],
15868            channels: vec![],
15869            ..Default::default()
15870        };
15871        // Regression: a structural (unnamed) tuple property previously failed
15872        // path traversal with "'address' is a scalar property, not a link —
15873        // cannot traverse further" because compile_path_select only allowed
15874        // further traversal for the nominal `__nt__:` named-tuple marker,
15875        // ignoring `tuple_members` on plain jsonb-typed properties.
15876        let out = compile_and_emit_with("SELECT default::Person.address.street", &schema);
15877        // The column is a composite row, so the member is a field of it —
15878        // a jsonb `->` here would be an operator the type does not have.
15879        assert!(out.sql.contains(r#"("t0"."address")."street""#), "got:\n{}", out.sql);
15880        // And it arrives as the text it is declared as, not as raw jsonb.
15881        assert!(
15882            matches!(out.shape.root, crate::query::ShapeNode::Scalar { .. }),
15883            "got {:?}",
15884            out.shape.root
15885        );
15886    }
15887
15888    /// A `Person` with one `array<tuple<street: str, zip: str>>` property —
15889    /// a `jsonb[]` column carrying the *element's* members (see
15890    /// `PropertyDescriptor::tuple_members`).
15891    /// One `Person` with a structural `tuple<street: str, zip: str>`
15892    /// property — the shape every tuple write/read test here needs.
15893    fn make_schema_with_a_structural_tuple_property() -> SchemaDescriptor {
15894        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
15895        let member = |name: &str| TupleMemberDescriptor {
15896            name: Some(name.to_string()),
15897            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15898        };
15899        SchemaDescriptor {
15900            types: vec![TypeDescriptor {
15901                name: "Person".into(),
15902                module: "default".into(),
15903                table: "Person".into(),
15904                abstract_: false,
15905                materialized: false,
15906                description: None,
15907                parents: vec![],
15908                interfaces: vec![],
15909                bases: vec![],
15910                properties: vec![PropertyDescriptor {
15911                    name: "address".into(),
15912                    pg_type: "jsonb".into(),
15913                    nullable: true,
15914                    default_sql: None,
15915                    default_pyql: None,
15916                    description: None,
15917                    check_constraints: vec![],
15918                    is_exclusive: false,
15919                    is_pk: false,
15920                    is_readonly: false,
15921                    rewrites: vec![],
15922                    tuple_members: Some(vec![member("street"), member("zip")]),
15923                    column_type: None,
15924                }],
15925                links: vec![],
15926                multilinks: vec![],
15927                computed: vec![],
15928                constraints: vec![],
15929                indexes: vec![],
15930                partition: None,
15931                vector_indexes: vec![],
15932                search_indexes: vec![],
15933                triggers: vec![],
15934                junction: false,
15935                signals: vec![],
15936            }],
15937            scalars: vec![],
15938            enums: vec![],
15939            named_tuples: vec![],
15940            globals: vec![],
15941            functions: vec![],
15942            aliases: vec![],
15943            channels: vec![],
15944            ..Default::default()
15945        }
15946    }
15947
15948    #[test]
15949    fn test_a_tuple_literal_assigned_to_a_column_is_a_composite_row() {
15950        // jsonb cannot be assigned to a composite column at all, and a
15951        // `jsonb_build_object` here used to be the only thing emitted — so
15952        // this is both the exactness fix and the thing that makes the write
15953        // run. The cast names the column's own type, which is what turns an
15954        // anonymous `ROW(...)` into a value of it.
15955        let schema = make_schema_with_a_structural_tuple_property();
15956        let out = compile_and_emit_with(
15957            "INSERT Person { address := (street := 'Main', zip := '1000') }",
15958            &schema,
15959        );
15960        let name =
15961            crate::schema::tuple_type::structural_name(schema.types[0].properties[0].tuple_members.as_ref().unwrap());
15962        assert!(
15963            out.sql
15964                .contains(&format!("VALUES ((ROW('Main', '1000'))::\"public\".\"{name}\")")),
15965            "got:\n{}",
15966            out.sql
15967        );
15968        // Written straight, not read back out of json: the members are right
15969        // here, and a round trip through `to_jsonb` of an anonymous row would
15970        // lose their names entirely (`f1`, `f2`, …).
15971        assert!(
15972            !out.sql.contains("populate_tuple") && !out.sql.contains("jsonb"),
15973            "a tuple literal on its way into a column is a row: got:\n{}",
15974            out.sql
15975        );
15976    }
15977
15978    #[test]
15979    fn test_a_tuple_column_compared_to_a_literal_gets_the_columns_own_type() {
15980        // A row literal is anonymous until it is cast, and Postgres refuses
15981        // to compare one against a named composite ("cannot compare
15982        // dissimilar column types").
15983        let schema = make_schema_with_a_structural_tuple_property();
15984        let out = compile_and_emit_with(
15985            "SELECT Person { address } FILTER .address = (street := 'Main', zip := '1000')",
15986            &schema,
15987        );
15988        let name =
15989            crate::schema::tuple_type::structural_name(schema.types[0].properties[0].tuple_members.as_ref().unwrap());
15990        assert!(
15991            out.sql
15992                .contains(&format!("(ROW('Main', '1000'))::\"public\".\"{name}\"")),
15993            "got:\n{}",
15994            out.sql
15995        );
15996    }
15997
15998    #[test]
15999    fn test_a_tuple_column_compared_to_a_parameter_is_read_into_the_columns_type() {
16000        let schema = make_schema_with_a_structural_tuple_property();
16001        let out = compile_and_emit_with(
16002            "SELECT Person { address } FILTER .address = <tuple<street: str, zip: str>>$a",
16003            &schema,
16004        );
16005        assert!(out.sql.contains("\"_pylon\".\"populate_tuple\""), "got:\n{}", out.sql);
16006    }
16007
16008    #[test]
16009    fn test_a_tuple_property_under_another_name_keeps_its_members() {
16010        // `p := .address` is the property itself, so its members stay
16011        // reachable by name — a computed carries no shape of its own to put
16012        // them in, and the value would hydrate as a bare tuple.
16013        let schema = make_schema_with_a_structural_tuple_property();
16014        let out = compile_and_emit_with("SELECT Person { p := .address }", &schema);
16015        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
16016            panic!("expected ShapeNode::Object, got {:?}", out.shape.root)
16017        };
16018        let p = pointers
16019            .iter()
16020            .find(|ptr| matches!(ptr, crate::query::ShapeNode::Tuple { name, .. } if name == "p"))
16021            .unwrap_or_else(|| panic!("expected a tuple pointer named 'p', got {pointers:?}"));
16022        let crate::query::ShapeNode::Tuple { names, .. } = p else {
16023            unreachable!("matched as a tuple just above")
16024        };
16025        assert_eq!(
16026            names.as_ref().expect("a named tuple's members name themselves"),
16027            &vec!["street".to_string(), "zip".to_string()]
16028        );
16029    }
16030
16031    #[test]
16032    fn test_a_tuple_parameter_assigned_to_a_column_is_read_into_it() {
16033        // A parameter arrives as jsonb, and there is no cast from jsonb to a
16034        // composite — `_pylon.populate_tuple` is how one is read, taking the
16035        // value as the object its member names describe.
16036        let schema = make_schema_with_a_structural_tuple_property();
16037        let out = compile_and_emit_with("INSERT Person { address := <tuple<street: str, zip: str>>$a }", &schema);
16038        assert!(
16039            out.sql.contains("\"_pylon\".\"populate_tuple\"") && out.sql.contains("to_jsonb("),
16040            "got:\n{}",
16041            out.sql
16042        );
16043    }
16044
16045    #[test]
16046    fn test_an_array_of_tuples_parameter_is_read_element_wise() {
16047        let schema = make_schema_with_an_array_of_tuples_property();
16048        let out = compile_and_emit_with(
16049            "INSERT Person { addresses := <array<tuple<street: str, zip: str>>>$a }",
16050            &schema,
16051        );
16052        assert!(
16053            out.sql.contains("\"_pylon\".\"populate_tuples\"") && out.sql.contains("to_jsonb("),
16054            "got:\n{}",
16055            out.sql
16056        );
16057    }
16058
16059    fn make_schema_with_an_array_of_tuples_property() -> SchemaDescriptor {
16060        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
16061        SchemaDescriptor {
16062            types: vec![TypeDescriptor {
16063                name: "Person".into(),
16064                module: "default".into(),
16065                table: "Person".into(),
16066                abstract_: false,
16067                materialized: false,
16068                description: None,
16069                parents: vec![],
16070                interfaces: vec![],
16071                bases: vec![],
16072                properties: vec![PropertyDescriptor {
16073                    name: "addresses".into(),
16074                    pg_type: "jsonb[]".into(),
16075                    nullable: true,
16076                    default_sql: None,
16077                    default_pyql: None,
16078                    description: None,
16079                    check_constraints: vec![],
16080                    is_exclusive: false,
16081                    is_pk: false,
16082                    is_readonly: false,
16083                    rewrites: vec![],
16084                    tuple_members: Some(vec![
16085                        TupleMemberDescriptor {
16086                            name: Some("street".into()),
16087                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
16088                        },
16089                        TupleMemberDescriptor {
16090                            name: Some("zip".into()),
16091                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
16092                        },
16093                    ]),
16094                    column_type: None,
16095                }],
16096                links: vec![],
16097                multilinks: vec![],
16098                computed: vec![],
16099                constraints: vec![],
16100                indexes: vec![],
16101                partition: None,
16102                vector_indexes: vec![],
16103                search_indexes: vec![],
16104                triggers: vec![],
16105                junction: false,
16106                signals: vec![],
16107            }],
16108            scalars: vec![],
16109            enums: vec![],
16110            named_tuples: vec![],
16111            globals: vec![],
16112            functions: vec![],
16113            aliases: vec![],
16114            channels: vec![],
16115            ..Default::default()
16116        }
16117    }
16118
16119    #[test]
16120    fn test_array_of_tuples_property_read_shape_is_an_array_of_named_tuples() {
16121        // Regression: an `array<tuple<…>>` property described itself as a
16122        // plain `Scalar`, so each element stayed the raw decoded jsonb dict
16123        // instead of hydrating into a named-tuple value with its members
16124        // reachable by name (reported as `headers[0].value` raising
16125        // "'dict' object has no attribute 'value'").
16126        let schema = make_schema_with_an_array_of_tuples_property();
16127        let out = compile_and_emit_with("SELECT Person { addresses }", &schema);
16128        // The column is an array of the element's composite type now, so
16129        // there is nothing to cast.
16130        assert!(!out.sql.contains("::jsonb"), "got:\n{}", out.sql);
16131        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
16132            panic!("expected ShapeNode::Object, got {:?}", out.shape.root)
16133        };
16134        let addresses = pointers
16135            .iter()
16136            .find(|p| matches!(p, crate::query::ShapeNode::Array { name, .. } if name == "addresses"))
16137            .expect("expected addresses pointer in shape");
16138        let crate::query::ShapeNode::Array { element, .. } = addresses else {
16139            unreachable!("matched as an array just above")
16140        };
16141        match element.as_ref() {
16142            crate::query::ShapeNode::Tuple {
16143                type_name,
16144                names,
16145                position,
16146                ..
16147            } => {
16148                assert_eq!(*type_name, None);
16149                assert_eq!(*position, 0, "an element is the whole of its own value");
16150                let names = names.as_ref().expect("a named tuple's members name themselves");
16151                assert_eq!(names, &vec!["street".to_string(), "zip".to_string()]);
16152            }
16153            other => panic!("expected Tuple element, got {other:?}"),
16154        }
16155    }
16156
16157    #[test]
16158    fn test_a_path_to_an_optional_property_drops_the_rows_without_one() {
16159        // `select Webhook.headers` over two webhooks, one of them without
16160        // headers, is one result — the row that has none reaches no value,
16161        // rather than contributing a NULL of its own.
16162        let out = compile_and_emit("SELECT Person.age");
16163        assert!(out.sql.contains(r#"("t0"."age" IS NOT NULL)"#), "got:\n{}", out.sql);
16164    }
16165
16166    #[test]
16167    fn test_a_path_to_a_required_property_needs_no_guard() {
16168        // A required column is never NULL, so the condition could only ever
16169        // be true — every path select would carry it for nothing.
16170        let out = compile_and_emit("SELECT Person.name");
16171        assert!(!out.sql.contains("IS NOT NULL"), "got:\n{}", out.sql);
16172    }
16173
16174    #[test]
16175    fn test_a_path_to_an_optional_property_keeps_its_own_filter_too() {
16176        let out = compile_and_emit("SELECT Person.age FILTER Person.name = $n");
16177        assert!(
16178            out.sql
16179                .contains(r#"WHERE (("t0"."name" = $1) AND ("t0"."age" IS NOT NULL))"#),
16180            "got:\n{}",
16181            out.sql
16182        );
16183    }
16184
16185    #[test]
16186    fn test_a_set_of_array_valued_properties_aggregates_as_records() {
16187        // PostgreSQL has no array of arrays, so accumulating one row's
16188        // `text[]` beside another's failed outright ("cannot accumulate
16189        // arrays of different dimensionality"). Each element travels as a
16190        // record of one field instead, the way a set of objects does.
16191        let out = compile_and_emit_with(
16192            "SELECT Company { permissions := .<company[is Person].perms }",
16193            &schema_with_array_property(),
16194        );
16195        assert!(out.sql.contains(r#"ROW("t2"."perms")"#), "got:\n{}", out.sql);
16196        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
16197            panic!("expected ShapeNode::Object, got {:?}", out.shape.root)
16198        };
16199        let permissions = pointers
16200            .iter()
16201            .find(|p| matches!(p, crate::query::ShapeNode::Array { name, .. } if name == "permissions"))
16202            .expect("expected the permissions pointer");
16203        let crate::query::ShapeNode::Array { element, .. } = permissions else {
16204            unreachable!("matched as an array just above")
16205        };
16206        // One element is the array itself, at position 0 of its record.
16207        match element.as_ref() {
16208            crate::query::ShapeNode::Scalar { position, .. } => assert_eq!(*position, 0),
16209            other => panic!("expected the array at position 0 of its record, got {other:?}"),
16210        }
16211    }
16212
16213    #[test]
16214    fn test_a_set_of_array_of_tuples_properties_keeps_the_member_shape() {
16215        let mut schema = make_schema_with_an_array_of_tuples_property();
16216        // The same `Person`, reached from a `Company` it links to.
16217        schema.types.push(TypeDescriptor {
16218            name: "Company".into(),
16219            module: "default".into(),
16220            table: "Company".into(),
16221            abstract_: false,
16222            materialized: false,
16223            description: None,
16224            parents: vec![],
16225            interfaces: vec![],
16226            bases: vec![],
16227            properties: vec![],
16228            links: vec![],
16229            multilinks: vec![],
16230            computed: vec![],
16231            constraints: vec![],
16232            indexes: vec![],
16233            partition: None,
16234            vector_indexes: vec![],
16235            search_indexes: vec![],
16236            triggers: vec![],
16237            junction: false,
16238            signals: vec![],
16239        });
16240        schema.types[0].links.push(crate::schema::LinkDescriptor {
16241            name: "company".into(),
16242            target: "default::Company".into(),
16243            nullable: true,
16244            description: None,
16245            default_pyql: None,
16246            is_exclusive: false,
16247            is_readonly: false,
16248            rewrites: vec![],
16249            on_delete: vec![],
16250            through: None,
16251        });
16252        let out = compile_and_emit_with(
16253            "SELECT Company { addresses := .<company[is Person].addresses }",
16254            &schema,
16255        );
16256        assert!(out.sql.contains(r#"ROW("t2"."addresses")"#), "got:\n{}", out.sql);
16257        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
16258            panic!("expected ShapeNode::Object, got {:?}", out.shape.root)
16259        };
16260        let addresses = pointers
16261            .iter()
16262            .find(|p| matches!(p, crate::query::ShapeNode::Array { name, .. } if name == "addresses"))
16263            .expect("expected the addresses pointer");
16264        let crate::query::ShapeNode::Array { element, .. } = addresses else {
16265            unreachable!("matched as an array just above")
16266        };
16267        // Each element is itself an array, of the named tuples the property
16268        // declares — not the opaque jsonb a flattening would hand back.
16269        match element.as_ref() {
16270            crate::query::ShapeNode::Array { element, position, .. } => {
16271                assert_eq!(*position, 0);
16272                assert!(
16273                    matches!(element.as_ref(), crate::query::ShapeNode::Tuple { names: Some(_), .. }),
16274                    "expected named-tuple elements, got {element:?}"
16275                );
16276            }
16277            other => panic!("expected an array per element, got {other:?}"),
16278        }
16279    }
16280
16281    #[test]
16282    fn test_an_optional_property_aggregated_into_an_array_drops_its_empties() {
16283        // The same rule inside an array: a NULL element is not an empty set,
16284        // and for an array-typed property Postgres refuses it outright
16285        // ("cannot accumulate null arrays").
16286        let out = compile_and_emit("SELECT Company { ages := .<company[is Person].age }");
16287        assert!(out.sql.contains(r#"("t2"."age" IS NOT NULL)"#), "got:\n{}", out.sql);
16288    }
16289
16290    #[test]
16291    fn test_bare_path_select_array_of_tuples_property_shape() {
16292        // Unlike a single tuple property, this one keeps the ROW() wrapper:
16293        // the array is read at a position inside the row, and only then is
16294        // each element a composite of its own.
16295        let schema = make_schema_with_an_array_of_tuples_property();
16296        let out = compile_and_emit_with("SELECT Person.addresses", &schema);
16297        assert!(out.sql.contains("ROW("), "got:\n{}", out.sql);
16298        match &out.shape.root {
16299            crate::query::ShapeNode::Array { element, position, .. } => {
16300                assert_eq!(*position, 0);
16301                match element.as_ref() {
16302                    crate::query::ShapeNode::Tuple { names, .. } => {
16303                        let names = names.as_ref().expect("a named tuple's members name themselves");
16304                        assert_eq!(names[0], "street");
16305                    }
16306                    other => panic!("expected Tuple element, got {other:?}"),
16307                }
16308            }
16309            other => panic!("expected ShapeNode::Array, got {other:?}"),
16310        }
16311    }
16312
16313    #[test]
16314    fn test_a_with_bound_path_select_keeps_the_shape_the_path_itself_has() {
16315        // `with entries := Person.addresses select entries` reads the CTE's
16316        // own result column, so it has to decode as the path select does —
16317        // it used to fall back to a plain scalar, handing back raw jsonb.
16318        let schema = make_schema_with_an_array_of_tuples_property();
16319        let out = compile_and_emit_with("WITH entries := Person.addresses SELECT entries", &schema);
16320        match &out.shape.root {
16321            crate::query::ShapeNode::Array { element, .. } => assert!(
16322                matches!(element.as_ref(), crate::query::ShapeNode::Tuple { names: Some(_), .. }),
16323                "expected a named-tuple element, got {element:?}"
16324            ),
16325            other => panic!("expected ShapeNode::Array, got {other:?}"),
16326        }
16327    }
16328
16329    #[test]
16330    fn test_path_traversal_into_array_of_tuples_property_rejected() {
16331        // A single tuple property allows `.address.street`; an array of them
16332        // has no one tuple to read a field out of.
16333        let schema = make_schema_with_an_array_of_tuples_property();
16334        let ast = parse::parse("SELECT default::Person.addresses.street").unwrap();
16335        match ir::compile(&ast, &schema) {
16336            Ok(_) => panic!("expected error for a field access on an array of tuples"),
16337            Err(e) => assert!(
16338                e.to_string()
16339                    .contains("'addresses' is a scalar property, not a link — cannot traverse further"),
16340                "got: {}",
16341                e
16342            ),
16343        }
16344    }
16345
16346    #[test]
16347    fn test_a_literal_cast_to_a_declared_tuple_type_is_a_value_of_it() {
16348        // `<default::Money>(…)` is a value of the type's own composite, which
16349        // coerces each member to the attribute it lands in — and makes the
16350        // value comparable to a column of that type, which a jsonb one is not.
16351        let mut schema = make_schema();
16352        schema.named_tuples.push(crate::schema::NamedTupleDescriptor {
16353            name: "Money".into(),
16354            module: "default".into(),
16355            members: vec![
16356                crate::schema::TupleMemberDescriptor {
16357                    name: Some("amount".into()),
16358                    kind: crate::schema::TupleMemberKind::Scalar {
16359                        pg_type: "numeric".into(),
16360                    },
16361                },
16362                crate::schema::TupleMemberDescriptor {
16363                    name: Some("currency".into()),
16364                    kind: crate::schema::TupleMemberKind::Scalar { pg_type: "text".into() },
16365                },
16366            ],
16367        });
16368        let out = compile_and_emit_with("SELECT <default::Money>(amount := 1.5, currency := 'EUR')", &schema);
16369        assert!(
16370            out.sql.contains("ROW((1.5::float8), 'EUR'))::\"public\".\"Money_t\""),
16371            "the composite cast coerces each member to its attribute: got:\n{}",
16372            out.sql
16373        );
16374        assert!(!out.sql.contains("jsonb"), "got:\n{}", out.sql);
16375        // Named for the type that declared it, so it hydrates to that class.
16376        match &out.shape.root {
16377            crate::query::ShapeNode::Tuple { type_name, names, .. } => {
16378                assert_eq!(type_name.as_deref(), Some("default::Money"));
16379                assert_eq!(
16380                    names.as_ref().unwrap(),
16381                    &vec!["amount".to_string(), "currency".to_string()]
16382                );
16383            }
16384            other => panic!("expected ShapeNode::Tuple, got {other:?}"),
16385        }
16386    }
16387
16388    #[test]
16389    fn test_an_array_literal_of_tuples_is_described_by_its_elements() {
16390        // Read as a plain array, its elements come back as bare tuples with
16391        // their members' names gone — and the array has to be read at a
16392        // position in the row for the element shape to reach them.
16393        let out = compile_and_emit("SELECT [(a := 1), (a := 2)]");
16394        let crate::query::ShapeNode::Array { element, position, .. } = &out.shape.root else {
16395            panic!("expected ShapeNode::Array, got {:?}", out.shape.root)
16396        };
16397        assert_eq!(*position, 0);
16398        match element.as_ref() {
16399            crate::query::ShapeNode::Tuple { names, position, .. } => {
16400                assert_eq!(*position, 0, "an element is the whole of its own value");
16401                assert_eq!(names.as_ref().unwrap(), &vec!["a".to_string()]);
16402            }
16403            other => panic!("expected a tuple element, got {other:?}"),
16404        }
16405        // Every other array literal keeps the bare top-level column it had.
16406        let scalars = compile_and_emit("SELECT [1, 2]");
16407        assert!(
16408            matches!(scalars.shape.root, crate::query::ShapeNode::RawScalar),
16409            "got {:?}",
16410            scalars.shape.root
16411        );
16412    }
16413
16414    #[test]
16415    fn test_a_tuple_on_its_way_into_json_is_built_as_json() {
16416        // A tuple is a composite row everywhere else, but `to_jsonb` of an
16417        // anonymous row names its members `f1`, `f2`, … — and a named
16418        // tuple's json form is its keys. So this one case keeps the jsonb
16419        // builders.
16420        let named = compile_and_emit("SELECT <json>(a := 1, b := 'x')");
16421        assert!(
16422            named.sql.contains("jsonb_build_object('a', 1, 'b', 'x')"),
16423            "got:\n{}",
16424            named.sql
16425        );
16426        // A positional tuple's json form is an array, not an object with
16427        // index keys.
16428        let positional = compile_and_emit("SELECT <json>(1, 'x')");
16429        assert!(
16430            positional.sql.contains("jsonb_build_array(1, 'x')"),
16431            "got:\n{}",
16432            positional.sql
16433        );
16434        // And a nested member becomes json too, rather than staying a row
16435        // inside a jsonb value.
16436        let nested = compile_and_emit("SELECT <json>(deep := (a := 1), b := 'x')");
16437        assert!(
16438            nested.sql.contains("jsonb_build_object('a', 1)"),
16439            "got:\n{}",
16440            nested.sql
16441        );
16442        assert!(!nested.sql.contains("ROW("), "got:\n{}", nested.sql);
16443    }
16444
16445    #[test]
16446    fn test_a_tuple_literal_carries_the_members_it_names() {
16447        // `(amount := 9.99, note := 'x')` says what its members are called,
16448        // and the shape carries them: the web UI renders `(key := value, …)`
16449        // from this list, and a reader hydrates a named-tuple value from it.
16450        let out = compile_and_emit("SELECT (amount := 9.99, note := 'x')");
16451        match &out.shape.root {
16452            crate::query::ShapeNode::Tuple { names, elements, .. } => {
16453                let names = names.as_ref().expect("a literal names its own members");
16454                assert_eq!(names, &vec!["amount".to_string(), "note".to_string()]);
16455                assert_eq!(elements.len(), 2);
16456            }
16457            other => panic!("expected ShapeNode::Tuple, got {other:?}"),
16458        }
16459    }
16460
16461    #[test]
16462    fn test_a_tuple_literal_member_that_is_itself_a_tuple_recurses() {
16463        let out = compile_and_emit("SELECT (origin := (x := 1, y := 2), label := 'a')");
16464        let crate::query::ShapeNode::Tuple { elements, .. } = &out.shape.root else {
16465            panic!("expected ShapeNode::Tuple, got {:?}", out.shape.root)
16466        };
16467        match &elements[0] {
16468            crate::query::ShapeNode::Tuple { name, names, .. } => {
16469                assert_eq!(name, "origin");
16470                let names = names.as_ref().expect("a nested literal names its members too");
16471                assert_eq!(names, &vec!["x".to_string(), "y".to_string()]);
16472            }
16473            other => panic!("expected a nested tuple member, got {other:?}"),
16474        }
16475        // Nested rows all the way down, rather than a jsonb value built
16476        // inside one.
16477        assert!(
16478            out.sql.contains("ROW(1, 2)") || out.sql.contains("(1, 2)"),
16479            "got:\n{}",
16480            out.sql
16481        );
16482        assert!(!out.sql.contains("jsonb_build"), "got:\n{}", out.sql);
16483    }
16484
16485    #[test]
16486    fn test_a_tuple_literal_member_holding_an_enum_keeps_its_type() {
16487        // `make_schema` declares no enums of its own.
16488        let mut schema = make_schema();
16489        schema.enums.push(crate::schema::EnumDescriptor {
16490            name: "Gender".into(),
16491            module: "default".into(),
16492            members: vec!["Male".into(), "Female".into()],
16493        });
16494        let out = compile_and_emit_with("SELECT (shade := default::Gender.Male, label := 'a')", &schema);
16495        let crate::query::ShapeNode::Tuple { elements, .. } = &out.shape.root else {
16496            panic!("expected ShapeNode::Tuple, got {:?}", out.shape.root)
16497        };
16498        match &elements[0] {
16499            // Postgres-schema-qualified, the way `ShapeNode::Enum`'s own
16500            // type always is.
16501            crate::query::ShapeNode::Enum { enum_type, .. } => assert_eq!(enum_type, "public::Gender"),
16502            other => panic!("expected an enum member, got {other:?}"),
16503        }
16504    }
16505
16506    #[test]
16507    fn test_a_free_object_literal_carries_no_member_plan() {
16508        // `{ foo := 'bar' }` is the same composite row a tuple literal is —
16509        // which is what gives each field its own type — but it is not a
16510        // tuple to its reader, so it is described as the object it reads as.
16511        let out = compile_and_emit("SELECT Person { meta := { foo := 'bar' } }");
16512        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
16513            panic!("expected ShapeNode::Object, got {:?}", out.shape.root)
16514        };
16515        let meta = pointers
16516            .iter()
16517            .find(|p| matches!(p, crate::query::ShapeNode::Object { name, .. } if name == "meta"))
16518            .expect("expected the meta pointer");
16519        match meta {
16520            crate::query::ShapeNode::Object {
16521                type_name, pointers, ..
16522            } => {
16523                assert_eq!(*type_name, None, "a free object names no declared type");
16524                assert!(
16525                    matches!(&pointers[..], [crate::query::ShapeNode::Scalar { name, .. }] if name == "foo"),
16526                    "got {pointers:?}"
16527                );
16528            }
16529            other => panic!("expected ShapeNode::Object, got {other:?}"),
16530        }
16531    }
16532
16533    #[test]
16534    fn test_structural_tuple_cast_shape_carries_real_members() {
16535        let out = compile_and_emit("SELECT <tuple<street: str, zip: str>>$p");
16536        match &out.shape.root {
16537            crate::query::ShapeNode::NamedTuple { type_name, members, .. } => {
16538                assert_eq!(*type_name, None);
16539                let members = members.as_ref().expect("expected resolved members");
16540                assert_eq!(members.len(), 2);
16541                assert_eq!(members[0].key.as_deref(), Some("street"));
16542                assert_eq!(members[1].key.as_deref(), Some("zip"));
16543            }
16544            other => panic!("expected ShapeNode::NamedTuple, got {other:?}"),
16545        }
16546    }
16547
16548    #[test]
16549    fn test_array_of_tuples_cast_shape_is_an_array_of_named_tuples() {
16550        // The cast target's members describe one *element* of the array, so
16551        // the value decodes as named tuples rather than as the raw jsonb each
16552        // slot of the `jsonb[]` holds.
16553        let out = compile_and_emit("SELECT <array<tuple<street: str, zip: str>>>$p");
16554        assert!(out.sql.contains("::jsonb[]"), "got:\n{}", out.sql);
16555        match &out.shape.root {
16556            crate::query::ShapeNode::Array { element, position, .. } => {
16557                assert_eq!(*position, 0);
16558                match element.as_ref() {
16559                    crate::query::ShapeNode::NamedTuple { members, position, .. } => {
16560                        assert_eq!(*position, 0);
16561                        let members = members.as_ref().expect("expected resolved members");
16562                        assert_eq!(members.len(), 2);
16563                        assert_eq!(members[0].key.as_deref(), Some("street"));
16564                        assert_eq!(members[1].key.as_deref(), Some("zip"));
16565                    }
16566                    other => panic!("expected NamedTuple element, got {other:?}"),
16567                }
16568            }
16569            other => panic!("expected ShapeNode::Array, got {other:?}"),
16570        }
16571    }
16572
16573    #[test]
16574    fn test_tuple_cast_mixed_named_and_unnamed_elements_rejected() {
16575        match parse::parse("SELECT <tuple<x: float64, bool>>$p") {
16576            Ok(_) => panic!("expected parse error for mixed named/unnamed tuple elements"),
16577            Err(e) => assert!(e.to_string().contains("all named or all unnamed"), "got: {}", e),
16578        }
16579    }
16580
16581    #[test]
16582    fn test_is_with_tuple_type_rejected() {
16583        let schema = make_schema();
16584        let ast = parse::parse("SELECT Person FILTER Person is tuple<x: float64, y: float64>").unwrap();
16585        match ir::compile(&ast, &schema) {
16586            Ok(_) => panic!("expected error for IS with a tuple type"),
16587            Err(e) => {
16588                assert!(
16589                    e.to_string().contains("cannot use IS with a tuple or array type"),
16590                    "got: {}",
16591                    e
16592                );
16593            }
16594        }
16595    }
16596
16597    #[test]
16598    fn test_is_with_array_type_rejected() {
16599        let schema = make_schema();
16600        let ast = parse::parse("SELECT Person FILTER Person is array<str>").unwrap();
16601        match ir::compile(&ast, &schema) {
16602            Ok(_) => panic!("expected error for IS with an array type"),
16603            Err(e) => {
16604                assert!(
16605                    e.to_string().contains("cannot use IS with a tuple or array type"),
16606                    "got: {}",
16607                    e
16608                );
16609            }
16610        }
16611    }
16612
16613    #[test]
16614    fn test_tuple_index_on_non_literal_falls_back_to_jsonb_index() {
16615        // `.1` only constant-folds against a literal tuple; anything else
16616        // (a $param, a cast result, …) needs a genuine runtime jsonb index
16617        // instead of erroring "only supported on tuple literals".
16618        let out = compile_and_emit("SELECT (<tuple<int64, str>>('1', 3)).1");
16619        assert!(out.sql.contains("->1"), "got:\n{}", out.sql);
16620    }
16621
16622    #[test]
16623    fn test_tuple_index_out_of_bounds_on_cast_target_errors_at_compile_time() {
16624        // Regression: `.2` on a 2-element tuple cast silently fell back to a
16625        // runtime jsonb index (returning null) instead of failing, because
16626        // the source is a TypeCast, not a literal Tuple/NamedTuple. The cast's
16627        // target type is statically known here, so this must bounds-check
16628        // and error instead.
16629        let ast = parse::parse("SELECT (<tuple<int64, str>>('1', 3)).2").unwrap();
16630        let schema = make_schema();
16631        match ir::compile(&ast, &schema) {
16632            Ok(_) => panic!("expected out-of-bounds tuple index error"),
16633            Err(e) => {
16634                assert!(
16635                    e.to_string()
16636                        .contains("2 is not a member of tuple<std::int64, std::str>"),
16637                    "got: {}",
16638                    e
16639                );
16640            }
16641        }
16642    }
16643
16644    #[test]
16645    fn test_positional_tuple_literal_cast_to_tuple_type_compiles() {
16646        // Each element must be cast to its own declared type, and the row
16647        // they make carries those types — so the cast itself has nothing
16648        // left to apply.
16649        let out = compile_and_emit("SELECT <tuple<int64, str>>(1, 'x')");
16650        assert!(out.sql.contains("ROW((1)::int8, ('x')::text)"), "got:\n{}", out.sql);
16651        assert!(!out.sql.contains("jsonb"), "got:\n{}", out.sql);
16652    }
16653
16654    #[test]
16655    fn test_positional_tuple_literal_cast_coerces_mismatched_literal_types() {
16656        // The exact case that was silently wrong: casting a string literal to
16657        // int64 and an int literal to str must actually coerce each one, not
16658        // just pass the raw literal through untouched.
16659        let out = compile_and_emit("SELECT <tuple<int64, str>>('1', 3)");
16660        assert!(out.sql.contains("ROW(('1')::int8, (3)::text)"), "got:\n{}", out.sql);
16661    }
16662
16663    #[test]
16664    fn test_nested_tuple_literal_cast_applies_casts_recursively() {
16665        let out = compile_and_emit(
16666            "SELECT <tuple<point: tuple<x: float64, y: float64>, label: str>>(point := ('1', 2), label := 5)",
16667        );
16668        assert!(
16669            out.sql.contains("ROW(ROW(('1')::float8, (2)::float8), (5)::text)"),
16670            "got:\n{}",
16671            out.sql
16672        );
16673        // The names still reach the reader, through the shape rather than
16674        // through jsonb keys.
16675        let crate::query::ShapeNode::Tuple { names, elements, .. } = &out.shape.root else {
16676            panic!("expected ShapeNode::Tuple, got {:?}", out.shape.root)
16677        };
16678        assert_eq!(names.as_ref().unwrap(), &vec!["point".to_string(), "label".to_string()]);
16679        assert!(
16680            matches!(&elements[0], crate::query::ShapeNode::Tuple { names: Some(n), .. } if n == &vec!["x".to_string(), "y".to_string()]),
16681            "got {:?}",
16682            elements[0]
16683        );
16684    }
16685
16686    #[test]
16687    fn test_positional_tuple_literal_nested_inside_named_tuple_compiles() {
16688        let out = compile_and_emit("SELECT (point := (1, 2), label := 'origin')");
16689        // Rows inside rows: each member keeps its own type, and the nested
16690        // one is a value of the outer row rather than jsonb built inside it.
16691        assert!(out.sql.contains("ROW(1, 2)"), "got:\n{}", out.sql);
16692        assert!(!out.sql.contains("jsonb_build"), "got:\n{}", out.sql);
16693        let crate::query::ShapeNode::Tuple { elements, names, .. } = &out.shape.root else {
16694            panic!("expected ShapeNode::Tuple, got {:?}", out.shape.root)
16695        };
16696        assert_eq!(names.as_ref().unwrap(), &vec!["point".to_string(), "label".to_string()]);
16697        // The nested one is positional, so it hydrates to a plain tuple.
16698        assert!(
16699            matches!(&elements[0], crate::query::ShapeNode::Tuple { names: None, .. }),
16700            "got {:?}",
16701            elements[0]
16702        );
16703    }
16704
16705    #[test]
16706    fn test_positional_tuple_literal_in_schema_bound_shape_field_compiles() {
16707        let out = compile_and_emit("SELECT Person { name, pair := (1, 2) }");
16708        assert!(out.sql.contains("ROW(1, 2)"), "got:\n{}", out.sql);
16709        assert!(!out.sql.contains("jsonb_build"), "got:\n{}", out.sql);
16710    }
16711
16712    // ── search index outbox enqueue tests ─────────────────────────────────────
16713    //
16714    // Regression coverage for a real bug: `collect_search_enqueue` used to
16715    // filter for `SearchBackend::OpenSearch` only, so a Meilisearch-backed
16716    // SearchIndex never got an outbox row on insert/update/delete — the
16717    // Meilisearch worker (Rust or the old Python one) never received real
16718    // traffic from normal DML, only from a manually-inserted outbox row.
16719
16720    fn make_schema_with_search_index(backend: crate::schema::SearchBackend) -> SchemaDescriptor {
16721        use crate::schema::{SearchIndexDescriptor, SearchPointerDescriptor, SearchWeight};
16722        let mut s = make_schema();
16723        if let Some(td) = s.types.iter_mut().find(|t| t.name == "Person") {
16724            td.search_indexes.push(SearchIndexDescriptor {
16725                index_name: None,
16726                backend,
16727                pointers: vec![SearchPointerDescriptor {
16728                    name: "name".into(),
16729                    weight: SearchWeight::A,
16730                }],
16731            });
16732        }
16733        s
16734    }
16735
16736    #[test]
16737    fn test_insert_enqueues_a_meilisearch_outbox_row() {
16738        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Meilisearch);
16739        let out = compile_and_emit_with("INSERT Person { name := 'Alice', age := 30 }", &schema);
16740        assert!(
16741            out.sql.contains("'Meilisearch'::_pylon.\"IndexKind\""),
16742            "expected a Meilisearch outbox enqueue CTE, got:\n{}",
16743            out.sql,
16744        );
16745        assert!(
16746            out.sql.contains("INSERT INTO _pylon.\"IndexOutbox\""),
16747            "got:\n{}",
16748            out.sql
16749        );
16750    }
16751
16752    #[test]
16753    fn test_insert_enqueues_an_opensearch_outbox_row() {
16754        let schema = make_schema_with_search_index(crate::schema::SearchBackend::OpenSearch);
16755        let out = compile_and_emit_with("INSERT Person { name := 'Alice', age := 30 }", &schema);
16756        assert!(
16757            out.sql.contains("'OpenSearch'::_pylon.\"IndexKind\""),
16758            "expected an OpenSearch outbox enqueue CTE, got:\n{}",
16759            out.sql,
16760        );
16761    }
16762
16763    #[test]
16764    fn test_insert_does_not_enqueue_an_outbox_row_for_a_postgres_backed_search_index() {
16765        // Postgres-backed search indexes are maintained synchronously by a
16766        // trigger-updated tsvector column — no async worker involved.
16767        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Postgres);
16768        let out = compile_and_emit_with("INSERT Person { name := 'Alice', age := 30 }", &schema);
16769        assert!(
16770            !out.sql.contains("_pylon.\"IndexOutbox\""),
16771            "did not expect an outbox enqueue, got:\n{}",
16772            out.sql
16773        );
16774    }
16775
16776    #[test]
16777    fn test_update_enqueues_a_meilisearch_outbox_row() {
16778        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Meilisearch);
16779        let out = compile_and_emit_with("UPDATE Person FILTER .name = 'Alice' SET { age := 31 }", &schema);
16780        assert!(
16781            out.sql.contains("'Meilisearch'::_pylon.\"IndexKind\""),
16782            "expected a Meilisearch outbox enqueue CTE, got:\n{}",
16783            out.sql,
16784        );
16785    }
16786
16787    #[test]
16788    fn test_delete_enqueues_a_meilisearch_outbox_delete_job() {
16789        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Meilisearch);
16790        let out = compile_and_emit_with("DELETE Person FILTER .name = 'Alice'", &schema);
16791        assert!(
16792            out.sql.contains("'Meilisearch'::_pylon.\"IndexKind\""),
16793            "expected a Meilisearch outbox enqueue CTE, got:\n{}",
16794            out.sql,
16795        );
16796        assert!(
16797            out.sql.contains("'delete'"),
16798            "expected the delete operation literal, got:\n{}",
16799            out.sql
16800        );
16801    }
16802
16803    #[test]
16804    fn test_range_intrinsic_resolves_int_literals_to_int8range() {
16805        // Regression: std::range's ImplStrategy::TranspilerIntrinsic had no
16806        // actual substitution anywhere — it fell through resolve_fn_call's
16807        // catch-all, which emitted a literal (nonexistent) `"std"."range"(...)`
16808        // call instead of a real PostgreSQL range constructor.
16809        let out = compile_and_emit("SELECT std::overlaps(std::range(1, 3), std::range(2, 5))");
16810        assert!(out.sql.contains("int8range(1, 3)"), "got:\n{}", out.sql);
16811        assert!(out.sql.contains("int8range(2, 5)"), "got:\n{}", out.sql);
16812        assert!(
16813            out.sql.contains(" && "),
16814            "expected infix && for overlaps, got:\n{}",
16815            out.sql
16816        );
16817        assert!(
16818            !out.sql.contains("\"std\""),
16819            "must not emit a literal std schema call, got:\n{}",
16820            out.sql
16821        );
16822    }
16823
16824    #[test]
16825    fn test_range_intrinsic_resolves_datetime_to_tstzrange() {
16826        let out =
16827            compile_and_emit("SELECT std::range(<datetime>'2024-01-01T00:00:00Z', <datetime>'2024-06-01T00:00:00Z')");
16828        assert!(out.sql.contains("tstzrange("), "got:\n{}", out.sql);
16829    }
16830
16831    /// Bounds written out as literals are folded into the constructor's own
16832    /// bounds string; only the default `[)` leaves it out entirely.
16833    #[test]
16834    fn test_range_intrinsic_named_bounds_become_a_bounds_string() {
16835        let out = compile_and_emit("SELECT std::range(1, 3, inc_lower := true, inc_upper := true)");
16836        assert!(out.sql.contains("int8range(1, 3, '[]')"), "got:\n{}", out.sql);
16837        let out = compile_and_emit("SELECT std::range(1, 3, inc_lower := false)");
16838        assert!(out.sql.contains("int8range(1, 3, '()')"), "got:\n{}", out.sql);
16839    }
16840
16841    /// A bound flag that is not a literal keeps the decision in the SQL.
16842    #[test]
16843    fn test_range_intrinsic_computes_a_bounds_string_from_an_expression() {
16844        let out = compile_and_emit("SELECT std::range(1, 3, inc_upper := <bool>$closed)");
16845        assert!(out.sql.contains("CASE WHEN"), "got:\n{}", out.sql);
16846    }
16847
16848    /// An open-ended range names only the bound it has.
16849    #[test]
16850    fn test_range_intrinsic_leaves_a_missing_upper_bound_unbounded() {
16851        let out = compile_and_emit("SELECT std::range(1)");
16852        assert!(out.sql.contains("int8range(1, NULL)"), "got:\n{}", out.sql);
16853    }
16854
16855    /// A call that writes a named-only argument by position, one that leaves a
16856    /// required one out, and one no overload accepts each say which.
16857    #[test]
16858    fn test_a_named_only_parameter_is_reported_as_named_only() {
16859        assert!(
16860            compile_err("SELECT std::range(1, 5, true, false)").contains("takes 'inc_lower' as a named argument only"),
16861            "{}",
16862            compile_err("SELECT std::range(1, 5, true, false)")
16863        );
16864        assert!(
16865            compile_err("SELECT std::json_set(std::to_json('{}'), 'a', std::to_json('1'))")
16866                .contains("requires the named argument 'value'"),
16867            "{}",
16868            compile_err("SELECT std::json_set(std::to_json('{}'), 'a', std::to_json('1'))")
16869        );
16870        // Counted by positions, not by the whole parameter list.
16871        assert!(
16872            compile_err("SELECT std::range()").contains("takes 1 or 2 argument(s), got 0"),
16873            "{}",
16874            compile_err("SELECT std::range()")
16875        );
16876        // And an overload's named-only parameters are spelled as such.
16877        let message = compile_err("SELECT std::json_get(std::to_json('{}'), 'a', 1, 2)");
16878        assert!(message.contains("default := optional<json>"), "{message}");
16879    }
16880
16881    #[test]
16882    fn test_range_intrinsic_builds_the_empty_range() {
16883        let out = compile_and_emit("SELECT std::range(1, 3, empty := true)");
16884        assert!(out.sql.contains("'empty'::int8range"), "got:\n{}", out.sql);
16885    }
16886
16887    #[test]
16888    fn test_multirange_intrinsic_resolves_from_range_element() {
16889        let out = compile_and_emit("SELECT std::multirange([std::range(1, 3), std::range(5, 7)])");
16890        assert!(out.sql.contains("int8multirange(VARIADIC "), "got:\n{}", out.sql);
16891        assert!(
16892            !out.sql.contains("\"std\""),
16893            "must not emit a literal std schema call, got:\n{}",
16894            out.sql
16895        );
16896    }
16897}