umbral-core 0.0.12

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
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
//! 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 SQL table name as a call — `UserProfile::table_name()` → `"profile"`.
    ///
    /// A convenience over the [`TABLE`](Self::TABLE) associated const so callers
    /// never hardcode the table string (which can diverge from the struct name,
    /// e.g. `UserProfile` → `profile`) and don't need the
    /// `<UserProfile as Model>::TABLE` turbofish. With `Model` in scope (it's in
    /// the prelude) `UserProfile::table_name()` resolves directly.
    fn table_name() -> &'static str {
        Self::TABLE
    }

    /// 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;

    /// gaps3 #54 — `#[umbral(audited)]`. Every write records an `umbral_audit`
    /// row (who / when / which row / which fields changed).
    const AUDITED: bool = false;

    /// features #73 — `#[umbral(view = "SELECT ...")]`. When set, this model is
    /// backed by a database VIEW rather than a table: the migration engine emits
    /// `CREATE VIEW <table> AS <this SQL>` and never a `CREATE TABLE`.
    ///
    /// The struct's fields must line up with the SELECT list — the framework
    /// cannot check that at compile time (the SQL is an opaque string), so a
    /// mismatch surfaces as a "no such column" the first time you query it.
    ///
    /// A view is **read-only**. Every write path rejects a view model with
    /// [`WriteError::ReadOnlyView`](crate::orm::write::WriteError::ReadOnlyView)
    /// before it reaches the database, because the alternative — a driver-level
    /// error from deep inside the insert — tells you nothing about *why*.
    const VIEW: Option<&'static str> = None;

    /// features #73 — `#[umbral(materialized_view = "SELECT ...")]`. Implies
    /// [`VIEW`](Self::VIEW); additionally emits `CREATE MATERIALIZED VIEW`, whose
    /// rows are computed once and stored until you call
    /// [`refresh_view`](crate::db::refresh_view).
    ///
    /// Postgres-only. SQLite has no materialized views, and rendering one as a
    /// plain view there would give you a backend that silently recomputes on every
    /// read — the same query, a different performance contract, and a "works on my
    /// machine" that only shows up under production load. The `model.materialized_view`
    /// system check fails the boot instead.
    const MATERIALIZED: 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)] = &[];

    /// Field names to STRIP from signal payloads (audit_2 core-app-config #10).
    /// Set per-field via `#[umbral(signal_skip)]`. The ORM signal emitters
    /// (`pre/post_save`, `pre/post_delete`, `pre/post_update`) serialize the
    /// whole row into the `"instance"` payload that fans out to every
    /// subscriber; a subscriber that logs or persists payloads (the natural
    /// audit-log shape) would otherwise copy password hashes, tokens, and PII
    /// into logs / secondary stores. Listed fields are removed from the
    /// serialized instance before it is emitted. Default empty (full row).
    const SIGNAL_SKIP_FIELDS: &'static [&'static str] = &[];

    /// 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,

    /// When `true`, this is a privileged/server-managed field: the untrusted
    /// JSON write path (`insert_json`/`update_json` — REST create/update and
    /// admin form-submit) strips it UNLESS the caller explicitly authorizes it
    /// via [`crate::orm::dynamic::DynQuerySet::allow_privileged`]. Set via
    /// `#[umbral(privileged)]`.
    ///
    /// This is the default-DENY mass-assignment guard (audit_2 H3): fields like
    /// `is_superuser` / `is_staff` / ownership FKs stay writable through the
    /// typed struct path and through an *authorized* dynamic write, but an
    /// unprivileged client can't set them by smuggling them into a create/update
    /// body. Unlike `noform`, the field still renders on forms (an admin with
    /// the right permission legitimately edits it); the guard is on the write,
    /// not the visibility. OpenAPI is unaffected — the field remains in the
    /// writable schema, since whether a given caller may set it is a runtime
    /// authorization decision, not a static contract.
    pub privileged: bool,

    /// Confidential, but legitimately viewable by SOME callers. Stripped from every
    /// serialized response (`DynQuerySet`'s JSON reads — which is what REST, GraphQL and the
    /// admin all sit on) UNLESS that read explicitly unlocks it via
    /// [`crate::orm::dynamic::DynQuerySet::allow_private`]. Set via `#[umbral(private)]`.
    ///
    /// This is the read-path twin of [`Self::privileged`]: default-deny, with an explicit
    /// unlock at the call site rather than a config flag, so that `grep -rn allow_private`
    /// is a complete inventory of every place confidential data is permitted to leave.
    ///
    /// Wholesale cost, internal notes, another user's email. NOT password hashes — those are
    /// [`Self::secret`], which has no unlock at all.
    pub private: bool,

    /// Must never be serialized to a client. By anyone. There is deliberately **no unlock**.
    /// Set via `#[umbral(secret)]`, and applied automatically to every `Masked<T>` field.
    ///
    /// The value of a tier with no escape hatch is that nobody can reach for it at 2am under
    /// a deadline. An admin that needs to show *whether* a password is set shows "set / not
    /// set" — never the hash.
    ///
    /// The one thing that is not a client: a database dump. `dumpdata` must round-trip
    /// password hashes or a restore locks every user out, so it reads through the loudly
    /// named [`crate::orm::dynamic::DynQuerySet::unredacted_for_backup`].
    pub secret: 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,
    /// `#[umbral(auto_user_add)]` — stamp the authenticated caller's id on
    /// INSERT only. Opt-in by ATTRIBUTE, never by column name: a field you
    /// happen to call `created_by` with no attribute is yours, untouched.
    pub auto_user_add: bool,
    /// `#[umbral(auto_user)]` — stamp the authenticated caller's id on every
    /// write.
    pub auto_user: 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,

    /// When `true`, the framework generates a fresh `Uuid::new_v4()` for
    /// this column at row-creation time when the body/struct omits it (or
    /// leaves it at the nil UUID). Set via `#[umbral(auto_uuid)]`. The
    /// who-am-I-publicly twin of `auto_now_add`: a stable, non-sequential
    /// public identifier that doesn't leak row counts, generated in Rust so
    /// it works identically on SQLite and Postgres (unlike a
    /// `gen_random_uuid()` DDL default, which is Postgres-only). Fires on
    /// BOTH the dynamic write path (REST/admin) and the typed
    /// `Manager::create` path; an explicitly-supplied non-nil value is kept.
    pub auto_uuid: bool,

    /// When `true`, the dynamic write path strips leading/trailing whitespace
    /// from this column's string value before INSERT/UPDATE. Set via
    /// `#[umbral(trim)]`; only valid on `String` / `Option<String>` fields
    /// (the derive rejects it elsewhere at compile time).
    ///
    /// **Where this fires:** the dynamic write path only
    /// (`DynQuerySet::insert_json`/`update_json` + the admin form builders),
    /// exactly like [`auto_now`](Self::auto_now). The typed
    /// `Manager::create(instance)` path is caller-controlled — normalize there
    /// yourself (e.g. `umbral_auth::normalize_email`) if you need it. Combines
    /// with [`lowercase`](Self::lowercase): trim runs first, then lowercase.
    pub trim: bool,

    /// When `true`, the dynamic write path lowercases this column's string
    /// value before INSERT/UPDATE. Set via `#[umbral(lowercase)]`; only valid
    /// on `String` / `Option<String>` fields. Pair with [`unique`](Self::unique)
    /// to get case-insensitive uniqueness for free (every stored row is already
    /// lowercased), and with [`trim`](Self::trim) to also drop surrounding
    /// whitespace. Same dynamic-path-only scope as [`trim`](Self::trim).
    pub lowercase: bool,

    /// When `true`, the column is **case-insensitive at the database level**:
    /// comparisons, `UNIQUE`, and lookups treat `Dalmas` and `dalmas` as equal,
    /// while the *original* casing is preserved in storage. Set via
    /// `#[umbral(case_insensitive)]`; `String` / `Option<String>` only.
    ///
    /// Unlike [`lowercase`](Self::lowercase) (which normalizes the stored value
    /// and rides a plain UNIQUE), this changes the emitted DDL: Postgres gets a
    /// `citext` column (the migration auto-creates the `citext` extension),
    /// SQLite gets `COLLATE NOCASE`. It is schema-affecting, so — like
    /// [`unique`](Self::unique) — it applies at `CREATE TABLE`; toggling it on an
    /// existing column needs a hand-written migration.
    ///
    /// Caveat: SQLite's `NOCASE` folds ASCII `A–Z` only (not Unicode); a boot
    /// check warns when this is used on SQLite. Postgres `citext` folds per the
    /// database collation. Prefer [`lowercase`](Self::lowercase) when you don't
    /// need to preserve the original casing.
    pub case_insensitive: bool,

    /// Human-readable column description (help text).
    /// Set via `#[umbral(help = "...")]`. Flows
    /// through to:
    ///
    /// - The **database itself**, as a Postgres `COMMENT ON COLUMN`
    ///   (gaps3 #43). SQLite has no comment facility, so it is
    ///   omitted there. This is the one place `help` is not
    ///   purely presentational: editing it produces a
    ///   `SetColumnComment` migration.
    /// - OpenAPI `description` on the property schema (closes
    ///   playground-openapi-gaps item 5).
    /// - Admin form field hint (the small line below the
    ///   input).
    /// - TSDoc on the generated TypeScript interface
    ///   (`umbral typegen`).
    ///
    /// Empty string means "no description" — the OpenAPI
    /// emitter and admin form skip the surrounding markup
    /// when this is unset, and no comment is emitted.
    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 `example` is: no DB effect. (`help` used to
    /// be in that list; it now renders as a Postgres column comment.)
    ///
    /// 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>,
}

impl FieldSpec {
    /// A do-nothing `FieldSpec` used only to initialise the fixed-size
    /// array inside [`concat_field_specs`] before the real specs are
    /// copied over. Never surfaces in a model's `FIELDS`: every slot is
    /// overwritten by a genuine spec during the const concat.
    pub const PLACEHOLDER: FieldSpec = FieldSpec {
        name: "",
        ty: SqlType::Integer,
        primary_key: false,
        nullable: false,
        supported_backends: &[],
        fk_target: None,
        noform: false,
        privileged: false,
        private: false,
        secret: false,
        db_constraint: true,
        noedit: false,
        is_string_repr: false,
        max_length: 0,
        choices: &[],
        choice_labels: &[],
        default: "",
        is_multichoice: false,
        unique: false,
        on_delete: FkAction::NoAction,
        on_update: FkAction::NoAction,
        index: false,
        auto_now_add: false,
        auto_user_add: false,
        auto_user: false,
        auto_now: false,
        auto_uuid: false,
        trim: false,
        lowercase: false,
        case_insensitive: false,
        help: "",
        widget: None,
        example: "",
        min: None,
        max: None,
        text_format: None,
        slug_from: None,
    };
}

/// Concatenate several `&'static [FieldSpec]` slices into one owned
/// `[FieldSpec; N]` array at compile time. `N` MUST equal the sum of all
/// `parts` lengths (the `#[derive(Model)]` macro computes it from the
/// base fields' `BASE_FIELDS.len()` plus the model's own field count).
///
/// This is the mechanism that lets a model embedding a [`ModelBase`] via
/// `#[umbral(flatten)]` splice the base's columns into its own `FIELDS`
/// const — `FieldSpec` is `Copy`, so the whole thing evaluates in a
/// `const` context without allocation.
pub const fn concat_field_specs<const N: usize>(parts: &[&[FieldSpec]]) -> [FieldSpec; N] {
    let mut out = [FieldSpec::PLACEHOLDER; N];
    let mut oi = 0;
    let mut pi = 0;
    while pi < parts.len() {
        let part = parts[pi];
        let mut i = 0;
        while i < part.len() {
            out[oi] = part[i];
            oi += 1;
            i += 1;
        }
        pi += 1;
    }
    out
}

/// A reusable group of model fields — the umbral equivalent of a Django
/// abstract base model. A struct deriving [`ModelBase`](macro@crate::orm::ModelBase)
/// declares shared columns once (with the full `#[umbral(...)]` attribute
/// set); any [`Model`] embeds it as a nested field marked
/// `#[umbral(flatten)]` and inherits those columns as if written inline.
///
/// The base's columns are spliced into the embedding model's
/// [`Model::FIELDS`] via [`concat_field_specs`], so migrations, the SELECT
/// list, inserts and auto-stamping all see one flat schema. The embedding
/// model reads/writes the base's values through `#[serde(flatten)]` +
/// `#[sqlx(flatten)]` on the nested field.
pub trait ModelBase {
    /// The base's columns, in declaration order, carrying every attribute
    /// they were declared with. Spliced into the embedding model's
    /// `FIELDS`.
    const BASE_FIELDS: &'static [FieldSpec];

    /// The name of the base's primary-key column, or `None` when the base
    /// declares no primary key. When a model has no PK of its own and
    /// embeds exactly one base with `BASE_PK = Some(_)`, that column
    /// becomes the model's primary key.
    const BASE_PK: Option<&'static str>;

    /// The Rust type of the base's primary key (echoing the field type).
    /// A dummy `i64` when the base has no PK — never read in that case.
    type BasePrimaryKey: PrimaryKey;

    /// Read this base value's primary key. Used by the embedding model's
    /// `Model::primary_key` when the PK lives on the base. Returns a
    /// meaningless default when the base has no PK (never called then).
    fn base_primary_key(&self) -> Self::BasePrimaryKey;
}

/// 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,
    /// Timestamp WITHOUT timezone. `chrono::NaiveDateTime` in Rust; a bare
    /// `TIMESTAMP` on Postgres (`DATETIME`/text on SQLite). Distinct from
    /// [`Self::Timestamptz`]: sqlx will not decode a Postgres `TIMESTAMP` into a
    /// `DateTime<Utc>`, so a schema that stores naive timestamps (Prisma's
    /// `DateTime`, some Django configs) needs this to round-trip. No timezone
    /// conversion happens at the marshalling boundary — the wall-clock value is
    /// stored and read verbatim.
    Timestamp,
    /// 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,
    /// Arbitrary-precision decimal backed by `bigdecimal::BigDecimal`
    /// (Postgres `numeric`, unbounded). The sibling of [`SqlType::Decimal`]
    /// for values that overflow `rust_decimal`'s ~28-significant-digit
    /// ceiling — a `numeric(38, 10)` column, astronomical quantities, or
    /// exact math that must not round. Same DDL family as `Decimal`
    /// (`numeric` / `numeric(p, s)`), but the Rust codec is `BigDecimal`,
    /// which carries as many digits as the value needs.
    ///
    /// Postgres-only, exactly like `Decimal`: SQLite has no arbitrary-
    /// precision numeric type, so the system check rejects a `BigDecimal`
    /// field against a SQLite backend at boot.
    BigDecimal,
    /// A fixed-precision decimal with **caller-chosen** dimensions —
    /// `numeric(precision, scale)` — the sibling of [`SqlType::Decimal`]
    /// (which is the `(19, 4)` default) for a column that needs different
    /// dimensions: `numeric(5, 2)` for a percentage, `numeric(19, 8)` for an
    /// FX rate. Produced by `#[umbral(precision = N, scale = M)]` on a
    /// `rust_decimal::Decimal` field. Shares the `rust_decimal` codec with
    /// `Decimal`, so precision must stay within rust_decimal's ~28-digit
    /// ceiling (use `BigDecimal` beyond that). Postgres-only, like `Decimal`.
    DecimalN(DecimalSpec),
    /// PostGIS `geometry(<kind>, <srid>)` — a planar spatial column.
    /// Postgres-only, behind the `postgis` cargo feature. The subtype and
    /// SRID travel inside the [`GeometrySpec`] payload (mirroring how
    /// `Array(ArrayElement)` nests a `Copy` sub-enum) so `SqlType` stays
    /// `Copy` and usable in a `const FIELDS` slice, and so the type and its
    /// SRID stay together the way PostGIS itself models `geometry(Point, 4326)`
    /// as one column type.
    Geometry(GeometrySpec),
    /// PostGIS `geography(<kind>, <srid>)` — a spheroidal spatial column where
    /// distances are in metres. The spheroidal sibling of [`SqlType::Geometry`].
    Geography(GeometrySpec),
}

/// The dimensions of a [`SqlType::DecimalN`] column — `numeric(precision,
/// scale)`. `Copy` so it nests inside `SqlType` and rides in a `const FIELDS`
/// slice.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DecimalSpec {
    /// Total number of significant digits (both sides of the point).
    pub precision: u16,
    /// Digits after the decimal point.
    pub scale: u16,
}

/// The subtype + SRID of a spatial column ([`SqlType::Geometry`] /
/// [`SqlType::Geography`]). `Copy` so it nests inside `SqlType` and rides in a
/// `const FIELDS` slice.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GeometrySpec {
    /// The constrained geometry subtype (`Point`, `Polygon`, …), or
    /// [`GeometryKind::Geometry`] for the unconstrained base type.
    pub kind: GeometryKind,
    /// Spatial Reference System Identifier. `4326` (WGS84 lon/lat) is the
    /// default; `0` means "unspecified SRID" (a `geometry` column with no SRID
    /// constraint).
    pub srid: i32,
}

impl GeometrySpec {
    /// The common default: unconstrained geometry in WGS84 (SRID 4326).
    pub const DEFAULT: GeometrySpec = GeometrySpec {
        kind: GeometryKind::Geometry,
        srid: 4326,
    };
}

/// The PostGIS geometry subtype constraint. `Geometry` is the unconstrained
/// base type (any subtype allowed); the rest pin the column to one shape.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum GeometryKind {
    /// The unconstrained base type — any subtype is accepted.
    Geometry,
    Point,
    LineString,
    Polygon,
    MultiPoint,
    MultiLineString,
    MultiPolygon,
    GeometryCollection,
}

impl GeometryKind {
    /// The PostGIS type-modifier spelling (`Point`, `MultiPolygon`, …) used
    /// inside `geometry(<kind>, <srid>)`. `Geometry` (unconstrained) has no
    /// modifier and renders the bare `geometry` type.
    pub const fn pg_modifier(self) -> &'static str {
        match self {
            GeometryKind::Geometry => "Geometry",
            GeometryKind::Point => "Point",
            GeometryKind::LineString => "LineString",
            GeometryKind::Polygon => "Polygon",
            GeometryKind::MultiPoint => "MultiPoint",
            GeometryKind::MultiLineString => "MultiLineString",
            GeometryKind::MultiPolygon => "MultiPolygon",
            GeometryKind::GeometryCollection => "GeometryCollection",
        }
    }

    /// Parse a `#[umbral(geometry = "...")]` attribute value (case-insensitive)
    /// into a kind. Returns `None` for an unknown spelling so the macro can
    /// emit a clear compile error.
    pub fn from_attr(s: &str) -> Option<GeometryKind> {
        Some(match s.to_ascii_lowercase().as_str() {
            "geometry" | "" => GeometryKind::Geometry,
            "point" => GeometryKind::Point,
            "linestring" => GeometryKind::LineString,
            "polygon" => GeometryKind::Polygon,
            "multipoint" => GeometryKind::MultiPoint,
            "multilinestring" => GeometryKind::MultiLineString,
            "multipolygon" => GeometryKind::MultiPolygon,
            "geometrycollection" => GeometryKind::GeometryCollection,
            _ => return None,
        })
    }
}

/// 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,
        }
    }
}