Skip to main content

umbral_core/
migrate.rs

1//! The migration engine — the north star.
2//!
3//! Implements the **declare → migrate → change → migrate** cycle from
4//! `arch.md §0`. Users declare or change a model, run `migrate`, and the
5//! framework either generates the missing migration file (via `make`)
6//! or applies pending migration files (via `run`).
7//!
8//! At M5 (this milestone) the surface ships:
9//!
10//! - A process-wide [`ModelRegistry`] populated by
11//!   `App::builder().model::<T>()`.
12//! - A [`Snapshot`] of every registered model's metadata, JSON-
13//!   serialisable so it can live inside a migration file's
14//!   `snapshot_after`.
15//! - An [`Operation`] enum with the two minimum-viable ops:
16//!   [`Operation::CreateTable`] and [`Operation::DropTable`]. Column-
17//!   level ops (`AddColumn`, `DropColumn`, `AlterColumn`) land at M8
18//!   alongside rename detection (per `arch.md §7` and
19//!   `docs/specs/06-migration-engine.md`). The "M5.1" label in the
20//!   `UnsupportedChange` error message is shorthand for the same slot.
21//! - A [`MigrationFile`] format (one JSON file per migration) carrying
22//!   `id`, `operations`, and `snapshot_after`.
23//! - The `umbral_migrations` tracking table (one row per applied
24//!   migration, keyed by `(plugin, name)`).
25//! - High-level entry points: [`make`], [`run`], [`show`].
26//!
27//! Reserved for M5.1+:
28//!
29//! - Column-level ops (`AddColumn`, `DropColumn`, `AlterColumn`).
30//! - Rename-detection vs drop+add disambiguation (spec 06 §M8).
31//! - `RunSql` / `RunCode` data-migration ops.
32//! - Squashing, `--fake`, `--fake-initial` (PRD F-MIG-6 P2).
33//! - Cross-plugin migration dependencies (needs M7 Plugin contract).
34//!
35//! See `docs/specs/06-migration-engine.md` for the full target shape.
36
37use std::path::{Path, PathBuf};
38use std::sync::OnceLock;
39
40use serde::{Deserialize, Serialize};
41
42use crate::backend::DatabaseBackend;
43use crate::orm::{FieldSpec, Model, SqlType};
44
45/// Per-process model registry. Published by `AppBuilder::build()`
46/// after `.model::<T>()` calls and `.plugin(...)` registrations
47/// collected metadata into the builder.
48///
49/// Stored as a flat vector of `(plugin_name, model)` pairs so M5's
50/// existing `registered_models()` keeps working (drop the plugin
51/// names) and the M7 plugin-aware walks (`registered_plugins`,
52/// `models_for_plugin`) can read the same source of truth without a
53/// second registry. The plugin name `"app"` covers models registered
54/// via `.model::<T>()`; every other name is a real Plugin's.
55static REGISTRY: OnceLock<Vec<(String, ModelMeta)>> = OnceLock::new();
56
57/// Initialize the registry with one entry per plugin.
58///
59/// `App::build()` calls this after collecting `.model::<T>()` into the
60/// implicit `"app"` plugin and walking every registered plugin's
61/// `Plugin::models()`. Plugins missing from the map contribute zero
62/// models (default-noop `models()` returns an empty vec; the entry
63/// can be omitted).
64pub(crate) fn init_plugins(per_plugin: std::collections::HashMap<String, Vec<ModelMeta>>) {
65    let mut flat: Vec<(String, ModelMeta)> = Vec::new();
66    let mut plugin_names: Vec<String> = per_plugin.keys().cloned().collect();
67    plugin_names.sort();
68    for plugin in plugin_names {
69        for m in per_plugin.get(&plugin).cloned().unwrap_or_default() {
70            flat.push((plugin.clone(), m));
71        }
72    }
73    REGISTRY
74        .set(flat)
75        .expect("umbral::migrate::init_plugins called more than once");
76}
77
78/// Return every registered model, flat. Drops the per-plugin grouping;
79/// useful when the caller only needs the model set (e.g. M5's `make`
80/// when the codebase only had a single `"app"` plugin).
81///
82/// # Panics
83///
84/// Panics if `App::build()` hasn't run.
85pub fn registered_models() -> Vec<ModelMeta> {
86    REGISTRY
87        .get()
88        .expect("umbral: model registry not initialised — did you call App::build()?")
89        .iter()
90        .map(|(_, m)| m.clone())
91        .collect()
92}
93
94/// Whether the model registry has been initialised. False before
95/// `App::build()` has run; true after the phase-3 `init_plugins`
96/// call publishes the per-plugin map. Used by system checks that
97/// walk the registry — they return an empty result when the
98/// registry isn't ready rather than panicking (so low-level tests
99/// that drive `check::run_all` without booting an App keep working).
100pub fn is_initialised() -> bool {
101    REGISTRY.get().is_some()
102}
103
104/// PK lift Pass E — cached `(pk_column_name, pk_sql_type)` lookup
105/// keyed by table name. Used by the FK decode path
106/// (`fk_target_pk_sql_type` in `orm/dynamic.rs`) and the
107/// select_related hydrators, both of which previously cloned the
108/// full `Vec<ModelMeta>` per call and linear-scanned for the
109/// target's PK column.
110///
111/// REGISTRY is a `OnceLock` set once during `App::build`; this cache
112/// reads from it the first time anyone asks for a PK lookup AFTER
113/// initialisation, then serves from a `HashMap` for every
114/// subsequent call. Eliminates the per-row `registered_models()`
115/// clone in hot decode loops.
116///
117/// Returns `None` when the registry isn't initialised (the cache
118/// stays uninstantiated so a follow-up call after `App::build`
119/// gets the real table set), OR when the named table isn't in the
120/// registry (orphan / system / typo).
121pub fn pk_meta_for_table(table: &str) -> Option<(String, crate::orm::SqlType)> {
122    if !is_initialised() {
123        // Defer cache init until App::build has populated REGISTRY.
124        // The cache MUST NOT memoize an empty map; otherwise
125        // post-init callers would see no PK metadata forever.
126        return None;
127    }
128    static CACHE: std::sync::OnceLock<
129        std::collections::HashMap<String, (String, crate::orm::SqlType)>,
130    > = std::sync::OnceLock::new();
131    let map = CACHE.get_or_init(|| {
132        let mut out = std::collections::HashMap::new();
133        for m in registered_models() {
134            if let Some(pk) = m.pk_column() {
135                out.insert(m.table.clone(), (pk.name.clone(), pk.ty));
136            }
137        }
138        out
139    });
140    map.get(table).cloned()
141}
142
143/// Cached model lookup by SQL table name.
144///
145/// Unlike [`registered_models`], this does not deep-clone the full
146/// registry on every call. It clones only the matched [`ModelMeta`],
147/// which keeps row-by-row dynamic serializers from paying
148/// O(registry-size) per row.
149pub fn model_meta_for_table(table: &str) -> Option<ModelMeta> {
150    if !is_initialised() {
151        return None;
152    }
153    static CACHE: std::sync::OnceLock<std::collections::HashMap<String, ModelMeta>> =
154        std::sync::OnceLock::new();
155    let map = CACHE.get_or_init(|| {
156        registered_models()
157            .into_iter()
158            .map(|m| (m.table.clone(), m))
159            .collect()
160    });
161    map.get(table).cloned()
162}
163
164/// The SQL type a column's value actually binds / decodes as (PK lift).
165/// Equals `col.ty` for everything except a `ForeignKey`, where it resolves
166/// to the referenced model's PK type via [`pk_meta_for_table`] — so an FK
167/// pointing at a `String`-slug- or `Uuid`-PK target is handled as text /
168/// uuid instead of being forced through i64. Falls back to `BigInt` (the
169/// historical default) when the target can't be resolved (registry not yet
170/// initialised, or an unregistered target table).
171///
172/// The single source of truth for "what shape is this FK really?", used by
173/// `backup` (dump/load) and the dynamic filter helpers.
174pub fn fk_effective_type(col: &Column) -> crate::orm::SqlType {
175    if matches!(col.ty, crate::orm::SqlType::ForeignKey) {
176        col.fk_target
177            .as_deref()
178            .and_then(pk_meta_for_table)
179            .map(|(_, ty)| ty)
180            .unwrap_or(crate::orm::SqlType::BigInt)
181    } else {
182        col.ty
183    }
184}
185
186/// Return the registered plugin names that contributed at least one
187/// model. Sorted deterministically. Used as a fallback when no
188/// topological order is published; the M7 walk used this directly,
189/// and M8 prefers [`plugin_order`] when it's been set.
190pub fn registered_plugins() -> Vec<String> {
191    let mut names: Vec<String> = REGISTRY
192        .get()
193        .expect("umbral: model registry not initialised — did you call App::build()?")
194        .iter()
195        .map(|(p, _)| p.clone())
196        .collect();
197    names.sort();
198    names.dedup();
199    names
200}
201
202/// The topological plugin order published by `App::build()` after its
203/// phase 1.5 sort. `None` until that runs; the CLI subcommands
204/// (`makemigrations`, `migrate`, `showmigrations`) call `App::build()`
205/// via `boot_for_management` before reaching the migration engine.
206static PLUGIN_ORDER: OnceLock<Vec<String>> = OnceLock::new();
207
208/// Per-model database alias (`Model::NAME -> alias`) published by
209/// `App::build()` after walking each registered plugin's
210/// `Plugin::database()`. Models whose plugin returned `None` are
211/// absent from the map; QuerySet's `resolve_pool` falls back to the
212/// `"default"` alias for those. Lookup is `O(1)` on a `HashMap`.
213static MODEL_ALIASES: OnceLock<std::collections::HashMap<String, String>> = OnceLock::new();
214
215/// Publish the topological plugin order. Called by `App::build()` once
216/// the phase 1.5 sort has produced the order. Must include the
217/// implicit `"app"` plugin even when no real plugins are registered.
218pub(crate) fn init_plugin_order(order: Vec<String>) {
219    PLUGIN_ORDER
220        .set(order)
221        .expect("umbral::migrate::init_plugin_order called more than once");
222}
223
224/// Return the topological plugin order if `App::build()` published
225/// one; otherwise fall back to [`registered_plugins`] (sorted by
226/// name). The fallback keeps existing M5 / M6 tests working without
227/// requiring them to wire a full plugin sort.
228pub fn plugin_order() -> Vec<String> {
229    PLUGIN_ORDER
230        .get()
231        .cloned()
232        .unwrap_or_else(registered_plugins)
233}
234
235/// The client-facing API endpoints every registered plugin advertised
236/// via `Plugin::api_endpoints()`, collected by `App::build()`. `None`
237/// until that runs; an app with no advertising plugins publishes an
238/// empty vec.
239static API_ENDPOINTS: OnceLock<Vec<crate::plugin::ApiEndpoint>> = OnceLock::new();
240
241/// Publish the collected `Plugin::api_endpoints()`. Called once by
242/// `App::build()` after walking every registered plugin.
243pub(crate) fn init_api_endpoints(endpoints: Vec<crate::plugin::ApiEndpoint>) {
244    let _ = API_ENDPOINTS.set(endpoints);
245}
246
247/// Every callable endpoint registered plugins advertised for service
248/// discovery, in plugin-registration order. Empty until `App::build()`
249/// has run. A REST API root (or any discovery surface) reads this to
250/// list plugin endpoints without depending on those plugins' crates.
251pub fn registered_api_endpoints() -> Vec<crate::plugin::ApiEndpoint> {
252    API_ENDPOINTS.get().cloned().unwrap_or_default()
253}
254
255/// Publish the per-model alias routing. Called by `App::build()`
256/// during phase 3 after walking every plugin's `Plugin::database()`.
257/// Plugins that returned `None` contribute no entries; only the
258/// explicit overrides land here.
259pub(crate) fn init_model_aliases(map: std::collections::HashMap<String, String>) {
260    MODEL_ALIASES
261        .set(map)
262        .expect("umbral::migrate::init_model_aliases called more than once");
263}
264
265/// Look up the database alias for a SQL table name — the reverse of
266/// the `Model::NAME → alias` lookup that [`model_alias`] does. Walks
267/// the registered model metas to find the one whose `table` matches
268/// (snake_case of the struct name + any `#[umbral(table = "...")]`
269/// override) and returns its alias if set. Falls back to `"default"`
270/// when no model owns the table (e.g. orphan schema, the
271/// `umbral_migrations` table itself) — those land on the main pool.
272///
273/// Used by the migration engine's per-DB dispatch in [`run_in`] to
274/// route each operation to the right pool.
275pub fn table_alias(table_name: &str) -> String {
276    for meta in registered_models() {
277        if meta.table == table_name {
278            return model_alias(&meta.name).unwrap_or_else(|| "default".to_string());
279        }
280    }
281    "default".to_string()
282}
283
284/// Look up the database alias for one model. Returns `None` if the
285/// model isn't routed explicitly (the caller falls back to the
286/// `"default"` pool); returns `None` even when the alias map hasn't
287/// been initialised so low-level tests that drive `init_plugins`
288/// directly don't have to wire a second call.
289pub fn model_alias(model_name: &str) -> Option<String> {
290    MODEL_ALIASES.get()?.get(model_name).cloned()
291}
292
293static MODEL_META_BY_NAME: OnceLock<std::collections::HashMap<String, ModelMeta>> = OnceLock::new();
294
295/// Cached `&ModelMeta` lookup by model name. Returns `None` before
296/// `App::build` populates the registry (low-level tests), which the routing
297/// seam treats as "fall back to legacy static routing".
298pub fn model_meta_ref(name: &str) -> Option<&'static ModelMeta> {
299    if !is_initialised() {
300        return None;
301    }
302    MODEL_META_BY_NAME
303        .get_or_init(|| {
304            registered_models()
305                .into_iter()
306                .map(|m| (m.name.clone(), m))
307                .collect()
308        })
309        .get(name)
310}
311
312/// Return the models registered against a specific plugin. Empty if
313/// no plugin by that name registered models.
314pub fn models_for_plugin(plugin: &str) -> Vec<ModelMeta> {
315    REGISTRY
316        .get()
317        .expect("umbral: model registry not initialised — did you call App::build()?")
318        .iter()
319        .filter(|(p, _)| p == plugin)
320        .map(|(_, m)| m.clone())
321        .collect()
322}
323
324/// Static metadata for one registered model, copied off the `Model`
325/// trait's `const`s when the user calls `App::builder().model::<T>()`.
326///
327/// Owned (no lifetimes) so the registry can hold an arbitrary number
328/// without the lifetime contortions a slice of trait references would
329/// need. The cost is one Vec at `App::build` time; the win is
330/// `registered_models()` having a plain `&'static [ModelMeta]` signature.
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332pub struct ModelMeta {
333    /// The struct name (`Model::NAME`). Identifies the model across
334    /// snapshot diffs even if the table is renamed.
335    pub name: String,
336    /// The SQL table name (`Model::TABLE`).
337    pub table: String,
338    /// One owned column descriptor per field, in declaration order.
339    /// Owned (`Column`, not the underlying static `FieldSpec`) so the
340    /// snapshot round-trips cleanly through serde.
341    pub fields: Vec<Column>,
342    /// Human-readable display name from `Model::DISPLAY`. Defaults to
343    /// `Model::NAME` when no `#[umbral(display = "...")]` is present.
344    #[serde(default)]
345    pub display: String,
346    /// Lucide icon slug from `Model::ICON`. Defaults to `"database"`.
347    #[serde(default = "default_icon")]
348    pub icon: String,
349    /// Database alias from `Model::DATABASE`, when set. `None` means
350    /// "fall back to the owning plugin's `Plugin::database()`, then
351    /// the `default` pool." Captured here so `App::build`'s alias
352    /// routing can read it without re-reaching into the trait at a
353    /// later phase.
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub database: Option<String>,
356    /// Mirrors `Model::SINGLETON`. Closes BUG-9 in
357    /// `bugs/tests/testBugs.md`. Default `false`; admin renderers
358    /// read it to auto-redirect list-view to the edit form.
359    #[serde(default, skip_serializing_if = "is_false")]
360    pub singleton: bool,
361    /// Mirrors `Model::UNIQUE_TOGETHER`. Composite-UNIQUE constraints,
362    /// each inner `Vec<String>` listing the columns of one constraint.
363    /// Closes BUG-6.
364    #[serde(default, skip_serializing_if = "Vec::is_empty")]
365    pub unique_together: Vec<Vec<String>>,
366    /// Mirrors `Model::INDEXES`. Each inner `Vec<String>` lists the
367    /// columns of one multi-column index. Closes BUG-7.
368    #[serde(default, skip_serializing_if = "Vec::is_empty")]
369    pub indexes: Vec<Vec<String>>,
370    /// Mirrors `Model::ORDERING`. Each tuple is `(column, descending)`
371    /// — `descending == true` lowers to `ORDER BY col DESC`. Closes
372    /// BUG-8.
373    #[serde(default, skip_serializing_if = "Vec::is_empty")]
374    pub ordering: Vec<(String, bool)>,
375    /// Mirrors `Model::M2M_RELATIONS`. Many-to-many relations declared
376    /// on this model. The migration engine uses this to auto-generate
377    /// junction tables. Closes BUG-16.
378    #[serde(default, skip_serializing_if = "Vec::is_empty")]
379    pub m2m_relations: Vec<M2MRelation>,
380    /// Mirrors `Model::SOFT_DELETE` (`#[umbral(soft_delete)]`). The
381    /// dynamic / annotate paths read this to auto-exclude
382    /// `deleted_at IS NULL` children from correlated counts and to
383    /// drive trash-aware admin views without re-reaching into the
384    /// typed trait. Shared enabler for gaps2 #35 + #39a.
385    #[serde(default, skip_serializing_if = "is_false")]
386    pub soft_delete: bool,
387    /// The app label (the owning plugin's name), mirrors `Model::APP_LABEL`.
388    /// Sourced from `#[umbral(plugin = "...")]`; `"app"` when absent.
389    /// Authoritative for permission codenames (gaps2 #80g): replaces the
390    /// old table-name-split heuristic that collided distinct models. The
391    /// `#[serde(default)]` keeps pre-#80g snapshot JSON round-tripping.
392    #[serde(default = "default_app_label")]
393    pub app_label: String,
394}
395
396fn default_app_label() -> String {
397    "app".to_string()
398}
399
400impl Default for ModelMeta {
401    fn default() -> Self {
402        Self {
403            name: String::new(),
404            table: String::new(),
405            fields: Vec::new(),
406            display: String::new(),
407            icon: default_icon(),
408            database: None,
409            singleton: false,
410            unique_together: Vec::new(),
411            indexes: Vec::new(),
412            ordering: Vec::new(),
413            m2m_relations: Vec::new(),
414            soft_delete: false,
415            app_label: default_app_label(),
416        }
417    }
418}
419
420/// Owned mirror of `orm::M2MRelationSpec` so `ModelMeta` can be
421/// serialised into migration JSON without lifetimes.
422#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
423pub struct M2MRelation {
424    pub field_name: String,
425    pub target_table: String,
426    pub target_name: String,
427}
428
429fn default_icon() -> String {
430    "database".to_string()
431}
432
433/// Serde default for [`Operation::CreateM2MTable`]'s `parent_ty` /
434/// `child_ty` fields. Older snapshot files (pre-phase-2) had no
435/// per-side PK type and assumed `BigInt` on both ends — this keeps
436/// them round-tripping without rewrites.
437fn default_bigint() -> crate::orm::SqlType {
438    crate::orm::SqlType::BigInt
439}
440
441impl ModelMeta {
442    /// The primary-key column on this model. Every umbral model
443    /// has exactly one PK by construction (the derive enforces
444    /// it), but the lookup is `Option`-shaped because nothing
445    /// stops a hand-written `ModelMeta` (test fixtures, etc.)
446    /// from omitting it.
447    pub fn pk_column(&self) -> Option<&Column> {
448        self.fields.iter().find(|c| c.primary_key)
449    }
450
451    /// Read static metadata off `T: Model` into an owned `ModelMeta`.
452    /// Called from `AppBuilder::model::<T>()`.
453    pub fn for_<T: Model>() -> Self {
454        Self {
455            name: T::NAME.to_string(),
456            table: T::TABLE.to_string(),
457            fields: T::FIELDS.iter().map(Column::from).collect(),
458            display: T::DISPLAY.to_string(),
459            icon: T::ICON.to_string(),
460            database: T::DATABASE.map(|s| s.to_string()),
461            singleton: T::SINGLETON,
462            unique_together: T::UNIQUE_TOGETHER
463                .iter()
464                .map(|group| group.iter().map(|s| s.to_string()).collect())
465                .collect(),
466            indexes: T::INDEXES
467                .iter()
468                .map(|group| group.iter().map(|s| s.to_string()).collect())
469                .collect(),
470            ordering: T::ORDERING
471                .iter()
472                .map(|(col, desc)| (col.to_string(), *desc))
473                .collect(),
474            m2m_relations: T::M2M_RELATIONS
475                .iter()
476                .map(|r| M2MRelation {
477                    field_name: r.field_name.to_string(),
478                    target_table: r.target_table.to_string(),
479                    target_name: r.target_name.to_string(),
480                })
481                .collect(),
482            soft_delete: T::SOFT_DELETE,
483            app_label: T::APP_LABEL.to_string(),
484        }
485    }
486}
487
488/// A snapshot of every registered model at a point in time.
489///
490/// Serialised into the `snapshot_after` field of a migration file so
491/// future `makemigrations` runs can diff against it without replaying
492/// every prior migration's operations.
493#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
494pub struct Snapshot {
495    /// Models sorted by name so the JSON is deterministic and the
496    /// snapshot_hash is stable across runs that produce equivalent
497    /// content.
498    pub models: Vec<ModelMeta>,
499}
500
501impl Snapshot {
502    /// Build a snapshot from the live registry (the current state of
503    /// the application's models, post-`App::build`).
504    pub fn current() -> Self {
505        let mut models = registered_models().to_vec();
506        models.sort_by(|a, b| a.name.cmp(&b.name));
507        Self { models }
508    }
509
510    /// Build a snapshot containing only the models registered
511    /// against the given plugin. Used by `make_in` to diff each
512    /// plugin's migrations independently against its own prior
513    /// snapshot, so cross-plugin model sets don't bleed into one
514    /// migration file.
515    pub fn current_for(plugin: &str) -> Self {
516        let mut models = models_for_plugin(plugin);
517        models.sort_by(|a, b| a.name.cmp(&b.name));
518        Self { models }
519    }
520
521    /// Compute the snapshot's SHA-256 hash, hex-encoded. Stored in the
522    /// `umbral_migrations.snapshot_hash` column for drift detection.
523    pub fn hash(&self) -> String {
524        use sha2::{Digest, Sha256};
525        let json = serde_json::to_string(self).expect("Snapshot serializes");
526        let digest = Sha256::digest(json.as_bytes());
527        hex(&digest[..])
528    }
529}
530
531fn hex(bytes: &[u8]) -> String {
532    const HEX: &[u8; 16] = b"0123456789abcdef";
533    let mut s = String::with_capacity(bytes.len() * 2);
534    for b in bytes {
535        s.push(HEX[(b >> 4) as usize] as char);
536        s.push(HEX[(b & 0x0f) as usize] as char);
537    }
538    s
539}
540
541/// One operation inside a migration. The migration engine renders each
542/// operation to SQL via the active backend (M4 `DatabaseBackend::
543/// map_type`) and runs them in declaration order inside one
544/// transaction per migration file.
545///
546/// M5 v1 shipped table-level ops; M8 v1 adds `AddColumn` and
547/// `DropColumn`. `AlterColumn`, index / constraint ops, and
548/// `RunSql` / `RunCode` are deferred (see `docs/specs/06-migration-
549/// engine.md`).
550#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
551#[serde(tag = "kind")]
552pub enum Operation {
553    /// Create a new table. `columns` is in declaration order; the
554    /// engine builds a sea-query `Table::create()` over them and runs
555    /// the rendered DDL. `unique_together` lowers to inline
556    /// `UNIQUE (col1, col2)` clauses; `indexes` lowers to follow-up
557    /// `CREATE INDEX` statements after the table is created. Both
558    /// default to empty for backward-compat with older snapshots.
559    CreateTable {
560        table: String,
561        columns: Vec<Column>,
562        #[serde(default, skip_serializing_if = "Vec::is_empty")]
563        unique_together: Vec<Vec<String>>,
564        #[serde(default, skip_serializing_if = "Vec::is_empty")]
565        indexes: Vec<Vec<String>>,
566    },
567    /// Drop an existing table.
568    DropTable { table: String },
569    /// Add a new column to an existing table. Rendered as
570    /// `ALTER TABLE x ADD COLUMN y TYPE [NOT NULL]`. SQLite refuses a
571    /// non-nullable add against a populated table without a default;
572    /// the engine surfaces that as a sqlx error at apply time (M8 v1).
573    /// A future op `AddColumnWithDefault` lifts the restriction once
574    /// the `#[umbral(default = ...)]` attribute lands.
575    AddColumn { table: String, column: Column },
576    /// Drop a column from an existing table. Rendered as
577    /// `ALTER TABLE x DROP COLUMN y`. SQLite 3.35+ and Postgres
578    /// support this natively; older SQLite would need a table-
579    /// recreation dance the engine doesn't implement.
580    DropColumn { table: String, column: String },
581    /// Alter a column's nullable flag (the only safe in-place change
582    /// the engine ships at M5.1). Self-contained: carries the full
583    /// new column list so the SQLite table-recreation dance can
584    /// rebuild the schema without re-reading the snapshot. The
585    /// `column` field names the specific column that triggered the
586    /// alter (used for the filename suffix and diagnostics); the
587    /// `new_columns` list is the post-change schema.
588    AlterColumn {
589        table: String,
590        column: String,
591        new_columns: Vec<Column>,
592        /// Snapshot of the table's columns *before* this alter. Carried
593        /// so the Postgres renderer can decide per-column whether it
594        /// needs a TYPE/USING clause vs a SET/DROP NOT NULL — without
595        /// re-walking the snapshot file. `Option` + `serde(default)`
596        /// keeps older on-disk migrations deserialising cleanly; ops
597        /// produced before this field existed get `None` and fall back
598        /// to the legacy nullable-only Postgres path.
599        #[serde(default, skip_serializing_if = "Option::is_none")]
600        prev_columns: Option<Vec<Column>>,
601    },
602    /// Rename an existing table. Emitted by `diff` when a model's table
603    /// name changes but its `Model::NAME` (the Rust struct name) stays
604    /// the same (first-pass detection), or when the column shapes are
605    /// bit-identical and the struct name changed too (second-pass
606    /// heuristic detection). Both SQLite and Postgres render as
607    /// `ALTER TABLE "<from>" RENAME TO "<to>"`.
608    ///
609    /// The migration tracking table records `(plugin, name)` of each
610    /// applied migration — it is not affected by a table rename inside
611    /// the migration.
612    RenameTable { from: String, to: String },
613    /// Create a many-to-many junction table. Auto-emitted when a model
614    /// gains an `M2M<T>` field. Closes BUG-16 phase 2.
615    ///
616    /// The junction table name is `parent_table_field_name`. Columns:
617    /// `parent_id` (FK to parent), `child_id` (FK to target), both with
618    /// `ON DELETE CASCADE`. Composite PK `(parent_id, child_id)`.
619    ///
620    /// `parent_ty` and `child_ty` carry the SQL types of the
621    /// referenced PK columns — `BigInt` for an `i64` PK, `Text` for a
622    /// `String` slug, `Uuid` for a `uuid::Uuid`. The renderer maps
623    /// these to the right column type per backend; without them the
624    /// junction's `child_id INTEGER` would reject a string codename
625    /// at insert time. `#[serde(default)]` keeps older snapshot files
626    /// (pre-phase-2) round-tripping — they default to `BigInt`,
627    /// matching the original i64-only behaviour.
628    CreateM2MTable {
629        junction_table: String,
630        parent_table: String,
631        parent_col: String,
632        child_table: String,
633        child_col: String,
634        #[serde(default = "default_bigint")]
635        parent_ty: crate::orm::SqlType,
636        #[serde(default = "default_bigint")]
637        child_ty: crate::orm::SqlType,
638    },
639    /// Drop a many-to-many junction table. Auto-emitted when an `M2M<T>`
640    /// field is removed from a model.
641    DropM2MTable { junction_table: String },
642    /// Gap 88: rename a column on an existing table. Emitted by the
643    /// diff engine when a single column with one shape was dropped
644    /// and one with the same shape was added in the same diff —
645    /// the heuristic match for "the user renamed `title` to
646    /// `headline`." Both SQLite (3.25+) and Postgres render as
647    /// `ALTER TABLE "<t>" RENAME COLUMN "<from>" TO "<to>"`.
648    ///
649    /// `column` carries the post-rename column shape so the
650    /// snapshot stays in sync. The migration only renames; never
651    /// alters other column attributes — a rename combined with a
652    /// type change emits a RenameColumn AND a follow-on AlterColumn
653    /// against the new name.
654    RenameColumn {
655        table: String,
656        from: String,
657        to: String,
658        #[serde(default, skip_serializing_if = "Option::is_none")]
659        column: Option<Column>,
660    },
661    /// Gap #69: a raw-SQL **data** migration. Unlike every other
662    /// variant it changes *rows*, not the schema model — so the
663    /// autodetector NEVER emits it (it has no model-state effect), and
664    /// a migration carrying only `RunSql` ops has
665    /// `snapshot_after == snapshot_before`. It is always hand-authored:
666    /// generate an empty migration with `makemigrations --empty
667    /// <plugin>`, then add the `RunSql` op by editing the file.
668    ///
669    /// `sql` is the forward statement(s), executed verbatim on the
670    /// per-migration transaction — same string on both backends (raw
671    /// SQL the renderer passes through untouched), so the author owns
672    /// portability. `reverse_sql` is the optional un-apply statement
673    /// (used by a future `migrate --reverse`); `None` means
674    /// irreversible.
675    ///
676    /// Under schema-per-tenant the op runs **per tenant schema** (the
677    /// schema-migrate loop applies every op under the
678    /// `<schema>, public` search_path), so a tenant-app `RunSql` writes
679    /// tenant rows while reading shared `public` lookup tables — the
680    /// boundary-spanning data migration. A shared-app `RunSql` runs once
681    /// in `public` via the normal `migrate`.
682    RunSql {
683        sql: String,
684        #[serde(default, skip_serializing_if = "Option::is_none")]
685        reverse_sql: Option<String>,
686    },
687}
688
689impl Operation {
690    /// The primary table this operation targets. For `RenameTable`,
691    /// returns the source name (the post-rename `to` lives in the new
692    /// snapshot, but routing decisions look up the model meta by its
693    /// pre-rename `from`).
694    ///
695    /// Used by `run_in`'s per-DB dispatch loop to route each op to the
696    /// pool where its table actually lives.
697    pub fn table_name(&self) -> &str {
698        match self {
699            Operation::CreateTable { table, .. }
700            | Operation::DropTable { table }
701            | Operation::AddColumn { table, .. }
702            | Operation::DropColumn { table, .. }
703            | Operation::AlterColumn { table, .. }
704            | Operation::RenameColumn { table, .. } => table,
705            Operation::RenameTable { from, .. } => from,
706            Operation::CreateM2MTable { junction_table, .. }
707            | Operation::DropM2MTable { junction_table } => junction_table,
708            // A data migration targets no single table. The empty name
709            // routes it to the `"default"` alias via `table_alias`'s
710            // fallback (see `op_targets_alias`).
711            Operation::RunSql { .. } => "",
712        }
713    }
714}
715
716/// One column inside a [`Operation::CreateTable`].
717///
718/// Mirrors the structure of [`FieldSpec`] but is fully owned for
719/// serialisation. Reconstructed from a `FieldSpec` at diff time.
720#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
721pub struct Column {
722    pub name: String,
723    pub ty: SqlType,
724    pub primary_key: bool,
725    pub nullable: bool,
726    /// For `SqlType::ForeignKey` columns: the SQL table name of the
727    /// referenced model. `None` for all non-FK columns.
728    #[serde(default, skip_serializing_if = "Option::is_none")]
729    pub fk_target: Option<String>,
730    /// When `true`, this field is never shown on any admin form (create or
731    /// edit). Propagated from `FieldSpec::noform`.
732    #[serde(default)]
733    pub noform: bool,
734    /// For FK columns: whether to emit a physical `FOREIGN KEY ...
735    /// REFERENCES` constraint. Propagated from `FieldSpec::db_constraint`.
736    /// `false` (set via `#[umbral(db_constraint = false)]`) keeps the
737    /// logical FK (column + `fk_target`) but renders no `REFERENCES`
738    /// clause — the only valid shape for a cross-database FK. Closes
739    /// gaps2 #22. Defaults to `true` so existing migration JSON
740    /// round-trips unchanged (omitted from JSON when at its default).
741    #[serde(default = "default_true", skip_serializing_if = "is_true")]
742    pub db_constraint: bool,
743    /// When `true`, this field appears on the edit form as read-only.
744    /// Propagated from `FieldSpec::noedit`.
745    #[serde(default)]
746    pub noedit: bool,
747    /// Display-string marker — propagated from
748    /// `FieldSpec::is_string_repr`. The admin uses the first column
749    /// with this flag as the default `list_display` label when no
750    /// explicit one is configured.
751    #[serde(default)]
752    pub is_string_repr: bool,
753    /// Display truncation cap — propagated from `FieldSpec::max_length`.
754    /// `0` means no truncation.
755    #[serde(default)]
756    pub max_length: u32,
757    /// Closed-set DB values for a choices column. Propagated from
758    /// `FieldSpec::choices`. Non-empty when the model field carries
759    /// `#[umbral(choices)]`; the migration engine emits a Postgres
760    /// `CHECK (col IN (...))` constraint when this slice is non-empty.
761    /// Empty for every non-choices column.
762    #[serde(default, skip_serializing_if = "Vec::is_empty")]
763    pub choices: Vec<String>,
764    /// Human labels matching `choices` position-for-position. Carried
765    /// alongside `choices` so the admin's `<select>` widget has labels
766    /// without the runtime needing to reflect on the model type.
767    #[serde(default, skip_serializing_if = "Vec::is_empty")]
768    pub choice_labels: Vec<String>,
769    /// SQL `DEFAULT` value — propagated from `FieldSpec::default`.
770    /// Empty string means no default. The migration engine reads this
771    /// at DDL-emit time for both `CREATE TABLE` and `ALTER TABLE ADD
772    /// COLUMN`. Set via `#[umbral(default = "...")]` on the model field.
773    #[serde(default, skip_serializing_if = "String::is_empty")]
774    pub default: String,
775    /// Distinguishes a multi-valued [`MultiChoice<E>`] column from a
776    /// single-valued choices column. Both share `ty: Text` plus the same
777    /// `choices` / `choice_labels` metadata; this flag is the only
778    /// signal that the value is a CSV. Empty / false for every other
779    /// column.
780    ///
781    /// [`MultiChoice<E>`]: crate::orm::MultiChoice
782    #[serde(default, skip_serializing_if = "is_false")]
783    pub is_multichoice: bool,
784
785    /// Carries `FieldSpec::unique` into the migration snapshot. The
786    /// DDL builders emit a `UNIQUE` clause on this column at
787    /// `CREATE TABLE` time when set. Default `false` keeps existing
788    /// migration JSON files round-tripping unchanged (the field is
789    /// omitted on serialise when default).
790    #[serde(default, skip_serializing_if = "is_false")]
791    pub unique: bool,
792
793    /// Carries `FieldSpec::on_delete` into the migration snapshot.
794    /// FK columns only — the DDL builders emit
795    /// `ON DELETE <action>` when this is anything other than
796    /// `NoAction`. Default `NoAction` is omitted from JSON so
797    /// existing migration files round-trip without churn.
798    #[serde(default, skip_serializing_if = "is_no_action")]
799    pub on_delete: crate::orm::FkAction,
800
801    /// Carries `FieldSpec::on_update` into the migration snapshot.
802    /// Same shape as `on_delete`; emits `ON UPDATE <action>`.
803    #[serde(default, skip_serializing_if = "is_no_action")]
804    pub on_update: crate::orm::FkAction,
805
806    /// Carries `FieldSpec::index` into the migration snapshot. The
807    /// CreateTable + AddColumn render paths emit a matching
808    /// `CREATE INDEX idx_<table>_<col>` for every column whose
809    /// flag is set. Default `false` keeps existing migration JSON
810    /// round-tripping unchanged.
811    #[serde(default, skip_serializing_if = "is_false")]
812    pub index: bool,
813
814    /// Carries `FieldSpec::auto_now_add` into the migration
815    /// snapshot. The dynamic write path (`DynQuerySet::insert_json`)
816    /// auto-populates the column with `Utc::now()` when the body
817    /// omits it. Default `false` so existing migration JSON
818    /// round-trips unchanged.
819    #[serde(default, skip_serializing_if = "is_false")]
820    pub auto_now_add: bool,
821
822    /// Carries `FieldSpec::auto_now` into the migration snapshot.
823    /// Same shape as `auto_now_add` but fires on update too.
824    #[serde(default, skip_serializing_if = "is_false")]
825    pub auto_now: bool,
826
827    /// Carries `FieldSpec::help` into the migration snapshot.
828    /// Default empty string is omitted from JSON so existing
829    /// migration files round-trip unchanged.
830    #[serde(default, skip_serializing_if = "String::is_empty")]
831    pub help: String,
832
833    /// Carries `FieldSpec::example` into the migration snapshot.
834    /// Same shape as `help`.
835    #[serde(default, skip_serializing_if = "String::is_empty")]
836    pub example: String,
837
838    /// Carries `FieldSpec::widget` into the migration snapshot — the
839    /// form-renderer presentation hint (features.md #4). Presentation
840    /// only, no DB effect, so it's excluded from the schema diff the
841    /// same way `help` / `example` are. `None` is omitted from JSON so
842    /// existing migration files round-trip unchanged.
843    #[serde(default, skip_serializing_if = "Option::is_none")]
844    pub widget: Option<String>,
845
846    /// Carries `FieldSpec::supported_backends` into the migration
847    /// snapshot. When non-empty, the boot system check rejects the
848    /// model on any backend not listed. Closes IMP-5 from
849    /// `bugs/tests/testBugs.md`. Default empty (works on every
850    /// backend); JSON skip-when-empty so existing migration files
851    /// don't churn.
852    #[serde(default, skip_serializing_if = "Vec::is_empty")]
853    pub supported_backends: Vec<String>,
854
855    /// IMP-3: numeric lower bound. `None` means "no minimum"; the
856    /// DDL emits a `CHECK (col >= N)` constraint when set.
857    #[serde(default, skip_serializing_if = "Option::is_none")]
858    pub min: Option<i64>,
859
860    /// IMP-3: numeric upper bound. Same shape as `min`.
861    #[serde(default, skip_serializing_if = "Option::is_none")]
862    pub max: Option<i64>,
863
864    /// BUG-11/12/13: constrained-text marker. `None` is plain text;
865    /// `Some("slug" | "email" | "url")` flags the column as a
866    /// `Slug` / `Email` / `Url` wrapper. OpenAPI emits the
867    /// corresponding `format` / `pattern`; the REST plugin
868    /// pre-validates the body via `validate_text_format`.
869    #[serde(default, skip_serializing_if = "Option::is_none")]
870    pub text_format: Option<String>,
871
872    /// Gap 109: auto-derive source. When `Some("title")`, the slug is
873    /// computed from the row's `title` column at write time if the
874    /// slug column itself is empty / missing on the body. Pure
875    /// runtime behaviour — has no DDL effect, so the diff engine
876    /// ignores changes to this field. `#[serde(default)]` keeps
877    /// older snapshots round-tripping.
878    #[serde(default, skip_serializing_if = "Option::is_none")]
879    pub slug_from: Option<String>,
880}
881
882fn is_no_action(a: &crate::orm::FkAction) -> bool {
883    matches!(a, crate::orm::FkAction::NoAction)
884}
885
886/// Build a portable `CREATE INDEX IF NOT EXISTS idx_<table>_<col>
887/// ON "<table>" ("<col>")` statement. Same DDL on SQLite and
888/// Postgres — both accept `CREATE INDEX IF NOT EXISTS` and the
889/// `idx_<table>_<col>` name convention is unique enough that the
890/// migration engine can re-emit it idempotently on subsequent
891/// applies. Used by [`render_operation_sqlite`] / `_postgres`
892/// after a `CreateTable` or `AddColumn` op whose column carries
893/// the `#[umbral(index)]` flag. Closes BUG-4.
894fn create_index_stmt(table: &str, column: &str) -> String {
895    let t = table.replace('"', "\"\"");
896    let c = column.replace('"', "\"\"");
897    format!(
898        "CREATE INDEX IF NOT EXISTS \"idx_{table}_{column}\" ON \"{t}\" (\"{c}\")",
899        table = table.replace('"', ""),
900        column = column.replace('"', ""),
901    )
902}
903
904/// Build a Postgres `CREATE INDEX ... USING GIN` for a `tsvector`
905/// (`SqlType::FullText`) column (#33). A tsvector column is useless for
906/// search without a GIN index, so the migration engine emits one
907/// automatically for every full-text column — the caller never has to
908/// hand-write it. **Postgres-only**: GIN is Postgres syntax and FullText
909/// columns are system-check-gated to Postgres, so this only ever renders
910/// from `render_operation_postgres`. The `_gin` name suffix keeps it
911/// distinct from any plain index on the same column.
912fn create_gin_index_stmt(table: &str, column: &str) -> String {
913    let t = table.replace('"', "\"\"");
914    let c = column.replace('"', "\"\"");
915    format!(
916        "CREATE INDEX IF NOT EXISTS \"idx_{table}_{column}_gin\" ON \"{t}\" USING GIN (\"{c}\")",
917        table = table.replace('"', ""),
918        column = column.replace('"', ""),
919    )
920}
921
922/// Multi-column variant of [`create_index_stmt`]. Closes BUG-7.
923/// Renders `CREATE INDEX IF NOT EXISTS idx_<table>_<col1>_<col2>
924/// ON "<table>" ("<col1>", "<col2>")`. Both backends accept the
925/// same form. Empty groups render no statement (defensive — the
926/// macro layer rejects them before the engine sees them, but the
927/// helper still returns a no-op SQL string to keep the caller
928/// simple).
929fn create_multi_index_stmt(table: &str, columns: &[String]) -> String {
930    if columns.is_empty() {
931        return String::new();
932    }
933    let t = table.replace('"', "");
934    let name_suffix = columns
935        .iter()
936        .map(|c| c.replace('"', ""))
937        .collect::<Vec<_>>()
938        .join("_");
939    let col_list = columns
940        .iter()
941        .map(|c| format!("\"{}\"", c.replace('"', "\"\"")))
942        .collect::<Vec<_>>()
943        .join(", ");
944    format!(
945        "CREATE INDEX IF NOT EXISTS \"idx_{t}_{name_suffix}\" ON \"{t}\" ({col_list})",
946        t = t.replace('"', "\"\""),
947    )
948}
949
950/// Lower an M2M junction column's PK type into the SQLite column
951/// declaration string used inside the raw `CREATE TABLE` template.
952/// SQLite has affinity types: every integer width stores as `INTEGER`
953/// (one ROWID-aliased column), and TEXT covers `String` / `Uuid`.
954/// Closes BUG-16 phase 2.
955fn m2m_pk_sql_type_sqlite(ty: crate::orm::SqlType) -> &'static str {
956    use crate::orm::SqlType;
957    match ty {
958        SqlType::SmallInt | SqlType::Integer | SqlType::BigInt | SqlType::ForeignKey => "INTEGER",
959        SqlType::Text | SqlType::Uuid => "TEXT",
960        // The macro-side classifier only sets these for PK columns
961        // when the user wrote a non-standard PK type. If we ever
962        // see one here that doesn't make sense as a junction column
963        // (Boolean, Date, Real, …), TEXT is the safest catch-all
964        // affinity — SQLite will accept it and the rest of the
965        // ORM will surface the deeper "this can't be a PK" error
966        // through the system check.
967        _ => "TEXT",
968    }
969}
970
971/// Lower an M2M junction column's PK type into the Postgres column
972/// declaration string. Postgres is strict about types — `BIGINT` for
973/// 64-bit integers, `INTEGER` for 32-bit, `SMALLINT` for 16-bit,
974/// `TEXT` for `String`, `UUID` for `uuid::Uuid`. Mirrors the choices
975/// `build_column_def_postgres` makes for the same `SqlType` variants.
976fn m2m_pk_sql_type_postgres(ty: crate::orm::SqlType) -> &'static str {
977    use crate::orm::SqlType;
978    match ty {
979        SqlType::SmallInt => "SMALLINT",
980        SqlType::Integer => "INTEGER",
981        SqlType::BigInt | SqlType::ForeignKey => "BIGINT",
982        SqlType::Text => "TEXT",
983        SqlType::Uuid => "UUID",
984        _ => "TEXT",
985    }
986}
987
988/// Build the ` ON DELETE <action> ON UPDATE <action>` suffix for a
989/// FK column. Each half is emitted only when its action is anything
990/// other than `NoAction` — keeps the generated DDL minimal and
991/// matches the SQL standard's default (NO ACTION when the clause is
992/// omitted).
993///
994/// Closes gap #68. Shared between the SQLite and Postgres builders
995/// because the REFERENCES tail syntax is identical on both.
996fn fk_action_suffix(col: &Column) -> String {
997    let mut s = String::new();
998    if let Some(kw) = col.on_delete.sql_keyword() {
999        s.push_str(" ON DELETE ");
1000        s.push_str(kw);
1001    }
1002    if let Some(kw) = col.on_update.sql_keyword() {
1003        s.push_str(" ON UPDATE ");
1004        s.push_str(kw);
1005    }
1006    s
1007}
1008
1009fn is_false(b: &bool) -> bool {
1010    !*b
1011}
1012
1013/// serde default for `Column::db_constraint`: a FK emits its physical
1014/// `REFERENCES` constraint unless the model opts out. Older migration
1015/// JSON predating gaps2 #22 has no `db_constraint` key, so it must
1016/// deserialize as `true` to preserve the historical "always emit"
1017/// behaviour.
1018fn default_true() -> bool {
1019    true
1020}
1021
1022fn is_true(b: &bool) -> bool {
1023    *b
1024}
1025
1026impl From<&FieldSpec> for Column {
1027    fn from(f: &FieldSpec) -> Self {
1028        Self {
1029            name: f.name.to_string(),
1030            ty: f.ty,
1031            primary_key: f.primary_key,
1032            nullable: f.nullable,
1033            fk_target: f.fk_target.map(|s| s.to_string()),
1034            noform: f.noform,
1035            db_constraint: f.db_constraint,
1036            noedit: f.noedit,
1037            is_string_repr: f.is_string_repr,
1038            max_length: f.max_length,
1039            choices: f.choices.iter().map(|s| s.to_string()).collect(),
1040            choice_labels: f.choice_labels.iter().map(|s| s.to_string()).collect(),
1041            default: f.default.to_string(),
1042            is_multichoice: f.is_multichoice,
1043            unique: f.unique,
1044            on_delete: f.on_delete,
1045            on_update: f.on_update,
1046            index: f.index,
1047            auto_now_add: f.auto_now_add,
1048            auto_now: f.auto_now,
1049            help: f.help.to_string(),
1050            example: f.example.to_string(),
1051            widget: f.widget.map(|s| s.to_string()),
1052            supported_backends: f.supported_backends.iter().map(|s| s.to_string()).collect(),
1053            min: f.min,
1054            max: f.max,
1055            text_format: f.text_format.map(|s| s.to_string()),
1056            slug_from: f.slug_from.map(|s| s.to_string()),
1057        }
1058    }
1059}
1060
1061/// The on-disk shape of one migration. Files in `migrations/<plugin>/`
1062/// deserialize into this struct.
1063#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1064pub struct MigrationFile {
1065    /// Stable id, matches the filename minus `.json`.
1066    pub id: String,
1067    /// The plugin that owns this migration. M5 hardcodes `"app"` for
1068    /// the user's binary; M7 generalises to one directory per plugin.
1069    pub plugin: String,
1070    /// Predecessor migrations, in `(plugin, id)` form. Within-plugin
1071    /// predecessors are implicit (the prior numeric file); cross-
1072    /// plugin predecessors land at M7.
1073    #[serde(default)]
1074    pub depends_on: Vec<MigrationRef>,
1075    /// Ordered operations applied when this migration runs.
1076    pub operations: Vec<Operation>,
1077    /// The full snapshot of every model after this migration has run.
1078    /// Source of truth for the next `make` to diff against.
1079    pub snapshot_after: Snapshot,
1080}
1081
1082/// A pointer to one (plugin, migration_id) pair.
1083#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1084pub struct MigrationRef {
1085    pub plugin: String,
1086    pub migration: String,
1087}
1088
1089/// At M5 every migration belongs to a single placeholder plugin. M7's
1090/// Plugin contract replaces this with `Plugin::name()`.
1091pub const APP_PLUGIN_NAME: &str = "app";
1092
1093/// Default directory for migration files. `make` writes into
1094/// `migrations/<plugin>/`; `run` reads from the same place. Override
1095/// with `--migrations-dir` once the CLI grows real arg parsing (M5+).
1096pub const MIGRATIONS_DIR: &str = "migrations";
1097
1098/// The state of a single migration from the perspective of drift detection.
1099/// Returned inside [`DriftReport`] so callers can decide how to handle each
1100/// state independently.
1101#[derive(Debug, Clone, PartialEq, Eq)]
1102pub enum MigrationStatus {
1103    /// The migration is recorded in the tracking table AND the file
1104    /// exists on disk. Normal applied state.
1105    Applied,
1106    /// The migration is recorded in the tracking table BUT the
1107    /// corresponding file is missing from disk. The database is ahead
1108    /// of what version control has; recovering requires restoring the
1109    /// file or running with `--allow-drift`.
1110    AppliedButMissing,
1111    /// The migration file exists on disk AND its sequence number is
1112    /// lower than the highest applied migration for this plugin, but it
1113    /// is not recorded in the tracking table. Looks like someone dropped
1114    /// a migration file back into a directory after a teammate already
1115    /// applied later ones. Should warn, not error.
1116    OutOfOrder,
1117    /// Normal pending state: the file is on disk and its sequence number
1118    /// is higher than anything applied. Ready to apply.
1119    Pending,
1120}
1121
1122/// Per-migration entry inside a [`DriftReport`].
1123#[derive(Debug, Clone, PartialEq, Eq)]
1124pub struct MigrationEntry {
1125    pub plugin: String,
1126    pub name: String,
1127    pub status: MigrationStatus,
1128}
1129
1130/// The output of [`detect_drift`]: one entry per migration (applied or
1131/// on-disk), categorised into the four states above.
1132///
1133/// The caller inspects `has_critical_drift()` to decide whether to abort
1134/// before applying migrations. Surfaced by `show_in_with_drift` for
1135/// `showmigrations` and checked by `run_in_with_drift_check` before
1136/// executing any SQL.
1137#[derive(Debug, Clone, Default)]
1138pub struct DriftReport {
1139    pub entries: Vec<MigrationEntry>,
1140}
1141
1142impl DriftReport {
1143    /// Returns true when at least one migration is `AppliedButMissing`.
1144    /// This state means the tracking table references a file that no
1145    /// longer exists on disk — the operator needs to act before it is
1146    /// safe to continue applying new migrations.
1147    pub fn has_critical_drift(&self) -> bool {
1148        self.entries
1149            .iter()
1150            .any(|e| e.status == MigrationStatus::AppliedButMissing)
1151    }
1152
1153    /// All migrations with `AppliedButMissing` status. Convenience
1154    /// accessor for building the error message.
1155    pub fn missing_on_disk(&self) -> Vec<&MigrationEntry> {
1156        self.entries
1157            .iter()
1158            .filter(|e| e.status == MigrationStatus::AppliedButMissing)
1159            .collect()
1160    }
1161}
1162
1163/// Errors the migration engine can produce.
1164#[derive(Debug)]
1165pub enum MigrateError {
1166    /// IO error reading or writing a migration file or directory.
1167    Io(std::io::Error),
1168    /// JSON parse error on a migration file.
1169    Json(serde_json::Error),
1170    /// sqlx error executing a migration's SQL or touching the
1171    /// tracking table.
1172    Sqlx(sqlx::Error),
1173    /// `make` ran but found no differences against the latest snapshot,
1174    /// so there's nothing to write. Surfaced so the CLI can print
1175    /// "no changes detected" instead of an empty migration file.
1176    NoChanges,
1177    /// The current models diverge from the snapshot in a way M5 v1
1178    /// can't represent yet (anything other than create/drop table).
1179    /// M5.1 lifts this when column-level ops land.
1180    UnsupportedChange(String),
1181    /// A column-level change the engine can't apply automatically:
1182    /// type change, or a nullable flip on a populated SQLite table.
1183    /// Surfaces from `diff` so the build stops before producing a
1184    /// migration that would lose data or fail to apply. The user
1185    /// resolves by hand-writing the migration with the appropriate
1186    /// data-preserving steps. Carries the model / column / reason.
1187    UnsafeAlter {
1188        model: String,
1189        column: String,
1190        reason: String,
1191    },
1192    /// The tracking table records migrations that no longer have
1193    /// corresponding files on disk. Carries the list of missing names.
1194    /// The operator must either restore the files from VCS or run with
1195    /// `--allow-drift` to proceed despite the inconsistency.
1196    DriftDetected { missing: Vec<(String, String)> },
1197    /// A schema-scoped migration ([`run_for_schema`]) was requested against a
1198    /// SQLite pool. SQLite has no schemas, so schema-per-tenant is Postgres-only
1199    /// (mirrors how `Inet`/`Cidr` gate on backend). Carries the schema name.
1200    SchemaUnsupportedOnSqlite { schema: String },
1201    /// `makemigrations --empty <plugin>` named a plugin that isn't
1202    /// registered. Carries the requested name and the registered set so
1203    /// the CLI can list the valid choices.
1204    UnknownPlugin { requested: String, known: Vec<String> },
1205}
1206
1207impl std::fmt::Display for MigrateError {
1208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1209        match self {
1210            MigrateError::Io(e) => write!(f, "umbral migrate: io: {e}"),
1211            MigrateError::Json(e) => write!(f, "umbral migrate: json: {e}"),
1212            MigrateError::Sqlx(e) => write!(f, "umbral migrate: sqlx: {e}"),
1213            MigrateError::NoChanges => write!(
1214                f,
1215                "umbral migrate: no changes detected; declare or change a model first"
1216            ),
1217            MigrateError::UnsupportedChange(msg) => {
1218                write!(f, "umbral migrate: unsupported change at M5 v1: {msg}")
1219            }
1220            MigrateError::UnsafeAlter {
1221                model,
1222                column,
1223                reason,
1224            } => write!(
1225                f,
1226                "umbral migrate: unsafe column change on `{model}.{column}`: {reason}; \
1227                 hand-write the migration with a data-preserving step"
1228            ),
1229            MigrateError::DriftDetected { missing } => {
1230                let names: Vec<String> = missing
1231                    .iter()
1232                    .map(|(plugin, name)| format!("{plugin}/{name}"))
1233                    .collect();
1234                write!(
1235                    f,
1236                    "umbral migrate: drift detected — the following migrations are recorded in \
1237                     the tracking table but their files are missing from disk:\n  {}\n\
1238                     Restore the files from VCS or run `umbral migrate --allow-drift` to \
1239                     proceed despite the inconsistency.",
1240                    names.join("\n  ")
1241                )
1242            }
1243            MigrateError::SchemaUnsupportedOnSqlite { schema } => write!(
1244                f,
1245                "umbral migrate: schema-per-tenant migration into `{schema}` requires \
1246                 Postgres; SQLite has no schemas. Point the app at a Postgres pool."
1247            ),
1248            MigrateError::UnknownPlugin { requested, known } => write!(
1249                f,
1250                "umbral makemigrations --empty: no registered plugin named `{requested}`. \
1251                 Known plugins: {}",
1252                known.join(", ")
1253            ),
1254        }
1255    }
1256}
1257
1258impl std::error::Error for MigrateError {}
1259
1260impl From<std::io::Error> for MigrateError {
1261    fn from(e: std::io::Error) -> Self {
1262        Self::Io(e)
1263    }
1264}
1265
1266impl From<serde_json::Error> for MigrateError {
1267    fn from(e: serde_json::Error) -> Self {
1268        Self::Json(e)
1269    }
1270}
1271
1272impl From<sqlx::Error> for MigrateError {
1273    fn from(e: sqlx::Error) -> Self {
1274        Self::Sqlx(e)
1275    }
1276}
1277
1278// =========================================================================
1279// Top-level entry points.
1280// =========================================================================
1281
1282/// Generate one migration file per registered plugin that has changes,
1283/// diffing each plugin's current model set against the latest snapshot
1284/// in `migrations/<plugin>/`. Each new file lands inside its own
1285/// plugin directory with the next sequence number and a `_<short_name>`
1286/// suffix derived from the dominant operation.
1287///
1288/// Returns the paths of every file written, one per plugin that had a
1289/// non-empty diff. Returns `MigrateError::NoChanges` if no plugin
1290/// produced any changes at all.
1291pub async fn make() -> Result<Vec<PathBuf>, MigrateError> {
1292    make_in(Path::new(MIGRATIONS_DIR)).await
1293}
1294
1295/// Same as [`make`] but takes an explicit base directory. Used by
1296/// tests to avoid touching the cwd.
1297///
1298/// Iterates [`plugin_order`], which is the topological order
1299/// published by `App::build()`'s phase 1.5 sort. Cross-plugin FKs
1300/// land in dependency order this way (a plugin's `CreateTable` for
1301/// the FK target runs before the dependent plugin's `CreateTable`).
1302/// Falls back to [`registered_plugins`] when no order has been
1303/// published (e.g. low-level tests that init the registry directly).
1304pub async fn make_in(dir: &Path) -> Result<Vec<PathBuf>, MigrateError> {
1305    let mut written: Vec<PathBuf> = Vec::new();
1306
1307    for plugin in plugin_order() {
1308        let plugin_dir = dir.join(&plugin);
1309
1310        // The previous snapshot is the `snapshot_after` of the highest-
1311        // numbered migration file (filenames are zero-padded so lexical
1312        // sort matches numeric order). An empty or missing directory
1313        // means "no prior state", the first-run case for this plugin.
1314        let existing = list_migration_files(&plugin_dir)?;
1315        let previous = match existing.last() {
1316            Some(path) => read_migration_file(path)?.snapshot_after,
1317            None => Snapshot::default(),
1318        };
1319
1320        let current = Snapshot::current_for(&plugin);
1321        let operations = diff(&previous, &current)?;
1322        if operations.is_empty() {
1323            continue;
1324        }
1325
1326        let seq = (existing.len() + 1) as u32;
1327        let suffix = suffix_for(&operations);
1328        let id = format!("{seq:04}_{suffix}");
1329        let filename = format!("{id}.json");
1330
1331        let file = MigrationFile {
1332            id: id.clone(),
1333            plugin: plugin.clone(),
1334            depends_on: Vec::new(),
1335            operations,
1336            snapshot_after: current,
1337        };
1338
1339        std::fs::create_dir_all(&plugin_dir)?;
1340        let path = plugin_dir.join(filename);
1341        let json = serde_json::to_string_pretty(&file)?;
1342        std::fs::write(&path, json)?;
1343        written.push(path);
1344    }
1345
1346    if written.is_empty() {
1347        return Err(MigrateError::NoChanges);
1348    }
1349    Ok(written)
1350}
1351
1352/// Write an **empty** migration for one plugin: the current snapshot
1353/// with an empty `operations` list, the authoring stub for a
1354/// hand-written data migration (`Operation::RunSql`). The developer
1355/// opens the file and adds a `RunSql { sql, reverse_sql }` op.
1356///
1357/// The empty op-list means `snapshot_after == snapshot_before`, so the
1358/// next `make` diffs against the same state and produces nothing — a
1359/// data migration never disturbs the schema-snapshot chain. Mirror of
1360/// [`make`] for the `--empty <plugin>` CLI path.
1361pub async fn make_empty(plugin: &str) -> Result<PathBuf, MigrateError> {
1362    make_empty_in(Path::new(MIGRATIONS_DIR), plugin).await
1363}
1364
1365/// Same as [`make_empty`] but takes an explicit base directory. The
1366/// seam tests drive.
1367pub async fn make_empty_in(dir: &Path, plugin: &str) -> Result<PathBuf, MigrateError> {
1368    // The plugin must be registered, else the snapshot/sequence would be
1369    // meaningless. Fail loudly with the known set.
1370    let known = plugin_order();
1371    if !known.iter().any(|p| p == plugin) {
1372        return Err(MigrateError::UnknownPlugin {
1373            requested: plugin.to_string(),
1374            known,
1375        });
1376    }
1377
1378    let plugin_dir = dir.join(plugin);
1379
1380    // Carry the latest snapshot forward verbatim: an empty migration has
1381    // NO schema effect, so `snapshot_after` equals the previous one. The
1382    // current model snapshot is the same as the prior file's
1383    // `snapshot_after` (no model changed); use the current registry state
1384    // so the file is self-consistent even on a plugin's very first
1385    // migration.
1386    let existing = list_migration_files(&plugin_dir)?;
1387    let snapshot = match existing.last() {
1388        Some(path) => read_migration_file(path)?.snapshot_after,
1389        None => Snapshot::current_for(plugin),
1390    };
1391
1392    let seq = (existing.len() + 1) as u32;
1393    let id = format!("{seq:04}_empty");
1394    let filename = format!("{id}.json");
1395
1396    let file = MigrationFile {
1397        id: id.clone(),
1398        plugin: plugin.to_string(),
1399        depends_on: Vec::new(),
1400        operations: Vec::new(),
1401        snapshot_after: snapshot,
1402    };
1403
1404    std::fs::create_dir_all(&plugin_dir)?;
1405    let path = plugin_dir.join(filename);
1406    let json = serde_json::to_string_pretty(&file)?;
1407    std::fs::write(&path, json)?;
1408    Ok(path)
1409}
1410
1411/// Apply every pending migration across every registered plugin's
1412/// `migrations/<plugin>/` directory to the ambient pool. Reads the
1413/// `umbral_migrations` tracking table to determine "pending"; each
1414/// migration runs in its own transaction along with its tracking-table
1415/// insert.
1416///
1417/// Returns the total number of migrations applied (zero if every
1418/// plugin's migrations were already in the tracking table).
1419///
1420/// This variant performs a drift check before executing any SQL. If
1421/// any migration is `AppliedButMissing` (in the DB but not on disk),
1422/// the call returns [`MigrateError::DriftDetected`] listing the
1423/// missing names. Pass `allow_drift = true` (via [`run_checked_in`])
1424/// to suppress the error and proceed anyway (with a warning printed to
1425/// stderr).
1426pub async fn run() -> Result<u64, MigrateError> {
1427    run_checked(false).await
1428}
1429
1430/// Same as [`run`] but controls drift handling.
1431/// `allow_drift = true` corresponds to the `--allow-drift` CLI flag:
1432/// the command logs a warning and proceeds even if some applied
1433/// migrations are missing on disk.
1434pub async fn run_checked(allow_drift: bool) -> Result<u64, MigrateError> {
1435    run_checked_in(Path::new(MIGRATIONS_DIR), allow_drift).await
1436}
1437
1438/// Same as [`run_checked`] but takes an explicit base directory.
1439pub async fn run_checked_in(dir: &Path, allow_drift: bool) -> Result<u64, MigrateError> {
1440    let mut total: u64 = 0;
1441    // Walk every registered DB. Drift-detection on the default pool
1442    // is the dominant flow; secondary pools currently use the same
1443    // tracking-table-vs-disk comparison but only against the
1444    // migration files whose ops actually targeted that DB. A future
1445    // pass can teach `detect_all_drift` to be alias-aware so drift
1446    // warnings name the offending pool — today it warns once per
1447    // checked DB if the issue is present in any.
1448    for alias in crate::db::registered_aliases() {
1449        match crate::db::pool_for_dispatched(&alias) {
1450            crate::db::DbPool::Sqlite(p) => {
1451                total += run_in_sqlite_checked(dir, p, allow_drift, &alias).await?
1452            }
1453            crate::db::DbPool::Postgres(p) => {
1454                total += run_in_postgres_checked(dir, p, allow_drift, &alias).await?
1455            }
1456        }
1457    }
1458    Ok(total)
1459}
1460
1461/// Same as [`run`] but takes an explicit base directory. Used by
1462/// tests to avoid touching the cwd.
1463///
1464/// Iterates `registered_plugins()` in sorted-by-name order. M7 v1
1465/// accepts this as a limitation: cross-plugin FK ordering wants
1466/// topological order across plugins (the FK target's `CreateTable`
1467/// applies before the dependent plugin's `CreateTable`), but the
1468/// engine doesn't see `Plugin::dependencies()` from inside this
1469/// standalone function. M8 lifts the limitation via a registry that
1470/// remembers the toposorted order computed at `App::build()` time.
1471///
1472/// This legacy entry point does NOT perform drift checking so the
1473/// existing tests (which bypass drift by design) keep passing. New
1474/// callers should prefer [`run_checked_in`].
1475pub async fn run_in(dir: &Path) -> Result<u64, MigrateError> {
1476    let mut total: u64 = 0;
1477    // Walk every registered DB so each pool gets its own
1478    // `umbral_migrations` table and runs only the operations targeting
1479    // tables routed to it. Order is alphabetical for determinism;
1480    // the "default" pool is always present.
1481    for alias in crate::db::registered_aliases() {
1482        match crate::db::pool_for_dispatched(&alias) {
1483            crate::db::DbPool::Sqlite(p) => {
1484                total += run_in_sqlite_for_alias(dir, &alias, p, None).await?
1485            }
1486            crate::db::DbPool::Postgres(p) => {
1487                total += run_in_postgres_for_alias(dir, &alias, p, None).await?
1488            }
1489        }
1490    }
1491    Ok(total)
1492}
1493
1494/// Apply only the **SHARED** apps' pending migrations to the default pool —
1495/// the `public`/shared half of schema-per-tenant multitenancy. This is the
1496/// mirror of [`run_for_schema_in`] (which migrates the *tenant* apps into a
1497/// tenant schema): here only plugins IN `shared_apps` migrate into `public`,
1498/// so a tenant app's tables — and crucially its M2M junctions — are NEVER
1499/// created in `public`. They live only in each tenant schema, where a junction's
1500/// FK to a SHARED child resolves via the `<schema>, public` search-path.
1501///
1502/// Use this instead of the unfiltered [`run`]/[`run_in`] when running a
1503/// schema-per-tenant app: `run_shared` (shared → public) then `migrate_schemas`
1504/// (tenant apps → each schema). On a non-multitenant app the two are equivalent
1505/// only if every app is shared; otherwise prefer plain [`run`].
1506pub async fn run_shared(shared_apps: &std::collections::HashSet<String>) -> Result<u64, MigrateError> {
1507    run_shared_in(Path::new(MIGRATIONS_DIR), shared_apps).await
1508}
1509
1510/// [`run_shared`] against an explicit migrations directory (tests / tooling).
1511pub async fn run_shared_in(
1512    dir: &Path,
1513    shared_apps: &std::collections::HashSet<String>,
1514) -> Result<u64, MigrateError> {
1515    let mut total: u64 = 0;
1516    for alias in crate::db::registered_aliases() {
1517        match crate::db::pool_for_dispatched(&alias) {
1518            crate::db::DbPool::Sqlite(p) => {
1519                total += run_in_sqlite_for_alias(dir, &alias, p, Some(shared_apps)).await?
1520            }
1521            crate::db::DbPool::Postgres(p) => {
1522                total += run_in_postgres_for_alias(dir, &alias, p, Some(shared_apps)).await?
1523            }
1524        }
1525    }
1526    Ok(total)
1527}
1528
1529/// Predicate: does `op` target a table that lives on `alias`?
1530///
1531/// Routing rule: look up the table → alias mapping via
1532/// [`table_alias`]. Tables not owned by any registered model fall
1533/// through to `"default"` so the migration engine's own
1534/// `umbral_migrations` book-keeping stays in the main DB.
1535///
1536/// A second gate consults the installed [`DatabaseRouter`]: if the
1537/// router's [`allow_migrate`](crate::db::DatabaseRouter::allow_migrate)
1538/// returns `false` for this (alias, model) pair the operation is
1539/// excluded from the alias's run. Junction / unowned tables (no
1540/// registered `ModelMeta`) are always allowed — the router has no
1541/// model to inspect.
1542fn op_targets_alias(op: &Operation, alias: &str) -> bool {
1543    if table_alias(op.table_name()) != alias {
1544        return false;
1545    }
1546    // Let the router veto migrating this table on this alias.
1547    match model_meta_for_table(op.table_name()) {
1548        Some(meta) => crate::db::router::router().allow_migrate(alias, &meta),
1549        None => true, // junction / unowned table — migrate on its alias
1550    }
1551}
1552
1553/// SQLite per-alias variant. Same shape as the legacy `run_in_sqlite`
1554/// but: filters ops to those routed to `alias`; skips files whose op
1555/// list contains nothing for this DB (so we don't stuff orphan
1556/// tracking rows into pools that didn't run any SQL).
1557async fn run_in_sqlite_for_alias(
1558    dir: &Path,
1559    alias: &str,
1560    pool: &sqlx::SqlitePool,
1561    shared_only: Option<&std::collections::HashSet<String>>,
1562) -> Result<u64, MigrateError> {
1563    ensure_tracking_table_sqlite(pool).await?;
1564    let applied = applied_names_sqlite(pool).await?;
1565
1566    let mut applied_count: u64 = 0;
1567    for plugin in plugin_order() {
1568        if let Some(shared) = shared_only {
1569            if !shared.contains(&plugin) {
1570                continue;
1571            }
1572        }
1573        let plugin_dir = dir.join(&plugin);
1574        let paths = list_migration_files(&plugin_dir)?;
1575
1576        for path in paths {
1577            let file = read_migration_file(&path)?;
1578            if applied.contains(&(file.plugin.clone(), file.id.clone())) {
1579                continue;
1580            }
1581
1582            let ops_for_this_db: Vec<&Operation> = file
1583                .operations
1584                .iter()
1585                .filter(|op| op_targets_alias(op, alias))
1586                .collect();
1587            if ops_for_this_db.is_empty() {
1588                // File's content all targets some other DB. Don't
1589                // record it here — re-runs will re-evaluate cleanly
1590                // once the right DB picks it up. The tracking rows
1591                // per-DB stay accurate to "what actually ran here."
1592                continue;
1593            }
1594
1595            let mut tx = pool.begin().await?;
1596            for op in &ops_for_this_db {
1597                for sql in render_operation(op) {
1598                    sqlx::query(&sql).execute(&mut *tx).await?;
1599                }
1600            }
1601            let snapshot_hash = file.snapshot_after.hash();
1602            let applied_at = chrono::Utc::now().to_rfc3339();
1603            sqlx::query(
1604                "INSERT INTO umbral_migrations (plugin, name, applied_at, snapshot_hash) \
1605                 VALUES (?, ?, ?, ?)",
1606            )
1607            .bind(&file.plugin)
1608            .bind(&file.id)
1609            .bind(&applied_at)
1610            .bind(&snapshot_hash)
1611            .execute(&mut *tx)
1612            .await?;
1613            tx.commit().await?;
1614            applied_count += 1;
1615        }
1616    }
1617    Ok(applied_count)
1618}
1619
1620/// Postgres per-alias variant. Mirror of `run_in_sqlite_for_alias`.
1621async fn run_in_postgres_for_alias(
1622    dir: &Path,
1623    alias: &str,
1624    pool: &sqlx::PgPool,
1625    shared_only: Option<&std::collections::HashSet<String>>,
1626) -> Result<u64, MigrateError> {
1627    ensure_tracking_table_postgres(pool).await?;
1628    let applied = applied_names_postgres(pool).await?;
1629
1630    let mut applied_count: u64 = 0;
1631    for plugin in plugin_order() {
1632        // Shared-filtered public migrate (multitenancy): when a shared-app set
1633        // is given, migrate ONLY those plugins into this pool, so a tenant
1634        // app's tables (and its M2M junctions) are NOT created in `public` —
1635        // they belong only in each tenant schema. `None` = migrate everything
1636        // (the default single-DB behaviour, byte-identical to before).
1637        if let Some(shared) = shared_only {
1638            if !shared.contains(&plugin) {
1639                continue;
1640            }
1641        }
1642        let plugin_dir = dir.join(&plugin);
1643        let paths = list_migration_files(&plugin_dir)?;
1644
1645        for path in paths {
1646            let file = read_migration_file(&path)?;
1647            if applied.contains(&(file.plugin.clone(), file.id.clone())) {
1648                continue;
1649            }
1650
1651            let ops_for_this_db: Vec<&Operation> = file
1652                .operations
1653                .iter()
1654                .filter(|op| op_targets_alias(op, alias))
1655                .collect();
1656            if ops_for_this_db.is_empty() {
1657                continue;
1658            }
1659
1660            let mut tx = pool.begin().await?;
1661            for op in &ops_for_this_db {
1662                for sql in render_operation(op) {
1663                    sqlx::query(&sql).execute(&mut *tx).await?;
1664                }
1665            }
1666            let snapshot_hash = file.snapshot_after.hash();
1667            let applied_at = chrono::Utc::now().to_rfc3339();
1668            sqlx::query(
1669                "INSERT INTO umbral_migrations (plugin, name, applied_at, snapshot_hash) \
1670                 VALUES ($1, $2, $3, $4)",
1671            )
1672            .bind(&file.plugin)
1673            .bind(&file.id)
1674            .bind(&applied_at)
1675            .bind(&snapshot_hash)
1676            .execute(&mut *tx)
1677            .await?;
1678            tx.commit().await?;
1679            applied_count += 1;
1680        }
1681    }
1682    Ok(applied_count)
1683}
1684
1685/// Migrate the **tenant** apps into a named Postgres schema (schema-per-tenant
1686/// style). The migration engine owns all schema DDL; this is the
1687/// sanctioned `CREATE SCHEMA` / `SET search_path` exception (a plugin calls this
1688/// rather than writing raw schema SQL itself).
1689///
1690/// Steps, all inside one transaction per migration file (mirroring
1691/// [`run_in_postgres_for_alias`]):
1692/// 1. `CREATE SCHEMA IF NOT EXISTS "<schema>"` (the `Schema` was already
1693///    validated to a safe PG identifier, but is still emitted quoted).
1694/// 2. `SET LOCAL search_path TO "<schema>"` so every unqualified
1695///    `CREATE TABLE` **and** the `umbral_migrations` ledger land *inside*
1696///    `<schema>` — per-schema migration tracking falls out for free.
1697/// 3. Apply pending migrations, **filtered to the tenant apps** — every plugin
1698///    NOT in `shared_apps` (those tables live in `public` and are migrated by
1699///    the normal [`run`]). A file with no tenant-app ops for this schema is
1700///    skipped without a tracking row.
1701///
1702/// Idempotent: re-running applies only the migrations the schema's own
1703/// `umbral_migrations` ledger hasn't recorded. Postgres-only — schemas don't
1704/// exist on SQLite, so a SQLite pool is a clear error
1705/// ([`MigrateError::SchemaUnsupportedOnSqlite`]).
1706pub async fn run_for_schema(
1707    schema: &crate::db::Schema,
1708    shared_apps: &std::collections::HashSet<String>,
1709) -> Result<u64, MigrateError> {
1710    run_for_schema_in(Path::new(MIGRATIONS_DIR), schema, shared_apps).await
1711}
1712
1713/// Same as [`run_for_schema`] but takes an explicit migrations base directory.
1714/// The entry tests drive.
1715pub async fn run_for_schema_in(
1716    dir: &Path,
1717    schema: &crate::db::Schema,
1718    shared_apps: &std::collections::HashSet<String>,
1719) -> Result<u64, MigrateError> {
1720    match crate::db::pool_dispatched() {
1721        crate::db::DbPool::Postgres(p) => {
1722            run_tenant_apps_in_postgres_schema(dir, schema, shared_apps, p).await
1723        }
1724        crate::db::DbPool::Sqlite(_) => Err(MigrateError::SchemaUnsupportedOnSqlite {
1725            schema: schema.as_str().to_string(),
1726        }),
1727    }
1728}
1729
1730/// Postgres schema-scoped variant of [`run_in_postgres_for_alias`]. Creates the
1731/// schema, pins `search_path` to it for the transaction, and applies only the
1732/// tenant apps' migrations (plugins not in `shared_apps`). The `umbral_migrations`
1733/// ledger is read/written *inside* the schema (search_path is set first), so
1734/// tracking is per-schema with no extra book-keeping.
1735async fn run_tenant_apps_in_postgres_schema(
1736    dir: &Path,
1737    schema: &crate::db::Schema,
1738    shared_apps: &std::collections::HashSet<String>,
1739    pool: &sqlx::PgPool,
1740) -> Result<u64, MigrateError> {
1741    let quoted = format!("\"{}\"", schema.as_str());
1742
1743    // Create the schema once, outside the per-file loop. IF NOT EXISTS makes
1744    // the whole call idempotent.
1745    sqlx::query(&format!("CREATE SCHEMA IF NOT EXISTS {quoted}"))
1746        .execute(pool)
1747        .await?;
1748
1749    // Ensure + read the ledger INSIDE the schema. Each block runs in its own
1750    // transaction with `SET LOCAL search_path` so the tracking table is created
1751    // in (and read from) `<schema>`, not `public` — AND the search_path is
1752    // transaction-scoped, so the pooled connection is NOT left pinned to this
1753    // schema when it returns to the pool. A plain session-level `SET` here
1754    // pollutes the pool: the next unqualified ORM query that reuses the
1755    // connection would resolve against `<schema>` instead of `public` (e.g. an
1756    // insert into the public `tenant` registry failing with "relation does not
1757    // exist") — a real cross-tenant bug, caught only against live Postgres.
1758    {
1759        let mut tx = pool.begin().await?;
1760        sqlx::query(&format!("SET LOCAL search_path TO {quoted}"))
1761            .execute(&mut *tx)
1762            .await?;
1763        ensure_tracking_table_pg_conn(&mut tx).await?;
1764        tx.commit().await?;
1765    }
1766    let applied = {
1767        let mut tx = pool.begin().await?;
1768        sqlx::query(&format!("SET LOCAL search_path TO {quoted}"))
1769            .execute(&mut *tx)
1770            .await?;
1771        let rows: Vec<(String, String)> =
1772            sqlx::query_as("SELECT plugin, name FROM umbral_migrations")
1773                .fetch_all(&mut *tx)
1774                .await?;
1775        tx.commit().await?;
1776        rows.into_iter().collect::<std::collections::HashSet<_>>()
1777    };
1778
1779    let mut applied_count: u64 = 0;
1780    for plugin in plugin_order() {
1781        // Tenant apps only — shared apps live in `public`.
1782        if shared_apps.contains(&plugin) {
1783            continue;
1784        }
1785        let plugin_dir = dir.join(&plugin);
1786        let paths = list_migration_files(&plugin_dir)?;
1787
1788        for path in paths {
1789            let file = read_migration_file(&path)?;
1790            if applied.contains(&(file.plugin.clone(), file.id.clone())) {
1791                continue;
1792            }
1793            // Belt-and-braces: skip a file whose declared plugin is shared.
1794            if shared_apps.contains(&file.plugin) {
1795                continue;
1796            }
1797
1798            let mut tx = pool.begin().await?;
1799            // Pin search_path for THIS transaction, tenant schema FIRST with
1800            // `public` as a fallback. `CREATE TABLE` / `INSERT` still land in
1801            // the tenant schema (it's first), but an unqualified reference that
1802            // ISN'T in the tenant schema resolves against `public` — which is
1803            // what makes a CROSS-BOUNDARY foreign key work: a tenant-owned
1804            // table (or an M2M junction) with an FK `REFERENCES <shared_child>`
1805            // resolves the shared child in `public` instead of erroring
1806            // `relation does not exist`. It also lets a (future) RunSql data
1807            // migration in a tenant schema read SHARED/`public` lookup tables.
1808            // The tenant-first ordering means a tenant table still shadows a
1809            // same-named public table, so no behaviour changes for the common
1810            // case where tenant and shared table names are distinct.
1811            sqlx::query(&format!("SET LOCAL search_path TO {quoted}, public"))
1812                .execute(&mut *tx)
1813                .await?;
1814            for op in &file.operations {
1815                for sql in render_operation_for(op, "postgres") {
1816                    sqlx::query(&sql).execute(&mut *tx).await?;
1817                }
1818            }
1819            let snapshot_hash = file.snapshot_after.hash();
1820            let applied_at = chrono::Utc::now().to_rfc3339();
1821            sqlx::query(
1822                "INSERT INTO umbral_migrations (plugin, name, applied_at, snapshot_hash) \
1823                 VALUES ($1, $2, $3, $4)",
1824            )
1825            .bind(&file.plugin)
1826            .bind(&file.id)
1827            .bind(&applied_at)
1828            .bind(&snapshot_hash)
1829            .execute(&mut *tx)
1830            .await?;
1831            tx.commit().await?;
1832            applied_count += 1;
1833        }
1834    }
1835    Ok(applied_count)
1836}
1837
1838/// Migrate the **tenant** apps into the pool registered under `alias`
1839/// (database-per-tenant). The db-per-tenant sibling of [`run_for_schema`]:
1840/// where the schema variant pins `search_path` inside one shared Postgres
1841/// database, this targets a *whole separate database/pool* registered at
1842/// runtime via [`register_tenant_pool`](crate::db::register_tenant_pool) and
1843/// resolved here through [`pool_for_dispatched`](crate::db::pool_for_dispatched)
1844/// (which sees dynamic pools). No schema games — per-database migration
1845/// tracking is just that database's own `umbral_migrations` table.
1846///
1847/// Like the schema variant it applies only the **tenant apps**: every plugin
1848/// NOT in `shared_apps` (the shared registry/auth tables live in the default
1849/// DB and are migrated there by the normal [`run`]). A migration file whose
1850/// declared plugin is shared is skipped without a tracking row. Idempotent:
1851/// re-running applies only what the tenant DB's own ledger hasn't recorded.
1852///
1853/// Works on both backends — a tenant pool can be Postgres (the production case)
1854/// or SQLite (tests). Unlike the alias-routed [`run_in`], this does NOT filter
1855/// ops by [`table_alias`]: a tenant-owned model's static alias is still
1856/// `"default"`, so the per-alias filter would wrongly exclude it from the
1857/// tenant DB. The shared/tenant split is the *only* filter here.
1858pub async fn migrate_apps_into_pool(
1859    alias: &str,
1860    shared_apps: &std::collections::HashSet<String>,
1861) -> Result<u64, MigrateError> {
1862    migrate_apps_into_pool_in(Path::new(MIGRATIONS_DIR), alias, shared_apps).await
1863}
1864
1865/// Same as [`migrate_apps_into_pool`] but takes an explicit migrations base
1866/// directory. The entry tests drive.
1867pub async fn migrate_apps_into_pool_in(
1868    dir: &Path,
1869    alias: &str,
1870    shared_apps: &std::collections::HashSet<String>,
1871) -> Result<u64, MigrateError> {
1872    match crate::db::pool_for_dispatched(alias) {
1873        crate::db::DbPool::Postgres(p) => {
1874            migrate_tenant_apps_into_pg_pool(dir, shared_apps, p).await
1875        }
1876        crate::db::DbPool::Sqlite(p) => {
1877            migrate_tenant_apps_into_sqlite_pool(dir, shared_apps, p).await
1878        }
1879    }
1880}
1881
1882/// Postgres tenant-DB apply loop. Mirrors [`run_in_postgres_for_alias`] but the
1883/// only filter is the shared/tenant split — every plugin not in `shared_apps`
1884/// is applied in full into this database.
1885async fn migrate_tenant_apps_into_pg_pool(
1886    dir: &Path,
1887    shared_apps: &std::collections::HashSet<String>,
1888    pool: &sqlx::PgPool,
1889) -> Result<u64, MigrateError> {
1890    ensure_tracking_table_postgres(pool).await?;
1891    let applied = applied_names_postgres(pool).await?;
1892
1893    let mut applied_count: u64 = 0;
1894    for plugin in plugin_order() {
1895        if shared_apps.contains(&plugin) {
1896            continue;
1897        }
1898        let plugin_dir = dir.join(&plugin);
1899        for path in list_migration_files(&plugin_dir)? {
1900            let file = read_migration_file(&path)?;
1901            if applied.contains(&(file.plugin.clone(), file.id.clone())) {
1902                continue;
1903            }
1904            if shared_apps.contains(&file.plugin) {
1905                continue;
1906            }
1907            let mut tx = pool.begin().await?;
1908            for op in &file.operations {
1909                for sql in render_operation_for(op, "postgres") {
1910                    sqlx::query(&sql).execute(&mut *tx).await?;
1911                }
1912            }
1913            let snapshot_hash = file.snapshot_after.hash();
1914            let applied_at = chrono::Utc::now().to_rfc3339();
1915            sqlx::query(
1916                "INSERT INTO umbral_migrations (plugin, name, applied_at, snapshot_hash) \
1917                 VALUES ($1, $2, $3, $4)",
1918            )
1919            .bind(&file.plugin)
1920            .bind(&file.id)
1921            .bind(&applied_at)
1922            .bind(&snapshot_hash)
1923            .execute(&mut *tx)
1924            .await?;
1925            tx.commit().await?;
1926            applied_count += 1;
1927        }
1928    }
1929    Ok(applied_count)
1930}
1931
1932/// SQLite tenant-DB apply loop (tests). Same shape as the Postgres variant.
1933async fn migrate_tenant_apps_into_sqlite_pool(
1934    dir: &Path,
1935    shared_apps: &std::collections::HashSet<String>,
1936    pool: &sqlx::SqlitePool,
1937) -> Result<u64, MigrateError> {
1938    ensure_tracking_table_sqlite(pool).await?;
1939    let applied = applied_names_sqlite(pool).await?;
1940
1941    let mut applied_count: u64 = 0;
1942    for plugin in plugin_order() {
1943        if shared_apps.contains(&plugin) {
1944            continue;
1945        }
1946        let plugin_dir = dir.join(&plugin);
1947        for path in list_migration_files(&plugin_dir)? {
1948            let file = read_migration_file(&path)?;
1949            if applied.contains(&(file.plugin.clone(), file.id.clone())) {
1950                continue;
1951            }
1952            if shared_apps.contains(&file.plugin) {
1953                continue;
1954            }
1955            let mut tx = pool.begin().await?;
1956            for op in &file.operations {
1957                for sql in render_operation_for(op, "sqlite") {
1958                    sqlx::query(&sql).execute(&mut *tx).await?;
1959                }
1960            }
1961            let snapshot_hash = file.snapshot_after.hash();
1962            let applied_at = chrono::Utc::now().to_rfc3339();
1963            sqlx::query(
1964                "INSERT INTO umbral_migrations (plugin, name, applied_at, snapshot_hash) \
1965                 VALUES (?, ?, ?, ?)",
1966            )
1967            .bind(&file.plugin)
1968            .bind(&file.id)
1969            .bind(&applied_at)
1970            .bind(&snapshot_hash)
1971            .execute(&mut *tx)
1972            .await?;
1973            tx.commit().await?;
1974            applied_count += 1;
1975        }
1976    }
1977    Ok(applied_count)
1978}
1979
1980/// `ensure_tracking_table_postgres` against an explicit connection (so the
1981/// caller can pin `search_path` first and have the table created in the tenant
1982/// schema rather than `public`).
1983async fn ensure_tracking_table_pg_conn(
1984    conn: &mut sqlx::PgConnection,
1985) -> Result<(), MigrateError> {
1986    sqlx::query(
1987        "CREATE TABLE IF NOT EXISTS umbral_migrations (
1988            plugin TEXT NOT NULL,
1989            name TEXT NOT NULL,
1990            applied_at TEXT NOT NULL,
1991            snapshot_hash TEXT NOT NULL,
1992            PRIMARY KEY (plugin, name)
1993        )",
1994    )
1995    .execute(conn)
1996    .await?;
1997    Ok(())
1998}
1999
2000/// SQLite drift-checking path for `run_checked_in`.
2001///
2002/// Reads the applied set, runs `detect_all_drift`, and either errors
2003/// (if `allow_drift = false` and critical drift is found) or logs a
2004/// warning and proceeds (if `allow_drift = true`). Then delegates to
2005/// `run_in_sqlite` for the actual apply loop.
2006async fn run_in_sqlite_checked(
2007    dir: &Path,
2008    pool: &sqlx::SqlitePool,
2009    allow_drift: bool,
2010    alias: &str,
2011) -> Result<u64, MigrateError> {
2012    ensure_tracking_table_sqlite(pool).await?;
2013    let applied = applied_names_sqlite(pool).await?;
2014    let report = detect_all_drift(&applied, dir)?;
2015
2016    if report.has_critical_drift() {
2017        if allow_drift {
2018            let missing = report.missing_on_disk();
2019            for entry in &missing {
2020                eprintln!(
2021                    "warning: umbral migrate --allow-drift: migration {}/{} is recorded in \
2022                     the tracking table but the file is missing from disk; proceeding.",
2023                    entry.plugin, entry.name
2024                );
2025            }
2026        } else {
2027            let missing: Vec<(String, String)> = report
2028                .missing_on_disk()
2029                .iter()
2030                .map(|e| (e.plugin.clone(), e.name.clone()))
2031                .collect();
2032            return Err(MigrateError::DriftDetected { missing });
2033        }
2034    }
2035
2036    // Emit warnings for out-of-order files.
2037    for entry in report
2038        .entries
2039        .iter()
2040        .filter(|e| e.status == MigrationStatus::OutOfOrder)
2041    {
2042        eprintln!(
2043            "warning: umbral migrate: migration {}/{} is on disk but appears before the \
2044             last applied migration for this plugin; it looks like a file was restored \
2045             after a teammate already applied later ones.",
2046            entry.plugin, entry.name
2047        );
2048    }
2049
2050    run_in_sqlite_for_alias(dir, alias, pool, None).await
2051}
2052
2053/// Postgres drift-checking path for `run_checked_in`. Same logic as
2054/// `run_in_sqlite_checked` but uses the Postgres applied-set reader.
2055async fn run_in_postgres_checked(
2056    dir: &Path,
2057    pool: &sqlx::PgPool,
2058    allow_drift: bool,
2059    alias: &str,
2060) -> Result<u64, MigrateError> {
2061    ensure_tracking_table_postgres(pool).await?;
2062    let applied = applied_names_postgres(pool).await?;
2063    let report = detect_all_drift(&applied, dir)?;
2064
2065    if report.has_critical_drift() {
2066        if allow_drift {
2067            let missing = report.missing_on_disk();
2068            for entry in &missing {
2069                eprintln!(
2070                    "warning: umbral migrate --allow-drift: migration {}/{} is recorded in \
2071                     the tracking table but the file is missing from disk; proceeding.",
2072                    entry.plugin, entry.name
2073                );
2074            }
2075        } else {
2076            let missing: Vec<(String, String)> = report
2077                .missing_on_disk()
2078                .iter()
2079                .map(|e| (e.plugin.clone(), e.name.clone()))
2080                .collect();
2081            return Err(MigrateError::DriftDetected { missing });
2082        }
2083    }
2084
2085    for entry in report
2086        .entries
2087        .iter()
2088        .filter(|e| e.status == MigrationStatus::OutOfOrder)
2089    {
2090        eprintln!(
2091            "warning: umbral migrate: migration {}/{} is on disk but appears before the \
2092             last applied migration for this plugin; it looks like a file was restored \
2093             after a teammate already applied later ones.",
2094            entry.plugin, entry.name
2095        );
2096    }
2097
2098    run_in_postgres_for_alias(dir, alias, pool, None).await
2099}
2100
2101/// Record a migration as applied in the `umbral_migrations` tracking
2102/// table without running its operations. The "mark as applied" path
2103/// `inspectdb --mark-applied` uses to register the introspected
2104/// `0001_initial` against an already-populated database. Idempotent:
2105/// if the `(plugin, name)` row already exists, the call is a no-op.
2106pub async fn record_applied(
2107    plugin: &str,
2108    name: &str,
2109    snapshot_hash: &str,
2110) -> Result<(), MigrateError> {
2111    let applied_at = chrono::Utc::now().to_rfc3339();
2112    match crate::db::pool_dispatched() {
2113        crate::db::DbPool::Sqlite(pool) => {
2114            ensure_tracking_table_sqlite(pool).await?;
2115            sqlx::query(
2116                "INSERT OR IGNORE INTO umbral_migrations \
2117                 (plugin, name, applied_at, snapshot_hash) \
2118                 VALUES (?, ?, ?, ?)",
2119            )
2120            .bind(plugin)
2121            .bind(name)
2122            .bind(&applied_at)
2123            .bind(snapshot_hash)
2124            .execute(pool)
2125            .await?;
2126        }
2127        crate::db::DbPool::Postgres(pool) => {
2128            ensure_tracking_table_postgres(pool).await?;
2129            sqlx::query(
2130                "INSERT INTO umbral_migrations \
2131                 (plugin, name, applied_at, snapshot_hash) \
2132                 VALUES ($1, $2, $3, $4) \
2133                 ON CONFLICT (plugin, name) DO NOTHING",
2134            )
2135            .bind(plugin)
2136            .bind(name)
2137            .bind(&applied_at)
2138            .bind(snapshot_hash)
2139            .execute(pool)
2140            .await?;
2141        }
2142    }
2143    Ok(())
2144}
2145
2146// =========================================================================
2147// Drift detection — gap 24.
2148// =========================================================================
2149
2150/// Compute the drift report for a single plugin directory. Compares the
2151/// set of `(plugin, name)` pairs recorded in the tracking table against
2152/// the migration files present on disk and classifies each into one of
2153/// the four [`MigrationStatus`] states.
2154///
2155/// `applied` is the full set of `(plugin, name)` tuples already read
2156/// from the tracking table (shared across plugins to avoid extra DB
2157/// round-trips). `plugin_dir` is the on-disk directory for this plugin;
2158/// an absent directory is treated the same as an empty one.
2159///
2160/// # Classification
2161///
2162/// - File present + in DB → `Applied`
2163/// - File absent + in DB → `AppliedButMissing`
2164/// - File present + not in DB + seq ≤ max_applied_seq → `OutOfOrder`
2165/// - File present + not in DB + seq > max_applied_seq → `Pending`
2166///
2167/// The sequence number is the numeric prefix of the migration name
2168/// (e.g. `0001` in `0001_create_post`). Absence of any applied
2169/// migration for this plugin means `max_applied_seq = 0`.
2170pub fn detect_drift(
2171    plugin: &str,
2172    applied: &std::collections::HashSet<(String, String)>,
2173    plugin_dir: &Path,
2174) -> Result<Vec<MigrationEntry>, MigrateError> {
2175    // Collect on-disk migration names (the id, not the full path).
2176    let paths = list_migration_files(plugin_dir)?;
2177    let mut on_disk: Vec<String> = Vec::new();
2178    for path in &paths {
2179        let file = read_migration_file(path)?;
2180        on_disk.push(file.id.clone());
2181    }
2182
2183    // Pull every tracking-table entry for this plugin.
2184    let plugin_applied: Vec<&str> = applied
2185        .iter()
2186        .filter(|(p, _)| p == plugin)
2187        .map(|(_, n)| n.as_str())
2188        .collect();
2189
2190    // Highest sequence number among applied migrations for this plugin.
2191    let max_applied_seq: u32 = plugin_applied
2192        .iter()
2193        .filter_map(|name| name.split('_').next()?.parse::<u32>().ok())
2194        .max()
2195        .unwrap_or(0);
2196
2197    let on_disk_set: std::collections::HashSet<&str> = on_disk.iter().map(|s| s.as_str()).collect();
2198
2199    let mut entries: Vec<MigrationEntry> = Vec::new();
2200
2201    // Walk on-disk files in order.
2202    for name in &on_disk {
2203        let key = (plugin.to_string(), name.clone());
2204        let status = if applied.contains(&key) {
2205            MigrationStatus::Applied
2206        } else {
2207            // Determine this migration's sequence number.
2208            let seq: u32 = name
2209                .split('_')
2210                .next()
2211                .and_then(|s| s.parse().ok())
2212                .unwrap_or(0);
2213            if seq <= max_applied_seq && max_applied_seq > 0 {
2214                MigrationStatus::OutOfOrder
2215            } else {
2216                MigrationStatus::Pending
2217            }
2218        };
2219        entries.push(MigrationEntry {
2220            plugin: plugin.to_string(),
2221            name: name.clone(),
2222            status,
2223        });
2224    }
2225
2226    // Walk applied entries not present on disk.
2227    for name in &plugin_applied {
2228        if !on_disk_set.contains(*name) {
2229            entries.push(MigrationEntry {
2230                plugin: plugin.to_string(),
2231                name: (*name).to_string(),
2232                status: MigrationStatus::AppliedButMissing,
2233            });
2234        }
2235    }
2236
2237    // Sort: applied-but-missing entries bubble after their expected
2238    // position is not determinable; sort all entries by name for a
2239    // deterministic order. In practice, applied-but-missing names
2240    // are still prefixed with the numeric sequence so lexical sort
2241    // yields the right display order.
2242    entries.sort_by(|a, b| a.name.cmp(&b.name));
2243
2244    Ok(entries)
2245}
2246
2247/// Detect drift across every registered plugin and return a combined
2248/// [`DriftReport`]. Called by `run_in_checked` before executing SQL
2249/// and by `show_in` when displaying the four-state list.
2250///
2251/// `applied` is already fetched from the DB; `dir` is the migrations
2252/// root directory.
2253pub fn detect_all_drift(
2254    applied: &std::collections::HashSet<(String, String)>,
2255    dir: &Path,
2256) -> Result<DriftReport, MigrateError> {
2257    let mut all_entries: Vec<MigrationEntry> = Vec::new();
2258
2259    // Also surface any tracking-table entries whose plugin directory
2260    // doesn't appear in the registered-plugins list — a plugin was
2261    // removed entirely but its DB rows remain.
2262    let mut seen_plugins: std::collections::HashSet<String> = std::collections::HashSet::new();
2263
2264    for plugin in plugin_order() {
2265        seen_plugins.insert(plugin.clone());
2266        let plugin_dir = dir.join(&plugin);
2267        let entries = detect_drift(&plugin, applied, &plugin_dir)?;
2268        all_entries.extend(entries);
2269    }
2270
2271    // Any applied entries whose plugin is not in the registered set at
2272    // all — treat them as AppliedButMissing (the whole plugin is gone).
2273    for (plugin, name) in applied {
2274        if !seen_plugins.contains(plugin.as_str()) {
2275            all_entries.push(MigrationEntry {
2276                plugin: plugin.clone(),
2277                name: name.clone(),
2278                status: MigrationStatus::AppliedButMissing,
2279            });
2280        }
2281    }
2282
2283    Ok(DriftReport {
2284        entries: all_entries,
2285    })
2286}
2287
2288/// Record a migration as applied in the tracking table WITHOUT running
2289/// its SQL operations. The `--fake` recovery path: the schema already
2290/// exists (e.g. the migration was run outside umbral, or the DB was
2291/// bootstrapped from a dump) and the operator wants to bring the
2292/// tracking table into sync without re-executing the DDL.
2293///
2294/// Idempotent: if `(plugin, name)` is already in the table the call
2295/// is a no-op (same behaviour as `record_applied`).
2296///
2297/// The snapshot hash is derived from the migration file on disk.
2298/// Returns `MigrateError::Io` if the file can't be found (the caller
2299/// should verify the name before calling this).
2300pub async fn fake_apply(plugin: &str, name: &str) -> Result<(), MigrateError> {
2301    fake_apply_in(plugin, name, Path::new(MIGRATIONS_DIR)).await
2302}
2303
2304/// Same as [`fake_apply`] but takes an explicit migrations base dir.
2305/// Used by tests and by the CLI when `--migrations-dir` is passed.
2306pub async fn fake_apply_in(plugin: &str, name: &str, dir: &Path) -> Result<(), MigrateError> {
2307    let path = dir.join(plugin).join(format!("{name}.json"));
2308    let file = read_migration_file(&path)?;
2309    let snapshot_hash = file.snapshot_after.hash();
2310    record_applied(plugin, name, &snapshot_hash).await
2311}
2312
2313/// For every registered plugin's first migration (`0001_*`), check
2314/// whether the tables that migration would create already exist in the
2315/// database. If they do, fake-apply the migration (mark it applied
2316/// without running its SQL).
2317///
2318/// This is the `--fake-initial` path: the operator has a database
2319/// bootstrapped outside umbral (a dump restore, a manual `CREATE TABLE`,
2320/// or a previous schema manager) and wants to bring the tracking table
2321/// into sync so subsequent `migrate` calls apply only the genuine
2322/// deltas.
2323///
2324/// Returns the number of plugins whose `0001_*` migration was
2325/// fake-applied. Zero means either no `0001_*` file exists or the
2326/// target tables were absent (in which case normal `migrate` should be
2327/// run to create them).
2328pub async fn fake_initial() -> Result<u64, MigrateError> {
2329    fake_initial_in(Path::new(MIGRATIONS_DIR)).await
2330}
2331
2332/// Same as [`fake_initial`] but takes an explicit migrations base dir.
2333pub async fn fake_initial_in(dir: &Path) -> Result<u64, MigrateError> {
2334    match crate::db::pool_dispatched() {
2335        crate::db::DbPool::Sqlite(pool) => fake_initial_sqlite(dir, pool).await,
2336        crate::db::DbPool::Postgres(pool) => fake_initial_postgres(dir, pool).await,
2337    }
2338}
2339
2340/// SQLite path for [`fake_initial_in`].
2341async fn fake_initial_sqlite(dir: &Path, pool: &sqlx::SqlitePool) -> Result<u64, MigrateError> {
2342    ensure_tracking_table_sqlite(pool).await?;
2343    let applied = applied_names_sqlite(pool).await?;
2344    let mut count: u64 = 0;
2345
2346    for plugin in plugin_order() {
2347        let plugin_dir = dir.join(&plugin);
2348        let paths = list_migration_files(&plugin_dir)?;
2349
2350        // Find the first migration file (lowest sequence number).
2351        let first = paths.first();
2352        let first = match first {
2353            Some(p) => p,
2354            None => continue,
2355        };
2356        let file = read_migration_file(first)?;
2357
2358        // Skip if already applied.
2359        if applied.contains(&(file.plugin.clone(), file.id.clone())) {
2360            continue;
2361        }
2362
2363        // Check whether the tables the first migration would create
2364        // already exist in the database.
2365        let tables_to_create: Vec<&str> = file
2366            .operations
2367            .iter()
2368            .filter_map(|op| match op {
2369                Operation::CreateTable { table, .. } => Some(table.as_str()),
2370                _ => None,
2371            })
2372            .collect();
2373
2374        if tables_to_create.is_empty() {
2375            continue;
2376        }
2377
2378        // All tables present → fake-apply.
2379        let mut all_present = true;
2380        for table in &tables_to_create {
2381            let exists: Option<(String,)> =
2382                sqlx::query_as("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
2383                    .bind(*table)
2384                    .fetch_optional(pool)
2385                    .await?;
2386            if exists.is_none() {
2387                all_present = false;
2388                break;
2389            }
2390        }
2391
2392        if all_present {
2393            let snapshot_hash = file.snapshot_after.hash();
2394            let applied_at = chrono::Utc::now().to_rfc3339();
2395            sqlx::query(
2396                "INSERT OR IGNORE INTO umbral_migrations \
2397                 (plugin, name, applied_at, snapshot_hash) VALUES (?, ?, ?, ?)",
2398            )
2399            .bind(&file.plugin)
2400            .bind(&file.id)
2401            .bind(&applied_at)
2402            .bind(&snapshot_hash)
2403            .execute(pool)
2404            .await?;
2405            count += 1;
2406        }
2407    }
2408
2409    Ok(count)
2410}
2411
2412/// Postgres path for [`fake_initial_in`].
2413async fn fake_initial_postgres(dir: &Path, pool: &sqlx::PgPool) -> Result<u64, MigrateError> {
2414    ensure_tracking_table_postgres(pool).await?;
2415    let applied = applied_names_postgres(pool).await?;
2416    let mut count: u64 = 0;
2417
2418    for plugin in plugin_order() {
2419        let plugin_dir = dir.join(&plugin);
2420        let paths = list_migration_files(&plugin_dir)?;
2421
2422        let first = paths.first();
2423        let first = match first {
2424            Some(p) => p,
2425            None => continue,
2426        };
2427        let file = read_migration_file(first)?;
2428
2429        if applied.contains(&(file.plugin.clone(), file.id.clone())) {
2430            continue;
2431        }
2432
2433        let tables_to_create: Vec<&str> = file
2434            .operations
2435            .iter()
2436            .filter_map(|op| match op {
2437                Operation::CreateTable { table, .. } => Some(table.as_str()),
2438                _ => None,
2439            })
2440            .collect();
2441
2442        if tables_to_create.is_empty() {
2443            continue;
2444        }
2445
2446        let mut all_present = true;
2447        for table in &tables_to_create {
2448            let exists: Option<(String,)> = sqlx::query_as(
2449                "SELECT table_name FROM information_schema.tables \
2450                 WHERE table_schema = 'public' AND table_name = $1",
2451            )
2452            .bind(*table)
2453            .fetch_optional(pool)
2454            .await?;
2455            if exists.is_none() {
2456                all_present = false;
2457                break;
2458            }
2459        }
2460
2461        if all_present {
2462            let snapshot_hash = file.snapshot_after.hash();
2463            let applied_at = chrono::Utc::now().to_rfc3339();
2464            sqlx::query(
2465                "INSERT INTO umbral_migrations \
2466                 (plugin, name, applied_at, snapshot_hash) VALUES ($1, $2, $3, $4) \
2467                 ON CONFLICT (plugin, name) DO NOTHING",
2468            )
2469            .bind(&file.plugin)
2470            .bind(&file.id)
2471            .bind(&applied_at)
2472            .bind(&snapshot_hash)
2473            .execute(pool)
2474            .await?;
2475            count += 1;
2476        }
2477    }
2478
2479    Ok(count)
2480}
2481
2482/// Print the per-migration state, applied or pending. Output goes to
2483/// stdout; the return value is the count of pending migrations so a
2484/// CLI can `exit(n)` on need.
2485pub async fn show() -> Result<u64, MigrateError> {
2486    show_in(Path::new(MIGRATIONS_DIR)).await
2487}
2488
2489/// Same as [`show`] but takes an explicit base directory. Walks every
2490/// registered plugin in sorted-by-name order, printing one section per
2491/// plugin that owns at least one migration file; empty plugins are
2492/// skipped silently rather than emitting a bare header.
2493///
2494/// Four-state output (gap 24):
2495///
2496/// - `[X]` applied and file present on disk (normal)
2497/// - `[ ]` pending (on disk, not yet applied, sequence after last applied)
2498/// - `[!]` applied but missing on disk (drift — tracking table ahead of VCS)
2499/// - `[?]` on disk but out of order (sequence before last applied, not in DB)
2500pub async fn show_in(dir: &Path) -> Result<u64, MigrateError> {
2501    let applied = match crate::db::pool_dispatched() {
2502        crate::db::DbPool::Sqlite(pool) => {
2503            ensure_tracking_table_sqlite(pool).await?;
2504            applied_names_sqlite(pool).await?
2505        }
2506        crate::db::DbPool::Postgres(pool) => {
2507            ensure_tracking_table_postgres(pool).await?;
2508            applied_names_postgres(pool).await?
2509        }
2510    };
2511
2512    let report = detect_all_drift(&applied, dir)?;
2513
2514    // Group by plugin for display.
2515    let mut by_plugin: std::collections::BTreeMap<&str, Vec<&MigrationEntry>> =
2516        std::collections::BTreeMap::new();
2517    for entry in &report.entries {
2518        by_plugin
2519            .entry(entry.plugin.as_str())
2520            .or_default()
2521            .push(entry);
2522    }
2523
2524    let mut pending: u64 = 0;
2525    for (plugin, entries) in &by_plugin {
2526        if entries.is_empty() {
2527            continue;
2528        }
2529        println!("# plugin: {plugin}");
2530        for entry in entries {
2531            let marker = match entry.status {
2532                MigrationStatus::Applied => "[X]",
2533                MigrationStatus::Pending => {
2534                    pending += 1;
2535                    "[ ]"
2536                }
2537                MigrationStatus::AppliedButMissing => "[!]",
2538                MigrationStatus::OutOfOrder => "[?]",
2539            };
2540            println!("{marker} {}/{}", entry.plugin, entry.name);
2541        }
2542    }
2543    Ok(pending)
2544}
2545
2546/// Safety classification for a single pending migration operation.
2547///
2548/// Feature #65 (blue-green / zero-downtime). The `checkmigrations`
2549/// command walks every pending operation and tags it so an operator
2550/// deploying without a maintenance window can tell which changes are safe
2551/// under a rolling deploy (old and new code serving traffic at once) and
2552/// which need the expand-contract dance. This is advisory triage — the
2553/// engine still *applies* every op exactly as written; nothing here gates
2554/// `migrate`.
2555#[derive(Debug, Clone, PartialEq, Eq)]
2556pub enum OpSafety {
2557    /// Additive and backward-compatible — safe while old code still runs.
2558    Safe,
2559    /// Applies cleanly but can break still-running old code, lock a large
2560    /// table, or fail against unexpected production data. Review first.
2561    Warning(String),
2562    /// Destroys data or is irreversible; old code referencing the dropped
2563    /// surface errors immediately.
2564    Unsafe(String),
2565}
2566
2567impl OpSafety {
2568    /// The advisory reason for a `Warning` / `Unsafe`; empty for `Safe`.
2569    pub fn reason(&self) -> &str {
2570        match self {
2571            OpSafety::Safe => "",
2572            OpSafety::Warning(r) | OpSafety::Unsafe(r) => r,
2573        }
2574    }
2575
2576    /// True for the destructive / irreversible tier only.
2577    pub fn is_unsafe(&self) -> bool {
2578        matches!(self, OpSafety::Unsafe(_))
2579    }
2580
2581    /// True for the review-before-deploy tier only.
2582    pub fn is_warning(&self) -> bool {
2583        matches!(self, OpSafety::Warning(_))
2584    }
2585}
2586
2587/// One pending operation tagged with its [`OpSafety`] and the migration
2588/// that introduced it. The unit of output for `checkmigrations`.
2589#[derive(Debug, Clone)]
2590pub struct ClassifiedOp {
2591    pub plugin: String,
2592    pub migration: String,
2593    pub op: Operation,
2594    pub safety: OpSafety,
2595}
2596
2597/// Classify one operation for zero-downtime safety. Pure — no DB access,
2598/// no file reads — so it is trivially unit-testable and reused by both
2599/// the CLI report and any plugin that wants to gate its own deploys.
2600pub fn classify_operation(op: &Operation) -> OpSafety {
2601    match op {
2602        // Brand-new tables touch no existing rows and no old code reads
2603        // them yet.
2604        Operation::CreateTable { .. } | Operation::CreateM2MTable { .. } => OpSafety::Safe,
2605
2606        // Adding a column is additive — unless it's NOT NULL with no
2607        // default, in which case old code inserting a row without the
2608        // column fails. (The engine refuses such an add against a
2609        // populated SQLite table at apply time; this surfaces the same
2610        // hazard *before* the operator runs it, and for Postgres too.)
2611        Operation::AddColumn { table, column } => {
2612            if !column.nullable && column.default.is_empty() {
2613                OpSafety::Warning(format!(
2614                    "adds NOT NULL column `{}.{}` with no default — old code inserting without it will fail. Add it nullable (or with a default), backfill, then tighten",
2615                    table, column.name
2616                ))
2617            } else {
2618                OpSafety::Safe
2619            }
2620        }
2621
2622        // Destructive / irreversible: data loss the moment it runs.
2623        Operation::DropTable { table } => OpSafety::Unsafe(format!(
2624            "drops table `{table}` and every row in it — irreversible, and old code still reading it breaks. Stop using it, deploy, then drop in a later migration"
2625        )),
2626        Operation::DropM2MTable { junction_table } => OpSafety::Unsafe(format!(
2627            "drops join table `{junction_table}` and every row in it — irreversible"
2628        )),
2629        Operation::DropColumn { table, column } => OpSafety::Unsafe(format!(
2630            "drops column `{table}.{column}` and its data — old code reading it breaks. Expand-contract: stop writing it, deploy, then drop"
2631        )),
2632
2633        // Renames apply atomically in the DB but NOT atomically with a
2634        // code deploy: between the migration and the rollout, one of the
2635        // two code versions references the missing name.
2636        Operation::RenameTable { from, to } => OpSafety::Warning(format!(
2637            "renames table `{from}` → `{to}` — not atomic with a code deploy; old code references `{from}`. Expand-contract: add `{to}`, dual-write, switch, then drop `{from}`"
2638        )),
2639        Operation::RenameColumn {
2640            table, from, to, ..
2641        } => OpSafety::Warning(format!(
2642            "renames column `{table}.{from}` → `{to}` — old code references `{from}`. Expand-contract: add `{to}`, backfill, switch reads, then drop `{from}`"
2643        )),
2644
2645        // An alter can rewrite a column (table lock on large data) and a
2646        // nullable→NOT NULL tightening fails on existing NULLs.
2647        Operation::AlterColumn { table, column, .. } => OpSafety::Warning(format!(
2648            "alters column `{table}.{column}` — a type change rewrites the column (locks the table on large data) and a NOT NULL tightening fails on existing NULLs; verify against production data first"
2649        )),
2650
2651        // A hand-authored data migration runs arbitrary SQL — the
2652        // engine can't reason about its row impact, so flag it for
2653        // human review (it may rewrite or delete data, and re-running
2654        // the rollout while it's mid-flight can double-apply).
2655        Operation::RunSql { .. } => OpSafety::Warning(
2656            "runs a hand-authored data migration (raw SQL) — review its row impact, ensure it's idempotent or guarded, and verify it against production data first".to_string(),
2657        ),
2658    }
2659}
2660
2661/// Classify every operation across all pending migrations against the
2662/// ambient pool. Reads the same applied-set + on-disk diff that
2663/// `migrate` / `showmigrations` use, then loads each pending migration
2664/// file and classifies its operations in order. Powers `checkmigrations`.
2665pub async fn check_pending_safety() -> Result<Vec<ClassifiedOp>, MigrateError> {
2666    check_pending_safety_in(Path::new(MIGRATIONS_DIR)).await
2667}
2668
2669/// [`check_pending_safety`] against an explicit migrations directory.
2670/// The seam tests use to point at a fixture tree.
2671pub async fn check_pending_safety_in(dir: &Path) -> Result<Vec<ClassifiedOp>, MigrateError> {
2672    let applied = match crate::db::pool_dispatched() {
2673        crate::db::DbPool::Sqlite(pool) => {
2674            ensure_tracking_table_sqlite(pool).await?;
2675            applied_names_sqlite(pool).await?
2676        }
2677        crate::db::DbPool::Postgres(pool) => {
2678            ensure_tracking_table_postgres(pool).await?;
2679            applied_names_postgres(pool).await?
2680        }
2681    };
2682
2683    let report = detect_all_drift(&applied, dir)?;
2684
2685    let mut out: Vec<ClassifiedOp> = Vec::new();
2686    for entry in &report.entries {
2687        if entry.status != MigrationStatus::Pending {
2688            continue;
2689        }
2690        let path = dir.join(&entry.plugin).join(format!("{}.json", entry.name));
2691        let file = read_migration_file(&path)?;
2692        for op in &file.operations {
2693            out.push(ClassifiedOp {
2694                plugin: entry.plugin.clone(),
2695                migration: entry.name.clone(),
2696                op: op.clone(),
2697                safety: classify_operation(op),
2698            });
2699        }
2700    }
2701    Ok(out)
2702}
2703
2704// =========================================================================
2705// Internal helpers. Crate-private; the public surface above is the only
2706// thing the rest of umbral calls into.
2707// =========================================================================
2708
2709/// Return every `*.json` migration file in `plugin_dir`, sorted by
2710/// filename (lexical sort matches numeric order because the prefix is
2711/// zero-padded). Returns an empty vec if the directory is missing.
2712fn list_migration_files(plugin_dir: &Path) -> Result<Vec<PathBuf>, MigrateError> {
2713    if !plugin_dir.exists() {
2714        return Ok(Vec::new());
2715    }
2716    let mut paths: Vec<PathBuf> = Vec::new();
2717    for entry in std::fs::read_dir(plugin_dir)? {
2718        let entry = entry?;
2719        let path = entry.path();
2720        if path.extension().and_then(|s| s.to_str()) == Some("json") {
2721            paths.push(path);
2722        }
2723    }
2724    paths.sort();
2725    Ok(paths)
2726}
2727
2728/// Read and parse one migration file.
2729fn read_migration_file(path: &Path) -> Result<MigrationFile, MigrateError> {
2730    let text = std::fs::read_to_string(path)?;
2731    let file: MigrationFile = serde_json::from_str(&text)?;
2732    Ok(file)
2733}
2734
2735/// Diff the previous snapshot against the current one and produce the
2736/// ordered operation list.
2737///
2738/// Emits `CreateTable` / `DropTable` for whole-model changes (M5 v1),
2739/// and `AddColumn` / `DropColumn` for column-level changes on a model
2740/// that appears in both snapshots (M8 v1). A column whose name stays
2741/// the same but whose type or nullable flag changed surfaces as
2742/// [`MigrateError::UnsafeAlter`]: SQLite can't ALTER COLUMN TYPE in
2743/// place, and a nullable flip on a populated table is destructive.
2744///
2745/// Gap 30 adds two-pass rename detection. `Model::NAME` (the Rust struct
2746/// name) is the stable identity key across snapshots; the SQL table name
2747/// in `Model::TABLE` may change (e.g. via the `#[umbral(plugin = "...")]`
2748/// opt-in). The two passes are:
2749///
2750/// - **First pass — struct-name match.** If a model present in `current`
2751///   but absent from `previous` (by `Model::NAME`) has the same NAME as
2752///   a model present in `previous` but absent from `current`, the table
2753///   name changed: emit `RenameTable { from, to }` instead of DropTable +
2754///   CreateTable. A stdout message names the rename so the developer can
2755///   audit `makemigrations` output.
2756/// - **Second pass — column-shape match.** Among unpaired drops and
2757///   creates, if a drop candidate and a create candidate have bit-identical
2758///   column shapes (same column names, types, nullable, fk_target), emit
2759///   `RenameTable` and log a warning so the developer can verify the
2760///   intent. Struct names differ; the shape heuristic fills in for cases
2761///   like a wholesale model rename (Foo → Bar, identical fields).
2762/// - **No-match.** Drop and create as today.
2763///
2764/// `pub` (not `pub(crate)`) so integration tests can drive the diff
2765/// directly with hand-built snapshots. Spec 06 calls the diff the
2766/// engine's contract; exposing it lets the tests pin every scenario
2767/// without laundering snapshots through the process-wide registry.
2768pub fn diff(previous: &Snapshot, current: &Snapshot) -> Result<Vec<Operation>, MigrateError> {
2769    use std::collections::{BTreeMap, HashSet};
2770
2771    let prev_by_name: BTreeMap<&str, &ModelMeta> = previous
2772        .models
2773        .iter()
2774        .map(|m| (m.name.as_str(), m))
2775        .collect();
2776    let curr_by_name: BTreeMap<&str, &ModelMeta> = current
2777        .models
2778        .iter()
2779        .map(|m| (m.name.as_str(), m))
2780        .collect();
2781
2782    let mut ops: Vec<Operation> = Vec::new();
2783
2784    // ---- Pass 0: Walk models present in both snapshots (same NAME). ----
2785    // Same-name models with a different table produce a first-pass rename.
2786    // Same-name models with identical table+columns produce nothing.
2787    // Same-name models with column changes produce column-level ops.
2788
2789    let mut drop_candidates: Vec<&ModelMeta> = Vec::new(); // in prev, not curr
2790    let mut create_candidates: Vec<&ModelMeta> = Vec::new(); // in curr, not prev
2791
2792    // Creates and column-level diffs, in deterministic name order.
2793    for (name, curr) in &curr_by_name {
2794        match prev_by_name.get(name) {
2795            None => {
2796                // In current but not previous — might be a create or a first-pass rename.
2797                create_candidates.push(curr);
2798            }
2799            Some(prev) if prev.table != curr.table => {
2800                // Same struct name, different table name → first-pass rename.
2801                println!(
2802                    "umbral makemigrations: rename detected (struct-name match): \
2803                     table `{}` → `{}`",
2804                    prev.table, curr.table
2805                );
2806                ops.push(Operation::RenameTable {
2807                    from: prev.table.clone(),
2808                    to: curr.table.clone(),
2809                });
2810                // After the rename the columns might also have changed; diff them.
2811                let col_ops = diff_columns(name, prev, curr)?;
2812                ops.extend(col_ops);
2813            }
2814            Some(prev) if prev == curr => {}
2815            Some(prev) => {
2816                ops.extend(diff_columns(name, prev, curr)?);
2817            }
2818        }
2819    }
2820
2821    // Drops — models in prev but not curr (by NAME).
2822    for (name, prev) in &prev_by_name {
2823        if !curr_by_name.contains_key(name) {
2824            drop_candidates.push(prev);
2825        }
2826    }
2827
2828    // ---- Pass 1: Column-shape heuristic for unpaired drops + creates. ----
2829    // A sorted, canonical serialisation of (name, ty, nullable, fk_target)
2830    // is the "shape" fingerprint. Bit-identical shapes → likely a model
2831    // rename where the struct name also changed.
2832
2833    let mut paired_drop_tables: HashSet<&str> = HashSet::new();
2834    let mut paired_create_tables: HashSet<&str> = HashSet::new();
2835
2836    for create in &create_candidates {
2837        let create_shape = column_shape(&create.fields);
2838        for drop in &drop_candidates {
2839            if paired_drop_tables.contains(drop.table.as_str()) {
2840                continue;
2841            }
2842            let drop_shape = column_shape(&drop.fields);
2843            if create_shape == drop_shape {
2844                eprintln!(
2845                    "umbral makemigrations: rename detected (column-shape match): \
2846                     `{}` → `{}` — please verify this is a rename and not a coincidental \
2847                     column-shape match between two unrelated models",
2848                    drop.table, create.table
2849                );
2850                ops.push(Operation::RenameTable {
2851                    from: drop.table.clone(),
2852                    to: create.table.clone(),
2853                });
2854                paired_drop_tables.insert(drop.table.as_str());
2855                paired_create_tables.insert(create.table.as_str());
2856                break;
2857            }
2858        }
2859    }
2860
2861    // ---- Pass 2: Emit plain CreateTable for unpaired creates. ----
2862    //
2863    // Sort the create list topologically by FK dependency so that a
2864    // table referenced by another table in this batch is created first.
2865    // Without this, Postgres rejects the second CreateTable with
2866    // `relation "<target>" does not exist`. (SQLite tolerates the wrong
2867    // order when `foreign_keys=OFF`, the historical default; once
2868    // we turned foreign_keys ON in connect_sqlite, SQLite agrees with
2869    // Postgres on the order requirement.)
2870    //
2871    // Kahn's algorithm on (table → set of FK-target tables that are
2872    // ALSO in the create batch). Self-references and FK targets outside
2873    // the batch are skipped (they're either harmless or already exist
2874    // by the time this migration runs).
2875    let creates: Vec<&&ModelMeta> = create_candidates
2876        .iter()
2877        .filter(|c| !paired_create_tables.contains(c.table.as_str()))
2878        .collect();
2879    let batch_tables: HashSet<&str> = creates.iter().map(|c| c.table.as_str()).collect();
2880    let mut deps: BTreeMap<&str, HashSet<&str>> = BTreeMap::new();
2881    for create in &creates {
2882        let mut in_batch: HashSet<&str> = HashSet::new();
2883        for col in &create.fields {
2884            if let Some(target) = col.fk_target.as_deref()
2885                && target != create.table.as_str()
2886                && batch_tables.contains(target)
2887            {
2888                in_batch.insert(target);
2889            }
2890        }
2891        deps.insert(create.table.as_str(), in_batch);
2892    }
2893    // Kahn: repeatedly pop tables with no remaining deps in the batch.
2894    // BTreeMap iteration is alphabetical → ties break alphabetically,
2895    // keeping the output stable.
2896    let mut ordered: Vec<&&ModelMeta> = Vec::with_capacity(creates.len());
2897    while !deps.is_empty() {
2898        let ready: Vec<&str> = deps
2899            .iter()
2900            .filter(|(_, d)| d.is_empty())
2901            .map(|(t, _)| *t)
2902            .collect();
2903        if ready.is_empty() {
2904            // Cyclic FK or other unresolvable dep — fall through to
2905            // the original order rather than dropping models. A cycle
2906            // here means the user's schema can't be created with
2907            // plain CreateTable anyway (Postgres needs deferrable
2908            // constraints), so we surface the user-visible error at
2909            // apply time instead of silently looping.
2910            for create in &creates {
2911                if deps.contains_key(create.table.as_str()) {
2912                    ordered.push(create);
2913                }
2914            }
2915            break;
2916        }
2917        for t in &ready {
2918            if let Some(create) = creates.iter().find(|c| c.table.as_str() == *t) {
2919                ordered.push(create);
2920            }
2921            deps.remove(t);
2922        }
2923        for (_, set) in deps.iter_mut() {
2924            for t in &ready {
2925                set.remove(t);
2926            }
2927        }
2928    }
2929    for create in ordered {
2930        ops.push(Operation::CreateTable {
2931            table: create.table.clone(),
2932            columns: create.fields.clone(),
2933            unique_together: create.unique_together.clone(),
2934            indexes: create.indexes.clone(),
2935        });
2936    }
2937
2938    // ---- Pass 3: Emit plain DropTable for unpaired drops. ----
2939    for drop in &drop_candidates {
2940        if !paired_drop_tables.contains(drop.table.as_str()) {
2941            ops.push(Operation::DropTable {
2942                table: drop.table.clone(),
2943            });
2944        }
2945    }
2946
2947    // ---- Pass 4: Diff M2M relations. Closes the remaining BUG-16 gap. ----
2948    //
2949    // Treat each (parent_table, field_name) pair as a junction-table
2950    // identity. Compare the flattened set across snapshots and emit
2951    // CreateM2MTable / DropM2MTable per delta. Renames of the parent
2952    // model trip a Drop + Create on the junction; the rename-tracking
2953    // we'd need to do better is ambitious enough to defer.
2954    let prev_m2m = collect_m2m_pairs(previous);
2955    let curr_m2m = collect_m2m_pairs(current);
2956    for (key, spec) in &curr_m2m {
2957        if prev_m2m.contains_key(key) {
2958            continue;
2959        }
2960        // New M2M field on an existing or new model. Resolve the
2961        // target's PK column from the current snapshot.
2962        match build_create_m2m_op(spec, current) {
2963            Ok(op) => ops.push(op),
2964            Err(e) => return Err(e),
2965        }
2966    }
2967    for (key, spec) in &prev_m2m {
2968        if curr_m2m.contains_key(key) {
2969            continue;
2970        }
2971        // M2M field removed (or its parent was dropped). The junction
2972        // table goes away.
2973        ops.push(Operation::DropM2MTable {
2974            junction_table: spec.junction_table.clone(),
2975        });
2976    }
2977
2978    Ok(ops)
2979}
2980
2981/// A flat-resolved M2M descriptor used by [`diff`] to compare snapshots.
2982/// Owns its strings so it can be keyed in a map without lifetime
2983/// gymnastics.
2984#[derive(Debug, Clone)]
2985struct M2MPair {
2986    parent_table: String,
2987    parent_pk: String,
2988    field_name: String,
2989    target_table: String,
2990    junction_table: String,
2991}
2992
2993/// Walk a snapshot and produce one [`M2MPair`] per declared M2M field.
2994/// Keyed on `(parent_table, field_name)` since that uniquely identifies
2995/// a junction table — two models can't share the same parent_table, and
2996/// one model can't declare two M2M fields with the same name.
2997fn collect_m2m_pairs(snap: &Snapshot) -> std::collections::BTreeMap<(String, String), M2MPair> {
2998    let mut out = std::collections::BTreeMap::new();
2999    for model in &snap.models {
3000        let parent_pk = model
3001            .fields
3002            .iter()
3003            .find(|c| c.primary_key)
3004            .map(|c| c.name.clone())
3005            .unwrap_or_else(|| "id".to_string());
3006        for rel in &model.m2m_relations {
3007            let key = (model.table.clone(), rel.field_name.clone());
3008            out.insert(
3009                key,
3010                M2MPair {
3011                    parent_table: model.table.clone(),
3012                    parent_pk: parent_pk.clone(),
3013                    field_name: rel.field_name.clone(),
3014                    target_table: rel.target_table.clone(),
3015                    junction_table: format!("{}_{}", model.table, rel.field_name),
3016                },
3017            );
3018        }
3019    }
3020    out
3021}
3022
3023/// Lift an [`M2MPair`] into a fully-specified [`Operation::CreateM2MTable`].
3024/// The target table's PK column name is resolved from `current` (the
3025/// snapshot the diff is computing toward) — without it the DDL would
3026/// reference a column the child table doesn't have.
3027fn build_create_m2m_op(spec: &M2MPair, current: &Snapshot) -> Result<Operation, MigrateError> {
3028    // Resolve the target's PK from the current snapshot, FALLING BACK to the
3029    // global model registry. Migrations are generated per-plugin, so a
3030    // CROSS-PLUGIN M2M (parent owned by app A, target model owned by app B —
3031    // e.g. a tenant model with an M2M to a SHARED lookup table, or any app's
3032    // M2M to `umbral-auth`'s `User`) has its target in a *different* plugin's
3033    // snapshot, absent from `current`. The global registry sees every
3034    // registered model, so the junction DDL resolves the child PK no matter
3035    // which plugin owns the target. (Cross-plugin FK ordering already lets the
3036    // junction migration run after the target table's own migration.)
3037    let pk_col_and_ty = |m: &ModelMeta| -> (String, crate::orm::SqlType) {
3038        let pk = m.fields.iter().find(|c| c.primary_key);
3039        (
3040            pk.map(|c| c.name.clone()).unwrap_or_else(|| "id".to_string()),
3041            pk.map(|c| c.ty).unwrap_or(crate::orm::SqlType::BigInt),
3042        )
3043    };
3044    let (child_pk_col, child_ty) = current
3045        .models
3046        .iter()
3047        .find(|m| m.table == spec.target_table)
3048        .map(|m| pk_col_and_ty(m))
3049        .or_else(|| {
3050            // Non-panicking global lookup. `registered_models()` panics if the
3051            // registry isn't initialised (unit tests that call `diff` directly,
3052            // with no `App::build`); a `None` registry simply yields no global
3053            // fallback, so a TRULY-unregistered target is still rejected below.
3054            REGISTRY.get().and_then(|reg| {
3055                reg.iter()
3056                    .find(|(_, m)| m.table == spec.target_table)
3057                    .map(|(_, m)| pk_col_and_ty(m))
3058            })
3059        })
3060        .ok_or_else(|| {
3061            MigrateError::UnsupportedChange(format!(
3062                "M2M `{}.{}` targets table `{}` which is not registered \
3063                 anywhere — register the target model via \
3064                 `AppBuilder::model::<{}>()` or its owning plugin.",
3065                spec.parent_table, spec.field_name, spec.target_table, spec.target_table,
3066            ))
3067        })?;
3068    let parent_model = current
3069        .models
3070        .iter()
3071        .find(|m| m.table == spec.parent_table)
3072        .expect("parent model exists in snapshot — collect_m2m_pairs iterated it");
3073    let parent_ty = parent_model
3074        .fields
3075        .iter()
3076        .find(|c| c.primary_key)
3077        .map(|c| c.ty)
3078        .unwrap_or(crate::orm::SqlType::BigInt);
3079    Ok(Operation::CreateM2MTable {
3080        junction_table: spec.junction_table.clone(),
3081        parent_table: spec.parent_table.clone(),
3082        parent_col: spec.parent_pk.clone(),
3083        child_table: spec.target_table.clone(),
3084        child_col: child_pk_col,
3085        parent_ty,
3086        child_ty,
3087    })
3088}
3089
3090/// Compute a canonical, sorted column-shape fingerprint for rename
3091/// heuristic detection in `diff`. Two models whose column fingerprints
3092/// are identical are candidates for a rename (second-pass detection).
3093///
3094/// The fingerprint is a sorted `Vec` of `(name, ty, nullable, fk_target)`
3095/// tuples. Sorting by name ensures the fingerprint is independent of
3096/// declaration order.
3097fn column_shape(fields: &[Column]) -> Vec<(String, SqlType, bool, Option<String>)> {
3098    let mut shape: Vec<(String, SqlType, bool, Option<String>)> = fields
3099        .iter()
3100        .map(|c| (c.name.clone(), c.ty, c.nullable, c.fk_target.clone()))
3101        .collect();
3102    shape.sort_by(|a, b| a.0.cmp(&b.0));
3103    shape
3104}
3105
3106/// Type changes the migration engine can apply without user
3107/// intervention. The contract: every entry in this whitelist must be
3108/// data-preserving on both backends.
3109///
3110/// SQLite handles every entry trivially via the table-recreation
3111/// dance: its dynamic typing means whatever lives in a column today
3112/// reads back fine under a new column type affinity. Postgres needs
3113/// `ALTER COLUMN ... TYPE new_type USING column::new_type`, which the
3114/// renderer emits when this returns `true`.
3115///
3116/// What's *not* here is deliberate:
3117/// - `Text -> BigInt` / numeric parses can fail at runtime on non-
3118///   numeric rows. Force the user to write the migration so they own
3119///   the validation.
3120/// - Bigger int -> smaller int truncates silently.
3121/// - `Text -> Date` / `Text -> Uuid` are format-dependent.
3122/// - Anything -> JSON. Even if existing rows are JSON-shaped, that's
3123///   the user's invariant to assert.
3124fn is_safe_cast(from: SqlType, to: SqlType) -> bool {
3125    use SqlType::*;
3126    if from == to {
3127        return true;
3128    }
3129    match (from, to) {
3130        // Stringify: every scalar serialises to text losslessly. Read-
3131        // path code that wants the typed value parses it back; the
3132        // cast itself never fails.
3133        (
3134            SmallInt | Integer | BigInt | Real | Double | Boolean | Date | Time | Timestamptz
3135            | Uuid | Inet | Cidr | MacAddr | ForeignKey,
3136            Text,
3137        ) => true,
3138        // Integer widening — no data loss.
3139        (SmallInt, Integer | BigInt) => true,
3140        (Integer, BigInt) => true,
3141        // Float widening.
3142        (Real, Double) => true,
3143        // ForeignKey is stored as BigInt under the hood, so the two
3144        // directions are storage-identical. The Rust-side type is
3145        // different but the bytes on disk are not.
3146        (ForeignKey, BigInt) => true,
3147        (BigInt, ForeignKey) => true,
3148        _ => false,
3149    }
3150}
3151
3152/// Postgres type name for an `ALTER COLUMN ... TYPE <name> USING …`
3153/// clause. Matches what sea-query's `PostgresQueryBuilder` emits for
3154/// the same `SqlType` inside a `CREATE TABLE`, so the resulting
3155/// schema after the alter is identical to a freshly created table.
3156fn postgres_type_name(ty: SqlType) -> &'static str {
3157    use SqlType::*;
3158    match ty {
3159        SmallInt => "smallint",
3160        Integer => "integer",
3161        BigInt | ForeignKey => "bigint",
3162        Real => "real",
3163        Double => "double precision",
3164        Boolean => "boolean",
3165        Text => "text",
3166        Date => "date",
3167        Time => "time",
3168        // sea-query's Postgres builder emits `timestamp with time zone`
3169        // for the equivalent column type; both spellings are accepted
3170        // by Postgres, but mirroring the builder keeps the surface
3171        // consistent if a test ever round-trips DDL.
3172        Timestamptz => "timestamp with time zone",
3173        Uuid => "uuid",
3174        Json => "jsonb",
3175        Inet => "inet",
3176        Cidr => "cidr",
3177        MacAddr => "macaddr",
3178        // gaps2 #70: text-backed Postgres types. `bit varying` mirrors
3179        // what sea-query's builder emits for the CREATE TABLE path.
3180        Xml => "xml",
3181        Ltree => "ltree",
3182        Bit => "bit varying",
3183        FullText => "tsvector",
3184        Bytes => "bytea",
3185        // BUG-10: NUMERIC(19, 4) — same dimensions as the CREATE TABLE
3186        // build path. Used by the `ALTER COLUMN ... TYPE ...` render
3187        // when the safe-cast diff allows transitioning to/from
3188        // Decimal.
3189        Decimal => "numeric(19, 4)",
3190        // Arrays render as `<inner>[]` in Postgres. The migration
3191        // engine doesn't model nested element types deeply enough to
3192        // emit a precise inner type here at v1; fall back to `text[]`
3193        // and rely on the column-def renderer for the real shape when
3194        // recreating the column.
3195        Array(_) => "text[]",
3196    }
3197}
3198
3199/// Per-model column diff. Same-name columns whose type or nullable
3200/// flag changed return `UnsafeAlter` (no `AlterColumn` until M8 v1.1
3201/// covers the table-recreation dance for SQLite plus native ALTER for
3202/// Postgres). New-named columns emit `AddColumn`; missing-name columns
3203/// emit `DropColumn`. The ordering is: drops first, then adds, so a
3204/// rename-as-drop+add doesn't violate a uniqueness constraint mid-
3205/// migration on a single-row table.
3206fn diff_columns(
3207    model: &str,
3208    previous: &ModelMeta,
3209    current: &ModelMeta,
3210) -> Result<Vec<Operation>, MigrateError> {
3211    use std::collections::BTreeMap;
3212
3213    let prev_cols: BTreeMap<&str, &Column> = previous
3214        .fields
3215        .iter()
3216        .map(|c| (c.name.as_str(), c))
3217        .collect();
3218    let curr_cols: BTreeMap<&str, &Column> = current
3219        .fields
3220        .iter()
3221        .map(|c| (c.name.as_str(), c))
3222        .collect();
3223
3224    // Walk the intersection by name. Two questions per shared column:
3225    //   - did the type change? If so, is the change in the safe-cast
3226    //     whitelist (e.g. BigInt -> Text, SmallInt -> Integer)? Safe
3227    //     casts emit AlterColumn; unsafe ones still UnsafeAlter so the
3228    //     user is forced to write the data-preserving migration by
3229    //     hand.
3230    //   - did the nullable flag flip? AlterColumn either way.
3231    // Primary-key changes still UnsafeAlter (a PK rebuild is its own
3232    // dance and isn't shipped yet).
3233    let mut alter_columns: Vec<&str> = Vec::new();
3234    for (name, prev_col) in &prev_cols {
3235        if let Some(curr_col) = curr_cols.get(name) {
3236            if prev_col.primary_key != curr_col.primary_key {
3237                return Err(MigrateError::UnsafeAlter {
3238                    model: model.to_string(),
3239                    column: (*name).to_string(),
3240                    reason: "primary-key flips need a manual data-preserving migration".to_string(),
3241                });
3242            }
3243            let type_changed = prev_col.ty != curr_col.ty;
3244            if type_changed && !is_safe_cast(prev_col.ty, curr_col.ty) {
3245                return Err(MigrateError::UnsafeAlter {
3246                    model: model.to_string(),
3247                    column: (*name).to_string(),
3248                    reason: format!(
3249                        "type change {prev_ty:?} -> {curr_ty:?} is not in the safe-cast whitelist — write a data-preserving migration by hand",
3250                        prev_ty = prev_col.ty,
3251                        curr_ty = curr_col.ty,
3252                    ),
3253                });
3254            }
3255            if prev_col.nullable && !curr_col.nullable && curr_col.default.is_empty() {
3256                return Err(MigrateError::UnsafeAlter {
3257                    model: model.to_string(),
3258                    column: (*name).to_string(),
3259                    reason: "nullable → NOT NULL requires a default/backfill before tightening; otherwise existing NULL rows abort the migration".to_string(),
3260                });
3261            }
3262            if !prev_col.unique && curr_col.unique {
3263                return Err(MigrateError::UnsafeAlter {
3264                    model: model.to_string(),
3265                    column: (*name).to_string(),
3266                    reason: "adding UNIQUE to an existing column requires a duplicate pre-check/backfill migration; otherwise existing duplicate values abort the migration".to_string(),
3267                });
3268            }
3269            // Any schema-meaningful field change triggers AlterColumn.
3270            // UI-only flags (`noform`, `noedit`, `max_length`,
3271            // `is_string_repr`, `is_multichoice`) are intentionally
3272            // excluded — they affect admin / OpenAPI rendering but
3273            // not the database schema, so emitting an ALTER would do
3274            // no DB work. The snapshot still updates because the next
3275            // CreateTable in the migration stream carries the flag.
3276            if type_changed
3277                || prev_col.nullable != curr_col.nullable
3278                || prev_col.fk_target != curr_col.fk_target
3279                || prev_col.unique != curr_col.unique
3280                || prev_col.default != curr_col.default
3281                || prev_col.choices != curr_col.choices
3282                || prev_col.choice_labels != curr_col.choice_labels
3283                || prev_col.on_delete != curr_col.on_delete
3284                || prev_col.on_update != curr_col.on_update
3285                || prev_col.index != curr_col.index
3286            {
3287                alter_columns.push(*name);
3288            }
3289        }
3290    }
3291
3292    let mut ops: Vec<Operation> = Vec::new();
3293
3294    // AlterColumn ops first, in name order. One AlterColumn per
3295    // changed column; each carries the full new schema so the render
3296    // can rebuild without further context. Multiple nullable flips on
3297    // one table generate multiple AlterColumns; the apply loop runs
3298    // them sequentially (each is a table-recreation, so back-to-back
3299    // alters drop and recreate twice; the cost is acceptable while
3300    // M5.1 ships the simple case).
3301    let new_columns: Vec<Column> = current.fields.clone();
3302    let prev_columns_snapshot: Vec<Column> = previous.fields.clone();
3303    for name in alter_columns {
3304        ops.push(Operation::AlterColumn {
3305            table: current.table.clone(),
3306            column: name.to_string(),
3307            new_columns: new_columns.clone(),
3308            prev_columns: Some(prev_columns_snapshot.clone()),
3309        });
3310    }
3311
3312    // Collect the dropped + added column names. We need both lists in
3313    // memory so the rename heuristic can pair them.
3314    let mut dropped: Vec<&Column> = Vec::new();
3315    let mut added: Vec<&Column> = Vec::new();
3316    for (name, prev_col) in &prev_cols {
3317        if !curr_cols.contains_key(name) {
3318            dropped.push(prev_col);
3319        }
3320    }
3321    for col in &current.fields {
3322        if !prev_cols.contains_key(col.name.as_str()) {
3323            added.push(col);
3324        }
3325    }
3326
3327    // Gap 88 — column rename detection. When the same diff yields
3328    // exactly one drop and one add whose column shapes (sans name)
3329    // match bit-for-bit, the most likely interpretation is a rename
3330    // rather than a coincidental drop+add of two unrelated columns.
3331    // Emit RenameColumn instead and warn the user so they can
3332    // verify. Anything more ambiguous (multiple drops or adds, or
3333    // mismatched shapes) falls back to the drop+add path so the
3334    // rename is never inferred against the user's actual intent.
3335    //
3336    // The heuristic deliberately stays conservative: some tools ask
3337    // interactively in this case; we don't have
3338    // a prompt at v1, so the conservative auto-pair is the safest
3339    // shape. Users can always override by writing the
3340    // `RenameColumn` op into the migration file by hand.
3341    let mut paired_drop: Option<&str> = None;
3342    let mut paired_add: Option<&str> = None;
3343    if dropped.len() == 1 && added.len() == 1 {
3344        let d = dropped[0];
3345        let a = added[0];
3346        if column_shape_matches(d, a) {
3347            eprintln!(
3348                "umbral makemigrations: column rename detected on `{}`: \
3349                 `{}` → `{}` — verify this is a rename and not a coincidental \
3350                 shape match; edit the migration file if it's wrong",
3351                current.table, d.name, a.name,
3352            );
3353            ops.push(Operation::RenameColumn {
3354                table: current.table.clone(),
3355                from: d.name.clone(),
3356                to: a.name.clone(),
3357                column: Some(a.clone()),
3358            });
3359            paired_drop = Some(d.name.as_str());
3360            paired_add = Some(a.name.as_str());
3361        }
3362    }
3363
3364    // Drops first so a same-position add can reuse the column slot.
3365    for col in &dropped {
3366        if Some(col.name.as_str()) == paired_drop {
3367            continue;
3368        }
3369        ops.push(Operation::DropColumn {
3370            table: current.table.clone(),
3371            column: col.name.clone(),
3372        });
3373    }
3374
3375    // Then adds, in current declaration order so the schema retains
3376    // the user-written column order even after re-runs.
3377    for col in &added {
3378        if Some(col.name.as_str()) == paired_add {
3379            continue;
3380        }
3381        // Gap 97 — refuse to add a NOT NULL column without a default
3382        // (and without `auto_now_add` / `auto_now`, which fill the
3383        // column server-side at insert). SQLite + Postgres both
3384        // reject the ADD on a non-empty table; we surface the same
3385        // failure at diff time with actionable guidance so the user
3386        // doesn't ship a migration that bricks every deploy.
3387        if !col.nullable
3388            && col.default.is_empty()
3389            && !col.auto_now_add
3390            && !col.auto_now
3391            && !col.primary_key
3392        {
3393            return Err(MigrateError::UnsafeAlter {
3394                model: model.to_string(),
3395                column: col.name.clone(),
3396                reason: format!(
3397                    "adding NOT NULL column `{}` without a default to existing \
3398                     table `{}` would fail on every populated row. Pick one: \
3399                     (a) make the field `Option<T>`, (b) add `#[umbral(default = \
3400                     \"...\")]` so the migration backfills, or (c) add \
3401                     `#[umbral(auto_now_add)]` for timestamp columns",
3402                    col.name, current.table,
3403                ),
3404            });
3405        }
3406        ops.push(Operation::AddColumn {
3407            table: current.table.clone(),
3408            column: (*col).clone(),
3409        });
3410    }
3411
3412    Ok(ops)
3413}
3414
3415/// Gap 88 helper: compare two column snapshots for shape identity (every
3416/// schema-meaningful attribute except `name`). Used by the rename-
3417/// detection heuristic — bit-identical attrs are the signal that a
3418/// dropped column matches an added column and the diff is actually a
3419/// rename. Excludes UI-only flags (`noform`, `noedit`, `max_length`,
3420/// `is_string_repr`, `help`, `example`, `slug_from`) for the same
3421/// reason the AlterColumn diff excludes them: they have no DB effect.
3422fn column_shape_matches(a: &Column, b: &Column) -> bool {
3423    a.ty == b.ty
3424        && a.primary_key == b.primary_key
3425        && a.nullable == b.nullable
3426        && a.fk_target == b.fk_target
3427        && a.choices == b.choices
3428        && a.choice_labels == b.choice_labels
3429        && a.default == b.default
3430        && a.is_multichoice == b.is_multichoice
3431        && a.unique == b.unique
3432        && a.on_delete == b.on_delete
3433        && a.on_update == b.on_update
3434        && a.index == b.index
3435        && a.auto_now_add == b.auto_now_add
3436        && a.auto_now == b.auto_now
3437        && a.min == b.min
3438        && a.max == b.max
3439        && a.text_format == b.text_format
3440}
3441
3442/// Pick the suffix used in a migration filename. Single-op migrations
3443/// get a descriptive suffix; multi-op migrations fall back to `auto`.
3444fn suffix_for(ops: &[Operation]) -> String {
3445    match ops {
3446        [Operation::CreateTable { table, .. }] => format!("create_{table}"),
3447        [Operation::DropTable { table }] => format!("drop_{table}"),
3448        [Operation::AddColumn { table, column }] => format!("add_{}_{}", table, column.name),
3449        [Operation::DropColumn { table, column }] => format!("drop_{table}_{column}"),
3450        [Operation::AlterColumn { table, column, .. }] => format!("alter_{table}_{column}"),
3451        [Operation::RenameTable { from, to }] => format!("rename_{from}_to_{to}"),
3452        [
3453            Operation::RenameColumn {
3454                table, from, to, ..
3455            },
3456        ] => format!("rename_{table}_{from}_to_{to}"),
3457        [Operation::RunSql { .. }] => "run_sql".to_string(),
3458        _ => "auto".to_string(),
3459    }
3460}
3461
3462/// Create the tracking table if it isn't there already. The DDL is
3463/// dialect-neutral (TEXT + composite PK is valid SQL on both shipped
3464/// backends), but the executor type isn't — sqlx::query is generic
3465/// over the database, so each backend gets its own thin wrapper.
3466///
3467/// Kept inline because this table is a chicken-and-egg case: every
3468/// other migration needs the tracking row written, so the table
3469/// itself can't be a migration.
3470async fn ensure_tracking_table_sqlite(pool: &sqlx::SqlitePool) -> Result<(), MigrateError> {
3471    sqlx::query(
3472        "CREATE TABLE IF NOT EXISTS umbral_migrations (
3473            plugin TEXT NOT NULL,
3474            name TEXT NOT NULL,
3475            applied_at TEXT NOT NULL,
3476            snapshot_hash TEXT NOT NULL,
3477            PRIMARY KEY (plugin, name)
3478        )",
3479    )
3480    .execute(pool)
3481    .await?;
3482    Ok(())
3483}
3484
3485/// Postgres counterpart to [`ensure_tracking_table_sqlite`].
3486async fn ensure_tracking_table_postgres(pool: &sqlx::PgPool) -> Result<(), MigrateError> {
3487    sqlx::query(
3488        "CREATE TABLE IF NOT EXISTS umbral_migrations (
3489            plugin TEXT NOT NULL,
3490            name TEXT NOT NULL,
3491            applied_at TEXT NOT NULL,
3492            snapshot_hash TEXT NOT NULL,
3493            PRIMARY KEY (plugin, name)
3494        )",
3495    )
3496    .execute(pool)
3497    .await?;
3498    Ok(())
3499}
3500
3501/// Pull the set of `(plugin, name)` tuples already recorded in the
3502/// tracking table (SQLite).
3503async fn applied_names_sqlite(
3504    pool: &sqlx::SqlitePool,
3505) -> Result<std::collections::HashSet<(String, String)>, MigrateError> {
3506    let rows: Vec<(String, String)> = sqlx::query_as("SELECT plugin, name FROM umbral_migrations")
3507        .fetch_all(pool)
3508        .await?;
3509    Ok(rows.into_iter().collect())
3510}
3511
3512/// Postgres counterpart to [`applied_names_sqlite`].
3513async fn applied_names_postgres(
3514    pool: &sqlx::PgPool,
3515) -> Result<std::collections::HashSet<(String, String)>, MigrateError> {
3516    let rows: Vec<(String, String)> = sqlx::query_as("SELECT plugin, name FROM umbral_migrations")
3517        .fetch_all(pool)
3518        .await?;
3519    Ok(rows.into_iter().collect())
3520}
3521
3522/// Render one operation to a list of SQL statements via sea-query.
3523///
3524/// Dispatches on the ambient backend's [`crate::backend::active`]
3525/// name; SQLite and Postgres are the two shipped dialects. Most ops
3526/// produce one statement; `AlterColumn` produces either the SQLite
3527/// table-recreation dance (`CREATE _umbral_new` + `INSERT ... SELECT`
3528/// + `DROP` + `RENAME`) or a single native `ALTER TABLE ... ALTER
3529/// COLUMN ... SET/DROP NOT NULL` on Postgres.
3530///
3531/// The apply loop in `run_in` executes each statement in order inside
3532/// the same transaction.
3533///
3534/// `AddColumn` ignores the `primary_key` flag: neither SQLite nor
3535/// Postgres lets a primary key be added to an existing table without
3536/// a table-recreation step, and the autodetector won't route a
3537/// pk-flagged column through `AddColumn` anyway. A hand-edited
3538/// migration that sets the flag is taken to mean "the user is taking
3539/// responsibility".
3540fn render_operation(op: &Operation) -> Vec<String> {
3541    render_operation_for(op, crate::backend::active().name())
3542}
3543
3544fn should_emit_btree_index(col: &Column) -> bool {
3545    !col.primary_key
3546        && !col.unique
3547        && (col.index || matches!(col.ty, SqlType::ForeignKey) || col.name == "deleted_at")
3548}
3549
3550/// Render one operation against an explicit backend name. The
3551/// dispatching seam — the public [`render_operation`] is just
3552/// `render_operation_for(op, backend::active().name())`. Splitting
3553/// the two lets tests render Postgres DDL without installing the
3554/// process-wide ambient backend (the `OnceLock` can only be set once,
3555/// so `App::build` and tests would otherwise collide).
3556///
3557/// Panics on unknown backend names; only `"sqlite"` and `"postgres"`
3558/// are shipped in Phase 2.
3559pub fn render_operation_for(op: &Operation, backend_name: &str) -> Vec<String> {
3560    match backend_name {
3561        "sqlite" => render_operation_sqlite(op),
3562        "postgres" => render_operation_postgres(op),
3563        other => panic!(
3564            "umbral::migrate: no DDL renderer for backend `{other}`; \
3565             Phase 2 ships sqlite and postgres only"
3566        ),
3567    }
3568}
3569
3570/// SQLite-dialect rendering for one operation.
3571fn render_operation_sqlite(op: &Operation) -> Vec<String> {
3572    use sea_query::{Alias, SqliteQueryBuilder, Table};
3573
3574    match op {
3575        Operation::CreateTable {
3576            table,
3577            columns,
3578            unique_together,
3579            indexes,
3580        } => {
3581            // sea-query's TableCreateStatement renders columns inline.
3582            // For composite UNIQUE constraints, we append them via
3583            // `stmt.index(Index::create().unique().col(...))` — works on
3584            // both backends and uses sea-query's quoting.
3585            let mut stmt = Table::create();
3586            stmt.table(Alias::new(table));
3587            for col in columns {
3588                let mut def = build_column_def_sqlite(col);
3589                stmt.col(&mut def);
3590            }
3591            for group in unique_together {
3592                let mut idx = sea_query::Index::create().unique().to_owned();
3593                for col in group {
3594                    idx.col(Alias::new(col));
3595                }
3596                stmt.index(&mut idx);
3597            }
3598            let mut stmts = vec![stmt.build(SqliteQueryBuilder)];
3599            // Single-column explicit indexes plus ORM-required helper
3600            // indexes follow the CREATE TABLE. FK columns need indexes
3601            // for reverse/select-related queries, and soft-delete
3602            // models read through `deleted_at IS NULL` by default.
3603            for col in columns {
3604                if should_emit_btree_index(col) {
3605                    stmts.push(create_index_stmt(table, &col.name));
3606                }
3607            }
3608            // BUG-7: multi-column indexes follow as plain CREATE INDEX.
3609            for group in indexes {
3610                stmts.push(create_multi_index_stmt(table, group));
3611            }
3612            stmts
3613        }
3614        Operation::DropTable { table } => vec![
3615            Table::drop()
3616                .table(Alias::new(table))
3617                .build(SqliteQueryBuilder),
3618        ],
3619        Operation::AddColumn { table, column } => {
3620            // SQLite-specific limitation: `ALTER TABLE ADD COLUMN`
3621            // requires a CONSTANT default. `CURRENT_TIMESTAMP` is
3622            // non-constant ("Cannot add a column with non-constant
3623            // default"). So when we're adding a NOT NULL auto_now /
3624            // auto_now_add column on top of an existing table, we
3625            // emit a two-statement sequence:
3626            //   1. ADD COLUMN as NULLABLE (no default needed).
3627            //   2. UPDATE every existing row to `datetime('now')`.
3628            // The column ends up NULL-permitting at the DB level on
3629            // SQLite — but the Rust type stays `DateTime<Utc>` (not
3630            // Option), and every INSERT through the ORM supplies a
3631            // value via the macro-emitted auto_now arm. The DB-side
3632            // NOT NULL guarantee is lost only for direct-SQL writers,
3633            // which umbral already discourages (see CLAUDE.md "Plugins
3634            // use the ORM"). Postgres has no such restriction —
3635            // `DEFAULT now()` works there in ALTER, no backfill
3636            // statement needed (see the Postgres render below).
3637            let needs_backfill = (column.auto_now || column.auto_now_add)
3638                && !column.nullable
3639                && matches!(
3640                    column.ty,
3641                    SqlType::Timestamptz | SqlType::Date | SqlType::Time
3642                );
3643
3644            let mut stmts = if needs_backfill {
3645                let mut nullable_col = column.clone();
3646                nullable_col.nullable = true;
3647                let mut stmt = Table::alter();
3648                stmt.table(Alias::new(table));
3649                let mut def = build_column_def_sqlite(&nullable_col);
3650                stmt.add_column(&mut def);
3651                let add_sql = stmt.build(SqliteQueryBuilder);
3652
3653                // Manual UPDATE — sea-query's update builder is
3654                // overkill for a single SET col = datetime('now').
3655                let table_quoted = table.replace('"', "\"\"");
3656                let col_quoted = column.name.replace('"', "\"\"");
3657                let backfill_sql = format!(
3658                    "UPDATE \"{table_quoted}\" SET \"{col_quoted}\" = datetime('now') \
3659                     WHERE \"{col_quoted}\" IS NULL"
3660                );
3661                vec![add_sql, backfill_sql]
3662            } else {
3663                let mut stmt = Table::alter();
3664                stmt.table(Alias::new(table));
3665                let mut def = build_column_def_sqlite(column);
3666                stmt.add_column(&mut def);
3667                vec![stmt.build(SqliteQueryBuilder)]
3668            };
3669            if should_emit_btree_index(column) {
3670                stmts.push(create_index_stmt(table, &column.name));
3671            }
3672            stmts
3673        }
3674        Operation::DropColumn { table, column } => vec![
3675            Table::alter()
3676                .table(Alias::new(table))
3677                .drop_column(Alias::new(column))
3678                .build(SqliteQueryBuilder),
3679        ],
3680        Operation::AlterColumn {
3681            table,
3682            column: _,
3683            new_columns,
3684            prev_columns: _,
3685        } => render_alter_column_dance_sqlite(table, new_columns),
3686        Operation::CreateM2MTable {
3687            junction_table,
3688            parent_table,
3689            parent_col,
3690            child_table,
3691            child_col,
3692            parent_ty,
3693            child_ty,
3694        } => {
3695            // Junction table for many-to-many: two FK columns + composite PK.
3696            // Column types follow the referenced PKs — `BigInt` → `INTEGER`
3697            // (SQLite affinity), `Text` → `TEXT`, `Uuid` → `TEXT` on SQLite
3698            // / `UUID` on Postgres. Raw DDL is the simplest expression of
3699            // the composite-PK + per-side cascade FK shape; sea-query's
3700            // builder can't express it cleanly in one call.
3701            vec![format!(
3702                r#"CREATE TABLE "{jt}" (
3703    "parent_id" {pty} NOT NULL REFERENCES "{pt}"("{pc}") ON DELETE CASCADE,
3704    "child_id" {cty} NOT NULL REFERENCES "{ct}"("{cc}") ON DELETE CASCADE,
3705    PRIMARY KEY ("parent_id", "child_id")
3706)"#,
3707                jt = junction_table,
3708                pt = parent_table,
3709                pc = parent_col,
3710                ct = child_table,
3711                cc = child_col,
3712                pty = m2m_pk_sql_type_sqlite(*parent_ty),
3713                cty = m2m_pk_sql_type_sqlite(*child_ty),
3714            )]
3715        }
3716        Operation::DropM2MTable { junction_table } => vec![
3717            Table::drop()
3718                .table(Alias::new(junction_table))
3719                .build(SqliteQueryBuilder),
3720        ],
3721        Operation::RenameTable { from, to } => {
3722            use sea_query::{Alias, SqliteQueryBuilder, Table};
3723            vec![
3724                Table::rename()
3725                    .table(Alias::new(from.as_str()), Alias::new(to.as_str()))
3726                    .build(SqliteQueryBuilder),
3727            ]
3728        }
3729        Operation::RenameColumn {
3730            table, from, to, ..
3731        } => {
3732            // SQLite 3.25+ supports `ALTER TABLE ... RENAME COLUMN`
3733            // natively. Quote both sides to allow names that need
3734            // escaping; sea-query's column-rename builder isn't
3735            // exposed cleanly so we render the DDL string directly.
3736            let t = table.replace('"', "\"\"");
3737            let f = from.replace('"', "\"\"");
3738            let tn = to.replace('"', "\"\"");
3739            vec![format!(
3740                "ALTER TABLE \"{t}\" RENAME COLUMN \"{f}\" TO \"{tn}\""
3741            )]
3742        }
3743        // A data migration renders to its raw forward SQL verbatim —
3744        // the author owns portability across backends.
3745        Operation::RunSql { sql, .. } => vec![sql.clone()],
3746    }
3747}
3748
3749/// Postgres-dialect rendering for one operation.
3750///
3751/// Postgres has native `ALTER COLUMN` so `AlterColumn` doesn't need
3752/// the SQLite table-recreation dance; it lowers to a single statement.
3753/// Integer primary keys use sea-query's `auto_increment()` flag, which
3754/// the Postgres query builder lowers to `BIGSERIAL` / `SERIAL` rather
3755/// than SQLite's `INTEGER PRIMARY KEY AUTOINCREMENT` quirk.
3756fn render_operation_postgres(op: &Operation) -> Vec<String> {
3757    use sea_query::{Alias, PostgresQueryBuilder, Table};
3758
3759    match op {
3760        Operation::CreateTable {
3761            table,
3762            columns,
3763            unique_together,
3764            indexes,
3765        } => {
3766            let mut stmt = Table::create();
3767            stmt.table(Alias::new(table));
3768            for col in columns {
3769                let mut def = build_column_def_postgres(col);
3770                stmt.col(&mut def);
3771            }
3772            for group in unique_together {
3773                let mut idx = sea_query::Index::create().unique().to_owned();
3774                for col in group {
3775                    idx.col(Alias::new(col));
3776                }
3777                stmt.index(&mut idx);
3778            }
3779            let mut stmts = vec![stmt.build(PostgresQueryBuilder)];
3780            for col in columns {
3781                if matches!(col.ty, crate::orm::SqlType::FullText) {
3782                    // tsvector columns get an auto-GIN index (#33) — they're
3783                    // useless for search without one, so the engine never
3784                    // makes the caller hand-write it.
3785                    stmts.push(create_gin_index_stmt(table, &col.name));
3786                } else if should_emit_btree_index(col) {
3787                    stmts.push(create_index_stmt(table, &col.name));
3788                }
3789            }
3790            for group in indexes {
3791                stmts.push(create_multi_index_stmt(table, group));
3792            }
3793            stmts
3794        }
3795        Operation::DropTable { table } => vec![
3796            Table::drop()
3797                .table(Alias::new(table))
3798                .build(PostgresQueryBuilder),
3799        ],
3800        Operation::AddColumn { table, column } => {
3801            let mut stmt = Table::alter();
3802            stmt.table(Alias::new(table));
3803            let mut def = build_column_def_postgres(column);
3804            stmt.add_column(&mut def);
3805            let mut stmts = vec![stmt.build(PostgresQueryBuilder)];
3806            if matches!(column.ty, crate::orm::SqlType::FullText) {
3807                // Auto-GIN for a tsvector column added later (#33).
3808                stmts.push(create_gin_index_stmt(table, &column.name));
3809            } else if should_emit_btree_index(column) {
3810                stmts.push(create_index_stmt(table, &column.name));
3811            }
3812            stmts
3813        }
3814        Operation::DropColumn { table, column } => vec![
3815            Table::alter()
3816                .table(Alias::new(table))
3817                .drop_column(Alias::new(column))
3818                .build(PostgresQueryBuilder),
3819        ],
3820        Operation::AlterColumn {
3821            table,
3822            column,
3823            new_columns,
3824            prev_columns,
3825        } => render_alter_column_postgres(table, column, new_columns, prev_columns.as_deref()),
3826        Operation::CreateM2MTable {
3827            junction_table,
3828            parent_table,
3829            parent_col,
3830            child_table,
3831            child_col,
3832            parent_ty,
3833            child_ty,
3834        } => {
3835            vec![format!(
3836                r#"CREATE TABLE "{jt}" (
3837    "parent_id" {pty} NOT NULL REFERENCES "{pt}"("{pc}") ON DELETE CASCADE,
3838    "child_id" {cty} NOT NULL REFERENCES "{ct}"("{cc}") ON DELETE CASCADE,
3839    PRIMARY KEY ("parent_id", "child_id")
3840)"#,
3841                jt = junction_table,
3842                pt = parent_table,
3843                pc = parent_col,
3844                ct = child_table,
3845                cc = child_col,
3846                pty = m2m_pk_sql_type_postgres(*parent_ty),
3847                cty = m2m_pk_sql_type_postgres(*child_ty),
3848            )]
3849        }
3850        Operation::DropM2MTable { junction_table } => vec![
3851            Table::drop()
3852                .table(Alias::new(junction_table))
3853                .build(PostgresQueryBuilder),
3854        ],
3855        Operation::RenameTable { from, to } => {
3856            // Postgres: ALTER TABLE "<from>" RENAME TO "<to>"
3857            // sea-query's Table::rename() emits the right form.
3858            use sea_query::{Alias, PostgresQueryBuilder, Table};
3859            vec![
3860                Table::rename()
3861                    .table(Alias::new(from.as_str()), Alias::new(to.as_str()))
3862                    .build(PostgresQueryBuilder),
3863            ]
3864        }
3865        Operation::RenameColumn {
3866            table, from, to, ..
3867        } => {
3868            let t = table.replace('"', "\"\"");
3869            let f = from.replace('"', "\"\"");
3870            let tn = to.replace('"', "\"\"");
3871            vec![format!(
3872                "ALTER TABLE \"{t}\" RENAME COLUMN \"{f}\" TO \"{tn}\""
3873            )]
3874        }
3875        // A data migration renders to its raw forward SQL verbatim —
3876        // the author owns portability across backends.
3877        Operation::RunSql { sql, .. } => vec![sql.clone()],
3878    }
3879}
3880
3881/// The SQLite table-recreation dance for `AlterColumn`. SQLite has no
3882/// in-place `ALTER COLUMN`, so the only safe way to flip a column's
3883/// nullable flag is to rebuild the table:
3884///
3885/// 1. `CREATE TABLE _umbral_new_<table>` with the new schema.
3886/// 2. `INSERT ... SELECT` to copy every row from the old table.
3887/// 3. `DROP TABLE <table>`.
3888/// 4. `ALTER TABLE _umbral_new_<table> RENAME TO <table>`.
3889///
3890/// Wrapped in a transaction by the caller. Indexes, triggers, and FK
3891/// targets aren't preserved at M5.1 because umbral-core's schema model
3892/// doesn't yet carry them; once it does, this routine picks them up
3893/// by rebuilding them at step 1.
3894///
3895/// Nullable `TRUE -> FALSE` fails at step 2 if any row holds NULL,
3896/// which is the correct data-integrity behaviour. Nullable
3897/// `FALSE -> TRUE` always succeeds.
3898fn render_alter_column_dance_sqlite(table: &str, new_columns: &[Column]) -> Vec<String> {
3899    use sea_query::{Alias, SqliteQueryBuilder, Table};
3900
3901    let tmp = format!("_umbral_new_{table}");
3902
3903    // Step 1 — CREATE TABLE _umbral_new_<table>.
3904    let mut create = Table::create();
3905    create.table(Alias::new(&tmp));
3906    for col in new_columns {
3907        let mut def = build_column_def_sqlite(col);
3908        create.col(&mut def);
3909    }
3910
3911    // Step 2 — INSERT ... SELECT. Same column list both sides; the
3912    // dance only handles nullable flips (columns are otherwise
3913    // identical). Each name is double-quoted so SQLite identifier
3914    // rules don't bite on reserved words.
3915    let column_list = new_columns
3916        .iter()
3917        .map(|c| format!("\"{}\"", c.name.replace('"', "\"\"")))
3918        .collect::<Vec<_>>()
3919        .join(", ");
3920    let insert_sql =
3921        format!("INSERT INTO \"{tmp}\" ({column_list}) SELECT {column_list} FROM \"{table}\"");
3922
3923    // Step 3 — DROP TABLE <table>.
3924    let drop_sql = Table::drop()
3925        .table(Alias::new(table))
3926        .build(SqliteQueryBuilder);
3927
3928    // Step 4 — ALTER TABLE _umbral_new_<table> RENAME TO <table>.
3929    let rename_sql = Table::rename()
3930        .table(Alias::new(&tmp), Alias::new(table))
3931        .build(SqliteQueryBuilder);
3932
3933    vec![
3934        create.build(SqliteQueryBuilder),
3935        insert_sql,
3936        drop_sql,
3937        rename_sql,
3938    ]
3939}
3940
3941/// Native Postgres `AlterColumn`. Postgres supports
3942/// `ALTER TABLE x ALTER COLUMN y SET NOT NULL` and
3943/// `ALTER TABLE x ALTER COLUMN y DROP NOT NULL` in place, so the
3944/// SQLite table-recreation dance isn't needed. Lowers to a single
3945/// statement.
3946///
3947/// `SET NOT NULL` fails at the server if any row holds NULL on `y`,
3948/// matching SQLite's INSERT-time failure on the dance — the
3949/// data-integrity contract is identical between backends.
3950///
3951/// `column` is the field name that triggered the flip; `new_columns`
3952/// is the post-change schema (carried for parity with the SQLite
3953/// dance, though Postgres only needs the one column).
3954fn render_alter_column_postgres(
3955    table: &str,
3956    column: &str,
3957    new_columns: &[Column],
3958    prev_columns: Option<&[Column]>,
3959) -> Vec<String> {
3960    let new = new_columns.iter().find(|c| c.name == column).expect(
3961        "umbral::migrate: AlterColumn op references a column missing from new_columns; \
3962             this is a bug in `diff_columns`",
3963    );
3964    let prev = prev_columns.and_then(|cols| cols.iter().find(|c| c.name == column));
3965
3966    let q_table = quote_pg_ident(table);
3967    let q_column = quote_pg_ident(column);
3968
3969    let mut stmts: Vec<String> = Vec::new();
3970
3971    // TYPE change: only when we have a previous snapshot AND it differs
3972    // AND the change is in the safe-cast whitelist (diff_columns has
3973    // already gated unsafe ones). Emitted before nullable so a NOT
3974    // NULL flip against the just-cast column reads the new type.
3975    if let Some(prev_col) = prev {
3976        if prev_col.ty != new.ty && is_safe_cast(prev_col.ty, new.ty) {
3977            let new_ty_sql = postgres_type_name(new.ty);
3978            stmts.push(format!(
3979                "ALTER TABLE {q_table} ALTER COLUMN {q_column} TYPE {new_ty_sql} USING {q_column}::{new_ty_sql}"
3980            ));
3981        }
3982    }
3983
3984    // NULL-flag change: skipped when prev is None (legacy migrations
3985    // with no snapshot — preserve the old "emit unconditionally" path
3986    // because it's idempotent on Postgres). With a snapshot, only emit
3987    // when the flag actually flipped.
3988    let nullable_changed = match prev {
3989        Some(prev_col) => prev_col.nullable != new.nullable,
3990        None => true,
3991    };
3992    if nullable_changed {
3993        let clause = if new.nullable {
3994            "DROP NOT NULL"
3995        } else {
3996            "SET NOT NULL"
3997        };
3998        stmts.push(format!(
3999            "ALTER TABLE {q_table} ALTER COLUMN {q_column} {clause}"
4000        ));
4001    }
4002
4003    // From here down — all the gap #65 follow-up changes. Each branch
4004    // checks if `prev` exists (legacy migrations with no snapshot
4005    // skip these, matching the historical behaviour) and emits the
4006    // matching ALTER on real flips.
4007    if let Some(prev_col) = prev {
4008        // UNIQUE flag flip. Postgres autogen for column-level UNIQUE
4009        // at CREATE TABLE is `<table>_<col>_key`; we use the same
4010        // name when ADDing so a subsequent DROP finds it.
4011        if prev_col.unique != new.unique {
4012            let cname = format!("{table}_{column}_key");
4013            if new.unique {
4014                stmts.push(format!(
4015                    "ALTER TABLE {q_table} ADD CONSTRAINT \"{cname}\" UNIQUE ({q_column})"
4016                ));
4017            } else {
4018                stmts.push(format!(
4019                    "ALTER TABLE {q_table} DROP CONSTRAINT IF EXISTS \"{cname}\""
4020                ));
4021            }
4022        }
4023
4024        // DEFAULT change. Empty string in either snapshot means "no
4025        // default"; the canonical SET / DROP pair fully expresses
4026        // the transition.
4027        if prev_col.default != new.default {
4028            if new.default.is_empty() {
4029                stmts.push(format!(
4030                    "ALTER TABLE {q_table} ALTER COLUMN {q_column} DROP DEFAULT"
4031                ));
4032            } else {
4033                let escaped = new.default.replace('\'', "''");
4034                stmts.push(format!(
4035                    "ALTER TABLE {q_table} ALTER COLUMN {q_column} SET DEFAULT '{escaped}'"
4036                ));
4037            }
4038        }
4039
4040        // FK target / on_delete / on_update — these are all carried
4041        // on the same constraint, so any one of them flipping
4042        // requires a DROP + readd of the whole FK. Autogen name
4043        // convention `<table>_<col>_fkey` matches Postgres at CREATE
4044        // TABLE time. Only emitted when the new column is still a
4045        // FK; if the column stopped being a FK (ty changed away
4046        // from ForeignKey), the type-change branch above handles
4047        // it indirectly via the column type rewrite.
4048        let fk_changed = prev_col.fk_target != new.fk_target
4049            || prev_col.on_delete != new.on_delete
4050            || prev_col.on_update != new.on_update;
4051        if fk_changed && matches!(new.ty, SqlType::ForeignKey) {
4052            let cname = format!("{table}_{column}_fkey");
4053            stmts.push(format!(
4054                "ALTER TABLE {q_table} DROP CONSTRAINT IF EXISTS \"{cname}\""
4055            ));
4056            // gaps2 #22: only re-add the physical constraint when the FK
4057            // still wants one. A `db_constraint = false` FK keeps the
4058            // DROP (so flipping the flag tears down any prior constraint)
4059            // but emits no ADD CONSTRAINT.
4060            if let Some(target) = &new.fk_target
4061                && new.db_constraint
4062            {
4063                let q_target = quote_pg_ident(target);
4064                let on_delete_clause = new
4065                    .on_delete
4066                    .sql_keyword()
4067                    .map(|k| format!(" ON DELETE {k}"))
4068                    .unwrap_or_default();
4069                let on_update_clause = new
4070                    .on_update
4071                    .sql_keyword()
4072                    .map(|k| format!(" ON UPDATE {k}"))
4073                    .unwrap_or_default();
4074                stmts.push(format!(
4075                    "ALTER TABLE {q_table} ADD CONSTRAINT \"{cname}\" \
4076                     FOREIGN KEY ({q_column}) REFERENCES {q_target}(\"id\")\
4077                     {on_delete_clause}{on_update_clause}"
4078                ));
4079            }
4080        }
4081
4082        // CHECK constraint (single-valued choices) change. MultiChoice
4083        // uses CSV storage which can't be expressed as a column-level
4084        // IN constraint; the runtime sqlx Decode path is the guard.
4085        if prev_col.choices != new.choices && !new.is_multichoice {
4086            let cname = format!("{table}_{column}_check");
4087            stmts.push(format!(
4088                "ALTER TABLE {q_table} DROP CONSTRAINT IF EXISTS \"{cname}\""
4089            ));
4090            if !new.choices.is_empty() {
4091                let values_sql = new
4092                    .choices
4093                    .iter()
4094                    .map(|v| format!("'{}'", v.replace('\'', "''")))
4095                    .collect::<Vec<_>>()
4096                    .join(", ");
4097                stmts.push(format!(
4098                    "ALTER TABLE {q_table} ADD CONSTRAINT \"{cname}\" \
4099                     CHECK ({q_column} IN ({values_sql}))"
4100                ));
4101            }
4102        }
4103    }
4104
4105    // Defensive: if we somehow produced no statements (shouldn't
4106    // happen — diff_columns gates on at least one schema-meaningful
4107    // flag changing), fall back to a single redundant SET NULL flip
4108    // to match the legacy contract. Tests cover both branches; this
4109    // is belt-and-braces.
4110    if stmts.is_empty() {
4111        let clause = if new.nullable {
4112            "DROP NOT NULL"
4113        } else {
4114            "SET NOT NULL"
4115        };
4116        stmts.push(format!(
4117            "ALTER TABLE {q_table} ALTER COLUMN {q_column} {clause}"
4118        ));
4119    }
4120
4121    stmts
4122}
4123
4124/// Quote a SQL identifier the Postgres way: wrap in double quotes,
4125/// escape inner double quotes by doubling them. Matches sea-query's
4126/// `PostgresQueryBuilder` output for identifiers so the rendered
4127/// statements look uniform.
4128fn quote_pg_ident(ident: &str) -> String {
4129    format!("\"{}\"", ident.replace('"', "\"\""))
4130}
4131
4132/// Build a SQLite `ColumnDef`. SQLite has one important quirk: its
4133/// ROWID-alias mechanic (which gives a primary-key column auto-
4134/// increment behaviour out of the box) only fires when the column's
4135/// type is the exact text `INTEGER` — case-insensitive but no other
4136/// variant. `BIGINT PRIMARY KEY`, even on a column the M3 derive
4137/// declared as `i64`, does NOT auto-increment, so an `INSERT INTO t
4138/// (other_col) VALUES (...)` without an explicit PK value fails the
4139/// NOT NULL constraint. Every umbral user with an `id: i64` model
4140/// would hit this without the override.
4141///
4142/// The fix: when a column is a primary key with an integer SqlType
4143/// (Integer or BigInt), force the rendered type to `Integer` and
4144/// attach `auto_increment()` so the generated DDL reads `"id" integer
4145/// NOT NULL PRIMARY KEY AUTOINCREMENT`. SQLite stores both `i32` and
4146/// `i64` as INTEGER affinity anyway, so the override is a no-op
4147/// semantically — the rows that round-trip through `sqlx::FromRow`
4148/// deserialize back into `i64` cleanly.
4149///
4150/// For `SqlType::Uuid` PKs: SQLite stores UUIDs as TEXT. No
4151/// `DEFAULT gen_random_uuid()` is emitted; the application must supply
4152/// the UUID at create time (or pass `Uuid::nil()` to trigger the
4153/// omit-on-insert sentinel that leaves the column to a future default).
4154///
4155/// For `SqlType::ForeignKey` columns: rendered as `BIGINT` with a
4156/// `REFERENCES "<target>"("id")` suffix appended via `.extra()`. The
4157/// target table name comes from `col.fk_target`.
4158/// Look up the FK target model's primary-key column name and SQL
4159/// type. Walks the registered ModelMeta set to find the model whose
4160/// table matches `fk_target_table`, then picks the first column
4161/// marked `primary_key = true`. Falls back to `("id", BigInteger)`
4162/// when the target isn't registered (cross-plugin lookup miss, or
4163/// the FK points outside the framework's model registry).
4164///
4165/// Used by both the SQLite and Postgres FK column-def builders so the
4166/// generated `<col> <type> REFERENCES <tbl>(<pk_col>)` matches the
4167/// target's actual PK shape — gap #60 made non-`id`, non-i64 PKs
4168/// (e.g. `Permission.codename: String`) a real case.
4169fn fk_target_pk(fk_target_table: &str) -> (String, sea_query::ColumnType) {
4170    use sea_query::ColumnType;
4171    let unesc = fk_target_table.replace("\"\"", "\"");
4172    // Non-panicking registry read — `registered_models()` itself
4173    // panics when called outside an `App::build()` context, but the
4174    // migration engine's unit tests construct snapshots by hand and
4175    // call into DDL emit without booting the framework. Fall through
4176    // to the historical "id"/BigInteger default in that case.
4177    let Some(metas) = REGISTRY.get() else {
4178        return ("id".to_string(), ColumnType::BigInteger);
4179    };
4180    for meta in metas.iter().map(|(_, m)| m) {
4181        if meta.table != unesc {
4182            continue;
4183        }
4184        if let Some(pk) = meta.fields.iter().find(|c| c.primary_key) {
4185            // Map the PK's SqlType to a sea-query ColumnType. We can't
4186            // route through `SqliteBackend::map_column` because that
4187            // wants a `Column` and applies max_length / choices
4188            // metadata which is irrelevant to a FK column. Hand-roll
4189            // the few cases the framework supports for PKs.
4190            let ct = match pk.ty {
4191                SqlType::BigInt | SqlType::Integer => ColumnType::BigInteger,
4192                SqlType::SmallInt => ColumnType::SmallInteger,
4193                SqlType::Text => ColumnType::Text,
4194                SqlType::Uuid => ColumnType::Uuid,
4195                // Other PK types fall back to BigInteger as the
4196                // historical default. The compile-time PrimaryKey
4197                // trait keeps this list closed in practice.
4198                _ => ColumnType::BigInteger,
4199            };
4200            return (pk.name.clone(), ct);
4201        }
4202    }
4203    ("id".to_string(), ColumnType::BigInteger)
4204}
4205
4206fn build_column_def_sqlite(col: &Column) -> sea_query::ColumnDef {
4207    use sea_query::{Alias, ColumnDef, ColumnType};
4208
4209    // ForeignKey gets a special path: column type + inline REFERENCES
4210    // clause both derived from the target model's PK column.
4211    if matches!(col.ty, SqlType::ForeignKey) {
4212        let fk_target = col
4213            .fk_target
4214            .as_deref()
4215            .unwrap_or("_unknown_")
4216            .replace('"', "\"\"");
4217        let (pk_col_name, pk_col_type) = fk_target_pk(&fk_target);
4218        let mut def = ColumnDef::new_with_type(Alias::new(&col.name), pk_col_type);
4219        if !col.nullable {
4220            def.not_null();
4221        }
4222        // BUG-15: `#[umbral(unique)]` on a FK column is the
4223        // OneToOne idiom — emit UNIQUE inline so the
4224        // referencing-row uniqueness is enforced at the DB.
4225        // The FK branch used to skip this because it returned
4226        // before the non-FK unique branch ran.
4227        if col.unique {
4228            def.unique_key();
4229        }
4230        // gaps2 #22: `#[umbral(db_constraint = false)]` keeps the logical
4231        // FK (column type derived from the target PK, above) but emits
4232        // NO physical `REFERENCES` clause. This is the only valid shape
4233        // for a cross-database FK. The default (`true`) emits the
4234        // constraint as before.
4235        if col.db_constraint {
4236            def.extra(format!(
4237                "REFERENCES \"{fk_target}\"(\"{pk_col_name}\"){}",
4238                fk_action_suffix(col),
4239            ));
4240        }
4241        return def;
4242    }
4243
4244    let is_int_pk = col.primary_key && matches!(col.ty, SqlType::Integer | SqlType::BigInt);
4245
4246    let column_type = if is_int_pk {
4247        ColumnType::Integer
4248    } else {
4249        crate::backend::SqliteBackend.map_column(col)
4250    };
4251
4252    let mut def = ColumnDef::new_with_type(Alias::new(&col.name), column_type);
4253    if !col.nullable {
4254        def.not_null();
4255    }
4256    if col.primary_key {
4257        def.primary_key();
4258        if is_int_pk {
4259            def.auto_increment();
4260        }
4261    }
4262    // `#[umbral(unique)]` lifts to a column-level UNIQUE clause.
4263    // Skipped on PK columns (already unique) so the DDL stays tidy.
4264    if col.unique && !col.primary_key {
4265        def.unique_key();
4266    }
4267    // IMP-3: `#[umbral(min = N)]` / `#[umbral(max = N)]` lift to a
4268    // column-level CHECK clause. Both SQLite and Postgres accept the
4269    // same syntax. The pre-validation in `insert_json`/`update_json`
4270    // catches violations earlier with a friendlier error; the CHECK
4271    // is the DB-side safety net against direct-SQL writers.
4272    if let Some(check) = check_min_max_sql(col) {
4273        def.extra(check);
4274    }
4275    // User-declared `#[umbral(default = "...")]` lifts to a DDL DEFAULT
4276    // clause. Required when emitting `ALTER TABLE ADD COLUMN` for a
4277    // NOT NULL column against a non-empty table (SQLite rejects the
4278    // ADD otherwise); on CREATE TABLE it sets the column-level default
4279    // the database uses when an INSERT omits the value.
4280    //
4281    // SQLite stores booleans as INTEGER; the literal `'true'` /
4282    // `'false'` would land as a TEXT default that fails type checks
4283    // on reads. Translate Boolean defaults to `1` / `0` so the
4284    // stored representation matches what sqlx expects on hydration
4285    // (closes IMP-2 in bugs/tests/testBugs.md).
4286    if !col.default.is_empty() {
4287        if matches!(col.ty, SqlType::Boolean) {
4288            // Pass an integer to sea-query so the rendered SQL is
4289            // `DEFAULT 1` / `DEFAULT 0` instead of the quoted-string
4290            // `DEFAULT '1'` (which sqlx rejects as TEXT on read of
4291            // a BOOLEAN column).
4292            def.default(sqlite_bool_default(&col.default));
4293        } else {
4294            def.default(col.default.clone());
4295        }
4296    }
4297    // NOTE: auto_now / auto_now_add deliberately does NOT emit a
4298    // `DEFAULT CURRENT_TIMESTAMP` here. SQLite rejects non-constant
4299    // defaults in `ALTER TABLE ADD COLUMN` ("Cannot add a column
4300    // with non-constant default") and that's the path that matters
4301    // for evolving an existing table. The SQLite `AddColumn` render
4302    // path handles the auto_now backfill via a two-statement
4303    // sequence (nullable ADD + UPDATE backfill). On CREATE TABLE
4304    // we don't need a default at all because every INSERT goes
4305    // through the macro-emitted Rust path which always supplies the
4306    // value. See `Operation::AddColumn` render below.
4307    def
4308}
4309
4310/// Map a user-supplied boolean default string (`"true"` / `"false"`
4311/// / `"1"` / `"0"`, case-insensitive) to the SQLite integer literal
4312/// the column expects. Anything unrecognised falls through to `0`
4313/// — a developer-visible miss (default is wrong, not stored as
4314/// text) is friendlier than the runtime decode error the textual
4315/// path produces.
4316fn sqlite_bool_default(raw: &str) -> i32 {
4317    match raw.trim().to_ascii_lowercase().as_str() {
4318        "true" | "1" | "t" | "yes" => 1,
4319        _ => 0,
4320    }
4321}
4322
4323/// IMP-3: lower `#[umbral(min = N)]` / `#[umbral(max = N)]` to a
4324/// DDL CHECK clause. Returns `None` when the column declares
4325/// neither bound. The rendered SQL works on both SQLite and
4326/// Postgres (`"<col>" >= N`, `"<col>" <= N`, joined by `AND`).
4327/// Only applied to numeric columns — applying it to text would
4328/// compare strings lexicographically and surprise everyone.
4329fn check_min_max_sql(col: &Column) -> Option<String> {
4330    if col.min.is_none() && col.max.is_none() {
4331        return None;
4332    }
4333    if !matches!(
4334        col.ty,
4335        SqlType::SmallInt | SqlType::Integer | SqlType::BigInt | SqlType::Real | SqlType::Double
4336    ) {
4337        return None;
4338    }
4339    let name = col.name.replace('"', "\"\"");
4340    let mut parts = Vec::with_capacity(2);
4341    if let Some(n) = col.min {
4342        parts.push(format!("\"{name}\" >= {n}"));
4343    }
4344    if let Some(n) = col.max {
4345        parts.push(format!("\"{name}\" <= {n}"));
4346    }
4347    Some(format!("CHECK ({})", parts.join(" AND ")))
4348}
4349
4350/// Build a Postgres `ColumnDef`. Integer primary keys use the
4351/// standard `auto_increment()` flag — sea-query's `PostgresQueryBuilder`
4352/// lowers that to `BIGSERIAL` for `BigInt` and `SERIAL` for `Integer`.
4353/// No SQLite-style INTEGER-type override needed; Postgres has proper
4354/// `BIGSERIAL` / identity columns and respects the declared width.
4355///
4356/// For `SqlType::ForeignKey` columns: rendered as `BIGINT` with a
4357/// `REFERENCES "<target>"("id")` suffix. The target table name comes
4358/// from `col.fk_target`.
4359fn build_column_def_postgres(col: &Column) -> sea_query::ColumnDef {
4360    use sea_query::{Alias, ColumnDef};
4361
4362    // ForeignKey gets a special path: column type + inline REFERENCES
4363    // clause both derived from the target model's PK.
4364    if matches!(col.ty, SqlType::ForeignKey) {
4365        let fk_target = col
4366            .fk_target
4367            .as_deref()
4368            .unwrap_or("_unknown_")
4369            .replace('"', "\"\"");
4370        let (pk_col_name, pk_col_type) = fk_target_pk(&fk_target);
4371        // sea-query's ColumnType variants are dialect-agnostic; the
4372        // same value works for both SQLite and Postgres builders here.
4373        let mut def = ColumnDef::new_with_type(Alias::new(&col.name), pk_col_type);
4374        if !col.nullable {
4375            def.not_null();
4376        }
4377        // BUG-15: `#[umbral(unique)]` on a FK column is the
4378        // OneToOne idiom — emit UNIQUE inline so the
4379        // referencing-row uniqueness is enforced at the DB.
4380        // The FK branch used to skip this because it returned
4381        // before the non-FK unique branch ran.
4382        if col.unique {
4383            def.unique_key();
4384        }
4385        // gaps2 #22: skip the physical `REFERENCES` clause when the FK
4386        // opted out of the DB constraint (cross-database FK). The
4387        // logical column + `fk_target` stay intact.
4388        if col.db_constraint {
4389            def.extra(format!(
4390                "REFERENCES \"{fk_target}\"(\"{pk_col_name}\"){}",
4391                fk_action_suffix(col),
4392            ));
4393        }
4394        return def;
4395    }
4396
4397    let column_type = crate::backend::PostgresBackend.map_column(col);
4398
4399    let mut def = ColumnDef::new_with_type(Alias::new(&col.name), column_type);
4400    if !col.nullable {
4401        def.not_null();
4402    }
4403    if col.primary_key {
4404        def.primary_key();
4405        if matches!(
4406            col.ty,
4407            SqlType::Integer | SqlType::BigInt | SqlType::SmallInt
4408        ) {
4409            def.auto_increment();
4410        }
4411    }
4412    // `#[umbral(unique)]` lifts to a column-level UNIQUE clause on
4413    // Postgres too. Skipped for PK columns (already unique).
4414    if col.unique && !col.primary_key {
4415        def.unique_key();
4416    }
4417    // IMP-3: numeric bounds CHECK. Mirrors the SQLite branch.
4418    if let Some(check) = check_min_max_sql(col) {
4419        def.extra(check);
4420    }
4421    // Single-valued Choices: emit a CHECK constraint so a third-party
4422    // process writing directly to the DB can't insert a value the Rust
4423    // enum can't model. MultiChoice carries the same choices/labels
4424    // metadata but the stored value is a CSV — a single-value `IN (...)`
4425    // constraint would reject every legal CSV. Validating "every CSV
4426    // piece is a known variant" needs a regex with per-variant
4427    // escaping, which we leave to the sqlx Decode path at v1.
4428    if !col.choices.is_empty() && !col.is_multichoice {
4429        let col_name_escaped = col.name.replace('"', "\"\"");
4430        let values_sql = col
4431            .choices
4432            .iter()
4433            .map(|v| format!("'{}'", v.replace('\'', "''")))
4434            .collect::<Vec<_>>()
4435            .join(", ");
4436        def.extra(format!("CHECK (\"{col_name_escaped}\" IN ({values_sql}))"));
4437    }
4438    // User-declared `#[umbral(default = "...")]` lifts to a DDL DEFAULT
4439    // clause. Required for `ALTER TABLE ADD COLUMN` of a NOT NULL
4440    // column against a non-empty table — Postgres needs either a
4441    // default or a separate backfill.
4442    if !col.default.is_empty() {
4443        def.default(col.default.clone());
4444    } else if (col.auto_now || col.auto_now_add)
4445        && matches!(col.ty, SqlType::Timestamptz | SqlType::Date | SqlType::Time)
4446    {
4447        // Mirror of the SQLite branch above. Without a DEFAULT
4448        // Postgres rejects `ALTER TABLE ADD COLUMN ... NOT NULL`
4449        // on a populated table. `now()` evaluates per-row during
4450        // the backfill so every existing row gets a sane value;
4451        // future INSERTs override via the macro-emitted Rust path.
4452        def.default(sea_query::Expr::cust("now()"));
4453    }
4454    def
4455}
4456
4457#[cfg(test)]
4458mod tests {
4459    use super::*;
4460
4461    /// M8 — `plugin_order()` falls back to `registered_plugins()` when
4462    /// no topological order has been published. The fallback keeps the
4463    /// engine usable from low-level paths that drive `init_plugins`
4464    /// directly (the M5 / M6 tests that pre-date phase 1.5 of
4465    /// `App::build()`).
4466    ///
4467    /// Runs in the lib's unit-test binary, which is wholly separate
4468    /// from the integration test binaries and so owns its own copies
4469    /// of `REGISTRY` and `PLUGIN_ORDER`. This test seeds `REGISTRY` via
4470    /// `init_plugins`, never touches `init_plugin_order`, and pins the
4471    /// fallback to the sorted-by-name `registered_plugins()` output.
4472    /// As the only test that touches either OnceLock in this binary,
4473    /// it has them to itself.
4474    #[test]
4475    fn plugin_order_falls_back_to_registered_plugins_when_unpublished() {
4476        let mut per_plugin: std::collections::HashMap<String, Vec<ModelMeta>> =
4477            std::collections::HashMap::new();
4478        per_plugin.insert(
4479            "zeta".to_string(),
4480            vec![ModelMeta {
4481                name: "ZetaModel".to_string(),
4482                table: "zeta".to_string(),
4483                fields: Vec::new(),
4484                display: "ZetaModel".to_string(),
4485                icon: "database".to_string(),
4486                database: None,
4487                singleton: false,
4488                unique_together: Vec::new(),
4489                indexes: Vec::new(),
4490                ordering: Vec::new(),
4491                m2m_relations: Vec::new(),
4492                soft_delete: false,
4493                app_label: "app".to_string(),
4494            }],
4495        );
4496        per_plugin.insert(
4497            "alpha".to_string(),
4498            vec![ModelMeta {
4499                name: "AlphaModel".to_string(),
4500                table: "alpha".to_string(),
4501                fields: Vec::new(),
4502                display: "AlphaModel".to_string(),
4503                icon: "database".to_string(),
4504                database: None,
4505                singleton: false,
4506                unique_together: Vec::new(),
4507                indexes: Vec::new(),
4508                ordering: Vec::new(),
4509                m2m_relations: Vec::new(),
4510                soft_delete: false,
4511                app_label: "app".to_string(),
4512            }],
4513        );
4514        init_plugins(per_plugin);
4515
4516        // `init_plugin_order` was never called, so `plugin_order` must
4517        // return the sorted-by-name fallback.
4518        let order = plugin_order();
4519        assert_eq!(
4520            order,
4521            vec!["alpha".to_string(), "zeta".to_string()],
4522            "fallback should sort by name; got {order:?}",
4523        );
4524        assert_eq!(
4525            order,
4526            registered_plugins(),
4527            "fallback should exactly equal registered_plugins()",
4528        );
4529    }
4530
4531    /// Gap #65: `#[umbral(unique)]` lifts to a column-level UNIQUE in
4532    /// CREATE TABLE DDL on both backends. PK columns skip the clause
4533    /// because they're already unique by virtue of being the PK.
4534    #[test]
4535    fn unique_column_emits_unique_keyword_on_both_backends() {
4536        use sea_query::{Alias, PostgresQueryBuilder, SqliteQueryBuilder, Table};
4537
4538        let id = Column {
4539            name: "id".into(),
4540            ty: SqlType::BigInt,
4541            primary_key: true,
4542            nullable: false,
4543            fk_target: None,
4544            noform: false,
4545            db_constraint: true,
4546            noedit: false,
4547            is_string_repr: false,
4548            max_length: 0,
4549            choices: vec![],
4550            choice_labels: vec![],
4551            default: String::new(),
4552            is_multichoice: false,
4553            // Set even though it's a PK so we can assert below that
4554            // the emit path drops the redundant clause.
4555            unique: true,
4556            on_delete: crate::orm::FkAction::NoAction,
4557            on_update: crate::orm::FkAction::NoAction,
4558            index: false,
4559            auto_now_add: false,
4560            auto_now: false,
4561            help: String::new(),
4562            example: String::new(),
4563            widget: None,
4564            supported_backends: Vec::new(),
4565            min: None,
4566            max: None,
4567            text_format: ::core::option::Option::None,
4568            slug_from: ::core::option::Option::None,
4569        };
4570        let username = Column {
4571            name: "username".into(),
4572            ty: SqlType::Text,
4573            primary_key: false,
4574            nullable: false,
4575            fk_target: None,
4576            noform: false,
4577            db_constraint: true,
4578            noedit: false,
4579            is_string_repr: false,
4580            max_length: 0,
4581            choices: vec![],
4582            choice_labels: vec![],
4583            default: String::new(),
4584            is_multichoice: false,
4585            unique: true,
4586            on_delete: crate::orm::FkAction::NoAction,
4587            on_update: crate::orm::FkAction::NoAction,
4588            index: false,
4589            auto_now_add: false,
4590            auto_now: false,
4591            help: String::new(),
4592            example: String::new(),
4593            widget: None,
4594            supported_backends: Vec::new(),
4595            min: None,
4596            max: None,
4597            text_format: ::core::option::Option::None,
4598            slug_from: ::core::option::Option::None,
4599        };
4600        let email = Column {
4601            name: "email".into(),
4602            ty: SqlType::Text,
4603            primary_key: false,
4604            nullable: false,
4605            fk_target: None,
4606            noform: false,
4607            db_constraint: true,
4608            noedit: false,
4609            is_string_repr: false,
4610            max_length: 0,
4611            choices: vec![],
4612            choice_labels: vec![],
4613            default: String::new(),
4614            is_multichoice: false,
4615            unique: false,
4616            on_delete: crate::orm::FkAction::NoAction,
4617            on_update: crate::orm::FkAction::NoAction,
4618            index: false,
4619            auto_now_add: false,
4620            auto_now: false,
4621            help: String::new(),
4622            example: String::new(),
4623            widget: None,
4624            supported_backends: Vec::new(),
4625            min: None,
4626            max: None,
4627            text_format: ::core::option::Option::None,
4628            slug_from: ::core::option::Option::None,
4629        };
4630
4631        for backend in ["sqlite", "postgres"] {
4632            let mut stmt = Table::create();
4633            stmt.table(Alias::new("u"));
4634            for col in [&id, &username, &email] {
4635                let mut def = if backend == "sqlite" {
4636                    build_column_def_sqlite(col)
4637                } else {
4638                    build_column_def_postgres(col)
4639                };
4640                stmt.col(&mut def);
4641            }
4642            let sql = if backend == "sqlite" {
4643                stmt.to_string(SqliteQueryBuilder)
4644            } else {
4645                stmt.to_string(PostgresQueryBuilder)
4646            };
4647
4648            // UNIQUE on the explicitly-marked non-PK column.
4649            assert!(
4650                sql.contains("\"username\"") && sql.to_uppercase().contains("UNIQUE"),
4651                "{backend}: expected UNIQUE on username; got: {sql}",
4652            );
4653            // No UNIQUE on `email` (flag false).
4654            let email_clause = sql
4655                .split("\"email\"")
4656                .nth(1)
4657                .unwrap_or_default()
4658                .split(',')
4659                .next()
4660                .unwrap_or_default();
4661            assert!(
4662                !email_clause.to_uppercase().contains("UNIQUE"),
4663                "{backend}: email should not be UNIQUE; clause: {email_clause}",
4664            );
4665            // PK still PK; the redundant UNIQUE flag is dropped so we
4666            // don't double up the constraint.
4667            let id_clause = sql
4668                .split("\"id\"")
4669                .nth(1)
4670                .unwrap_or_default()
4671                .split(',')
4672                .next()
4673                .unwrap_or_default();
4674            assert!(
4675                id_clause.to_uppercase().contains("PRIMARY KEY"),
4676                "{backend}: id should still be PRIMARY KEY; clause: {id_clause}",
4677            );
4678            assert!(
4679                !id_clause.to_uppercase().contains("UNIQUE"),
4680                "{backend}: PK column should not also carry UNIQUE; clause: {id_clause}",
4681            );
4682        }
4683    }
4684
4685    /// Gap #68: `on_delete` / `on_update` lift to the `REFERENCES`
4686    /// tail in DDL. `NoAction` emits no clause (the SQL default);
4687    /// any other variant emits `ON DELETE <kw>` / `ON UPDATE <kw>`
4688    /// on both backends. The clause goes inside the same `extra(...)`
4689    /// string that already carries `REFERENCES "<target>"("id")` —
4690    /// the test asserts the full tail shape so a future refactor
4691    /// that splits the FK rendering won't silently regress.
4692    #[test]
4693    fn fk_action_lifts_to_references_clause_on_both_backends() {
4694        use sea_query::{Alias, PostgresQueryBuilder, SqliteQueryBuilder, Table};
4695
4696        // Need an FK target table; the DDL renderer looks up the
4697        // PK column type for `auth_user` via `fk_target_pk`.
4698        // Using "post" since it's already registered as a real
4699        // Model in the lib (resolves to BigInt id).
4700        let plain_fk = Column {
4701            name: "owner_id".into(),
4702            ty: SqlType::ForeignKey,
4703            primary_key: false,
4704            nullable: false,
4705            fk_target: Some("post".into()),
4706            noform: false,
4707            db_constraint: true,
4708            noedit: false,
4709            is_string_repr: false,
4710            max_length: 0,
4711            choices: vec![],
4712            choice_labels: vec![],
4713            default: String::new(),
4714            is_multichoice: false,
4715            unique: false,
4716            on_delete: crate::orm::FkAction::NoAction,
4717            on_update: crate::orm::FkAction::NoAction,
4718            index: false,
4719            auto_now_add: false,
4720            auto_now: false,
4721            help: String::new(),
4722            example: String::new(),
4723            widget: None,
4724            supported_backends: Vec::new(),
4725            min: None,
4726            max: None,
4727            text_format: ::core::option::Option::None,
4728            slug_from: ::core::option::Option::None,
4729        };
4730        let cascade_fk = Column {
4731            on_delete: crate::orm::FkAction::Cascade,
4732            on_update: crate::orm::FkAction::Cascade,
4733            index: false,
4734            auto_now_add: false,
4735            auto_now: false,
4736            help: String::new(),
4737            example: String::new(),
4738            widget: None,
4739            supported_backends: Vec::new(),
4740            ..plain_fk.clone()
4741        };
4742        let restrict_fk = Column {
4743            on_delete: crate::orm::FkAction::Restrict,
4744            ..plain_fk.clone()
4745        };
4746        let set_null_fk = Column {
4747            nullable: true,
4748            on_delete: crate::orm::FkAction::SetNull,
4749            ..plain_fk.clone()
4750        };
4751
4752        for backend in ["sqlite", "postgres"] {
4753            let render_one = |col: &Column| -> String {
4754                let mut stmt = Table::create();
4755                stmt.table(Alias::new("t"));
4756                let mut def = if backend == "sqlite" {
4757                    build_column_def_sqlite(col)
4758                } else {
4759                    build_column_def_postgres(col)
4760                };
4761                stmt.col(&mut def);
4762                if backend == "sqlite" {
4763                    stmt.to_string(SqliteQueryBuilder)
4764                } else {
4765                    stmt.to_string(PostgresQueryBuilder)
4766                }
4767            };
4768
4769            // NoAction → REFERENCES with no tail clauses.
4770            let sql = render_one(&plain_fk);
4771            assert!(
4772                sql.contains("REFERENCES")
4773                    && !sql.to_uppercase().contains("ON DELETE")
4774                    && !sql.to_uppercase().contains("ON UPDATE"),
4775                "{backend}: NoAction should emit REFERENCES alone; got: {sql}",
4776            );
4777
4778            // Cascade on both ON DELETE and ON UPDATE.
4779            let sql = render_one(&cascade_fk);
4780            assert!(
4781                sql.to_uppercase().contains("ON DELETE CASCADE")
4782                    && sql.to_uppercase().contains("ON UPDATE CASCADE"),
4783                "{backend}: Cascade should emit both clauses; got: {sql}",
4784            );
4785
4786            // Restrict on ON DELETE only; ON UPDATE is NoAction so
4787            // no clause appears.
4788            let sql = render_one(&restrict_fk);
4789            assert!(
4790                sql.to_uppercase().contains("ON DELETE RESTRICT"),
4791                "{backend}: Restrict missing; got: {sql}",
4792            );
4793            assert!(
4794                !sql.to_uppercase().contains("ON UPDATE"),
4795                "{backend}: ON UPDATE shouldn't appear for NoAction; got: {sql}",
4796            );
4797
4798            // SET NULL renders verbatim (two-word keyword).
4799            let sql = render_one(&set_null_fk);
4800            assert!(
4801                sql.to_uppercase().contains("ON DELETE SET NULL"),
4802                "{backend}: SET NULL missing; got: {sql}",
4803            );
4804        }
4805    }
4806
4807    /// Gap #65 follow-up: the diff engine detects changes to *every*
4808    /// schema-meaningful field, not just `ty` and `nullable`. Each
4809    /// branch builds a baseline column, mutates one field, runs
4810    /// `diff_columns`, and asserts an `AlterColumn` op is produced.
4811    /// Catches the regression where toggling `unique` or `on_delete`
4812    /// would silently leave the table unchanged.
4813    #[test]
4814    fn diff_detects_all_schema_meaningful_field_changes() {
4815        fn baseline() -> Column {
4816            Column {
4817                name: "x".into(),
4818                ty: SqlType::Text,
4819                primary_key: false,
4820                nullable: false,
4821                fk_target: None,
4822                noform: false,
4823                db_constraint: true,
4824                noedit: false,
4825                is_string_repr: false,
4826                max_length: 0,
4827                choices: vec![],
4828                choice_labels: vec![],
4829                default: String::new(),
4830                is_multichoice: false,
4831                unique: false,
4832                on_delete: crate::orm::FkAction::NoAction,
4833                on_update: crate::orm::FkAction::NoAction,
4834                index: false,
4835                auto_now_add: false,
4836                auto_now: false,
4837                help: String::new(),
4838                example: String::new(),
4839                widget: None,
4840                supported_backends: Vec::new(),
4841                min: None,
4842                max: None,
4843                text_format: ::core::option::Option::None,
4844                slug_from: ::core::option::Option::None,
4845            }
4846        }
4847        fn meta_with(col: Column) -> ModelMeta {
4848            ModelMeta {
4849                name: "M".into(),
4850                table: "m".into(),
4851                fields: vec![col],
4852                display: "M".into(),
4853                icon: "database".into(),
4854                database: None,
4855                singleton: false,
4856                unique_together: Vec::new(),
4857                indexes: Vec::new(),
4858                ordering: Vec::new(),
4859                m2m_relations: Vec::new(),
4860                soft_delete: false,
4861                app_label: "app".into(),
4862            }
4863        }
4864        let prev = meta_with(baseline());
4865        // Safe-to-alter changes: each must surface as an `AlterColumn`.
4866        // (`nullable` here is false→true — a *loosening*, which is safe;
4867        // the tightening direction is guarded separately below.)
4868        let safe_mutations: Vec<(&str, fn(&mut Column))> = vec![
4869            ("default", |c| c.default = "hello".into()),
4870            ("choices", |c| {
4871                c.choices = vec!["a".into(), "b".into()];
4872                c.choice_labels = vec!["A".into(), "B".into()];
4873            }),
4874            ("nullable", |c| c.nullable = true),
4875        ];
4876        for (label, mutate) in safe_mutations {
4877            let mut col = baseline();
4878            mutate(&mut col);
4879            let current = meta_with(col);
4880            let ops = diff_columns("M", &prev, &current).expect("diff should succeed");
4881            assert!(
4882                !ops.is_empty(),
4883                "{label}: diff should produce at least one op; got none",
4884            );
4885            assert!(
4886                ops.iter()
4887                    .any(|op| matches!(op, Operation::AlterColumn { column, .. } if column == "x")),
4888                "{label}: expected AlterColumn on `x`; got: {ops:?}",
4889            );
4890        }
4891
4892        // Adding UNIQUE to an existing column is detected too, but as an
4893        // `UnsafeAlter` guard rather than a bare `AlterColumn`: dropping a
4894        // UNIQUE constraint onto a populated column aborts the migration
4895        // if duplicates already exist, so the engine refuses it with a
4896        // duplicate-pre-check message instead of silently emitting it.
4897        let mut col = baseline();
4898        col.unique = true;
4899        let current = meta_with(col);
4900        match diff_columns("M", &prev, &current) {
4901            Err(MigrateError::UnsafeAlter { column, reason, .. }) => {
4902                assert_eq!(column, "x");
4903                assert!(
4904                    reason.contains("UNIQUE"),
4905                    "unsafe-alter reason should mention UNIQUE; got: {reason}",
4906                );
4907            }
4908            other => panic!("unique add should be an UnsafeAlter guard; got: {other:?}"),
4909        }
4910    }
4911
4912    /// Gap #65 follow-up: the Postgres `AlterColumn` render handles
4913    /// the new diff types (unique, default, choices, FK actions)
4914    /// with native `ALTER TABLE ... ADD/DROP CONSTRAINT` /
4915    /// `SET/DROP DEFAULT` statements. SQLite is unchanged — the
4916    /// rebuild dance already swallows any column metadata change.
4917    #[test]
4918    fn postgres_alter_column_renders_constraint_changes() {
4919        let baseline = Column {
4920            name: "x".into(),
4921            ty: SqlType::Text,
4922            primary_key: false,
4923            nullable: false,
4924            fk_target: None,
4925            noform: false,
4926            db_constraint: true,
4927            noedit: false,
4928            is_string_repr: false,
4929            max_length: 0,
4930            choices: vec![],
4931            choice_labels: vec![],
4932            default: String::new(),
4933            is_multichoice: false,
4934            unique: false,
4935            on_delete: crate::orm::FkAction::NoAction,
4936            on_update: crate::orm::FkAction::NoAction,
4937            index: false,
4938            auto_now_add: false,
4939            auto_now: false,
4940            help: String::new(),
4941            example: String::new(),
4942            widget: None,
4943            supported_backends: Vec::new(),
4944            min: None,
4945            max: None,
4946            text_format: ::core::option::Option::None,
4947            slug_from: ::core::option::Option::None,
4948        };
4949
4950        // unique false → true: emit ADD CONSTRAINT ... UNIQUE
4951        let mut new = baseline.clone();
4952        new.unique = true;
4953        let stmts = render_alter_column_postgres("m", "x", &[new], Some(&[baseline.clone()]));
4954        let joined = stmts.join("\n");
4955        assert!(
4956            joined.contains("ADD CONSTRAINT") && joined.contains("UNIQUE"),
4957            "unique add: expected ADD CONSTRAINT UNIQUE; got: {joined}",
4958        );
4959
4960        // unique true → false: emit DROP CONSTRAINT ... IF EXISTS
4961        let prev_unique = Column {
4962            unique: true,
4963            ..baseline.clone()
4964        };
4965        let stmts =
4966            render_alter_column_postgres("m", "x", &[baseline.clone()], Some(&[prev_unique]));
4967        let joined = stmts.join("\n");
4968        assert!(
4969            joined.contains("DROP CONSTRAINT IF EXISTS"),
4970            "unique drop: expected DROP CONSTRAINT IF EXISTS; got: {joined}",
4971        );
4972
4973        // default empty → "hello": SET DEFAULT 'hello'
4974        let mut new = baseline.clone();
4975        new.default = "hello".into();
4976        let stmts = render_alter_column_postgres("m", "x", &[new], Some(&[baseline.clone()]));
4977        let joined = stmts.join("\n");
4978        assert!(
4979            joined.contains("SET DEFAULT 'hello'"),
4980            "default set: expected SET DEFAULT; got: {joined}",
4981        );
4982
4983        // default "hello" → empty: DROP DEFAULT
4984        let prev_default = Column {
4985            default: "hello".into(),
4986            ..baseline.clone()
4987        };
4988        let stmts =
4989            render_alter_column_postgres("m", "x", &[baseline.clone()], Some(&[prev_default]));
4990        let joined = stmts.join("\n");
4991        assert!(
4992            joined.contains("DROP DEFAULT"),
4993            "default drop: expected DROP DEFAULT; got: {joined}",
4994        );
4995
4996        // FK on_delete change → DROP + readd FK with new clause
4997        let fk_baseline = Column {
4998            ty: SqlType::ForeignKey,
4999            fk_target: Some("other".into()),
5000            ..baseline.clone()
5001        };
5002        let fk_cascade = Column {
5003            on_delete: crate::orm::FkAction::Cascade,
5004            ..fk_baseline.clone()
5005        };
5006        let stmts = render_alter_column_postgres("m", "x", &[fk_cascade], Some(&[fk_baseline]));
5007        let joined = stmts.join("\n");
5008        assert!(
5009            joined.contains("DROP CONSTRAINT IF EXISTS")
5010                && joined.contains("FOREIGN KEY")
5011                && joined.contains("ON DELETE CASCADE"),
5012            "FK cascade add: expected drop+readd with ON DELETE CASCADE; got: {joined}",
5013        );
5014    }
5015
5016    /// IMP-2 from bugs/tests/testBugs.md: a `#[umbral(default = "true")]`
5017    /// on a boolean column used to land as `DEFAULT 'true'` on
5018    /// SQLite, which decode-fails on read (column type is INTEGER,
5019    /// the stored TEXT can't deserialize as `bool`). The SQLite
5020    /// renderer now maps the string to `1` / `0`.
5021    #[test]
5022    fn sqlite_bool_default_translates_to_integer_literal() {
5023        use sea_query::{Alias, SqliteQueryBuilder, Table};
5024
5025        let bool_col = Column {
5026            name: "is_active".into(),
5027            ty: SqlType::Boolean,
5028            primary_key: false,
5029            nullable: false,
5030            fk_target: None,
5031            noform: false,
5032            db_constraint: true,
5033            noedit: false,
5034            is_string_repr: false,
5035            max_length: 0,
5036            choices: vec![],
5037            choice_labels: vec![],
5038            default: "true".into(),
5039            is_multichoice: false,
5040            unique: false,
5041            on_delete: crate::orm::FkAction::NoAction,
5042            on_update: crate::orm::FkAction::NoAction,
5043            index: false,
5044            auto_now_add: false,
5045            auto_now: false,
5046            help: String::new(),
5047            example: String::new(),
5048            widget: None,
5049            supported_backends: Vec::new(),
5050            min: None,
5051            max: None,
5052            text_format: ::core::option::Option::None,
5053            slug_from: ::core::option::Option::None,
5054        };
5055        let mut stmt = Table::create();
5056        stmt.table(Alias::new("t"));
5057        let mut def = build_column_def_sqlite(&bool_col);
5058        stmt.col(&mut def);
5059        let sql = stmt.to_string(SqliteQueryBuilder);
5060        assert!(
5061            sql.contains("DEFAULT 1") && !sql.contains("DEFAULT 'true'"),
5062            "bool default 'true' on sqlite should render as DEFAULT 1; got: {sql}",
5063        );
5064
5065        // "false" → 0
5066        let mut bool_col_false = bool_col.clone();
5067        bool_col_false.default = "false".into();
5068        let mut stmt = Table::create();
5069        stmt.table(Alias::new("t"));
5070        let mut def = build_column_def_sqlite(&bool_col_false);
5071        stmt.col(&mut def);
5072        let sql = stmt.to_string(SqliteQueryBuilder);
5073        assert!(
5074            sql.contains("DEFAULT 0") && !sql.contains("DEFAULT 'false'"),
5075            "bool default 'false' on sqlite should render as DEFAULT 0; got: {sql}",
5076        );
5077
5078        // Non-bool columns are untouched (text default stays
5079        // single-quoted literal).
5080        let text_col = Column {
5081            name: "label".into(),
5082            ty: SqlType::Text,
5083            default: "hello".into(),
5084            ..bool_col.clone()
5085        };
5086        let mut stmt = Table::create();
5087        stmt.table(Alias::new("t"));
5088        let mut def = build_column_def_sqlite(&text_col);
5089        stmt.col(&mut def);
5090        let sql = stmt.to_string(SqliteQueryBuilder);
5091        assert!(
5092            sql.contains("DEFAULT 'hello'"),
5093            "text default should stay quoted; got: {sql}",
5094        );
5095    }
5096
5097    /// BUG-4 from bugs/tests/testBugs.md: `#[umbral(index)]` lifts
5098    /// to a `CREATE INDEX IF NOT EXISTS idx_<table>_<col>` statement
5099    /// alongside the `CREATE TABLE`. The index is skipped on PK
5100    /// and UNIQUE columns (those are already indexed by the
5101    /// constraint).
5102    #[test]
5103    fn index_attribute_emits_create_index_alongside_create_table() {
5104        let id = Column {
5105            name: "id".into(),
5106            ty: SqlType::BigInt,
5107            primary_key: true,
5108            nullable: false,
5109            fk_target: None,
5110            noform: false,
5111            db_constraint: true,
5112            noedit: false,
5113            is_string_repr: false,
5114            max_length: 0,
5115            choices: vec![],
5116            choice_labels: vec![],
5117            default: String::new(),
5118            is_multichoice: false,
5119            unique: false,
5120            on_delete: crate::orm::FkAction::NoAction,
5121            on_update: crate::orm::FkAction::NoAction,
5122            // PK with index=true; the renderer should skip the
5123            // extra CREATE INDEX because the PK constraint
5124            // already covers it.
5125            index: true,
5126            auto_now_add: false,
5127            auto_now: false,
5128            help: String::new(),
5129            example: String::new(),
5130            widget: None,
5131            supported_backends: Vec::new(),
5132            min: None,
5133            max: None,
5134            text_format: ::core::option::Option::None,
5135            slug_from: ::core::option::Option::None,
5136        };
5137        let slug = Column {
5138            name: "slug".into(),
5139            ty: SqlType::Text,
5140            primary_key: false,
5141            nullable: false,
5142            index: true,
5143            auto_now_add: false,
5144            auto_now: false,
5145            help: String::new(),
5146            example: String::new(),
5147            widget: None,
5148            supported_backends: Vec::new(),
5149            ..id.clone()
5150        };
5151        let title = Column {
5152            name: "title".into(),
5153            ty: SqlType::Text,
5154            primary_key: false,
5155            nullable: false,
5156            index: false,
5157            auto_now_add: false,
5158            auto_now: false,
5159            help: String::new(),
5160            example: String::new(),
5161            widget: None,
5162            supported_backends: Vec::new(),
5163            ..id.clone()
5164        };
5165        let op = Operation::CreateTable {
5166            table: "post".into(),
5167            columns: vec![id, slug, title],
5168            unique_together: Vec::new(),
5169            indexes: Vec::new(),
5170        };
5171
5172        for backend in ["sqlite", "postgres"] {
5173            let stmts = render_operation_for(&op, backend);
5174            assert!(
5175                stmts
5176                    .iter()
5177                    .any(|s| s.to_uppercase().contains("CREATE TABLE")),
5178                "{backend}: expected a CREATE TABLE; got: {stmts:?}",
5179            );
5180            let index_stmts: Vec<_> = stmts
5181                .iter()
5182                .filter(|s| s.to_uppercase().contains("CREATE INDEX"))
5183                .collect();
5184            assert_eq!(
5185                index_stmts.len(),
5186                1,
5187                "{backend}: expected exactly one CREATE INDEX (on `slug`); got {index_stmts:?}",
5188            );
5189            let ix = index_stmts[0];
5190            assert!(
5191                ix.contains("\"idx_post_slug\"") && ix.contains("(\"slug\")"),
5192                "{backend}: index should target post(slug); got: {ix}",
5193            );
5194            assert!(
5195                ix.to_uppercase().contains("IF NOT EXISTS"),
5196                "{backend}: should be idempotent via IF NOT EXISTS; got: {ix}",
5197            );
5198        }
5199    }
5200
5201    /// Regression: adding an `auto_now` / `auto_now_add` column to an
5202    /// existing populated table.
5203    ///
5204    ///   - SQLite: a 2-statement sequence (nullable ADD + UPDATE
5205    ///     backfill) since SQLite refuses non-constant defaults in
5206    ///     ALTER. The column ends up nullable at the DB level;
5207    ///     Rust still enforces non-null at the type level.
5208    ///   - Postgres: a single ALTER with `DEFAULT now()` — Postgres
5209    ///     allows the non-constant default and backfills inline.
5210    #[test]
5211    fn auto_now_add_column_renders_safe_backfill_per_backend() {
5212        for (label, auto_now, auto_now_add) in
5213            [("auto_now", true, false), ("auto_now_add", false, true)]
5214        {
5215            let col = Column {
5216                name: "updated_at".to_string(),
5217                ty: SqlType::Timestamptz,
5218                primary_key: false,
5219                nullable: false,
5220                fk_target: None,
5221                noform: false,
5222                db_constraint: true,
5223                noedit: false,
5224                is_string_repr: false,
5225                max_length: 0,
5226                choices: Vec::new(),
5227                choice_labels: Vec::new(),
5228                default: String::new(),
5229                is_multichoice: false,
5230                unique: false,
5231                on_delete: crate::orm::FkAction::NoAction,
5232                on_update: crate::orm::FkAction::NoAction,
5233                index: false,
5234                auto_now_add,
5235                auto_now,
5236                help: String::new(),
5237                example: String::new(),
5238                widget: None,
5239                supported_backends: Vec::new(),
5240                min: None,
5241                max: None,
5242                text_format: None,
5243                slug_from: None,
5244            };
5245
5246            // SQLite: the AddColumn op must produce TWO statements:
5247            // an ADD COLUMN nullable + an UPDATE backfill. The ADD
5248            // must NOT carry `NOT NULL` (otherwise SQLite rejects
5249            // it on the populated rows), and must NOT carry a
5250            // DEFAULT (otherwise SQLite rejects the non-constant).
5251            let op = Operation::AddColumn {
5252                table: "customer".to_string(),
5253                column: col.clone(),
5254            };
5255            let stmts = render_operation_sqlite(&op);
5256            assert_eq!(
5257                stmts.len(),
5258                2,
5259                "{label} SQLite: must emit ADD + UPDATE, got: {stmts:?}",
5260            );
5261            let add_sql = stmts[0].to_uppercase();
5262            assert!(
5263                add_sql.contains("ADD COLUMN"),
5264                "{label} SQLite: first stmt must be ADD COLUMN, got: {}",
5265                stmts[0],
5266            );
5267            assert!(
5268                !add_sql.contains("NOT NULL"),
5269                "{label} SQLite: ADD COLUMN must be nullable (NOT NULL = SQLite reject), got: {}",
5270                stmts[0],
5271            );
5272            assert!(
5273                !add_sql.contains("DEFAULT"),
5274                "{label} SQLite: ADD COLUMN must omit DEFAULT (non-constant = SQLite reject), got: {}",
5275                stmts[0],
5276            );
5277            let backfill_sql = &stmts[1];
5278            assert!(
5279                backfill_sql.contains("UPDATE") && backfill_sql.contains("datetime('now')"),
5280                "{label} SQLite: second stmt must be backfill UPDATE, got: {backfill_sql}",
5281            );
5282
5283            // Postgres: single ALTER with NOT NULL + DEFAULT now().
5284            let pstmts = render_operation_postgres(&op);
5285            assert_eq!(
5286                pstmts.len(),
5287                1,
5288                "{label} Postgres: single statement suffices, got: {pstmts:?}",
5289            );
5290            let p = &pstmts[0];
5291            assert!(
5292                p.to_lowercase().contains("default now()"),
5293                "{label} Postgres: expected DEFAULT now() in ALTER, got: {p}",
5294            );
5295            assert!(
5296                p.to_uppercase().contains("NOT NULL"),
5297                "{label} Postgres: keeps NOT NULL (Postgres allows non-constant defaults), got: {p}",
5298            );
5299        }
5300    }
5301}