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
//! `select_related` — single-hop `LEFT JOIN` emission + `JoinedRow<T>`
//! stitching.
//!
//! # What
//!
//! Consumed by
//! [`QuerySet::fetch_all_joined`](crate::query::QuerySet::fetch_all_joined).
//! For each registered path on the queryset, the select_related emitter:
//!
//! 1. Appends a `LEFT JOIN {target_table} rel_{source_column}
//!    ON {parent_table}.{source_column} = rel_{source_column}.id` clause
//!    to the main query.
//! 2. Extends the `SELECT` list with one entry per child column,
//!    aliased as `rel_{source_column}.{col} AS "rel_{source_column}.{col}"`
//!    so the result set carries both sides with no column-name
//!    collisions.
//!
//! After the query runs, the terminal walks each row and decodes the
//! parent plus every registered child from the aliased columns,
//! packaging them into `JoinedRow<T>`.
//!
//! # Why aliased child columns (not `SELECT t.*`)
//!
//! A prefix-aware decoder looks columns up by the exact alias name — `try_get("id")`
//! finds the first column named `id`, which would be ambiguous the
//! moment both parent and child tables contribute an `id`. Aliasing
//! the child columns under `"rel_{source_column}.{col}"` — with a
//! literal dot embedded in the alias — gives every column a unique
//! name in the result set and lets the
//! [`FromJoinedPgRow`](crate::pg::decode::FromJoinedPgRow)
//! decoder use the `"{prefix}{column}"` shape without collision.
//!
//! The quoted-dot alias matches the table-qualified form Postgres would
//! emit if you projected `SELECT t.*` with a named prefix, so the
//! aliases read naturally in raw SQL logs: a reviewer scanning
//! pg_stat_statements sees `"rel_owner_id.id"` and recognises the
//! origin immediately.
//!
//! # Why LEFT JOIN (always)
//!
//! Even for NOT-NULL FKs, the select_related emitter uses `LEFT JOIN`
//! so an orphan FK (the referenced row has been deleted since insert,
//! bypassing the `RESTRICT` / `CASCADE` policy via raw SQL) produces
//! a `None` on the child side rather than omitting the parent row.
//! Dropping the parent row from the result set would silently break
//! the caller's mental model that `select_related` is a
//! performance-only eager-load — the set of matching parent rows must
//! match what `fetch_all` would have returned on the same queryset.
//!
//! `INNER JOIN` is a valid future optimisation for confirmed-non-null
//! columns, but that opt-in lives behind a `.select_related_strict(...)`
//! surface (Phase 4) rather than silently changing the default's
//! semantics.
//!
//! # T2 scope
//!
//! - Single-hop only — no chained `select_related(path_a.path_b)`. Multi-hop
//!   decode lands in T4.
//! - Multi-relation-per-queryset **is** supported (multiple
//!   `.select_related(...)` calls accumulate into a `Vec<ErasedSelectRelated>`,
//!   each producing its own aliased `LEFT JOIN`).
//! - No join-time filtering — filters still target the parent table.
//! - `.select_related(...)` + `.prefetch(...)` can coexist on the same
//!   queryset; the terminal honours both.

use crate::DjogiError;
use crate::context::ContextInner;
use crate::model::Model;
use crate::pg::accumulator::SqlAccumulator;
use crate::pg::decode::FromJoinedPgRow;
use crate::relation::joined_row::JoinedRow;
use std::any::Any;
use std::collections::HashMap;
use tokio_postgres::Row as PgRow;

/// Decoder function type for a single child column in the select_related
/// path. One monomorphised `fn` per `(Parent, Child)` pair — same erasure
/// strategy Task 4's prefetch loader uses.
///
/// Returns `Some(Box<Child>)` when the child row materialised, or
/// `None` on a LEFT JOIN miss. The probe for miss-vs-hit uses
/// `tokio_postgres::Row::try_get::<i64>(id_alias)` on the child's `id`
/// alias — a NULL result indicates a LEFT JOIN miss.
pub(crate) type JoinDecoderFn =
    for<'r> fn(
        row: &'r PgRow,
        prefix: &str,
    ) -> Result<Option<Box<dyn Any + Send + Sync>>, DjogiError>;

/// Accessor type for the child's `ModelDescriptor`. Monomorphised per
/// `Child` and stored as a plain `fn` pointer on
/// [`ErasedSelectRelated`] so the emitter can read the child's
/// declared column list without naming the concrete type.
///
/// Delegates to `<Child as Model>::descriptor()` via [`child_descriptor`].
pub(crate) type ChildDescriptorFn = fn() -> &'static crate::descriptor::ModelDescriptor;

/// A single registered select_related path on a
/// [`QuerySet<T>`](crate::query::QuerySet).
///
/// Built by
/// [`QuerySet::select_related`](crate::query::QuerySet::select_related)
/// from a typed [`RelationPath<Source, Child>`]. The emitter carries
/// only the dynamic parameters needed to build the join SQL + alias
/// the child columns; the generic `Child` type is captured via the
/// monomorphised [`join_decoder`] fn pointer.
#[derive(Clone)]
pub(crate) struct ErasedSelectRelated {
    /// Source column on the parent table (e.g. `"owner_id"`). Doubles
    /// as the prefix stem — the child alias is `"rel_{source_column}"`
    /// and the column alias is `"rel_{source_column}.{col}"`. The
    /// `source_column` is also the key under which the resolved child
    /// lands in
    /// [`JoinedRow::relations`](crate::relation::JoinedRow), matching
    /// the prefetch wrapper's keying convention.
    pub source_column: &'static str,
    /// Child table name (e.g. `"owners_p3"`). Used in the `LEFT JOIN`
    /// clause.
    pub child_table: &'static str,
    /// Monomorphised decoder that knows how to read one aliased row
    /// into a `Box<dyn Any>` of the concrete child type.
    pub decoder: JoinDecoderFn,
    /// Monomorphised accessor for the child's `ModelDescriptor`. The
    /// SELECT emitter reads `fields[].name` from the descriptor to
    /// alias every child column under the `"rel_{source_column}.{col}"`
    /// shape. Going through a fn pointer (rather than storing a
    /// pre-projected static slice) keeps `ErasedSelectRelated`
    /// construction free of `Vec<&'static str>` allocations while
    /// still giving the emitter access to the full descriptor — a
    /// later phase that needs more than just column names (e.g.
    /// per-column codec selection) can lean on this hook without
    /// reshaping the struct.
    pub child_descriptor: ChildDescriptorFn,
}

/// Monomorphised accessor for `<Child as Model>::descriptor()`. Stored
/// as a plain `fn` pointer on [`ErasedSelectRelated`] so the emitter
/// can read the child's declared column list without naming the
/// concrete type.
pub(crate) fn child_descriptor<Child: Model>() -> &'static crate::descriptor::ModelDescriptor {
    <Child as Model>::descriptor()
}

impl std::fmt::Debug for ErasedSelectRelated {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // `decoder` and `child_descriptor` are fn-pointers — pointer
        // addresses are non-stable across builds and would make debug
        // output noisy. The source column + child table together
        // identify the join unambiguously.
        f.debug_struct("ErasedSelectRelated")
            .field("source_column", &self.source_column)
            .field("child_table", &self.child_table)
            .finish_non_exhaustive()
    }
}

/// Monomorphised join decoder for a concrete `Child`.
///
/// Probes the child `id` column (under the prefixed alias) for NULL to
/// distinguish LEFT JOIN misses (NULL child columns end-to-end) from live
/// child rows. The probe uses `row.try_get::<Option<i64>>(id_alias)` —
/// a `None` result indicates a LEFT JOIN miss (NULL FK or orphan target).
///
/// This works for `HeerId`-keyed models (BIGINT) and is the standard
/// T2 probe. T4 will generalise this to other PK types via the
/// `FromPgRow` trait's column-index probing.
///
/// Returns `Ok(None)` on miss so the caller can omit the child from
/// the row's relation map; returns `Ok(Some(box))` on hit; propagates
/// decode errors on decode failure. A missing `{prefix}id` column
/// surfaces as a conservative `None` (rather than a cryptic decode
/// error) because a schema mismatch at that level is a framework bug
/// the test suite would catch, not a user-facing fault.
pub(crate) fn join_decoder<Child>(
    row: &PgRow,
    prefix: &str,
) -> Result<Option<Box<dyn Any + Send + Sync>>, DjogiError>
where
    Child: Model + FromJoinedPgRow + Send + Sync + 'static,
{
    // Build the id-alias lookup: "{prefix}id". Inlining `format!` here
    // is fine — the hot path is one call per (row, path), which is a
    // negligible allocation next to the row decode itself.
    let id_alias = format!("{prefix}id");

    // Probe for LEFT JOIN miss: if the aliased `id` column is NULL then
    // the join found no child row. `try_get::<Option<i64>>` returns
    // `Ok(None)` for SQL NULL and `Ok(Some(v))` for a live integer.
    // A missing column (schema/aliasing bug) returns `Err` — we treat
    // that conservatively as "no child" and emit a debug assertion so
    // the test suite catches the framework-level mismatch early.
    let id_probe: Result<Option<i64>, _> = row.try_get(id_alias.as_str());
    debug_assert!(
        id_probe.is_ok(),
        "select_related: missing join alias '{id_alias}' on joined row — framework bug (check select_columns emission vs push_joins alias)"
    );
    let child_is_null = id_probe.map(|v| v.is_none()).unwrap_or(true);

    if child_is_null {
        return Ok(None);
    }

    let child = <Child as FromJoinedPgRow>::from_joined_pg_row(row, prefix)?;
    Ok(Some(Box::new(child) as Box<dyn Any + Send + Sync>))
}

/// Append the `LEFT JOIN` clauses for every registered select_related
/// path to `acc`.
///
/// Emits one `LEFT JOIN {child_table} rel_{source_column} ON
/// {parent_table}.{source_column} = rel_{source_column}.id` per path.
/// The aliases (`rel_{source_column}`) are unique per parent column
/// by construction — the macro-emitted `{Model}Related` builders
/// pin one path per source column, and the queryset's dedup check
/// ensures no duplicate registrations.
///
/// All identifiers are `&'static str` literals (baked by the
/// `#[model]` macro), so `push_sql(...)` is safe without quoting.
pub(crate) fn push_joins<T: Model>(acc: &mut SqlAccumulator, paths: &[ErasedSelectRelated]) {
    for path in paths {
        acc.push_sql(" LEFT JOIN ");
        acc.push_sql(path.child_table);
        acc.push_sql(" rel_");
        acc.push_sql(path.source_column);
        acc.push_sql(" ON ");
        acc.push_sql(T::table_name());
        acc.push_sql(".");
        acc.push_sql(path.source_column);
        acc.push_sql(" = rel_");
        acc.push_sql(path.source_column);
        acc.push_sql(".id");
    }
}

/// Build the SELECT column list for a queryset with registered
/// select_related paths.
///
/// Returns a pre-rendered string of the form:
///
/// ```text
/// parent_table.*, rel_col_a.id AS "rel_col_a.id", rel_col_a.name AS "rel_col_a.name", …
/// ```
///
/// The parent columns come through unqualified (their column name
/// surfaces in the result set as just `"id"`, `"make"`, …). Each
/// joined child's columns are aliased as `"rel_{source_column}.{col}"`
/// — the embedded dot matches the shape Postgres uses internally for
/// table-qualified projections and gives the
/// [`FromJoinedPgRow`](crate::pg::decode::FromJoinedPgRow)
/// decoder a stable, collision-free lookup key.
///
/// The returned `String` is pushed onto the builder verbatim — no
/// user-supplied data reaches this path, only `&'static str`
/// literals from the descriptor and from the registered path.
pub(crate) fn select_columns<T: Model>(paths: &[ErasedSelectRelated]) -> String {
    let mut out = String::new();
    out.push_str(T::table_name());
    out.push_str(".*");
    for path in paths {
        let alias = format!("rel_{}", path.source_column);
        let desc = (path.child_descriptor)();
        for field in desc.fields {
            // The Model trait is sealed, so `desc.fields` comes from a
            // `#[derive(Model)]`-emitted descriptor — `field.name` should
            // already satisfy the identifier contract. Re-validate in
            // debug builds so a malformed emission (or a downstream macro
            // pretending to be `#[derive(Model)]`) surfaces as a loud
            // framework-bug panic instead of malformed SQL.
            crate::ident::debug_assert_ident!(field.name, "field_name");
            out.push_str(", ");
            // Source column: `rel_owner_id.id`.
            out.push_str(&alias);
            out.push('.');
            out.push_str(field.name);
            // Destination alias: `"rel_owner_id.id"`. The double-quote
            // wrap preserves the dot as a literal character in the
            // result-set column name (rather than Postgres treating
            // the dot as a qualifier).
            out.push_str(" AS \"");
            out.push_str(&alias);
            out.push('.');
            out.push_str(field.name);
            out.push('"');
        }
    }
    out
}

/// Decode every registered select_related child from one joined row
/// and package the result into a `JoinedRow<T>`.
///
/// Each path contributes one entry to the `relations` map, keyed by
/// `source_column` (matching
/// [`PrefetchedRow`](crate::relation::PrefetchedRow)'s key convention).
/// LEFT JOIN misses — surfaced by `join_decoder` returning
/// `Ok(None)` — skip the map insert so
/// [`JoinedRow::get`](crate::relation::JoinedRow::get) returns `None`
/// for that path.
///
/// `FromJoinedPgRow::from_joined_pg_row(row, "")` decodes the parent
/// with the empty prefix — similar in spirit to what `FromPgRow::from_pg_row`
/// reads for a bare-columns row, but reusing the same trait across
/// both sides keeps the macro emission small (one extra impl per model).
pub(crate) fn decode_joined_row<T: Model + FromJoinedPgRow>(
    row: &PgRow,
    paths: &[ErasedSelectRelated],
) -> Result<JoinedRow<T>, DjogiError> {
    let parent = <T as FromJoinedPgRow>::from_joined_pg_row(row, "")?;
    let mut relations: HashMap<&'static str, Box<dyn Any + Send + Sync>> =
        HashMap::with_capacity(paths.len());
    for path in paths {
        let prefix = format!("rel_{}.", path.source_column);
        if let Some(child_box) = (path.decoder)(row, &prefix)? {
            relations.insert(path.source_column, child_box);
        }
    }
    Ok(JoinedRow::new(parent, relations))
}

/// Terminal-layer helper that runs the select_related decode over
/// every row returned by the main query.
///
/// Short-circuits on an empty row set — no decoder is invoked, no
/// prefetch fan-out happens. Kept separate from the SQL-emission
/// helpers so the emitter can be unit-tested in isolation from the
/// decode path.
pub(crate) fn apply_select_related<T>(
    rows: Vec<PgRow>,
    paths: &[ErasedSelectRelated],
) -> Result<Vec<JoinedRow<T>>, DjogiError>
where
    T: Model + FromJoinedPgRow,
{
    let mut out: Vec<JoinedRow<T>> = Vec::with_capacity(rows.len());
    for row in &rows {
        out.push(decode_joined_row::<T>(row, paths)?);
    }
    Ok(out)
}

/// Erased-prefetch stitcher used by the `fetch_all_joined` terminal.
///
/// This is a thin bridge between the prefetch loader (which works on
/// `Vec<T>` + parent PKs) and the `JoinedRow<T>` wrapper type. The
/// terminal calls this when `.select_related(...)` and `.prefetch(...)`
/// are both registered on the same queryset; the prefetched entries
/// land under the same `source_column` keys `select_related`'s joined
/// entries use, so a single call to
/// [`JoinedRow::get`](crate::relation::JoinedRow::get) on a merged
/// row resolves either path transparently.
///
/// Kept separate from [`apply_select_related`] to preserve the
/// one-concern-per-function split: JOIN decoding here, prefetch
/// stitching over there. The terminal orchestrates both.
pub(crate) async fn stitch_prefetches_into_joined<T>(
    mut joined: Vec<JoinedRow<T>>,
    prefetches: &[crate::relation::prefetch::ErasedPrefetch],
    exec: &mut ContextInner,
) -> Result<Vec<JoinedRow<T>>, DjogiError>
where
    T: Model,
    T::Pk: Clone + Send + Sync + 'static,
{
    // Empty prefetch set — short-circuit without extracting PKs.
    // Covers the common `.select_related(...)` without `.prefetch(...)`
    // case; also a safety rail for the terminal-layer dispatch.
    if prefetches.is_empty() || joined.is_empty() {
        return Ok(joined);
    }

    // Fan out each prefetch loader, same shape as
    // `relation::prefetch::apply_prefetches` — we duplicate the
    // orchestration here (rather than reusing that function) because
    // the stitcher writes into `JoinedRow::relations_mut()`, not the
    // `PrefetchedRow::relations` field, and `PrefetchedRow` is a
    // distinct wrapper type whose conversion into `JoinedRow` would
    // cost more than the 20-line fan-out.
    //
    // One fresh cloned PK list per loader — each loader consumes its
    // input by downcasting out of the box, so reusing a single `Vec`
    // across loaders would break on the first downcast. The per-loader
    // clone pays `T::Pk::clone` per parent row per prefetch, which is
    // negligible for the HeerId / RanjId PK types Djogi ships.
    for prefetch in prefetches {
        let parent_pks_for_loader: Vec<Box<dyn Any + Send + Sync>> = joined
            .iter()
            .map(|jr| Box::new(jr.row.pk_value().clone()) as Box<dyn Any + Send + Sync>)
            .collect();
        // Re-borrow the context per loader iteration — see the mirror
        // call site in `prefetch::apply_prefetches` for the full note.
        let aligned = (prefetch.loader)(
            &mut *exec,
            prefetch.parent_table,
            prefetch.source_column,
            parent_pks_for_loader,
        )
        .await?;

        // Zip aligned results into per-row relation maps. Same
        // `take()`-per-slot pattern the prefetch stitcher uses —
        // each boxed target moves into exactly one row.
        for (jr, slot) in joined.iter_mut().zip(aligned) {
            if let Some(child_box) = slot {
                jr.relations_mut().insert(prefetch.source_column, child_box);
            }
        }
    }

    Ok(joined)
}

#[cfg(test)]
mod tests {
    //! Emitter unit tests — assert on the generated SQL without touching
    //! a real DB. Live-Postgres coverage lives in
    //! `tests/integration/phase3_relations.rs`.

    use super::*;
    use crate::descriptor::{
        FieldDescriptor, FieldSqlType, ModelDescriptor, PkType, field_descriptor, model_descriptor,
    };
    use crate::model::Model;
    use crate::pg::accumulator::SqlAccumulator;
    use crate::types::HeerId;
    use std::future::Future;

    // Minimal Model stubs mirroring the pattern in sibling `relation/`
    // modules — enough to satisfy the `Model` bound on `push_joins` /
    // `select_columns` without dragging in the full `#[model]` macro.
    struct Src;
    impl crate::model::__sealed::Sealed for Src {}
    #[allow(clippy::manual_async_fn)]
    impl Model for Src {
        type Pk = HeerId;
        type Fields = ();
        fn table_name() -> &'static str {
            "srcs"
        }
        fn pk_value(&self) -> &HeerId {
            unreachable!()
        }
        fn descriptor() -> &'static ModelDescriptor {
            unreachable!()
        }
        fn get(
            _ctx: &mut crate::context::DjogiContext,
            _id: HeerId,
        ) -> impl Future<Output = Result<Self, crate::DjogiError>> + Send {
            async { unreachable!() }
        }
        fn create(
            _ctx: &mut crate::context::DjogiContext,
            _v: Self,
        ) -> impl Future<Output = Result<Self, crate::DjogiError>> + Send {
            async { unreachable!() }
        }
        fn save<'ctx>(
            &'ctx mut self,
            _ctx: &'ctx mut crate::context::DjogiContext,
        ) -> impl Future<Output = Result<(), crate::DjogiError>> + Send + 'ctx {
            async { unreachable!() }
        }
        fn delete(
            self,
            _ctx: &mut crate::context::DjogiContext,
        ) -> impl Future<Output = Result<(), crate::DjogiError>> + Send {
            async { unreachable!() }
        }
        fn refresh_from_db<'ctx>(
            &'ctx self,
            _ctx: &'ctx mut crate::context::DjogiContext,
        ) -> impl Future<Output = Result<Self, crate::DjogiError>> + Send + 'ctx {
            async { unreachable!() }
        }
    }

    fn dummy_decoder(
        _row: &PgRow,
        _prefix: &str,
    ) -> Result<Option<Box<dyn Any + Send + Sync>>, DjogiError> {
        // Never invoked by the SQL-emission unit tests — they only
        // exercise `push_joins` / `select_columns`, which never call
        // the decoder.
        unreachable!("dummy decoder should not run in SQL-emission tests")
    }

    // Static two-column "child" descriptor used by the select_columns
    // emitter tests. Must be static so the fn pointer below can return
    // a `&'static ModelDescriptor` without runtime construction.
    static OWNERS_DESC: ModelDescriptor = ModelDescriptor {
        ..model_descriptor(
            "Owner",
            "owners",
            PkType::HeerId,
            &[
                FieldDescriptor {
                    unique: true,
                    indexed: true,
                    ..field_descriptor("id", FieldSqlType::BigInt, false)
                },
                FieldDescriptor {
                    ..field_descriptor("name", FieldSqlType::Text, false)
                },
            ],
        )
    };

    static FUEL_TYPES_DESC: ModelDescriptor = ModelDescriptor {
        ..model_descriptor(
            "FuelType",
            "fuel_types",
            PkType::HeerId,
            &[FieldDescriptor {
                unique: true,
                indexed: true,
                ..field_descriptor("id", FieldSqlType::BigInt, false)
            }],
        )
    };

    fn owners_descriptor() -> &'static ModelDescriptor {
        &OWNERS_DESC
    }
    fn fuel_types_descriptor() -> &'static ModelDescriptor {
        &FUEL_TYPES_DESC
    }

    #[test]
    fn push_joins_emits_left_join_with_aliased_table() {
        // Single registered path — one `LEFT JOIN {child} rel_{col} ON
        // {parent}.{col} = rel_{col}.id` appended. The parent table
        // name comes from the `Model` bound; the child table and
        // source column come from the erased path.
        let path = ErasedSelectRelated {
            source_column: "owner_id",
            child_table: "owners",
            decoder: dummy_decoder,
            child_descriptor: owners_descriptor,
        };
        let mut acc = SqlAccumulator::new("SELECT * FROM srcs");
        push_joins::<Src>(&mut acc, &[path]);
        let sql = acc.sql();
        assert!(
            sql.contains("LEFT JOIN owners rel_owner_id ON srcs.owner_id = rel_owner_id.id"),
            "expected aliased LEFT JOIN, got: {sql}"
        );
    }

    #[test]
    fn push_joins_emits_one_clause_per_path() {
        // Two paths on the same queryset — two LEFT JOINs, each with
        // its own `rel_{source_column}` alias. The aliases don't
        // collide because source columns are unique per parent model.
        let paths = vec![
            ErasedSelectRelated {
                source_column: "owner_id",
                child_table: "owners",
                decoder: dummy_decoder,
                child_descriptor: owners_descriptor,
            },
            ErasedSelectRelated {
                source_column: "fuel_type_id",
                child_table: "fuel_types",
                decoder: dummy_decoder,
                child_descriptor: fuel_types_descriptor,
            },
        ];
        let mut acc = SqlAccumulator::new("SELECT * FROM srcs");
        push_joins::<Src>(&mut acc, &paths);
        let sql = acc.sql();
        assert!(
            sql.contains("LEFT JOIN owners rel_owner_id"),
            "missing owner join in: {sql}"
        );
        assert!(
            sql.contains("LEFT JOIN fuel_types rel_fuel_type_id"),
            "missing fuel_type join in: {sql}"
        );
    }

    #[test]
    fn select_columns_emits_parent_star_and_aliased_children() {
        let path = ErasedSelectRelated {
            source_column: "owner_id",
            child_table: "owners",
            decoder: dummy_decoder,
            child_descriptor: owners_descriptor,
        };
        let cols = select_columns::<Src>(&[path]);
        // Parent star comes through unqualified — the child columns
        // land under `"rel_owner_id.{col}"` aliases.
        assert!(cols.starts_with("srcs.*"), "got: {cols}");
        assert!(
            cols.contains("rel_owner_id.id AS \"rel_owner_id.id\""),
            "missing aliased id column, got: {cols}"
        );
        assert!(
            cols.contains("rel_owner_id.name AS \"rel_owner_id.name\""),
            "missing aliased name column, got: {cols}"
        );
    }

    #[test]
    fn select_columns_empty_paths_returns_just_parent_star() {
        // No registered paths — the emitter degenerates to just the
        // parent `*` projection, matching what the plain build_select
        // would emit. Keeps the column-list builder safe to call
        // unconditionally.
        let cols = select_columns::<Src>(&[]);
        assert_eq!(cols, "srcs.*");
    }
}