umbral-core 0.0.4

umbral internals: ORM, migrations, routing, DB backends, the Plugin trait. Do not depend on this directly; use the `umbral` facade.
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
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
//! The `Model` trait: the abstraction every umbral model implements.
//!
//! At M2 the trait is implemented by hand (`impl Model for Post` lives
//! in `post.rs`). At M3 the same impl is generated by a
//! `#[derive(Model)]` proc macro. M4 hooks into `FIELDS` for the
//! field/backend compatibility system check. M5 hooks into `FIELDS` for
//! the migration engine's snapshot diff. The trait is intentionally
//! narrow at M2 — primary-key type, table name, field metadata, and
//! that's it.
//!
//! Through Phase 2 of the Postgres rollout `Model` carried
//! `for<'r> sqlx::FromRow<'r, SqliteRow>` as a supertrait so the
//! QuerySet terminals could blanket on `T: Model`. Phase 2.5 drops
//! that supertrait: the user struct still uses `#[derive(sqlx::FromRow)]`
//! (which emits a generic `impl<'r, R: Row> FromRow<'r, R>` covering
//! both SQLite and Postgres rows), and the QuerySet terminals carry
//! the FromRow bound on the method, not the trait — so the same
//! `Manager<T>` works on either backend.
//!
//! See `docs/specs/04-orm-model-and-fields.md` for the target shape and
//! the M2→M3→M4→M5 progression.

/// Trait for eagerly hydrating `ForeignKey<U>.resolved` fields by name.
///
/// `#[derive(Model)]` emits this impl for every model. Models with no FK
/// fields get a no-op impl; models with FK fields get a `hydrate_fk` body
/// that matches on `field_name` and a `fk_id_for` body that returns the raw
/// FK integer for a named field.
///
/// The `select_related` machinery in `QuerySet` calls these two methods in
/// sequence: first `fk_id_for` to collect the IDs to batch-fetch, then
/// `hydrate_fk` with the fetched JSON to populate `ForeignKey<U>.resolved`.
///
/// `U` must implement `serde::Deserialize` for `hydrate_fk` to succeed.
/// All umbral models already derive `Deserialize`, so this bound is always
/// satisfied in practice.
pub trait HydrateRelated {
    /// Return the raw FK value stored in the field named `field_name`,
    /// or `None` if the field doesn't exist on this model or is not a FK.
    ///
    /// Used by `select_related` to collect all FK ids from the result
    /// set before running the batch `IN (...)` lookup.
    ///
    /// PK lift Pass D: returns `Option<serde_json::Value>` (was
    /// `Option<i64>`) so FK targets keyed by `String` / `Uuid` /
    /// composite codename flow through the typed select_related
    /// path. The macro emits `serde_json::to_value(self.<field>.id())`
    /// — works for any `Serialize` PK type without per-target
    /// specialization. Integer-PK targets carry through as
    /// `Value::Number`; the existing i64 hot path is unchanged at
    /// the JSON layer.
    fn fk_id_for(&self, field_name: &str) -> Option<serde_json::Value>;

    /// Set `ForeignKey<U>.resolved` for the field named `field_name` by
    /// deserialising `row` as the target model type.
    ///
    /// A field name that doesn't match any FK on this model is silently
    /// ignored (a noop). Deserialisation errors are also silently swallowed —
    /// the FK keeps its raw-integer form without a resolved object. This
    /// is intentional: a bad `select_related` name is a
    /// programming error caught in tests, not a runtime panic.
    fn hydrate_fk(&mut self, field_name: &str, row: &serde_json::Value);

    /// Set the `parent_id` cache on every `M2M<U>` field this model
    /// owns. Closes the second BUG-16 gap: without this, `m2m.add(&t)`
    /// silently writes a junction row with `parent_id = 0` because the
    /// macro skips M2M fields in the `FromRow` decode path.
    ///
    /// Called by QuerySet terminals after each row is decoded. The
    /// macro emits a body that walks the model's M2M fields and calls
    /// `set_parent_id(self.<pk>)` on each — so loading a `Group`
    /// gives every `M2M<U>` slot on it the right `parent_id` to write
    /// against.
    ///
    /// Default: no-op. The macro-emitted body shadows this for any
    /// model that declares an M2M field. A model with no M2M fields
    /// inherits the default and pays nothing.
    ///
    /// PK-agnostic: the macro sets each `M2M<Child, P>` field's parent id
    /// from `self.<pk>` via the typed [`crate::orm::M2M::set_parent_id`],
    /// so it works for **any** parent PK type — `i64`, `String`,
    /// `uuid::Uuid`. A non-i64-PK parent declares the field with the
    /// matching `P` (e.g. `M2M<Student, String>`); `P` defaults to `i64`.
    fn set_m2m_parent_ids(&mut self) {}

    /// Return this row's primary key as a `serde_json::Value`, whatever
    /// the PK type — `i64`, `String`, `uuid::Uuid`, a custom newtype.
    /// The relation-hydration paths (`prefetch_related`, reverse-FK and
    /// reverse-OneToOne collection) bucket children by the parent's PK,
    /// and keying those buckets on a `Value` (canonicalised via
    /// [`crate::orm::pk_key`]) lets UUID- and slug-PK models flow through
    /// too, not just i64.
    ///
    /// Default: `None`. The `#[derive(Model)]` macro emits an override for
    /// every model that returns `to_value(&self.<pk>)` — so a hand-written
    /// `Model` impl that doesn't override it simply opts out of the
    /// Value-keyed hydration (a forgive-and-skip posture).
    fn pk_as_json(&self) -> Option<serde_json::Value> {
        None
    }

    /// Attach a list of pre-fetched child rows to the named `M2M<U>`
    /// field's `resolved` slot. Called by `QuerySet::prefetch_related`
    /// (gap #19) after a batched JOIN through the junction table
    /// returns one Vec<U> per parent.
    ///
    /// `rows` carries the child rows as JSON objects ready for
    /// `serde_json::from_value::<U>(...)`. Decoding failures (e.g. a
    /// row that doesn't match the target struct shape) silently drop
    /// that one row from the resolved set — same forgive-and-continue
    /// posture as `hydrate_fk` for `select_related`.
    ///
    /// A field name that doesn't match any M2M field on this model is
    /// a no-op. The macro-emitted body pattern-matches the M2M fields
    /// declared on this struct; the default below is empty so models
    /// without M2M fields pay nothing.
    fn set_m2m_resolved_json(&mut self, _field_name: &str, _rows: Vec<serde_json::Value>) {}

    /// Gap #44 — attach a list of pre-fetched child rows to the
    /// named `ReverseSet<C>` field's `resolved` slot. Counterpart
    /// to `set_m2m_resolved_json` but for reverse-FK collections
    /// (one parent, many children pointing at it via a FK column).
    ///
    /// Called by `QuerySet::prefetch_related` after the batched
    /// `SELECT * FROM <child> WHERE <fk_col> IN (parent_pks)` query
    /// returns child rows grouped by `<fk_col>` value.
    ///
    /// `rows` carries the child rows as JSON objects ready for
    /// `serde_json::from_value::<C>(...)`. Decoding failures
    /// silently drop that one row — same forgive-and-continue
    /// posture as the M2M variant.
    ///
    /// A field name that doesn't match any `ReverseSet` field on
    /// this model is a no-op. The macro-emitted body pattern-
    /// matches the ReverseSet fields declared on this struct; the
    /// default below is empty so models without reverse-FK fields
    /// pay nothing.
    fn set_reverse_fk_resolved_json(&mut self, _field_name: &str, _rows: Vec<serde_json::Value>) {}

    /// Reverse-OneToOne counterpart to
    /// `set_reverse_fk_resolved_json`. Called by `prefetch_related`
    /// with `Some(child_json)` when the runtime FK lookup found
    /// exactly one matching child, or `None` when no child matched
    /// (the slot still flips `is_loaded()` to `true`).
    ///
    /// Default: no-op. The macro emits per-field arms for any model
    /// declaring `pub <name>: OneToOne<C>` fields.
    fn set_one_to_one_resolved_json(&mut self, _field_name: &str, _row: Option<serde_json::Value>) {
    }

    /// Move form-staged M2M pending ids from `self` into `dest`,
    /// field by field. The typed `create()` builds its INSERT from the
    /// caller's instance, then reads a *fresh* row back from the DB
    /// (carrying the autoincremented PK) — the pending ids staged by the
    /// Form derive live on the caller's instance, not the readback row.
    /// This hook transfers them across so `write_pending_m2m` on the
    /// readback row (which has the real parent_id seeded) finds them.
    /// Default: no-op for models with no M2M fields.
    fn take_pending_m2m_into(&mut self, _dest: &mut Self) {}

    /// Flush form-staged M2M selections to their junction tables after
    /// the parent row was inserted. The macro emits a body that walks
    /// this model's M2M fields, reads `parent_id` + `junction_table`
    /// (seeded by `set_m2m_parent_ids`) and the pending child ids, and
    /// calls `set_junction_dynamic`. Default: no-op for models with no
    /// M2M fields.
    ///
    /// Async + boxed (rather than `#[async_trait]` on the whole trait)
    /// so `HydrateRelated`'s existing non-async methods stay as they
    /// are. Junction writes hit the DB, so this is kept off the hot
    /// decode path — only the typed `create()` calls it.
    fn write_pending_m2m<'a>(
        &'a mut self,
    ) -> std::pin::Pin<
        Box<
            dyn std::future::Future<Output = Result<(), crate::orm::write::WriteError>> + Send + 'a,
        >,
    > {
        Box::pin(async { Ok(()) })
    }
}

/// The trait every model implements.
///
/// Read at runtime to build queries (`T::TABLE`, `T::FIELDS`), at boot
/// to validate field/backend compatibility (M4), and at migration time
/// to diff against the last snapshot (M5).
///
/// `Model` is metadata-only — it carries no row-materialization bound.
/// QuerySet terminals add `for<'r> FromRow<'r, R>` for the row type
/// they need at the call site (sqlite or postgres). User structs pick
/// up both impls via a single `#[derive(sqlx::FromRow)]` because
/// sqlx's derive emits a generic-over-`R` impl.
pub trait Model: Sized + Send + Sync + Unpin + 'static {
    /// The primary-key type. M2 supports `i64` only; UUID lands later.
    type PrimaryKey: PrimaryKey;

    /// The struct name, used by the migration engine (M5) to label
    /// snapshot entries and to map autodetected operations back to
    /// the model that produced them. The M3 derive emits the struct
    /// ident verbatim ("Post", "Comment", etc.).
    const NAME: &'static str;

    /// The SQL table name. M3's derive defaults this to the
    /// `snake_case` of the struct name unless `#[umbral(table = "...")]`
    /// overrides it.
    const TABLE: &'static str;

    /// The app label (the owning plugin's name) this model belongs to.
    ///
    /// Sourced from `#[umbral(plugin = "...")]`; defaults to `"app"` (the
    /// registry's default key) when the attribute is absent. Authoritative
    /// for permission codenames (gaps2 #80g): `umbral-permissions` reads this
    /// to build `<app_label>.<verb>_<model>` codenames, instead of splitting
    /// the table name at the first `_` (which collided distinct models).
    const APP_LABEL: &'static str = "app";

    /// Static metadata for every field on the model.
    ///
    /// One [`FieldSpec`] per field, in declaration order. Read by the
    /// QuerySet (to build the SELECT column list), by the system check
    /// (M4) for field/backend compatibility, and by the migration
    /// engine (M5) for snapshot diffing.
    const FIELDS: &'static [FieldSpec];

    /// Human-readable display name for this model, used by the admin
    /// sidebar as the default label. Defaults to `Self::NAME`.
    ///
    /// Override via `#[umbral(display = "Users")]` on the struct.
    const DISPLAY: &'static str = Self::NAME;

    /// Lucide icon slug shown next to this model in the admin sidebar.
    /// Defaults to `"database"`. Any valid Lucide icon name works; unknown
    /// names are silently ignored by Lucide at render time.
    ///
    /// Override via `#[umbral(icon = "users")]` on the struct.
    const ICON: &'static str = "database";

    /// Database alias this model lives on, when the app registers more
    /// than one pool via `AppBuilder::database(...)`. `None` (the
    /// default) means "use whatever the owning plugin chose via
    /// `Plugin::database()`, or `\"default\"` if neither side
    /// overrode."
    ///
    /// Override via `#[umbral(database = "analytics")]` on the struct.
    /// Per-model wins over per-plugin — useful for a single plugin
    /// that owns one model on the primary DB and another on an
    /// archive/analytics DB.
    const DATABASE: Option<&'static str> = None;

    /// Single-row-marker. When `true`, the admin auto-redirects the
    /// list view to the (sole) row's edit form, hides the "+ New"
    /// button, and surfaces the model as a settings-style screen.
    /// The single-row settings model pattern. Set via
    /// `#[umbral(singleton)]` on the struct. Closes BUG-9 in
    /// `bugs/tests/testBugs.md`.
    ///
    /// Default `false`. Default-row seeding (so the first admin
    /// visit doesn't 404) is the user's responsibility — typically
    /// a one-liner in `Plugin::on_ready` that calls
    /// `T::objects().create(T::default()).await` if the count is
    /// zero. A future framework helper could automate that; for v1
    /// the trait const is enough to let admin and any third-party
    /// tool know the model is singleton-shaped.
    const SINGLETON: bool = false;

    /// Feature #72 — soft-delete marker. Set via
    /// `#[umbral(soft_delete)]` on the struct. When true, the
    /// framework treats this model as having a `deleted_at:
    /// Option<DateTime<Utc>>` column (which the user MUST declare
    /// — derive macros can't add fields to the input struct), and:
    ///
    /// - Every `QuerySet<T>` terminal auto-injects
    ///   `WHERE deleted_at IS NULL` so soft-deleted rows are
    ///   invisible by default.
    /// - `Manager::delete_instance(&row)` and `QuerySet::delete()`
    ///   issue `UPDATE table SET deleted_at = NOW() WHERE ...`
    ///   instead of a hard `DELETE FROM table WHERE ...`.
    /// - Callers who actually want the soft-deleted rows (admin
    ///   trash views, audit dumps, undelete flows) opt back in
    ///   per-query via `.with_deleted()` or `.only_deleted()`.
    /// - Callers who need a hard DELETE (GDPR purge, etc.) use
    ///   `.hard_delete()` to bypass the soft path on a per-call
    ///   basis.
    ///
    /// Default false so existing models compile unchanged.
    const SOFT_DELETE: bool = false;

    /// Composite-UNIQUE constraints. Each inner slice names a
    /// constraint over the listed column names. Set via
    /// `#[umbral(unique_together = [["a", "b"]])]`. Closes BUG-6 in
    /// `bugs/tests/testBugs.md`. Default empty; the migration engine
    /// emits one `UNIQUE (col1, col2)` clause per inner group on
    /// `CREATE TABLE`.
    const UNIQUE_TOGETHER: &'static [&'static [&'static str]] = &[];

    /// Multi-column indexes. Each inner slice names an index over
    /// the listed columns. Set via
    /// `#[umbral(indexes = [["tenant_id", "created_at"]])]`. Closes
    /// BUG-7. Default empty; the migration engine emits
    /// `CREATE INDEX IF NOT EXISTS idx_<table>_<col1>_<col2>` after
    /// the `CREATE TABLE`. Single-column indexes stay on the field
    /// attribute (`#[umbral(index)]`).
    const INDEXES: &'static [&'static [&'static str]] = &[];

    /// Default `ORDER BY` clause, applied when a QuerySet terminates
    /// without an explicit `order_by`. Each tuple is `(column_name,
    /// is_descending)`. Set via
    /// `#[umbral(ordering = ["-published_at", "id"])]` (leading `-`
    /// flips to DESC). Closes BUG-8. Default empty.
    const ORDERING: &'static [(&'static str, bool)] = &[];

    /// Many-to-many relations declared on this model. Each entry names
    /// a field and its target model. The migration engine uses this to
    /// auto-generate junction tables; the admin uses it to render M2M
    /// pickers. Default empty.
    const M2M_RELATIONS: &'static [M2MRelationSpec] = &[];

    /// Gap #44 — reverse-FK collections declared on this model via
    /// `#[umbral(reverse_fk = "<fk_col>")] pub <name>: ReverseSet<C>`.
    /// Each entry tells `prefetch_related` how to fetch the children:
    /// `SELECT * FROM <target_table> WHERE <fk_column> IN (parent_pks)`
    /// then group by `<fk_column>` value, populate each parent's
    /// `ReverseSet.resolved`. Default empty; the macro emits one
    /// entry per declared `ReverseSet<C>` field.
    const REVERSE_FK_RELATIONS: &'static [ReverseFkRelationSpec] = &[];

    /// Reverse OneToOne accessors declared on this model via
    /// `pub <name>: OneToOne<C>` (no umbral attribute required).
    /// Unlike `REVERSE_FK_RELATIONS`, the FK column on the child is
    /// not named at macro time — `prefetch_related` looks it up at
    /// runtime by scanning the child's `FIELDS` for the UNIQUE FK
    /// pointing back at this model's table. Exactly one match
    /// required; 0 or 2+ matches surface a loud error naming the
    /// ambiguity.
    const ONE_TO_ONE_RELATIONS: &'static [OneToOneRelationSpec] = &[];

    /// Return the primary key of this instance.
    fn primary_key(&self) -> Self::PrimaryKey;
}

/// Static metadata for one many-to-many relation declared on a model.
///
/// Carried by `Model::M2M_RELATIONS`. The migration engine uses this
/// to emit `CREATE TABLE` for the junction table; the admin uses it
/// to know which fields render as multi-select pickers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct M2MRelationSpec {
    /// The Rust field name (e.g. `"tags"`).
    pub field_name: &'static str,
    /// The target model's table name (e.g. `"tag"`).
    pub target_table: &'static str,
    /// The target model's struct name (e.g. `"Tag"`). Used for reverse
    /// accessor lookups and OpenAPI schema references.
    pub target_name: &'static str,
}

/// Static metadata for one reverse OneToOne field on a model. The
/// FK column on the child is intentionally omitted — `prefetch_related`
/// resolves it at runtime by scanning the child's `FIELDS` for the
/// UNIQUE FK pointing back at `target_table`. Carried by
/// [`Model::ONE_TO_ONE_RELATIONS`].
///
/// Example: `pub struct User { pub profile: OneToOne<Profile>, ... }`
/// emits one entry: `{ field_name: "profile", target_table:
/// "profile", target_name: "Profile" }`. At prefetch time the loader
/// finds the column on Profile (`pub user: ForeignKey<User>` with
/// `#[umbral(unique)]`) and issues `SELECT * FROM profile WHERE user
/// IN (parent_pks)`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OneToOneRelationSpec {
    /// The Rust field name on the parent (e.g. `"profile"`).
    pub field_name: &'static str,
    /// The child model's table name (e.g. `"profile"`).
    pub target_table: &'static str,
    /// The child model's struct name (e.g. `"Profile"`). Reserved
    /// for symmetry with `M2MRelationSpec` / `ReverseFkRelationSpec`.
    pub target_name: &'static str,
}

/// Static metadata for one reverse-FK collection field on a model
/// (gap #44). Carried by `Model::REVERSE_FK_RELATIONS`.
///
/// Example: `pub struct Post` with
/// `#[umbral(reverse_fk = "post")] pub comment_set: ReverseSet<Comment>`
/// emits one entry: `{ field_name: "comment_set", target_table:
/// "comment", target_name: "Comment", fk_column: "post" }`.
///
/// `prefetch_related("comment_set")` uses this to issue
/// `SELECT * FROM comment WHERE post IN (parent_pks)` then group
/// rows by `post` value, populating each parent's `ReverseSet`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReverseFkRelationSpec {
    /// The Rust field name on the parent (e.g. `"comment_set"`).
    pub field_name: &'static str,
    /// The child model's table name (e.g. `"comment"`).
    pub target_table: &'static str,
    /// The child model's struct name (e.g. `"Comment"`). Reserved
    /// for symmetry with `M2MRelationSpec`.
    pub target_name: &'static str,
    /// Name of the FK column on the child that points back at the
    /// parent (e.g. `"post"`). The prefetch loader filters on this
    /// column: `WHERE <fk_column> IN (parent_pks)`.
    pub fk_column: &'static str,
    /// Mirrors the CHILD model's `Model::SOFT_DELETE`. `annotate_count`
    /// folds `AND <child>.deleted_at IS NULL` into the correlated
    /// count subquery when this is `true`, so a trashed child stops
    /// inflating the parent's count. Filled by the Model derive from
    /// `<Child as Model>::SOFT_DELETE`.
    pub soft_delete: bool,
}

/// Types that can serve as a model's primary key.
///
/// Built-in impls cover the integer widths sea-query has native
/// `Value` variants for (i8 / i16 / i32 / i64, u8 / u16 / u32 / u64),
/// `uuid::Uuid`, and `String` (for slug-style keys). The bound is
/// `Clone + Send + Sync + 'static + Into<sea_query::Value>` — the
/// `Into<Value>` requirement lets the M2M junction-table CRUD path
/// bind the PK through sea-query without a per-type adapter, on both
/// SQLite and Postgres. Closes BUG-16 phase 2.
///
/// 128-bit integers (`i128` / `u128`) are deliberately not in the
/// catalogue: sea-query's `Value` enum has no native variant for them
/// and neither shipped backend exposes a 128-bit integer column type.
/// Use `i64` or `String` instead.
///
/// User crates extend the catalogue with one line as long as the
/// custom type already lowers to a `sea_query::Value`:
///
/// ```ignore
/// #[derive(Clone)]
/// pub struct UserId(pub u64);
///
/// impl From<UserId> for sea_query::Value {
///     fn from(id: UserId) -> Self { id.0.into() }
/// }
/// impl umbral::orm::PrimaryKey for UserId {}
/// ```
pub trait PrimaryKey:
    Clone + Send + Sync + 'static + Into<sea_query::Value> + std::fmt::Display
{
}

// Integer widths sea-query has Value variants for. Postgres exposes
// SMALLINT / INT / BIGINT for the signed half; the unsigned widths
// upcast (sea-query lowers u8/u16/u32 to the next signed width, u64
// to BIGINT, matching what both backends actually store).
impl PrimaryKey for i8 {}
impl PrimaryKey for i16 {}
impl PrimaryKey for i32 {}
impl PrimaryKey for i64 {}
impl PrimaryKey for u8 {}
impl PrimaryKey for u16 {}
impl PrimaryKey for u32 {}
impl PrimaryKey for u64 {}

// Non-integer built-ins. UUIDs and slug-style String keys are the
// two non-integer shapes the porting catalogue calls out.
impl PrimaryKey for uuid::Uuid {}
impl PrimaryKey for String {}

/// Static metadata for one column on a model.
///
/// Constructed once per field as a const, lives in `Model::FIELDS`.
/// Carries enough information for the QuerySet, the system check, and
/// the migration engine to do their jobs without the model needing any
/// runtime introspection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FieldSpec {
    /// The SQL column name — always the Rust field name. Only the table name
    /// is overridable (via `#[umbral(table = "...")]`); there is no field-level
    /// column-rename attribute, so the column is whatever the field is called.
    pub name: &'static str,

    /// The SQL type kind. M2 ships the minimum set needed for the
    /// hardcoded `Post` model (`BigInt`, `Text`, `Timestamptz`);
    /// additional variants land as the M3 derive's field-type
    /// catalogue grows.
    pub ty: SqlType,

    /// Whether the column is part of the primary key.
    pub primary_key: bool,

    /// Whether the column accepts SQL NULL. Maps from `Option<T>` in
    /// the struct definition; the only path to NULL is `Option<T>`,
    /// per the `04-orm-model-and-fields.md` invariant.
    pub nullable: bool,

    /// Which backends this field type works on. Empty slice means "all
    /// backends." Non-empty restricts the field to those listed; the
    /// M4 boot system check rejects models that use a field on an
    /// unsupported backend.
    pub supported_backends: &'static [&'static str],

    /// For `SqlType::ForeignKey` fields: the SQL table name of the
    /// referenced model (i.e. `T::TABLE`). The migration engine reads
    /// this at DDL-emit time to produce `REFERENCES "<target>"("id")`.
    /// `None` for all non-FK fields.
    pub fk_target: Option<&'static str>,

    /// When `true`, this field is never rendered on any form (create or
    /// edit) AND the REST plugin drops it from POST/PUT/PATCH request
    /// bodies before write. This is the framework's "server-managed,
    /// never accepts client input" flag — `password_hash`,
    /// `internal_token`, audit timestamps the database owns.
    /// Set via `#[umbral(noform)]`.
    ///
    /// OpenAPI emits `readOnly: true` for `noform` columns so Swagger
    /// UI / generated clients honour the contract too. If you only
    /// want the admin to render the field disabled — without affecting
    /// the REST API or the spec — use `noedit` below.
    ///
    /// If `noform` is true, `noedit` is moot (noform takes precedence).
    pub noform: bool,

    /// For `SqlType::ForeignKey` fields: whether the migration engine
    /// emits a *physical* `FOREIGN KEY ... REFERENCES` constraint.
    /// Toggles the physical FK constraint. Set via
    /// `#[umbral(db_constraint = false)]`; defaults to `true` (today's
    /// behaviour — emit the constraint).
    ///
    /// When `false`, the FK stays a *logical* relation: the column +
    /// `fk_target` are unchanged, so joins, `select_related`, and the
    /// app-level `check_fk_row_exists` pre-validation all keep working —
    /// but no `REFERENCES` clause is rendered. This is the only way to
    /// model an FK whose target lives on a *different* database (a real
    /// DB constraint can't span databases). The boot-time guard in
    /// `App::build` rejects a cross-database FK that has NOT opted out
    /// via this flag (`BuildError::CrossDatabaseForeignKey`). Closes
    /// gaps2 #22. Ignored for non-FK fields.
    pub db_constraint: bool,

    /// When `true`, the admin shows this field disabled on the edit
    /// form. Pure UX hint — no effect on the REST API or the OpenAPI
    /// spec; clients can still POST/PUT/PATCH the column normally.
    /// Set via `#[umbral(noedit)]`.
    ///
    /// Use case: a value the user supplies once at signup (`email`,
    /// `username`) but isn't supposed to change later through the
    /// admin. The REST API may still accept updates — gate that
    /// separately via `ResourceConfig::hide(...)` or a permission
    /// class if you want hard enforcement. To block writes entirely,
    /// use `noform` instead.
    ///
    /// Has no effect when `noform` is also set.
    pub noedit: bool,

    /// When `true`, this field is the display string for the
    /// model — the admin uses it as the default label in
    /// `list_display` when the developer hasn't specified one
    /// explicitly. Set via `#[umbral(string)]` /
    /// `#[umbral(string = true)]`. Only meaningful on `String`-typed
    /// columns; on non-string columns the admin falls back to the PK.
    pub is_string_repr: bool,

    /// Soft length cap for display. The admin truncates the value at
    /// this many characters when rendering it in `list_display` so a
    /// long body doesn't blow out a column. `0` means no truncation.
    /// Set via `#[umbral(max_length = N)]`.
    pub max_length: u32,

    /// Closed-set values for a choices column, in declaration order.
    /// Populated by the `#[derive(Model)]` macro for fields tagged
    /// `#[umbral(choices)]` by reading `<T as ChoiceField>::VALUES` at
    /// derive time. Empty slice means "not a choices field" — every
    /// non-choices column uses the empty default.
    ///
    /// The migration engine emits a Postgres `CHECK (col IN (...))`
    /// constraint when this slice is non-empty; the admin renders a
    /// `<select>` widget with these as the `<option>` values.
    pub choices: &'static [&'static str],

    /// Human-readable labels matching `choices` position-for-position.
    /// Used by the admin to render the `<select>` widget's option text.
    /// Empty when `choices` is empty.
    pub choice_labels: &'static [&'static str],

    /// SQL `DEFAULT` clause for this column. Set via
    /// `#[umbral(default = "...")]` — accepts a string literal that
    /// the DDL pass passes verbatim into `DEFAULT '<value>'`. Empty
    /// string means no default. Carried through to the migration
    /// engine, which emits the `DEFAULT` on both `CREATE TABLE` and
    /// `ALTER TABLE ADD COLUMN`.
    pub default: &'static str,

    /// When `true`, this column is a [`MultiChoice<E>`] field: TEXT
    /// storage holding a CSV of the variants of `E`. The `choices` and
    /// `choice_labels` slices carry the same metadata as a single-valued
    /// choices field — the admin uses `is_multichoice` to pick the
    /// checkbox-chip widget over the `<select>` widget.
    ///
    /// [`MultiChoice<E>`]: crate::orm::MultiChoice
    pub is_multichoice: bool,

    /// When `true`, the migration engine emits a `UNIQUE` constraint
    /// on this column at `CREATE TABLE` time. Set via
    /// `#[umbral(unique)]`. Closes gap #65.
    ///
    /// Scope at v1: applies to *new* tables only. Toggling `unique`
    /// on an existing column does not generate an automatic
    /// `ALTER TABLE ADD CONSTRAINT` — SQLite cannot add a unique
    /// constraint without rebuilding the table, and the M8 diff
    /// engine only watches `ty` and `nullable`. Add or remove
    /// uniqueness on a live table via a hand-written migration
    /// until the diff engine grows constraint-level ops.
    ///
    /// Primary-key columns are already implicitly unique, so this
    /// flag is a no-op on a PK field. Set it on every other column
    /// that needs database-enforced uniqueness (`username`,
    /// `email`, opaque tokens, slugs, etc.) so handler-level
    /// pre-checks become unnecessary.
    pub unique: bool,

    /// Referential action emitted on `DELETE` of the FK target row.
    /// Only meaningful when `ty == ForeignKey`; ignored for every
    /// other column. Set via `#[umbral(on_delete = "...")]`. Closes
    /// gap #68. Defaults to `NoAction` so existing migrations
    /// don't change shape.
    pub on_delete: FkAction,

    /// Referential action emitted on `UPDATE` of the FK target row's
    /// primary key. Same FK-only semantics as `on_delete`; almost
    /// nobody touches this in practice (PKs rarely move) but the
    /// symmetry matches `REFERENCES ... ON UPDATE ...` and the
    /// `on_delete` / `on_update` pair. Set via
    /// `#[umbral(on_update = "...")]`.
    pub on_update: FkAction,

    /// When `true`, the migration engine emits a single-column
    /// `CREATE INDEX` statement alongside the `CREATE TABLE`. Set
    /// via `#[umbral(index)]`. Closes BUG-4 in
    /// `bugs/tests/testBugs.md`.
    ///
    /// Index name convention: `idx_<table>_<column>`. Apps that
    /// need a custom name, a multi-column index, or a partial
    /// index write the `CREATE INDEX` by hand in a follow-up
    /// migration.
    pub index: bool,

    /// When `true`, the column gets populated with `Utc::now()` at
    /// row-creation time *only*. Set via `#[umbral(auto_now_add)]`.
    /// Closes BUG-5 in `bugs/tests/testBugs.md`.
    ///
    /// **Where this fires:** the dynamic write path
    /// (`DynQuerySet::insert_json`, used by `umbral-rest` /
    /// `umbral-admin`). The typed `Manager::create(instance)` path
    /// is user-controlled — the caller passes whatever value they
    /// chose at the struct-init site. v1 scope: the framework
    /// auto-populates only when the body / form omits the field.
    pub auto_now_add: bool,

    /// When `true`, the column gets populated with `Utc::now()` on
    /// every write (create AND update). Set via `#[umbral(auto_now)]`. Closes
    /// BUG-5 in `bugs/tests/testBugs.md`.
    ///
    /// **Where this fires:** the dynamic write path
    /// (`DynQuerySet::insert_json` and `update_json`, used by
    /// `umbral-rest` / `umbral-admin`). The typed paths stay
    /// user-controlled at v1. Body-supplied values are kept —
    /// users can override `auto_now` columns on the dynamic
    /// path, matching the lenient "fill if missing" shape of
    /// `auto_now_add`. An "always override" shape lands as
    /// a future v2 toggle if a real consumer asks.
    pub auto_now: bool,

    /// Human-readable column description (help text).
    /// Set via `#[umbral(help = "...")]`. Flows
    /// through to:
    ///
    /// - OpenAPI `description` on the property schema (closes
    ///   playground-openapi-gaps item 5).
    /// - Admin form field hint (the small line below the
    ///   input).
    /// - Doc-comment-style introspection for any future code
    ///   generator.
    ///
    /// Empty string means "no description" — the OpenAPI
    /// emitter and admin form skip the surrounding markup
    /// when this is unset.
    pub help: &'static str,

    /// Presentation hint for form-rendering surfaces. Set via
    /// `#[umbral(widget = "markdown" | "rte" | "textarea" | ...)]`;
    /// `None` (the default) means "let the renderer pick by
    /// `SqlType`". features.md #4.
    ///
    /// It is **metadata only** — the column's `SqlType`, DDL, and
    /// stored value are unchanged. A `widget = "markdown"` field is
    /// still `TEXT`; the widget only tells the admin (or any plugin
    /// form) to render a markdown editor instead of a bare
    /// `<textarea>`, and pairs with the `{{ value | markdown }}`
    /// filter on the display side. Excluded from the migration diff
    /// for the same reason `help` / `example` are: no DB effect.
    ///
    /// Renderers fall back to the `SqlType`-derived input for any
    /// widget name they don't recognise, so an unknown widget is a
    /// soft no-op rather than an error — third-party plugins can ship
    /// new widget names without the core knowing them.
    pub widget: Option<&'static str>,

    /// Sample value rendered as OpenAPI `example` on the property
    /// schema. Set via `#[umbral(example = "...")]`. Closes
    /// playground-openapi-gaps item 6.
    ///
    /// Empty string means no example. Emitted as a JSON string in
    /// the spec — clients that want typed examples can coerce on
    /// their end. Pairs naturally with `help` to make a column's
    /// purpose clear in Swagger UI.
    pub example: &'static str,

    /// Optional numeric lower bound. Set via `#[umbral(min = N)]`.
    /// Closes IMP-3 from `bugs/tests/testBugs.md`. Flows to:
    ///
    /// - OpenAPI `minimum` on the property schema.
    /// - REST plugin's dynamic write path pre-validation (400
    ///   response with a structured message).
    /// - Future: HTML5 `min` attribute on admin form inputs.
    ///
    /// `i64::MIN` sentinel means "no minimum"; the DDL +
    /// OpenAPI emitters skip the constraint when this is the
    /// sentinel value. Macro accepts integer literals only at
    /// v1 (a `Decimal`-aware shape can land when there's a real
    /// consumer for decimal-typed validators).
    pub min: Option<i64>,

    /// Optional numeric upper bound. Set via `#[umbral(max = N)]`.
    /// Mirror of `min`; same plumbing on the OpenAPI / REST /
    /// admin sides.
    pub max: Option<i64>,

    /// Constrained-text marker. `None` is a plain `String` /
    /// `SqlType::Text` column; `Some("slug" | "email" | "url")` is
    /// one of the validator wrapper types from
    /// [`crate::orm::validators`]. Closes BUG-11/12/13. Flows to:
    ///
    /// - OpenAPI `format: email` / `format: uri` / `pattern` on the
    ///   property schema (the standard 3.0 markers).
    /// - REST plugin's dynamic write path: `validate_text_format`
    ///   pre-checks the body value and returns a structured 400
    ///   on a bad input.
    /// - Admin form: HTML5 `type="email"` / `type="url"` widget
    ///   (when those land).
    ///
    /// The marker is set by the macro classifier from the field type
    /// — `Slug` → `Some("slug")`, `Email` → `Some("email")`,
    /// `Url` → `Some("url")`. The wrapper type + marker stay in sync
    /// because they're produced from the same single match arm in
    /// `umbral-macros::classify_field_type`.
    pub text_format: Option<&'static str>,

    /// Source column for an auto-derived slug. Set via
    /// `#[umbral(slug_from = "title")]` on a `Slug` / `String` field;
    /// names a sibling column on the same model whose value seeds
    /// this column at write time. Gap 109.
    ///
    /// **Where this fires:** the dynamic write path
    /// ([`crate::orm::DynQuerySet::insert_json`] +
    /// [`crate::orm::DynQuerySet::update_json`]). On insert, an empty
    /// or absent slug column is replaced by `slugify(source_value)`
    /// derived from the source column in the same body. On update,
    /// the slug is regenerated only when the source column is also
    /// in the update payload, so callers who edit nothing but the
    /// slug itself keep their hand-tuned value.
    ///
    /// `None` is the default — no auto-derive. The string is a
    /// column name (snake_case), not a Rust field name, so it must
    /// match exactly what ends up in `FieldSpec::name`.
    pub slug_from: Option<&'static str>,
}

/// Referential action emitted in the SQL `REFERENCES ... ON
/// {DELETE,UPDATE} <action>` clause. Mirrors the standard SQL set.
///
/// Copy + 'static so it can live on `FieldSpec` (which is itself
/// `Copy` for storage in `&'static [FieldSpec]`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum FkAction {
    /// SQL `NO ACTION` — the default. The migration engine emits no
    /// clause at all (which means "default" on both backends; sqlite
    /// and Postgres both default to NO ACTION when omitted).
    #[default]
    NoAction,
    /// SQL `CASCADE` — when the FK target row is deleted/updated,
    /// the referencing row is deleted/updated too. The right answer
    /// for "owned" relationships (an `AuthToken` follows its
    /// owning `AuthUser` to the grave).
    Cascade,
    /// SQL `RESTRICT` — block the delete/update of the FK target
    /// row if any referencing row exists. Checked immediately;
    /// doesn't defer to commit. Right for "you can't drop a
    /// category that still has products in it."
    Restrict,
    /// SQL `SET NULL` — null the referencing column. Only valid on
    /// nullable FK columns; the migration engine doesn't currently
    /// check this at boot, so a mismatched pair (NOT NULL + SET NULL)
    /// will fail at FK action time, not at CREATE TABLE.
    SetNull,
}

impl FkAction {
    /// SQL keyword for the `ON {DELETE,UPDATE} <kw>` clause.
    /// Returns `None` for `NoAction` so the DDL builder can skip
    /// the clause entirely (rather than emitting the redundant
    /// `NO ACTION` literal).
    pub fn sql_keyword(self) -> Option<&'static str> {
        match self {
            Self::NoAction => None,
            Self::Cascade => Some("CASCADE"),
            Self::Restrict => Some("RESTRICT"),
            Self::SetNull => Some("SET NULL"),
        }
    }

    /// Parse the attribute string supplied to `#[umbral(on_delete = "...")]`.
    /// Case-insensitive; accepts both `set_null` and `set null` for
    /// the multi-word case so users can write whichever feels
    /// natural.
    pub fn from_attr_str(s: &str) -> Option<Self> {
        match s.to_lowercase().as_str() {
            "no_action" | "no action" => Some(Self::NoAction),
            "cascade" => Some(Self::Cascade),
            "restrict" => Some(Self::Restrict),
            "set_null" | "set null" => Some(Self::SetNull),
            _ => None,
        }
    }
}

/// The SQL type kind of a column.
///
/// The dialect-specific rendering (`BIGINT` vs `INTEGER` vs whatever
/// the backend calls it) is the backend's responsibility, set up by
/// the M4 `DatabaseBackend` abstraction. This enum is the abstract
/// classification umbral reasons about.
///
/// The catalogue follows spec 04 §4.1: each variant covers one
/// field type. Rust types in the field declaration map to a
/// variant via the M3 derive's `classify_field_type`; the table is in
/// `umbral-macros/src/lib.rs` alongside the derive.
///
/// Backend-specific variants (Postgres `Array`, `HStore`, `Jsonb`) land
/// at M4 when the system check exists to gate them at boot.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SqlType {
    /// A foreign-key reference to another table. Stored as `i64` (the
    /// referenced row's primary key). Renders as `BIGINT REFERENCES
    /// "<target_table>"("id")` on both Postgres and SQLite.
    ///
    /// The referenced table name is carried separately in
    /// [`FieldSpec::fk_target`] so this enum stays `Copy`. The migration
    /// engine reads `fk_target` at DDL-emit time.
    ///
    /// Out of scope at v1: non-`i64` FK targets, `ON DELETE` behaviours
    /// beyond the default RESTRICT, reverse accessors (`User::posts`),
    /// and many-to-many join tables. See `docs/specs/relationships.md`.
    ForeignKey,
    /// 16-bit signed integer. `i8` / `i16` / `u8` in Rust.
    SmallInt,
    /// 32-bit signed integer. `i32` / `u16` in Rust.
    Integer,
    /// 64-bit signed integer. `i64` / `u32` in Rust.
    BigInt,
    /// 32-bit floating point. `f32` in Rust.
    Real,
    /// 64-bit floating point. `f64` in Rust.
    Double,
    /// Boolean. `bool` in Rust.
    Boolean,
    /// Variable-length string. `String` in Rust.
    Text,
    /// Date without time. `chrono::NaiveDate` in Rust.
    Date,
    /// Time without date. `chrono::NaiveTime` in Rust.
    Time,
    /// Timestamp with timezone. `chrono::DateTime<chrono::Utc>` in Rust.
    Timestamptz,
    /// 128-bit UUID. `uuid::Uuid` in Rust.
    Uuid,
    /// JSON document. `serde_json::Value` in Rust.
    ///
    /// Cross-backend: Postgres stores native `JSONB` (binary form with
    /// index / operator support); SQLite stores `TEXT` (JSON-as-string).
    /// `serde_json::Value` round-trips through both via sqlx's `json`
    /// feature, so a user model with a `Value` field works on either
    /// backend without code changes: a portable JSON field with a
    /// portable shape and dialect-specific storage. Native JSONB-only
    /// operators (`@>`, `->`, `->>` etc.) are a deferred follow-on
    /// landed alongside Postgres-specific column predicates.
    Json,
    /// Array column. `Vec<T>` in Rust where `T` is one of the
    /// [`ArrayElement`] variants.
    ///
    /// **Postgres-only.** SQLite has no native array type; the M4
    /// system check fails at boot if an Array field is registered
    /// against the SQLite backend. For portable list storage, declare
    /// the field as `serde_json::Value` (the [`Self::Json`] variant)
    /// and store a JSON array inside.
    ///
    /// The inner type is restricted to [`ArrayElement`] rather than
    /// `Box<SqlType>` so the outer enum stays `Copy` and `SqlType`
    /// values can live in `const FIELDS` slices the derive emits.
    /// Multi-dim arrays (`Vec<Vec<T>>`), nullable elements
    /// (`Vec<Option<T>>`), and nested JSON arrays (`Vec<Value>`) are
    /// out of scope for v1.
    Array(ArrayElement),
    /// `INET` — Postgres IP address column with optional netmask.
    /// Maps to `ipnetwork::IpNetwork` in Rust. **Postgres-only.**
    /// Stores a generic IP address.
    Inet,
    /// `CIDR` — Postgres network address column. Same Rust type as
    /// `Inet` (`ipnetwork::IpNetwork`) but with the constraint that
    /// the host bits must be zero. **Postgres-only.**
    Cidr,
    /// `MACADDR` — Postgres MAC address column. Maps to
    /// `mac_address::MacAddress` in Rust. **Postgres-only.**
    MacAddr,
    /// `XML` — Postgres XML document column. Maps to `String` in Rust
    /// (umbral stores and round-trips the serialized XML text; it does
    /// not parse or validate the document at the framework level —
    /// Postgres does that on insert). **Postgres-only.** Reach for this
    /// over `Text` only when you want Postgres' `xml` type checking and
    /// the `xpath` / `xmlexists` operator surface; otherwise `Text`
    /// stores XML strings just fine (XML is otherwise modelled as plain
    /// text).
    Xml,
    /// `LTREE` — Postgres hierarchical label-path column (the `ltree`
    /// extension). Maps to `String` in Rust (the dotted path, e.g.
    /// `"Top.Science.Astronomy"`). **Postgres-only**, and requires the
    /// `ltree` extension (`CREATE EXTENSION ltree`) to be installed in
    /// the target database. The umbral migration engine emits the bare
    /// `ltree` column type; the extension itself is the operator's
    /// responsibility (a hand-written migration or a DB bootstrap step).
    Ltree,
    /// `BIT VARYING` — Postgres bit-string column. Maps to `String` in
    /// Rust (the textual `"0"`/`"1"` representation, e.g. `"101"`).
    /// **Postgres-only.** v1 renders as `BIT VARYING` (variable-length);
    /// a fixed-width `BIT(n)` needs a hand-written migration after the
    /// initial create until a `#[umbral(bit_len = N)]` attribute lands
    /// for a real consumer. There is otherwise no dedicated bit-string
    /// type; the fallback is plain text.
    Bit,
    /// `TSVECTOR` — Postgres full-text search lexeme vector. Maps to
    /// [`crate::orm::TsVector`] in Rust (a thin newtype around
    /// `String` with sqlx Type/Encode/Decode impls). **Postgres-only.**
    ///
    /// The column is typically populated by a Postgres trigger or
    /// `GENERATED ALWAYS AS (to_tsvector(...)) STORED` clause; umbral's
    /// migration engine emits the bare `tsvector` type, leaving the
    /// population mechanism to the user. Queries against a
    /// `FullTextCol` use the `@@` match operator with `to_tsquery` /
    /// `websearch_to_tsquery`.
    FullText,
    /// `BLOB` (SQLite) / `BYTEA` (Postgres) — arbitrary binary payload.
    /// Maps to `Vec<u8>` in Rust. Used by anything that stores opaque
    /// bytes: file uploads, the cache backend's value column, encrypted
    /// envelopes, etc.
    ///
    /// `Vec<u8>` was previously routed to `SqlType::Array(SmallInt)`
    /// because the array detection treated `u8` as a small int. The
    /// detection now checks for `Vec<u8>` specifically first and
    /// routes to `Bytes`; `Vec<i8>` / `Vec<i16>` still map to
    /// `Array(SmallInt)`.
    Bytes,
    /// `NUMERIC(19, 4)` — fixed-point decimal. Maps to
    /// `rust_decimal::Decimal` in Rust. Closes BUG-10 from
    /// `bugs/tests/testBugs.md`. Money / price columns must use
    /// this, not `f64` (binary float drops cents) or `String`
    /// (no DB-level arithmetic).
    ///
    /// **Postgres-only at v1.** sqlx's `rust_decimal` feature
    /// adds Encode/Decode for Postgres `NUMERIC` only; SQLite has
    /// no native decimal type (every numeric value is INTEGER /
    /// REAL / TEXT affinity). The boot system check rejects
    /// Decimal models against SQLite the same way it rejects
    /// `Array(_)` — apps deploying to SQLite either pick a
    /// portable type (`Real` or `Text` with manual formatting) or
    /// use Postgres for the parts of their schema that need
    /// decimal arithmetic. A fixed-precision decimal column.
    ///
    /// **v1 scope.** Precision and scale are fixed at `(19, 4)` —
    /// 19 significant digits, 4 after the decimal point. That's
    /// enough headroom for currency values up to one quadrillion
    /// dollars (with sub-cent precision) and matches sqlx's
    /// `Decimal` default. Apps that need a different precision
    /// alter the column via a hand-written migration after the
    /// initial create. A `#[umbral(precision = N, scale = M)]`
    /// attribute lands when there's a real consumer that needs
    /// dimensions outside the default.
    Decimal,
}

/// Element types valid inside [`SqlType::Array`].
///
/// A strict subset of the [`SqlType`] catalogue: the value types
/// Postgres supports as `T[]` and that umbral knows how to bind / decode
/// through sqlx. Stays `Copy` so the outer `SqlType::Array(ArrayElement)`
/// remains usable in `const FIELDS` slices.
///
/// Catalogue:
///
/// | Variant     | Postgres type | Rust inner type   |
/// |-------------|---------------|-------------------|
/// | `SmallInt`  | `int2[]`      | `Vec<i16>`        |
/// | `Integer`   | `int4[]`      | `Vec<i32>`        |
/// | `BigInt`    | `int8[]`      | `Vec<i64>`        |
/// | `Real`      | `float4[]`    | `Vec<f32>`        |
/// | `Double`    | `float8[]`    | `Vec<f64>`        |
/// | `Boolean`   | `bool[]`      | `Vec<bool>`       |
/// | `Text`      | `text[]`      | `Vec<String>`     |
/// | `Uuid`      | `uuid[]`      | `Vec<uuid::Uuid>` |
///
/// Other element types (Date / Time / Timestamptz / Json) land as
/// follow-ons when there's a real consumer; the binding semantics for
/// chrono types as Postgres array elements need a deliberate pass.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ArrayElement {
    SmallInt,
    Integer,
    BigInt,
    Real,
    Double,
    Boolean,
    Text,
    Uuid,
}

impl ArrayElement {
    /// Lift this element type back to its [`SqlType`] equivalent. Used
    /// when a per-element decision needs to dispatch through the same
    /// SqlType match the rest of umbral uses (e.g. picking a
    /// `sea_query::ColumnType` for the element).
    pub fn to_sql_type(self) -> SqlType {
        match self {
            ArrayElement::SmallInt => SqlType::SmallInt,
            ArrayElement::Integer => SqlType::Integer,
            ArrayElement::BigInt => SqlType::BigInt,
            ArrayElement::Real => SqlType::Real,
            ArrayElement::Double => SqlType::Double,
            ArrayElement::Boolean => SqlType::Boolean,
            ArrayElement::Text => SqlType::Text,
            ArrayElement::Uuid => SqlType::Uuid,
        }
    }
}