djogi 0.1.0-alpha.2

Model-first web framework for Rust — web-framework-agnostic core; Axum integration opt-in via the `axum` feature flag
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
//! Bulk update — `QuerySet::update(|f| f.col.set(v))` + `QuerySet::delete`.
//!
//! # What
//!
//! [`UpdateAssignment`] is a single `SET column = value` leaf produced by
//! [`FieldRef::set`]. [`IntoAssignments`] is the closure-return shape — one
//! assignment or many — that [`QuerySet::update`] accepts. [`UpdateStmt`]
//! is the terminal-pending struct the builder returns; the actual `UPDATE`
//! runs when the caller invokes [`UpdateStmt::execute`] with an executor.
//!
//! The sibling `.delete(...)` terminal lives on [`QuerySet`] directly rather
//! than going through an `UpdateStmt`-style intermediate — DELETE has no
//! payload to carry across a builder/terminal split, so it is wired the
//! same way [`QuerySet::fetch_all`] and friends are.
//!
//! # Why the terminal split for UPDATE
//!
//! `.update(|f| ...)` returns a pending [`UpdateStmt`] rather than a future
//! because the builder shape is symmetric with the read terminals
//! ([`QuerySet::fetch_all`] also takes the executor *at terminal time*,
//! not during builder accumulation). The intermediate type also lets
//! callers:
//!
//! - **Log or inspect** the queued assignments before execution.
//! - **Retry** an UPDATE without re-running the filter closure. `UpdateStmt`
//!   is `Clone` because [`QuerySet`] is `Clone`; cloning the statement is
//!   exactly as cheap as cloning the underlying queryset.
//! - **Branch on short-circuit**: callers that already know a mutation is
//!   inert can avoid constructing it; otherwise mutation terminals run
//!   `validate_mutation_read_tail(...)` first (mirroring `insert_select.rs`),
//!   then short-circuit on pure `none()` / empty assignments.
//!
//! # Constructor-only invariant on `UpdateAssignment`
//!
//! `UpdateAssignment`'s fields are `pub(crate)`; the only way to build one
//! from outside this crate is [`FieldRef::set`], which funnels through
//! [`IntoFilterValue`]. This mirrors [`crate::query::filter::FilterClause`]'s
//! lock-down added in Task 8: there is no "build a raw assignment" escape
//! hatch, so the column literal is always macro-baked and the value is
//! always a structurally-valid scalar `FilterValue` (never a `List`, `Pair`,
//! or `Null`, which the UPDATE emitter has no sensible rendering for).
//!
//! Users who need `col = col + N`, `col = other_col`, or other
//! field-vs-field / arithmetic assignments reach for the expression
//! builder [`FieldRef::set_expr`]: it wraps an [`crate::expr::Expr<V>`]
//! IR tree into the same [`UpdateAssignment`] shape the literal `.set(v)`
//! produces. For richer SQL the emitter cannot express (`NOW() - interval
//! '1 day'`, `CASE WHEN ...` before Task 5 lands), the raw
//! `SqlAccumulator` escape hatch in [`crate::raw`] is still there.
//!
//! # `updated_at = now()` stamping
//!
//! The SQL emitter ([`build_update`]) always appends `updated_at = now()`
//! to the SET list, even when the caller's closure omits it. Parity with
//! the single-row [`crate::model::Model::save`] path, which also bumps
//! `updated_at` on every write. Users who need to preserve `updated_at`
//! across a bulk update reach for the raw escape hatch — same as any
//! other ORM layer that makes auditing hard to bypass.
//!
//! # Mutation validation + `is_empty` short-circuit
//!
//! UPDATE/DELETE terminals validate unsupported read-tail state first (same
//! contract as `insert_select.rs`), then honour `TASK6:empty_contract`.
//! A pure [`QuerySet::none`]-derived queryset (or a pure empty assignment
//! list, for UPDATE) still returns `Ok(0)` / empty output without issuing
//! SQL. The grep marker lives on the `is_empty` field in `queryset.rs` —
//! if that field's shape ever changes, every terminal that honours it
//! surfaces through the marker.
//!
//! [`build_update`]: crate::query::sql::build_update
//! [`FieldRef`]: crate::query::field::FieldRef
//! [`QuerySet`]: crate::query::queryset::QuerySet
#![allow(clippy::manual_async_fn)]

use crate::DjogiError;
use crate::context::DjogiContext;
use crate::model::Model;
use crate::pg::accumulator::as_params;
use crate::pg::decode::{FromJoinedPgRow, FromPgRow, decode_at};
use crate::query::condition::FilterValue;
use crate::query::field::{FieldRef, IntoFilterValue};
use crate::query::queryset::QuerySet;
use crate::query::returning::ReturningPair;
use crate::query::sql::{
    build_delete, build_delete_returning, build_update, build_update_returning_ids,
    build_update_returning_pairs,
};
use crate::query::terminal::auto_set_tenant;
use std::future::Future;
use std::marker::PhantomData;

/// A single `SET column = value` clause — produced by [`FieldRef::set`]
/// (literal) or [`FieldRef::set_expr`] (expression IR).
///
/// # Invariants
///
/// Fields are `pub(crate)` so the only way to construct an
/// `UpdateAssignment` from outside this crate is [`FieldRef::set`] or
/// [`FieldRef::set_expr`]. The literal path funnels through
/// [`IntoFilterValue`], so a `Literal` payload is always a structurally-
/// valid scalar `FilterValue` (never `List`, `Pair`, or `Null`). The
/// expression path takes a typed `Expr<V>` wrapper; the inner
/// `ExprNode` is crate-private so downstream code cannot fabricate
/// unsafe tree shapes. `column` is always a macro-baked `&'static str`.
///
/// `Debug` + `Clone` are derived so callers can log pending assignments
/// and retry an `UpdateStmt` without re-running the builder closure.
#[derive(Debug, Clone)]
pub struct UpdateAssignment {
    /// SQL column name — macro-baked literal, never user input.
    pub(crate) column: &'static str,
    /// Assignment payload — either a projected literal or an IR tree.
    pub(crate) value: AssignmentValue,
}

/// The right-hand side of an `UpdateAssignment`.
///
/// `Literal` carries a single bind value (the Phase 2 shape). `Expr`
/// carries an IR node whose emission (`col = col + 1`, `col = other_col`,
/// arithmetic combinations) is handled by the Phase 4 expression emitter.
#[derive(Debug, Clone)]
pub(crate) enum AssignmentValue {
    /// Literal value — emitted as a single `$n` bind via
    /// [`crate::query::sql::push_filter_value`].
    Literal(FilterValue),
    /// Expression IR — emitted via
    /// [`crate::expr::sql::emit_expr`]; the emitter recurses through
    /// arithmetic, field refs, and comparisons.
    Expr(crate::expr::node::ExprNode),
}

impl UpdateAssignment {
    /// Internal constructor for literal assignments — called only by
    /// [`FieldRef::set`]. Kept `pub(crate)` because the public-API path
    /// is the typed `FieldRef::set` surface; hand-building an assignment
    /// from downstream code would bypass the `V: IntoFilterValue` type
    /// check.
    pub(crate) fn new(column: &'static str, value: FilterValue) -> Self {
        Self {
            column,
            value: AssignmentValue::Literal(value),
        }
    }

    /// Internal constructor for expression-backed assignments — called
    /// only by [`FieldRef::set_expr`]. The `ExprNode` tree is already
    /// `V`-safe at construction; the emitter walks it directly.
    pub(crate) fn new_expr(column: &'static str, node: crate::expr::node::ExprNode) -> Self {
        Self {
            column,
            value: AssignmentValue::Expr(node),
        }
    }

    /// Internal accessor for the column name. Used by the SQL emitter
    /// ([`crate::query::sql::build_update`]) to render the SET clause.
    #[doc(hidden)]
    pub fn column(&self) -> &'static str {
        self.column
    }

    /// Internal accessor for the assignment payload. Used by the SQL
    /// emitter to dispatch between the literal-bind and expression-IR
    /// emission paths.
    #[doc(hidden)]
    pub(crate) fn value(&self) -> &AssignmentValue {
        &self.value
    }
}

/// Typed constructor — `field.set(value)` produces a single
/// [`UpdateAssignment`] that slots into the closure passed to
/// [`QuerySet::update`].
///
/// Mirrors the `V: IntoFilterValue` bound every other [`FieldRef`] lookup
/// method uses, so newtype columns and string-like types compose the same
/// way they do in `.eq` / `.gte` / `.in_list`.
///
/// ```ignore
/// Post::objects()
///     .filter(|f| f.published().eq(true))
///     .update(|f| f.view_count().set(999i32))
///     .execute(&mut ctx).await?;
/// ```
impl<M: Model, V: IntoFilterValue> FieldRef<M, V> {
    /// Build a typed `SET column = value` assignment for
    /// [`QuerySet::update`].
    #[must_use = "assignments are lazy — drop one and the SET clause is silently omitted"]
    pub fn set(self, value: V) -> UpdateAssignment {
        UpdateAssignment::new(self.column(), value.into_filter_value())
    }
}

/// Expression-backed assignment builder — `field.set_expr(Expr<V>)`
/// produces an [`UpdateAssignment`] whose SQL right-hand side is the
/// compiled IR tree rather than a single bind.
///
/// Typical call sites:
///
/// ```ignore
/// // col = col + 1
/// Account::objects()
///     .update(|f| f.balance().set_expr(f.balance().as_expr() + Expr::literal(1i64)))
///     .execute(&mut ctx).await?;
///
/// // col = other_col  (field-vs-field copy)
/// Account::objects()
///     .update(|f| f.balance().set_expr(f.overdraft_limit().as_expr()))
///     .execute(&mut ctx).await?;
/// ```
///
/// The `V: IntoFilterValue` bound is kept for symmetry with
/// [`FieldRef::set`] — both entry points flow through the same typed
/// surface, and the `ExprNode` tree has already committed to a value
/// type by the time the user reaches this method.
impl<M: Model, V: IntoFilterValue> FieldRef<M, V> {
    /// Build an expression-backed `SET column = <expr>` assignment for
    /// [`QuerySet::update`]. `<expr>` composes field references,
    /// arithmetic, and literals via the [`crate::expr::Expr<V>`] IR.
    #[must_use = "assignments are lazy — drop one and the SET clause is silently omitted"]
    pub fn set_expr(self, expr: crate::expr::Expr<V>) -> UpdateAssignment {
        UpdateAssignment::new_expr(self.column(), expr.node)
    }

    /// Build a column-to-column copy assignment: `SET self = other`.
    ///
    /// Sugar for `self.set_expr(other.as_expr())`. Both sides must be
    /// refs on the same model `M` with matching value type `V` — the
    /// type system enforces this at compile time. Both `self` and
    /// `other` are taken by value because [`FieldRef`] is `Copy`.
    ///
    /// # Example
    ///
    /// ```ignore
    /// // Reset each account's working_balance to its confirmed_balance.
    /// Account::objects()
    ///     .filter(|f| f.needs_reset().eq(true))
    ///     .update(|f| f.working_balance().set_field(f.confirmed_balance()))
    ///     .execute(&mut ctx).await?;
    /// ```
    #[must_use = "assignments are lazy — drop one and the SET clause is silently omitted"]
    pub fn set_field(self, other: FieldRef<M, V>) -> UpdateAssignment {
        UpdateAssignment::new_expr(self.column(), other.as_expr().node)
    }
}

/// Numeric-column arithmetic assignments — `increment(amount)` and
/// `decrement(amount)` emit `SET col = col + $n` and `SET col = col - $n`
/// respectively.
///
/// The `V: Numeric` bound is the same sealed trait that gates
/// [`crate::expr::Expr<V>`] arithmetic (`+`, `-`, `*`, `/`). Djogi's
/// [`crate::expr::arithmetic::Numeric`] is sealed to the framework-blessed
/// numeric types: `i16`, `i32`, `i64`, `f32`, `f64`, and
/// `time::Duration`. Calling `increment` / `decrement` on a non-numeric
/// field type is a compile error.
///
/// The additional `Into<crate::expr::Expr<V>>` bound lets
/// [`crate::expr::Expr::literal`] wrap `amount` into the expression IR;
/// every `Numeric` type has a matching `From<V> for Expr<V>` impl so
/// this bound is always satisfied alongside `Numeric`.
///
/// # Examples
///
/// ```ignore
/// use djogi::prelude::*;
///
/// // Increment a post's view count by 1.
/// Post::objects()
///     .filter(|f| f.id().eq(post_id))
///     .update(|f| f.view_count().increment(1i32))
///     .execute(&mut ctx).await?;
///
/// // Deduct a withdrawal amount from an account balance.
/// Account::objects()
///     .filter(|f| f.id().eq(account_id))
///     .update(|f| f.balance().decrement(withdrawal_amount))
///     .execute(&mut ctx).await?;
/// ```
impl<M: Model, V> FieldRef<M, V>
where
    V: IntoFilterValue + crate::expr::arithmetic::Numeric + Into<crate::expr::Expr<V>>,
{
    /// Build `SET col = col + amount` — emits `col = col + $n` in SQL.
    ///
    /// `amount` is bound as a positional parameter (`$n`), not inlined.
    #[must_use = "assignments are lazy — drop one and the SET clause is silently omitted"]
    pub fn increment(self, amount: V) -> UpdateAssignment {
        let expr = self.as_expr() + crate::expr::Expr::literal(amount);
        UpdateAssignment::new_expr(self.column(), expr.node)
    }

    /// Build `SET col = col - amount` — emits `col = col - $n` in SQL.
    ///
    /// `amount` is bound as a positional parameter (`$n`), not inlined.
    #[must_use = "assignments are lazy — drop one and the SET clause is silently omitted"]
    pub fn decrement(self, amount: V) -> UpdateAssignment {
        let expr = self.as_expr() - crate::expr::Expr::literal(amount);
        UpdateAssignment::new_expr(self.column(), expr.node)
    }
}

impl<M: Model> FieldRef<M, crate::Interval> {
    /// Build `SET col = col + amount` — emits `col = col + $n` in SQL.
    ///
    /// `amount` is bound as a positional parameter (`$n`), not inlined.
    #[must_use = "assignments are lazy — drop one and the SET clause is silently omitted"]
    pub fn increment(self, amount: crate::Interval) -> UpdateAssignment {
        let expr = self.as_expr() + crate::expr::Expr::literal(amount);
        UpdateAssignment::new_expr(self.column(), expr.node)
    }

    /// Build `SET col = col - amount` — emits `col = col - $n` in SQL.
    ///
    /// `amount` is bound as a positional parameter (`$n`), not inlined.
    #[must_use = "assignments are lazy — drop one and the SET clause is silently omitted"]
    pub fn decrement(self, amount: crate::Interval) -> UpdateAssignment {
        let expr = self.as_expr() - crate::expr::Expr::literal(amount);
        UpdateAssignment::new_expr(self.column(), expr.node)
    }
}

/// Closure-return shape for [`QuerySet::update`]. The closure can return
/// a single [`UpdateAssignment`] or a `Vec<UpdateAssignment>` — this trait
/// bridges both so the user writes the natural thing at the call site.
///
/// Sealed-by-convention: only the two shipped impls (`UpdateAssignment`
/// and `Vec<UpdateAssignment>`) exist, and there is no public trait method
/// that a downstream impl would add value beyond. Users do not implement
/// this trait by hand.
pub trait IntoAssignments {
    /// Flatten `self` into the ordered list of assignments the UPDATE
    /// emitter renders as `SET col = $n, col = $n, ...`.
    fn into_assignments(self) -> Vec<UpdateAssignment>;
}

impl IntoAssignments for UpdateAssignment {
    fn into_assignments(self) -> Vec<UpdateAssignment> {
        vec![self]
    }
}

impl IntoAssignments for Vec<UpdateAssignment> {
    fn into_assignments(self) -> Vec<UpdateAssignment> {
        self
    }
}

/// Terminal-pending bulk update. [`UpdateStmt::execute`] emits the
/// `UPDATE` and returns the affected row count.
///
/// The struct is `Clone` because [`QuerySet`] is `Clone` — both fields
/// are cheap structural vectors, so cloning an `UpdateStmt` to retry a
/// transient failure (deadlock, serialization error) is a constant-time
/// operation that does not re-run the user's builder closure.
///
/// `Clone` / `Debug` are hand-rolled (not derived) so they do not
/// require `T: Clone` / `T: Debug` — `UpdateStmt` never owns or borrows
/// a `T`, it only carries a `PhantomData<fn() -> T>` tag, mirroring the
/// pattern on [`QuerySet<T>`].
#[must_use = "UpdateStmt is inert — call .execute(ctx) to run the UPDATE"]
pub struct UpdateStmt<T: Model> {
    /// The accumulated queryset — contributes the `WHERE` clause and the
    /// `is_empty` short-circuit flag.
    pub(crate) qs: QuerySet<T>,
    /// The `SET col = $n, ...` payload built by the closure the user
    /// passed to [`QuerySet::update`].
    pub(crate) assignments: Vec<UpdateAssignment>,
    /// Covariant `T` tag — matches [`QuerySet<T>`]'s variance so an
    /// `UpdateStmt<T>` composes with the same `Send + Sync` story.
    pub(crate) _m: PhantomData<fn() -> T>,
}

impl<T: Model> Clone for UpdateStmt<T> {
    fn clone(&self) -> Self {
        UpdateStmt {
            qs: self.qs.clone(),
            assignments: self.assignments.clone(),
            _m: PhantomData,
        }
    }
}

impl<T: Model> std::fmt::Debug for UpdateStmt<T> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("UpdateStmt")
            .field("table", &T::table_name())
            .field("qs", &self.qs)
            .field("assignments", &self.assignments)
            .finish()
    }
}

impl<T: Model> UpdateStmt<T> {
    /// Run the accumulated UPDATE and return the affected row count.
    ///
    /// Validation for unsupported queryset read-tail state runs first, then
    /// inert paths short-circuit.
    ///
    /// Short-circuits to `Ok(0)` when either:
    /// - The underlying queryset is `QuerySet::none()`-derived
    ///   (`is_empty()` is `true`), or
    /// - The closure produced zero assignments — `UPDATE ... SET ...`
    ///   with an empty SET list is a Postgres syntax error, so the
    ///   short-circuit here is both a contract shortcut and a safety
    ///   rail.
    ///
    /// Takes `&mut DjogiContext`, matching [`QuerySet::fetch_all`] /
    /// [`QuerySet::count`] — the same call site works against a pool-
    /// backed context or a transaction-backed one post-Phase-4 retrofit.
    ///
    /// Returns `u64` — the row-count from `tokio_postgres`'s
    /// `CommandTag::rows_affected()`. Postgres' UPDATE rowcount is
    /// non-negative by definition, so there is no sign conversion at
    /// the call site.
    ///
    /// # Cache invalidation
    ///
    /// For macro-backed models with a registered Punnu, transaction-backed bulk
    /// updates collect affected primary keys internally with `UPDATE ... RETURNING
    /// <pk>` and enqueue one `on_commit` invalidation callback. Warmed entries are
    /// evicted after commit and preserved after rollback.
    ///
    /// The public return type remains `u64`. Pool-backed contexts keep the
    /// existing row-count fast path; wrap the call in `atomic(...)` when bulk cache
    /// invalidation must be commit/rollback-aware.
    pub fn execute<'ctx>(
        self,
        ctx: &'ctx mut DjogiContext,
    ) -> impl Future<Output = Result<u64, DjogiError>> + Send + 'ctx
    where
        T: 'ctx,
    {
        async move {
            self.qs.validate_mutation_read_tail("update")?;
            // TASK6:empty_contract — runs AFTER validation (see
            // insert_select.rs validate-then-short-circuit contract):
            // structural-none queryset OR empty assignment list returns
            // `Ok(0)` without touching the DB.
            //
            // The assignment-list branch is load-bearing: a closure that
            // produces `vec![]` would otherwise lead to
            // `UPDATE <table> SET , updated_at = now() WHERE ...`, which
            // Postgres rejects with a syntax error. Short-circuiting here
            // keeps the user's call site free of "why did this panic?"
            // and matches the structural-empty contract on `QuerySet::none()`.
            if self.qs.is_empty() || self.assignments.is_empty() {
                return Ok(0);
            }
            auto_set_tenant::<T>(ctx).await?;
            if T::__djogi_should_collect_bulk_update_ids(ctx) {
                // Transaction-backed + Punnu registered: collect affected IDs for deferred invalidation.
                let acc = build_update_returning_ids(&self.qs, &self.assignments)
                    .map_err(crate::DjogiError::from)?;
                let pk_column = T::descriptor().pk_column().expect(
                    "Model implementations with CRUD support must expose a primary-key column",
                );
                let (sql, binds) = acc.into_parts();
                let params = as_params(&binds);
                // NOTE: loads all affected PKs into memory (unbounded allocation).
                // Operators with large matched sets should batch updates via
                // scoped filters. See docs/guide/queries.md for guidance.
                let rows = ctx.query_all(&sql, &params).await?;

                let mut ids = Vec::with_capacity(rows.len());
                for row in &rows {
                    ids.push(decode_at::<T::Pk>(row, 0, pk_column)?);
                }

                let rows_affected = ids.len() as u64;
                <T as Model>::__djogi_enqueue_bulk_on_save_cache_invalidation(ctx, ids)?;
                Ok(rows_affected)
            } else {
                // Pool-backed or no Punnu: keep the existing fast row-count path.
                let acc =
                    build_update(&self.qs, &self.assignments).map_err(crate::DjogiError::from)?;
                let (sql, binds) = acc.into_parts();
                let params = as_params(&binds);
                let rows_affected = ctx.execute(&sql, &params).await?;
                Ok(rows_affected)
            }
        }
    }

    /// Run the accumulated UPDATE and return a before/after snapshot pair for
    /// every affected row.
    ///
    /// Uses PostgreSQL 18 `RETURNING WITH (OLD AS __djogi_old, NEW AS
    /// __djogi_new)` to retrieve both row images in a single round-trip. The
    /// rows are decoded using [`FromJoinedPgRow`] with the `"__djogi_old__"`
    /// and `"__djogi_new__"` prefixes.
    ///
    /// # Short-circuits
    ///
    /// Validation for unsupported queryset read-tail state runs first, then
    /// inert paths short-circuit.
    ///
    /// Returns `Ok(Vec::new())` without issuing any SQL when:
    /// - The underlying queryset is `QuerySet::none()`-derived.
    /// - The assignment list is empty (an UPDATE with an empty SET list is a
    ///   Postgres syntax error).
    ///
    /// # Warning — unbounded materialization
    ///
    /// This method loads **one `ReturningPair<T>` per affected row** into memory.
    /// On large tables or unfiltered updates this can exhaust available memory.
    /// Narrow the queryset with `.filter(...)` before calling
    /// `execute_returning_pairs`, or process rows in application-level chunks
    /// using multiple filtered update passes.
    ///
    /// # Protected fields
    ///
    /// Both `old` and `new` contain full model values, including
    /// `#[field(protected(...))]` fields. Field-level redaction is presently
    /// not implemented. Log or persist pairs with care.
    ///
    /// # PostgreSQL 18 only
    ///
    /// Djogi has a hard PostgreSQL 18 floor. No fallback or polyfill is
    /// provided for older PostgreSQL versions.
    ///
    /// # Hooks, outbox, and cache invalidation
    ///
    /// Lifecycle hooks are **not** run for this bulk path:
    /// `before_save`/`after_save` do not fire per row.
    ///
    /// For `#[model(events)]` models, this method emits one `Save` outbox row
    /// per returned `pair.new` payload using a batched insert path.
    ///
    /// Cache invalidation is enqueued once per bulk statement with
    /// `InvalidationReason::OnSave` via `ctx.on_commit`, invalidating every
    /// returned `pair.new` primary key after commit and preserving warmed entries
    /// after rollback.
    pub fn execute_returning_pairs<'ctx>(
        self,
        ctx: &'ctx mut DjogiContext,
    ) -> impl Future<Output = Result<Vec<ReturningPair<T>>, DjogiError>> + Send + 'ctx
    where
        T: FromPgRow + FromJoinedPgRow + 'ctx,
    {
        async move {
            self.qs
                .validate_mutation_read_tail("execute_returning_pairs")?;
            // TASK6:empty_contract — runs AFTER validation (see
            // insert_select.rs validate-then-short-circuit contract):
            // structural-none queryset OR empty assignment list returns an
            // empty vector without touching the DB.
            if self.qs.is_empty() || self.assignments.is_empty() {
                return Ok(Vec::new());
            }
            auto_set_tenant::<T>(ctx).await?;
            let acc = build_update_returning_pairs(&self.qs, &self.assignments)
                .map_err(crate::DjogiError::from)?;
            let (sql, binds) = acc.into_parts();
            let params = as_params(&binds);
            let rows = ctx.query_all(&sql, &params).await?;
            let mut pairs = Vec::with_capacity(rows.len());
            for row in &rows {
                let old = T::from_joined_pg_row(row, "__djogi_old__")?;
                let new = T::from_joined_pg_row(row, "__djogi_new__")?;
                pairs.push(ReturningPair { old, new });
            }
            let outbox_rows: Vec<&T> = pairs.iter().map(|pair| &pair.new).collect();
            <T as Model>::__djogi_emit_save_outbox_batch(ctx, &outbox_rows).await?;
            let cache_ids: Vec<T::Pk> = pairs
                .iter()
                .map(|pair| pair.new.pk_value().clone())
                .collect();
            <T as Model>::__djogi_enqueue_bulk_on_save_cache_invalidation(ctx, cache_ids)?;
            Ok(pairs)
        }
    }
}

impl<T: Model> QuerySet<T> {
    /// Build a bulk `UPDATE <table> SET col = val, ... [WHERE ...]`
    /// statement. The closure receives the model's default-constructed
    /// `Fields` handle and returns one or more typed
    /// [`UpdateAssignment`]s (either a single assignment or a `Vec`).
    ///
    /// Two assignment forms are accepted in the closure:
    ///
    /// - Literal: `f.col().set(value)` where `value: V: IntoFilterValue`.
    /// - Expression IR: `f.col().set_expr(expr)` for `col = col + 1`,
    ///   `col = NOW()`, `col = other_col`, and similar shapes the
    ///   [`crate::expr`] builder supports.
    ///
    /// For SQL the expression builder cannot express, reach for
    /// [`DjogiContext::raw_execute`](crate::DjogiContext::raw_execute).
    ///
    /// The returned [`UpdateStmt`] is inert — the actual SQL runs when
    /// the caller invokes [`UpdateStmt::execute`] with a
    /// `&mut DjogiContext`. Splitting the builder from the terminal keeps
    /// the call-site shape symmetric with the read terminals
    /// (`fetch_all`, `count`, etc.) and lets callers log, inspect, or
    /// retry the pending statement without re-running the closure.
    ///
    /// ```ignore
    /// Post::objects()
    ///     .filter(|f| f.published().eq(true))
    ///     .update(|f| f.view_count().set(999i32))
    ///     .execute(&mut ctx)
    ///     .await?;
    /// ```
    #[must_use = "UpdateStmt is inert — call .execute(ctx) to run the UPDATE"]
    pub fn update<F, A>(self, f: F) -> UpdateStmt<T>
    where
        F: FnOnce(T::Fields) -> A,
        A: IntoAssignments,
    {
        let assignments = f(T::Fields::default()).into_assignments();
        UpdateStmt {
            qs: self,
            assignments,
            _m: PhantomData,
        }
    }

    /// Run `DELETE FROM <table> [WHERE ...]` and return the affected row
    /// count.
    ///
    /// Unlike [`QuerySet::update`], DELETE carries no payload across a
    /// builder/terminal split, so this method is a terminal directly —
    /// same shape as [`QuerySet::fetch_all`] / [`QuerySet::count`].
    ///
    /// Validation for unsupported queryset read-tail state runs first, then
    /// inert paths short-circuit.
    ///
    /// Short-circuits to `Ok(0)` for `QuerySet::none()`-derived
    /// querysets (the `TASK6:empty_contract`). A DELETE with no WHERE
    /// clause (an unfiltered queryset) is still a real DELETE — it
    /// removes every row in the table. Callers who want "wipe this
    /// table" DDL-style reach for `TRUNCATE` via
    /// [`DjogiContext::raw_execute`](crate::DjogiContext::raw_execute);
    /// this method only runs `DELETE FROM`.
    ///
    /// ```ignore
    /// Post::objects()
    ///     .filter(|f| f.published().eq(false))
    ///     .delete(&mut ctx)
    ///     .await?;
    /// ```
    pub fn delete<'ctx>(
        self,
        ctx: &'ctx mut DjogiContext,
    ) -> impl Future<Output = Result<u64, DjogiError>> + Send + 'ctx
    where
        T: 'ctx,
    {
        async move {
            self.validate_mutation_read_tail("delete")?;
            // TASK6:empty_contract — runs AFTER validation (see
            // insert_select.rs validate-then-short-circuit contract):
            // structural-none queryset returns `Ok(0)` with no SQL.
            if self.is_empty() {
                return Ok(0);
            }
            auto_set_tenant::<T>(ctx).await?;
            let acc = build_delete(&self).map_err(crate::DjogiError::from)?;
            let (sql, binds) = acc.into_parts();
            let params = as_params(&binds);
            let rows_affected = ctx.execute(&sql, &params).await?;
            Ok(rows_affected)
        }
    }

    /// Run `DELETE FROM <table> [WHERE ...]` and return the deleted rows as
    /// typed model instances.
    ///
    /// Uses PostgreSQL 18 `RETURNING WITH (OLD AS __djogi_old)` to retrieve the
    /// pre-delete row snapshot for every deleted row. The rows are decoded using
    /// [`FromJoinedPgRow`] with the `"__djogi_old__"` prefix.
    ///
    /// DELETE has no `NEW` side — the returned `Vec<T>` contains only old-row
    /// snapshots. For UPDATE old/new pairs see
    /// [`UpdateStmt::execute_returning_pairs`].
    ///
    /// # Short-circuit
    ///
    /// Validation for unsupported queryset read-tail state runs first, then
    /// inert paths short-circuit.
    ///
    /// Returns `Ok(Vec::new())` for `QuerySet::none()`-derived querysets
    /// without issuing any SQL.
    ///
    /// # Warning — unbounded materialization
    ///
    /// This method loads **one `T` per deleted row** into memory. On large
    /// tables or unfiltered deletes this can exhaust available memory. Narrow
    /// the queryset with `.filter(...)` or process rows in application-level
    /// chunks.
    ///
    /// # Protected fields
    ///
    /// The returned snapshots contain full model values, including
    /// `#[field(protected(...))]` fields. Field-level redaction is presently
    /// not implemented.
    ///
    /// # PostgreSQL 18 only
    ///
    /// Djogi has a hard PostgreSQL 18 floor. No fallback or polyfill is
    /// provided for older PostgreSQL versions.
    pub fn delete_returning<'ctx>(
        self,
        ctx: &'ctx mut DjogiContext,
    ) -> impl Future<Output = Result<Vec<T>, DjogiError>> + Send + 'ctx
    where
        T: FromPgRow + FromJoinedPgRow + 'ctx,
    {
        async move {
            self.validate_mutation_read_tail("delete_returning")?;
            // TASK6:empty_contract — runs AFTER validation (see
            // insert_select.rs validate-then-short-circuit contract):
            // structural-none queryset returns empty output with no SQL.
            if self.is_empty() {
                return Ok(Vec::new());
            }
            auto_set_tenant::<T>(ctx).await?;
            let acc = build_delete_returning(&self).map_err(crate::DjogiError::from)?;
            let (sql, binds) = acc.into_parts();
            let params = as_params(&binds);
            let rows = ctx.query_all(&sql, &params).await?;
            let mut deleted = Vec::with_capacity(rows.len());
            for row in &rows {
                deleted.push(T::from_joined_pg_row(row, "__djogi_old__")?);
            }
            Ok(deleted)
        }
    }
}

#[cfg(test)]
mod tests {
    //! Unit tests for the builder surface — no SQL, no executor. Live
    //! DB coverage is in `tests/integration/phase2_queryset.rs`.
    //!
    //! We reach through the `FieldRef` API to build assignments so the
    //! `pub(crate)` fields on `UpdateAssignment` never leak into the
    //! test module's observed surface (same pattern as the Task 8
    //! `FilterClause` tests).

    use super::*;
    use crate::descriptor::ModelDescriptor;
    use crate::query::field::FieldRef;

    // Minimal `Model` impl — mirrors the `Fake` used in `query::field` and
    // `query::sql` unit tests so this file's checks stay independent of
    // `#[model]` macro expansion.
    struct Fake;
    impl crate::model::__sealed::Sealed for Fake {}
    #[allow(clippy::manual_async_fn)]
    impl Model for Fake {
        type Pk = i64;
        type Fields = ();
        fn table_name() -> &'static str {
            "fakes"
        }
        fn pk_value(&self) -> &i64 {
            unreachable!()
        }
        fn descriptor() -> &'static ModelDescriptor {
            unreachable!()
        }
        fn get(
            _ctx: &mut crate::context::DjogiContext,
            _id: i64,
        ) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send {
            async { unreachable!() }
        }
        fn create(
            _ctx: &mut crate::context::DjogiContext,
            _v: Self,
        ) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send {
            async { unreachable!() }
        }
        fn save<'ctx>(
            &'ctx mut self,
            _ctx: &'ctx mut crate::context::DjogiContext,
        ) -> impl std::future::Future<Output = Result<(), crate::DjogiError>> + Send + 'ctx
        {
            async { unreachable!() }
        }
        fn delete(
            self,
            _ctx: &mut crate::context::DjogiContext,
        ) -> impl std::future::Future<Output = Result<(), crate::DjogiError>> + Send {
            async { unreachable!() }
        }
        fn refresh_from_db<'ctx>(
            &'ctx self,
            _ctx: &'ctx mut crate::context::DjogiContext,
        ) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send + 'ctx
        {
            async { unreachable!() }
        }
    }

    #[test]
    fn field_ref_set_builds_assignment_with_projected_value() {
        let f: FieldRef<Fake, i32> = FieldRef::new("view_count");
        let a = f.set(42i32);
        assert_eq!(a.column(), "view_count");
        assert!(matches!(
            a.value(),
            crate::query::update::AssignmentValue::Literal(FilterValue::I32(42))
        ));
    }

    #[test]
    fn field_ref_set_expr_builds_expression_assignment() {
        use crate::expr::Expr;
        let f: FieldRef<Fake, i64> = FieldRef::new("balance");
        // balance + 5
        let a = f.set_expr(f.as_expr() + Expr::literal(5i64));
        assert_eq!(a.column(), "balance");
        assert!(matches!(
            a.value(),
            crate::query::update::AssignmentValue::Expr(_)
        ));
    }

    #[test]
    fn into_assignments_single_wraps_in_vec() {
        let f: FieldRef<Fake, bool> = FieldRef::new("published");
        let a = f.set(true);
        let v = a.into_assignments();
        assert_eq!(v.len(), 1);
        assert_eq!(v[0].column(), "published");
    }

    #[test]
    fn into_assignments_vec_passes_through() {
        let a: FieldRef<Fake, i32> = FieldRef::new("view_count");
        let b: FieldRef<Fake, bool> = FieldRef::new("published");
        let vs = vec![a.set(0i32), b.set(false)];
        let out = vs.into_assignments();
        assert_eq!(out.len(), 2);
        assert_eq!(out[0].column(), "view_count");
        assert_eq!(out[1].column(), "published");
    }

    #[test]
    fn update_stmt_clones_preserve_assignments() {
        // `UpdateStmt: Clone` is documented on the struct — assert the
        // clone preserves the assignment list without re-running any
        // closure (there isn't one to re-run post-build).
        let f: FieldRef<Fake, i32> = FieldRef::new("view_count");
        let qs: QuerySet<Fake> = QuerySet::new();
        let stmt = qs.update(|_| f.set(42i32));
        let cloned = stmt.clone();
        assert_eq!(cloned.assignments.len(), 1);
        assert_eq!(cloned.assignments[0].column(), "view_count");
    }

    #[test]
    fn set_field_builds_expr_assignment() {
        // set_field(other) — wraps `other.as_expr().node` so the
        // variant is Expr, not Literal. The SQL emitter renders
        // `target_col = source_col` (no bind slot for a column ref).
        let target: FieldRef<Fake, i64> = FieldRef::new("balance");
        let source: FieldRef<Fake, i64> = FieldRef::new("overdraft_limit");
        let a = target.set_field(source);
        assert_eq!(a.column(), "balance");
        assert!(
            matches!(a.value(), AssignmentValue::Expr(_)),
            "set_field must produce an Expr assignment, not a Literal"
        );
    }

    #[test]
    fn increment_builds_add_expr_assignment() {
        // increment(n) — column + literal. The variant must be Expr
        // (the Add node wraps both the field-ref node and the literal
        // node) so the emitter renders `balance = balance + $n`.
        let f: FieldRef<Fake, i64> = FieldRef::new("balance");
        let a = f.increment(10i64);
        assert_eq!(a.column(), "balance");
        assert!(
            matches!(a.value(), AssignmentValue::Expr(_)),
            "increment must produce an Expr assignment"
        );
    }
}