Skip to main content

umbral_core/orm/queryset/
mod.rs

1//! `QuerySet<T>` and `Manager<T>`: chainable lazy SQL builder + entry
2//! point.
3//!
4//! `T::objects()` returns a `Manager<T>`; chaining `.filter` / `.order_by`
5//! / `.limit` / etc. on it (or on a `QuerySet<T>` directly) yields a new
6//! `QuerySet<T>`. Terminals (`.fetch`, `.first`, `.count`, `.exists`)
7//! await an async DB roundtrip via the ambient or explicit pool.
8//!
9//! At M1 the surface is intentionally narrow per
10//! `docs/specs/03-orm-querysets.md`: filter / order_by / limit / offset
11//! for chaining, and fetch / first / count / exists for terminals. No
12//! exclude / distinct / values / annotate / aggregate / update / delete
13//! yet — those land as later milestones surface real need.
14//!
15//! M2 lifted the terminals and the `Manager` delegation onto a generic
16//! `T: Model` bound. The table name comes from `T::TABLE`, the SELECT
17//! column list from `T::FIELDS`, and row materialisation from the
18//! `FromRow` bound the terminals carry. M3 generates the `Model` impl
19//! from `#[derive(Model)]`.
20//!
21//! ## Phase 2.5 — backend-agnostic terminals
22//!
23//! Through Phase 2 the QuerySet stored a `SqlitePool` and built every
24//! query with sea-query's `SqliteQueryBuilder`. Phase 2.5 widens that:
25//! the explicit-pool slot is `Option<DbPool>`, `.on(&SqlitePool)` keeps
26//! working unchanged, and a new `.on_pg(&PgPool)` registers a Postgres
27//! pool. The terminal methods dispatch on the resolved pool variant —
28//! SQLite path uses `SqliteQueryBuilder` + a `SqlitePool` executor;
29//! Postgres path uses `PostgresQueryBuilder` + a `PgPool` executor.
30//!
31//! The row-materialization bound on each terminal is the conjunction
32//! of both backends' `FromRow` impls. `#[derive(sqlx::FromRow)]` emits
33//! a generic-over-`R` impl, so a user struct with standard field
34//! types satisfies both bounds without any per-backend ceremony.
35
36mod backend_pg;
37mod backend_sqlite;
38mod errors;
39pub(crate) mod hydration;
40mod tx;
41mod write_helpers;
42
43pub use errors::{GetError, TryForEachError};
44use hydration::{hydrate_prefetch_related, hydrate_select_related};
45pub use tx::QuerySetTx;
46use write_helpers::{
47    build_insert_many_for, build_insert_one_for, fk_pk_hint, pk_field, serialize_to_map,
48};
49
50use std::collections::HashMap;
51use std::marker::PhantomData;
52
53use sea_query::{
54    Alias, Expr, Func, IntoIden, Order, PostgresQueryBuilder, Query, SqliteQueryBuilder,
55};
56use sea_query_binder::SqlxBinder;
57use serde_json::Value as JsonValue;
58
59use crate::db::DbPool;
60use crate::orm::{FExpr, HydrateRelated, Model, OrderExpr, Predicate};
61use umbral_casing::to_snake_case;
62
63/// Entry point for queries on a model.
64///
65/// `Manager<T>` wraps a freshly-constructed `QuerySet<T>` and exposes
66/// the same chainable surface. The user never constructs one directly;
67/// `Post::objects()` is the only door.
68pub struct Manager<T> {
69    _phantom: PhantomData<T>,
70    /// Per-Manager override for the `atomic_transactions` builder
71    /// default. `None` = inherit the global default; `Some(true)` =
72    /// wrap subsequent writes in a transaction; `Some(false)` =
73    /// explicitly opt out. Propagates into the QuerySet `queryset()`
74    /// constructs.
75    atomic: Option<bool>,
76}
77
78impl<T> Manager<T> {
79    pub(crate) fn new() -> Self {
80        Self {
81            _phantom: PhantomData,
82            atomic: None,
83        }
84    }
85
86    /// Wrap every write terminal that hangs off this Manager in a
87    /// transaction. Equivalent to calling `.atomic()` on each
88    /// QuerySet derived from it. Per-call `.non_atomic()` overrides.
89    pub fn atomic(mut self) -> Self {
90        self.atomic = Some(true);
91        self
92    }
93
94    /// Opt this Manager (and every QuerySet derived from it) out of
95    /// the global `App::builder().atomic_transactions(true)` default.
96    pub fn non_atomic(mut self) -> Self {
97        self.atomic = Some(false);
98        self
99    }
100}
101
102impl<T> Default for Manager<T> {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108/// SQL join flavor recorded per `join_related` hop. `None` in a
109/// `JoinReq` means "infer from FK nullability" (gap 4c); an explicit
110/// `left_/inner_/right_join_related` records `Some(..)`.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum JoinKind {
113    Inner,
114    Left,
115    Right,
116}
117
118impl JoinKind {
119    /// Lower to sea-query's join type.
120    pub(crate) fn sea(self) -> sea_query::JoinType {
121        match self {
122            JoinKind::Inner => sea_query::JoinType::InnerJoin,
123            JoinKind::Left => sea_query::JoinType::LeftJoin,
124            JoinKind::Right => sea_query::JoinType::RightJoin,
125        }
126    }
127}
128
129/// One requested eager-join: a dotted relation path (`"plugin__author"`)
130/// plus the join type to apply to the LAST hop. `kind: None` means
131/// auto-infer per-hop from FK nullability (INNER for NOT NULL, LEFT for
132/// nullable), the default. The explicit methods pin `Some(..)`.
133#[derive(Debug, Clone)]
134pub(crate) struct JoinReq {
135    pub(crate) path: String,
136    pub(crate) kind: Option<JoinKind>,
137}
138
139/// A lazy, chainable SQL query.
140///
141/// Carries a sea-query `SelectStatement` plus pool-resolution state.
142/// Nothing is sent to the database until a terminal method is awaited.
143/// Cloning is cheap (the `SelectStatement` clones in O(query size)).
144pub struct QuerySet<T> {
145    /// The base SelectStatement — FROM, columns, joins, group-by,
146    /// order-by, limit, offset. Filters are NOT applied here; they
147    /// accumulate on [`Self::predicates`] and get woven in at
148    /// terminal time, so per-backend predicate variants (Phase
149    /// 4.2.2) can pick the right SimpleExpr based on the resolved
150    /// pool.
151    pub(crate) query: sea_query::SelectStatement,
152    /// Accumulated filter predicates. Each one renders to either its
153    /// default `cond` (for Postgres) or its `cond_sqlite` override
154    /// (for SQLite, if set) at terminal time.
155    pub(crate) predicates: Vec<Predicate<T>>,
156    pub(crate) explicit_pool: Option<DbPool>,
157    /// FK field names requested for eager loading via `select_related`.
158    /// After the main query returns rows, a batch `IN (...)` query
159    /// fetches the related rows for each named field and calls
160    /// `HydrateRelated::hydrate_fk` to populate `ForeignKey.resolved`.
161    pub(crate) select_related: Vec<String>,
162    /// M2M field names requested for eager loading via
163    /// `prefetch_related`. After the main query, one batched JOIN
164    /// against the junction + child table fetches every related row
165    /// for every parent in a single round-trip; each parent's
166    /// `M2M.resolved` slot is populated via
167    /// `HydrateRelated::set_m2m_resolved_json`. Gap #19.
168    pub(crate) prefetch_related: Vec<String>,
169    /// BUG-8: `#[umbral(ordering = [...])]` lowers to a default ORDER
170    /// BY applied at terminal time when the caller didn't supply an
171    /// explicit `.order_by(...)`. The semantics: explicit calls REPLACE
172    /// the default rather than appending to it.
173    pub(crate) default_ordering: Vec<(&'static str, bool)>,
174    /// Set to `true` the first time `.order_by(...)` is called; when
175    /// `false`, `build_query_for` applies `default_ordering`.
176    pub(crate) explicit_order: bool,
177    /// Per-QuerySet override for the `atomic_transactions` builder
178    /// default. `None` = inherit the global default via
179    /// [`crate::db::atomic_default`]; `Some(true)` = wrap this
180    /// QuerySet's write terminal in a transaction; `Some(false)` =
181    /// explicitly opt out.
182    pub(crate) atomic: Option<bool>,
183    /// Feature #72 — soft-delete state. Snapshotted from
184    /// `T::SOFT_DELETE` when the QuerySet is constructed from a
185    /// Manager (the no-bounds `QuerySet::new` constructor leaves
186    /// this `false` so hand-built QuerySets stay opt-out by
187    /// default).
188    pub(crate) soft_delete_active: bool,
189    /// True when the caller opted back into soft-deleted rows via
190    /// `.with_deleted()`. Skips the auto `WHERE deleted_at IS NULL`
191    /// injection.
192    pub(crate) with_deleted: bool,
193    /// True when the caller wants ONLY soft-deleted rows via
194    /// `.only_deleted()`. Inverts the auto-filter to
195    /// `WHERE deleted_at IS NOT NULL`.
196    pub(crate) only_deleted: bool,
197    /// True when the caller asked for a real DELETE via
198    /// `.hard_delete()` — bypasses the soft-delete rewrite that
199    /// would normally turn `delete()` into an UPDATE.
200    pub(crate) hard_delete: bool,
201    /// Gap #111 — column projection set by [`Self::only`]. When
202    /// `Some`, [`Self::to_sql`] / [`Self::to_sql_pg`] swap the
203    /// SELECT list for just these columns, and the typed terminals
204    /// (`fetch` / `first` / `get`) refuse to run with a clear error
205    /// pointing the caller at [`Self::values`] (FromRow can't
206    /// hydrate `T` from a partial-column row). `None` keeps the
207    /// pre-#111 behaviour (full SELECT).
208    pub(crate) only_cols: Option<Vec<String>>,
209    /// FK field names requested for JOIN-based prefetch via
210    /// [`Self::join_related`]. Distinct from `select_related`:
211    /// `join_related` weaves `LEFT JOIN <related_table>` into the
212    /// main SELECT (with aliased columns `<field>__<col>`) so one
213    /// round-trip pulls parent + related rows together. The existing
214    /// `select_related` path keeps its "batched-IN-followup-query"
215    /// shape — both are valid; this one wins when round-trip count
216    /// matters more than the per-row column overhead.
217    pub(crate) join_related: Vec<JoinReq>,
218    /// Related-aggregate annotations added via
219    /// [`Self::annotate_related`] / [`Self::annotate_count`]. Applied
220    /// inside `build_query_for`, so EVERY terminal and introspection
221    /// path — `fetch_annotated`, `explain`, `to_sql`, `to_sql_pg` —
222    /// sees the same correlated subqueries. That's the
223    /// `annotate()` contract: an annotation is query-builder state,
224    /// not a side query.
225    pub(crate) annotations: Vec<RelatedAnnotation>,
226    /// audit_2 plugin-storage-tasks #6 — when `true`, a read terminal appends
227    /// `FOR UPDATE SKIP LOCKED` (Postgres only). Lets N contending workers each
228    /// claim a DIFFERENT row instead of all piling onto the same head row and
229    /// serializing on its lock. A no-op on SQLite (no such clause; its
230    /// single-writer model needs none). Set via [`Self::for_update_skip_locked`].
231    pub(crate) for_update_skip_locked: bool,
232    _phantom: PhantomData<T>,
233}
234
235// Manual `Clone` — NOT `#[derive(Clone)]`, because the derive would force a
236// spurious `T: Clone` bound (every field is `T`-independent or carries its
237// own `T`-free `Clone`: `Predicate<T>` has a manual `impl<T> Clone`, and
238// `PhantomData<T>` clones for any `T`). The doc comment above already
239// promised cheap cloning; this is what makes `Paginator` (and any
240// requery-without-consume caller) slice the same query per page.
241impl<T> Clone for QuerySet<T> {
242    fn clone(&self) -> Self {
243        Self {
244            query: self.query.clone(),
245            predicates: self.predicates.clone(),
246            explicit_pool: self.explicit_pool.clone(),
247            select_related: self.select_related.clone(),
248            prefetch_related: self.prefetch_related.clone(),
249            default_ordering: self.default_ordering.clone(),
250            explicit_order: self.explicit_order,
251            atomic: self.atomic,
252            soft_delete_active: self.soft_delete_active,
253            with_deleted: self.with_deleted,
254            only_deleted: self.only_deleted,
255            hard_delete: self.hard_delete,
256            only_cols: self.only_cols.clone(),
257            join_related: self.join_related.clone(),
258            annotations: self.annotations.clone(),
259            for_update_skip_locked: self.for_update_skip_locked,
260            _phantom: PhantomData,
261        }
262    }
263}
264
265// Manual `Debug` for the same `T: Debug`-free reason; `Predicate<T>`'s own
266// `Debug` is likewise `T`-free.
267impl<T> std::fmt::Debug for QuerySet<T> {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        // `Predicate<T>` is intentionally not `Debug` (its `SimpleExpr`
270        // carries no `T`), so report the predicate count rather than the
271        // opaque expressions.
272        f.debug_struct("QuerySet")
273            .field("query", &self.query)
274            .field(
275                "predicates",
276                &format_args!("[{} predicate(s)]", self.predicates.len()),
277            )
278            .field("explicit_pool", &self.explicit_pool)
279            .field("select_related", &self.select_related)
280            .field("prefetch_related", &self.prefetch_related)
281            .field("default_ordering", &self.default_ordering)
282            .field("explicit_order", &self.explicit_order)
283            .field("atomic", &self.atomic)
284            .field("soft_delete_active", &self.soft_delete_active)
285            .field("with_deleted", &self.with_deleted)
286            .field("only_deleted", &self.only_deleted)
287            .field("hard_delete", &self.hard_delete)
288            .field("only_cols", &self.only_cols)
289            .field("join_related", &self.join_related)
290            .field("annotations", &self.annotations)
291            .finish()
292    }
293}
294
295/// One related-aggregate annotation on a [`QuerySet`] —
296/// `(alias, relation, aggregate)` resolved against the model's
297/// `REVERSE_FK_RELATIONS` at builder time. A name that fails to
298/// resolve is stored poisoned (`resolved: Err`) so the infallible
299/// builder stays chainable while every fallible consumer
300/// (`fetch_annotated`, `explain`) reports it loudly.
301#[derive(Debug, Clone)]
302pub(crate) struct RelatedAnnotation {
303    pub(crate) alias: String,
304    pub(crate) agg: crate::orm::Aggregate,
305    /// `Ok((child_table, fk_column, parent_table, parent_pk))` or the
306    /// loud error message for an unknown relation.
307    pub(crate) resolved: Result<(String, String, String, String), String>,
308    /// Child model is `#[umbral(soft_delete)]` — fold
309    /// `AND <child>.deleted_at IS NULL` into the correlated subquery so
310    /// a trashed child stops inflating the parent's count.
311    pub(crate) child_soft_delete: bool,
312    /// Optional child-side predicate (a filtered count),
313    /// pre-rendered to a backend-default `SimpleExpr`. ANDed into the
314    /// subquery WHERE. From `annotate_count_where`.
315    pub(crate) child_filter: Option<sea_query::SimpleExpr>,
316    /// `Some(junction_table)` when this annotation counts M2M junction
317    /// rows instead of child rows (`annotate_count` over an `M2M<T>`).
318    pub(crate) m2m_junction: Option<String>,
319}
320
321/// Outcome of auto-discovering a reverse-FK relation (gaps2 #45) when
322/// the parent declares no matching `ReverseSet` field. The resolver
323/// scans the registry for children whose FK targets the parent table
324/// and matches `relation` against their conventional name forms.
325enum AutoDiscovery {
326    /// Exactly one (child, fk_column) candidate matched.
327    Resolved {
328        child_table: String,
329        fk_column: String,
330        soft_delete: bool,
331    },
332    /// Two or more candidates matched — the caller must declare a
333    /// `#[umbral(reverse_fk = "...")]` field to disambiguate. Carries
334    /// the candidate `child.fk` labels for the error message.
335    Ambiguous(Vec<String>),
336    /// No candidate matched. Carries the list of auto-discoverable
337    /// child names so the error can teach the available relations.
338    NotFound(Vec<String>),
339}
340
341// `snake_case` replaced by `umbral_casing::to_snake_case` (imported above)
342// in the gaps2 #77 consolidation refactor.
343
344/// Scan the model registry for children whose FK targets `T::TABLE`,
345/// and match `relation` against each candidate's conventional name
346/// forms: the child's table name, the child's struct name in
347/// snake_case and bare-lowercase, and any of those with a `_set`
348/// suffix (the `<model>_set` form). Declared `REVERSE_FK_RELATIONS` /
349/// `M2M_RELATIONS` are resolved by the caller BEFORE this runs, so
350/// they always take precedence.
351fn discover_reverse_relation<T: crate::orm::Model>(relation: &str) -> AutoDiscovery {
352    if !crate::migrate::is_initialised() {
353        return AutoDiscovery::NotFound(Vec::new());
354    }
355    let parent_table = T::TABLE;
356    // Each candidate: (child_table, fk_column, child_soft_delete).
357    let mut candidates: Vec<(String, String, bool)> = Vec::new();
358    let mut discoverable: Vec<String> = Vec::new();
359    for meta in crate::migrate::registered_models() {
360        for col in &meta.fields {
361            if col.fk_target.as_deref() != Some(parent_table) {
362                continue;
363            }
364            // Conventional name forms this (child, fk_column) answers to.
365            let snake = to_snake_case(&meta.name);
366            let lower = meta.name.to_ascii_lowercase();
367            let mut forms = vec![
368                meta.table.clone(),
369                snake.clone(),
370                lower.clone(),
371                format!("{}_set", meta.table),
372                format!("{snake}_set"),
373                format!("{lower}_set"),
374            ];
375            forms.sort();
376            forms.dedup();
377            // Surface a friendly name for "available children" errors.
378            discoverable.push(format!("{}_set", meta.table));
379            if forms.iter().any(|f| f == relation) {
380                candidates.push((meta.table.clone(), col.name.clone(), meta.soft_delete));
381            }
382        }
383    }
384    match candidates.len() {
385        1 => {
386            let (child_table, fk_column, soft_delete) = candidates.pop().unwrap();
387            AutoDiscovery::Resolved {
388                child_table,
389                fk_column,
390                soft_delete,
391            }
392        }
393        0 => {
394            discoverable.sort();
395            discoverable.dedup();
396            AutoDiscovery::NotFound(discoverable)
397        }
398        _ => {
399            let labels = candidates
400                .into_iter()
401                .map(|(child, fk, _)| format!("{child}.{fk}"))
402                .collect();
403            AutoDiscovery::Ambiguous(labels)
404        }
405    }
406}
407
408impl<T> QuerySet<T> {
409    pub(crate) fn new(query: sea_query::SelectStatement) -> Self {
410        Self {
411            query,
412            default_ordering: Vec::new(),
413            explicit_order: false,
414            predicates: Vec::new(),
415            explicit_pool: None,
416            select_related: Vec::new(),
417            prefetch_related: Vec::new(),
418            atomic: None,
419            soft_delete_active: false,
420            with_deleted: false,
421            only_deleted: false,
422            hard_delete: false,
423            only_cols: None,
424            join_related: Vec::new(),
425            annotations: Vec::new(),
426            for_update_skip_locked: false,
427            _phantom: PhantomData,
428        }
429    }
430
431    /// Feature #72 — include soft-deleted rows in this query. Skips
432    /// the auto `WHERE deleted_at IS NULL` injection. No-op on
433    /// models that aren't tagged `#[umbral(soft_delete)]`.
434    pub fn with_deleted(mut self) -> Self {
435        self.with_deleted = true;
436        self
437    }
438
439    /// Feature #72 — only soft-deleted rows. Useful for admin
440    /// trash views and undelete workflows. No-op on models that
441    /// aren't tagged `#[umbral(soft_delete)]`.
442    pub fn only_deleted(mut self) -> Self {
443        self.only_deleted = true;
444        self
445    }
446
447    /// Feature #72 — force a real DELETE for the next `.delete()`
448    /// terminal call. Soft-delete models normally rewrite delete()
449    /// as `UPDATE ... SET deleted_at = NOW()`; `.hard_delete()`
450    /// bypasses that for GDPR purges, test cleanup, or any other
451    /// case where the row truly should be gone. No-op on models
452    /// that aren't tagged `#[umbral(soft_delete)]` (their delete()
453    /// is already a hard DELETE).
454    pub fn hard_delete(mut self) -> Self {
455        self.hard_delete = true;
456        self
457    }
458
459    /// Gap #111 — restrict the SELECT to the named columns.
460    ///
461    /// Affects [`Self::to_sql`] / [`Self::to_sql_pg`] (the SELECT list
462    /// shrinks to just these columns) and propagates into
463    /// [`Self::values`] when that terminal is called without its own
464    /// explicit column slice.
465    ///
466    /// **The typed terminals (`fetch` / `first` / `get`) refuse to
467    /// run with `.only()` set** because a partial-column row can't
468    /// satisfy `T`'s `FromRow` impl. The error message points at
469    /// `.values(...)` (returns `Vec<serde_json::Value>`) as the
470    /// execution path. Use `.only()` for `.to_sql()` inspection and
471    /// `.values()` for actual reads.
472    ///
473    /// ```rust,ignore
474    /// // Inspect: "SELECT \"id\", \"name\" FROM \"brand\" WHERE \"id\" = ?"
475    /// let sql = Brand::objects()
476    ///     .filter(brand::ID.eq(1))
477    ///     .only(&["id", "name"])
478    ///     .to_sql();
479    ///
480    /// // Execute (returns JSON rows):
481    /// let rows = Brand::objects()
482    ///     .filter(brand::ID.eq(1))
483    ///     .values(&["id", "name"])
484    ///     .await?;
485    /// ```
486    ///
487    /// Unknown column names are not validated here — they surface at
488    /// terminal time the same way `.values()` reports them (the
489    /// rendered SQL contains the bad identifier and SQLite/Postgres
490    /// raises). This keeps the chainable surface return-type-stable.
491    pub fn only(mut self, cols: &[&str]) -> Self {
492        self.only_cols = Some(cols.iter().map(|s| s.to_string()).collect());
493        self
494    }
495
496    /// Wrap this QuerySet's write terminal in a transaction. Reads are
497    /// unaffected (read terminals are single statements and the DB
498    /// gives them a consistent snapshot). Mutually exclusive with
499    /// [`Self::on_tx`] — if both are set, `on_tx` wins (you're
500    /// already inside a transaction, so wrapping again would deadlock
501    /// or fail on backends without nested transactions).
502    pub fn atomic(mut self) -> Self {
503        self.atomic = Some(true);
504        self
505    }
506
507    /// Opt this QuerySet's write terminal out of the global
508    /// `App::builder().atomic_transactions(true)` default. Useful in
509    /// hot-path batches where the caller already owns the outer
510    /// transaction.
511    pub fn non_atomic(mut self) -> Self {
512        self.atomic = Some(false);
513        self
514    }
515
516    /// Resolve whether this QuerySet should auto-wrap its write
517    /// terminal in a transaction. Per-call override > builder global.
518    pub(crate) fn should_atomic_wrap(&self) -> bool {
519        self.atomic.unwrap_or_else(crate::db::atomic_default)
520    }
521
522    /// Clone the base query and weave in the accumulated predicates,
523    /// picking the dialect-appropriate `SimpleExpr` for each one. The
524    /// `backend_name` is `"sqlite"` or `"postgres"`; any other value
525    /// behaves like Postgres (the default).
526    pub(crate) fn build_query_for(&self, backend_name: &str) -> sea_query::SelectStatement {
527        let mut q = self.query.clone();
528        for p in &self.predicates {
529            q.and_where(p.cond_for(backend_name));
530        }
531        // Feature #72 — soft-delete auto-filter. When the model
532        // opted in via `#[umbral(soft_delete)]` AND the caller
533        // didn't switch the visibility via `.with_deleted()` /
534        // `.only_deleted()`, inject `WHERE deleted_at IS NULL`.
535        // `.with_deleted()` shows everything; `.only_deleted()`
536        // shows just the soft-deleted rows.
537        if self.soft_delete_active {
538            use sea_query::Expr;
539            if self.only_deleted {
540                q.and_where(Expr::col(Alias::new("deleted_at")).is_not_null());
541            } else if !self.with_deleted {
542                q.and_where(Expr::col(Alias::new("deleted_at")).is_null());
543            }
544        }
545        // Related-aggregate annotations: one correlated scalar
546        // subquery per entry, aliased onto the SELECT list. Living
547        // HERE is what makes `.annotate_*` compose with everything —
548        // explain(), to_sql(), fetch_annotated() all see the same
549        // query. Poisoned entries (unknown relation) are skipped in
550        // this infallible path; the fallible consumers call
551        // `check_annotations()` first and fail loudly instead.
552        for ann in &self.annotations {
553            // M2M-junction annotations count rows of the junction table
554            // (`<parent>_<field>`, columns parent_id / child_id),
555            // correlated on parent_id = <parent>.<pk>.
556            if let Some(junction) = &ann.m2m_junction {
557                if let Ok((_child_table, _fk_col, parent_table, parent_pk)) = &ann.resolved {
558                    let mut sub = sea_query::Query::select();
559                    sub.expr(ann.agg.to_simple_expr())
560                        .from(crate::db::router::schema_qualified_table(junction.as_str()))
561                        .and_where(
562                            sea_query::Expr::col((
563                                Alias::new(junction.as_str()),
564                                Alias::new("parent_id"),
565                            ))
566                            .equals((
567                                Alias::new(parent_table.as_str()),
568                                Alias::new(parent_pk.as_str()),
569                            )),
570                        );
571                    q.expr_as(
572                        sea_query::SimpleExpr::SubQuery(
573                            None,
574                            Box::new(sea_query::SubQueryStatement::SelectStatement(
575                                sub.to_owned(),
576                            )),
577                        ),
578                        Alias::new(ann.alias.as_str()),
579                    );
580                }
581                continue;
582            }
583            if let Ok((child_table, fk_col, parent_table, parent_pk)) = &ann.resolved {
584                let mut sub = sea_query::Query::select();
585                sub.expr(ann.agg.to_simple_expr())
586                    .from(crate::db::router::schema_qualified_table(
587                        child_table.as_str(),
588                    ))
589                    .and_where(
590                        sea_query::Expr::col((
591                            Alias::new(child_table.as_str()),
592                            Alias::new(fk_col.as_str()),
593                        ))
594                        .equals((
595                            Alias::new(parent_table.as_str()),
596                            Alias::new(parent_pk.as_str()),
597                        )),
598                    );
599                // Auto-exclude soft-deleted children from the count when
600                // the child model is `#[umbral(soft_delete)]`.
601                if ann.child_soft_delete {
602                    sub.and_where(
603                        sea_query::Expr::col((
604                            Alias::new(child_table.as_str()),
605                            Alias::new("deleted_at"),
606                        ))
607                        .is_null(),
608                    );
609                }
610                // Child-side predicate (annotate_count_where).
611                if let Some(filter) = &ann.child_filter {
612                    sub.and_where(filter.clone());
613                }
614                q.expr_as(
615                    sea_query::SimpleExpr::SubQuery(
616                        None,
617                        Box::new(sea_query::SubQueryStatement::SelectStatement(
618                            sub.to_owned(),
619                        )),
620                    ),
621                    Alias::new(ann.alias.as_str()),
622                );
623            }
624        }
625        // BUG-8: default ORDER BY applies only when the caller didn't
626        // supply an explicit `.order_by(...)`: the model-default
627        // ordering semantics.
628        if !self.explicit_order {
629            for (col, desc) in &self.default_ordering {
630                let order = if *desc { Order::Desc } else { Order::Asc };
631                q.order_by(Alias::new(*col), order);
632            }
633        }
634        // audit_2 plugin-storage-tasks #6 — `FOR UPDATE SKIP LOCKED`, Postgres
635        // ONLY. It lets concurrent claimers skip rows another txn already locked
636        // (each grabs a different row) instead of all blocking on the head row's
637        // lock. SQLite has no such clause and its single-writer model makes it
638        // unnecessary, so it's a no-op there (never appended).
639        if self.for_update_skip_locked && backend_name != "sqlite" {
640            q.lock_with_behavior(
641                sea_query::LockType::Update,
642                sea_query::LockBehavior::SkipLocked,
643            );
644        }
645        q
646    }
647}
648
649/// Chainable methods on every `QuerySet<T>`.
650///
651/// These are model-agnostic: they only touch the sea-query
652/// `SelectStatement` and the pool-resolution slot, neither of which
653/// depends on `T`. Terminals (which need row mapping) live in the
654/// `impl<T: Model> QuerySet<T>` block below.
655impl<T> QuerySet<T> {
656    /// Add a WHERE condition. Multiple `.filter` calls AND together
657    /// (sea-query's `and_where` semantics — applied at terminal time
658    /// once the resolved pool's backend is known).
659    pub fn filter(mut self, p: Predicate<T>) -> Self {
660        self.predicates.push(p);
661        self
662    }
663
664    /// Add a negated WHERE condition. The negated predicate ANDs into
665    /// the chain alongside any `filter()` calls, so
666    /// `.filter(A).exclude(B).filter(C)` renders as `WHERE A AND NOT B
667    /// AND C`. Sugar for `filter(Q::not(p))`.
668    ///
669    /// The negated-filter terminal.
670    pub fn exclude(self, p: Predicate<T>) -> Self {
671        self.filter(crate::orm::Q::not(p))
672    }
673
674    /// Add an ORDER BY clause. Multiple `.order_by` calls append.
675    /// The first explicit call also opts out of the model's
676    /// `#[umbral(ordering = [...])]` default (BUG-8): explicit ordering
677    /// replaces the default rather than stacking on top of it.
678    pub fn order_by(mut self, o: OrderExpr<T>) -> Self {
679        let order = if o.descending {
680            Order::Desc
681        } else {
682            Order::Asc
683        };
684        self.query.order_by(Alias::new(o.column), order);
685        self.explicit_order = true;
686        self
687    }
688
689    /// Set LIMIT.
690    pub fn limit(mut self, n: u64) -> Self {
691        self.query.limit(n);
692        self
693    }
694
695    /// Set OFFSET.
696    pub fn offset(mut self, n: u64) -> Self {
697        self.query.offset(n);
698        self
699    }
700
701    /// Append `FOR UPDATE SKIP LOCKED` to a read terminal — **Postgres only**
702    /// (a no-op on SQLite). Rows another transaction has already locked are
703    /// skipped rather than blocked on, so N concurrent workers running the same
704    /// `SELECT ... LIMIT k` each claim DIFFERENT rows instead of all contending
705    /// for the same head row. The canonical use is a task/job queue's claim
706    /// query (audit_2 plugin-storage-tasks #6): pair it with a conditional
707    /// `UPDATE ... WHERE status = 'pending'` inside the same transaction.
708    ///
709    /// Must be used inside a transaction (`.on_tx(&mut tx)`) — the row locks a
710    /// bare `SELECT` takes are released immediately, defeating the point.
711    pub fn for_update_skip_locked(mut self) -> Self {
712        self.for_update_skip_locked = true;
713        self
714    }
715
716    /// Override the pool resolved at terminal time with a SQLite pool.
717    ///
718    /// Wins over the ambient default. Used by tests that drive the ORM
719    /// without going through `App::build()`. For a Postgres override
720    /// use [`Self::on_pg`].
721    pub fn on(mut self, pool: &sqlx::SqlitePool) -> Self {
722        self.explicit_pool = Some(DbPool::Sqlite(pool.clone()));
723        self
724    }
725
726    /// Override the pool resolved at terminal time with a Postgres pool.
727    ///
728    /// The Postgres counterpart of [`Self::on`]. Tests that want to
729    /// exercise the Postgres branch (or that drive against a real
730    /// Postgres instance) reach for this directly.
731    pub fn on_pg(mut self, pool: &sqlx::PgPool) -> Self {
732        self.explicit_pool = Some(DbPool::Postgres(pool.clone()));
733        self
734    }
735
736    /// Attach this `QuerySet` to an open transaction.
737    ///
738    /// Returns a [`QuerySetTx`] that holds both the query and a mutable
739    /// reference to the transaction. Every terminal on `QuerySetTx`
740    /// (`fetch`, `first`, `count`, `exists`, `get`, `delete`,
741    /// `update_values`) executes inside the open transaction so all
742    /// operations in the same closure commit or roll back as a unit.
743    ///
744    /// ```rust,ignore
745    /// umbral::db::transaction(|tx| async move {
746    ///     let order = Order::objects().on_tx(tx).create(new_order).await?;
747    ///     Stock::objects()
748    ///         .on_tx(tx)
749    ///         .filter(stock::SKU.eq(sku))
750    ///         .update_values(delta)
751    ///         .await?;
752    ///     Ok::<_, MyError>(order)
753    /// }).await?;
754    /// ```
755    pub fn on_tx(self, tx: &mut crate::db::Transaction) -> QuerySetTx<'_, T> {
756        QuerySetTx { qs: self, tx }
757    }
758
759    /// Eagerly load a single FK field by name.
760    ///
761    /// After the main SELECT returns rows, a batch `SELECT ... FROM <related_table>
762    /// WHERE id IN (...)` fetches all referenced rows in one round-trip. Each
763    /// returned row is deserialised as the target model and stored in
764    /// `ForeignKey<U>.resolved` so template rendering (`{{ post.author.username }}`)
765    /// and `serde_json::to_value(&post)["author"]["username"]` both work without
766    /// additional queries.
767    ///
768    /// Calling `select_related` multiple times accumulates the names:
769    /// `.select_related("author").select_related("editor")` works the same as
770    /// `.select_related_many(&["author", "editor"])`.
771    ///
772    /// ## Nested traversal (post-#42)
773    ///
774    /// `.select_related("author__manager")` walks the FK chain through
775    /// the `__` separator. One batched `IN (...)` query per hop —
776    /// `1 + len(hops)` round-trips regardless of parent count. No
777    /// N+1. Each hop's related row is embedded into the prior level's
778    /// JSON, and recursive `ForeignKey<T>::Deserialize` unpacks the
779    /// chain into `resolved()` slots at every depth. Bonus: a
780    /// select_related'd model now round-trips through
781    /// `serde_json::to_value(&t)` / `from_value` without losing the
782    /// resolved relation.
783    ///
784    /// ## Companion shapes
785    ///
786    /// - **`join_related(name)`** — same goal (load related rows) via
787    ///   a true `LEFT JOIN` in the main SELECT. One round-trip total
788    ///   vs. `select_related`'s `1 + N` batched-IN approach. Wider
789    ///   per-row payload; better when round-trip count dominates.
790    /// - **`prefetch_related(name)`** — M2M batched loading (one
791    ///   query per declared M2M field). For reverse-FK collections
792    ///   (`prefetch_related("comment_set")`-style) see gap #44 — not
793    ///   yet implemented.
794    ///
795    /// ## Loud errors
796    ///
797    /// Unknown field names (typos, M2M names accidentally passed
798    /// here, fields without `fk_target`) return a clear
799    /// `sqlx::Error::Protocol` from the terminal naming the bad hop
800    /// and the table it was looked up against. Pre-#42 these
801    /// silently no-op'd.
802    pub fn select_related(mut self, field_name: impl Into<String>) -> Self {
803        self.select_related.push(field_name.into());
804        self
805    }
806
807    /// Eagerly load multiple FK fields in one call.
808    ///
809    /// Sugar for chained `.select_related(name)` calls.
810    pub fn select_related_many(mut self, field_names: &[&str]) -> Self {
811        for name in field_names {
812            self.select_related.push(name.to_string());
813        }
814        self
815    }
816
817    /// JOIN-based eager FK loading — emits `LEFT JOIN <related> ON ...`
818    /// in the main SELECT (with aliased child columns `<field>__<col>`)
819    /// so one round-trip pulls the parent + related rows together.
820    ///
821    /// Trade-off vs. [`Self::select_related`]:
822    ///   - `select_related` runs ONE extra batched query after the
823    ///     main fetch (`SELECT * FROM related WHERE id IN (...)`).
824    ///     Two round-trips total; rows stay narrow.
825    ///   - `join_related` runs the main query AS the join — one
826    ///     round-trip total — at the cost of a wider per-row payload
827    ///     (every related column rides along even for duplicated
828    ///     parents).
829    ///
830    /// Both populate `ForeignKey<U>.resolved` the same way so
831    /// downstream code (templates, serde) doesn't care which path
832    /// was used. Pick `join_related` when round-trip count dominates
833    /// (hot listing pages, small related tables) and `select_related`
834    /// when the related row is wide or only loaded for a subset of
835    /// the parent rows.
836    ///
837    /// Composes with `.select_related(other_fk)` and
838    /// `.prefetch_related(m2m)` — different fields can take different
839    /// paths in the same query.
840    ///
841    /// Multi-hop FK chains are supported: a `"__"`-separated path like
842    /// `"author__manager"` resolves one JOIN per hop in a single query.
843    ///
844    /// **Constraints**: FK fields must live in `model.fields` (M2M links
845    /// route through `prefetch_related`), and every related model along the
846    /// chain must be registered with the framework
847    /// (`App::builder().model::<U>()` or contributed by a plugin) so we can
848    /// resolve its column layout for the aliased SELECT.
849    pub fn join_related(mut self, field_name: impl Into<String>) -> Self {
850        self.join_related.push(JoinReq {
851            path: field_name.into(),
852            kind: None,
853        });
854        self
855    }
856
857    /// Sugar for chained [`Self::join_related`] calls.
858    pub fn join_related_many(mut self, field_names: &[&str]) -> Self {
859        for name in field_names {
860            self.join_related.push(JoinReq {
861                path: (*name).to_string(),
862                kind: None,
863            });
864        }
865        self
866    }
867
868    /// `LEFT JOIN` the related path — keeps parent rows whose relation
869    /// is absent (the relation hydrates as unresolved/None). Accepts a
870    /// nested path (`"plugin__author"`); the join type applies to the
871    /// deepest hop.
872    pub fn left_join_related(mut self, path: impl Into<String>) -> Self {
873        self.join_related.push(JoinReq {
874            path: path.into(),
875            kind: Some(JoinKind::Left),
876        });
877        self
878    }
879
880    /// `INNER JOIN` the related path — drops parent rows whose relation
881    /// is absent. The default for a NOT NULL FK.
882    pub fn inner_join_related(mut self, path: impl Into<String>) -> Self {
883        self.join_related.push(JoinReq {
884            path: path.into(),
885            kind: Some(JoinKind::Inner),
886        });
887        self
888    }
889
890    /// `RIGHT JOIN` the related path. Postgres-unconditional; SQLite
891    /// needs >= 3.39 — a runtime warning fires on older SQLite (see the
892    /// boot/runtime note in the joins docs). The precise version gate
893    /// lives at execute time (the SQLite driver's own error); the warn
894    /// is the early nudge.
895    pub fn right_join_related(mut self, path: impl Into<String>) -> Self {
896        self.join_related.push(JoinReq {
897            path: path.into(),
898            kind: Some(JoinKind::Right),
899        });
900        self
901    }
902
903    /// Eagerly load an M2M relation via a single batched join.
904    ///
905    /// After the main SELECT returns rows, one query of the shape
906    /// `SELECT j.parent_id, child.* FROM <child_table> child INNER
907    /// JOIN <junction> j ON child.<pk> = j.child_id WHERE
908    /// j.parent_id IN (...)` fetches every related child for every
909    /// parent in one round-trip. Each parent's `M2M.resolved` slot
910    /// is populated with its matching children.
911    ///
912    /// The M2M counterpart of [`Self::select_related`] for FKs —
913    /// same goal of killing N+1: a batch-loaded `prefetch_related('tags')`.
914    ///
915    /// ## Reverse-FK collections (post-#44)
916    ///
917    /// `prefetch_related` also loads `ReverseSet<C>` fields — the
918    /// "for each Post, give me every Comment that points at it"
919    /// shape. Declare the field on the parent with
920    /// `#[sqlx(skip)] #[serde(skip)]
921    /// #[umbral(reverse_fk = "<fk_col>")] pub <name>: ReverseSet<C>`
922    /// where `<fk_col>` names the FK column on `C` pointing back.
923    /// One `SELECT * FROM <child> WHERE <fk_col> IN (parent_pks)`
924    /// regardless of parent count — no N+1.
925    ///
926    /// ## Scope (v1)
927    ///
928    /// - **M2M + reverse-FK only.** FK fields go through
929    ///   [`Self::select_related`] (batched IN) or
930    ///   [`Self::join_related`] (LEFT JOIN).
931    /// - **i64 parent PK only.** Same constraint as the rest of the
932    ///   M2M plumbing; models with non-i64 PKs surface a clean
933    ///   compile error.
934    /// - **Unknown field name → loud error** (post-#42). If the
935    ///   name matches neither an M2M nor a `ReverseSet` field,
936    ///   fetch returns a clear `sqlx::Error::Protocol` pointing at
937    ///   the right method.
938    pub fn prefetch_related(mut self, field_name: impl Into<String>) -> Self {
939        self.prefetch_related.push(field_name.into());
940        self
941    }
942
943    /// Eagerly load multiple M2M relations. Sugar for chained
944    /// `.prefetch_related(name)` calls.
945    pub fn prefetch_related_many(mut self, field_names: &[&str]) -> Self {
946        for name in field_names {
947            self.prefetch_related.push(name.to_string());
948        }
949        self
950    }
951
952    /// Convert this QuerySet into a [`Subquery`] suitable for use in
953    /// an `IN (SELECT ...)` predicate. Projects only the named
954    /// column; the accumulated WHERE / ORDER BY survive.
955    ///
956    /// `Post::objects().filter(...).into_subquery("author_id")` →
957    /// `Subquery` you can hand to `user::ID.in_subquery(...)`.
958    pub fn into_subquery(self, col_name: &str) -> crate::orm::Subquery {
959        let mut q = self.build_query_for("sqlite");
960        q.clear_selects();
961        q.column(Alias::new(col_name));
962        crate::orm::Subquery::from_select(q)
963    }
964
965    /// Combine this QuerySet with `other` via SQL `UNION` (gap #28).
966    /// Both QuerySets must produce the same column shape — which
967    /// they always do here because both are typed `QuerySet<T>`.
968    /// Duplicates are removed (the de-duplicating UNION, not UNION
969    /// ALL).
970    pub fn union(self, other: QuerySet<T>) -> Self {
971        self.combine(other, sea_query::UnionType::Distinct)
972    }
973
974    /// Combine this QuerySet with `other` via SQL `INTERSECT`
975    /// (gap #28). Returns rows present in BOTH inputs.
976    pub fn intersect(self, other: QuerySet<T>) -> Self {
977        self.combine(other, sea_query::UnionType::Intersect)
978    }
979
980    /// Combine this QuerySet with `other` via SQL `EXCEPT`
981    /// (gap #28). Returns rows present in `self` but not in `other`.
982    pub fn except(self, other: QuerySet<T>) -> Self {
983        self.combine(other, sea_query::UnionType::Except)
984    }
985
986    /// Internal: attach `other`'s SelectStatement to `self`'s
987    /// SelectStatement with the given UnionType. Both sides apply
988    /// their accumulated predicates / ORDER BY before the union.
989    fn combine(mut self, other: QuerySet<T>, ty: sea_query::UnionType) -> Self {
990        let backend = "sqlite";
991        let other_select = other.build_query_for(backend);
992        // Fold our own predicates into the base query so the union
993        // sees them; further `.filter()` calls on the returned
994        // QuerySet would still apply to the OUTER (combined) query.
995        let mut base = self.build_query_for(backend);
996        self.predicates.clear();
997        base.union(ty, other_select);
998        self.query = base;
999        self
1000    }
1001
1002    /// Emit `SELECT DISTINCT ...` for this query (gap #17). Most
1003    /// useful when combined with [`Self::values`] to dedupe a
1004    /// column-projected list (`distinct().values(&["tag"])`); the
1005    /// full-row DISTINCT is rarely what you want.
1006    ///
1007    /// Postgres-specific `DISTINCT ON (cols)` is deferred until a
1008    /// real consumer surfaces the need — the standard `DISTINCT`
1009    /// covers most use cases.
1010    pub fn distinct(mut self) -> Self {
1011        self.query.distinct();
1012        self
1013    }
1014}
1015
1016/// Resolve the pool to run a terminal against.
1017///
1018/// Precedence: explicit `.on(&pool)` / `.on_pg(&pool)` override wins;
1019/// then the per-model database alias the Plugin contract published
1020/// via `Plugin::database()` (FEATURES.md #6); then the `"default"`
1021/// pool. Tests that skip the App builder pass an explicit pool and
1022/// bypass the alias lookup entirely.
1023/// Validate that every name passed to `.join_related(...)` resolves
1024/// to a foreign-key field on `T`. Loud-error path that replaces the
1025/// pre-#42 silent no-op (where an unknown field, an M2M field, or a
1026/// non-FK column produced an empty JOIN list and a confusing
1027/// "ForeignKey::resolved() returned None" downstream).
1028fn validate_join_related_fields<T: Model>(fields: &[String]) -> Result<(), sqlx::Error> {
1029    for field_name in fields {
1030        // Nested path (`"plugin__author"` / `"tags__category"`):
1031        // validate via the hop resolver. A leading M2M segment is a
1032        // valid chain entry even though it isn't an FK on `T`, so
1033        // accept it and let `apply_join_related` route it.
1034        if field_name.contains("__") {
1035            let first = field_name.split("__").next().unwrap_or(field_name);
1036            let leads_m2m = T::M2M_RELATIONS.iter().any(|r| r.field_name == first);
1037            if leads_m2m || resolve_join_hops::<T>(field_name).is_some() {
1038                continue;
1039            }
1040            return Err(sqlx::Error::Protocol(format!(
1041                "umbral::orm::join_related: nested path `{field_name}` on `{}` has an \
1042                 unresolvable hop (each segment must be a FK or M2M to a registered model)",
1043                T::NAME
1044            )));
1045        }
1046        // Try as a regular column first.
1047        let col = T::FIELDS.iter().find(|f| f.name == field_name.as_str());
1048        if let Some(col) = col {
1049            if col.fk_target.is_some() {
1050                continue; // FK column — OK.
1051            }
1052            return Err(sqlx::Error::Protocol(format!(
1053                "umbral::orm::join_related: field `{field_name}` on `{}` is not a foreign \
1054                 key (it has no fk_target)",
1055                T::NAME
1056            )));
1057        }
1058        // M2M field name? Post-#113 these go through the double
1059        // LEFT JOIN path: apply_join_related emits
1060        // `LEFT JOIN <junction> LEFT JOIN <child>` with aliased
1061        // child cols, and fetch()'s dedup-aware path collects M2M
1062        // children per parent. Trade-off documented at the join_related
1063        // docstring — M2M JOINs multiply parent rows by avg
1064        // cardinality, so prefetch_related stays the default for
1065        // any list page where M2M cardinality isn't tiny.
1066        if T::M2M_RELATIONS
1067            .iter()
1068            .any(|r| r.field_name == field_name.as_str())
1069        {
1070            continue;
1071        }
1072        return Err(sqlx::Error::Protocol(format!(
1073            "umbral::orm::join_related: unknown field `{field_name}` on model `{}`",
1074            T::NAME
1075        )));
1076    }
1077    Ok(())
1078}
1079
1080/// One resolved hop of a `join_related` FK chain.
1081#[derive(Debug, Clone)]
1082pub(crate) struct JoinHop {
1083    /// FK column name on the *previous* level's table.
1084    pub(crate) fk_col: String,
1085    /// Table this hop targets.
1086    pub(crate) child_table: String,
1087    /// PK column on `child_table`.
1088    pub(crate) child_pk: String,
1089    /// Was the FK column nullable? (drives auto-inference)
1090    pub(crate) nullable: bool,
1091}
1092
1093/// Resolve a dotted FK path (`"plugin__author"`) into ordered hops.
1094/// Hop 0 reads `T::FIELDS`; deeper hops read the migrate registry's
1095/// `Column`s for the prior hop's target table. Returns `None` (skip,
1096/// emit no JOIN) on any unresolved hop — same forgiving posture as the
1097/// pre-existing one-hop path's silent skip in `to_sql`.
1098///
1099/// FK-only: a path whose FIRST segment is an M2M field is NOT handled
1100/// here (M2M chains route through `apply_join_related`'s M2M branch);
1101/// this returns `None` for such a path.
1102pub(crate) fn resolve_join_hops<T: Model>(path: &str) -> Option<Vec<JoinHop>> {
1103    let registered = crate::migrate::registered_models();
1104    let segs: Vec<&str> = path.split("__").filter(|s| !s.is_empty()).collect();
1105    if segs.is_empty() {
1106        return None;
1107    }
1108    let mut hops = Vec::with_capacity(segs.len());
1109    // Hop 0 off the typed parent.
1110    let f0 = T::FIELDS.iter().find(|f| f.name == segs[0])?;
1111    let t0 = f0.fk_target?;
1112    let m0 = registered.iter().find(|m| m.table == t0)?;
1113    let pk0 = m0.fields.iter().find(|c| c.primary_key)?;
1114    hops.push(JoinHop {
1115        fk_col: segs[0].to_string(),
1116        child_table: t0.to_string(),
1117        child_pk: pk0.name.clone(),
1118        nullable: f0.nullable,
1119    });
1120    let mut current = t0;
1121    for seg in &segs[1..] {
1122        let meta = registered.iter().find(|m| m.table == current)?;
1123        let col = meta.fields.iter().find(|c| c.name == *seg)?;
1124        let tgt = col.fk_target.as_deref()?;
1125        let tmeta = registered.iter().find(|m| m.table == tgt)?;
1126        let pk = tmeta.fields.iter().find(|c| c.primary_key)?;
1127        hops.push(JoinHop {
1128            fk_col: (*seg).to_string(),
1129            child_table: tgt.to_string(),
1130            child_pk: pk.name.clone(),
1131            nullable: col.nullable,
1132        });
1133        current = tgt;
1134    }
1135    Some(hops)
1136}
1137
1138/// Thin wrapper so the backend hydration helpers (a sibling module)
1139/// can resolve a chain without importing the private name directly.
1140pub(crate) fn resolve_join_hops_for<T: Model>(path: &str) -> Option<Vec<JoinHop>> {
1141    resolve_join_hops::<T>(path)
1142}
1143
1144/// Resolve a path whose FIRST segment is an M2M field on `T` into the
1145/// M2M child table + child PK + the onward FK chain hops off that
1146/// child. `onward` is empty for a bare `"tags"`; for `"tags__category"`
1147/// it carries the `category` FK hop off the child table. `None` when
1148/// `segs[0]` isn't an M2M field or any onward hop fails to resolve.
1149pub(crate) fn resolve_m2m_chain<T: Model>(path: &str) -> Option<(String, String, Vec<JoinHop>)> {
1150    let registered = crate::migrate::registered_models();
1151    let segs: Vec<&str> = path.split("__").filter(|s| !s.is_empty()).collect();
1152    let first = segs.first()?;
1153    let rel = T::M2M_RELATIONS.iter().find(|r| r.field_name == *first)?;
1154    let child_meta = registered.iter().find(|m| m.table == rel.target_table)?;
1155    let child_pk = child_meta.fields.iter().find(|c| c.primary_key)?;
1156    let mut onward = Vec::with_capacity(segs.len().saturating_sub(1));
1157    let mut current = rel.target_table;
1158    for seg in &segs[1..] {
1159        let meta = registered.iter().find(|m| m.table == current)?;
1160        let col = meta.fields.iter().find(|c| c.name == *seg)?;
1161        let tgt = col.fk_target.as_deref()?;
1162        let tmeta = registered.iter().find(|m| m.table == tgt)?;
1163        let pk = tmeta.fields.iter().find(|c| c.primary_key)?;
1164        onward.push(JoinHop {
1165            fk_col: (*seg).to_string(),
1166            child_table: tgt.to_string(),
1167            child_pk: pk.name.clone(),
1168            nullable: col.nullable,
1169        });
1170        current = tgt;
1171    }
1172    Some((rel.target_table.to_string(), child_pk.name.clone(), onward))
1173}
1174
1175/// Gap #111 — error returned when a typed terminal (`fetch` / `first`
1176/// / `get`) runs against a QuerySet that has `.only(...)` set. A
1177/// partial-column row can't satisfy `T`'s `FromRow` impl, so the
1178/// caller has to either drop the `.only(...)` (full SELECT, typed
1179/// rows back) or terminate via `.values(&[...])` (JSON rows with
1180/// just the requested columns). The message names the offending
1181/// terminal so the fix is one rename away.
1182fn only_with_typed_terminal_error(terminal: &'static str) -> sqlx::Error {
1183    sqlx::Error::Protocol(format!(
1184        "umbral::orm::{terminal}: cannot run a typed terminal on a QuerySet \
1185         with `.only(...)` set — a partial-column row can't hydrate `T` via \
1186         FromRow. Either drop `.only(...)` to fetch full typed rows, or \
1187         terminate via `.values(&[...])` to get JSON rows with just the \
1188         projected columns."
1189    ))
1190}
1191
1192fn resolve_pool<T: Model>(explicit: Option<DbPool>, op: crate::db::RouteOp) -> DbPool {
1193    if let Some(pool) = explicit {
1194        return pool;
1195    }
1196    // Route through the swappable router when the registry is up.
1197    if let Some(meta) = crate::migrate::model_meta_ref(T::NAME) {
1198        let ctx = crate::db::route_context::current();
1199        let r = crate::db::router::router();
1200        let alias = match op {
1201            crate::db::RouteOp::Read => r.db_for_read(meta, &ctx),
1202            crate::db::RouteOp::Write => r.db_for_write(meta, &ctx),
1203        };
1204        return crate::db::pool_for_dispatched(alias.as_str()).clone();
1205    }
1206    // Registry-less fallback (low-level tests): today's static behavior.
1207    if let Some(alias) = crate::migrate::model_alias(T::NAME) {
1208        return crate::db::pool_for_dispatched(&alias).clone();
1209    }
1210    crate::db::pool_dispatched().clone()
1211}
1212
1213/// Pin a QuerySet to an explicit pool, dispatching SQLite vs Postgres. Used by
1214/// the upsert paths (`get_or_create` / `update_or_create`) so their
1215/// existence-check reads run on the WRITE database — read-your-writes, so a
1216/// read/write-split router never probes a lagging replica and inserts a
1217/// duplicate (or reads a stale row back after the update).
1218fn pin_to_pool<T: Model>(qs: QuerySet<T>, pool: &DbPool) -> QuerySet<T> {
1219    match pool {
1220        DbPool::Sqlite(p) => qs.on(p),
1221        DbPool::Postgres(p) => qs.on_pg(p),
1222    }
1223}
1224
1225// GetError / TryForEachError moved to `errors`; re-exported above.
1226
1227/// Emit a one-shot advisory when a `right_join_related` is applied
1228/// against a SQLite pool.
1229///
1230/// RIGHT/FULL JOIN landed in SQLite 3.39 (June 2022); Postgres has
1231/// always supported it. The boot system check (`check.rs`) can't surface
1232/// this — it's synchronous, has no live pool, and whether a RIGHT join
1233/// is *reachable* is a runtime QuerySet fact rather than static model
1234/// metadata. So the spec's "boot warning" is realized here: the first
1235/// time a RIGHT join is built against a SQLite pool, we `tracing::warn!`
1236/// once per process. We do NOT probe the library version (that needs an
1237/// async round-trip the SQL builder doesn't have) — the precise gate is
1238/// the SQLite driver's own error at execute time on an engine < 3.39;
1239/// this warn is the early nudge, consistent with `check.rs`'s
1240/// `Severity::Warning` posture.
1241///
1242/// Postgres pools and the no-pool case (a pure `to_sql` build with no
1243/// app booted) are silent.
1244fn warn_right_join_on_sqlite() {
1245    use std::sync::Once;
1246    static ONCE: Once = Once::new();
1247    if matches!(
1248        crate::db::try_pool_dispatched(),
1249        Some(crate::db::DbPool::Sqlite(_))
1250    ) {
1251        ONCE.call_once(|| {
1252            tracing::warn!(
1253                "umbral::orm::right_join_related: RIGHT JOIN requires SQLite >= 3.39. \
1254                 If your SQLite is older the query will error at execute time; \
1255                 Postgres is unaffected. Prefer left_/inner_join_related on SQLite \
1256                 unless you've confirmed the engine version."
1257            );
1258        });
1259    }
1260}
1261
1262/// Terminal methods for every `QuerySet<T>` where `T: Model`.
1263///
1264/// Each terminal that materializes `T` carries a FromRow bound on the
1265/// method (not the impl block) — the conjunction of both backends'
1266/// FromRow impls. `#[derive(sqlx::FromRow)]` emits a generic-over-`R`
1267/// impl, so any user struct with standard field types satisfies both
1268/// bounds automatically.
1269impl<T: Model> QuerySet<T> {
1270    /// Render the SQL the QuerySet would execute, without running it.
1271    ///
1272    /// Returns the prepared statement with `?` placeholders for the
1273    /// bound values, exactly the string sqlx would send. Useful for
1274    /// `eprintln!`-style debugging and for tests that want to pin
1275    /// the rendered query without round-tripping through a pool.
1276    ///
1277    /// The bound values are intentionally not surfaced (sqlx's binder
1278    /// types aren't part of umbral's public surface); a `(sql, values)`
1279    /// accessor lands when EXPLAIN-style integration needs it.
1280    ///
1281    /// The rendered placeholder dialect is SQLite's (`?`). When the
1282    /// dispatched pool is Postgres the actual at-execute rendering
1283    /// uses `$1`-style placeholders; the `to_sql` debug surface
1284    /// continues to emit SQLite-style for stability across calls
1285    /// regardless of which pool is registered.
1286    pub fn to_sql(&self) -> String {
1287        let mut q = self.build_query_for("sqlite");
1288        self.apply_join_related(&mut q);
1289        self.apply_only_projection(&mut q);
1290        let (sql, _values) = q.build_sqlx(SqliteQueryBuilder);
1291        sql
1292    }
1293
1294    /// Render the QuerySet's SQL against the **Postgres** dialect,
1295    /// without running it. Companion to [`Self::to_sql`].
1296    ///
1297    /// The two render slightly different placeholder syntax (`?` for
1298    /// SQLite, `$1..$N` for Postgres) and any Postgres-specific
1299    /// operators like the array `@>` / `<@` / `&&` family only render
1300    /// correctly through this entry point — `to_sql`'s SQLite path
1301    /// leaves `$N` tokens in the template untouched. Use this when
1302    /// debugging a Postgres query or asserting on the rendered shape
1303    /// in tests.
1304    pub fn to_sql_pg(&self) -> String {
1305        let mut q = self.build_query_for("postgres");
1306        self.apply_join_related(&mut q);
1307        self.apply_only_projection(&mut q);
1308        let (sql, _values) = q.build_sqlx(PostgresQueryBuilder);
1309        sql
1310    }
1311
1312    /// Internal helper — when `.only(...)` was set, swap the SELECT
1313    /// list for just those columns. Shared by `to_sql` / `to_sql_pg`
1314    /// so the inspection surface stays in sync with what `values()`
1315    /// would emit. No-op when `only_cols` is `None`.
1316    fn apply_only_projection(&self, q: &mut sea_query::SelectStatement) {
1317        if let Some(cols) = &self.only_cols {
1318            q.clear_selects();
1319            for c in cols {
1320                q.column(Alias::new(c.as_str()));
1321            }
1322        }
1323    }
1324
1325    /// Internal helper — when `.join_related(name)` was set, wrap the
1326    /// current query as a subquery and build an outer SELECT that
1327    /// LEFT JOINs every requested related table (with child columns
1328    /// aliased as `<field>__<col>`). The subquery wrapper is the
1329    /// load-bearing trick: WHERE / ORDER BY / LIMIT predicates inside
1330    /// the inner query reference parent columns only — there's no
1331    /// JOIN in scope, so bare names like `id` resolve unambiguously
1332    /// to the parent. Without this wrapper SQLite raises
1333    /// "ambiguous column name: id" on any predicate sharing a column
1334    /// name with a JOIN'd table.
1335    ///
1336    /// No-op when `join_related` is empty. Unknown field names /
1337    /// unregistered related models / FK columns missing `fk_target`
1338    /// are silently skipped — the SQL just won't carry the JOIN and
1339    /// the caller notices when `ForeignKey::resolved()` stays empty.
1340    fn apply_join_related(&self, q: &mut sea_query::SelectStatement) {
1341        if self.join_related.is_empty() {
1342            return;
1343        }
1344        use sea_query::{Expr, Query};
1345        let registered = crate::migrate::registered_models();
1346
1347        // Inner-subquery column trim. When `.only(...)` is also set,
1348        // the outer SELECT only references a subset of parent
1349        // columns (plus the JOIN'd child columns it gets through
1350        // its alias). The inner subquery only needs to expose:
1351        //   - parent columns named in `.only(...)` (intersected
1352        //     with T::FIELDS so the JOIN aliases like
1353        //     `category__name` don't leak in here — those live on
1354        //     the JOIN'd table, not on the parent),
1355        //   - PLUS the FK columns each `.join_related(name)` needs
1356        //     for its `ON __p.<name> = <child>.<pk>` clause.
1357        // WHERE / ORDER BY inside the inner subquery can still
1358        // reference any parent column (SQL doesn't require an
1359        // ORDER BY column to be in the SELECT list), so we don't
1360        // need to promote those. Postgres often skips this prune
1361        // through subquery boundaries; SQLite usually doesn't —
1362        // either way trimming here is a measurable win on wide
1363        // tables (think 30-column Product on a busy hot path).
1364        if let Some(only) = &self.only_cols {
1365            let parent_field_names: std::collections::HashSet<&str> =
1366                T::FIELDS.iter().map(|f| f.name).collect();
1367            let mut needed: std::collections::HashSet<String> = only
1368                .iter()
1369                .filter(|c| parent_field_names.contains(c.as_str()))
1370                .cloned()
1371                .collect();
1372            for jr in &self.join_related {
1373                // Nested paths only need the FIRST hop's FK column at
1374                // the parent level; deeper hops join off the prior
1375                // level's alias, not the parent subquery.
1376                let join_field = jr.path.split("__").next().unwrap_or(jr.path.as_str());
1377                if parent_field_names.contains(join_field) {
1378                    needed.insert(join_field.to_string());
1379                }
1380            }
1381            if !needed.is_empty() {
1382                q.clear_selects();
1383                // Stable ordering so the SQL is deterministic
1384                // across runs (HashSet iteration is not).
1385                let mut ordered: Vec<String> = needed.into_iter().collect();
1386                ordered.sort();
1387                for col in &ordered {
1388                    q.column(Alias::new(col.as_str()));
1389                }
1390            }
1391        }
1392
1393        // Take ownership of the (possibly-trimmed) inner query and
1394        // re-mount it as the FROM clause of the new outer SELECT.
1395        let inner = std::mem::replace(q, Query::select().take());
1396        let parent_alias = Alias::new("__p");
1397        let mut outer = Query::select();
1398        outer.from_subquery(inner, parent_alias.clone());
1399        // Re-project the parent's full column set so the outer SELECT
1400        // exposes them unaliased — FromRow on `T` reads parent
1401        // columns by their bare names (`id`, `name`, ...). When
1402        // `.only(...)` later clears this list in
1403        // `apply_only_projection`, the inner-subquery trim above
1404        // means we still didn't pay for columns we ended up
1405        // dropping anyway.
1406        for f in T::FIELDS {
1407            outer.expr(Expr::col((parent_alias.clone(), Alias::new(f.name))));
1408        }
1409        // Set when any emitted hop is a RIGHT JOIN — drives the
1410        // once-per-process old-SQLite advisory after the emit loop.
1411        let mut emitted_right = false;
1412        for jr in &self.join_related {
1413            let field_name = &jr.path;
1414            // FK chain branch first. A (possibly nested) FK path splits
1415            // on `__` into ordered hops; each hop joins onto the prior
1416            // level's alias, and the DEEPEST hop's child columns are
1417            // aliased by the full dotted path so hydration can rebuild
1418            // the nested relation graph. The single-hop case is
1419            // byte-identical in child-column aliases to the pre-nesting
1420            // path (`<field>__<col>`); only the internal join alias
1421            // gains an `_h{idx}` suffix, which no test asserts.
1422            if let Some(hops) = resolve_join_hops::<T>(field_name) {
1423                let mut prev_alias = parent_alias.clone();
1424                let last = hops.len() - 1;
1425                // Cumulative dotted prefix per hop so EVERY level's own
1426                // columns ride along, aliased by its path-so-far. Hop 0
1427                // of `plugin__author` is `plugin`, hop 1 is
1428                // `plugin__author`. Selecting every level (not just the
1429                // leaf) is what lets hydration rebuild a FULL nested
1430                // object — the intermediate `plugin` row needs its own
1431                // `id`/`name` to deserialise into `ForeignKey<Plugin>`
1432                // before `author` nests inside it.
1433                let segs: Vec<&str> = field_name.split("__").collect();
1434                for (idx, hop) in hops.iter().enumerate() {
1435                    let hop_alias = Alias::new(format!("__j_{field_name}_h{idx}"));
1436                    // Last hop: explicit request, else infer from THIS
1437                    // hop's nullability. Intermediate hops always infer
1438                    // per-hop (an INNER can nest inside an outer
1439                    // LEFT etc.); an explicit kind only pins the leaf.
1440                    let kind = if idx == last {
1441                        jr.kind.unwrap_or(if hop.nullable {
1442                            JoinKind::Left
1443                        } else {
1444                            JoinKind::Inner
1445                        })
1446                    } else if hop.nullable {
1447                        JoinKind::Left
1448                    } else {
1449                        JoinKind::Inner
1450                    };
1451                    emitted_right |= kind == JoinKind::Right;
1452                    outer.join_as(
1453                        kind.sea(),
1454                        crate::db::router::schema_qualified_table(hop.child_table.as_str()),
1455                        hop_alias.clone(),
1456                        Expr::col((prev_alias.clone(), Alias::new(hop.fk_col.as_str())))
1457                            .equals((hop_alias.clone(), Alias::new(hop.child_pk.as_str()))),
1458                    );
1459                    if let Some(meta) = registered.iter().find(|m| m.table == hop.child_table) {
1460                        // Cumulative dotted prefix for this hop's columns.
1461                        let prefix = segs[..=idx].join("__");
1462                        for col in &meta.fields {
1463                            let alias = format!("{}__{}", prefix, col.name);
1464                            outer.expr_as(
1465                                Expr::col((hop_alias.clone(), Alias::new(col.name.as_str()))),
1466                                Alias::new(alias),
1467                            );
1468                        }
1469                    }
1470                    prev_alias = hop_alias;
1471                }
1472                continue;
1473            }
1474
1475            // M2M branch (post-#113). Emit the double LEFT JOIN
1476            // through the junction table:
1477            //   LEFT JOIN <junction> AS __jm_<field>
1478            //     ON __p.<parent_pk> = __jm_<field>.parent_id
1479            //   LEFT JOIN <child_table> AS __j_<field>
1480            //     ON __jm_<field>.child_id = __j_<field>.<child_pk>
1481            // Aliased child cols use the same `<field>__<col>` shape
1482            // as the FK branch so the decode helper can be reused.
1483            // The M2M field is the FIRST segment of the path; a nested
1484            // path like `"tags__category"` passes THROUGH the M2M hop
1485            // and continues with an onward FK chain off the child.
1486            let m2m_seg = field_name.split("__").next().unwrap_or(field_name.as_str());
1487            if let Some(m2m_rel) = T::M2M_RELATIONS.iter().find(|r| r.field_name == m2m_seg)
1488                && let Some(parent_pk) = T::FIELDS.iter().find(|f| f.primary_key)
1489                && let Some(child_meta) =
1490                    registered.iter().find(|m| m.table == m2m_rel.target_table)
1491                && let Some(child_pk) = child_meta.fields.iter().find(|c| c.primary_key)
1492            {
1493                // Junction table + aliases key off the M2M field name
1494                // (segs[0]), NOT the full dotted path.
1495                let junction_table = format!("{}_{}", T::TABLE, m2m_seg);
1496                let junction_alias = Alias::new(format!("__jm_{m2m_seg}"));
1497                let child_alias = Alias::new(format!("__j_{m2m_seg}"));
1498                // The junction hop stays LEFT so a parent with zero
1499                // junction rows isn't dropped by the join to the
1500                // junction table itself — the CHILD hop's kind is what
1501                // decides drop/keep. Plain `join_related` (kind None)
1502                // leaves the child LEFT too, preserving the shipped
1503                // double-LEFT-JOIN M2M behavior (a tag-less parent
1504                // survives with an empty M2M slot). An explicit
1505                // inner_join_related drops parents whose relation is
1506                // absent: the junction-LEFT miss yields a NULL child_id,
1507                // then the child INNER on NULL has no match -> the parent
1508                // is dropped, which is the INNER contract.
1509                let child_kind = jr.kind.unwrap_or(JoinKind::Left);
1510                emitted_right |= child_kind == JoinKind::Right;
1511                outer.join_as(
1512                    sea_query::JoinType::LeftJoin,
1513                    crate::db::router::schema_qualified_table(&junction_table),
1514                    junction_alias.clone(),
1515                    Expr::col((parent_alias.clone(), Alias::new(parent_pk.name)))
1516                        .equals((junction_alias.clone(), Alias::new("parent_id"))),
1517                );
1518                outer.join_as(
1519                    child_kind.sea(),
1520                    crate::db::router::schema_qualified_table(m2m_rel.target_table),
1521                    child_alias.clone(),
1522                    Expr::col((junction_alias.clone(), Alias::new("child_id")))
1523                        .equals((child_alias.clone(), Alias::new(child_pk.name.as_str()))),
1524                );
1525                // Child columns aliased by the M2M field name so the
1526                // M2M decode path (`<m2m_field>__<col>`) reads them.
1527                for col in &child_meta.fields {
1528                    let alias = format!("{}__{}", m2m_seg, col.name);
1529                    outer.expr_as(
1530                        Expr::col((child_alias.clone(), Alias::new(col.name.as_str()))),
1531                        Alias::new(alias),
1532                    );
1533                }
1534                // Onward FK chain off the child (segs[1..]). Each hop
1535                // joins onto the prior level's alias and aliases its
1536                // columns by the cumulative dotted path
1537                // (`tags__category__name`) so the M2M decode path can
1538                // nest the onward object into each child row.
1539                if let Some((_child_table, _child_pk, onward)) = resolve_m2m_chain::<T>(field_name)
1540                {
1541                    let segs: Vec<&str> = field_name.split("__").collect();
1542                    let mut prev_alias = child_alias.clone();
1543                    for (i, hop) in onward.iter().enumerate() {
1544                        // segs index for this hop: segs[0] is the M2M
1545                        // field, segs[1] is onward[0], etc.
1546                        let seg_idx = i + 1;
1547                        let hop_alias = Alias::new(format!("__j_{m2m_seg}_o{i}"));
1548                        let kind = if hop.nullable {
1549                            JoinKind::Left
1550                        } else {
1551                            JoinKind::Inner
1552                        };
1553                        outer.join_as(
1554                            kind.sea(),
1555                            crate::db::router::schema_qualified_table(hop.child_table.as_str()),
1556                            hop_alias.clone(),
1557                            Expr::col((prev_alias.clone(), Alias::new(hop.fk_col.as_str())))
1558                                .equals((hop_alias.clone(), Alias::new(hop.child_pk.as_str()))),
1559                        );
1560                        if let Some(meta) = registered.iter().find(|m| m.table == hop.child_table) {
1561                            let prefix = segs[..=seg_idx].join("__");
1562                            for col in &meta.fields {
1563                                let alias = format!("{}__{}", prefix, col.name);
1564                                outer.expr_as(
1565                                    Expr::col((hop_alias.clone(), Alias::new(col.name.as_str()))),
1566                                    Alias::new(alias),
1567                                );
1568                            }
1569                        }
1570                        prev_alias = hop_alias;
1571                    }
1572                }
1573                continue;
1574            }
1575        }
1576        // A RIGHT JOIN against SQLite needs >= 3.39; warn once per
1577        // process. Postgres / no-pool builds stay silent.
1578        if emitted_right {
1579            warn_right_join_on_sqlite();
1580        }
1581        *q = outer;
1582    }
1583
1584    /// Run the SELECT and return every matching row.
1585    ///
1586    /// If `.select_related(name)` was called, a follow-up batch query
1587    /// populates `ForeignKey<U>.resolved` for each named field before
1588    /// the rows are returned.
1589    pub async fn fetch(self) -> Result<Vec<T>, sqlx::Error>
1590    where
1591        T: for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
1592            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
1593            + HydrateRelated,
1594    {
1595        if self.only_cols.is_some() {
1596            return Err(only_with_typed_terminal_error("fetch"));
1597        }
1598        let sr_fields = self.select_related.clone();
1599        let prefetch_fields = self.prefetch_related.clone();
1600        let join_reqs = self.join_related.clone();
1601        let join_fields: Vec<String> = join_reqs.iter().map(|j| j.path.clone()).collect();
1602        // Validate join_related field names up front so a typo or an
1603        // M2M field name doesn't silently no-op (it used to render a
1604        // SELECT with no JOIN). Pre-#42 the failure mode was
1605        // `ForeignKey::resolved()` stays None and the caller debugs
1606        // the wrong thing. Now they get a typed error.
1607        validate_join_related_fields::<T>(&join_fields)?;
1608        // The turbofish on `query_as_with::<DB, _, _>` is load-bearing:
1609        // with both `sqlx-sqlite` and `sqlx-postgres` features on
1610        // sea-query-binder, `SqlxValues` implements `IntoArguments` for
1611        // both backends, so the compiler can't infer DB from the values
1612        // alone. Naming DB explicitly pins which `FromRow` bound is
1613        // checked.
1614        // Split join_fields into FK vs M2M groups up front. The
1615        // M2M branch needs parent dedup (one parent row per JOIN
1616        // combo would surface duplicate Ts to the caller); the FK
1617        // branch is one-to-one with rows.
1618        // A path whose FIRST segment is an M2M field routes to the M2M
1619        // group even when it continues with an onward FK chain
1620        // (`"tags__category"`): the junction double-join + parent dedup
1621        // live on the M2M side, and the onward FK nests into each child.
1622        let (m2m_join_fields, fk_join_fields): (Vec<String>, Vec<String>) =
1623            join_fields.iter().cloned().partition(|f| {
1624                let first = f.split("__").next().unwrap_or(f.as_str());
1625                T::M2M_RELATIONS.iter().any(|r| r.field_name == first)
1626            });
1627        let has_m2m_join = !m2m_join_fields.is_empty();
1628
1629        let mut rows = match resolve_pool::<T>(self.explicit_pool.clone(), crate::db::RouteOp::Read)
1630        {
1631            DbPool::Sqlite(pool) => {
1632                let mut q = self.build_query_for("sqlite");
1633                self.apply_join_related(&mut q);
1634                let (sql, values) = q.build_sqlx(SqliteQueryBuilder);
1635                if join_fields.is_empty() {
1636                    sqlx::query_as_with::<sqlx::Sqlite, T, _>(&sql, values)
1637                        .fetch_all(&pool)
1638                        .await?
1639                } else if !has_m2m_join {
1640                    // FK-only JOIN path: one row in → one T out.
1641                    let raw_rows = sqlx::query_with::<sqlx::Sqlite, _>(&sql, values)
1642                        .fetch_all(&pool)
1643                        .await?;
1644                    let mut typed = Vec::with_capacity(raw_rows.len());
1645                    for row in &raw_rows {
1646                        let mut t = <T as sqlx::FromRow<_>>::from_row(row)?;
1647                        backend_sqlite::hydrate_joined_rels::<T>(&mut t, row, &fk_join_fields)?;
1648                        typed.push(t);
1649                    }
1650                    typed
1651                } else {
1652                    // Mixed (FK + M2M) or pure M2M JOIN path:
1653                    // dedup parents, collect M2M children per
1654                    // (parent_pk, field).
1655                    let raw_rows = sqlx::query_with::<sqlx::Sqlite, _>(&sql, values)
1656                        .fetch_all(&pool)
1657                        .await?;
1658                    dedup_decode_sqlite::<T>(&raw_rows, &fk_join_fields, &m2m_join_fields)?
1659                }
1660            }
1661            DbPool::Postgres(pool) => {
1662                let mut q = self.build_query_for("postgres");
1663                self.apply_join_related(&mut q);
1664                let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
1665                if join_fields.is_empty() {
1666                    sqlx::query_as_with::<sqlx::Postgres, T, _>(&sql, values)
1667                        .fetch_all(&pool)
1668                        .await?
1669                } else if !has_m2m_join {
1670                    let raw_rows = sqlx::query_with::<sqlx::Postgres, _>(&sql, values)
1671                        .fetch_all(&pool)
1672                        .await?;
1673                    let mut typed = Vec::with_capacity(raw_rows.len());
1674                    for row in &raw_rows {
1675                        let mut t = <T as sqlx::FromRow<_>>::from_row(row)?;
1676                        backend_pg::hydrate_joined_rels::<T>(&mut t, row, &fk_join_fields)?;
1677                        typed.push(t);
1678                    }
1679                    typed
1680                } else {
1681                    let raw_rows = sqlx::query_with::<sqlx::Postgres, _>(&sql, values)
1682                        .fetch_all(&pool)
1683                        .await?;
1684                    dedup_decode_pg::<T>(&raw_rows, &fk_join_fields, &m2m_join_fields)?
1685                }
1686            }
1687        };
1688        // BUG-16 step 2: wire each row's PK into its `M2M<U>` slots so
1689        // `add`/`remove`/`clear` know which parent they belong to.
1690        // No-op for models with no M2M fields.
1691        for r in &mut rows {
1692            r.set_m2m_parent_ids();
1693        }
1694        if !sr_fields.is_empty() {
1695            let pool = resolve_pool::<T>(self.explicit_pool.clone(), crate::db::RouteOp::Read);
1696            hydrate_select_related::<T>(&mut rows, &sr_fields, &pool).await?;
1697        }
1698        if !prefetch_fields.is_empty() {
1699            let pool = resolve_pool::<T>(self.explicit_pool.clone(), crate::db::RouteOp::Read);
1700            hydrate_prefetch_related::<T>(&mut rows, &prefetch_fields, &pool).await?;
1701        }
1702        Ok(rows)
1703    }
1704
1705    /// Feature 29 Phase 1 — chunked streaming via a callback.
1706    ///
1707    /// Runs the SELECT in pages of `chunk_size` rows and invokes
1708    /// `callback` once per row. Memory bound = `chunk_size *
1709    /// sizeof::<T>` instead of the full row count `fetch()` would
1710    /// buffer, so this is the right shape for million-row exports,
1711    /// migrations, and batch transforms.
1712    ///
1713    /// Deliberately NOT named `iterator()` — that name suggests a
1714    /// `Stream`-shaped return value, which would force a
1715    /// `futures-util` dep. The callback shape is idiomatic Rust,
1716    /// requires no new crates, and ships the same memory bound. A
1717    /// future `iterator()` returning `BoxStream<T>` can land later
1718    /// once `futures-util` is in the workspace for some other reason
1719    /// (likely SSE / WebSockets).
1720    ///
1721    /// Error contract: the callback may return any error type `E`.
1722    /// SQL failures become `TryForEachError::Sqlx`; callback errors
1723    /// become `TryForEachError::Callback(e)`. The first error stops
1724    /// the walk — subsequent rows are not fetched.
1725    ///
1726    /// Caveats: pages are stable only if the result set isn't being
1727    /// mutated concurrently. For consistent-snapshot iteration over
1728    /// a live table, wrap the call in a serialised-or-repeatable-read
1729    /// transaction. `select_related` and `prefetch_related` hooks
1730    /// are NOT applied on each row — `try_for_each` is intentionally
1731    /// the "raw column data, one row at a time" terminal.
1732    pub async fn try_for_each<F, E>(
1733        self,
1734        chunk_size: usize,
1735        mut callback: F,
1736    ) -> Result<(), TryForEachError<E>>
1737    where
1738        T: for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
1739            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
1740            + HydrateRelated,
1741        F: FnMut(T) -> Result<(), E>,
1742    {
1743        let chunk_size = chunk_size.max(1);
1744        let pool = resolve_pool::<T>(self.explicit_pool.clone(), crate::db::RouteOp::Read);
1745        let mut offset: u64 = 0;
1746        loop {
1747            let mut rows: Vec<T> = match &pool {
1748                DbPool::Sqlite(pg) => {
1749                    let mut q = self.build_query_for("sqlite");
1750                    q.limit(chunk_size as u64).offset(offset);
1751                    let (sql, values) = q.build_sqlx(SqliteQueryBuilder);
1752                    sqlx::query_as_with::<sqlx::Sqlite, T, _>(&sql, values)
1753                        .fetch_all(pg)
1754                        .await
1755                        .map_err(TryForEachError::Sqlx)?
1756                }
1757                DbPool::Postgres(pg) => {
1758                    let mut q = self.build_query_for("postgres");
1759                    q.limit(chunk_size as u64).offset(offset);
1760                    let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
1761                    sqlx::query_as_with::<sqlx::Postgres, T, _>(&sql, values)
1762                        .fetch_all(pg)
1763                        .await
1764                        .map_err(TryForEachError::Sqlx)?
1765                }
1766            };
1767            let fetched = rows.len();
1768            if fetched == 0 {
1769                break;
1770            }
1771            for row in rows.drain(..) {
1772                callback(row).map_err(TryForEachError::Callback)?;
1773            }
1774            if fetched < chunk_size {
1775                break;
1776            }
1777            offset += fetched as u64;
1778        }
1779        Ok(())
1780    }
1781
1782    /// Run the SELECT with LIMIT 1 and return the first row, if any.
1783    pub async fn first(mut self) -> Result<Option<T>, sqlx::Error>
1784    where
1785        T: for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
1786            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
1787            + HydrateRelated,
1788    {
1789        if self.only_cols.is_some() {
1790            return Err(only_with_typed_terminal_error("first"));
1791        }
1792        // Review #2: delegate to `fetch()` with LIMIT 1 so select_related,
1793        // prefetch_related, AND join_related are all hydrated. `first()`
1794        // used to build a plain query and hydrate only select_related, so
1795        // `.prefetch_related("tags").first()` returned an unprefetched row
1796        // and `.join_related("author").first()` an unresolved join — both
1797        // silently. (For a to-many `join_related`, LIMIT 1 truncates the
1798        // joined children the same way `fetch()` does with `.limit(1)`;
1799        // prefer `prefetch_related` there.)
1800        self.query.limit(1);
1801        let rows = self.fetch().await?;
1802        Ok(rows.into_iter().next())
1803    }
1804
1805    /// Return the row with the smallest value in `col_name`. Sugar
1806    /// for `order_by(col.asc()).first()`. The `earliest('created_at')`
1807    /// terminal.
1808    ///
1809    /// Takes a `&'static str` column name (same shape as
1810    /// `select_related`) so the call site stays terse:
1811    /// `.earliest("created_at")` reads naturally without spelling out
1812    /// `.asc()`.
1813    pub async fn earliest(self, col_name: &'static str) -> Result<Option<T>, sqlx::Error>
1814    where
1815        T: for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
1816            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
1817            + HydrateRelated,
1818    {
1819        self.order_by(OrderExpr::new(col_name, false)).first().await
1820    }
1821
1822    /// Return the row with the largest value in `col_name`. Sugar
1823    /// for `order_by(col.desc()).first()`. The `latest('created_at')`
1824    /// terminal.
1825    pub async fn latest(self, col_name: &'static str) -> Result<Option<T>, sqlx::Error>
1826    where
1827        T: for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
1828            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
1829            + HydrateRelated,
1830    {
1831        self.order_by(OrderExpr::new(col_name, true)).first().await
1832    }
1833
1834    /// Fetch many rows by their primary keys and return a
1835    /// `HashMap<T::PrimaryKey, T>` keyed by PK. The everyday companion to
1836    /// a cached list of ids — `User::objects().in_bulk(user_ids)` gives
1837    /// you direct lookup access without a second `.iter().find(...)` pass
1838    /// per id.
1839    ///
1840    /// Missing ids are silently absent from the map; callers that
1841    /// need the existence check can compare `map.len()` to
1842    /// `pks.len()`. Empty input is a no-op (returns the empty map).
1843    ///
1844    /// PK-agnostic (PK lift — was `Vec<i64>` / `HashMap<i64, T>`): the key
1845    /// is the model's `PrimaryKey` type, so i64-, String/slug-, and
1846    /// Uuid-keyed models all work. The map key requires `Hash + Eq`,
1847    /// which every standard PK type (integers, `String`, `Uuid`) satisfies.
1848    pub async fn in_bulk(
1849        self,
1850        pks: Vec<T::PrimaryKey>,
1851    ) -> Result<HashMap<T::PrimaryKey, T>, sqlx::Error>
1852    where
1853        T: for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
1854            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
1855            + HydrateRelated,
1856        T::PrimaryKey: std::hash::Hash + Eq,
1857    {
1858        if pks.is_empty() {
1859            return Ok(HashMap::new());
1860        }
1861        let pk_name = pk_field::<T>().map(|f| f.name).unwrap_or("id");
1862        // Each PK converts to a `sea_query::Value` (the `PrimaryKey` trait
1863        // bounds `Into<sea_query::Value>`); wrap as a `SimpleExpr` for the
1864        // IN-list so any PK shape binds correctly.
1865        let pk_pred: Predicate<T> = Predicate::new(
1866            Expr::col(Alias::new(pk_name)).is_in(
1867                pks.into_iter()
1868                    .map(|p| sea_query::SimpleExpr::Value(p.into())),
1869            ),
1870        );
1871        let rows = self.filter(pk_pred).fetch().await?;
1872        let mut out: HashMap<T::PrimaryKey, T> = HashMap::with_capacity(rows.len());
1873        for row in rows {
1874            out.insert(row.primary_key(), row);
1875        }
1876        Ok(out)
1877    }
1878
1879    /// Return the database's execution plan for this query as a
1880    /// plain-text string. Doesn't run the underlying query — just
1881    /// asks the DB how it would be executed.
1882    ///
1883    /// Backend dispatch:
1884    ///
1885    /// - SQLite: `EXPLAIN QUERY PLAN <sql>` — returns the planner's
1886    ///   nested loop hierarchy, one row per access step.
1887    /// - Postgres: `EXPLAIN <sql>` — returns the default text plan.
1888    ///   For machine-readable output use raw sqlx with
1889    ///   `EXPLAIN (FORMAT JSON)`; the framework defaults to text
1890    ///   because most callers want eyeball-able output.
1891    ///
1892    /// Lines are joined with newlines. The returned string is what a
1893    /// developer would paste into a debugger or a perf-review issue.
1894    pub async fn explain(self) -> Result<String, sqlx::Error> {
1895        // Annotations are part of the built query (they render inside
1896        // build_query_for), so the plan below includes them — but a
1897        // poisoned annotation (unknown relation) must fail loudly
1898        // here, not silently vanish from the plan.
1899        self.check_annotations()?;
1900        let pool = resolve_pool::<T>(self.explicit_pool.clone(), crate::db::RouteOp::Read);
1901        let backend = pool.backend_name();
1902        let q = self.build_query_for(backend);
1903        match pool {
1904            DbPool::Sqlite(pool) => {
1905                let (sql, vals) = q.build_sqlx(SqliteQueryBuilder);
1906                let explain_sql = format!("EXPLAIN QUERY PLAN {sql}");
1907                let rows = sqlx::query_with::<sqlx::Sqlite, _>(&explain_sql, vals)
1908                    .fetch_all(&pool)
1909                    .await?;
1910                let mut out = String::new();
1911                for row in &rows {
1912                    use sqlx::Row;
1913                    // SQLite returns: id, parent, notused, detail.
1914                    // The `detail` column is the human-readable step.
1915                    let detail: String = row.try_get("detail")?;
1916                    if !out.is_empty() {
1917                        out.push('\n');
1918                    }
1919                    out.push_str(&detail);
1920                }
1921                Ok(out)
1922            }
1923            DbPool::Postgres(pool) => {
1924                let (sql, vals) = q.build_sqlx(PostgresQueryBuilder);
1925                let explain_sql = format!("EXPLAIN {sql}");
1926                let rows = sqlx::query_with::<sqlx::Postgres, _>(&explain_sql, vals)
1927                    .fetch_all(&pool)
1928                    .await?;
1929                let mut out = String::new();
1930                for row in &rows {
1931                    use sqlx::Row;
1932                    // Postgres EXPLAIN returns one column named
1933                    // "QUERY PLAN", one row per line of the plan.
1934                    let line: String = row.try_get("QUERY PLAN")?;
1935                    if !out.is_empty() {
1936                        out.push('\n');
1937                    }
1938                    out.push_str(&line);
1939                }
1940                Ok(out)
1941            }
1942        }
1943    }
1944
1945    /// Run `SELECT COUNT(*)` against the same FROM + WHERE.
1946    ///
1947    /// Reshapes the query rather than wrapping the existing SELECT: the
1948    /// projection becomes `COUNT(*)` and LIMIT/OFFSET drop away. ORDER
1949    /// BY is harmless on a scalar aggregate and is left in place. The
1950    /// row type is `(i64,)` so the FromRow constraint comes from sqlx's
1951    /// tuple impl rather than the user struct — count() doesn't need
1952    /// T's FromRow bounds.
1953    pub async fn count(self) -> Result<i64, sqlx::Error> {
1954        let pool = resolve_pool::<T>(self.explicit_pool.clone(), crate::db::RouteOp::Read);
1955        let backend = pool.backend_name();
1956        // Build the dialect-appropriate filtered query first, then
1957        // rebuild as COUNT. Doing it in this order keeps the predicate
1958        // walk pluggable per backend without duplicating the COUNT
1959        // rewrite logic across branches.
1960        let mut rebuilt = self.build_query_for(backend);
1961        rebuilt.clear_selects();
1962        // Postgres rejects `"*"` as a quoted identifier (SQLite tolerates
1963        // it); use sea_query's Asterisk token which renders bare `*`
1964        // on both backends.
1965        rebuilt.expr(Func::count(Expr::col(sea_query::Asterisk)));
1966        rebuilt.reset_limit();
1967        rebuilt.reset_offset();
1968
1969        match pool {
1970            DbPool::Sqlite(pool) => {
1971                let (sql, values) = rebuilt.build_sqlx(SqliteQueryBuilder);
1972                let (n,): (i64,) = sqlx::query_as_with::<sqlx::Sqlite, (i64,), _>(&sql, values)
1973                    .fetch_one(&pool)
1974                    .await?;
1975                Ok(n)
1976            }
1977            DbPool::Postgres(pool) => {
1978                let (sql, values) = rebuilt.build_sqlx(PostgresQueryBuilder);
1979                let (n,): (i64,) = sqlx::query_as_with::<sqlx::Postgres, (i64,), _>(&sql, values)
1980                    .fetch_one(&pool)
1981                    .await?;
1982                Ok(n)
1983            }
1984        }
1985    }
1986
1987    /// Return whether any row matches.
1988    ///
1989    /// M1 keeps the simple form: add LIMIT 1, fetch, check non-empty.
1990    /// A later milestone may swap the projection for `SELECT 1` to
1991    /// skip column materialisation.
1992    pub async fn exists(self) -> Result<bool, sqlx::Error>
1993    where
1994        T: for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
1995            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
1996            + HydrateRelated,
1997    {
1998        let rows = self.limit(1).fetch().await?;
1999        Ok(!rows.is_empty())
2000    }
2001
2002    /// `.get()` — the exactly-one terminal.
2003    ///
2004    /// Returns `Ok(row)` when the filter chain matches exactly one
2005    /// row. The two not-exactly-one cases each get their own
2006    /// `GetError` variant so the caller can branch deliberately:
2007    ///
2008    /// - [`GetError::NotFound`] — zero rows matched. The right
2009    ///   choice for "fetch the row this user just clicked on; 404
2010    ///   if it's gone."
2011    /// - [`GetError::MultipleObjectsReturned`] — more than one row
2012    ///   matched. The right choice for filters that should be
2013    ///   uniquely-keyed (e.g. `.filter(user::EMAIL.eq("..."))`
2014    ///   when email has a UNIQUE constraint); a result of 2+ is a
2015    ///   data-integrity bug worth crashing on.
2016    /// - The underlying sqlx error wraps as [`GetError::Sqlx`].
2017    ///
2018    /// Internally this issues `SELECT ... LIMIT 2` — the cheapest
2019    /// way to distinguish "one row" from "many." The second row, if
2020    /// it exists, isn't materialised beyond the bare FromRow call.
2021    ///
2022    /// ```ignore
2023    /// match Post::objects().filter(post::ID.eq(42)).get().await {
2024    ///     Ok(p)                                            => /* render */,
2025    ///     Err(GetError::NotFound)                          => /* 404 */,
2026    ///     Err(GetError::MultipleObjectsReturned)           => unreachable!("ID is unique"),
2027    ///     Err(GetError::Sqlx(e))                           => /* 500 */,
2028    /// }
2029    /// ```
2030    pub async fn get(self) -> Result<T, GetError>
2031    where
2032        T: for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
2033            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
2034            + HydrateRelated,
2035    {
2036        let mut rows = self.limit(2).fetch().await.map_err(GetError::Sqlx)?;
2037        match rows.len() {
2038            0 => Err(GetError::NotFound),
2039            1 => Ok(rows.pop().unwrap()),
2040            _ => Err(GetError::MultipleObjectsReturned),
2041        }
2042    }
2043
2044    // =====================================================================
2045    // Postgres-only terminals (Phase 4.1).
2046    //
2047    // Models with Postgres-only field types (`Vec<T>` arrays, the future
2048    // Hstore / CIDR / FullTextSearch types) can't satisfy the dual
2049    // FromRow bound on `fetch` / `first` / `count` / `exists`. These
2050    // `_pg` variants bound on `FromRow<PgRow>` alone, take the pool as
2051    // an argument, and skip the dispatch — the call site explicitly
2052    // says "this model is Postgres-only."
2053    //
2054    // For models with portable fields, the existing `fetch` etc. stay
2055    // the recommended call: they pick up the ambient pool and route
2056    // through `.on(&pool)` / `.on_pg(&pool)` overrides exactly as
2057    // Phase 2.5 documented.
2058    // =====================================================================
2059
2060    // =====================================================================
2061    // Write terminals — DELETE and UPDATE.
2062    //
2063    // Both apply the accumulated filter predicates as the WHERE clause,
2064    // dispatch to the resolved pool's backend, and return the affected-
2065    // rows count from sqlx. No row materialisation — DELETE is keyless,
2066    // and UPDATE doesn't do a RETURNING read-back at v1 (use
2067    // `.filter(...).fetch()` after a write if you need the updated
2068    // rows back).
2069    //
2070    // **Without a `.filter(...)`, both terminals affect every row in
2071    // the table.** That mirrors raw SQL semantics; the type system
2072    // can't distinguish "I forgot the filter" from "I really meant to
2073    // truncate." Users protecting against accidental full-table writes
2074    // wrap their callers or assert a row count via `.count()` first.
2075    // =====================================================================
2076
2077    /// Project the query to only the named columns, returning a
2078    /// vector of `serde_json::Value::Object` rows instead of typed
2079    /// `T` instances. The columns-projection terminal: `values('id', 'title')`.
2080    ///
2081    /// Use when a list view only needs a few fields — skipping the
2082    /// 50KB body BLOB on every Post saves both memory and the
2083    /// FromRow hydration overhead. Each returned `Value` is an
2084    /// object keyed by the requested column names, with values
2085    /// typed per the column's declared SqlType (integers stay
2086    /// integers, booleans stay booleans, dates render as ISO
2087    /// strings).
2088    ///
2089    /// Unknown column names fail loudly with
2090    /// `sqlx::Error::Protocol` naming the offending column.
2091    /// Composes with `filter`, `exclude`, `order_by`, `limit`,
2092    /// `offset` exactly the way the typed terminals do.
2093    ///
2094    /// ```rust,ignore
2095    /// let rows = Post::objects()
2096    ///     .filter(post::PUBLISHED.eq(true))
2097    ///     .order_by(post::ID.desc())
2098    ///     .values(&["id", "title"])
2099    ///     .await?;
2100    /// // [ { "id": 3, "title": "c" }, ... ]
2101    /// ```
2102    pub async fn values(self, columns: &[&str]) -> Result<Vec<JsonValue>, sqlx::Error> {
2103        // Gap #46 follow-up: if any name uses `__` traversal
2104        // (`author__id`), route to the JOIN-aware path that builds
2105        // nested per-relation JSON objects. The unbranched path
2106        // below stays byte-for-byte identical for the common
2107        // parent-cols-only case.
2108        if columns.iter().any(|c| c.contains("__")) {
2109            return self.values_with_traversal(columns).await;
2110        }
2111        let meta = crate::migrate::ModelMeta::for_::<T>();
2112        // Resolve every requested name against the model's metadata
2113        // up front so an unknown column errors before any SQL runs.
2114        let mut chosen: Vec<&crate::migrate::Column> = Vec::with_capacity(columns.len());
2115        for name in columns {
2116            let col = meta
2117                .fields
2118                .iter()
2119                .find(|c| c.name == *name)
2120                .ok_or_else(|| {
2121                    sqlx::Error::Protocol(format!(
2122                        "umbral::orm::values: unknown column `{}` on model `{}`",
2123                        name,
2124                        T::NAME
2125                    ))
2126                })?;
2127            chosen.push(col);
2128        }
2129        let pool = resolve_pool::<T>(self.explicit_pool.clone(), crate::db::RouteOp::Read);
2130        let backend = pool.backend_name();
2131        // Build the base query (predicates + ORDER BY) then swap its
2132        // SELECT list for only the requested columns.
2133        let mut q = self.build_query_for(backend);
2134        q.clear_selects();
2135        for col in &chosen {
2136            q.column(Alias::new(col.name.as_str()));
2137        }
2138        match pool {
2139            DbPool::Sqlite(pool) => {
2140                let (sql, vals) = q.build_sqlx(SqliteQueryBuilder);
2141                let rows = sqlx::query_with::<sqlx::Sqlite, _>(&sql, vals)
2142                    .fetch_all(&pool)
2143                    .await?;
2144                let mut out: Vec<JsonValue> = Vec::with_capacity(rows.len());
2145                for row in &rows {
2146                    let mut obj = serde_json::Map::with_capacity(chosen.len());
2147                    for col in &chosen {
2148                        let v = crate::orm::dynamic::decode_to_json(row, col)?;
2149                        obj.insert(col.name.clone(), v);
2150                    }
2151                    out.push(JsonValue::Object(obj));
2152                }
2153                Ok(out)
2154            }
2155            DbPool::Postgres(pool) => {
2156                let (sql, vals) = q.build_sqlx(PostgresQueryBuilder);
2157                let rows = sqlx::query_with::<sqlx::Postgres, _>(&sql, vals)
2158                    .fetch_all(&pool)
2159                    .await?;
2160                let mut out: Vec<JsonValue> = Vec::with_capacity(rows.len());
2161                for row in &rows {
2162                    let mut obj = serde_json::Map::with_capacity(chosen.len());
2163                    for col in &chosen {
2164                        let v = crate::orm::dynamic::decode_pg_to_json(row, col)?;
2165                        obj.insert(col.name.clone(), v);
2166                    }
2167                    out.push(JsonValue::Object(obj));
2168                }
2169                Ok(out)
2170            }
2171        }
2172    }
2173
2174    /// `.values("author__name")`-style traversal. One-hop only at
2175    /// v1 (`a__b`, not `a__b__c` — that fails loudly so the user
2176    /// doesn't get a silent partial result). Emits one LEFT JOIN
2177    /// per distinct relation referenced, aliases child columns as
2178    /// `<rel>__<col>`, and returns each row as a nested JSON
2179    /// object: `{id, title, author: {id, name}, editor: {id}}`.
2180    ///
2181    /// A LEFT JOIN miss (nullable FK pointing at nothing) maps the
2182    /// whole relation key to `Value::Null` rather than a nested
2183    /// object full of nulls — caller code that branches on
2184    /// `obj["author"].is_null()` works naturally.
2185    ///
2186    /// Validates every name up front so a typo errors before any
2187    /// SQL runs. Unknown parent col / non-FK relation name /
2188    /// unknown child col / deeper-than-one-hop path all surface
2189    /// distinct messages.
2190    async fn values_with_traversal(self, columns: &[&str]) -> Result<Vec<JsonValue>, sqlx::Error> {
2191        use sea_query::{Expr, Query};
2192        let meta = crate::migrate::ModelMeta::for_::<T>();
2193        let registered = crate::migrate::registered_models();
2194
2195        // Split each column name into (relation, child_col) or
2196        // (None, parent_col). Reject paths with more than one `__`
2197        // hop — nested traversal across two relation layers is a
2198        // separate piece of work (it'd need to chain JOINs and
2199        // build doubly-nested JSON).
2200        let mut parent_cols: Vec<String> = Vec::new();
2201        let mut per_rel: std::collections::BTreeMap<String, Vec<String>> =
2202            std::collections::BTreeMap::new();
2203        for raw in columns {
2204            let mut parts = raw.splitn(3, "__");
2205            let first = parts.next().unwrap_or("");
2206            let second = parts.next();
2207            let third = parts.next();
2208            if third.is_some() {
2209                return Err(sqlx::Error::Protocol(format!(
2210                    "umbral::orm::values: nested `{raw}` is not supported in v1 \
2211                     (one-hop only — `a__b`, not `a__b__c`)"
2212                )));
2213            }
2214            match second {
2215                Some(child) => {
2216                    per_rel
2217                        .entry(first.to_string())
2218                        .or_default()
2219                        .push(child.to_string());
2220                }
2221                None => parent_cols.push(first.to_string()),
2222            }
2223        }
2224
2225        // Validate every parent name against T::FIELDS.
2226        for name in &parent_cols {
2227            if !meta.fields.iter().any(|c| c.name == *name) {
2228                return Err(sqlx::Error::Protocol(format!(
2229                    "umbral::orm::values: unknown column `{name}` on model `{}`",
2230                    T::NAME
2231                )));
2232            }
2233        }
2234
2235        // Validate every relation + child trio. Build a struct per
2236        // relation that the SQL/decoder loops below need.
2237        struct RelInfo<'a> {
2238            rel_name: String,
2239            related_table: &'a str,
2240            related_pk: &'a crate::migrate::Column,
2241            child_cols: Vec<&'a crate::migrate::Column>,
2242        }
2243        let mut rel_infos: Vec<RelInfo<'_>> = Vec::with_capacity(per_rel.len());
2244        for (rel_name, child_names) in &per_rel {
2245            let fk_field = meta.fields.iter().find(|c| c.name == *rel_name);
2246            let Some(fk_field) = fk_field else {
2247                return Err(sqlx::Error::Protocol(format!(
2248                    "umbral::orm::values: unknown relation `{rel_name}` on model `{}` \
2249                     (used in `{rel_name}__...`)",
2250                    T::NAME
2251                )));
2252            };
2253            let Some(related_table) = fk_field.fk_target.as_deref() else {
2254                return Err(sqlx::Error::Protocol(format!(
2255                    "umbral::orm::values: field `{rel_name}` on `{}` is not a foreign \
2256                     key — `__` traversal only works through FK fields",
2257                    T::NAME
2258                )));
2259            };
2260            let Some(related_meta) = registered.iter().find(|m| m.table == related_table) else {
2261                return Err(sqlx::Error::Protocol(format!(
2262                    "umbral::orm::values: related model for table `{related_table}` \
2263                     is not registered"
2264                )));
2265            };
2266            let Some(related_pk) = related_meta.fields.iter().find(|c| c.primary_key) else {
2267                return Err(sqlx::Error::Protocol(format!(
2268                    "umbral::orm::values: related model `{related_table}` has no \
2269                     primary key column"
2270                )));
2271            };
2272            let mut child_cols: Vec<&crate::migrate::Column> =
2273                Vec::with_capacity(child_names.len());
2274            for name in child_names {
2275                let col = related_meta
2276                    .fields
2277                    .iter()
2278                    .find(|c| c.name == *name)
2279                    .ok_or_else(|| {
2280                        sqlx::Error::Protocol(format!(
2281                            "umbral::orm::values: unknown child column `{name}` on \
2282                             related model `{related_table}` (full path `{rel_name}__{name}`)"
2283                        ))
2284                    })?;
2285                child_cols.push(col);
2286            }
2287            rel_infos.push(RelInfo {
2288                rel_name: rel_name.clone(),
2289                related_table,
2290                related_pk,
2291                child_cols,
2292            });
2293        }
2294
2295        // Build the SQL. Subquery-wrap the parent (WHERE / ORDER
2296        // BY / LIMIT all stay scoped to it) so JOIN'd tables can't
2297        // shadow bare-column predicates — same trick
2298        // apply_join_related uses.
2299        let pool = resolve_pool::<T>(self.explicit_pool.clone(), crate::db::RouteOp::Read);
2300        let backend = pool.backend_name();
2301        let inner = self.build_query_for(backend);
2302        let parent_alias = Alias::new("__p");
2303        let mut outer = Query::select();
2304        outer.from_subquery(inner, parent_alias.clone());
2305        // Outer SELECT: parent cols (bare), aliased child cols.
2306        for name in &parent_cols {
2307            outer.expr_as(
2308                Expr::col((parent_alias.clone(), Alias::new(name.as_str()))),
2309                Alias::new(name.as_str()),
2310            );
2311        }
2312        for info in &rel_infos {
2313            let join_alias = Alias::new(format!("__j_{}", info.rel_name));
2314            outer.join_as(
2315                sea_query::JoinType::LeftJoin,
2316                crate::db::router::schema_qualified_table(info.related_table),
2317                join_alias.clone(),
2318                Expr::col((parent_alias.clone(), Alias::new(info.rel_name.as_str()))).equals((
2319                    join_alias.clone(),
2320                    Alias::new(info.related_pk.name.as_str()),
2321                )),
2322            );
2323            // Always include the related PK alias so the decoder
2324            // can detect a LEFT JOIN miss → emit Value::Null for
2325            // the whole relation. Plus every requested child col.
2326            let pk_alias = format!("{}__{}", info.rel_name, info.related_pk.name);
2327            outer.expr_as(
2328                Expr::col((
2329                    join_alias.clone(),
2330                    Alias::new(info.related_pk.name.as_str()),
2331                )),
2332                Alias::new(pk_alias),
2333            );
2334            for col in &info.child_cols {
2335                let alias = format!("{}__{}", info.rel_name, col.name);
2336                outer.expr_as(
2337                    Expr::col((join_alias.clone(), Alias::new(col.name.as_str()))),
2338                    Alias::new(alias),
2339                );
2340            }
2341        }
2342
2343        // Execute + decode. Per-backend dispatch.
2344        match pool {
2345            DbPool::Sqlite(pool) => {
2346                let (sql, vals) = outer.build_sqlx(SqliteQueryBuilder);
2347                let rows = sqlx::query_with::<sqlx::Sqlite, _>(&sql, vals)
2348                    .fetch_all(&pool)
2349                    .await?;
2350                let mut out: Vec<JsonValue> = Vec::with_capacity(rows.len());
2351                for row in &rows {
2352                    let mut obj = serde_json::Map::new();
2353                    // Parent cols decode by their bare alias name.
2354                    for name in &parent_cols {
2355                        if let Some(col) = meta.fields.iter().find(|c| c.name == *name) {
2356                            let v = crate::orm::dynamic::decode_to_json_aliased(row, col, name)?;
2357                            obj.insert(name.clone(), v);
2358                        }
2359                    }
2360                    // Per-relation nested object — `null` on LEFT JOIN miss.
2361                    for info in &rel_infos {
2362                        let pk_alias = format!("{}__{}", info.rel_name, info.related_pk.name);
2363                        let pk_is_null =
2364                            backend_sqlite::joined_pk_is_null(row, &info.related_pk, &pk_alias);
2365                        if pk_is_null {
2366                            obj.insert(info.rel_name.clone(), JsonValue::Null);
2367                            continue;
2368                        }
2369                        let mut nested = serde_json::Map::with_capacity(info.child_cols.len());
2370                        for col in &info.child_cols {
2371                            let alias = format!("{}__{}", info.rel_name, col.name);
2372                            let v = crate::orm::dynamic::decode_to_json_aliased(row, col, &alias)?;
2373                            nested.insert(col.name.clone(), v);
2374                        }
2375                        obj.insert(info.rel_name.clone(), JsonValue::Object(nested));
2376                    }
2377                    out.push(JsonValue::Object(obj));
2378                }
2379                Ok(out)
2380            }
2381            DbPool::Postgres(pool) => {
2382                let (sql, vals) = outer.build_sqlx(PostgresQueryBuilder);
2383                let rows = sqlx::query_with::<sqlx::Postgres, _>(&sql, vals)
2384                    .fetch_all(&pool)
2385                    .await?;
2386                let mut out: Vec<JsonValue> = Vec::with_capacity(rows.len());
2387                for row in &rows {
2388                    let mut obj = serde_json::Map::new();
2389                    for name in &parent_cols {
2390                        if let Some(col) = meta.fields.iter().find(|c| c.name == *name) {
2391                            let v = crate::orm::dynamic::decode_pg_to_json_aliased(row, col, name)?;
2392                            obj.insert(name.clone(), v);
2393                        }
2394                    }
2395                    for info in &rel_infos {
2396                        let pk_alias = format!("{}__{}", info.rel_name, info.related_pk.name);
2397                        let pk_is_null =
2398                            backend_pg::joined_pk_is_null(row, &info.related_pk, &pk_alias);
2399                        if pk_is_null {
2400                            obj.insert(info.rel_name.clone(), JsonValue::Null);
2401                            continue;
2402                        }
2403                        let mut nested = serde_json::Map::with_capacity(info.child_cols.len());
2404                        for col in &info.child_cols {
2405                            let alias = format!("{}__{}", info.rel_name, col.name);
2406                            let v =
2407                                crate::orm::dynamic::decode_pg_to_json_aliased(row, col, &alias)?;
2408                            nested.insert(col.name.clone(), v);
2409                        }
2410                        obj.insert(info.rel_name.clone(), JsonValue::Object(nested));
2411                    }
2412                    out.push(JsonValue::Object(obj));
2413                }
2414                Ok(out)
2415            }
2416        }
2417    }
2418
2419    /// Single-row aggregate. Runs `SELECT AGG(col) AS name, ...` with
2420    /// the QuerySet's accumulated WHERE clause (ORDER BY / LIMIT /
2421    /// OFFSET are dropped — they make no sense over an aggregate
2422    /// without GROUP BY).
2423    ///
2424    /// Returns a `serde_json::Value::Object` keyed by the supplied
2425    /// names. COUNT comes back as an integer; AVG as a float; SUM /
2426    /// MAX / MIN inherit the source column's declared type.
2427    ///
2428    /// ```rust,ignore
2429    /// use umbral::orm::Aggregate;
2430    /// let summary = Post::objects()
2431    ///     .filter(post::PUBLISHED.eq(true))
2432    ///     .aggregate(&[
2433    ///         ("count", Aggregate::count()),
2434    ///         ("total", Aggregate::sum("view_count")),
2435    ///     ])
2436    ///     .await?;
2437    /// // { "count": 42, "total": 9999 }
2438    /// ```
2439    pub async fn aggregate(
2440        self,
2441        aggs: &[(&str, crate::orm::Aggregate)],
2442    ) -> Result<JsonValue, sqlx::Error> {
2443        let meta = crate::migrate::ModelMeta::for_::<T>();
2444        // Validate every aggregate's source column exists.
2445        for (name, agg) in aggs {
2446            if let Some(col) = agg.source_column()
2447                && !meta.fields.iter().any(|c| c.name == col)
2448            {
2449                return Err(sqlx::Error::Protocol(format!(
2450                    "umbral::orm::aggregate: unknown column `{}` on model `{}` for aggregate `{}`",
2451                    col,
2452                    T::NAME,
2453                    name
2454                )));
2455            }
2456        }
2457        let pool = resolve_pool::<T>(self.explicit_pool.clone(), crate::db::RouteOp::Read);
2458        let backend = pool.backend_name();
2459        let mut q = self.build_query_for(backend);
2460        q.clear_selects();
2461        q.reset_limit();
2462        q.reset_offset();
2463        for (name, agg) in aggs {
2464            q.expr_as(agg.to_simple_expr(), Alias::new(*name));
2465        }
2466        match pool {
2467            DbPool::Sqlite(pool) => {
2468                let (sql, vals) = q.build_sqlx(SqliteQueryBuilder);
2469                let row = sqlx::query_with::<sqlx::Sqlite, _>(&sql, vals)
2470                    .fetch_one(&pool)
2471                    .await?;
2472                let mut obj = serde_json::Map::with_capacity(aggs.len());
2473                for (name, agg) in aggs {
2474                    let source_ty = agg
2475                        .source_column()
2476                        .and_then(|c| meta.fields.iter().find(|f| f.name == c).map(|f| f.ty));
2477                    obj.insert(
2478                        name.to_string(),
2479                        backend_sqlite::decode_agg(&row, name, agg, source_ty)?,
2480                    );
2481                }
2482                Ok(JsonValue::Object(obj))
2483            }
2484            DbPool::Postgres(pool) => {
2485                let (sql, vals) = q.build_sqlx(PostgresQueryBuilder);
2486                let row = sqlx::query_with::<sqlx::Postgres, _>(&sql, vals)
2487                    .fetch_one(&pool)
2488                    .await?;
2489                let mut obj = serde_json::Map::with_capacity(aggs.len());
2490                for (name, agg) in aggs {
2491                    let source_ty = agg
2492                        .source_column()
2493                        .and_then(|c| meta.fields.iter().find(|f| f.name == c).map(|f| f.ty));
2494                    obj.insert(
2495                        name.to_string(),
2496                        backend_pg::decode_agg(&row, name, agg, source_ty)?,
2497                    );
2498                }
2499                Ok(JsonValue::Object(obj))
2500            }
2501        }
2502    }
2503
2504    /// Grouped aggregate. Runs `SELECT <group_cols>, AGG(col) AS name,
2505    /// ... GROUP BY <group_cols>` with the accumulated WHERE clause.
2506    ///
2507    /// Returns one `Value::Object` per group, with both the group
2508    /// columns and each named aggregate as fields. Group columns are
2509    /// decoded per their declared SqlType (so an integer
2510    /// `author_id` stays a JSON number).
2511    ///
2512    /// ```rust,ignore
2513    /// let by_author = Post::objects()
2514    ///     .annotate(&["author_id"], &[("count", Aggregate::count())])
2515    ///     .await?;
2516    /// // [ { "author_id": 1, "count": 3 }, { "author_id": 2, "count": 2 } ]
2517    /// ```
2518    pub async fn annotate(
2519        self,
2520        group_cols: &[&str],
2521        aggs: &[(&str, crate::orm::Aggregate)],
2522    ) -> Result<Vec<JsonValue>, sqlx::Error> {
2523        let meta = crate::migrate::ModelMeta::for_::<T>();
2524        // Resolve group columns up front so unknown names fail
2525        // before any SQL runs.
2526        let mut chosen_groups: Vec<&crate::migrate::Column> = Vec::with_capacity(group_cols.len());
2527        for name in group_cols {
2528            let col = meta
2529                .fields
2530                .iter()
2531                .find(|c| c.name == *name)
2532                .ok_or_else(|| {
2533                    sqlx::Error::Protocol(format!(
2534                        "umbral::orm::annotate: unknown group column `{}` on model `{}`",
2535                        name,
2536                        T::NAME
2537                    ))
2538                })?;
2539            chosen_groups.push(col);
2540        }
2541        // Validate aggregate source columns.
2542        for (name, agg) in aggs {
2543            if let Some(col) = agg.source_column()
2544                && !meta.fields.iter().any(|c| c.name == col)
2545            {
2546                return Err(sqlx::Error::Protocol(format!(
2547                    "umbral::orm::annotate: unknown column `{}` on model `{}` for aggregate `{}`",
2548                    col,
2549                    T::NAME,
2550                    name
2551                )));
2552            }
2553        }
2554        let pool = resolve_pool::<T>(self.explicit_pool.clone(), crate::db::RouteOp::Read);
2555        let backend = pool.backend_name();
2556        let mut q = self.build_query_for(backend);
2557        q.clear_selects();
2558        // GROUP BY columns appear in the SELECT list AND the GROUP BY
2559        // clause. Aggregates only in the SELECT.
2560        for col in &chosen_groups {
2561            q.column(Alias::new(col.name.as_str()));
2562            q.add_group_by([sea_query::SimpleExpr::Column(sea_query::ColumnRef::Column(
2563                Alias::new(col.name.as_str()).into_iden(),
2564            ))]);
2565        }
2566        for (name, agg) in aggs {
2567            q.expr_as(agg.to_simple_expr(), Alias::new(*name));
2568        }
2569        match pool {
2570            DbPool::Sqlite(pool) => {
2571                let (sql, vals) = q.build_sqlx(SqliteQueryBuilder);
2572                let rows = sqlx::query_with::<sqlx::Sqlite, _>(&sql, vals)
2573                    .fetch_all(&pool)
2574                    .await?;
2575                let mut out: Vec<JsonValue> = Vec::with_capacity(rows.len());
2576                for row in &rows {
2577                    let mut obj = serde_json::Map::with_capacity(chosen_groups.len() + aggs.len());
2578                    for col in &chosen_groups {
2579                        obj.insert(
2580                            col.name.clone(),
2581                            crate::orm::dynamic::decode_to_json(row, col)?,
2582                        );
2583                    }
2584                    for (name, agg) in aggs {
2585                        let source_ty = agg
2586                            .source_column()
2587                            .and_then(|c| meta.fields.iter().find(|f| f.name == c).map(|f| f.ty));
2588                        obj.insert(
2589                            name.to_string(),
2590                            backend_sqlite::decode_agg(row, name, agg, source_ty)?,
2591                        );
2592                    }
2593                    out.push(JsonValue::Object(obj));
2594                }
2595                Ok(out)
2596            }
2597            DbPool::Postgres(pool) => {
2598                let (sql, vals) = q.build_sqlx(PostgresQueryBuilder);
2599                let rows = sqlx::query_with::<sqlx::Postgres, _>(&sql, vals)
2600                    .fetch_all(&pool)
2601                    .await?;
2602                let mut out: Vec<JsonValue> = Vec::with_capacity(rows.len());
2603                for row in &rows {
2604                    let mut obj = serde_json::Map::with_capacity(chosen_groups.len() + aggs.len());
2605                    for col in &chosen_groups {
2606                        obj.insert(
2607                            col.name.clone(),
2608                            crate::orm::dynamic::decode_pg_to_json(row, col)?,
2609                        );
2610                    }
2611                    for (name, agg) in aggs {
2612                        let source_ty = agg
2613                            .source_column()
2614                            .and_then(|c| meta.fields.iter().find(|f| f.name == c).map(|f| f.ty));
2615                        obj.insert(
2616                            name.to_string(),
2617                            backend_pg::decode_agg(row, name, agg, source_ty)?,
2618                        );
2619                    }
2620                    out.push(JsonValue::Object(obj));
2621                }
2622                Ok(out)
2623            }
2624        }
2625    }
2626
2627    /// The chainable `annotate(alias=Agg("relation"))`: attach a
2628    /// related-aggregate annotation to this QuerySet. The annotation
2629    /// is **query-builder state** — it renders as a correlated scalar
2630    /// subquery inside the one SELECT every terminal builds, so it
2631    /// composes with `.filter` / `.order_by` / `.limit`, stacks with
2632    /// further annotations, and shows up in [`Self::explain`] /
2633    /// [`Self::to_sql`] out of the box. Never a side query, never an
2634    /// N+1.
2635    ///
2636    /// `relation` names a `ReverseSet` relation on the model
2637    /// (`#[umbral(reverse_fk = "...")]`), the same names
2638    /// `prefetch_related` accepts. When no declared relation matches,
2639    /// the resolver AUTO-DISCOVERS the relation (gaps2 #45): it scans
2640    /// the model registry for any child whose FK targets this parent's
2641    /// table and matches `relation` against the child's conventional
2642    /// name forms (table name, `snake_case` / lowercase struct name,
2643    /// any of those with a `_set` suffix). Declared relations always
2644    /// take precedence; an ambiguous auto-match (two children, or a
2645    /// child with two FKs to this parent) poisons the annotation with
2646    /// an error that names the candidates and points at the
2647    /// `#[umbral(reverse_fk = "...")]` escape hatch. Any
2648    /// [`crate::orm::Aggregate`] works; non-count aggregates name a
2649    /// column on the CHILD model:
2650    ///
2651    /// ```rust,ignore
2652    /// let rows = Plugin::objects()
2653    ///     .filter(plugin::MODERATION.eq("approved"))
2654    ///     .annotate_count("comment_set")                                // COUNT(*)
2655    ///     .annotate_related("rating_avg", "review_set", Aggregate::avg("rating"))
2656    ///     .fetch_annotated()
2657    ///     .await?;                       // Vec<(Plugin, Map<alias, value>)>
2658    /// ```
2659    ///
2660    /// An unknown relation name doesn't panic the (infallible)
2661    /// builder — it poisons the annotation, and every fallible
2662    /// consumer (`fetch_annotated`, `explain`) reports it loudly.
2663    /// v1 caveats: child rows aggregate unconditionally — a
2664    /// child-side predicate (a filtered count)
2665    /// and child soft-delete awareness are tracked follow-ups
2666    /// (gaps2 #39).
2667    pub fn annotate_related(
2668        mut self,
2669        alias: &str,
2670        relation: &str,
2671        agg: crate::orm::Aggregate,
2672    ) -> Self {
2673        let rev_spec = T::REVERSE_FK_RELATIONS
2674            .iter()
2675            .find(|r| r.field_name == relation);
2676        let m2m_spec = T::M2M_RELATIONS.iter().find(|r| r.field_name == relation);
2677
2678        let pk = T::FIELDS
2679            .iter()
2680            .find(|f| f.primary_key)
2681            .map(|f| f.name)
2682            .unwrap_or("id");
2683
2684        let mut child_soft_delete = false;
2685        let mut m2m_junction: Option<String> = None;
2686
2687        let resolved = if let Some(spec) = rev_spec {
2688            child_soft_delete = spec.soft_delete;
2689            Ok((
2690                spec.target_table.to_string(),
2691                spec.fk_column.to_string(),
2692                T::TABLE.to_string(),
2693                pk.to_string(),
2694            ))
2695        } else if let Some(spec) = m2m_spec {
2696            // M2M count: junction table = "<parent>_<field>", columns
2697            // parent_id / child_id. The subquery counts junction rows.
2698            m2m_junction = Some(format!("{}_{}", T::TABLE, spec.field_name));
2699            // child_table / fk_column are unused for the M2M shape, but
2700            // the tuple still carries parent_table + parent_pk for the
2701            // correlation in build_query_for.
2702            Ok((
2703                spec.target_table.to_string(),
2704                "child_id".to_string(),
2705                T::TABLE.to_string(),
2706                pk.to_string(),
2707            ))
2708        } else {
2709            // No DECLARED relation matched. Fall back to auto-discovery
2710            // (gaps2 #45): scan the model registry for any child whose
2711            // FK points back at this parent's table, and match `relation`
2712            // against the conventional name forms. Declared relations
2713            // always win above; this only runs as a fallback.
2714            match discover_reverse_relation::<T>(relation) {
2715                AutoDiscovery::Resolved {
2716                    child_table,
2717                    fk_column,
2718                    soft_delete,
2719                } => {
2720                    child_soft_delete = soft_delete;
2721                    Ok((child_table, fk_column, T::TABLE.to_string(), pk.to_string()))
2722                }
2723                AutoDiscovery::Ambiguous(candidates) => Err(format!(
2724                    "umbral::orm::annotate_related: ambiguous reverse relation `{relation}` on `{}` — candidates: [{}]; declare a `#[umbral(reverse_fk = \"<fk>\")] ReverseSet<Child>` field to disambiguate",
2725                    T::NAME,
2726                    candidates.join(", "),
2727                )),
2728                AutoDiscovery::NotFound(discoverable) => {
2729                    let declared = T::REVERSE_FK_RELATIONS
2730                        .iter()
2731                        .map(|r| r.field_name)
2732                        .collect::<Vec<_>>()
2733                        .join(", ");
2734                    let m2m = T::M2M_RELATIONS
2735                        .iter()
2736                        .map(|r| r.field_name)
2737                        .collect::<Vec<_>>()
2738                        .join(", ");
2739                    Err(format!(
2740                        "umbral::orm::annotate_related: `{relation}` is not a reverse-FK or M2M relation on `{}` — reverse-FK relations: [{declared}], M2M relations: [{m2m}], auto-discoverable children: [{}]",
2741                        T::NAME,
2742                        discoverable.join(", "),
2743                    ))
2744                }
2745            }
2746        };
2747
2748        self.annotations.push(RelatedAnnotation {
2749            alias: alias.to_string(),
2750            agg,
2751            resolved,
2752            child_soft_delete,
2753            child_filter: None,
2754            m2m_junction,
2755        });
2756        self
2757    }
2758
2759    /// Sugar for the overwhelmingly common annotation:
2760    /// `annotate_related("<relation>_count", relation, Aggregate::count())`.
2761    /// `.annotate_count("comment_set")` exposes the value under the
2762    /// `comment_set_count` alias in [`Self::fetch_annotated`].
2763    pub fn annotate_count(self, relation: &str) -> Self {
2764        let alias = format!("{relation}_count");
2765        self.annotate_related(&alias, relation, crate::orm::Aggregate::count())
2766    }
2767
2768    /// Like [`Self::annotate_count`] but counts only the children
2769    /// matching `pred` — a filtered count over `"comments"`.
2770    /// `C` is the CHILD model, so the predicate is typed against the
2771    /// child's columns (`comment::MODERATION.eq("visible")`). The
2772    /// predicate renders into the correlated count subquery's WHERE
2773    /// alongside the FK correlation and the auto soft-delete filter.
2774    ///
2775    /// ```rust,ignore
2776    /// Plugin::objects()
2777    ///     .annotate_count_where::<PluginComment>(
2778    ///         "visible_comments",
2779    ///         "comment_set",
2780    ///         plugin_comment::MODERATION.eq("visible"),
2781    ///     )
2782    /// ```
2783    pub fn annotate_count_where<C: crate::orm::Model>(
2784        self,
2785        alias: &str,
2786        relation: &str,
2787        pred: crate::orm::Predicate<C>,
2788    ) -> Self {
2789        // Render the child predicate to a backend-default SimpleExpr.
2790        // The count subquery embeds one expression; the equality /
2791        // comparison predicates used for child filters render the same
2792        // on both backends, so the default `cond` is correct.
2793        let child_filter = pred.cond_for("postgres");
2794        let mut queryset = self.annotate_related(alias, relation, crate::orm::Aggregate::count());
2795        // The just-pushed annotation is the last one; attach the filter.
2796        if let Some(last) = queryset.annotations.last_mut() {
2797            last.child_filter = Some(child_filter);
2798        }
2799        queryset
2800    }
2801
2802    /// Loud-failure check for poisoned annotations (unknown relation
2803    /// names recorded by the infallible builder). Called by every
2804    /// fallible consumer before SQL runs.
2805    fn check_annotations(&self) -> Result<(), sqlx::Error> {
2806        for ann in &self.annotations {
2807            if let Err(msg) = &ann.resolved {
2808                return Err(sqlx::Error::Protocol(msg.clone()));
2809            }
2810        }
2811        Ok(())
2812    }
2813
2814    /// Run the SELECT and return every matching row **with its
2815    /// annotation values** — the execution terminal for
2816    /// [`Self::annotate_related`] / [`Self::annotate_count`]. One
2817    /// query; each row's annotations arrive as an `alias → JSON
2818    /// value` map (count → integer, AVG → float/null, SUM/MAX/MIN →
2819    /// typed per the child column, NULL on empty sets for the
2820    /// non-count aggregates).
2821    pub async fn fetch_annotated(
2822        self,
2823    ) -> Result<Vec<(T, serde_json::Map<String, JsonValue>)>, sqlx::Error>
2824    where
2825        T: for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
2826            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>,
2827    {
2828        self.check_annotations()?;
2829        // Child-column types for SUM/MAX/MIN decoding, resolved from
2830        // the runtime registry (the child model is known by table
2831        // name only). `None` falls back to decode_agg's string path.
2832        let source_types: Vec<(String, crate::orm::Aggregate, Option<crate::orm::SqlType>)> = {
2833            let registry_up = crate::migrate::is_initialised();
2834            self.annotations
2835                .iter()
2836                .map(|ann| {
2837                    let ty = match (&ann.resolved, registry_up, ann.agg.source_column()) {
2838                        (Ok((child_table, ..)), true, Some(col)) => {
2839                            crate::migrate::registered_models()
2840                                .into_iter()
2841                                .find(|m| m.table == *child_table)
2842                                .and_then(|m| m.fields.iter().find(|f| f.name == col).map(|f| f.ty))
2843                        }
2844                        _ => None,
2845                    };
2846                    (ann.alias.clone(), ann.agg.clone(), ty)
2847                })
2848                .collect()
2849        };
2850
2851        let pool = resolve_pool::<T>(self.explicit_pool.clone(), crate::db::RouteOp::Read);
2852        match pool {
2853            DbPool::Sqlite(pool) => {
2854                let q = self.build_query_for("sqlite");
2855                let (sql, vals) = q.build_sqlx(SqliteQueryBuilder);
2856                let rows = sqlx::query_with::<sqlx::Sqlite, _>(&sql, vals)
2857                    .fetch_all(&pool)
2858                    .await?;
2859                let mut out = Vec::with_capacity(rows.len());
2860                for row in &rows {
2861                    let t = <T as sqlx::FromRow<_>>::from_row(row)?;
2862                    let mut anns = serde_json::Map::with_capacity(source_types.len());
2863                    for (alias, agg, ty) in &source_types {
2864                        anns.insert(
2865                            alias.clone(),
2866                            backend_sqlite::decode_agg(row, alias, agg, *ty)?,
2867                        );
2868                    }
2869                    out.push((t, anns));
2870                }
2871                Ok(out)
2872            }
2873            DbPool::Postgres(pool) => {
2874                let q = self.build_query_for("postgres");
2875                let (sql, vals) = q.build_sqlx(PostgresQueryBuilder);
2876                let rows = sqlx::query_with::<sqlx::Postgres, _>(&sql, vals)
2877                    .fetch_all(&pool)
2878                    .await?;
2879                let mut out = Vec::with_capacity(rows.len());
2880                for row in &rows {
2881                    let t = <T as sqlx::FromRow<_>>::from_row(row)?;
2882                    let mut anns = serde_json::Map::with_capacity(source_types.len());
2883                    for (alias, agg, ty) in &source_types {
2884                        anns.insert(alias.clone(), backend_pg::decode_agg(row, alias, agg, *ty)?);
2885                    }
2886                    out.push((t, anns));
2887                }
2888                Ok(out)
2889            }
2890        }
2891    }
2892
2893    /// `DELETE FROM table WHERE <predicates>`. Returns the number of
2894    /// rows deleted. With no `.filter` calls, deletes every row.
2895    ///
2896    /// Fires `bulk_post_delete:<table>` once with the list of removed
2897    /// PKs when at least one row was deleted. Per-row `pre_delete` /
2898    /// `post_delete` are NOT fired by this path — use
2899    /// [`Manager::delete_instance`] when per-row signal semantics are
2900    /// required.
2901    ///
2902    /// Feature #72: for `#[umbral(soft_delete)]` models this rewrites
2903    /// to `UPDATE ... SET deleted_at = NOW() WHERE ...` so rows
2904    /// survive in the DB (filtered out of subsequent queries by the
2905    /// auto `WHERE deleted_at IS NULL`). Call `.hard_delete()`
2906    /// beforehand for a real DELETE (GDPR purge, test cleanup).
2907    pub async fn delete(self) -> Result<u64, sqlx::Error> {
2908        // Feature #72 — soft-delete redirect. The whole `delete()`
2909        // contract collapses to an UPDATE setting `deleted_at`. We
2910        // keep the bulk_post_delete signal so subscribers see the
2911        // same event shape regardless of the underlying SQL.
2912        if self.soft_delete_active && !self.hard_delete {
2913            return self.soft_delete_update().await;
2914        }
2915        let atomic = self.should_atomic_wrap();
2916        let pool = resolve_pool::<T>(self.explicit_pool.clone(), crate::db::RouteOp::Write);
2917        let backend = pool.backend_name();
2918        let mut stmt = self.build_delete_for(backend);
2919        let pk = pk_field::<T>();
2920        if let Some(field) = pk {
2921            stmt.returning_col(Alias::new(field.name));
2922        }
2923        let ids: Vec<JsonValue> = match pool {
2924            DbPool::Sqlite(pool) => {
2925                let (sql, values) = stmt.build_sqlx(SqliteQueryBuilder);
2926                let rows = if atomic {
2927                    let mut tx = pool.begin().await?;
2928                    let r = sqlx::query_with::<sqlx::Sqlite, _>(&sql, values)
2929                        .fetch_all(&mut *tx)
2930                        .await;
2931                    match r {
2932                        Ok(rows) => {
2933                            tx.commit().await?;
2934                            rows
2935                        }
2936                        Err(e) => {
2937                            let _ = tx.rollback().await;
2938                            return Err(e);
2939                        }
2940                    }
2941                } else {
2942                    sqlx::query_with::<sqlx::Sqlite, _>(&sql, values)
2943                        .fetch_all(&pool)
2944                        .await?
2945                };
2946                match pk {
2947                    Some(field) => rows
2948                        .iter()
2949                        .map(|r| backend_sqlite::pk_to_json(r, field.name, field.ty))
2950                        .collect::<Result<_, _>>()?,
2951                    None => Vec::new(),
2952                }
2953            }
2954            DbPool::Postgres(pool) => {
2955                let (sql, values) = stmt.build_sqlx(PostgresQueryBuilder);
2956                let rows = if atomic {
2957                    let mut tx = pool.begin().await?;
2958                    let r = sqlx::query_with::<sqlx::Postgres, _>(&sql, values)
2959                        .fetch_all(&mut *tx)
2960                        .await;
2961                    match r {
2962                        Ok(rows) => {
2963                            tx.commit().await?;
2964                            rows
2965                        }
2966                        Err(e) => {
2967                            let _ = tx.rollback().await;
2968                            return Err(e);
2969                        }
2970                    }
2971                } else {
2972                    sqlx::query_with::<sqlx::Postgres, _>(&sql, values)
2973                        .fetch_all(&pool)
2974                        .await?
2975                };
2976                match pk {
2977                    Some(field) => rows
2978                        .iter()
2979                        .map(|r| backend_pg::pk_to_json(r, field.name, field.ty))
2980                        .collect::<Result<_, _>>()?,
2981                    None => Vec::new(),
2982                }
2983            }
2984        };
2985        let count = ids.len() as u64;
2986        if !ids.is_empty() {
2987            crate::signals::emit_bulk_post_delete::<T>(ids).await;
2988        }
2989        Ok(count)
2990    }
2991
2992    /// `UPDATE table SET col = <expr> WHERE <predicates>` using an
2993    /// F-expression for the new value.
2994    ///
2995    /// This is the companion to `update_values` for atomic column
2996    /// arithmetic. `F::col("views").add(1)` produces an [`FExpr`] that
2997    /// renders as `SET views = views + 1` — the database computes the
2998    /// increment atomically on the server side rather than needing a
2999    /// read-modify-write round-trip in application code.
3000    ///
3001    /// ```rust,ignore
3002    /// use umbral::orm::F;
3003    ///
3004    /// Post::objects()
3005    ///     .filter(post::ID.eq(42))
3006    ///     .update_expr("views", F::col("views").add(1))
3007    ///     .await?;
3008    /// ```
3009    ///
3010    /// Mixing `update_values` and `update_expr` for different columns in
3011    /// one statement requires two separate calls. A combined API (a map
3012    /// where values can be either JSON or FExpr) would require a new sum
3013    /// type; deferred until a consumer surfaces the need.
3014    pub async fn update_expr(
3015        self,
3016        col_name: &str,
3017        expr: FExpr,
3018    ) -> Result<u64, crate::orm::write::WriteError> {
3019        use crate::orm::write::WriteError;
3020        // Validate the column exists on the model.
3021        let field = T::FIELDS
3022            .iter()
3023            .find(|f| f.name == col_name)
3024            .ok_or_else(|| WriteError::UnknownColumn {
3025                field: col_name.to_string(),
3026            })?;
3027        if field.primary_key {
3028            // Silently skip PK rewrites, same as update_values.
3029            return Ok(0);
3030        }
3031        let pool = resolve_pool::<T>(self.explicit_pool.clone(), crate::db::RouteOp::Write);
3032        let backend = pool.backend_name();
3033
3034        let mut stmt = sea_query::Query::update();
3035        stmt.table(crate::db::router::schema_qualified_table(T::TABLE));
3036        stmt.value(Alias::new(field.name), expr.to_simple_expr());
3037        for p in &self.predicates {
3038            stmt.and_where(p.cond_for(backend));
3039        }
3040        if self.soft_delete_active {
3041            if self.only_deleted {
3042                stmt.and_where(Expr::col(Alias::new("deleted_at")).is_not_null());
3043            } else if !self.with_deleted {
3044                stmt.and_where(Expr::col(Alias::new("deleted_at")).is_null());
3045            }
3046        }
3047        let pk = pk_field::<T>();
3048        if let Some(pkf) = pk {
3049            stmt.returning_col(Alias::new(pkf.name));
3050        }
3051
3052        let ids: Vec<JsonValue> = match pool {
3053            DbPool::Sqlite(pool) => {
3054                let (sql, values) = stmt.build_sqlx(SqliteQueryBuilder);
3055                let rows = sqlx::query_with::<sqlx::Sqlite, _>(&sql, values)
3056                    .fetch_all(&pool)
3057                    .await?;
3058                match pk {
3059                    Some(pkf) => rows
3060                        .iter()
3061                        .map(|r| backend_sqlite::pk_to_json(r, pkf.name, pkf.ty))
3062                        .collect::<Result<_, _>>()
3063                        .map_err(crate::orm::write::WriteError::Sqlx)?,
3064                    None => Vec::new(),
3065                }
3066            }
3067            DbPool::Postgres(pool) => {
3068                let (sql, values) = stmt.build_sqlx(PostgresQueryBuilder);
3069                let rows = sqlx::query_with::<sqlx::Postgres, _>(&sql, values)
3070                    .fetch_all(&pool)
3071                    .await?;
3072                match pk {
3073                    Some(pkf) => rows
3074                        .iter()
3075                        .map(|r| backend_pg::pk_to_json(r, pkf.name, pkf.ty))
3076                        .collect::<Result<_, _>>()
3077                        .map_err(crate::orm::write::WriteError::Sqlx)?,
3078                    None => Vec::new(),
3079                }
3080            }
3081        };
3082        let count = ids.len() as u64;
3083        if !ids.is_empty() {
3084            crate::signals::emit_bulk_post_save::<T>(ids, false).await;
3085        }
3086        Ok(count)
3087    }
3088
3089    /// `UPDATE table SET k=v[, ...] WHERE <predicates>`. The values
3090    /// map provides `column_name → JSON value` pairs; each is
3091    /// converted to a `sea_query::Value` per the column's declared
3092    /// `SqlType` via [`crate::orm::write::json_to_sea_value`]. Returns
3093    /// the number of rows affected.
3094    ///
3095    /// Unknown columns in the map fail loudly with
3096    /// `WriteError::UnknownColumn`. JSON `null` is rejected for
3097    /// non-nullable columns; supplying a column that exists but is
3098    /// absent from the map is silently a no-op (the column keeps its
3099    /// current value — PATCH semantics, not PUT).
3100    ///
3101    /// Fires `bulk_post_save:<table>` once with `{ ids, created:
3102    /// false, actor }` when at least one row matched. Per-row
3103    /// `pre_save` / `post_save` are NOT fired — use [`Manager::save`]
3104    /// when per-row signal semantics are required.
3105    pub async fn update_values(
3106        self,
3107        values: serde_json::Map<String, serde_json::Value>,
3108    ) -> Result<u64, crate::orm::write::WriteError> {
3109        let atomic = self.should_atomic_wrap();
3110        let pool = resolve_pool::<T>(self.explicit_pool.clone(), crate::db::RouteOp::Write);
3111        let backend = pool.backend_name();
3112        let mut stmt = self.build_update_for(backend, &values)?;
3113        // RETURNING <pk> so bulk_post_save can include the matched ids.
3114        let pk = pk_field::<T>();
3115        if let Some(field) = pk {
3116            stmt.returning_col(Alias::new(field.name));
3117        }
3118        let ids: Vec<JsonValue> = match pool {
3119            DbPool::Sqlite(pool) => {
3120                let (sql, values) = stmt.build_sqlx(SqliteQueryBuilder);
3121                let rows = if atomic {
3122                    let mut tx = pool
3123                        .begin()
3124                        .await
3125                        .map_err(crate::orm::write::WriteError::Sqlx)?;
3126                    let r = sqlx::query_with::<sqlx::Sqlite, _>(&sql, values)
3127                        .fetch_all(&mut *tx)
3128                        .await;
3129                    match r {
3130                        Ok(rows) => {
3131                            tx.commit()
3132                                .await
3133                                .map_err(crate::orm::write::WriteError::Sqlx)?;
3134                            rows
3135                        }
3136                        Err(e) => {
3137                            let _ = tx.rollback().await;
3138                            return Err(crate::orm::write::WriteError::Sqlx(e));
3139                        }
3140                    }
3141                } else {
3142                    sqlx::query_with::<sqlx::Sqlite, _>(&sql, values)
3143                        .fetch_all(&pool)
3144                        .await?
3145                };
3146                match pk {
3147                    Some(field) => rows
3148                        .iter()
3149                        .map(|r| backend_sqlite::pk_to_json(r, field.name, field.ty))
3150                        .collect::<Result<_, _>>()
3151                        .map_err(crate::orm::write::WriteError::Sqlx)?,
3152                    None => Vec::new(),
3153                }
3154            }
3155            DbPool::Postgres(pool) => {
3156                let (sql, values) = stmt.build_sqlx(PostgresQueryBuilder);
3157                let rows = if atomic {
3158                    let mut tx = pool
3159                        .begin()
3160                        .await
3161                        .map_err(crate::orm::write::WriteError::Sqlx)?;
3162                    let r = sqlx::query_with::<sqlx::Postgres, _>(&sql, values)
3163                        .fetch_all(&mut *tx)
3164                        .await;
3165                    match r {
3166                        Ok(rows) => {
3167                            tx.commit()
3168                                .await
3169                                .map_err(crate::orm::write::WriteError::Sqlx)?;
3170                            rows
3171                        }
3172                        Err(e) => {
3173                            let _ = tx.rollback().await;
3174                            return Err(crate::orm::write::WriteError::Sqlx(e));
3175                        }
3176                    }
3177                } else {
3178                    sqlx::query_with::<sqlx::Postgres, _>(&sql, values)
3179                        .fetch_all(&pool)
3180                        .await?
3181                };
3182                match pk {
3183                    Some(field) => rows
3184                        .iter()
3185                        .map(|r| backend_pg::pk_to_json(r, field.name, field.ty))
3186                        .collect::<Result<_, _>>()
3187                        .map_err(crate::orm::write::WriteError::Sqlx)?,
3188                    None => Vec::new(),
3189                }
3190            }
3191        };
3192        let count = ids.len() as u64;
3193        if !ids.is_empty() {
3194            crate::signals::emit_bulk_post_save::<T>(ids, false).await;
3195        }
3196        Ok(count)
3197    }
3198
3199    /// Helper: build the DELETE statement for the active backend.
3200    /// Public-by-virtue-of-being-pub(crate) so the `_pg` and (future)
3201    /// `_sqlite` explicit-pool variants can share the SQL builder.
3202    fn build_delete_for(&self, backend_name: &str) -> sea_query::DeleteStatement {
3203        let mut stmt = Query::delete();
3204        stmt.from_table(crate::db::router::schema_qualified_table(T::TABLE));
3205        for p in &self.predicates {
3206            stmt.and_where(p.cond_for(backend_name));
3207        }
3208        stmt
3209    }
3210
3211    /// Feature #72 — soft-delete rewrite: turn `DELETE FROM table
3212    /// WHERE ...` into `UPDATE table SET deleted_at = NOW() WHERE
3213    /// ... AND deleted_at IS NULL`. The trailing `IS NULL` guard
3214    /// makes the operation idempotent: re-soft-deleting an already-
3215    /// soft-deleted row doesn't bump its timestamp. Fires
3216    /// `bulk_post_delete:<table>` so subscribers see the same event
3217    /// shape as a hard delete.
3218    async fn soft_delete_update(self) -> Result<u64, sqlx::Error> {
3219        let atomic = self.should_atomic_wrap();
3220        let pool = resolve_pool::<T>(self.explicit_pool.clone(), crate::db::RouteOp::Write);
3221        let backend = pool.backend_name();
3222        let now = chrono::Utc::now();
3223        let mut stmt = sea_query::Query::update();
3224        stmt.table(crate::db::router::schema_qualified_table(T::TABLE));
3225        stmt.value(
3226            Alias::new("deleted_at"),
3227            sea_query::Value::ChronoDateTimeUtc(Some(Box::new(now))),
3228        );
3229        for p in &self.predicates {
3230            stmt.and_where(p.cond_for(backend));
3231        }
3232        // Idempotency guard — never bump an already-set deleted_at.
3233        stmt.and_where(sea_query::Expr::col(Alias::new("deleted_at")).is_null());
3234        let pk = pk_field::<T>();
3235        if let Some(pkf) = pk {
3236            stmt.returning_col(Alias::new(pkf.name));
3237        }
3238        let ids: Vec<JsonValue> = match pool {
3239            DbPool::Sqlite(pool) => {
3240                let (sql, values) = stmt.build_sqlx(SqliteQueryBuilder);
3241                let rows = if atomic {
3242                    let mut tx = pool.begin().await?;
3243                    let r = sqlx::query_with::<sqlx::Sqlite, _>(&sql, values)
3244                        .fetch_all(&mut *tx)
3245                        .await;
3246                    match r {
3247                        Ok(rows) => {
3248                            tx.commit().await?;
3249                            rows
3250                        }
3251                        Err(e) => {
3252                            let _ = tx.rollback().await;
3253                            return Err(e);
3254                        }
3255                    }
3256                } else {
3257                    sqlx::query_with::<sqlx::Sqlite, _>(&sql, values)
3258                        .fetch_all(&pool)
3259                        .await?
3260                };
3261                match pk {
3262                    Some(field) => rows
3263                        .iter()
3264                        .map(|r| backend_sqlite::pk_to_json(r, field.name, field.ty))
3265                        .collect::<Result<_, _>>()?,
3266                    None => Vec::new(),
3267                }
3268            }
3269            DbPool::Postgres(pool) => {
3270                let (sql, values) = stmt.build_sqlx(PostgresQueryBuilder);
3271                let rows = if atomic {
3272                    let mut tx = pool.begin().await?;
3273                    let r = sqlx::query_with::<sqlx::Postgres, _>(&sql, values)
3274                        .fetch_all(&mut *tx)
3275                        .await;
3276                    match r {
3277                        Ok(rows) => {
3278                            tx.commit().await?;
3279                            rows
3280                        }
3281                        Err(e) => {
3282                            let _ = tx.rollback().await;
3283                            return Err(e);
3284                        }
3285                    }
3286                } else {
3287                    sqlx::query_with::<sqlx::Postgres, _>(&sql, values)
3288                        .fetch_all(&pool)
3289                        .await?
3290                };
3291                match pk {
3292                    Some(field) => rows
3293                        .iter()
3294                        .map(|r| backend_pg::pk_to_json(r, field.name, field.ty))
3295                        .collect::<Result<_, _>>()?,
3296                    None => Vec::new(),
3297                }
3298            }
3299        };
3300        let count = ids.len() as u64;
3301        if !ids.is_empty() {
3302            crate::signals::emit_bulk_post_delete::<T>(ids).await;
3303        }
3304        Ok(count)
3305    }
3306
3307    /// Helper: build the UPDATE statement for the active backend.
3308    /// Walks the `values` map, validates each column against the
3309    /// model's `FIELDS` metadata, converts the JSON value via
3310    /// `write::json_to_sea_value`, and threads the accumulated
3311    /// predicates into the WHERE clause.
3312    fn build_update_for(
3313        &self,
3314        backend_name: &str,
3315        values: &serde_json::Map<String, serde_json::Value>,
3316    ) -> Result<sea_query::UpdateStatement, crate::orm::write::WriteError> {
3317        use crate::orm::write::{WriteError, json_to_sea_value};
3318        let mut stmt = Query::update();
3319        stmt.table(crate::db::router::schema_qualified_table(T::TABLE));
3320        for (col_name, val) in values {
3321            // Look up the column on the model. Unknown column names
3322            // fail loudly here rather than producing a bad UPDATE.
3323            let field = T::FIELDS
3324                .iter()
3325                .find(|f| f.name == col_name.as_str())
3326                .ok_or_else(|| WriteError::UnknownColumn {
3327                    field: col_name.clone(),
3328                })?;
3329            // Reject attempts to overwrite the PK via update_values.
3330            // The QuerySet's WHERE clause is the only way to identify
3331            // rows; rewriting the PK while filtering on the old one
3332            // is a footgun.
3333            if field.primary_key {
3334                continue;
3335            }
3336            let sea_value =
3337                json_to_sea_value(field.ty, val, field.nullable, field.name, fk_pk_hint(field))?;
3338            stmt.value(Alias::new(field.name), sea_value);
3339        }
3340        for p in &self.predicates {
3341            stmt.and_where(p.cond_for(backend_name));
3342        }
3343        if self.soft_delete_active {
3344            if self.only_deleted {
3345                stmt.and_where(Expr::col(Alias::new("deleted_at")).is_not_null());
3346            } else if !self.with_deleted {
3347                stmt.and_where(Expr::col(Alias::new("deleted_at")).is_null());
3348            }
3349        }
3350        Ok(stmt)
3351    }
3352
3353    /// Run the SELECT against an explicit `PgPool` and return every
3354    /// matching row. Bound by `FromRow<PgRow>` alone so models with
3355    /// Postgres-only field types compile.
3356    pub async fn fetch_pg(self, pool: &sqlx::PgPool) -> Result<Vec<T>, sqlx::Error>
3357    where
3358        T: for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>,
3359    {
3360        let q = self.build_query_for("postgres");
3361        let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
3362        sqlx::query_as_with::<sqlx::Postgres, T, _>(&sql, values)
3363            .fetch_all(pool)
3364            .await
3365    }
3366
3367    /// Run the SELECT against an explicit `PgPool` with LIMIT 1.
3368    pub async fn first_pg(mut self, pool: &sqlx::PgPool) -> Result<Option<T>, sqlx::Error>
3369    where
3370        T: for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>,
3371    {
3372        self.query.limit(1);
3373        let q = self.build_query_for("postgres");
3374        let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
3375        sqlx::query_as_with::<sqlx::Postgres, T, _>(&sql, values)
3376            .fetch_optional(pool)
3377            .await
3378    }
3379
3380    /// Run `SELECT COUNT(*)` against an explicit `PgPool`. No FromRow
3381    /// bound on `T` — the count tuple type is `(i64,)`.
3382    pub async fn count_pg(self, pool: &sqlx::PgPool) -> Result<i64, sqlx::Error> {
3383        let mut rebuilt = self.build_query_for("postgres");
3384        rebuilt.clear_selects();
3385        rebuilt.expr(Func::count(Expr::col(sea_query::Asterisk)));
3386        rebuilt.reset_limit();
3387        rebuilt.reset_offset();
3388        let (sql, values) = rebuilt.build_sqlx(PostgresQueryBuilder);
3389        let (n,): (i64,) = sqlx::query_as_with::<sqlx::Postgres, (i64,), _>(&sql, values)
3390            .fetch_one(pool)
3391            .await?;
3392        Ok(n)
3393    }
3394
3395    /// Return whether any row matches, against an explicit `PgPool`.
3396    pub async fn exists_pg(self, pool: &sqlx::PgPool) -> Result<bool, sqlx::Error>
3397    where
3398        T: for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>,
3399    {
3400        let rows = self.limit(1).fetch_pg(pool).await?;
3401        Ok(!rows.is_empty())
3402    }
3403
3404    /// Exactly-one terminal against an explicit `PgPool`.
3405    /// See [`QuerySet::get`] for the error-variant semantics.
3406    pub async fn get_pg(self, pool: &sqlx::PgPool) -> Result<T, GetError>
3407    where
3408        T: for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>,
3409    {
3410        let mut rows = self.limit(2).fetch_pg(pool).await.map_err(GetError::Sqlx)?;
3411        match rows.len() {
3412            0 => Err(GetError::NotFound),
3413            1 => Ok(rows.pop().unwrap()),
3414            _ => Err(GetError::MultipleObjectsReturned),
3415        }
3416    }
3417}
3418
3419/// Delegating chainable + terminal surface on `Manager<T>`.
3420///
3421/// Lets users write `Post::objects().filter(...).fetch().await` without
3422/// a separate `.query()` hop. Each method constructs the initial
3423/// `SelectStatement` against `T::TABLE` with one column per
3424/// `T::FIELDS` entry, wraps it in a fresh `QuerySet<T>`, and forwards.
3425impl<T: Model> Manager<T> {
3426    fn queryset(&self) -> QuerySet<T> {
3427        let columns: Vec<Alias> = T::FIELDS.iter().map(|f| Alias::new(f.name)).collect();
3428        let query = Query::select()
3429            .columns(columns)
3430            .from(crate::db::router::schema_qualified_table(T::TABLE))
3431            .take();
3432        let mut qs = QuerySet::new(query);
3433        // BUG-8: seed the default ORDER BY from `Model::ORDERING` so
3434        // terminals that don't see an explicit `.order_by(...)` still
3435        // get a deterministic row order.
3436        qs.default_ordering = T::ORDERING.to_vec();
3437        // Propagate the Manager's atomic override so QuerySet
3438        // terminals inherit it without the caller re-specifying.
3439        qs.atomic = self.atomic;
3440        // Feature #72 — snapshot the model's soft-delete opt-in into
3441        // the QuerySet so the build_query_for path knows whether to
3442        // auto-inject `WHERE deleted_at IS NULL`. Without this
3443        // snapshot the `impl<T> QuerySet<T>` path can't read
3444        // `T::SOFT_DELETE` (T is unbounded there).
3445        qs.soft_delete_active = T::SOFT_DELETE;
3446        qs
3447    }
3448
3449    /// Resolve whether write terminals on this Manager should auto-wrap
3450    /// in a transaction. Per-call override > builder global.
3451    fn should_atomic_wrap(&self) -> bool {
3452        self.atomic.unwrap_or_else(crate::db::atomic_default)
3453    }
3454
3455    /// See `QuerySet::filter`.
3456    pub fn filter(&self, p: Predicate<T>) -> QuerySet<T> {
3457        self.queryset().filter(p)
3458    }
3459
3460    /// See `QuerySet::exclude`.
3461    pub fn exclude(&self, p: Predicate<T>) -> QuerySet<T> {
3462        self.queryset().exclude(p)
3463    }
3464
3465    /// A bare [`QuerySet`] over every row — the `Model::objects().all()` form.
3466    ///
3467    /// The entry point when you need a `QuerySet` terminal without a
3468    /// filter: a grouped aggregate over the whole table, an unfiltered
3469    /// `aggregate`, or just an explicit "all rows" for readability.
3470    pub fn all(&self) -> QuerySet<T> {
3471        self.queryset()
3472    }
3473
3474    /// See [`QuerySet::aggregate`] — single-row aggregate over every row.
3475    ///
3476    /// Forwards from the manager so `Model::objects().aggregate(...)`
3477    /// works without an intervening `.filter(...)` / `.on(...)`.
3478    pub async fn aggregate(
3479        &self,
3480        aggs: &[(&str, crate::orm::Aggregate)],
3481    ) -> Result<JsonValue, sqlx::Error> {
3482        self.queryset().aggregate(aggs).await
3483    }
3484
3485    /// See [`QuerySet::annotate`] — grouped aggregate (`GROUP BY <group_cols>`).
3486    ///
3487    /// Forwards from the manager so the documented
3488    /// `Model::objects().annotate(&["status"], &[("count", Aggregate::count())])`
3489    /// (a grouped count over `"status"`) compiles
3490    /// directly, without a filter first.
3491    pub async fn annotate(
3492        &self,
3493        group_cols: &[&str],
3494        aggs: &[(&str, crate::orm::Aggregate)],
3495    ) -> Result<Vec<JsonValue>, sqlx::Error> {
3496        self.queryset().annotate(group_cols, aggs).await
3497    }
3498
3499    /// Feature #72 — see `QuerySet::with_deleted`.
3500    pub fn with_deleted(&self) -> QuerySet<T> {
3501        self.queryset().with_deleted()
3502    }
3503
3504    /// Feature #72 — see `QuerySet::only_deleted`.
3505    pub fn only_deleted(&self) -> QuerySet<T> {
3506        self.queryset().only_deleted()
3507    }
3508
3509    /// Gap #111 — see [`QuerySet::only`].
3510    pub fn only(&self, cols: &[&str]) -> QuerySet<T> {
3511        self.queryset().only(cols)
3512    }
3513
3514    /// See [`QuerySet::join_related`].
3515    pub fn join_related(&self, field_name: impl Into<String>) -> QuerySet<T> {
3516        self.queryset().join_related(field_name)
3517    }
3518
3519    /// See [`QuerySet::join_related_many`].
3520    pub fn join_related_many(&self, field_names: &[&str]) -> QuerySet<T> {
3521        self.queryset().join_related_many(field_names)
3522    }
3523
3524    /// See [`QuerySet::left_join_related`].
3525    pub fn left_join_related(&self, path: impl Into<String>) -> QuerySet<T> {
3526        self.queryset().left_join_related(path)
3527    }
3528
3529    /// See [`QuerySet::inner_join_related`].
3530    pub fn inner_join_related(&self, path: impl Into<String>) -> QuerySet<T> {
3531        self.queryset().inner_join_related(path)
3532    }
3533
3534    /// See [`QuerySet::right_join_related`].
3535    pub fn right_join_related(&self, path: impl Into<String>) -> QuerySet<T> {
3536        self.queryset().right_join_related(path)
3537    }
3538
3539    /// See [`QuerySet::select_related`].
3540    pub fn select_related(&self, field_name: impl Into<String>) -> QuerySet<T> {
3541        self.queryset().select_related(field_name)
3542    }
3543
3544    /// See [`QuerySet::select_related_many`].
3545    pub fn select_related_many(&self, field_names: &[&str]) -> QuerySet<T> {
3546        self.queryset().select_related_many(field_names)
3547    }
3548
3549    /// See [`QuerySet::prefetch_related`].
3550    pub fn prefetch_related(&self, field_name: impl Into<String>) -> QuerySet<T> {
3551        self.queryset().prefetch_related(field_name)
3552    }
3553
3554    /// See [`QuerySet::prefetch_related_many`].
3555    pub fn prefetch_related_many(&self, field_names: &[&str]) -> QuerySet<T> {
3556        self.queryset().prefetch_related_many(field_names)
3557    }
3558
3559    /// Feature #72 — see `QuerySet::hard_delete`.
3560    pub fn hard_delete(&self) -> QuerySet<T> {
3561        self.queryset().hard_delete()
3562    }
3563
3564    /// See `QuerySet::into_subquery`.
3565    pub fn into_subquery(&self, col_name: &str) -> crate::orm::Subquery {
3566        self.queryset().into_subquery(col_name)
3567    }
3568
3569    /// See `QuerySet::order_by`.
3570    pub fn order_by(&self, o: OrderExpr<T>) -> QuerySet<T> {
3571        self.queryset().order_by(o)
3572    }
3573
3574    /// See `QuerySet::limit`.
3575    pub fn limit(&self, n: u64) -> QuerySet<T> {
3576        self.queryset().limit(n)
3577    }
3578
3579    /// See `QuerySet::offset`.
3580    pub fn offset(&self, n: u64) -> QuerySet<T> {
3581        self.queryset().offset(n)
3582    }
3583
3584    /// See `QuerySet::on`.
3585    pub fn on(&self, pool: &sqlx::SqlitePool) -> QuerySet<T> {
3586        self.queryset().on(pool)
3587    }
3588
3589    /// See `QuerySet::on_pg`.
3590    pub fn on_pg(&self, pool: &sqlx::PgPool) -> QuerySet<T> {
3591        self.queryset().on_pg(pool)
3592    }
3593
3594    /// See `QuerySet::fetch`.
3595    pub async fn fetch(&self) -> Result<Vec<T>, sqlx::Error>
3596    where
3597        T: for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
3598            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
3599            + HydrateRelated,
3600    {
3601        self.queryset().fetch().await
3602    }
3603
3604    /// See `QuerySet::first`.
3605    pub async fn first(&self) -> Result<Option<T>, sqlx::Error>
3606    where
3607        T: for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
3608            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
3609            + HydrateRelated,
3610    {
3611        self.queryset().first().await
3612    }
3613
3614    /// See `QuerySet::count`.
3615    pub async fn count(&self) -> Result<i64, sqlx::Error> {
3616        self.queryset().count().await
3617    }
3618
3619    /// See [`QuerySet::annotate_related`] — starts an annotated chain
3620    /// from the manager, like `filter` does.
3621    pub fn annotate_related(
3622        &self,
3623        alias: &str,
3624        relation: &str,
3625        agg: crate::orm::Aggregate,
3626    ) -> QuerySet<T> {
3627        self.queryset().annotate_related(alias, relation, agg)
3628    }
3629
3630    /// See [`QuerySet::annotate_count`].
3631    pub fn annotate_count(&self, relation: &str) -> QuerySet<T> {
3632        self.queryset().annotate_count(relation)
3633    }
3634
3635    /// See [`QuerySet::annotate_count_where`] — starts a filtered
3636    /// annotated chain from the manager.
3637    pub fn annotate_count_where<C: crate::orm::Model>(
3638        &self,
3639        alias: &str,
3640        relation: &str,
3641        pred: crate::orm::Predicate<C>,
3642    ) -> QuerySet<T> {
3643        self.queryset()
3644            .annotate_count_where::<C>(alias, relation, pred)
3645    }
3646
3647    /// See [`QuerySet::fetch_annotated`].
3648    pub async fn fetch_annotated(
3649        &self,
3650    ) -> Result<Vec<(T, serde_json::Map<String, JsonValue>)>, sqlx::Error>
3651    where
3652        T: for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
3653            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>,
3654    {
3655        self.queryset().fetch_annotated().await
3656    }
3657
3658    /// See [`QuerySet::values`].
3659    pub async fn values(&self, columns: &[&str]) -> Result<Vec<JsonValue>, sqlx::Error> {
3660        self.queryset().values(columns).await
3661    }
3662
3663    /// See `QuerySet::exists`.
3664    pub async fn exists(&self) -> Result<bool, sqlx::Error>
3665    where
3666        T: for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
3667            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
3668            + HydrateRelated,
3669    {
3670        self.queryset().exists().await
3671    }
3672
3673    /// `.get(predicate)` — sugar for `.filter(predicate).get()`.
3674    ///
3675    /// The one-liner: `User::objects().get(user::ID.eq(1))`.
3676    /// See [`QuerySet::get`] for error-variant semantics.
3677    pub async fn get(&self, p: Predicate<T>) -> Result<T, GetError>
3678    where
3679        T: for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
3680            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
3681            + HydrateRelated,
3682    {
3683        self.queryset().filter(p).get().await
3684    }
3685
3686    /// See [`QuerySet::fetch_pg`].
3687    pub async fn fetch_pg(&self, pool: &sqlx::PgPool) -> Result<Vec<T>, sqlx::Error>
3688    where
3689        T: for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>,
3690    {
3691        self.queryset().fetch_pg(pool).await
3692    }
3693
3694    /// See [`QuerySet::first_pg`].
3695    pub async fn first_pg(&self, pool: &sqlx::PgPool) -> Result<Option<T>, sqlx::Error>
3696    where
3697        T: for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>,
3698    {
3699        self.queryset().first_pg(pool).await
3700    }
3701
3702    /// See [`QuerySet::count_pg`].
3703    pub async fn count_pg(&self, pool: &sqlx::PgPool) -> Result<i64, sqlx::Error> {
3704        self.queryset().count_pg(pool).await
3705    }
3706
3707    /// See [`QuerySet::exists_pg`].
3708    pub async fn exists_pg(&self, pool: &sqlx::PgPool) -> Result<bool, sqlx::Error>
3709    where
3710        T: for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>,
3711    {
3712        self.queryset().exists_pg(pool).await
3713    }
3714
3715    /// Postgres-only sugar for `.filter(predicate).get_pg(pool)`.
3716    pub async fn get_pg(&self, pool: &sqlx::PgPool, p: Predicate<T>) -> Result<T, GetError>
3717    where
3718        T: for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>,
3719    {
3720        self.queryset().filter(p).get_pg(pool).await
3721    }
3722
3723    // =====================================================================
3724    // Write methods — INSERT.
3725    //
3726    // `create(instance)` does one row; `bulk_create([...])` does many in
3727    // a single multi-VALUES INSERT. Both serialise the instance(s) to a
3728    // JSON map via `serde::Serialize`, look up each field in the model's
3729    // `FIELDS` metadata, and bind values through
3730    // [`crate::orm::write::json_to_sea_value`].
3731    //
3732    // PK handling:
3733    // - Default value (0 for ints, nil for UUIDs, empty for String):
3734    //   omitted from the INSERT column list so the DB autoincrement /
3735    //   default kicks in.
3736    // - Explicit non-default value: included in the INSERT so the
3737    //   caller can supply UUIDs / slug PKs themselves.
3738    // =====================================================================
3739
3740    /// INSERT one row, return the row as it now exists in the
3741    /// database (with any autoincrement PK populated). Uses the
3742    /// ambient pool via `Manager::queryset().resolve_pool`.
3743    pub async fn create(&self, mut instance: T) -> Result<T, crate::orm::write::WriteError>
3744    where
3745        T: serde::Serialize
3746            + for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
3747            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
3748            + HydrateRelated,
3749    {
3750        use crate::orm::write::WriteError;
3751        let map = serialize_to_map(&instance)?;
3752
3753        // Same pre-DB validation pipeline the dynamic
3754        // `insert_json` path runs — choices + FK existence +
3755        // M2M shape. Empty-string + required-field checks are
3756        // intentionally relaxed on the typed path: a Rust
3757        // `pub title: String` field set to `""` is the caller's
3758        // deliberate choice, not a form-default leak, and
3759        // missing-required can't happen because the struct
3760        // forced the caller to supply every column at compile
3761        // time. We only validate the things the typed path
3762        // can't catch at compile time.
3763        let meta = crate::migrate::ModelMeta::for_::<T>();
3764        let validation_errors = crate::orm::validation::validate_on_typed_create(&meta, &map).await;
3765        if !validation_errors.is_empty() {
3766            return Err(WriteError::Multiple {
3767                errors: validation_errors,
3768            });
3769        }
3770
3771        let pool = resolve_pool::<T>(None, crate::db::RouteOp::Write);
3772        let backend = pool.backend_name();
3773        let stmt = build_insert_one_for::<T>(backend, &map)?;
3774        let atomic = self.should_atomic_wrap();
3775        // Post-execution SQL classification: turns the DB's
3776        // UNIQUE / FK / NOT NULL / CHECK violations into the
3777        // structured `WriteError` variants instead of a raw
3778        // `Sqlx(_)` 500. Symmetric with `DynQuerySet::insert_json`.
3779        match pool {
3780            DbPool::Sqlite(pool) => {
3781                let (sql, values) = stmt.build_sqlx(SqliteQueryBuilder);
3782                let row_result = if atomic {
3783                    let mut tx = pool.begin().await.map_err(WriteError::Sqlx)?;
3784                    let r = sqlx::query_as_with::<sqlx::Sqlite, T, _>(&sql, values)
3785                        .fetch_one(&mut *tx)
3786                        .await;
3787                    match r {
3788                        Ok(row) => {
3789                            tx.commit().await.map_err(WriteError::Sqlx)?;
3790                            Ok(row)
3791                        }
3792                        Err(e) => {
3793                            let _ = tx.rollback().await;
3794                            Err(e)
3795                        }
3796                    }
3797                } else {
3798                    sqlx::query_as_with::<sqlx::Sqlite, T, _>(&sql, values)
3799                        .fetch_one(&pool)
3800                        .await
3801                };
3802                let mut row = row_result.map_err(|e| {
3803                    crate::orm::validation::classify_sql_error(&e, &map)
3804                        .unwrap_or(WriteError::Sqlx(e))
3805                })?;
3806                // BUG-16 step 2: every materialised row, including the
3807                // post-INSERT readback, needs `parent_id` +
3808                // `junction_table` seeded on its M2M slots — otherwise
3809                // `row.tags.add(...)` is a silent no-op.
3810                row.set_m2m_parent_ids();
3811                // Carry form-staged M2M pending ids from the caller's
3812                // instance onto the readback row, then flush them to
3813                // junction rows now that parent_id + junction_table are
3814                // seeded on the readback row.
3815                instance.take_pending_m2m_into(&mut row);
3816                row.write_pending_m2m().await?;
3817                Ok(row)
3818            }
3819            DbPool::Postgres(pool) => {
3820                let (sql, values) = stmt.build_sqlx(PostgresQueryBuilder);
3821                let row_result = if atomic {
3822                    let mut tx = pool.begin().await.map_err(WriteError::Sqlx)?;
3823                    let r = sqlx::query_as_with::<sqlx::Postgres, T, _>(&sql, values)
3824                        .fetch_one(&mut *tx)
3825                        .await;
3826                    match r {
3827                        Ok(row) => {
3828                            tx.commit().await.map_err(WriteError::Sqlx)?;
3829                            Ok(row)
3830                        }
3831                        Err(e) => {
3832                            let _ = tx.rollback().await;
3833                            Err(e)
3834                        }
3835                    }
3836                } else {
3837                    sqlx::query_as_with::<sqlx::Postgres, T, _>(&sql, values)
3838                        .fetch_one(&pool)
3839                        .await
3840                };
3841                let mut row = row_result.map_err(|e| {
3842                    crate::orm::validation::classify_sql_error(&e, &map)
3843                        .unwrap_or(WriteError::Sqlx(e))
3844                })?;
3845                row.set_m2m_parent_ids();
3846                instance.take_pending_m2m_into(&mut row);
3847                row.write_pending_m2m().await?;
3848                Ok(row)
3849            }
3850        }
3851    }
3852
3853    /// INSERT many rows in a single statement. Returns the number of
3854    /// rows inserted. The full populated rows aren't materialised —
3855    /// use a follow-up `Model::objects().filter(...).fetch()` if you
3856    /// need them.
3857    ///
3858    /// Empty input is a no-op (returns Ok(0)) — the alternative
3859    /// (building an `INSERT INTO t () VALUES ()` and failing at the
3860    /// DB) doesn't help anyone.
3861    ///
3862    /// Fires `bulk_post_save:<table>` once with `{ ids, created: true,
3863    /// actor }` when at least one row was inserted. Per-row
3864    /// `pre_save` / `post_save` are NOT fired — use [`Self::save`]
3865    /// when per-row signal semantics are required.
3866    pub async fn bulk_create(&self, instances: Vec<T>) -> Result<u64, crate::orm::write::WriteError>
3867    where
3868        T: serde::Serialize,
3869    {
3870        use crate::orm::write::WriteError;
3871        if instances.is_empty() {
3872            return Ok(0);
3873        }
3874        let maps: Result<Vec<_>, _> = instances.iter().map(serialize_to_map).collect();
3875        let maps = maps?;
3876        // Validate every instance through the typed-create
3877        // pipeline. Collected into one `Multiple` so a caller
3878        // that submitted ten rows and got two bad ones can fix
3879        // both in one pass.
3880        let meta = crate::migrate::ModelMeta::for_::<T>();
3881        let mut all_errors: Vec<WriteError> = Vec::new();
3882        for map in &maps {
3883            let errs = crate::orm::validation::validate_on_typed_create(&meta, map).await;
3884            all_errors.extend(errs);
3885        }
3886        if !all_errors.is_empty() {
3887            return Err(WriteError::Multiple { errors: all_errors });
3888        }
3889        let pool = resolve_pool::<T>(None, crate::db::RouteOp::Write);
3890        let backend = pool.backend_name();
3891        let mut stmt = build_insert_many_for::<T>(backend, &maps)?;
3892        // First row's map is used to enrich UNIQUE / FK
3893        // messages with the offending value when the engine
3894        // doesn't name it. Imperfect for bulk (the failing row
3895        // could be later in the batch) but better than the raw
3896        // sqlx error.
3897        let first_map = maps.first().cloned().unwrap_or_default();
3898        // Add `RETURNING <pk>` so the bulk_post_save signal payload can
3899        // carry the inserted PKs. Both backends support this — SQLite
3900        // since 3.35, Postgres natively. Replaces the previous
3901        // `execute()` + rows_affected path; count comes from the
3902        // returned row vector instead.
3903        let pk = pk_field::<T>();
3904        if let Some(field) = pk {
3905            stmt.returning_col(Alias::new(field.name));
3906        }
3907        let atomic = self.should_atomic_wrap();
3908        let ids: Vec<JsonValue> = match pool {
3909            DbPool::Sqlite(pool) => {
3910                let (sql, values) = stmt.build_sqlx(SqliteQueryBuilder);
3911                let rows = if atomic {
3912                    let mut tx = pool.begin().await.map_err(WriteError::Sqlx)?;
3913                    let r = sqlx::query_with::<sqlx::Sqlite, _>(&sql, values)
3914                        .fetch_all(&mut *tx)
3915                        .await;
3916                    match r {
3917                        Ok(rows) => {
3918                            tx.commit().await.map_err(WriteError::Sqlx)?;
3919                            rows
3920                        }
3921                        Err(e) => {
3922                            let _ = tx.rollback().await;
3923                            return Err(crate::orm::validation::classify_sql_error(&e, &first_map)
3924                                .unwrap_or(WriteError::Sqlx(e)));
3925                        }
3926                    }
3927                } else {
3928                    sqlx::query_with::<sqlx::Sqlite, _>(&sql, values)
3929                        .fetch_all(&pool)
3930                        .await
3931                        .map_err(|e| {
3932                            crate::orm::validation::classify_sql_error(&e, &first_map)
3933                                .unwrap_or(WriteError::Sqlx(e))
3934                        })?
3935                };
3936                match pk {
3937                    Some(field) => rows
3938                        .iter()
3939                        .map(|r| backend_sqlite::pk_to_json(r, field.name, field.ty))
3940                        .collect::<Result<_, _>>()
3941                        .map_err(WriteError::Sqlx)?,
3942                    None => Vec::new(),
3943                }
3944            }
3945            DbPool::Postgres(pool) => {
3946                let (sql, values) = stmt.build_sqlx(PostgresQueryBuilder);
3947                let rows = if atomic {
3948                    let mut tx = pool.begin().await.map_err(WriteError::Sqlx)?;
3949                    let r = sqlx::query_with::<sqlx::Postgres, _>(&sql, values)
3950                        .fetch_all(&mut *tx)
3951                        .await;
3952                    match r {
3953                        Ok(rows) => {
3954                            tx.commit().await.map_err(WriteError::Sqlx)?;
3955                            rows
3956                        }
3957                        Err(e) => {
3958                            let _ = tx.rollback().await;
3959                            return Err(crate::orm::validation::classify_sql_error(&e, &first_map)
3960                                .unwrap_or(WriteError::Sqlx(e)));
3961                        }
3962                    }
3963                } else {
3964                    sqlx::query_with::<sqlx::Postgres, _>(&sql, values)
3965                        .fetch_all(&pool)
3966                        .await
3967                        .map_err(|e| {
3968                            crate::orm::validation::classify_sql_error(&e, &first_map)
3969                                .unwrap_or(WriteError::Sqlx(e))
3970                        })?
3971                };
3972                match pk {
3973                    Some(field) => rows
3974                        .iter()
3975                        .map(|r| backend_pg::pk_to_json(r, field.name, field.ty))
3976                        .collect::<Result<_, _>>()
3977                        .map_err(WriteError::Sqlx)?,
3978                    None => Vec::new(),
3979                }
3980            }
3981        };
3982        let count = ids.len() as u64;
3983        if !ids.is_empty() {
3984            crate::signals::emit_bulk_post_save::<T>(ids, true).await;
3985        }
3986        Ok(count)
3987    }
3988
3989    /// The `get_or_create` terminal: fetch the first row matching `predicate`;
3990    /// if none exists, insert `defaults` and return it. Returns
3991    /// `(row, created)` so the caller can branch on whether the write
3992    /// happened. Two queries on the miss path (filter+first then create),
3993    /// one query on the hit path.
3994    ///
3995    /// ## Concurrency
3996    ///
3997    /// Convergent under concurrent callers: if two callers both miss the
3998    /// SELECT and race to INSERT, the one that loses gets a
3999    /// `UniqueViolation`; that error is caught here and the existing row
4000    /// is re-fetched, so both callers return the same row with
4001    /// `created = false` for the loser. A UNIQUE constraint on the
4002    /// predicate columns is required for true at-most-one semantics — the
4003    /// constraint is what makes the convergence deterministic.
4004    pub async fn get_or_create(
4005        &self,
4006        predicate: Predicate<T>,
4007        defaults: T,
4008    ) -> Result<(T, bool), crate::orm::write::WriteError>
4009    where
4010        T: serde::Serialize
4011            + for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
4012            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
4013            + HydrateRelated,
4014    {
4015        use crate::orm::write::WriteError;
4016
4017        // Read-your-writes: probe for the existing row on the WRITE database,
4018        // not a (possibly lagging) read replica — otherwise a read/write-split
4019        // router could miss a just-written row and insert a duplicate. The
4020        // following `create()` already resolves the same write target.
4021        let write_pool = resolve_pool::<T>(None, crate::db::RouteOp::Write);
4022        if let Some(existing) = pin_to_pool(self.filter(predicate.clone()), &write_pool)
4023            .first()
4024            .await
4025            .map_err(WriteError::Sqlx)?
4026        {
4027            return Ok((existing, false));
4028        }
4029
4030        // Attempt the INSERT. On a UNIQUE violation (concurrent writer won the
4031        // race between our SELECT and this INSERT), catch the error and
4032        // re-SELECT to return the now-existing row with created=false.
4033        // This is the standard try-insert-then-fetch convergence pattern.
4034        //
4035        // Note on transaction semantics: a plain INSERT is atomic at the
4036        // statement level. Wrapping SELECT+INSERT in a serialisable transaction
4037        // would be stricter but requires SAVEPOINT support to recover from the
4038        // Postgres "aborted transaction" state after a constraint violation —
4039        // a per-operation SAVEPOINT would add two extra round-trips on every
4040        // write for marginal gain. The UNIQUE-constraint backstop plus this
4041        // re-fetch gives the same observable guarantee: callers always converge
4042        // on the same row and never see a spurious UniqueViolation.
4043        match self.create(defaults).await {
4044            Ok(created) => Ok((created, true)),
4045            Err(WriteError::UniqueViolation { .. }) => {
4046                // A concurrent writer inserted the row between our SELECT and
4047                // our INSERT. Re-fetch the now-existing row.
4048                let existing = pin_to_pool(self.filter(predicate), &write_pool)
4049                    .first()
4050                    .await
4051                    .map_err(WriteError::Sqlx)?
4052                    .ok_or_else(|| {
4053                        WriteError::Sqlx(sqlx::Error::Protocol(
4054                            "get_or_create: row vanished after UniqueViolation re-fetch"
4055                                .to_string(),
4056                        ))
4057                    })?;
4058                Ok((existing, false))
4059            }
4060            Err(e) => Err(e),
4061        }
4062    }
4063
4064    /// The `update_or_create` terminal: fetch the first row matching
4065    /// `predicate`; if found, update its non-PK columns with the
4066    /// `defaults` instance's values and return the fresh row;
4067    /// otherwise insert `defaults` and return it. Returns
4068    /// `(row, created)` so the caller can branch on the path taken.
4069    ///
4070    /// The defaults' PK is intentionally ignored on the update path —
4071    /// the matched row keeps its original PK. On the insert path the
4072    /// defaults' PK is honoured (autoincrement sentinel `0` → DB
4073    /// assigns; explicit value → DB uses it).
4074    ///
4075    /// ## Concurrency
4076    ///
4077    /// Convergent under concurrent callers: if two callers both miss the
4078    /// SELECT and race to INSERT, the loser gets a `UniqueViolation`; that
4079    /// error is caught here and the existing row is re-fetched, then the
4080    /// update is applied to it. Both callers converge on the same row, with
4081    /// `created = false` for the loser. A UNIQUE constraint on the predicate
4082    /// columns is required for deterministic convergence.
4083    ///
4084    /// Implementation: 2 queries on the hit path (`first` + UPDATE + re-fetch),
4085    /// 2 queries on the miss+create path (`first` + `create`), or
4086    /// 4 queries on the miss+race path (`first` + failed-INSERT + `first`
4087    /// + UPDATE + re-fetch).
4088    pub async fn update_or_create(
4089        &self,
4090        predicate: Predicate<T>,
4091        defaults: T,
4092    ) -> Result<(T, bool), crate::orm::write::WriteError>
4093    where
4094        T: serde::Serialize
4095            + Clone
4096            + for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
4097            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
4098            + HydrateRelated,
4099    {
4100        use crate::orm::write::WriteError;
4101        let pk = pk_field::<T>().ok_or_else(|| {
4102            WriteError::Sqlx(sqlx::Error::Protocol(
4103                "update_or_create: model has no primary key".to_string(),
4104            ))
4105        })?;
4106        let pk_name = pk.name;
4107
4108        // Read-your-writes: the existence probe and the post-update re-fetch
4109        // run on the WRITE database, so a read/write-split router doesn't miss
4110        // a just-written row (duplicate insert) or read a stale row back. The
4111        // intervening UPDATE already routes to the same write target.
4112        let write_pool = resolve_pool::<T>(None, crate::db::RouteOp::Write);
4113
4114        // Shared helper: given the existing row (already fetched) and the
4115        // defaults instance, apply the non-PK column update and return the
4116        // re-fetched row. Used by both the direct-hit path and the
4117        // UniqueViolation-convergence path so the update logic is in one place.
4118        macro_rules! do_update {
4119            ($existing:expr, $defaults:expr) => {{
4120                let existing: T = $existing;
4121                let defaults: T = $defaults;
4122
4123                // Serialize defaults, drop the PK so the matched row's PK is
4124                // preserved, then UPDATE WHERE <pk_col> = <existing_pk>.
4125                let mut update_map = serialize_to_map(&defaults)?;
4126                update_map.remove(pk_name);
4127
4128                // Build a PK predicate from the existing row's serialized PK
4129                // value. Goes through serde_json so any PK type (i64, String,
4130                // Uuid) round-trips correctly through sea-query.
4131                let existing_json =
4132                    serde_json::to_value(&existing).map_err(WriteError::SerializeFailed)?;
4133                let pk_value_json = existing_json
4134                    .get(pk_name)
4135                    .cloned()
4136                    .unwrap_or(serde_json::Value::Null);
4137                let pk_sea = crate::orm::write::json_to_sea_value(
4138                    pk.ty,
4139                    &pk_value_json,
4140                    false,
4141                    pk_name,
4142                    None,
4143                )?;
4144                let pk_pred: Predicate<T> =
4145                    Predicate::new(sea_query::Expr::col(sea_query::Alias::new(pk_name)).eq(pk_sea));
4146
4147                // Run the UPDATE.
4148                self.filter(pk_pred).update_values(update_map).await?;
4149
4150                // Re-fetch to return the populated row.
4151                let pk_sea2 = crate::orm::write::json_to_sea_value(
4152                    pk.ty,
4153                    &pk_value_json,
4154                    false,
4155                    pk_name,
4156                    None,
4157                )?;
4158                let refetch_pred: Predicate<T> = Predicate::new(
4159                    sea_query::Expr::col(sea_query::Alias::new(pk_name)).eq(pk_sea2),
4160                );
4161                let updated_row: T = pin_to_pool(self.filter(refetch_pred), &write_pool)
4162                    .first()
4163                    .await
4164                    .map_err(WriteError::Sqlx)?
4165                    .ok_or_else(|| {
4166                        WriteError::Sqlx(sqlx::Error::Protocol(
4167                            "update_or_create: row vanished between UPDATE and re-fetch"
4168                                .to_string(),
4169                        ))
4170                    })?;
4171
4172                // `update_values` above fires `bulk_post_save`; ALSO fire the
4173                // per-row `post_save` so signal / realtime `on_model` consumers
4174                // (which subscribe to the per-row event) see this upsert-update.
4175                // Without it, the CREATE branch (via `self.create()`) emits
4176                // `post_save` but the UPDATE branch was silent to those
4177                // consumers — an asymmetry that's very hard to reason about
4178                // from the call site (gaps3 #14). `bulk_post_save` and
4179                // `post_save` are distinct signal names, so no single consumer
4180                // fires twice.
4181                crate::signals::emit_post_save::<T>(&updated_row, false).await;
4182                updated_row
4183            }};
4184        }
4185
4186        if let Some(existing) = pin_to_pool(self.filter(predicate.clone()), &write_pool)
4187            .first()
4188            .await
4189            .map_err(WriteError::Sqlx)?
4190        {
4191            let updated = do_update!(existing, defaults);
4192            return Ok((updated, false));
4193        }
4194
4195        // Attempt the INSERT. On a UNIQUE violation (concurrent writer won the
4196        // race between our SELECT and this INSERT), catch the error, re-fetch
4197        // the now-existing row, and apply the update to it — same convergence
4198        // as get_or_create but with an extra UPDATE step.
4199        match self.create(defaults.clone()).await {
4200            Ok(created) => {
4201                // `create()` is deliberately signal-free (only `save()` fires
4202                // per-row signals), so emit `post_save` here — otherwise the
4203                // CREATE branch would be silent to on_model / realtime
4204                // consumers while the UPDATE branch (above) fires it, an
4205                // inconsistency across the two halves of one API (gaps3 #14).
4206                crate::signals::emit_post_save::<T>(&created, true).await;
4207                Ok((created, true))
4208            }
4209            Err(WriteError::UniqueViolation { .. }) => {
4210                // A concurrent writer inserted the row between our SELECT and
4211                // our INSERT. Re-fetch then update, same as the direct-hit path.
4212                let existing = pin_to_pool(self.filter(predicate), &write_pool)
4213                    .first()
4214                    .await
4215                    .map_err(WriteError::Sqlx)?
4216                    .ok_or_else(|| {
4217                        WriteError::Sqlx(sqlx::Error::Protocol(
4218                            "update_or_create: row vanished after UniqueViolation re-fetch"
4219                                .to_string(),
4220                        ))
4221                    })?;
4222                let updated = do_update!(existing, defaults);
4223                Ok((updated, false))
4224            }
4225            Err(e) => Err(e),
4226        }
4227    }
4228
4229    /// INSERT-or-UPDATE keyed on the primary key. The row's PK column
4230    /// is the conflict target; on a hit, every non-PK column is
4231    /// overwritten with the supplied value. Returns the row as the DB
4232    /// stored it (post-upsert).
4233    ///
4234    /// Both backends use `INSERT ... ON CONFLICT(<pk>) DO UPDATE SET
4235    /// col = excluded.col, ...`. The SQLite and Postgres syntax happens
4236    /// to match exactly here so a single sea-query `OnConflict` builder
4237    /// covers both.
4238    pub async fn upsert(&self, instance: T) -> Result<T, crate::orm::write::WriteError>
4239    where
4240        T: serde::Serialize
4241            + for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
4242            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>,
4243    {
4244        let map = serialize_to_map(&instance)?;
4245        let pool = resolve_pool::<T>(None, crate::db::RouteOp::Write);
4246        let backend = pool.backend_name();
4247        let mut stmt = build_insert_one_for::<T>(backend, &map)?;
4248
4249        // Conflict target = PK column. update_columns = every non-PK
4250        // column the body included. sea-query renders `DO UPDATE SET
4251        // col = excluded.col` (SQLite) / `DO UPDATE SET col =
4252        // EXCLUDED.col` (PG) — both forms work cross-dialect.
4253        let pk_name = T::FIELDS
4254            .iter()
4255            .find(|f| f.primary_key)
4256            .map(|f| f.name)
4257            .ok_or_else(|| {
4258                crate::orm::write::WriteError::Sqlx(sqlx::Error::Protocol(
4259                    "upsert: model has no primary key — use get_or_create or create instead"
4260                        .to_string(),
4261                ))
4262            })?;
4263        let update_cols: Vec<Alias> = T::FIELDS
4264            .iter()
4265            .filter(|f| !f.primary_key && map.contains_key(f.name))
4266            .map(|f| Alias::new(f.name))
4267            .collect();
4268        let mut on_conflict = sea_query::OnConflict::column(Alias::new(pk_name));
4269        if !update_cols.is_empty() {
4270            on_conflict.update_columns(update_cols);
4271        } else {
4272            // No non-PK columns to overwrite — this is a "INSERT OR
4273            // IGNORE" shape. sea-query encodes that as `DO NOTHING`.
4274            on_conflict.do_nothing();
4275        }
4276        stmt.on_conflict(on_conflict);
4277
4278        match pool {
4279            DbPool::Sqlite(pool) => {
4280                let (sql, values) = stmt.build_sqlx(SqliteQueryBuilder);
4281                let row = sqlx::query_as_with::<sqlx::Sqlite, T, _>(&sql, values)
4282                    .fetch_one(&pool)
4283                    .await?;
4284                Ok(row)
4285            }
4286            DbPool::Postgres(pool) => {
4287                let (sql, values) = stmt.build_sqlx(PostgresQueryBuilder);
4288                let row = sqlx::query_as_with::<sqlx::Postgres, T, _>(&sql, values)
4289                    .fetch_one(&pool)
4290                    .await?;
4291                Ok(row)
4292            }
4293        }
4294    }
4295
4296    /// `create` against an explicit Postgres pool. The Postgres
4297    /// counterpart of [`Self::create`] for models with Postgres-only
4298    /// field types (Array, Inet, MacAddr, FullText), whose `FromRow`
4299    /// impl exists only for `PgRow`.
4300    pub async fn create_pg(
4301        &self,
4302        instance: T,
4303        pool: &sqlx::PgPool,
4304    ) -> Result<T, crate::orm::write::WriteError>
4305    where
4306        T: serde::Serialize + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>,
4307    {
4308        let map = serialize_to_map(&instance)?;
4309        let stmt = build_insert_one_for::<T>("postgres", &map)?;
4310        let (sql, values) = stmt.build_sqlx(PostgresQueryBuilder);
4311        let row = sqlx::query_as_with::<sqlx::Postgres, T, _>(&sql, values)
4312            .fetch_one(pool)
4313            .await?;
4314        Ok(row)
4315    }
4316
4317    /// Apply per-row differing values to a list of instances in one
4318    /// statement. Each instance carries its own PK and its own
4319    /// column values; the generated SQL uses one `CASE pk WHEN ...
4320    /// THEN ... END` per non-PK column, plus a `WHERE pk IN (...)`
4321    /// to scope the update.
4322    ///
4323    /// A `bulk_update(objs, fields)` that
4324    /// updates every non-PK column rather than asking the caller to
4325    /// list them. Returns the number of rows affected.
4326    ///
4327    /// Empty input is a no-op (returns 0). The pattern works on both
4328    /// SQLite and Postgres — the `CASE` expression is SQL-standard.
4329    ///
4330    /// Limitations:
4331    /// - All instances must have a non-default PK (the caller has
4332    ///   already loaded the rows). Default-PK instances are skipped.
4333    /// - Bulk-write signals are NOT fired by this path — it's the
4334    ///   `Manager::bulk_create` analogue for UPDATE, deliberately
4335    ///   silent for speed.
4336    pub async fn bulk_update(&self, instances: Vec<T>) -> Result<u64, crate::orm::write::WriteError>
4337    where
4338        T: serde::Serialize,
4339    {
4340        use crate::orm::write::{WriteError, is_default_pk, json_to_sea_value};
4341        if instances.is_empty() {
4342            return Ok(0);
4343        }
4344        let pk = pk_field::<T>().ok_or_else(|| {
4345            WriteError::Sqlx(sqlx::Error::Protocol(
4346                "bulk_update: model has no primary key".to_string(),
4347            ))
4348        })?;
4349        let pk_name = pk.name;
4350        let pk_ty = pk.ty;
4351
4352        // Serialize every instance, collecting (pk_value, full_map)
4353        // for the CASE branches and the IN clause. Skip rows whose
4354        // PK is still the default sentinel — they were never
4355        // persisted and a bulk UPDATE on them is a no-op anyway.
4356        let mut serialized: Vec<(
4357            serde_json::Value,
4358            serde_json::Map<String, serde_json::Value>,
4359        )> = Vec::with_capacity(instances.len());
4360        for instance in &instances {
4361            let map = serialize_to_map(instance)?;
4362            let pk_val = map.get(pk_name).cloned().unwrap_or(serde_json::Value::Null);
4363            if is_default_pk(pk_ty, &pk_val) {
4364                continue;
4365            }
4366            serialized.push((pk_val, map));
4367        }
4368        if serialized.is_empty() {
4369            return Ok(0);
4370        }
4371
4372        // Collect the list of non-PK columns to update from the
4373        // first row (every row contributes the same column set
4374        // because they're typed instances of T).
4375        let update_cols: Vec<&crate::orm::FieldSpec> =
4376            T::FIELDS.iter().filter(|f| !f.primary_key).collect();
4377
4378        // Build the UPDATE: one CASE per column, IN clause for the
4379        // WHERE. Goes through sea-query's update statement for
4380        // backend portability.
4381        let mut stmt = sea_query::Query::update();
4382        stmt.table(crate::db::router::schema_qualified_table(T::TABLE));
4383
4384        for field in &update_cols {
4385            // CASE pk_col
4386            //   WHEN <pk1> THEN <val1>
4387            //   WHEN <pk2> THEN <val2>
4388            //   ...
4389            // END
4390            let mut case = sea_query::CaseStatement::new();
4391            for (pk_val, map) in &serialized {
4392                let val = map
4393                    .get(field.name)
4394                    .cloned()
4395                    .unwrap_or(serde_json::Value::Null);
4396                let cell = json_to_sea_value(
4397                    field.ty,
4398                    &val,
4399                    field.nullable,
4400                    field.name,
4401                    fk_pk_hint(field),
4402                )?;
4403                let pk_sea = json_to_sea_value(pk_ty, pk_val, false, pk_name, None)?;
4404                case = case.case(sea_query::Expr::col(Alias::new(pk_name)).eq(pk_sea), cell);
4405            }
4406            stmt.value(Alias::new(field.name), case);
4407        }
4408
4409        // WHERE pk IN (<pk1>, <pk2>, ...)
4410        let pk_seas: Vec<sea_query::Value> = serialized
4411            .iter()
4412            .map(|(pk_val, _)| json_to_sea_value(pk_ty, pk_val, false, pk_name, None))
4413            .collect::<Result<_, _>>()?;
4414        stmt.and_where(sea_query::Expr::col(Alias::new(pk_name)).is_in(pk_seas));
4415
4416        let pool = resolve_pool::<T>(None, crate::db::RouteOp::Write);
4417        let affected = match pool {
4418            DbPool::Sqlite(pool) => {
4419                let (sql, values) = stmt.build_sqlx(SqliteQueryBuilder);
4420                sqlx::query_with::<sqlx::Sqlite, _>(&sql, values)
4421                    .execute(&pool)
4422                    .await
4423                    .map_err(WriteError::Sqlx)?
4424                    .rows_affected()
4425            }
4426            DbPool::Postgres(pool) => {
4427                let (sql, values) = stmt.build_sqlx(PostgresQueryBuilder);
4428                sqlx::query_with::<sqlx::Postgres, _>(&sql, values)
4429                    .execute(&pool)
4430                    .await
4431                    .map_err(WriteError::Sqlx)?
4432                    .rows_affected()
4433            }
4434        };
4435        Ok(affected)
4436    }
4437
4438    /// Run a hand-written SQL query and return typed `Vec<T>` rows.
4439    ///
4440    /// The escape hatch for queries the QuerySet builder can't (or
4441    /// shouldn't) model — CTEs, vendor-specific functions, ad-hoc
4442    /// reporting. Delegates to `sqlx::query_as` against the ambient
4443    /// pool and dispatches on backend, so user code stays portable.
4444    /// The string is sent verbatim; no parameter binding (use
4445    /// `Predicate` / the typed query path for parameterised
4446    /// queries). Inject input only after manual sanitisation.
4447    ///
4448    /// Skips the `select_related` / `prefetch_related` chain — those
4449    /// only apply to the typed QuerySet build path.
4450    pub async fn raw(&self, sql: &str) -> Result<Vec<T>, sqlx::Error>
4451    where
4452        T: for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
4453            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>,
4454    {
4455        // Routing: `raw` resolves to the READ database (most raw statements
4456        // are SELECTs, which this returns as `Vec<T>`). Under a read/write-
4457        // split router, a raw statement that WRITES must pin the write pool
4458        // explicitly via `.on(&pool)` / `.on_pg(&pool)`, since the router
4459        // cannot inspect arbitrary SQL to know it mutates.
4460        let pool = resolve_pool::<T>(None, crate::db::RouteOp::Read);
4461        match pool {
4462            DbPool::Sqlite(pool) => {
4463                sqlx::query_as::<sqlx::Sqlite, T>(sql)
4464                    .fetch_all(&pool)
4465                    .await
4466            }
4467            DbPool::Postgres(pool) => {
4468                sqlx::query_as::<sqlx::Postgres, T>(sql)
4469                    .fetch_all(&pool)
4470                    .await
4471            }
4472        }
4473    }
4474
4475    /// `bulk_create` against an explicit Postgres pool.
4476    pub async fn bulk_create_pg(
4477        &self,
4478        instances: Vec<T>,
4479        pool: &sqlx::PgPool,
4480    ) -> Result<u64, crate::orm::write::WriteError>
4481    where
4482        T: serde::Serialize,
4483    {
4484        if instances.is_empty() {
4485            return Ok(0);
4486        }
4487        let maps: Result<Vec<_>, _> = instances.iter().map(serialize_to_map).collect();
4488        let maps = maps?;
4489        let stmt = build_insert_many_for::<T>("postgres", &maps)?;
4490        let (sql, values) = stmt.build_sqlx(PostgresQueryBuilder);
4491        let result = sqlx::query_with::<sqlx::Postgres, _>(&sql, values)
4492            .execute(pool)
4493            .await?;
4494        Ok(result.rows_affected())
4495    }
4496}
4497
4498// QuerySetTx (struct + impl) moved to `super::tx`; re-exported above.
4499
4500impl<T: Model> Manager<T> {
4501    /// Begin a new query on this manager attached to the given open transaction.
4502    ///
4503    /// Sugar for `T::objects().on_tx(tx)` — lets callers skip the intermediate
4504    /// `QuerySet` construction when they want to go straight to a terminal:
4505    ///
4506    /// ```rust,ignore
4507    /// umbral::db::transaction(|tx| async move {
4508    ///     let post = Post::objects().on_tx(tx).create(new_post).await?;
4509    ///     Ok::<_, MyError>(post)
4510    /// }).await?;
4511    /// ```
4512    pub fn on_tx<'a>(&self, tx: &'a mut crate::db::Transaction) -> QuerySetTx<'a, T> {
4513        self.queryset().on_tx(tx)
4514    }
4515
4516    /// INSERT one row inside `tx` and return the populated row.
4517    ///
4518    /// This is the primary Manager-level entry point for transactional writes.
4519    /// Equivalent to `Post::objects().on_tx(tx).create(instance)` but more
4520    /// ergonomic when you only need the one INSERT (no filter chain needed).
4521    ///
4522    /// ```rust,ignore
4523    /// umbral::db::transaction(|tx| async move {
4524    ///     let post = Post::objects().create_in_tx(new_post, tx).await?;
4525    ///     Ok::<_, MyError>(post)
4526    /// }).await?;
4527    /// ```
4528    pub async fn create_in_tx(
4529        &self,
4530        instance: T,
4531        tx: &mut crate::db::Transaction,
4532    ) -> Result<T, crate::orm::write::WriteError>
4533    where
4534        T: serde::Serialize
4535            + for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
4536            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
4537            + HydrateRelated,
4538    {
4539        let map = serialize_to_map(&instance)?;
4540        let stmt = build_insert_one_for::<T>(tx.backend_name(), &map)?;
4541        match tx.backend_name() {
4542            "sqlite" => {
4543                let inner = tx.as_sqlite_mut().unwrap();
4544                let (sql, values) = stmt.build_sqlx(SqliteQueryBuilder);
4545                let mut row = sqlx::query_as_with::<sqlx::Sqlite, T, _>(&sql, values)
4546                    .fetch_one(&mut **inner)
4547                    .await?;
4548                row.set_m2m_parent_ids();
4549                Ok(row)
4550            }
4551            _ => {
4552                let inner = tx.as_pg_mut().unwrap();
4553                let (sql, values) = stmt.build_sqlx(PostgresQueryBuilder);
4554                let mut row = sqlx::query_as_with::<sqlx::Postgres, T, _>(&sql, values)
4555                    .fetch_one(&mut **inner)
4556                    .await?;
4557                row.set_m2m_parent_ids();
4558                Ok(row)
4559            }
4560        }
4561    }
4562
4563    /// INSERT many rows inside `tx`.
4564    ///
4565    /// Returns the number of rows inserted. Empty input is a no-op.
4566    pub async fn bulk_create_in_tx(
4567        &self,
4568        instances: Vec<T>,
4569        tx: &mut crate::db::Transaction,
4570    ) -> Result<u64, crate::orm::write::WriteError>
4571    where
4572        T: serde::Serialize,
4573    {
4574        if instances.is_empty() {
4575            return Ok(0);
4576        }
4577        let maps: Result<Vec<_>, _> = instances.iter().map(serialize_to_map).collect();
4578        let maps = maps?;
4579        let stmt = build_insert_many_for::<T>(tx.backend_name(), &maps)?;
4580        match tx.backend_name() {
4581            "sqlite" => {
4582                let inner = tx.as_sqlite_mut().unwrap();
4583                let (sql, values) = stmt.build_sqlx(SqliteQueryBuilder);
4584                let result = sqlx::query_with::<sqlx::Sqlite, _>(&sql, values)
4585                    .execute(&mut **inner)
4586                    .await?;
4587                Ok(result.rows_affected())
4588            }
4589            _ => {
4590                let inner = tx.as_pg_mut().unwrap();
4591                let (sql, values) = stmt.build_sqlx(PostgresQueryBuilder);
4592                let result = sqlx::query_with::<sqlx::Postgres, _>(&sql, values)
4593                    .execute(&mut **inner)
4594                    .await?;
4595                Ok(result.rows_affected())
4596            }
4597        }
4598    }
4599
4600    // =====================================================================
4601    // Per-instance signal-firing write methods.
4602    //
4603    // `save(instance)` and `delete_instance(instance)` are the methods that
4604    // fire the ORM lifecycle signals (`pre_save` / `post_save` /
4605    // `pre_delete` / `post_delete`). The existing bulk methods
4606    // (`create`, `bulk_create`, `QuerySet::update_values`,
4607    // `QuerySet::delete`) remain signal-free by design:
4608    // bulk operations bypass signals for performance.
4609    //
4610    // Signal name format: `<event>:<table>` — e.g. `post_save:post`.
4611    // Payload shapes:
4612    //   save:   `{ "instance": <M as JSON>, "created": bool }`
4613    //   delete: `{ "instance": <M as JSON> }`
4614    //
4615    // The `created` flag on save follows the convention:
4616    //   `true`  when the PK is the default sentinel → INSERT path.
4617    //   `false` when the PK is non-default           → UPDATE path.
4618    // =====================================================================
4619
4620    /// Save one instance, firing `pre_save` + `post_save` signals.
4621    ///
4622    /// Determines INSERT vs UPDATE by checking whether the primary key
4623    /// is the autoincrement sentinel (`0` for integers, nil UUID, empty
4624    /// string). If it is, an INSERT is performed (`created = true`);
4625    /// otherwise an `UPDATE ... WHERE pk = <value>` is run (`created = false`).
4626    ///
4627    /// Returns the row as it exists in the database after the write
4628    /// (populated PK for inserts, same row for updates).
4629    ///
4630    /// ## Signal contract
4631    ///
4632    /// - `pre_save:<table>` fires before the database write with
4633    ///   `{ "instance": ..., "created": bool, "actor": ... }`.
4634    /// - `post_save:<table>` fires after the database write with the
4635    ///   DB-read-back row and the same envelope keys.
4636    ///
4637    /// The `"actor"` value is set by the nearest enclosing
4638    /// [`crate::signals::with_actor`] scope; `Value::Null` when no
4639    /// scope is active.
4640    ///
4641    /// ## Bulk paths fire bulk signals, not per-row signals
4642    ///
4643    /// `Manager::create`, `Manager::bulk_create`, and
4644    /// `QuerySet::update_values` / `QuerySet::delete` do NOT fire
4645    /// per-row `post_save` / `post_delete`. They fire
4646    /// `bulk_post_save:<table>` / `bulk_post_delete:<table>` once per
4647    /// statement with the affected PKs in the payload. Use `save` /
4648    /// `delete_instance` when per-row signal semantics are needed.
4649    pub async fn save(&self, instance: T) -> Result<T, crate::orm::write::SaveError>
4650    where
4651        T: serde::Serialize
4652            + for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
4653            + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>,
4654    {
4655        use crate::orm::write::{SaveError, is_default_pk};
4656        // Determine INSERT vs UPDATE by inspecting the PK field.
4657        let pk_field = T::FIELDS
4658            .iter()
4659            .find(|f| f.primary_key)
4660            .ok_or(SaveError::NoPrimaryKey)?;
4661        let map = serialize_to_map(&instance).map_err(SaveError::Write)?;
4662        let pk_val = map
4663            .get(pk_field.name)
4664            .cloned()
4665            .unwrap_or(serde_json::Value::Null);
4666        let created = is_default_pk(pk_field.ty, &pk_val);
4667
4668        // Fire pre_save before the write.
4669        crate::signals::emit_pre_save::<T>(&instance, created).await;
4670
4671        let pool = resolve_pool::<T>(None, crate::db::RouteOp::Write);
4672        let backend = pool.backend_name();
4673
4674        if created {
4675            // INSERT path.
4676            let stmt = build_insert_one_for::<T>(backend, &map).map_err(SaveError::Write)?;
4677            let row = match pool {
4678                DbPool::Sqlite(pool) => {
4679                    let (sql, values) = stmt.build_sqlx(SqliteQueryBuilder);
4680                    sqlx::query_as_with::<sqlx::Sqlite, T, _>(&sql, values)
4681                        .fetch_one(&pool)
4682                        .await
4683                        .map_err(|e| SaveError::Write(crate::orm::write::WriteError::Sqlx(e)))?
4684                }
4685                DbPool::Postgres(pool) => {
4686                    let (sql, values) = stmt.build_sqlx(PostgresQueryBuilder);
4687                    sqlx::query_as_with::<sqlx::Postgres, T, _>(&sql, values)
4688                        .fetch_one(&pool)
4689                        .await
4690                        .map_err(|e| SaveError::Write(crate::orm::write::WriteError::Sqlx(e)))?
4691                }
4692            };
4693            // Fire post_save with the DB-populated row.
4694            crate::signals::emit_post_save::<T>(&row, true).await;
4695            Ok(row)
4696        } else {
4697            // UPDATE path: UPDATE ... WHERE <pk> = <value> RETURNING *.
4698            use sea_query::{Alias, Expr, Query};
4699
4700            // gaps2 #92 — snapshot the pre-UPDATE row for `pre_update` /
4701            // `post_update` subscribers, but ONLY when one exists. The
4702            // extra SELECT-by-PK is gated on `has_subscribers` so the
4703            // common UPDATE path (no `*_update` listener) pays nothing.
4704            // Best-effort TOCTOU: the snapshot reads the row before the
4705            // UPDATE; a concurrent writer between the two is accepted.
4706            let pre_table = T::TABLE;
4707            let want_pre = crate::signals::has_subscribers(&format!("pre_update:{pre_table}"));
4708            let want_post = crate::signals::has_subscribers(&format!("post_update:{pre_table}"));
4709            let previous: Option<T> = if want_pre || want_post {
4710                let mut sel = Query::select();
4711                sel.from(crate::db::router::schema_qualified_table(T::TABLE));
4712                for field in T::FIELDS {
4713                    sel.column(Alias::new(field.name));
4714                }
4715                let pk_sea_sel = crate::orm::write::json_to_sea_value(
4716                    pk_field.ty,
4717                    &pk_val,
4718                    false,
4719                    pk_field.name,
4720                    None,
4721                )
4722                .map_err(SaveError::Write)?;
4723                sel.and_where(Expr::col(Alias::new(pk_field.name)).eq(pk_sea_sel));
4724                sel.limit(1);
4725                match pool {
4726                    DbPool::Sqlite(ref pool) => {
4727                        let (sql, values) = sel.build_sqlx(SqliteQueryBuilder);
4728                        sqlx::query_as_with::<sqlx::Sqlite, T, _>(&sql, values)
4729                            .fetch_optional(pool)
4730                            .await
4731                            .map_err(|e| SaveError::Write(crate::orm::write::WriteError::Sqlx(e)))?
4732                    }
4733                    DbPool::Postgres(ref pool) => {
4734                        let (sql, values) = sel.build_sqlx(PostgresQueryBuilder);
4735                        sqlx::query_as_with::<sqlx::Postgres, T, _>(&sql, values)
4736                            .fetch_optional(pool)
4737                            .await
4738                            .map_err(|e| SaveError::Write(crate::orm::write::WriteError::Sqlx(e)))?
4739                    }
4740                }
4741            } else {
4742                None
4743            };
4744            // Fire pre_update before the UPDATE when both a snapshot exists
4745            // and a subscriber wants it.
4746            if want_pre {
4747                if let Some(prev) = &previous {
4748                    crate::signals::emit_pre_update::<T>(prev, &instance).await;
4749                }
4750            }
4751
4752            let mut stmt = Query::update();
4753            stmt.table(crate::db::router::schema_qualified_table(T::TABLE));
4754            for field in T::FIELDS {
4755                if field.primary_key {
4756                    continue;
4757                }
4758                let val = map
4759                    .get(field.name)
4760                    .cloned()
4761                    .unwrap_or(serde_json::Value::Null);
4762                let sea_val = crate::orm::write::json_to_sea_value(
4763                    field.ty,
4764                    &val,
4765                    field.nullable,
4766                    field.name,
4767                    fk_pk_hint(field),
4768                )
4769                .map_err(SaveError::Write)?;
4770                stmt.value(Alias::new(field.name), sea_val);
4771            }
4772            // WHERE pk = <value>
4773            let pk_sea = crate::orm::write::json_to_sea_value(
4774                pk_field.ty,
4775                &pk_val,
4776                false,
4777                pk_field.name,
4778                None,
4779            )
4780            .map_err(SaveError::Write)?;
4781            stmt.and_where(Expr::col(Alias::new(pk_field.name)).eq(pk_sea));
4782            // RETURNING * so we can return the updated row.
4783            stmt.returning_all();
4784
4785            let row = match pool {
4786                DbPool::Sqlite(pool) => {
4787                    let (sql, values) = stmt.build_sqlx(SqliteQueryBuilder);
4788                    sqlx::query_as_with::<sqlx::Sqlite, T, _>(&sql, values)
4789                        .fetch_one(&pool)
4790                        .await
4791                        .map_err(|e| SaveError::Write(crate::orm::write::WriteError::Sqlx(e)))?
4792                }
4793                DbPool::Postgres(pool) => {
4794                    let (sql, values) = stmt.build_sqlx(PostgresQueryBuilder);
4795                    sqlx::query_as_with::<sqlx::Postgres, T, _>(&sql, values)
4796                        .fetch_one(&pool)
4797                        .await
4798                        .map_err(|e| SaveError::Write(crate::orm::write::WriteError::Sqlx(e)))?
4799                }
4800            };
4801            // Fire post_save with created=false.
4802            crate::signals::emit_post_save::<T>(&row, false).await;
4803            // gaps2 #92 — post_update carries the pre-UPDATE snapshot (old)
4804            // and the freshly-written row (new). Only when a subscriber
4805            // exists and the snapshot was captured.
4806            if want_post {
4807                if let Some(prev) = &previous {
4808                    crate::signals::emit_post_update::<T>(prev, &row).await;
4809                }
4810            }
4811            Ok(row)
4812        }
4813    }
4814
4815    /// Delete one instance by primary key, firing `pre_delete` +
4816    /// `post_delete` signals.
4817    ///
4818    /// Issues `DELETE FROM <table> WHERE <pk> = <value>`. Returns the
4819    /// number of rows affected (0 if the row was already gone, 1 otherwise).
4820    ///
4821    /// ## Signal contract
4822    ///
4823    /// - `pre_delete:<table>` fires before the DELETE with
4824    ///   `{ "instance": ..., "actor": ... }`.
4825    /// - `post_delete:<table>` fires after the DELETE with the same
4826    ///   payload shape.
4827    ///
4828    /// The `"actor"` value is set by the nearest enclosing
4829    /// [`crate::signals::with_actor`] scope; `Value::Null` when no
4830    /// scope is active. The instance value passed to both signals is
4831    /// the value supplied by the caller — not a DB read-back. If you
4832    /// need the freshest DB state before deletion, fetch it first with
4833    /// `.get(...)` then pass to this method.
4834    ///
4835    /// ## Bulk paths fire bulk signals
4836    ///
4837    /// `QuerySet::delete()` (the filter-chain DELETE) fires
4838    /// `bulk_post_delete:<table>` with the list of affected PKs, not
4839    /// per-row `pre_delete` / `post_delete`. Use `delete_instance` for
4840    /// per-row signal semantics.
4841    pub async fn delete_instance(&self, instance: &T) -> Result<u64, crate::orm::write::SaveError>
4842    where
4843        T: serde::Serialize,
4844    {
4845        use crate::orm::write::SaveError;
4846        let pk_field = T::FIELDS
4847            .iter()
4848            .find(|f| f.primary_key)
4849            .ok_or(SaveError::NoPrimaryKey)?;
4850        let map = serialize_to_map(instance).map_err(SaveError::Write)?;
4851        let pk_val = map
4852            .get(pk_field.name)
4853            .cloned()
4854            .unwrap_or(serde_json::Value::Null);
4855
4856        // Fire pre_delete before the write.
4857        crate::signals::emit_pre_delete::<T>(instance).await;
4858
4859        let pk_sea =
4860            crate::orm::write::json_to_sea_value(pk_field.ty, &pk_val, false, pk_field.name, None)
4861                .map_err(SaveError::Write)?;
4862
4863        use sea_query::{Alias, Expr, Query};
4864        // Feature #72 — soft-delete redirect. For models tagged
4865        // `#[umbral(soft_delete)]`, set deleted_at instead of issuing
4866        // DELETE. Pre/post_delete signals still fire because the
4867        // logical contract ("this row is gone from the visible
4868        // table") is preserved — only the physical SQL changed.
4869        // Hard-delete is not exposed through delete_instance (it's
4870        // a typed per-row helper); call `QuerySet::filter(pk =
4871        // instance.id).hard_delete().delete()` when you need it.
4872        let stmt_sql = if T::SOFT_DELETE {
4873            let now = chrono::Utc::now();
4874            let mut up = Query::update();
4875            up.table(crate::db::router::schema_qualified_table(T::TABLE));
4876            up.value(
4877                Alias::new("deleted_at"),
4878                sea_query::Value::ChronoDateTimeUtc(Some(Box::new(now))),
4879            );
4880            up.and_where(Expr::col(Alias::new(pk_field.name)).eq(pk_sea));
4881            // Idempotency guard — don't bump an already-set timestamp.
4882            up.and_where(Expr::col(Alias::new("deleted_at")).is_null());
4883            SoftOrHardStatement::Update(up)
4884        } else {
4885            let mut stmt = Query::delete();
4886            stmt.from_table(crate::db::router::schema_qualified_table(T::TABLE));
4887            stmt.and_where(Expr::col(Alias::new(pk_field.name)).eq(pk_sea));
4888            SoftOrHardStatement::Delete(stmt)
4889        };
4890
4891        let pool = resolve_pool::<T>(None, crate::db::RouteOp::Write);
4892        let affected = match (&pool, stmt_sql) {
4893            (DbPool::Sqlite(pool), SoftOrHardStatement::Delete(stmt)) => {
4894                let (sql, values) = stmt.build_sqlx(SqliteQueryBuilder);
4895                sqlx::query_with::<sqlx::Sqlite, _>(&sql, values)
4896                    .execute(pool)
4897                    .await
4898                    .map_err(|e| SaveError::Write(crate::orm::write::WriteError::Sqlx(e)))?
4899                    .rows_affected()
4900            }
4901            (DbPool::Postgres(pool), SoftOrHardStatement::Delete(stmt)) => {
4902                let (sql, values) = stmt.build_sqlx(PostgresQueryBuilder);
4903                sqlx::query_with::<sqlx::Postgres, _>(&sql, values)
4904                    .execute(pool)
4905                    .await
4906                    .map_err(|e| SaveError::Write(crate::orm::write::WriteError::Sqlx(e)))?
4907                    .rows_affected()
4908            }
4909            (DbPool::Sqlite(pool), SoftOrHardStatement::Update(stmt)) => {
4910                let (sql, values) = stmt.build_sqlx(SqliteQueryBuilder);
4911                sqlx::query_with::<sqlx::Sqlite, _>(&sql, values)
4912                    .execute(pool)
4913                    .await
4914                    .map_err(|e| SaveError::Write(crate::orm::write::WriteError::Sqlx(e)))?
4915                    .rows_affected()
4916            }
4917            (DbPool::Postgres(pool), SoftOrHardStatement::Update(stmt)) => {
4918                let (sql, values) = stmt.build_sqlx(PostgresQueryBuilder);
4919                sqlx::query_with::<sqlx::Postgres, _>(&sql, values)
4920                    .execute(pool)
4921                    .await
4922                    .map_err(|e| SaveError::Write(crate::orm::write::WriteError::Sqlx(e)))?
4923                    .rows_affected()
4924            }
4925        };
4926
4927        // Fire post_delete after the write.
4928        crate::signals::emit_post_delete::<T>(instance).await;
4929
4930        Ok(affected)
4931    }
4932}
4933
4934/// Internal enum used by `Manager::delete_instance` to dispatch on
4935/// soft-delete vs hard-delete without duplicating the four backend ×
4936/// statement match arms inline. One variant per SQL shape.
4937enum SoftOrHardStatement {
4938    Delete(sea_query::DeleteStatement),
4939    Update(sea_query::UpdateStatement),
4940}
4941
4942// Hydration helpers (hydrate_select_related, hydrate_select_related_nested,
4943// hydrate_prefetch_related, hydrate_reverse_fk_for_field, fetch_related_as_json,
4944// fetch_reverse_fk_children) moved to `super::hydration`.
4945
4946// Insert builders (serialize_to_map, build_insert_one_for, build_insert_many_for)
4947// and pk_field moved to `super::write_helpers`.
4948
4949// `decode_agg_sqlite` / `decode_agg_pg` moved to
4950// `backend_sqlite::decode_agg` / `backend_pg::decode_agg`.
4951
4952// =========================================================================
4953// #113: M2M-via-JOIN dedup decoders
4954//
4955// When .join_related() includes one or more M2M fields, the result
4956// set has one row per (parent, child) combo, so a parent with N
4957// matching children appears N times. The caller wants ONE T per
4958// parent with the M2M slot populated.
4959//
4960// The algorithm:
4961//   - First time we see a parent PK: decode T via FromRow, hydrate
4962//     any FK joins from this row.
4963//   - Every subsequent row for the same parent: extract the M2M
4964//     child JsonValue (or skip on LEFT JOIN miss).
4965//   - Dedup children by (parent_pk, field, child_pk) so that
4966//     joining TWO M2Ms doesn't multiply the child sets (the JOIN
4967//     produces parent × m2m1 × m2m2 rows).
4968//   - Hand each parent its M2M buckets via set_m2m_resolved_json.
4969// =========================================================================
4970
4971fn dedup_decode_sqlite<T: Model + HydrateRelated>(
4972    raw_rows: &[sqlx::sqlite::SqliteRow],
4973    fk_join_fields: &[String],
4974    m2m_join_fields: &[String],
4975) -> Result<Vec<T>, sqlx::Error>
4976where
4977    T: for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>,
4978{
4979    // PK-agnostic dedup: read the parent PK back through the shape-aware
4980    // decoder (using the parent model's PK SqlType) and key by `pk_key`,
4981    // so i64 / String / Uuid parents all dedup correctly.
4982    let parent_pk_col = crate::migrate::ModelMeta::for_::<T>()
4983        .fields
4984        .into_iter()
4985        .find(|c| c.primary_key)
4986        .ok_or_else(|| {
4987            sqlx::Error::Protocol(format!(
4988                "umbral::orm::join_related: model `{}` has no primary key, M2M JOIN \
4989                 dedup requires one",
4990                T::NAME
4991            ))
4992        })?;
4993    let registered = crate::migrate::registered_models();
4994    let mut typed: Vec<T> = Vec::new();
4995    let mut idx_by_pk: HashMap<String, usize> = HashMap::new();
4996    // (parent_pk_key, field) → Vec<JsonValue> + a Set of seen child PK keys.
4997    let mut buckets: HashMap<(String, String), Vec<JsonValue>> = HashMap::new();
4998    let mut seen_children: HashMap<(String, String), std::collections::HashSet<String>> =
4999        HashMap::new();
5000    for row in raw_rows {
5001        let Ok(parent_json) = crate::orm::dynamic::decode_to_json(row, &parent_pk_col) else {
5002            continue;
5003        };
5004        let parent_key = crate::orm::pk_key(&parent_json);
5005        if let std::collections::hash_map::Entry::Vacant(e) = idx_by_pk.entry(parent_key.clone()) {
5006            let mut t = <T as sqlx::FromRow<_>>::from_row(row)?;
5007            backend_sqlite::hydrate_joined_rels::<T>(&mut t, row, fk_join_fields)?;
5008            e.insert(typed.len());
5009            typed.push(t);
5010        }
5011        for m2m_field in m2m_join_fields {
5012            // The M2M slot is keyed by the FIRST segment (the M2M field
5013            // name); the full `m2m_field` path may carry an onward FK
5014            // chain (`"tags__category"`) the child decoder nests.
5015            let m2m_seg = m2m_field.split("__").next().unwrap_or(m2m_field.as_str());
5016            let Some(rel) = T::M2M_RELATIONS.iter().find(|r| r.field_name == m2m_seg) else {
5017                continue;
5018            };
5019            let Some(child_meta) = registered.iter().find(|m| m.table == rel.target_table) else {
5020                continue;
5021            };
5022            let Some(child_json) =
5023                backend_sqlite::extract_m2m_child_json::<T>(row, m2m_field, child_meta)?
5024            else {
5025                continue;
5026            };
5027            // Dedup by child PK (PK-agnostic) so multi-M2M cartesian
5028            // doesn't duplicate this field's children.
5029            let child_key = child_json
5030                .as_object()
5031                .and_then(|m| {
5032                    let pk_col = child_meta.fields.iter().find(|c| c.primary_key)?;
5033                    m.get(&pk_col.name).map(crate::orm::pk_key)
5034                })
5035                .unwrap_or_default();
5036            let key = (parent_key.clone(), m2m_seg.to_string());
5037            let seen = seen_children.entry(key.clone()).or_default();
5038            if seen.insert(child_key) {
5039                buckets.entry(key).or_default().push(child_json);
5040            }
5041        }
5042    }
5043    for ((parent_key, field), children) in buckets {
5044        if let Some(&idx) = idx_by_pk.get(&parent_key) {
5045            typed[idx].set_m2m_resolved_json(&field, children);
5046        }
5047    }
5048    // LEFT JOIN miss handling: walk every (parent, field) pair
5049    // we expected to populate and zero-init any slot that never
5050    // got a hit. Without this a parent with no matching M2M
5051    // children would leave its slot None — distinguishable from
5052    // "loaded, empty" only by callers checking the absence.
5053    for (parent_key, &idx) in idx_by_pk.iter() {
5054        for field in m2m_join_fields {
5055            let seg = field.split("__").next().unwrap_or(field.as_str());
5056            if !seen_children.contains_key(&(parent_key.clone(), seg.to_string())) {
5057                typed[idx].set_m2m_resolved_json(seg, Vec::new());
5058            }
5059        }
5060    }
5061    Ok(typed)
5062}
5063
5064fn dedup_decode_pg<T: Model + HydrateRelated>(
5065    raw_rows: &[sqlx::postgres::PgRow],
5066    fk_join_fields: &[String],
5067    m2m_join_fields: &[String],
5068) -> Result<Vec<T>, sqlx::Error>
5069where
5070    T: for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>,
5071{
5072    // PK-agnostic dedup — see the SQLite variant.
5073    let parent_pk_col = crate::migrate::ModelMeta::for_::<T>()
5074        .fields
5075        .into_iter()
5076        .find(|c| c.primary_key)
5077        .ok_or_else(|| {
5078            sqlx::Error::Protocol(format!(
5079                "umbral::orm::join_related: model `{}` has no primary key, M2M JOIN \
5080                 dedup requires one",
5081                T::NAME
5082            ))
5083        })?;
5084    let registered = crate::migrate::registered_models();
5085    let mut typed: Vec<T> = Vec::new();
5086    let mut idx_by_pk: HashMap<String, usize> = HashMap::new();
5087    let mut buckets: HashMap<(String, String), Vec<JsonValue>> = HashMap::new();
5088    let mut seen_children: HashMap<(String, String), std::collections::HashSet<String>> =
5089        HashMap::new();
5090    for row in raw_rows {
5091        let Ok(parent_json) = crate::orm::dynamic::decode_pg_to_json(row, &parent_pk_col) else {
5092            continue;
5093        };
5094        let parent_key = crate::orm::pk_key(&parent_json);
5095        if let std::collections::hash_map::Entry::Vacant(e) = idx_by_pk.entry(parent_key.clone()) {
5096            let mut t = <T as sqlx::FromRow<_>>::from_row(row)?;
5097            backend_pg::hydrate_joined_rels::<T>(&mut t, row, fk_join_fields)?;
5098            e.insert(typed.len());
5099            typed.push(t);
5100        }
5101        for m2m_field in m2m_join_fields {
5102            let m2m_seg = m2m_field.split("__").next().unwrap_or(m2m_field.as_str());
5103            let Some(rel) = T::M2M_RELATIONS.iter().find(|r| r.field_name == m2m_seg) else {
5104                continue;
5105            };
5106            let Some(child_meta) = registered.iter().find(|m| m.table == rel.target_table) else {
5107                continue;
5108            };
5109            let Some(child_json) =
5110                backend_pg::extract_m2m_child_json::<T>(row, m2m_field, child_meta)?
5111            else {
5112                continue;
5113            };
5114            let child_key = child_json
5115                .as_object()
5116                .and_then(|m| {
5117                    let pk_col = child_meta.fields.iter().find(|c| c.primary_key)?;
5118                    m.get(&pk_col.name).map(crate::orm::pk_key)
5119                })
5120                .unwrap_or_default();
5121            let key = (parent_key.clone(), m2m_seg.to_string());
5122            let seen = seen_children.entry(key.clone()).or_default();
5123            if seen.insert(child_key) {
5124                buckets.entry(key).or_default().push(child_json);
5125            }
5126        }
5127    }
5128    for ((parent_key, field), children) in buckets {
5129        if let Some(&idx) = idx_by_pk.get(&parent_key) {
5130            typed[idx].set_m2m_resolved_json(&field, children);
5131        }
5132    }
5133    // Same LEFT JOIN miss zero-init as the SQLite path.
5134    for (parent_key, &idx) in idx_by_pk.iter() {
5135        for field in m2m_join_fields {
5136            let seg = field.split("__").next().unwrap_or(field.as_str());
5137            if !seen_children.contains_key(&(parent_key.clone(), seg.to_string())) {
5138                typed[idx].set_m2m_resolved_json(seg, Vec::new());
5139            }
5140        }
5141    }
5142    Ok(typed)
5143}