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
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
//! Terminal read methods on [`QuerySet<T>`].
//!
//! # What
//!
//! Every method here is a terminal — it consumes the queryset, executes SQL
//! against a caller-provided `&mut DjogiContext`, and returns a decoded result
//! (`Vec<T>`, `T`, `Option<T>`, `i64`, `bool`). This is the **only** place
//! in the query layer that talks to the database.
//!
//! # Why kept in its own file
//!
//! Splitting read terminals out of `queryset.rs` keeps each file auditable:
//! the builder file deals only with structural transforms (filter/order/limit
//! accumulation), this file owns SQL execution + error mapping. Writes
//! (`update` / `delete`) land in a sibling `update.rs` module in a later
//! task rather than being mixed into this file. Each layer can be reviewed
//! for its own invariants (no SQL in queryset.rs; no mutation of accumulated
//! state in terminal.rs beyond the documented `limit` override in
//! `fetch_one` / `first`).
//!
//! # Context dispatch
//!
//! Each terminal takes `&mut DjogiContext` and calls through the context's
//! execution helpers (`query_all`, `query_opt`, `query_one`, `execute`),
//! which dispatch to either a pool-acquired connection or the active
//! transaction connection transparently. The same call site works against
//! a pool-backed context or a transaction-backed one.
//!
//! # `is_empty` short-circuit contract
//!
//! Every terminal honours the `QuerySet::none()` contract — a queryset
//! marked `is_empty = true` returns the empty result **without issuing any
//! SQL**:
//!
//! | Method       | Empty result                                   |
//! |--------------|------------------------------------------------|
//! | `fetch_all`  | `Ok(vec![])`                                   |
//! | `fetch_one`  | `Err(DjogiError::NotFound { table: T::... })`  |
//! | `first`      | `Ok(None)`                                     |
//! | `count`      | `Ok(0)`                                        |
//! | `exists`     | `Ok(false)`                                    |
//!
//! The grep marker `TASK6:empty_contract` on the `is_empty` field in
//! `queryset.rs` is the anchor for this contract — if the field shape ever
//! changes, that marker surfaces every terminal that needs updating.
//!
//! # `fetch_one` row-count strategy
//!
//! `fetch_one` expects **exactly one** row. Rather than issuing two round
//! trips (`COUNT(*)` then `SELECT`), it rewrites the user's `limit` to 2
//! before building the SELECT. A single-element result is success; a
//! two-element result proves "more than one matches" without scanning the
//! whole table, which matters for unbounded-cardinality filters (e.g.
//! `published = true` on a posts table). Zero rows -> `NotFound`.
//!
//! # Why RPITIT (not `async fn`)
//!
//! Every terminal returns `impl Future<Output = ...> + Send` rather than
//! using bare `async fn`. The explicit `+ Send` bound matches the Phase 1
//! `Model` trait shape (`model.rs`) and guarantees the returned future can
//! be `.await`ed across task boundaries — required for any async runtime
//! context that spawns terminals onto a multi-thread runtime (e.g. an Axum
//! handler running on Tokio's multi-thread runtime under the opt-in `axum`
//! feature).
//!
//! `clippy::manual_async_fn` fires on this pattern; the lint is allowed at
//! the module level because the explicit-bound form is the deliberate
//! choice, not an oversight.
//!
//! [`ContextInner`]: crate::context::DjogiContext
#![allow(clippy::manual_async_fn)]

use crate::DjogiError;
use crate::context::DjogiContext;
use crate::model::Model;
use crate::pg::accumulator::{SqlAccumulator, as_params};
use crate::pg::decode::{FromJoinedPgRow, FromPgRow, try_get_scalar};
use crate::query::queryset::QuerySet;
use crate::query::sql::{build_count, build_exists, build_select, build_select_joined};
use crate::query::stream::{DEFAULT_FETCH_SIZE, ModelCursorStream, build_model_stream};
use crate::relation::joined_row::JoinedRow;
use crate::relation::prefetch::{PrefetchedRow, apply_prefetches};
use crate::relation::select_related::{apply_select_related, stitch_prefetches_into_joined};
use std::collections::HashMap;
use std::future::Future;
use std::hash::Hash;

// ── Auto-tenant helper ────────────────────────────────────────────────────

/// If `T` has a `tenant_key` and `ctx.auth()` carries a `tenant_id`, call
/// `ctx.ensure_tenant_set(tenant_id)` so the `app.tenant_id` GUC is active
/// before any SQL is issued.
///
/// This helper is shared by all QuerySet terminals. The borrow is structured
/// to avoid overlapping borrows: `ctx.auth()` borrows `ctx` immutably;
/// cloning the `String` drops that borrow before `ctx.ensure_tenant_set`
/// takes `&mut ctx`.
///
/// Behavior by case:
///
/// - `T::descriptor().tenant_key` is `None` → no-op (model has no RLS column).
/// - `ctx.auth()` is `None` → no-op (no auth context attached; pre-auth
///   or non-tenant code path).
/// - `ctx.auth().tenant_id` is `Some(tid)` → delegates to
///   `ctx.ensure_tenant_set(tid)` which re-issues `SET LOCAL` when the
///   requested tid differs from the currently-applied one.
/// - `ctx.auth().tenant_id` is `None` AND a previous tid is still applied
///   → clears the `app.tenant_id` GUC via `ctx.clear_tenant()`. Without
///   this reset, a `set_auth(auth_with_tenant)` followed by
///   `set_auth(auth_without_tenant)` inside one transaction would leave
///   the earlier tenant's `SET LOCAL` active silently — a cross-tenant
///   leak. A `tracing::warn!` is also emitted (unless
///   `ctx.with_no_tenant_scope()` was called) so the caller sees the
///   transition.
pub(crate) async fn auto_set_tenant<T: Model>(ctx: &mut DjogiContext) -> Result<(), DjogiError> {
    if T::descriptor().tenant_key.is_none() {
        return Ok(());
    }
    // No auth attached → hands-off. Users driving `set_tenant` manually
    // (explicit Phase 5 tenancy flow, admin tooling, test harnesses) must
    // not have their explicit GUC state clobbered by the auto-wiring.
    if ctx.auth().is_none() {
        return Ok(());
    }
    let tid: Option<String> = ctx.auth().and_then(|a| a.tenant_id.clone());
    match tid {
        Some(tid) => ctx.ensure_tenant_set(&tid).await?,
        None => {
            // Auth IS attached but carries no tenant_id. If an earlier
            // auth-driven ensure_tenant_set applied a tid, reset it so
            // subsequent queries don't run under the stale GUC — this is
            // the Phase 5.5 phase-boundary fix for the
            // "set_auth(auth_with_tenant) → set_auth(auth_without_tenant)
            // leaves old SET LOCAL active" bug class.
            if ctx.applied_tenant_id().is_some() {
                ctx.clear_tenant().await?;
            }
            if !ctx.__tenant_scope_suppressed_for_macros() {
                tracing::warn!(
                    model = std::any::type_name::<T>(),
                    "auth attached but tenant_id is None on a tenant-keyed model; \
                     queries will span tenants — call ctx.with_no_tenant_scope() to suppress",
                );
            }
        }
    }
    Ok(())
}

// ── Row-returning terminals (require `T: FromPgRow`) ────────────────────────

impl<T: Model> QuerySet<T>
where
    T: FromPgRow,
{
    /// Build the `SELECT` SQL this queryset would execute, without
    /// touching a database. **Internal-test plumbing — never call this
    /// from adopter code.**
    ///
    /// Tests that pin SQL shape (column ordering, bind ordinals,
    /// portable predicate lowering, joined-select qualification) reach
    /// for this hook to assert the textual contract directly without
    /// needing `pg_stat_statements` server-side configuration. Mirrors
    /// the pre-existing `VisageQuerySet::__sql_for_test` shape.
    ///
    /// The double-underscore prefix is the framework's
    /// `feedback_macro_path_routing` convention for "macro-internal
    /// public-by-necessity surface, do not depend on" — same as
    /// `__make_field_ref_with_path` and friends. The method stays
    /// `pub` (cross-crate test access requires it) but `#[doc(hidden)]`
    /// keeps it out of rustdoc.
    ///
    /// Phase 8eta PR3 introduces this helper so the regression test
    /// `phase8eta_queryset_filter_preservation` can verify SQL parity
    /// through the post-flip root accessor surface. Returns the raw SQL
    /// string only; callers needing both SQL and binds use the
    /// underlying `build_select` (crate-private) instead.
    #[doc(hidden)]
    pub fn __sql_for_test(&self) -> Result<String, crate::DjogiError> {
        let acc = crate::query::sql::build_select(self).map_err(crate::DjogiError::from)?;
        let (sql, _binds) = acc.into_parts();
        Ok(sql)
    }
}

impl<T: Model> QuerySet<T>
where
    T: FromPgRow + Send + Unpin,
{
    /// Execute the query and collect every matching row into a `Vec<T>`.
    ///
    /// A `QuerySet::none()`-derived queryset returns `Ok(Vec::new())` without
    /// touching the database — see the module docs for the full short-
    /// circuit contract.
    pub fn fetch_all<'ctx>(
        self,
        ctx: &'ctx mut DjogiContext,
    ) -> impl Future<Output = Result<Vec<T>, DjogiError>> + Send + 'ctx
    where
        T: 'ctx,
    {
        async move {
            // TASK6:empty_contract — structural-none queryset, no SQL.
            if self.is_empty() {
                return Ok(Vec::new());
            }
            auto_set_tenant::<T>(ctx).await?;
            // Snapshot the cache binding before we consume `self` in
            // `build_select(&self)` — the SQL emitter borrows the
            // queryset, but the post-fetch hook needs to outlive that
            // borrow. Cluster 8δ T7.3. The clone is `Arc::clone` on
            // the trait-object handle (see `queryset.rs` Clone impl
            // for the cache_target field rationale), so it's cheap.
            let cache_target = self.cache_target.clone();
            let acc = build_select(&self).map_err(DjogiError::from)?;
            let (sql, binds) = acc.into_parts();
            let params = as_params(&binds);
            let rows = ctx.query_all(&sql, &params).await?;
            let result: Vec<T> = rows
                .iter()
                .map(|r| T::from_pg_row(r))
                .collect::<Result<Vec<T>, _>>()?;
            // Cluster 8δ T7.3 post-fetch cache hook. Fires only when
            // `.cache(&punnu)` was called on the queryset — adopters
            // who didn't bind a Punnu pay zero (the `Option::is_none`
            // branch is the optimiser's hot path here). The trait
            // object's `insert(&T)` clones internally where the
            // `T: Clone` bound is satisfied (the `.cache(...)`
            // builder requires it), so this terminal stays free of
            // a `T: Clone` bound and the existing fetch_all surface
            // is unchanged for callers who don't bind a cache.
            //
            // Errors from `Punnu::insert` are logged-and-swallowed
            // inside `PunnuCacheTarget::insert`; see the granular
            // plan §3 commit T7.3 risk note for the rationale.
            if let Some(target) = cache_target.as_ref() {
                for row in &result {
                    target.insert(row).await;
                }
            }
            Ok(result)
        }
    }

    /// Execute the query and require **exactly one** matching row.
    ///
    /// - Zero rows -> [`DjogiError::NotFound`].
    /// - Two or more rows -> [`DjogiError::MultipleObjects`] (via `LIMIT 2`;
    ///   `count_seen = 2`).
    ///
    /// User-supplied `limit` on the queryset is ignored — this terminal
    /// owns the row-count probe.
    pub fn fetch_one<'ctx>(
        self,
        ctx: &'ctx mut DjogiContext,
    ) -> impl Future<Output = Result<T, DjogiError>> + Send + 'ctx
    where
        T: 'ctx,
    {
        async move {
            // TASK6:empty_contract — structural-none treats as "no row
            // matched" on purpose: the caller asked for exactly-one and
            // got zero, which is precisely what `NotFound` means.
            if self.is_empty() {
                return Err(DjogiError::not_found(T::table_name()));
            }
            auto_set_tenant::<T>(ctx).await?;
            // Override the user's LIMIT (if any) with 2 so we can
            // distinguish the single-row success path from the
            // multiple-rows error path without a `COUNT(*)` round trip.
            let mut qs = self;
            qs.limit = Some(2);
            // Cluster 8δ T7.3: snapshot the cache binding before we
            // consume `qs` in `build_select(&qs)`. The success path
            // below feeds the (sole) decoded row to the bound Punnu;
            // the error paths (NotFound / MultipleObjects) skip the
            // hook because no user-facing row materialised — the
            // caller wouldn't have been able to observe a cached
            // entry from a failed lookup either, and inserting a row
            // that the caller never saw would surface in the Punnu's
            // event stream as an unexplained Insert.
            let cache_target = qs.cache_target.clone();
            let acc = build_select(&qs).map_err(DjogiError::from)?;
            let (sql, binds) = acc.into_parts();
            let params = as_params(&binds);
            let rows = ctx.query_all(&sql, &params).await?;
            match rows.len() {
                0 => Err(DjogiError::not_found(T::table_name())),
                1 => {
                    // `into_iter().next().unwrap()` is safe — we just
                    // matched `len() == 1`. `expect` instead of `unwrap`
                    // keeps the message actionable in the unlikely event a
                    // future refactor reshapes the branch.
                    let row = rows
                        .into_iter()
                        .next()
                        .expect("rows.len() == 1 was just matched");
                    let decoded = T::from_pg_row(&row)?;
                    // Post-fetch cache hook (success path only). See
                    // the comment on `cache_target` snapshot above for
                    // why the error branches skip the insert.
                    if let Some(target) = cache_target.as_ref() {
                        target.insert(&decoded).await;
                    }
                    Ok(decoded)
                }
                // `n` here is a sentinel: because we force `LIMIT 2`
                // above, `n` is always exactly 2 on this branch — not the
                // true matching-row count. The cap is intentional (one
                // round trip instead of a `COUNT(*)` probe); callers who
                // need the precise count follow up with `.count()`. Ship
                // the sentinel through to `MultipleObjects.count_seen`; the
                // error message renders "at least 2".
                n => Err(DjogiError::multiple_objects(T::table_name(), n)),
            }
        }
    }

    /// Execute the query and collect every matching row into a
    /// `Vec<PrefetchedRow<T>>`, materialising every relation registered
    /// via [`QuerySet::prefetch`](crate::query::QuerySet::prefetch) in
    /// follow-up SQL queries and stitching the results back into per-row
    /// wrappers.
    ///
    /// # Why a separate terminal (not a change to `fetch_all`)
    ///
    /// Preserving [`fetch_all`](Self::fetch_all)'s `Vec<T>` return type
    /// keeps the Phase 2 terminal stable across Phase 3: a queryset
    /// built without prefetches and fetched via `fetch_all` returns
    /// exactly what it did before Task 4 landed. Prefetches are an
    /// opt-in extension reachable through the dedicated
    /// `fetch_all_prefetched` entry — no pre-existing call site is
    /// forced into `Vec<PrefetchedRow<T>>`. This also makes prefetch
    /// registrations free on querysets whose terminal happens to be
    /// `fetch_all`: the `prefetch_paths` field is ignored on that path,
    /// which is documented on the field itself.
    ///
    /// # Short-circuit contract
    ///
    /// Honours the same `is_empty` short-circuit as the other terminals:
    /// a structural-none queryset returns `Ok(Vec::new())` without
    /// touching the database. An empty main result also short-circuits
    /// the prefetch pass — no prefetch loader runs when there are no
    /// parent rows to stitch against.
    ///
    /// # Context shape
    ///
    /// Takes `&mut DjogiContext` matching every other terminal. Both
    /// pool-backed and transaction-backed contexts are supported: the
    /// prefetch loader threads `&mut ContextInner` internally and
    /// dispatches each fetch to either the pool or the outer
    /// transaction via inline-match. Prefetch fan-out inside an
    /// `atomic()` scope (Phase 4 Task 1) works transparently and sees
    /// the scope's uncommitted writes.
    // TODO(8δ T7.x): does NOT yet honour `cache_target` — see the
    // remote anchor at the bottom of this impl block (above `stream`)
    // for the cluster-wide deferral rationale.
    pub fn fetch_all_prefetched<'ctx>(
        self,
        ctx: &'ctx mut DjogiContext,
    ) -> impl Future<Output = Result<Vec<PrefetchedRow<T>>, DjogiError>> + Send + 'ctx
    where
        T::Pk: Clone + Send + Sync + 'static,
        T: 'ctx,
    {
        async move {
            // TASK6:empty_contract — structural-none queryset returns
            // `Vec::new()` without touching the DB. Mirrors the
            // `fetch_all` short-circuit.
            if self.is_empty() {
                return Ok(Vec::new());
            }
            auto_set_tenant::<T>(ctx).await?;

            // Snapshot prefetch paths before we consume `self` in the
            // main-query build. The shared SQL emitter borrows the
            // queryset, so we pull out what we need first.
            let prefetches = self.prefetch_paths.clone();

            // Main query — identical shape to `fetch_all`.
            let acc = build_select(&self).map_err(DjogiError::from)?;
            let (sql, binds) = acc.into_parts();
            let params = as_params(&binds);
            let pg_rows = ctx.query_all(&sql, &params).await?;
            let rows: Vec<T> = pg_rows
                .iter()
                .map(|r| T::from_pg_row(r))
                .collect::<Result<Vec<T>, _>>()?;

            // Apply each prefetch loader. Empty main result -> no
            // loaders run (short-circuit inside `apply_prefetches`).
            // The generalised loader signature lets the inner fan-out
            // see the same transaction-backed context the main query
            // ran on — no connection juggling.
            apply_prefetches(ctx.inner_mut(), &prefetches, rows).await
        }
    }

    /// Execute the query as a single `LEFT JOIN` per registered
    /// select_related path and collect every matching row into a
    /// `Vec<JoinedRow<T>>`, with joined child rows exposed via
    /// [`JoinedRow::get`](crate::relation::JoinedRow::get).
    ///
    /// # Why a separate terminal (not a change to `fetch_all`)
    ///
    /// Preserving [`fetch_all`](Self::fetch_all)'s `Vec<T>` return
    /// type keeps the Phase 2 terminal stable across Phase 3: a
    /// queryset built without select_related and fetched via
    /// `fetch_all` returns exactly what it did before Task 5 landed.
    /// select_related is an opt-in extension reachable through the
    /// dedicated `fetch_all_joined` entry — no pre-existing call site
    /// is forced into `Vec<JoinedRow<T>>`. Registrations on the
    /// `select_related_paths` queryset field are ignored on the plain
    /// `fetch_all` path, matching the free-register-on-any-terminal
    /// behaviour `prefetch` already documents.
    ///
    /// # Composes with `.prefetch(...)`
    ///
    /// When the queryset carries both select_related and prefetch
    /// registrations, the terminal runs:
    ///
    /// 1. The main query with the LEFT JOINs + aliased child columns.
    ///    Each row decodes into a `JoinedRow<T>` carrying the joined
    ///    children under their `source_column` keys.
    /// 2. The prefetch fan-out — one follow-up query per registered
    ///    `prefetch_paths` entry — whose resolved targets are
    ///    stitched into the same `JoinedRow<T>` values. The two
    ///    paths never collide on the same `source_column` in practice
    ///    because `.select_related(path)` and `.prefetch(path)`
    ///    target different relations on any realistic queryset, but
    ///    if they did, the prefetch stitcher would overwrite the
    ///    select_related entry — documented on
    ///    [`crate::relation::select_related::stitch_prefetches_into_joined`].
    ///
    /// # Short-circuit contract
    ///
    /// Honours the same `is_empty` short-circuit as every other
    /// terminal — a structural-none queryset returns `Ok(Vec::new())`
    /// without touching the database. An empty main result also
    /// short-circuits the prefetch pass — no prefetch loader runs
    /// when there are no parent rows to stitch against.
    ///
    /// # Context shape
    ///
    /// Takes `&mut DjogiContext`, matching
    /// [`fetch_all_prefetched`](Self::fetch_all_prefetched). Pool-backed
    /// and transaction-backed contexts are both supported — the main
    /// query and prefetch fan-out both dispatch through the context
    /// helpers, so `select_related` works inside an
    /// `atomic()` scope and sees the scope's uncommitted writes.
    // TODO(8δ T7.x): does NOT yet honour `cache_target` — see the
    // remote anchor at the bottom of this impl block (above `stream`)
    // for the cluster-wide deferral rationale.
    pub fn fetch_all_joined<'ctx>(
        self,
        ctx: &'ctx mut DjogiContext,
    ) -> impl Future<Output = Result<Vec<JoinedRow<T>>, DjogiError>> + Send + 'ctx
    where
        T: FromJoinedPgRow + 'ctx,
        T::Pk: Clone + Send + Sync + 'static,
    {
        async move {
            // TASK6:empty_contract — structural-none queryset returns
            // `Vec::new()` without touching the DB. Mirrors every other
            // terminal.
            if self.is_empty() {
                return Ok(Vec::new());
            }
            auto_set_tenant::<T>(ctx).await?;

            // Snapshot prefetch + select_related paths before we build
            // the SQL — `build_select_joined` borrows the queryset
            // shape, not the path vectors.
            let select_related_paths = self.select_related_paths.clone();
            let prefetches = self.prefetch_paths.clone();

            // Build and execute the joined main query. `build_select_joined`
            // emits the aliased projection + LEFT JOINs when
            // `select_related_paths` is non-empty; with an empty path
            // list it degenerates to `SELECT {parent}.* FROM ...` — same
            // shape as `build_select` minus the `*` shortcut. The
            // decoded rows come back as raw `tokio_postgres::Row`s so
            // both parent and (per registered path) child can be
            // extracted via `FromJoinedPgRow::from_joined_pg_row`.
            let acc = build_select_joined(&self).map_err(DjogiError::from)?;
            let (sql, binds) = acc.into_parts();
            let params = as_params(&binds);
            let rows: Vec<tokio_postgres::Row> = ctx.query_all(&sql, &params).await?;

            // Decode each row into a JoinedRow<T> carrying any joined
            // children. `apply_select_related` is pure CPU work — no
            // additional SQL round trips.
            let joined = apply_select_related::<T>(rows, &select_related_paths)?;

            // If prefetches were also registered, fan them out and
            // stitch the resolved targets into the same JoinedRow<T>
            // values. `stitch_prefetches_into_joined` short-circuits
            // when `prefetches` is empty, so there's no cost for the
            // common `select_related`-only path.
            stitch_prefetches_into_joined(joined, &prefetches, ctx.inner_mut()).await
        }
    }

    /// Execute with `LIMIT 1` and return the first matching row or `None`.
    ///
    /// Unlike `fetch_one`, this does not care whether other rows exist — it
    /// is the terminal you reach for when you want "any row that matches"
    /// rather than "the unique row that matches". Pair it with
    /// [`QuerySet::order_by`] for a deterministic choice.
    pub fn first<'ctx>(
        self,
        ctx: &'ctx mut DjogiContext,
    ) -> impl Future<Output = Result<Option<T>, DjogiError>> + Send + 'ctx
    where
        T: 'ctx,
    {
        async move {
            // TASK6:empty_contract — structural-none returns `None` without
            // a round trip.
            if self.is_empty() {
                return Ok(None);
            }
            auto_set_tenant::<T>(ctx).await?;
            let mut qs = self;
            qs.limit = Some(1);
            // Cluster 8δ T7.3: snapshot before the SQL emitter
            // borrows `qs`.
            let cache_target = qs.cache_target.clone();
            let acc = build_select(&qs).map_err(DjogiError::from)?;
            let (sql, binds) = acc.into_parts();
            let params = as_params(&binds);
            let opt = ctx.query_opt(&sql, &params).await?;
            let decoded = opt.as_ref().map(|r| T::from_pg_row(r)).transpose()?;
            // Cluster 8δ T7.3 post-fetch cache hook — fires only on
            // the `Some(_)` branch (no row, no insert). Error
            // semantics + bound rationale match the `fetch_all`
            // hook above.
            if let (Some(target), Some(row)) = (cache_target.as_ref(), decoded.as_ref()) {
                target.insert(row).await;
            }
            Ok(decoded)
        }
    }

    /// Find-or-create: return the first row matching the queryset, or
    /// create a new row from `factory()` when none matches.
    ///
    /// The tuple's second element reports which branch ran —
    /// `true` when a new row was inserted, `false` when an existing row
    /// was found. This mirrors the Django ORM's `get_or_create` shape
    /// so app-level consumers have the same "insert-if-absent" idiom
    /// they're used to.
    ///
    /// # Lookup semantics
    ///
    /// The lookup uses [`first`](Self::first) — the same `LIMIT 1`
    /// probe every "any row that matches" caller uses. If the
    /// queryset's filter is not unique, this method returns the first
    /// row the database chooses (non-deterministic without an
    /// `order_by`). Call sites that need exactly-one semantics should
    /// reach for `fetch_one` followed by a separate `create` branch
    /// instead.
    ///
    /// # Transactions and races
    ///
    /// The SELECT and the INSERT are two separate statements. Under
    /// concurrent writers a second caller may slip between the
    /// probe and the create — the INSERT then collides with whatever
    /// uniqueness constraint covers the filter. Wrap the call in
    /// [`atomic`](crate::transaction::atomic) + one of:
    ///
    /// - `select_for_update()` on the queryset to serialise lookups
    /// - an `ON CONFLICT` clause on the underlying table
    ///
    /// when the caller needs strict once-only semantics. Phase 4
    /// Task 7.5 adds `create_or_find` for the conflict-key path.
    ///
    /// # Short-circuit
    ///
    /// A `QuerySet::none()`-derived queryset short-circuits the
    /// lookup to `Ok(None)`, so the factory **runs and a row is
    /// inserted**. Callers who want "no insert on structural-none"
    /// must guard before calling.
    pub fn get_or_create<'ctx, F>(
        self,
        ctx: &'ctx mut DjogiContext,
        factory: F,
    ) -> impl Future<Output = Result<(T, bool), DjogiError>> + Send + 'ctx
    where
        F: FnOnce() -> T + Send + 'ctx,
        T: 'ctx,
    {
        async move {
            if let Some(row) = self.first(ctx).await? {
                return Ok((row, false));
            }
            let created = T::create(ctx, factory()).await?;
            Ok((created, true))
        }
    }

    /// Update-or-create: find the first matching row and mutate it via
    /// `updater`, or create a fresh row from `factory()` when none
    /// matches.
    ///
    /// The tuple's second element reports which branch ran —
    /// `true` when a new row was inserted, `false` when an existing
    /// row was updated in place.
    ///
    /// # Semantics
    ///
    /// - Found branch: `updater(&mut row)` runs, then
    ///   [`save`](crate::model::Model::save) rehydrates the row from
    ///   `UPDATE ... RETURNING *` — `updated_at` advances and any
    ///   trigger-mutated column surfaces in the returned `T`.
    /// - Missing branch: `factory()` runs and
    ///   [`create`](crate::model::Model::create) inserts the new row;
    ///   the returned `T` is the `RETURNING *` rehydration.
    ///
    /// `updater` takes `&mut T` so callers can mutate multiple fields
    /// in one pass without needing to rebuild the struct.
    ///
    /// # Race caveat
    ///
    /// Same non-atomic caveat as [`get_or_create`](Self::get_or_create)
    /// — the SELECT and the UPDATE/INSERT are distinct statements.
    /// Wrap in [`atomic`](crate::transaction::atomic) + a row lock
    /// when strict once-only semantics are required.
    pub fn update_or_create<'ctx, F, U>(
        self,
        ctx: &'ctx mut DjogiContext,
        factory: F,
        updater: U,
    ) -> impl Future<Output = Result<(T, bool), DjogiError>> + Send + 'ctx
    where
        F: FnOnce() -> T + Send + 'ctx,
        U: FnOnce(&mut T) + Send + 'ctx,
        T: 'ctx,
    {
        async move {
            if let Some(mut row) = self.first(ctx).await? {
                updater(&mut row);
                row.save(ctx).await?;
                return Ok((row, false));
            }
            let created = T::create(ctx, factory()).await?;
            Ok((created, true))
        }
    }

    /// Fetch every row whose primary key is in `ids` and return them
    /// keyed by PK in a `HashMap`.
    ///
    /// One round trip. The generated SQL is
    /// `SELECT * FROM <table> WHERE id IN ($1, $2, ...)` — one bound
    /// parameter per id. Postgres' bind-parameter cap is 65_535; larger
    /// id batches should be chunked by the caller.
    ///
    /// # Why on `QuerySet`, not `Model`
    ///
    /// The queryset receiver means callers can still stack filters and
    /// orderings before the PK probe:
    ///
    /// ```rust,ignore
    /// Account::objects()
    ///     .filter(|f| f.tenant_id.eq(tenant))
    ///     .in_bulk(&mut ctx, ids)
    ///     .await?;
    /// ```
    ///
    /// A bare `Account::in_bulk(ctx, ids)` is still reachable as
    /// `Account::objects().in_bulk(ctx, ids)`.
    ///
    /// # Empty input
    ///
    /// `ids.is_empty()` returns `Ok(HashMap::new())` without a round
    /// trip — `id IN ()` is invalid SQL, and an empty probe always
    /// yields an empty map anyway.
    ///
    /// # Short-circuit
    ///
    /// Honours the `is_empty` structural-none contract — a
    /// `QuerySet::none()`-derived queryset returns `Ok(HashMap::new())`
    /// without SQL emission, matching every other terminal.
    // TODO(8δ T7.x): does NOT yet honour `cache_target` — see the
    // remote anchor at the bottom of this impl block (above `stream`)
    // for the cluster-wide deferral rationale.
    pub fn in_bulk<'ctx>(
        self,
        ctx: &'ctx mut DjogiContext,
        ids: Vec<T::Pk>,
    ) -> impl Future<Output = Result<HashMap<T::Pk, T>, DjogiError>> + Send + 'ctx
    where
        T::Pk: Eq + Hash,
        T: 'ctx,
    {
        async move {
            // TASK6:empty_contract — structural-none skips SQL.
            if self.is_empty() || ids.is_empty() {
                return Ok(HashMap::new());
            }
            auto_set_tenant::<T>(ctx).await?;
            // Raw SELECT by PK list. We bypass `build_select` + the
            // filter chain because generic `QuerySet<T>` has no handle
            // on the `{Model}Fields::id` FieldRef — the field bag is a
            // per-model ZST emitted by the macro and not reachable
            // from this generic method. Any additional filters /
            // orderings the caller stacked on `self` are dropped for
            // this call; callers who need combined semantics should use
            // `.filter(...).fetch_all(...)` and key the result themselves.
            //
            // TODO(phase4-task7d): layer user filters back in via a
            // dedicated `build_where_only` helper so `in_bulk` honours
            // upstream `.filter(...)` calls.
            let mut acc = SqlAccumulator::new("SELECT ");
            acc.push_sql(<T as FromPgRow>::COLUMN_LIST);
            acc.push_sql(" FROM ");
            acc.push_sql(T::table_name());
            acc.push_sql(" WHERE id IN (");
            acc.push_list_binds(ids.iter().cloned());
            acc.push_sql(")");
            let (sql, binds) = acc.into_parts();
            let params = as_params(&binds);
            let pg_rows = ctx.query_all(&sql, &params).await?;
            let mut out: HashMap<T::Pk, T> = HashMap::with_capacity(pg_rows.len());
            for row in &pg_rows {
                let item = T::from_pg_row(row)?;
                out.insert(item.pk_value().clone(), item);
            }
            Ok(out)
        }
    }
}

// ── Scalar terminals (no FromPgRow bound needed) ─────────────────────────────

impl<T: Model> QuerySet<T> {
    /// `SELECT COUNT(*) FROM <table> [WHERE ...]`.
    ///
    /// Returns `i64` to match Postgres' `BIGINT` result of `COUNT(*)` and to
    /// leave headroom for tables that grow past `i32::MAX` rows. User code
    /// that needs a `usize` converts at the call site.
    pub fn count<'ctx>(
        self,
        ctx: &'ctx mut DjogiContext,
    ) -> impl Future<Output = Result<i64, DjogiError>> + Send + 'ctx
    where
        T: 'ctx,
    {
        async move {
            // TASK6:empty_contract — structural-none returns 0 without SQL.
            if self.is_empty() {
                return Ok(0);
            }
            auto_set_tenant::<T>(ctx).await?;
            let acc = build_count(&self).map_err(DjogiError::from)?;
            let (sql, binds) = acc.into_parts();
            let params = as_params(&binds);
            let row = ctx.query_one(&sql, &params).await?;
            let n: i64 = try_get_scalar(&row, 0)?;
            Ok(n)
        }
    }

    /// `SELECT EXISTS(SELECT 1 FROM <table> [WHERE ...] LIMIT 1)`.
    ///
    /// The `LIMIT 1` is inside the EXISTS subquery (see
    /// [`crate::query::sql::build_exists`]) so Postgres stops scanning at
    /// the first match — meaningful for large tables where even a count
    /// probe would touch many pages.
    pub fn exists<'ctx>(
        self,
        ctx: &'ctx mut DjogiContext,
    ) -> impl Future<Output = Result<bool, DjogiError>> + Send + 'ctx
    where
        T: 'ctx,
    {
        async move {
            // TASK6:empty_contract — structural-none returns false without SQL.
            if self.is_empty() {
                return Ok(false);
            }
            auto_set_tenant::<T>(ctx).await?;
            let acc = build_exists(&self).map_err(DjogiError::from)?;
            let (sql, binds) = acc.into_parts();
            let params = as_params(&binds);
            let row = ctx.query_one(&sql, &params).await?;
            let b: bool = try_get_scalar(&row, 0)?;
            Ok(b)
        }
    }
}

// ── Streaming terminal (requires `T: FromPgRow + Unpin + Send`) ─────────────

impl<T: Model> QuerySet<T>
where
    T: FromPgRow + Unpin + Send,
{
    /// Stream rows from the database using a Postgres named cursor.
    ///
    /// Returns `impl Stream<Item = Result<T, DjogiError>>` where rows are
    /// decoded one at a time as they arrive. The stream back-pressures
    /// naturally — no rows are held beyond one `fetch_size` batch.
    ///
    /// # Transaction requirement
    ///
    /// Named Postgres cursors are transaction-local. This terminal requires
    /// `ctx` to be backed by an active transaction (i.e. called inside an
    /// [`atomic()`](crate::transaction::atomic) scope). Calling `stream` on a
    /// pool-backed context returns
    /// `Err(DjogiError::StreamOutsideTransaction)` immediately — the error
    /// surfaces at construction time, not on the first `poll_next`.
    ///
    /// # Fetch size
    ///
    /// Default is `1000` rows per `FETCH` round trip. Use
    /// [`stream_with_fetch_size`](Self::stream_with_fetch_size) to override.
    ///
    /// # Example
    ///
    /// ```ignore
    /// use futures::StreamExt;
    ///
    /// atomic(&mut ctx, |ctx| Box::pin(async move {
    ///     let mut stream = Post::objects()
    ///         .filter(|f| f.published.eq(true))
    ///         .stream(ctx)
    ///         .await?;
    ///
    ///     while let Some(result) = stream.next().await {
    ///         let post = result?;
    ///         // process post …
    ///     }
    ///     Ok(())
    /// })).await?;
    /// ```
    // TODO(8δ T7.x): stream surfaces (`stream`,
    // `stream_with_fetch_size`) and the joined / prefetched
    // terminals (`fetch_all_prefetched`, `fetch_all_joined`,
    // `in_bulk`) intentionally do NOT yet honour `cache_target`.
    // Cluster 8δ commit T7.3 scopes the post-fetch hook to the
    // three terminals named in the granular plan §3 commit T7.3
    // — `fetch_all`, `first`, `fetch_one`. The streaming surface
    // needs a separate design pass (back-pressure interactions
    // with `Punnu::insert`'s async write-through, and the
    // semantics of "cache mid-stream" if the consumer aborts
    // before exhausting the cursor) that's deliberately a future
    // commit's concern.
    pub async fn stream<'ctx>(
        self,
        ctx: &'ctx mut DjogiContext,
    ) -> Result<ModelCursorStream<'ctx, T>, DjogiError>
    where
        T: 'ctx,
    {
        self.stream_with_fetch_size(ctx, DEFAULT_FETCH_SIZE).await
    }

    /// Stream rows using a Postgres named cursor with a custom fetch size.
    ///
    /// Like [`stream`](Self::stream) but lets the caller override the number
    /// of rows retrieved per `FETCH` round trip. Smaller values (e.g. `100`)
    /// reduce per-batch memory pressure; larger values (e.g. `10_000`) reduce
    /// round-trip overhead for high-throughput exports.
    ///
    /// `fetch_size` must be at least `1`. Passing `0` returns
    /// `Err(DjogiError::Validation)` immediately.
    ///
    /// # Transaction requirement
    ///
    /// Same as [`stream`](Self::stream) — `ctx` must be transaction-backed.
    pub async fn stream_with_fetch_size<'ctx>(
        self,
        ctx: &'ctx mut DjogiContext,
        fetch_size: u32,
    ) -> Result<ModelCursorStream<'ctx, T>, DjogiError>
    where
        T: 'ctx,
    {
        if fetch_size == 0 {
            return Err(DjogiError::Validation(
                "stream fetch_size must be at least 1".to_owned(),
            ));
        }
        auto_set_tenant::<T>(ctx).await?;

        // Short-circuit on structural-none: an empty QuerySet has no rows to
        // stream. The stream would immediately close the cursor after a zero-
        // row DECLARE+FETCH; skip the round trip by returning a no-op stream.
        // Note: we cannot early-return an `impl Stream` here because Rust
        // requires a concrete return type. We build a real cursor stream but
        // let the standard cursor exhaustion path terminate it after one
        // FETCH that returns 0 rows. This is simpler and more correct than
        // a special empty-stream type.
        let acc = build_select(&self).map_err(DjogiError::from)?;
        let (sql, binds) = acc.into_parts();
        let params = as_params(&binds);
        build_model_stream(ctx, &sql, &params, fetch_size).await
    }
}