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 registered_models_opt()
87 .expect("umbral: model registry not initialised — did you call App::build()?")
88}
89
90/// [`registered_models`] without the panic: `None` when the registry hasn't been
91/// initialised yet.
92///
93/// For callers that legitimately run before (or without) `App::build()` — the
94/// boot system checks are the case in point, and a check that panics is a check
95/// that can never report anything.
96pub fn registered_models_opt() -> Option<Vec<ModelMeta>> {
97 Some(REGISTRY.get()?.iter().map(|(_, m)| m.clone()).collect())
98}
99
100/// Whether the model registry has been initialised. False before
101/// `App::build()` has run; true after the phase-3 `init_plugins`
102/// call publishes the per-plugin map. Used by system checks that
103/// walk the registry — they return an empty result when the
104/// registry isn't ready rather than panicking (so low-level tests
105/// that drive `check::run_all` without booting an App keep working).
106pub fn is_initialised() -> bool {
107 REGISTRY.get().is_some()
108}
109
110/// PK lift Pass E — cached `(pk_column_name, pk_sql_type)` lookup
111/// keyed by table name. Used by the FK decode path
112/// (`fk_target_pk_sql_type` in `orm/dynamic.rs`) and the
113/// select_related hydrators, both of which previously cloned the
114/// full `Vec<ModelMeta>` per call and linear-scanned for the
115/// target's PK column.
116///
117/// REGISTRY is a `OnceLock` set once during `App::build`; this cache
118/// reads from it the first time anyone asks for a PK lookup AFTER
119/// initialisation, then serves from a `HashMap` for every
120/// subsequent call. Eliminates the per-row `registered_models()`
121/// clone in hot decode loops.
122///
123/// Returns `None` when the registry isn't initialised (the cache
124/// stays uninstantiated so a follow-up call after `App::build`
125/// gets the real table set), OR when the named table isn't in the
126/// registry (orphan / system / typo).
127pub fn pk_meta_for_table(table: &str) -> Option<(String, crate::orm::SqlType)> {
128 if !is_initialised() {
129 // Defer cache init until App::build has populated REGISTRY.
130 // The cache MUST NOT memoize an empty map; otherwise
131 // post-init callers would see no PK metadata forever.
132 return None;
133 }
134 static CACHE: std::sync::OnceLock<
135 std::collections::HashMap<String, (String, crate::orm::SqlType)>,
136 > = std::sync::OnceLock::new();
137 let map = CACHE.get_or_init(|| {
138 let mut out = std::collections::HashMap::new();
139 for m in registered_models() {
140 if let Some(pk) = m.pk_column() {
141 out.insert(m.table.clone(), (pk.name.clone(), pk.ty));
142 }
143 }
144 out
145 });
146 map.get(table).cloned()
147}
148
149/// Cached model lookup by SQL table name.
150///
151/// Unlike [`registered_models`], this does not deep-clone the full
152/// registry on every call. It clones only the matched [`ModelMeta`],
153/// which keeps row-by-row dynamic serializers from paying
154/// O(registry-size) per row.
155pub fn model_meta_for_table(table: &str) -> Option<ModelMeta> {
156 if !is_initialised() {
157 return None;
158 }
159 static CACHE: std::sync::OnceLock<std::collections::HashMap<String, ModelMeta>> =
160 std::sync::OnceLock::new();
161 let map = CACHE.get_or_init(|| {
162 registered_models()
163 .into_iter()
164 .map(|m| (m.table.clone(), m))
165 .collect()
166 });
167 map.get(table).cloned()
168}
169
170/// The SQL type a column's value actually binds / decodes as (PK lift).
171/// Equals `col.ty` for everything except a `ForeignKey`, where it resolves
172/// to the referenced model's PK type via [`pk_meta_for_table`] — so an FK
173/// pointing at a `String`-slug- or `Uuid`-PK target is handled as text /
174/// uuid instead of being forced through i64. Falls back to `BigInt` (the
175/// historical default) when the target can't be resolved (registry not yet
176/// initialised, or an unregistered target table).
177///
178/// The single source of truth for "what shape is this FK really?", used by
179/// `backup` (dump/load) and the dynamic filter helpers.
180pub fn fk_effective_type(col: &Column) -> crate::orm::SqlType {
181 if matches!(col.ty, crate::orm::SqlType::ForeignKey) {
182 col.fk_target
183 .as_deref()
184 .and_then(pk_meta_for_table)
185 .map(|(_, ty)| ty)
186 .unwrap_or(crate::orm::SqlType::BigInt)
187 } else {
188 col.ty
189 }
190}
191
192/// Return the registered plugin names that contributed at least one
193/// model. Sorted deterministically. Used as a fallback when no
194/// topological order is published; the M7 walk used this directly,
195/// and M8 prefers [`plugin_order`] when it's been set.
196pub fn registered_plugins() -> Vec<String> {
197 let mut names: Vec<String> = REGISTRY
198 .get()
199 .expect("umbral: model registry not initialised — did you call App::build()?")
200 .iter()
201 .map(|(p, _)| p.clone())
202 .collect();
203 names.sort();
204 names.dedup();
205 names
206}
207
208/// The topological plugin order published by `App::build()` after its
209/// phase 1.5 sort. `None` until that runs; the CLI subcommands
210/// (`makemigrations`, `migrate`, `showmigrations`) call `App::build()`
211/// via `boot_for_management` before reaching the migration engine.
212static PLUGIN_ORDER: OnceLock<Vec<String>> = OnceLock::new();
213
214/// Per-model database alias (`Model::NAME -> alias`) published by
215/// `App::build()` after walking each registered plugin's
216/// `Plugin::database()`. Models whose plugin returned `None` are
217/// absent from the map; QuerySet's `resolve_pool` falls back to the
218/// `"default"` alias for those. Lookup is `O(1)` on a `HashMap`.
219static MODEL_ALIASES: OnceLock<std::collections::HashMap<String, String>> = OnceLock::new();
220
221/// Publish the topological plugin order. Called by `App::build()` once
222/// the phase 1.5 sort has produced the order. Must include the
223/// implicit `"app"` plugin even when no real plugins are registered.
224pub(crate) fn init_plugin_order(order: Vec<String>) {
225 PLUGIN_ORDER
226 .set(order)
227 .expect("umbral::migrate::init_plugin_order called more than once");
228}
229
230/// Return the topological plugin order if `App::build()` published
231/// one; otherwise fall back to [`registered_plugins`] (sorted by
232/// name). The fallback keeps existing M5 / M6 tests working without
233/// requiring them to wire a full plugin sort.
234pub fn plugin_order() -> Vec<String> {
235 PLUGIN_ORDER
236 .get()
237 .cloned()
238 .unwrap_or_else(registered_plugins)
239}
240
241/// The client-facing API endpoints every registered plugin advertised
242/// via `Plugin::api_endpoints()`, collected by `App::build()`. `None`
243/// until that runs; an app with no advertising plugins publishes an
244/// empty vec.
245static API_ENDPOINTS: OnceLock<Vec<crate::plugin::ApiEndpoint>> = OnceLock::new();
246
247/// Publish the collected `Plugin::api_endpoints()`. Called once by
248/// `App::build()` after walking every registered plugin.
249pub(crate) fn init_api_endpoints(endpoints: Vec<crate::plugin::ApiEndpoint>) {
250 let _ = API_ENDPOINTS.set(endpoints);
251}
252
253/// Every callable endpoint registered plugins advertised for service
254/// discovery, in plugin-registration order. Empty until `App::build()`
255/// has run. A REST API root (or any discovery surface) reads this to
256/// list plugin endpoints without depending on those plugins' crates.
257pub fn registered_api_endpoints() -> Vec<crate::plugin::ApiEndpoint> {
258 API_ENDPOINTS.get().cloned().unwrap_or_default()
259}
260
261/// Publish the per-model alias routing. Called by `App::build()`
262/// during phase 3 after walking every plugin's `Plugin::database()`.
263/// Plugins that returned `None` contribute no entries; only the
264/// explicit overrides land here.
265pub(crate) fn init_model_aliases(map: std::collections::HashMap<String, String>) {
266 MODEL_ALIASES
267 .set(map)
268 .expect("umbral::migrate::init_model_aliases called more than once");
269}
270
271/// Look up the database alias for a SQL table name — the reverse of
272/// the `Model::NAME → alias` lookup that [`model_alias`] does. Walks
273/// the registered model metas to find the one whose `table` matches
274/// (snake_case of the struct name + any `#[umbral(table = "...")]`
275/// override) and returns its alias if set. Falls back to `"default"`
276/// when no model owns the table (e.g. orphan schema, the
277/// `umbral_migrations` table itself) — those land on the main pool.
278///
279/// Used by the migration engine's per-DB dispatch in [`run_in`] to
280/// route each operation to the right pool.
281pub fn table_alias(table_name: &str) -> String {
282 for meta in registered_models() {
283 if meta.table == table_name {
284 return model_alias(&meta.name).unwrap_or_else(|| "default".to_string());
285 }
286 }
287 "default".to_string()
288}
289
290/// Look up the database alias for one model. Returns `None` if the
291/// model isn't routed explicitly (the caller falls back to the
292/// `"default"` pool); returns `None` even when the alias map hasn't
293/// been initialised so low-level tests that drive `init_plugins`
294/// directly don't have to wire a second call.
295pub fn model_alias(model_name: &str) -> Option<String> {
296 MODEL_ALIASES.get()?.get(model_name).cloned()
297}
298
299static MODEL_META_BY_NAME: OnceLock<std::collections::HashMap<String, ModelMeta>> = OnceLock::new();
300
301/// Cached `&ModelMeta` lookup by model name. Returns `None` before
302/// `App::build` populates the registry (low-level tests), which the routing
303/// seam treats as "fall back to legacy static routing".
304pub fn model_meta_ref(name: &str) -> Option<&'static ModelMeta> {
305 if !is_initialised() {
306 return None;
307 }
308 MODEL_META_BY_NAME
309 .get_or_init(|| {
310 registered_models()
311 .into_iter()
312 .map(|m| (m.name.clone(), m))
313 .collect()
314 })
315 .get(name)
316}
317
318/// Return the models registered against a specific plugin. Empty if
319/// no plugin by that name registered models.
320pub fn models_for_plugin(plugin: &str) -> Vec<ModelMeta> {
321 REGISTRY
322 .get()
323 .expect("umbral: model registry not initialised — did you call App::build()?")
324 .iter()
325 .filter(|(p, _)| p == plugin)
326 .map(|(_, m)| m.clone())
327 .collect()
328}
329
330/// Static metadata for one registered model, copied off the `Model`
331/// trait's `const`s when the user calls `App::builder().model::<T>()`.
332///
333/// Owned (no lifetimes) so the registry can hold an arbitrary number
334/// without the lifetime contortions a slice of trait references would
335/// need. The cost is one Vec at `App::build` time; the win is
336/// `registered_models()` having a plain `&'static [ModelMeta]` signature.
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338pub struct ModelMeta {
339 /// The struct name (`Model::NAME`). Identifies the model across
340 /// snapshot diffs even if the table is renamed.
341 pub name: String,
342 /// The SQL table name (`Model::TABLE`).
343 pub table: String,
344 /// One owned column descriptor per field, in declaration order.
345 /// Owned (`Column`, not the underlying static `FieldSpec`) so the
346 /// snapshot round-trips cleanly through serde.
347 pub fields: Vec<Column>,
348 /// Human-readable display name from `Model::DISPLAY`. Defaults to
349 /// `Model::NAME` when no `#[umbral(display = "...")]` is present.
350 #[serde(default)]
351 pub display: String,
352 /// Lucide icon slug from `Model::ICON`. Defaults to `"database"`.
353 #[serde(default = "default_icon")]
354 pub icon: String,
355 /// Database alias from `Model::DATABASE`, when set. `None` means
356 /// "fall back to the owning plugin's `Plugin::database()`, then
357 /// the `default` pool." Captured here so `App::build`'s alias
358 /// routing can read it without re-reaching into the trait at a
359 /// later phase.
360 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub database: Option<String>,
362 /// Mirrors `Model::SINGLETON`. Closes BUG-9 in
363 /// `bugs/tests/testBugs.md`. Default `false`; admin renderers
364 /// read it to auto-redirect list-view to the edit form.
365 #[serde(default, skip_serializing_if = "is_false")]
366 pub singleton: bool,
367 /// Mirrors `Model::UNIQUE_TOGETHER`. Composite-UNIQUE constraints,
368 /// each inner `Vec<String>` listing the columns of one constraint.
369 /// Closes BUG-6.
370 #[serde(default, skip_serializing_if = "Vec::is_empty")]
371 pub unique_together: Vec<Vec<String>>,
372 /// Mirrors `Model::INDEXES`. Each inner `Vec<String>` lists the
373 /// columns of one multi-column index. Closes BUG-7.
374 #[serde(default, skip_serializing_if = "Vec::is_empty")]
375 pub indexes: Vec<Vec<String>>,
376 /// Mirrors `Model::ORDERING`. Each tuple is `(column, descending)`
377 /// — `descending == true` lowers to `ORDER BY col DESC`. Closes
378 /// BUG-8.
379 #[serde(default, skip_serializing_if = "Vec::is_empty")]
380 pub ordering: Vec<(String, bool)>,
381 /// Mirrors `Model::M2M_RELATIONS`. Many-to-many relations declared
382 /// on this model. The migration engine uses this to auto-generate
383 /// junction tables. Closes BUG-16.
384 #[serde(default, skip_serializing_if = "Vec::is_empty")]
385 pub m2m_relations: Vec<M2MRelation>,
386 /// Mirrors `Model::SOFT_DELETE` (`#[umbral(soft_delete)]`). The
387 /// dynamic / annotate paths read this to auto-exclude
388 /// `deleted_at IS NULL` children from correlated counts and to
389 /// drive trash-aware admin views without re-reaching into the
390 /// typed trait. Shared enabler for gaps2 #35 + #39a.
391 #[serde(default, skip_serializing_if = "is_false")]
392 pub soft_delete: bool,
393 /// `#[umbral(audited)]`. Lives on `ModelMeta` (not re-derived per path) so
394 /// the DYNAMIC write path — which is what admin and REST run on — reads the
395 /// same source of truth the typed one does.
396 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
397 pub audited: bool,
398 /// features #73 — `#[umbral(view = "...")]`. `Some(sql)` means this model is
399 /// backed by a database VIEW whose body is `sql`; the migration engine emits
400 /// `CREATE VIEW` for it and never `CREATE TABLE`. Carried on `ModelMeta` (not
401 /// just the trait) because the *snapshot* is what `makemigrations` diffs — a
402 /// view whose SQL changed has to be detectable without the model being in scope.
403 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub view: Option<String>,
405 /// features #73 — the view is MATERIALIZED (Postgres-only). Meaningless unless
406 /// `view` is `Some`.
407 #[serde(default, skip_serializing_if = "is_false")]
408 pub materialized: bool,
409 /// The app label (the owning plugin's name), mirrors `Model::APP_LABEL`.
410 /// Sourced from `#[umbral(plugin = "...")]`; `"app"` when absent.
411 /// Authoritative for permission codenames (gaps2 #80g): replaces the
412 /// old table-name-split heuristic that collided distinct models. The
413 /// `#[serde(default)]` keeps pre-#80g snapshot JSON round-tripping.
414 #[serde(default = "default_app_label")]
415 pub app_label: String,
416}
417
418fn default_app_label() -> String {
419 "app".to_string()
420}
421
422impl Default for ModelMeta {
423 fn default() -> Self {
424 Self {
425 name: String::new(),
426 table: String::new(),
427 fields: Vec::new(),
428 display: String::new(),
429 icon: default_icon(),
430 database: None,
431 singleton: false,
432 unique_together: Vec::new(),
433 indexes: Vec::new(),
434 ordering: Vec::new(),
435 m2m_relations: Vec::new(),
436 soft_delete: false,
437 audited: false,
438 view: None,
439 materialized: false,
440 app_label: default_app_label(),
441 }
442 }
443}
444
445impl Default for Column {
446 /// A nullable-free `BigInt` column with every optional marker off, so a
447 /// hand-built fixture can name the two or three fields it cares about and
448 /// `..Column::default()` the rest.
449 ///
450 /// `db_constraint` defaults to `true`, matching the serde default: an FK
451 /// emits a physical `REFERENCES` clause unless the field opts out. Getting
452 /// this backwards would silently drop constraints AND the plugin ordering
453 /// edges derived from them (see `app::fk_plugin_edges`).
454 fn default() -> Self {
455 Self {
456 name: String::new(),
457 ty: crate::orm::SqlType::BigInt,
458 primary_key: false,
459 nullable: false,
460 fk_target: None,
461 noform: false,
462 privileged: false,
463 private: false,
464 secret: false,
465 db_constraint: true,
466 noedit: false,
467 is_string_repr: false,
468 max_length: 0,
469 choices: Vec::new(),
470 choice_labels: Vec::new(),
471 default: String::new(),
472 is_multichoice: false,
473 unique: false,
474 on_delete: crate::orm::FkAction::NoAction,
475 on_update: crate::orm::FkAction::NoAction,
476 index: false,
477 auto_now_add: false,
478 auto_now: false,
479 auto_user_add: false,
480 auto_user: false,
481 trim: false,
482 lowercase: false,
483 case_insensitive: false,
484 help: String::new(),
485 example: String::new(),
486 widget: None,
487 supported_backends: Vec::new(),
488 min: None,
489 max: None,
490 text_format: None,
491 slug_from: None,
492 }
493 }
494}
495
496/// Owned mirror of `orm::M2MRelationSpec` so `ModelMeta` can be
497/// serialised into migration JSON without lifetimes.
498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
499pub struct M2MRelation {
500 pub field_name: String,
501 pub target_table: String,
502 pub target_name: String,
503}
504
505fn default_icon() -> String {
506 "database".to_string()
507}
508
509/// Serde default for [`Operation::CreateM2MTable`]'s `parent_ty` /
510/// `child_ty` fields. Older snapshot files (pre-phase-2) had no
511/// per-side PK type and assumed `BigInt` on both ends — this keeps
512/// them round-tripping without rewrites.
513fn default_bigint() -> crate::orm::SqlType {
514 crate::orm::SqlType::BigInt
515}
516
517impl ModelMeta {
518 /// The primary-key column on this model. Every umbral model
519 /// has exactly one PK by construction (the derive enforces
520 /// it), but the lookup is `Option`-shaped because nothing
521 /// stops a hand-written `ModelMeta` (test fixtures, etc.)
522 /// from omitting it.
523 pub fn pk_column(&self) -> Option<&Column> {
524 self.fields.iter().find(|c| c.primary_key)
525 }
526
527 /// Read static metadata off `T: Model` into an owned `ModelMeta`.
528 /// Called from `AppBuilder::model::<T>()`.
529 pub fn for_<T: Model>() -> Self {
530 Self {
531 name: T::NAME.to_string(),
532 table: T::TABLE.to_string(),
533 fields: T::FIELDS.iter().map(Column::from).collect(),
534 display: T::DISPLAY.to_string(),
535 icon: T::ICON.to_string(),
536 database: T::DATABASE.map(|s| s.to_string()),
537 singleton: T::SINGLETON,
538 unique_together: T::UNIQUE_TOGETHER
539 .iter()
540 .map(|group| group.iter().map(|s| s.to_string()).collect())
541 .collect(),
542 indexes: T::INDEXES
543 .iter()
544 .map(|group| group.iter().map(|s| s.to_string()).collect())
545 .collect(),
546 ordering: T::ORDERING
547 .iter()
548 .map(|(col, desc)| (col.to_string(), *desc))
549 .collect(),
550 m2m_relations: T::M2M_RELATIONS
551 .iter()
552 .map(|r| M2MRelation {
553 field_name: r.field_name.to_string(),
554 target_table: r.target_table.to_string(),
555 target_name: r.target_name.to_string(),
556 })
557 .collect(),
558 soft_delete: T::SOFT_DELETE,
559 audited: T::AUDITED,
560 view: T::VIEW.map(|s| s.to_string()),
561 materialized: T::MATERIALIZED,
562 app_label: T::APP_LABEL.to_string(),
563 }
564 }
565}
566
567/// A snapshot of every registered model at a point in time.
568///
569/// Serialised into the `snapshot_after` field of a migration file so
570/// future `makemigrations` runs can diff against it without replaying
571/// every prior migration's operations.
572#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
573pub struct Snapshot {
574 /// Models sorted by name so the JSON is deterministic and the
575 /// snapshot_hash is stable across runs that produce equivalent
576 /// content.
577 pub models: Vec<ModelMeta>,
578}
579
580impl Snapshot {
581 /// Build a snapshot from the live registry (the current state of
582 /// the application's models, post-`App::build`).
583 pub fn current() -> Self {
584 let mut models = registered_models().to_vec();
585 models.sort_by(|a, b| a.name.cmp(&b.name));
586 Self { models }
587 }
588
589 /// Build a snapshot containing only the models registered
590 /// against the given plugin. Used by `make_in` to diff each
591 /// plugin's migrations independently against its own prior
592 /// snapshot, so cross-plugin model sets don't bleed into one
593 /// migration file.
594 pub fn current_for(plugin: &str) -> Self {
595 let mut models = models_for_plugin(plugin);
596 models.sort_by(|a, b| a.name.cmp(&b.name));
597 Self { models }
598 }
599
600 /// Compute the snapshot's SHA-256 hash, hex-encoded. Stored in the
601 /// `umbral_migrations.snapshot_hash` column for drift detection.
602 pub fn hash(&self) -> String {
603 use sha2::{Digest, Sha256};
604 let json = serde_json::to_string(self).expect("Snapshot serializes");
605 let digest = Sha256::digest(json.as_bytes());
606 hex(&digest[..])
607 }
608}
609
610fn hex(bytes: &[u8]) -> String {
611 const HEX: &[u8; 16] = b"0123456789abcdef";
612 let mut s = String::with_capacity(bytes.len() * 2);
613 for b in bytes {
614 s.push(HEX[(b >> 4) as usize] as char);
615 s.push(HEX[(b & 0x0f) as usize] as char);
616 }
617 s
618}
619
620/// One operation inside a migration. The migration engine renders each
621/// operation to SQL via the active backend (M4 `DatabaseBackend::
622/// map_type`) and runs them in declaration order inside one
623/// transaction per migration file.
624///
625/// M5 v1 shipped table-level ops; M8 v1 adds `AddColumn` and
626/// `DropColumn`. `AlterColumn`, index / constraint ops, and
627/// `RunSql` / `RunCode` are deferred (see `docs/specs/06-migration-
628/// engine.md`).
629#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
630#[serde(tag = "kind")]
631pub enum Operation {
632 /// Create a new table. `columns` is in declaration order; the
633 /// engine builds a sea-query `Table::create()` over them and runs
634 /// the rendered DDL. `unique_together` lowers to inline
635 /// `UNIQUE (col1, col2)` clauses; `indexes` lowers to follow-up
636 /// `CREATE INDEX` statements after the table is created. Both
637 /// default to empty for backward-compat with older snapshots.
638 CreateTable {
639 table: String,
640 columns: Vec<Column>,
641 #[serde(default, skip_serializing_if = "Vec::is_empty")]
642 unique_together: Vec<Vec<String>>,
643 #[serde(default, skip_serializing_if = "Vec::is_empty")]
644 indexes: Vec<Vec<String>>,
645 },
646 /// Drop an existing table.
647 DropTable { table: String },
648 /// features #73 — create a database VIEW from `#[umbral(view = "...")]`.
649 ///
650 /// There is no `AlterView`, and there never will be: a view holds no rows, so
651 /// changing one is a drop and a recreate. That is why views escape every hard
652 /// problem the table side of this engine spent its life on — no data-preserving
653 /// ALTER, no rename-vs-drop disambiguation, nothing to back up.
654 CreateView {
655 /// The view's name in the database (the model's `TABLE`).
656 name: String,
657 /// The SELECT body, verbatim from the attribute. Opaque to the engine.
658 sql: String,
659 /// `CREATE MATERIALIZED VIEW` (Postgres-only) rather than `CREATE VIEW`.
660 #[serde(default, skip_serializing_if = "is_false")]
661 materialized: bool,
662 },
663 /// features #73 — drop a database VIEW.
664 DropView {
665 /// The view's name in the database.
666 name: String,
667 /// Whether it was created as MATERIALIZED — the DROP statement differs.
668 #[serde(default, skip_serializing_if = "is_false")]
669 materialized: bool,
670 },
671 /// Add a new column to an existing table. Rendered as
672 /// `ALTER TABLE x ADD COLUMN y TYPE [NOT NULL]`. SQLite refuses a
673 /// non-nullable add against a populated table without a default;
674 /// the engine surfaces that as a sqlx error at apply time (M8 v1).
675 /// A future op `AddColumnWithDefault` lifts the restriction once
676 /// the `#[umbral(default = ...)]` attribute lands.
677 AddColumn { table: String, column: Column },
678 /// Drop a column from an existing table. Rendered as
679 /// `ALTER TABLE x DROP COLUMN y`. SQLite 3.35+ and Postgres
680 /// support this natively; older SQLite would need a table-
681 /// recreation dance the engine doesn't implement.
682 DropColumn { table: String, column: String },
683 /// Alter a column's nullable flag (the only safe in-place change
684 /// the engine ships at M5.1). Self-contained: carries the full
685 /// new column list so the SQLite table-recreation dance can
686 /// rebuild the schema without re-reading the snapshot. The
687 /// `column` field names the specific column that triggered the
688 /// alter (used for the filename suffix and diagnostics); the
689 /// `new_columns` list is the post-change schema.
690 AlterColumn {
691 table: String,
692 column: String,
693 new_columns: Vec<Column>,
694 /// The table's composite UNIQUE groups (audit_2 core-migrate #10). The
695 /// SQLite recreation dance rebuilds the table from scratch, so it must
696 /// re-emit these — otherwise a nullable-flip / safe-cast alter silently
697 /// DROPS every composite UNIQUE constraint (duplicates become
698 /// insertable — integrity loss). Postgres alters in place and ignores
699 /// this. `serde(default)` keeps older on-disk migrations deserialising.
700 #[serde(default, skip_serializing_if = "Vec::is_empty")]
701 unique_together: Vec<Vec<String>>,
702 /// The table's composite (multi-column) index groups (audit_2
703 /// core-migrate #10). Re-created by the SQLite dance after the rebuild,
704 /// for the same reason as `unique_together`. Single-column / FK /
705 /// soft-delete indexes are re-derived from `new_columns`.
706 #[serde(default, skip_serializing_if = "Vec::is_empty")]
707 indexes: Vec<Vec<String>>,
708 /// Snapshot of the table's columns *before* this alter. Carried
709 /// so the Postgres renderer can decide per-column whether it
710 /// needs a TYPE/USING clause vs a SET/DROP NOT NULL — without
711 /// re-walking the snapshot file. `Option` + `serde(default)`
712 /// keeps older on-disk migrations deserialising cleanly; ops
713 /// produced before this field existed get `None` and fall back
714 /// to the legacy nullable-only Postgres path.
715 #[serde(default, skip_serializing_if = "Option::is_none")]
716 prev_columns: Option<Vec<Column>>,
717 },
718 /// Rename an existing table. Emitted by `diff` when a model's table
719 /// name changes but its `Model::NAME` (the Rust struct name) stays
720 /// the same (first-pass detection), or when the column shapes are
721 /// bit-identical and the struct name changed too (second-pass
722 /// heuristic detection). Both SQLite and Postgres render as
723 /// `ALTER TABLE "<from>" RENAME TO "<to>"`.
724 ///
725 /// The migration tracking table records `(plugin, name)` of each
726 /// applied migration — it is not affected by a table rename inside
727 /// the migration.
728 RenameTable { from: String, to: String },
729 /// Create a many-to-many junction table. Auto-emitted when a model
730 /// gains an `M2M<T>` field. Closes BUG-16 phase 2.
731 ///
732 /// The junction table name is `parent_table_field_name`. Columns:
733 /// `parent_id` (FK to parent), `child_id` (FK to target), both with
734 /// `ON DELETE CASCADE`. Composite PK `(parent_id, child_id)`.
735 ///
736 /// `parent_ty` and `child_ty` carry the SQL types of the
737 /// referenced PK columns — `BigInt` for an `i64` PK, `Text` for a
738 /// `String` slug, `Uuid` for a `uuid::Uuid`. The renderer maps
739 /// these to the right column type per backend; without them the
740 /// junction's `child_id INTEGER` would reject a string codename
741 /// at insert time. `#[serde(default)]` keeps older snapshot files
742 /// (pre-phase-2) round-tripping — they default to `BigInt`,
743 /// matching the original i64-only behaviour.
744 CreateM2MTable {
745 junction_table: String,
746 parent_table: String,
747 parent_col: String,
748 child_table: String,
749 child_col: String,
750 #[serde(default = "default_bigint")]
751 parent_ty: crate::orm::SqlType,
752 #[serde(default = "default_bigint")]
753 child_ty: crate::orm::SqlType,
754 },
755 /// Drop a many-to-many junction table. Auto-emitted when an `M2M<T>`
756 /// field is removed from a model.
757 DropM2MTable { junction_table: String },
758 /// Gap 88: rename a column on an existing table. Emitted by the
759 /// diff engine when a single column with one shape was dropped
760 /// and one with the same shape was added in the same diff —
761 /// the heuristic match for "the user renamed `title` to
762 /// `headline`." Both SQLite (3.25+) and Postgres render as
763 /// `ALTER TABLE "<t>" RENAME COLUMN "<from>" TO "<to>"`.
764 ///
765 /// `column` carries the post-rename column shape so the
766 /// snapshot stays in sync. The migration only renames; never
767 /// alters other column attributes — a rename combined with a
768 /// type change emits a RenameColumn AND a follow-on AlterColumn
769 /// against the new name.
770 RenameColumn {
771 table: String,
772 from: String,
773 to: String,
774 #[serde(default, skip_serializing_if = "Option::is_none")]
775 column: Option<Column>,
776 },
777 /// gaps3 #43: set (or clear) a column's database comment from
778 /// `#[umbral(help = "...")]`. Renders as `COMMENT ON COLUMN "<t>"."<c>" IS
779 /// '<comment>'` on Postgres, and to *nothing* on SQLite, which has no
780 /// comment facility. An empty `comment` clears it with `IS NULL` — Postgres
781 /// distinguishes "no comment" from "the empty comment".
782 ///
783 /// `CreateTable` and `AddColumn` emit their own comments inline, so this op
784 /// exists for the case they can't cover: the help text on an *existing*
785 /// column changed. It touches no rows and takes no lock worth naming, so it
786 /// classifies as `OpSafety::Safe`.
787 SetColumnComment {
788 table: String,
789 column: String,
790 /// Empty string means "remove the comment".
791 comment: String,
792 },
793 /// Gap #69: a raw-SQL **data** migration. Unlike every other
794 /// variant it changes *rows*, not the schema model — so the
795 /// autodetector NEVER emits it (it has no model-state effect), and
796 /// a migration carrying only `RunSql` ops has
797 /// `snapshot_after == snapshot_before`. It is always hand-authored:
798 /// generate an empty migration with `makemigrations --empty
799 /// <plugin>`, then add the `RunSql` op by editing the file.
800 ///
801 /// `sql` is the forward statement(s), executed verbatim on the
802 /// per-migration transaction — same string on both backends (raw
803 /// SQL the renderer passes through untouched), so the author owns
804 /// portability. `reverse_sql` is the optional un-apply statement
805 /// (used by a future `migrate --reverse`); `None` means
806 /// irreversible.
807 ///
808 /// Under schema-per-tenant the op runs **per tenant schema** (the
809 /// schema-migrate loop applies every op under the
810 /// `<schema>, public` search_path), so a tenant-app `RunSql` writes
811 /// tenant rows while reading shared `public` lookup tables — the
812 /// boundary-spanning data migration. A shared-app `RunSql` runs once
813 /// in `public` via the normal `migrate`.
814 RunSql {
815 sql: String,
816 #[serde(default, skip_serializing_if = "Option::is_none")]
817 reverse_sql: Option<String>,
818 },
819 /// Create a composite index, or a composite UNIQUE constraint, on an
820 /// EXISTING table. Emitted by `diff` when a model gains a
821 /// `unique_together` group or a multi-column `indexes` entry with no
822 /// accompanying column change — a case that previously produced NO
823 /// migration at all, so the constraint was silently never created.
824 ///
825 /// `unique` selects `CREATE UNIQUE INDEX` (a `unique_together` group)
826 /// vs a plain `CREATE INDEX` (an `indexes` group). The index NAME is
827 /// deterministic — `uniq_<table>_<cols>` when unique, `idx_<table>_<cols>`
828 /// otherwise — so the matching [`DropIndex`](Operation::DropIndex) can
829 /// name it. Rendered `IF NOT EXISTS` on both backends, so it is a safe
830 /// no-op when a same-migration `AlterColumn` already rebuilt the table
831 /// with the constraint (the SQLite dance) — the two never conflict.
832 AddIndex {
833 table: String,
834 columns: Vec<String>,
835 #[serde(default)]
836 unique: bool,
837 },
838 /// Drop a composite index / UNIQUE constraint previously created by an
839 /// [`AddIndex`](Operation::AddIndex), or by a `CreateTable` that renders
840 /// its `unique_together`/`indexes` as the same deterministically-named
841 /// indexes. Emitted by `diff` when a model LOSES a `unique_together`
842 /// group or `indexes` entry. Rendered `DROP INDEX IF EXISTS <name>` on
843 /// both backends (the name is recomputed from `table` + `columns` +
844 /// `unique`, matching `AddIndex`).
845 DropIndex {
846 table: String,
847 columns: Vec<String>,
848 #[serde(default)]
849 unique: bool,
850 },
851}
852
853impl Operation {
854 /// The primary table this operation targets. For `RenameTable`,
855 /// returns the source name (the post-rename `to` lives in the new
856 /// snapshot, but routing decisions look up the model meta by its
857 /// pre-rename `from`).
858 ///
859 /// Used by `run_in`'s per-DB dispatch loop to route each op to the
860 /// pool where its table actually lives.
861 pub fn table_name(&self) -> &str {
862 match self {
863 Operation::CreateView { name, .. } | Operation::DropView { name, .. } => name,
864 Operation::CreateTable { table, .. }
865 | Operation::DropTable { table }
866 | Operation::AddColumn { table, .. }
867 | Operation::DropColumn { table, .. }
868 | Operation::AlterColumn { table, .. }
869 | Operation::RenameColumn { table, .. }
870 | Operation::SetColumnComment { table, .. }
871 | Operation::AddIndex { table, .. }
872 | Operation::DropIndex { table, .. } => table,
873 Operation::RenameTable { from, .. } => from,
874 Operation::CreateM2MTable { junction_table, .. }
875 | Operation::DropM2MTable { junction_table } => junction_table,
876 // A data migration targets no single table. The empty name
877 // routes it to the `"default"` alias via `table_alias`'s
878 // fallback (see `op_targets_alias`).
879 Operation::RunSql { .. } => "",
880 }
881 }
882}
883
884/// One column inside a [`Operation::CreateTable`].
885///
886/// Mirrors the structure of [`FieldSpec`] but is fully owned for
887/// serialisation. Reconstructed from a `FieldSpec` at diff time.
888#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
889pub struct Column {
890 pub name: String,
891 pub ty: SqlType,
892 pub primary_key: bool,
893 pub nullable: bool,
894 /// For `SqlType::ForeignKey` columns: the SQL table name of the
895 /// referenced model. `None` for all non-FK columns.
896 #[serde(default, skip_serializing_if = "Option::is_none")]
897 pub fk_target: Option<String>,
898 /// When `true`, this field is never shown on any admin form (create or
899 /// edit). Propagated from `FieldSpec::noform`.
900 #[serde(default)]
901 pub noform: bool,
902 /// When `true`, this field is a privileged/server-managed column that the
903 /// untrusted JSON write path (`insert_json`/`update_json`, i.e. REST
904 /// create/update + admin form-submit) strips UNLESS the caller explicitly
905 /// authorizes it via [`DynQuerySet::allow_privileged`]. Propagated from
906 /// `FieldSpec::privileged`. This is the default-DENY guard for mass
907 /// assignment of fields like `is_superuser`/`is_staff`/ownership FKs
908 /// (audit_2 H3): they can't be set by an unprivileged writer even if the
909 /// model exposes them on its create/update surface. Purely a write-auth
910 /// concern — never part of the schema shape, so it is skipped from
911 /// migration snapshots when at its `false` default.
912 #[serde(default, skip_serializing_if = "is_false")]
913 pub privileged: bool,
914 /// Confidential: stripped from serialized output unless the read unlocks it via
915 /// `DynQuerySet::allow_private`. Propagated from `FieldSpec::private`. Purely a
916 /// read-auth concern — never part of the schema shape, so it is skipped from migration
917 /// snapshots at its `false` default, exactly like `privileged`.
918 #[serde(default, skip_serializing_if = "is_false")]
919 pub private: bool,
920 /// Never serialized to a client, with no unlock. Propagated from `FieldSpec::secret`
921 /// (and set automatically for every `Masked<T>` field). Also not schema shape.
922 #[serde(default, skip_serializing_if = "is_false")]
923 pub secret: bool,
924 /// For FK columns: whether to emit a physical `FOREIGN KEY ...
925 /// REFERENCES` constraint. Propagated from `FieldSpec::db_constraint`.
926 /// `false` (set via `#[umbral(db_constraint = false)]`) keeps the
927 /// logical FK (column + `fk_target`) but renders no `REFERENCES`
928 /// clause — the only valid shape for a cross-database FK. Closes
929 /// gaps2 #22. Defaults to `true` so existing migration JSON
930 /// round-trips unchanged (omitted from JSON when at its default).
931 #[serde(default = "default_true", skip_serializing_if = "is_true")]
932 pub db_constraint: bool,
933 /// When `true`, this field appears on the edit form as read-only.
934 /// Propagated from `FieldSpec::noedit`.
935 #[serde(default)]
936 pub noedit: bool,
937 /// Display-string marker — propagated from
938 /// `FieldSpec::is_string_repr`. The admin uses the first column
939 /// with this flag as the default `list_display` label when no
940 /// explicit one is configured.
941 #[serde(default)]
942 pub is_string_repr: bool,
943 /// Display truncation cap — propagated from `FieldSpec::max_length`.
944 /// `0` means no truncation.
945 #[serde(default)]
946 pub max_length: u32,
947 /// Closed-set DB values for a choices column. Propagated from
948 /// `FieldSpec::choices`. Non-empty when the model field carries
949 /// `#[umbral(choices)]`; the migration engine emits a Postgres
950 /// `CHECK (col IN (...))` constraint when this slice is non-empty.
951 /// Empty for every non-choices column.
952 #[serde(default, skip_serializing_if = "Vec::is_empty")]
953 pub choices: Vec<String>,
954 /// Human labels matching `choices` position-for-position. Carried
955 /// alongside `choices` so the admin's `<select>` widget has labels
956 /// without the runtime needing to reflect on the model type.
957 #[serde(default, skip_serializing_if = "Vec::is_empty")]
958 pub choice_labels: Vec<String>,
959 /// SQL `DEFAULT` value — propagated from `FieldSpec::default`.
960 /// Empty string means no default. The migration engine reads this
961 /// at DDL-emit time for both `CREATE TABLE` and `ALTER TABLE ADD
962 /// COLUMN`. Set via `#[umbral(default = "...")]` on the model field.
963 #[serde(default, skip_serializing_if = "String::is_empty")]
964 pub default: String,
965 /// Distinguishes a multi-valued [`MultiChoice<E>`] column from a
966 /// single-valued choices column. Both share `ty: Text` plus the same
967 /// `choices` / `choice_labels` metadata; this flag is the only
968 /// signal that the value is a CSV. Empty / false for every other
969 /// column.
970 ///
971 /// [`MultiChoice<E>`]: crate::orm::MultiChoice
972 #[serde(default, skip_serializing_if = "is_false")]
973 pub is_multichoice: bool,
974
975 /// Carries `FieldSpec::unique` into the migration snapshot. The
976 /// DDL builders emit a `UNIQUE` clause on this column at
977 /// `CREATE TABLE` time when set. Default `false` keeps existing
978 /// migration JSON files round-tripping unchanged (the field is
979 /// omitted on serialise when default).
980 #[serde(default, skip_serializing_if = "is_false")]
981 pub unique: bool,
982
983 /// Carries `FieldSpec::on_delete` into the migration snapshot.
984 /// FK columns only — the DDL builders emit
985 /// `ON DELETE <action>` when this is anything other than
986 /// `NoAction`. Default `NoAction` is omitted from JSON so
987 /// existing migration files round-trip without churn.
988 #[serde(default, skip_serializing_if = "is_no_action")]
989 pub on_delete: crate::orm::FkAction,
990
991 /// Carries `FieldSpec::on_update` into the migration snapshot.
992 /// Same shape as `on_delete`; emits `ON UPDATE <action>`.
993 #[serde(default, skip_serializing_if = "is_no_action")]
994 pub on_update: crate::orm::FkAction,
995
996 /// Carries `FieldSpec::index` into the migration snapshot. The
997 /// CreateTable + AddColumn render paths emit a matching
998 /// `CREATE INDEX idx_<table>_<col>` for every column whose
999 /// flag is set. Default `false` keeps existing migration JSON
1000 /// round-tripping unchanged.
1001 #[serde(default, skip_serializing_if = "is_false")]
1002 pub index: bool,
1003
1004 /// Carries `FieldSpec::auto_now_add` into the migration
1005 /// snapshot. The dynamic write path (`DynQuerySet::insert_json`)
1006 /// auto-populates the column with `Utc::now()` when the body
1007 /// omits it. Default `false` so existing migration JSON
1008 /// round-trips unchanged.
1009 #[serde(default, skip_serializing_if = "is_false")]
1010 pub auto_now_add: bool,
1011 /// Carries `FieldSpec::auto_user_add` to the runtime (dyn) write path, so
1012 /// admin and REST stamp the author too — they run on `DynQuerySet`, not the
1013 /// typed one. Not a schema property: skipped in snapshots.
1014 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1015 pub auto_user_add: bool,
1016 /// Carries `FieldSpec::auto_user`.
1017 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1018 pub auto_user: bool,
1019
1020 /// Carries `FieldSpec::auto_now` into the migration snapshot.
1021 /// Same shape as `auto_now_add` but fires on update too.
1022 #[serde(default, skip_serializing_if = "is_false")]
1023 pub auto_now: bool,
1024
1025 /// Carries `FieldSpec::trim` into the snapshot. Behavioral (dynamic write
1026 /// path strips surrounding whitespace), not schema-affecting — excluded
1027 /// from the schema diff like `auto_now`. Default `false` so existing
1028 /// migration JSON round-trips unchanged.
1029 #[serde(default, skip_serializing_if = "is_false")]
1030 pub trim: bool,
1031
1032 /// Carries `FieldSpec::lowercase` into the snapshot. Behavioral (dynamic
1033 /// write path lowercases the value), not schema-affecting. Default `false`
1034 /// so existing migration JSON round-trips unchanged.
1035 #[serde(default, skip_serializing_if = "is_false")]
1036 pub lowercase: bool,
1037
1038 /// Carries `FieldSpec::case_insensitive` into the snapshot. Schema-affecting
1039 /// (Postgres `citext` / SQLite `COLLATE NOCASE`), but — like `unique` — the
1040 /// diff comparator (`column_shape`) does NOT watch it, so it applies at
1041 /// CREATE TABLE and toggling it on a live column needs a hand-written
1042 /// migration. Default `false` so existing migration JSON round-trips.
1043 #[serde(default, skip_serializing_if = "is_false")]
1044 pub case_insensitive: bool,
1045
1046 /// Carries `FieldSpec::help` into the migration snapshot.
1047 /// Default empty string is omitted from JSON so existing
1048 /// migration files round-trip unchanged.
1049 #[serde(default, skip_serializing_if = "String::is_empty")]
1050 pub help: String,
1051
1052 /// Carries `FieldSpec::example` into the migration snapshot.
1053 /// Same shape as `help`.
1054 #[serde(default, skip_serializing_if = "String::is_empty")]
1055 pub example: String,
1056
1057 /// Carries `FieldSpec::widget` into the migration snapshot — the
1058 /// form-renderer presentation hint (features.md #4). Presentation
1059 /// only, no DB effect, so it's excluded from the schema diff the
1060 /// same way `help` / `example` are. `None` is omitted from JSON so
1061 /// existing migration files round-trip unchanged.
1062 #[serde(default, skip_serializing_if = "Option::is_none")]
1063 pub widget: Option<String>,
1064
1065 /// Carries `FieldSpec::supported_backends` into the migration
1066 /// snapshot. When non-empty, the boot system check rejects the
1067 /// model on any backend not listed. Closes IMP-5 from
1068 /// `bugs/tests/testBugs.md`. Default empty (works on every
1069 /// backend); JSON skip-when-empty so existing migration files
1070 /// don't churn.
1071 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1072 pub supported_backends: Vec<String>,
1073
1074 /// IMP-3: numeric lower bound. `None` means "no minimum"; the
1075 /// DDL emits a `CHECK (col >= N)` constraint when set.
1076 #[serde(default, skip_serializing_if = "Option::is_none")]
1077 pub min: Option<i64>,
1078
1079 /// IMP-3: numeric upper bound. Same shape as `min`.
1080 #[serde(default, skip_serializing_if = "Option::is_none")]
1081 pub max: Option<i64>,
1082
1083 /// BUG-11/12/13: constrained-text marker. `None` is plain text;
1084 /// `Some("slug" | "email" | "url")` flags the column as a
1085 /// `Slug` / `Email` / `Url` wrapper. OpenAPI emits the
1086 /// corresponding `format` / `pattern`; the REST plugin
1087 /// pre-validates the body via `validate_text_format`.
1088 #[serde(default, skip_serializing_if = "Option::is_none")]
1089 pub text_format: Option<String>,
1090
1091 /// Gap 109: auto-derive source. When `Some("title")`, the slug is
1092 /// computed from the row's `title` column at write time if the
1093 /// slug column itself is empty / missing on the body. Pure
1094 /// runtime behaviour — has no DDL effect, so the diff engine
1095 /// ignores changes to this field. `#[serde(default)]` keeps
1096 /// older snapshots round-tripping.
1097 #[serde(default, skip_serializing_if = "Option::is_none")]
1098 pub slug_from: Option<String>,
1099}
1100
1101fn is_no_action(a: &crate::orm::FkAction) -> bool {
1102 matches!(a, crate::orm::FkAction::NoAction)
1103}
1104
1105/// Build a portable `CREATE INDEX IF NOT EXISTS idx_<table>_<col>
1106/// ON "<table>" ("<col>")` statement. Same DDL on SQLite and
1107/// Postgres — both accept `CREATE INDEX IF NOT EXISTS` and the
1108/// `idx_<table>_<col>` name convention is unique enough that the
1109/// migration engine can re-emit it idempotently on subsequent
1110/// applies. Used by [`render_operation_sqlite`] / `_postgres`
1111/// after a `CreateTable` or `AddColumn` op whose column carries
1112/// the `#[umbral(index)]` flag. Closes BUG-4.
1113/// Postgres `COMMENT ON COLUMN` for one column (gaps3 #43).
1114///
1115/// An empty `comment` renders `IS NULL`, which is Postgres's "this column has no
1116/// comment" — distinct from `IS ''`, an empty comment that `\d+` would print as
1117/// a blank line.
1118///
1119/// Help text is prose, and prose has apostrophes, so the literal is escaped by
1120/// doubling single quotes. The value originates in a `#[umbral(help = "...")]`
1121/// attribute (a compile-time literal, not user input), but a migration file is
1122/// hand-editable and an unescaped quote would produce a syntax error at apply
1123/// time rather than a diagnostic at generate time.
1124fn comment_on_column_stmt(table: &str, column: &str, comment: &str) -> String {
1125 let t = table.replace('"', "\"\"");
1126 let c = column.replace('"', "\"\"");
1127 if comment.is_empty() {
1128 return format!("COMMENT ON COLUMN \"{t}\".\"{c}\" IS NULL");
1129 }
1130 let body = comment.replace('\'', "''");
1131 format!("COMMENT ON COLUMN \"{t}\".\"{c}\" IS '{body}'")
1132}
1133
1134fn create_index_stmt(table: &str, column: &str) -> String {
1135 let t = table.replace('"', "\"\"");
1136 let c = column.replace('"', "\"\"");
1137 format!(
1138 "CREATE INDEX IF NOT EXISTS \"idx_{table}_{column}\" ON \"{t}\" (\"{c}\")",
1139 table = table.replace('"', ""),
1140 column = column.replace('"', ""),
1141 )
1142}
1143
1144/// Build a Postgres `CREATE INDEX ... USING GIN` for a `tsvector`
1145/// (`SqlType::FullText`) column (#33). A tsvector column is useless for
1146/// search without a GIN index, so the migration engine emits one
1147/// automatically for every full-text column — the caller never has to
1148/// hand-write it. **Postgres-only**: GIN is Postgres syntax and FullText
1149/// columns are system-check-gated to Postgres, so this only ever renders
1150/// from `render_operation_postgres`. The `_gin` name suffix keeps it
1151/// distinct from any plain index on the same column.
1152fn create_gin_index_stmt(table: &str, column: &str) -> String {
1153 let t = table.replace('"', "\"\"");
1154 let c = column.replace('"', "\"\"");
1155 format!(
1156 "CREATE INDEX IF NOT EXISTS \"idx_{table}_{column}_gin\" ON \"{t}\" USING GIN (\"{c}\")",
1157 table = table.replace('"', ""),
1158 column = column.replace('"', ""),
1159 )
1160}
1161
1162/// Multi-column variant of [`create_index_stmt`]. Closes BUG-7.
1163/// Renders `CREATE INDEX IF NOT EXISTS idx_<table>_<col1>_<col2>
1164/// ON "<table>" ("<col1>", "<col2>")`. Both backends accept the
1165/// same form. Empty groups render no statement (defensive — the
1166/// macro layer rejects them before the engine sees them, but the
1167/// helper still returns a no-op SQL string to keep the caller
1168/// simple).
1169fn create_multi_index_stmt(table: &str, columns: &[String]) -> String {
1170 // A plain composite index IS an `AddIndex { unique: false }` render —
1171 // delegate so the NAME (`idx_<table>_<cols>`) is defined in exactly one
1172 // place and a `CreateTable`'s composite index and a later `DropIndex`
1173 // always agree on it.
1174 add_index_stmt(table, columns, false)
1175}
1176
1177/// Deterministic name for a composite index. `unique` selects the `uniq_`
1178/// prefix (a `unique_together` group), otherwise `idx_`. Derived purely
1179/// from the quote-stripped table + column list so an [`Operation::AddIndex`]
1180/// and the later [`Operation::DropIndex`] that reverses it always compute
1181/// the same name. The `uniq_`/`idx_` split means a UNIQUE and a plain index
1182/// on the SAME columns never collide.
1183fn index_name(table: &str, columns: &[String], unique: bool) -> String {
1184 let t = table.replace('"', "");
1185 let suffix = columns
1186 .iter()
1187 .map(|c| c.replace('"', ""))
1188 .collect::<Vec<_>>()
1189 .join("_");
1190 let prefix = if unique { "uniq" } else { "idx" };
1191 format!("{prefix}_{t}_{suffix}")
1192}
1193
1194/// `CREATE [UNIQUE] INDEX IF NOT EXISTS "<name>" ON "<table>" (cols)` —
1195/// identical syntax on SQLite and Postgres. The index NAME is a bare
1196/// identifier (via [`index_name`]); the ON-clause table reference is a
1197/// *quoted* identifier with inner quotes doubled. An empty column list
1198/// renders an empty string (defensive no-op; the macro layer rejects
1199/// empty groups upstream).
1200fn add_index_stmt(table: &str, columns: &[String], unique: bool) -> String {
1201 if columns.is_empty() {
1202 return String::new();
1203 }
1204 let name = index_name(table, columns, unique);
1205 let t_esc = table.replace('"', "\"\"");
1206 let col_list = columns
1207 .iter()
1208 .map(|c| format!("\"{}\"", c.replace('"', "\"\"")))
1209 .collect::<Vec<_>>()
1210 .join(", ");
1211 let unique_kw = if unique { "UNIQUE " } else { "" };
1212 format!("CREATE {unique_kw}INDEX IF NOT EXISTS \"{name}\" ON \"{t_esc}\" ({col_list})")
1213}
1214
1215/// `DROP INDEX IF EXISTS "<name>"` — same on both backends. Postgres
1216/// resolves the unqualified name via the search_path (so a schema-per-tenant
1217/// migrate drops the index inside the active schema).
1218fn drop_index_stmt(name: &str) -> String {
1219 let n = name.replace('"', "\"\"");
1220 format!("DROP INDEX IF EXISTS \"{n}\"")
1221}
1222
1223/// Lower an M2M junction column's PK type into the SQLite column
1224/// declaration string used inside the raw `CREATE TABLE` template.
1225/// SQLite has affinity types: every integer width stores as `INTEGER`
1226/// (one ROWID-aliased column), and TEXT covers `String` / `Uuid`.
1227/// Closes BUG-16 phase 2.
1228fn m2m_pk_sql_type_sqlite(ty: crate::orm::SqlType) -> &'static str {
1229 use crate::orm::SqlType;
1230 match ty {
1231 SqlType::SmallInt | SqlType::Integer | SqlType::BigInt | SqlType::ForeignKey => "INTEGER",
1232 SqlType::Text | SqlType::Uuid => "TEXT",
1233 // The macro-side classifier only sets these for PK columns
1234 // when the user wrote a non-standard PK type. If we ever
1235 // see one here that doesn't make sense as a junction column
1236 // (Boolean, Date, Real, …), TEXT is the safest catch-all
1237 // affinity — SQLite will accept it and the rest of the
1238 // ORM will surface the deeper "this can't be a PK" error
1239 // through the system check.
1240 _ => "TEXT",
1241 }
1242}
1243
1244/// Lower an M2M junction column's PK type into the Postgres column
1245/// declaration string. Postgres is strict about types — `BIGINT` for
1246/// 64-bit integers, `INTEGER` for 32-bit, `SMALLINT` for 16-bit,
1247/// `TEXT` for `String`, `UUID` for `uuid::Uuid`. Mirrors the choices
1248/// `build_column_def_postgres` makes for the same `SqlType` variants.
1249fn m2m_pk_sql_type_postgres(ty: crate::orm::SqlType) -> &'static str {
1250 use crate::orm::SqlType;
1251 match ty {
1252 SqlType::SmallInt => "SMALLINT",
1253 SqlType::Integer => "INTEGER",
1254 SqlType::BigInt | SqlType::ForeignKey => "BIGINT",
1255 SqlType::Text => "TEXT",
1256 SqlType::Uuid => "UUID",
1257 _ => "TEXT",
1258 }
1259}
1260
1261/// Build the ` ON DELETE <action> ON UPDATE <action>` suffix for a
1262/// FK column. Each half is emitted only when its action is anything
1263/// other than `NoAction` — keeps the generated DDL minimal and
1264/// matches the SQL standard's default (NO ACTION when the clause is
1265/// omitted).
1266///
1267/// Closes gap #68. Shared between the SQLite and Postgres builders
1268/// because the REFERENCES tail syntax is identical on both.
1269fn fk_action_suffix(col: &Column) -> String {
1270 let mut s = String::new();
1271 if let Some(kw) = col.on_delete.sql_keyword() {
1272 s.push_str(" ON DELETE ");
1273 s.push_str(kw);
1274 }
1275 if let Some(kw) = col.on_update.sql_keyword() {
1276 s.push_str(" ON UPDATE ");
1277 s.push_str(kw);
1278 }
1279 s
1280}
1281
1282fn is_false(b: &bool) -> bool {
1283 !*b
1284}
1285
1286/// serde default for `Column::db_constraint`: a FK emits its physical
1287/// `REFERENCES` constraint unless the model opts out. Older migration
1288/// JSON predating gaps2 #22 has no `db_constraint` key, so it must
1289/// deserialize as `true` to preserve the historical "always emit"
1290/// behaviour.
1291fn default_true() -> bool {
1292 true
1293}
1294
1295fn is_true(b: &bool) -> bool {
1296 *b
1297}
1298
1299impl From<&FieldSpec> for Column {
1300 fn from(f: &FieldSpec) -> Self {
1301 Self {
1302 name: f.name.to_string(),
1303 ty: f.ty,
1304 primary_key: f.primary_key,
1305 nullable: f.nullable,
1306 fk_target: f.fk_target.map(|s| s.to_string()),
1307 noform: f.noform,
1308 privileged: f.privileged,
1309 private: f.private,
1310 secret: f.secret,
1311 db_constraint: f.db_constraint,
1312 noedit: f.noedit,
1313 is_string_repr: f.is_string_repr,
1314 max_length: f.max_length,
1315 choices: f.choices.iter().map(|s| s.to_string()).collect(),
1316 choice_labels: f.choice_labels.iter().map(|s| s.to_string()).collect(),
1317 default: f.default.to_string(),
1318 is_multichoice: f.is_multichoice,
1319 unique: f.unique,
1320 on_delete: f.on_delete,
1321 on_update: f.on_update,
1322 index: f.index,
1323 auto_now_add: f.auto_now_add,
1324 auto_user_add: f.auto_user_add,
1325 auto_user: f.auto_user,
1326 auto_now: f.auto_now,
1327 trim: f.trim,
1328 lowercase: f.lowercase,
1329 case_insensitive: f.case_insensitive,
1330 help: f.help.to_string(),
1331 example: f.example.to_string(),
1332 widget: f.widget.map(|s| s.to_string()),
1333 supported_backends: f.supported_backends.iter().map(|s| s.to_string()).collect(),
1334 min: f.min,
1335 max: f.max,
1336 text_format: f.text_format.map(|s| s.to_string()),
1337 slug_from: f.slug_from.map(|s| s.to_string()),
1338 }
1339 }
1340}
1341
1342/// The on-disk shape of one migration. Files in `migrations/<plugin>/`
1343/// deserialize into this struct.
1344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1345pub struct MigrationFile {
1346 /// Stable id, matches the filename minus `.json`.
1347 pub id: String,
1348 /// The plugin that owns this migration. M5 hardcodes `"app"` for
1349 /// the user's binary; M7 generalises to one directory per plugin.
1350 pub plugin: String,
1351 /// Predecessor migrations, in `(plugin, id)` form. Within-plugin
1352 /// predecessors are implicit (the prior numeric file); cross-
1353 /// plugin predecessors land at M7.
1354 #[serde(default)]
1355 pub depends_on: Vec<MigrationRef>,
1356 /// Ordered operations applied when this migration runs.
1357 pub operations: Vec<Operation>,
1358 /// The full snapshot of every model after this migration has run.
1359 /// Source of truth for the next `make` to diff against.
1360 pub snapshot_after: Snapshot,
1361 /// gaps2 #100: when non-empty, this is a *squash* — it collapses the
1362 /// listed predecessor migrations into one optimized file. The originals
1363 /// are kept on disk (non-destructive) so older deploys still migrate; the
1364 /// runner treats the squash and its replaced set as mutually exclusive
1365 /// (see [`squash_plan`]). Empty for an ordinary migration; `#[serde(default)]`
1366 /// so every pre-squash file on disk deserializes unchanged.
1367 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1368 pub replaces: Vec<MigrationRef>,
1369}
1370
1371/// A pointer to one (plugin, migration_id) pair.
1372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1373pub struct MigrationRef {
1374 pub plugin: String,
1375 pub migration: String,
1376}
1377
1378/// gaps2 #100 — what the runner does with one on-disk migration file, given the
1379/// tracking-table's applied-set. The decision is pure and backend-agnostic; the
1380/// only DB-specific step downstream is *how* a tracking row is written, never
1381/// *whether* the file's operations run.
1382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1383pub enum ApplyDecision {
1384 /// Run the file's operations, then record it in the tracking table. The
1385 /// normal "pending migration" path.
1386 Apply,
1387 /// Do nothing: the migration is already applied, or it's an original that a
1388 /// squash the runner is using has shadowed (running it too would
1389 /// double-apply the schema the squash already built).
1390 Skip,
1391 /// A squash whose entire replaced set is already applied. Its operations
1392 /// would rebuild schema that already exists, so DON'T run them — but insert
1393 /// a tracking row so future runs treat the squash as applied and the now
1394 /// redundant original files can be deleted.
1395 RecordOnly,
1396}
1397
1398/// gaps2 #100 — decide, per on-disk migration file for ONE plugin, whether to
1399/// [`Apply`](ApplyDecision::Apply) / [`Skip`](ApplyDecision::Skip) /
1400/// [`RecordOnly`](ApplyDecision::RecordOnly) it, honoring squash `replaces`.
1401///
1402/// `files` is every migration file for the plugin (the returned Vec is in the
1403/// same order). `applied` is the `(plugin, id)` set from the tracking table.
1404/// The function is pure: no DB, no IO — so both the SQLite and Postgres apply
1405/// loops route through it and inherit identical squash semantics.
1406///
1407/// For a squash `S` replacing set `R`:
1408/// - **all of `R` applied** → `S` is `RecordOnly` (or `Skip` if `S` itself is
1409/// already recorded); every member of `R` is `Skip` (already applied).
1410/// - **none of `R` applied** → `S` is `Apply` (or `Skip` if already recorded);
1411/// every member of `R` is `Skip` (the squash builds their schema).
1412/// - **some of `R` applied** (an interrupted transition) → fall back to the
1413/// individual originals if they are ALL still on disk: `S` is `Skip` and each
1414/// unapplied member of `R` applies as usual. If any original is gone, the
1415/// history can't be reconciled → [`MigrateError::SquashInconsistent`].
1416///
1417/// A plain migration (empty `replaces`) is `Skip` if applied or shadowed by an
1418/// active squash, else `Apply`.
1419fn squash_plan(
1420 files: &[MigrationFile],
1421 applied: &std::collections::HashSet<(String, String)>,
1422) -> Result<Vec<ApplyDecision>, MigrateError> {
1423 use std::collections::HashSet;
1424
1425 let is_applied =
1426 |plugin: &str, id: &str| applied.contains(&(plugin.to_string(), id.to_string()));
1427 let on_disk: HashSet<&str> = files.iter().map(|f| f.id.as_str()).collect();
1428
1429 let mut decisions = vec![ApplyDecision::Apply; files.len()];
1430 // Ids covered by a squash the runner is USING (fully-applied or fully-
1431 // unapplied). Their originals must be skipped so they don't double-apply.
1432 let mut shadowed: HashSet<String> = HashSet::new();
1433
1434 // First pass: classify every squash and collect the shadow set.
1435 for (i, f) in files.iter().enumerate() {
1436 if f.replaces.is_empty() {
1437 continue;
1438 }
1439 let applied_in_r = f
1440 .replaces
1441 .iter()
1442 .filter(|m| is_applied(&m.plugin, &m.migration))
1443 .count();
1444 let self_applied = is_applied(&f.plugin, &f.id);
1445
1446 if applied_in_r == f.replaces.len() {
1447 // Whole history already applied: adopt the squash without running it.
1448 decisions[i] = if self_applied {
1449 ApplyDecision::Skip
1450 } else {
1451 ApplyDecision::RecordOnly
1452 };
1453 for m in &f.replaces {
1454 shadowed.insert(m.migration.clone());
1455 }
1456 } else if applied_in_r == 0 {
1457 // Fresh: the squash builds the whole schema in one shot.
1458 decisions[i] = if self_applied {
1459 ApplyDecision::Skip
1460 } else {
1461 ApplyDecision::Apply
1462 };
1463 for m in &f.replaces {
1464 shadowed.insert(m.migration.clone());
1465 }
1466 } else {
1467 // Partial transition: prefer the originals if they all survive.
1468 let missing: Vec<String> = f
1469 .replaces
1470 .iter()
1471 .filter(|m| !on_disk.contains(m.migration.as_str()))
1472 .map(|m| format!("{}/{}", m.plugin, m.migration))
1473 .collect();
1474 if missing.is_empty() {
1475 // Inactive squash: skip it, let the originals run individually.
1476 decisions[i] = ApplyDecision::Skip;
1477 } else {
1478 return Err(MigrateError::SquashInconsistent {
1479 plugin: f.plugin.clone(),
1480 squash: f.id.clone(),
1481 missing,
1482 });
1483 }
1484 }
1485 }
1486
1487 // Second pass: plain migrations (applied or shadowed → Skip, else Apply).
1488 for (i, f) in files.iter().enumerate() {
1489 if !f.replaces.is_empty() {
1490 continue;
1491 }
1492 if shadowed.contains(&f.id) || is_applied(&f.plugin, &f.id) {
1493 decisions[i] = ApplyDecision::Skip;
1494 }
1495 }
1496
1497 // A squash can itself be shadowed by a larger squash (squash-of-squashes):
1498 // an active outer squash covers it, so it must not run either.
1499 for (i, f) in files.iter().enumerate() {
1500 if shadowed.contains(&f.id) {
1501 decisions[i] = ApplyDecision::Skip;
1502 }
1503 }
1504
1505 Ok(decisions)
1506}
1507
1508/// At M5 every migration belongs to a single placeholder plugin. M7's
1509/// Plugin contract replaces this with `Plugin::name()`.
1510pub const APP_PLUGIN_NAME: &str = "app";
1511
1512/// Default directory for migration files. `make` writes into
1513/// `migrations/<plugin>/`; `run` reads from the same place. Override
1514/// with `--migrations-dir` once the CLI grows real arg parsing (M5+).
1515pub const MIGRATIONS_DIR: &str = "migrations";
1516
1517/// The state of a single migration from the perspective of drift detection.
1518/// Returned inside [`DriftReport`] so callers can decide how to handle each
1519/// state independently.
1520#[derive(Debug, Clone, PartialEq, Eq)]
1521pub enum MigrationStatus {
1522 /// The migration is recorded in the tracking table AND the file
1523 /// exists on disk. Normal applied state.
1524 Applied,
1525 /// The migration is recorded in the tracking table BUT the
1526 /// corresponding file is missing from disk. The database is ahead
1527 /// of what version control has; recovering requires restoring the
1528 /// file or running with `--allow-drift`.
1529 AppliedButMissing,
1530 /// The migration file exists on disk AND its sequence number is
1531 /// lower than the highest applied migration for this plugin, but it
1532 /// is not recorded in the tracking table. Looks like someone dropped
1533 /// a migration file back into a directory after a teammate already
1534 /// applied later ones. Should warn, not error.
1535 OutOfOrder,
1536 /// Normal pending state: the file is on disk and its sequence number
1537 /// is higher than anything applied. Ready to apply.
1538 Pending,
1539}
1540
1541/// Per-migration entry inside a [`DriftReport`].
1542#[derive(Debug, Clone, PartialEq, Eq)]
1543pub struct MigrationEntry {
1544 pub plugin: String,
1545 pub name: String,
1546 pub status: MigrationStatus,
1547}
1548
1549/// The output of [`detect_drift`]: one entry per migration (applied or
1550/// on-disk), categorised into the four states above.
1551///
1552/// The caller inspects `has_critical_drift()` to decide whether to abort
1553/// before applying migrations. Surfaced by `show_in_with_drift` for
1554/// `showmigrations` and checked by `run_in_with_drift_check` before
1555/// executing any SQL.
1556#[derive(Debug, Clone, Default)]
1557pub struct DriftReport {
1558 pub entries: Vec<MigrationEntry>,
1559}
1560
1561impl DriftReport {
1562 /// Returns true when at least one migration is `AppliedButMissing`.
1563 /// This state means the tracking table references a file that no
1564 /// longer exists on disk — the operator needs to act before it is
1565 /// safe to continue applying new migrations.
1566 pub fn has_critical_drift(&self) -> bool {
1567 self.entries
1568 .iter()
1569 .any(|e| e.status == MigrationStatus::AppliedButMissing)
1570 }
1571
1572 /// All migrations with `AppliedButMissing` status. Convenience
1573 /// accessor for building the error message.
1574 pub fn missing_on_disk(&self) -> Vec<&MigrationEntry> {
1575 self.entries
1576 .iter()
1577 .filter(|e| e.status == MigrationStatus::AppliedButMissing)
1578 .collect()
1579 }
1580
1581 /// Migrations on disk this binary knows about that the database has NOT
1582 /// applied yet — the readiness blocker (Kikosi #5 / gaps3 #38). Empty means
1583 /// the schema is at least as new as this binary's code.
1584 ///
1585 /// Deliberately excludes `AppliedButMissing` (the database is *ahead*: a
1586 /// valid rollback / backward-compatible-migration state where an older
1587 /// binary should keep serving) and `OutOfOrder` (a stray file the migrate
1588 /// engine only warns about). Only `Pending` — "the schema is behind the
1589 /// code" — should hold traffic off a freshly-booted pod.
1590 pub fn pending(&self) -> Vec<&MigrationEntry> {
1591 self.entries
1592 .iter()
1593 .filter(|e| e.status == MigrationStatus::Pending)
1594 .collect()
1595 }
1596}
1597
1598/// Errors the migration engine can produce.
1599#[derive(Debug)]
1600pub enum MigrateError {
1601 /// IO error reading or writing a migration file or directory.
1602 Io(std::io::Error),
1603 /// JSON parse error on a migration file.
1604 Json(serde_json::Error),
1605 /// sqlx error executing a migration's SQL or touching the
1606 /// tracking table.
1607 Sqlx(sqlx::Error),
1608 /// `make` ran but found no differences against the latest snapshot,
1609 /// so there's nothing to write. Surfaced so the CLI can print
1610 /// "no changes detected" instead of an empty migration file.
1611 NoChanges,
1612 /// The current models diverge from the snapshot in a way M5 v1
1613 /// can't represent yet (anything other than create/drop table).
1614 /// M5.1 lifts this when column-level ops land.
1615 UnsupportedChange(String),
1616 /// A column-level change the engine can't apply automatically:
1617 /// type change, or a nullable flip on a populated SQLite table.
1618 /// Surfaces from `diff` so the build stops before producing a
1619 /// migration that would lose data or fail to apply. The user
1620 /// resolves by hand-writing the migration with the appropriate
1621 /// data-preserving steps. Carries the model / column / reason.
1622 UnsafeAlter {
1623 model: String,
1624 column: String,
1625 reason: String,
1626 },
1627 /// The tracking table records migrations that no longer have
1628 /// corresponding files on disk. Carries the list of missing names.
1629 /// The operator must either restore the files from VCS or run with
1630 /// `--allow-drift` to proceed despite the inconsistency.
1631 DriftDetected { missing: Vec<(String, String)> },
1632 /// A schema-scoped migration ([`run_for_schema`]) was requested against a
1633 /// SQLite pool. SQLite has no schemas, so schema-per-tenant is Postgres-only
1634 /// (mirrors how `Inet`/`Cidr` gate on backend). Carries the schema name.
1635 SchemaUnsupportedOnSqlite { schema: String },
1636 /// `makemigrations --empty <plugin>` named a plugin that isn't
1637 /// registered. Carries the requested name and the registered set so
1638 /// the CLI can list the valid choices.
1639 UnknownPlugin {
1640 requested: String,
1641 known: Vec<String>,
1642 },
1643 /// audit_2 H23 — the column-shape rename heuristic found an unpaired
1644 /// dropped model and an unpaired created model with **identical** column
1645 /// shapes. That's genuinely ambiguous: it's either a model rename (move the
1646 /// old table's rows to the new name) or two unrelated models that happen to
1647 /// share a shape (drop the old, create the new empty). Auto-applying either
1648 /// silently loses or mis-associates data, so `diff` refuses to guess and
1649 /// fails closed. The operator resolves it explicitly via
1650 /// `UMBRAL_MIGRATIONS_ASSUME_RENAMES` (`assume` → rename, `independent` →
1651 /// drop+create) or by hand-writing the op. Carries the two table names.
1652 AmbiguousRename {
1653 from_table: String,
1654 to_table: String,
1655 },
1656 /// audit_2 core-migrate #7 — couldn't acquire the Postgres migration
1657 /// advisory lock within the timeout: another process has been holding it
1658 /// (running a long migration, or wedged). Carries the alias/schema the lock
1659 /// was keyed on and the seconds waited. The operator retries once the other
1660 /// migrator finishes, or investigates a stuck migration.
1661 MigrationLockTimeout {
1662 discriminator: String,
1663 waited_secs: u64,
1664 },
1665 /// gaps2 #100 — a squash migration is stuck in a partially-applied state:
1666 /// SOME of the migrations it replaces are recorded as applied and others
1667 /// aren't, and the unapplied originals are no longer on disk to fall back
1668 /// to. The engine can't safely apply the squash (it would re-run schema the
1669 /// applied originals already built) nor complete the originals (their files
1670 /// are gone). Carries the plugin and squash id. The operator restores the
1671 /// missing original files from VCS (finish the transition), or resets the
1672 /// tracking rows for this plugin to a consistent point.
1673 SquashInconsistent {
1674 plugin: String,
1675 squash: String,
1676 missing: Vec<String>,
1677 },
1678 /// gaps2 #100 — `squashmigrations <plugin>` couldn't produce a squash:
1679 /// fewer than two squashable migrations, an unknown plugin, or the history
1680 /// already contains a squash (nested squashing isn't supported yet). Carries
1681 /// the plugin and a human-readable reason.
1682 CannotSquash { plugin: String, reason: String },
1683}
1684
1685impl std::fmt::Display for MigrateError {
1686 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1687 match self {
1688 MigrateError::Io(e) => write!(f, "umbral migrate: io: {e}"),
1689 MigrateError::Json(e) => write!(f, "umbral migrate: json: {e}"),
1690 MigrateError::Sqlx(e) => write!(f, "umbral migrate: sqlx: {e}"),
1691 MigrateError::NoChanges => write!(
1692 f,
1693 "umbral migrate: no changes detected; declare or change a model first"
1694 ),
1695 MigrateError::UnsupportedChange(msg) => {
1696 write!(f, "umbral migrate: unsupported change at M5 v1: {msg}")
1697 }
1698 MigrateError::UnsafeAlter {
1699 model,
1700 column,
1701 reason,
1702 } => write!(
1703 f,
1704 "umbral migrate: unsafe column change on `{model}.{column}`: {reason}; \
1705 hand-write the migration with a data-preserving step"
1706 ),
1707 MigrateError::DriftDetected { missing } => {
1708 let names: Vec<String> = missing
1709 .iter()
1710 .map(|(plugin, name)| format!("{plugin}/{name}"))
1711 .collect();
1712 write!(
1713 f,
1714 "umbral migrate: drift detected — the following migrations are recorded in \
1715 the tracking table but their files are missing from disk:\n {}\n\
1716 Restore the files from VCS or run `umbral migrate --allow-drift` to \
1717 proceed despite the inconsistency.",
1718 names.join("\n ")
1719 )
1720 }
1721 MigrateError::SchemaUnsupportedOnSqlite { schema } => write!(
1722 f,
1723 "umbral migrate: schema-per-tenant migration into `{schema}` requires \
1724 Postgres; SQLite has no schemas. Point the app at a Postgres pool."
1725 ),
1726 MigrateError::UnknownPlugin { requested, known } => write!(
1727 f,
1728 "umbral makemigrations --empty: no registered plugin named `{requested}`. \
1729 Known plugins: {}",
1730 known.join(", ")
1731 ),
1732 MigrateError::AmbiguousRename {
1733 from_table,
1734 to_table,
1735 } => write!(
1736 f,
1737 "umbral makemigrations: ambiguous rename — the dropped model `{from_table}` and \
1738 the new model `{to_table}` have identical column shapes, so this is either a \
1739 rename (move `{from_table}`'s rows to `{to_table}`) or two unrelated models. \
1740 Refusing to guess: auto-renaming would hand one model's rows to another and skip \
1741 the intended drop, while auto-dropping would delete `{from_table}`'s rows — both \
1742 silent data bugs. Resolve it: set UMBRAL_MIGRATIONS_ASSUME_RENAMES=assume to \
1743 treat every shape match as a rename, or =independent to treat them as unrelated \
1744 (drop + create), or hand-write the intended op into the migration file."
1745 ),
1746 MigrateError::MigrationLockTimeout {
1747 discriminator,
1748 waited_secs,
1749 } => write!(
1750 f,
1751 "umbral migrate: timed out after {waited_secs}s waiting for the Postgres \
1752 migration lock (alias/schema `{discriminator}`). Another process is holding it — \
1753 a long-running migration on another replica, or a wedged migrator. Retry once it \
1754 finishes; if nothing is migrating, check for a stuck backend holding \
1755 pg_advisory_lock."
1756 ),
1757 MigrateError::SquashInconsistent {
1758 plugin,
1759 squash,
1760 missing,
1761 } => write!(
1762 f,
1763 "umbral migrate: squash `{plugin}/{squash}` is half-applied — some of the \
1764 migrations it replaces are recorded as applied and the unapplied ones are \
1765 missing from disk:\n {}\nRestore those original migration files from VCS so \
1766 the transition can finish, then re-run migrate.",
1767 missing.join("\n ")
1768 ),
1769 MigrateError::CannotSquash { plugin, reason } => write!(
1770 f,
1771 "umbral squashmigrations: can't squash `{plugin}`: {reason}"
1772 ),
1773 }
1774 }
1775}
1776
1777impl std::error::Error for MigrateError {}
1778
1779impl From<std::io::Error> for MigrateError {
1780 fn from(e: std::io::Error) -> Self {
1781 Self::Io(e)
1782 }
1783}
1784
1785impl From<serde_json::Error> for MigrateError {
1786 fn from(e: serde_json::Error) -> Self {
1787 Self::Json(e)
1788 }
1789}
1790
1791impl From<sqlx::Error> for MigrateError {
1792 fn from(e: sqlx::Error) -> Self {
1793 Self::Sqlx(e)
1794 }
1795}
1796
1797// =========================================================================
1798// Top-level entry points.
1799// =========================================================================
1800
1801/// Generate one migration file per registered plugin that has changes,
1802/// diffing each plugin's current model set against the latest snapshot
1803/// in `migrations/<plugin>/`. Each new file lands inside its own
1804/// plugin directory with the next sequence number and a `_<short_name>`
1805/// suffix derived from the dominant operation.
1806///
1807/// Returns the paths of every file written, one per plugin that had a
1808/// non-empty diff. Returns `MigrateError::NoChanges` if no plugin
1809/// produced any changes at all.
1810pub async fn make() -> Result<Vec<PathBuf>, MigrateError> {
1811 make_in(Path::new(MIGRATIONS_DIR)).await
1812}
1813
1814/// Same as [`make`] but takes an explicit base directory. Used by
1815/// tests to avoid touching the cwd.
1816///
1817/// Iterates [`plugin_order`], which is the topological order
1818/// published by `App::build()`'s phase 1.5 sort. Cross-plugin FKs
1819/// land in dependency order this way (a plugin's `CreateTable` for
1820/// the FK target runs before the dependent plugin's `CreateTable`).
1821/// Falls back to [`registered_plugins`] when no order has been
1822/// published (e.g. low-level tests that init the registry directly).
1823pub async fn make_in(dir: &Path) -> Result<Vec<PathBuf>, MigrateError> {
1824 let mut written: Vec<PathBuf> = Vec::new();
1825
1826 for plugin in plugin_order() {
1827 let plugin_dir = dir.join(&plugin);
1828
1829 // The previous snapshot is the `snapshot_after` of the highest-
1830 // numbered migration file (filenames are zero-padded so lexical
1831 // sort matches numeric order). An empty or missing directory
1832 // means "no prior state", the first-run case for this plugin.
1833 let existing = list_migration_files(&plugin_dir)?;
1834 let previous = match existing.last() {
1835 Some(path) => read_migration_file(path)?.snapshot_after,
1836 None => Snapshot::default(),
1837 };
1838
1839 let current = Snapshot::current_for(&plugin);
1840 let operations = diff(&previous, ¤t)?;
1841 if operations.is_empty() {
1842 continue;
1843 }
1844
1845 let seq = (existing.len() + 1) as u32;
1846 let suffix = suffix_for(&operations);
1847 let id = format!("{seq:04}_{suffix}");
1848 let filename = format!("{id}.json");
1849
1850 let file = MigrationFile {
1851 id: id.clone(),
1852 plugin: plugin.clone(),
1853 depends_on: Vec::new(),
1854 operations,
1855 snapshot_after: current,
1856 replaces: Vec::new(),
1857 };
1858
1859 std::fs::create_dir_all(&plugin_dir)?;
1860 let path = plugin_dir.join(filename);
1861 let json = serde_json::to_string_pretty(&file)?;
1862 std::fs::write(&path, json)?;
1863 written.push(path);
1864 }
1865
1866 if written.is_empty() {
1867 return Err(MigrateError::NoChanges);
1868 }
1869 Ok(written)
1870}
1871
1872/// Write an **empty** migration for one plugin: the current snapshot
1873/// with an empty `operations` list, the authoring stub for a
1874/// hand-written data migration (`Operation::RunSql`). The developer
1875/// opens the file and adds a `RunSql { sql, reverse_sql }` op.
1876///
1877/// The empty op-list means `snapshot_after == snapshot_before`, so the
1878/// next `make` diffs against the same state and produces nothing — a
1879/// data migration never disturbs the schema-snapshot chain. Mirror of
1880/// [`make`] for the `--empty <plugin>` CLI path.
1881pub async fn make_empty(plugin: &str) -> Result<PathBuf, MigrateError> {
1882 make_empty_in(Path::new(MIGRATIONS_DIR), plugin).await
1883}
1884
1885/// Same as [`make_empty`] but takes an explicit base directory. The
1886/// seam tests drive.
1887pub async fn make_empty_in(dir: &Path, plugin: &str) -> Result<PathBuf, MigrateError> {
1888 // The plugin must be registered, else the snapshot/sequence would be
1889 // meaningless. Fail loudly with the known set.
1890 let known = plugin_order();
1891 if !known.iter().any(|p| p == plugin) {
1892 return Err(MigrateError::UnknownPlugin {
1893 requested: plugin.to_string(),
1894 known,
1895 });
1896 }
1897
1898 let plugin_dir = dir.join(plugin);
1899
1900 // Carry the latest snapshot forward verbatim: an empty migration has
1901 // NO schema effect, so `snapshot_after` equals the previous one. The
1902 // current model snapshot is the same as the prior file's
1903 // `snapshot_after` (no model changed); use the current registry state
1904 // so the file is self-consistent even on a plugin's very first
1905 // migration.
1906 let existing = list_migration_files(&plugin_dir)?;
1907 let snapshot = match existing.last() {
1908 Some(path) => read_migration_file(path)?.snapshot_after,
1909 None => Snapshot::current_for(plugin),
1910 };
1911
1912 let seq = (existing.len() + 1) as u32;
1913 let id = format!("{seq:04}_empty");
1914 let filename = format!("{id}.json");
1915
1916 let file = MigrationFile {
1917 id: id.clone(),
1918 plugin: plugin.to_string(),
1919 depends_on: Vec::new(),
1920 operations: Vec::new(),
1921 snapshot_after: snapshot,
1922 replaces: Vec::new(),
1923 };
1924
1925 std::fs::create_dir_all(&plugin_dir)?;
1926 let path = plugin_dir.join(filename);
1927 let json = serde_json::to_string_pretty(&file)?;
1928 std::fs::write(&path, json)?;
1929 Ok(path)
1930}
1931
1932/// The numeric sequence prefix of a migration id (`0003_add_x` → `0003`),
1933/// used to name a squash `0001_squashed_0003`.
1934fn seq_prefix(id: &str) -> &str {
1935 id.split('_').next().unwrap_or(id)
1936}
1937
1938/// Outcome of [`squash_in`]: where the squash landed and what it collapsed.
1939pub struct SquashOutcome {
1940 /// Path of the written squash file.
1941 pub path: PathBuf,
1942 /// Id of the new squash migration (e.g. `0001_squashed_0005`).
1943 pub id: String,
1944 /// Ids of the original migrations it replaces — kept on disk, non-destructive.
1945 pub replaced: Vec<String>,
1946}
1947
1948/// gaps2 #100 — collapse a plugin's entire linear migration history into ONE
1949/// optimized squash file, **non-destructively**: the originals stay on disk so
1950/// older deploys still migrate, and the runner ([`squash_plan`]) treats the
1951/// squash and its originals as mutually exclusive.
1952///
1953/// The squash's operations are the diff from an empty schema to the last
1954/// migration's `snapshot_after` — i.e. one `CreateTable` per model with every
1955/// intermediate `AlterColumn` already folded into the final column set. That's
1956/// the minimal replay of the whole history, which is the entire point (a 20-file
1957/// history with repeated alters collapses to N clean creates).
1958///
1959/// Refuses (with [`MigrateError::CannotSquash`]) when it can't produce a safe
1960/// squash: fewer than two migrations, a history that already contains a squash
1961/// (nested squashing is out of scope), or a history containing a `RunSql` data
1962/// migration — a snapshot diff can't see hand-written data steps, so squashing
1963/// across one would silently drop it. The operator squashes the schema-only
1964/// prefix, or leaves that history intact.
1965pub fn squash_in(dir: &Path, plugin: &str) -> Result<SquashOutcome, MigrateError> {
1966 let plugin_dir = dir.join(plugin);
1967 let paths = list_migration_files(&plugin_dir)?;
1968 let files: Vec<MigrationFile> = paths
1969 .iter()
1970 .map(|p| read_migration_file(p))
1971 .collect::<Result<_, _>>()?;
1972
1973 if files.len() < 2 {
1974 return Err(MigrateError::CannotSquash {
1975 plugin: plugin.to_string(),
1976 reason: format!(
1977 "need at least 2 migrations to squash, found {}",
1978 files.len()
1979 ),
1980 });
1981 }
1982 if files.iter().any(|f| !f.replaces.is_empty()) {
1983 return Err(MigrateError::CannotSquash {
1984 plugin: plugin.to_string(),
1985 reason: "this history already contains a squash; nested squashing isn't supported \
1986 yet. Delete the now-redundant original files once every deploy has migrated, \
1987 then squash again"
1988 .to_string(),
1989 });
1990 }
1991 if files.iter().any(|f| {
1992 f.operations
1993 .iter()
1994 .any(|op| matches!(op, Operation::RunSql { .. }))
1995 }) {
1996 return Err(MigrateError::CannotSquash {
1997 plugin: plugin.to_string(),
1998 reason: "this history contains a RunSql data migration, which a snapshot diff can't \
1999 reconstruct — squashing across it would silently drop the data step. Squash \
2000 the schema-only migrations before/after it, or leave this history intact"
2001 .to_string(),
2002 });
2003 }
2004
2005 let first = &files[0];
2006 let last = files.last().expect("len >= 2 checked above");
2007
2008 // Optimal from-scratch replay: empty schema → final snapshot. Every
2009 // intermediate alter is already baked into `snapshot_after`.
2010 let empty = Snapshot { models: Vec::new() };
2011 let operations = diff(&empty, &last.snapshot_after)?;
2012
2013 let id = format!(
2014 "{}_squashed_{}",
2015 seq_prefix(&first.id),
2016 seq_prefix(&last.id)
2017 );
2018 let replaced: Vec<String> = files.iter().map(|f| f.id.clone()).collect();
2019 let squash = MigrationFile {
2020 id: id.clone(),
2021 plugin: plugin.to_string(),
2022 // Preserve the first migration's cross-plugin predecessors: the squash
2023 // stands in for the whole run, so it inherits the run's external deps.
2024 depends_on: first.depends_on.clone(),
2025 operations,
2026 snapshot_after: last.snapshot_after.clone(),
2027 replaces: replaced
2028 .iter()
2029 .map(|mid| MigrationRef {
2030 plugin: plugin.to_string(),
2031 migration: mid.clone(),
2032 })
2033 .collect(),
2034 };
2035
2036 let path = plugin_dir.join(format!("{id}.json"));
2037 let json = serde_json::to_string_pretty(&squash)?;
2038 std::fs::write(&path, json)?;
2039 Ok(SquashOutcome { path, id, replaced })
2040}
2041
2042/// Apply every pending migration across every registered plugin's
2043/// `migrations/<plugin>/` directory to the ambient pool. Reads the
2044/// `umbral_migrations` tracking table to determine "pending"; each
2045/// migration runs in its own transaction along with its tracking-table
2046/// insert.
2047///
2048/// Returns the total number of migrations applied (zero if every
2049/// plugin's migrations were already in the tracking table).
2050///
2051/// This variant performs a drift check before executing any SQL. If
2052/// any migration is `AppliedButMissing` (in the DB but not on disk),
2053/// the call returns [`MigrateError::DriftDetected`] listing the
2054/// missing names. Pass `allow_drift = true` (via [`run_checked_in`])
2055/// to suppress the error and proceed anyway (with a warning printed to
2056/// stderr).
2057pub async fn run() -> Result<u64, MigrateError> {
2058 run_checked(false).await
2059}
2060
2061/// Same as [`run`] but controls drift handling.
2062/// `allow_drift = true` corresponds to the `--allow-drift` CLI flag:
2063/// the command logs a warning and proceeds even if some applied
2064/// migrations are missing on disk.
2065pub async fn run_checked(allow_drift: bool) -> Result<u64, MigrateError> {
2066 run_checked_in(Path::new(MIGRATIONS_DIR), allow_drift).await
2067}
2068
2069/// Same as [`run_checked`] but takes an explicit base directory.
2070pub async fn run_checked_in(dir: &Path, allow_drift: bool) -> Result<u64, MigrateError> {
2071 let mut total: u64 = 0;
2072 // Walk every registered DB. Drift-detection on the default pool
2073 // is the dominant flow; secondary pools currently use the same
2074 // tracking-table-vs-disk comparison but only against the
2075 // migration files whose ops actually targeted that DB. A future
2076 // pass can teach `detect_all_drift` to be alias-aware so drift
2077 // warnings name the offending pool — today it warns once per
2078 // checked DB if the issue is present in any.
2079 for alias in crate::db::registered_aliases() {
2080 match crate::db::pool_for_dispatched(&alias) {
2081 crate::db::DbPool::Sqlite(p) => {
2082 total += run_in_sqlite_checked(dir, p, allow_drift, &alias).await?
2083 }
2084 crate::db::DbPool::Postgres(p) => {
2085 total += run_in_postgres_checked(dir, p, allow_drift, &alias).await?
2086 }
2087 }
2088 }
2089 Ok(total)
2090}
2091
2092/// Same as [`run`] but takes an explicit base directory. Used by
2093/// tests to avoid touching the cwd.
2094///
2095/// Iterates `registered_plugins()` in sorted-by-name order. M7 v1
2096/// accepts this as a limitation: cross-plugin FK ordering wants
2097/// topological order across plugins (the FK target's `CreateTable`
2098/// applies before the dependent plugin's `CreateTable`), but the
2099/// engine doesn't see `Plugin::dependencies()` from inside this
2100/// standalone function. M8 lifts the limitation via a registry that
2101/// remembers the toposorted order computed at `App::build()` time.
2102///
2103/// This legacy entry point does NOT perform drift checking so the
2104/// existing tests (which bypass drift by design) keep passing. New
2105/// callers should prefer [`run_checked_in`].
2106pub async fn run_in(dir: &Path) -> Result<u64, MigrateError> {
2107 let mut total: u64 = 0;
2108 // Walk every registered DB so each pool gets its own
2109 // `umbral_migrations` table and runs only the operations targeting
2110 // tables routed to it. Order is alphabetical for determinism;
2111 // the "default" pool is always present.
2112 for alias in crate::db::registered_aliases() {
2113 match crate::db::pool_for_dispatched(&alias) {
2114 crate::db::DbPool::Sqlite(p) => {
2115 total += run_in_sqlite_for_alias(dir, &alias, p, None).await?
2116 }
2117 crate::db::DbPool::Postgres(p) => {
2118 total += run_in_postgres_for_alias(dir, &alias, p, None).await?
2119 }
2120 }
2121 }
2122 Ok(total)
2123}
2124
2125/// Apply only the **SHARED** apps' pending migrations to the default pool —
2126/// the `public`/shared half of schema-per-tenant multitenancy. This is the
2127/// mirror of [`run_for_schema_in`] (which migrates the *tenant* apps into a
2128/// tenant schema): here only plugins IN `shared_apps` migrate into `public`,
2129/// so a tenant app's tables — and crucially its M2M junctions — are NEVER
2130/// created in `public`. They live only in each tenant schema, where a junction's
2131/// FK to a SHARED child resolves via the `<schema>, public` search-path.
2132///
2133/// Use this instead of the unfiltered [`run`]/[`run_in`] when running a
2134/// schema-per-tenant app: `run_shared` (shared → public) then `migrate_schemas`
2135/// (tenant apps → each schema). On a non-multitenant app the two are equivalent
2136/// only if every app is shared; otherwise prefer plain [`run`].
2137pub async fn run_shared(
2138 shared_apps: &std::collections::HashSet<String>,
2139) -> Result<u64, MigrateError> {
2140 run_shared_in(Path::new(MIGRATIONS_DIR), shared_apps).await
2141}
2142
2143/// [`run_shared`] against an explicit migrations directory (tests / tooling).
2144pub async fn run_shared_in(
2145 dir: &Path,
2146 shared_apps: &std::collections::HashSet<String>,
2147) -> Result<u64, MigrateError> {
2148 let mut total: u64 = 0;
2149 for alias in crate::db::registered_aliases() {
2150 match crate::db::pool_for_dispatched(&alias) {
2151 crate::db::DbPool::Sqlite(p) => {
2152 total += run_in_sqlite_for_alias(dir, &alias, p, Some(shared_apps)).await?
2153 }
2154 crate::db::DbPool::Postgres(p) => {
2155 total += run_in_postgres_for_alias(dir, &alias, p, Some(shared_apps)).await?
2156 }
2157 }
2158 }
2159 Ok(total)
2160}
2161
2162/// Predicate: does `op` target a table that lives on `alias`?
2163///
2164/// Routing rule: look up the table → alias mapping via
2165/// [`table_alias`]. Tables not owned by any registered model fall
2166/// through to `"default"` so the migration engine's own
2167/// `umbral_migrations` book-keeping stays in the main DB.
2168///
2169/// A second gate consults the installed [`DatabaseRouter`]: if the
2170/// router's [`allow_migrate`](crate::db::DatabaseRouter::allow_migrate)
2171/// returns `false` for this (alias, model) pair the operation is
2172/// excluded from the alias's run. Junction / unowned tables (no
2173/// registered `ModelMeta`) are always allowed — the router has no
2174/// model to inspect.
2175fn op_targets_alias(op: &Operation, alias: &str) -> bool {
2176 if table_alias(op.table_name()) != alias {
2177 return false;
2178 }
2179 // Let the router veto migrating this table on this alias.
2180 match model_meta_for_table(op.table_name()) {
2181 Some(meta) => crate::db::router::router().allow_migrate(alias, &meta),
2182 None => true, // junction / unowned table — migrate on its alias
2183 }
2184}
2185
2186/// SQLite per-alias variant. Same shape as the legacy `run_in_sqlite`
2187/// but: filters ops to those routed to `alias`; skips files whose op
2188/// list contains nothing for this DB (so we don't stuff orphan
2189/// tracking rows into pools that didn't run any SQL).
2190async fn run_in_sqlite_for_alias(
2191 dir: &Path,
2192 alias: &str,
2193 pool: &sqlx::SqlitePool,
2194 shared_only: Option<&std::collections::HashSet<String>>,
2195) -> Result<u64, MigrateError> {
2196 ensure_tracking_table_sqlite(pool).await?;
2197 let applied = applied_names_sqlite(pool).await?;
2198
2199 let mut applied_count: u64 = 0;
2200 for plugin in plugin_order() {
2201 if let Some(shared) = shared_only {
2202 if !shared.contains(&plugin) {
2203 continue;
2204 }
2205 }
2206 let plugin_dir = dir.join(&plugin);
2207 let paths = list_migration_files(&plugin_dir)?;
2208
2209 // Read the plugin's full file set, then plan squash decisions in one
2210 // pass (gaps2 #100). `squash_plan` decides Apply / Skip / RecordOnly per
2211 // file, honoring `replaces` so a squash and its originals never both
2212 // apply. Pure and backend-agnostic — the Postgres runner plans the same.
2213 let files: Vec<MigrationFile> = paths
2214 .iter()
2215 .map(|p| read_migration_file(p))
2216 .collect::<Result<_, _>>()?;
2217 let plan = squash_plan(&files, &applied)?;
2218
2219 for (file, decision) in files.iter().zip(plan) {
2220 if decision == ApplyDecision::Skip {
2221 continue;
2222 }
2223
2224 let ops_for_this_db: Vec<&Operation> = file
2225 .operations
2226 .iter()
2227 .filter(|op| op_targets_alias(op, alias))
2228 .collect();
2229 if ops_for_this_db.is_empty() {
2230 // File's content all targets some other DB. Don't
2231 // record it here — re-runs will re-evaluate cleanly
2232 // once the right DB picks it up. The tracking rows
2233 // per-DB stay accurate to "what actually ran here."
2234 continue;
2235 }
2236
2237 let snapshot_hash = file.snapshot_after.hash();
2238 let applied_at = chrono::Utc::now().to_rfc3339();
2239 // RecordOnly: the squash's replaced set is already applied, so its
2240 // schema exists — insert the tracking row without running any ops
2241 // (an empty op slice inserts the row and commits).
2242 let ops_to_run: &[&Operation] = if decision == ApplyDecision::RecordOnly {
2243 &[]
2244 } else {
2245 &ops_for_this_db
2246 };
2247 apply_sqlite_migration_tx(
2248 pool,
2249 ops_to_run,
2250 &file.plugin,
2251 &file.id,
2252 &applied_at,
2253 &snapshot_hash,
2254 )
2255 .await?;
2256 applied_count += 1;
2257 }
2258 }
2259 Ok(applied_count)
2260}
2261
2262/// Apply one migration file's SQLite `ops` in a single transaction, then record
2263/// it in the tracking table.
2264///
2265/// When any op is an `AlterColumn` (the table-recreation dance), the
2266/// transaction is bracketed with `PRAGMA foreign_keys=OFF` … `PRAGMA
2267/// foreign_key_check` … `PRAGMA foreign_keys=ON` on a **pinned** connection
2268/// (SQLite's official recipe), so step 3's `DROP TABLE` on a table with inbound
2269/// FKs doesn't fail with `FOREIGN KEY constraint failed` (error 787, gaps3
2270/// #13). The pragma MUST be toggled outside the tx (it's a no-op inside one),
2271/// and enforcement is restored even on failure so the pooled connection never
2272/// returns with FK checks disabled. `foreign_key_check` before commit keeps the
2273/// integrity guarantee: a migration that genuinely orphans a row is aborted.
2274async fn apply_sqlite_migration_tx(
2275 pool: &sqlx::SqlitePool,
2276 ops: &[&Operation],
2277 plugin: &str,
2278 name: &str,
2279 applied_at: &str,
2280 snapshot_hash: &str,
2281) -> Result<(), MigrateError> {
2282 use sqlx::Acquire as _;
2283
2284 let needs_fk_off = ops
2285 .iter()
2286 .any(|op| matches!(op, Operation::AlterColumn { .. }));
2287
2288 let mut conn = pool.acquire().await?;
2289 if needs_fk_off {
2290 sqlx::query("PRAGMA foreign_keys=OFF")
2291 .execute(&mut *conn)
2292 .await?;
2293 }
2294
2295 let result: Result<(), MigrateError> = async {
2296 let mut tx = conn.begin().await?;
2297 for op in ops {
2298 for sql in render_operation_for(op, "sqlite") {
2299 sqlx::query(&sql).execute(&mut *tx).await?;
2300 }
2301 }
2302 if needs_fk_off {
2303 // Enforcement was off during the dance; verify the recreation left
2304 // no dangling references before we commit.
2305 let violations = sqlx::query("PRAGMA foreign_key_check")
2306 .fetch_all(&mut *tx)
2307 .await?;
2308 if !violations.is_empty() {
2309 return Err(MigrateError::Sqlx(sqlx::Error::Protocol(format!(
2310 "migration `{plugin}/{name}` would leave {} dangling foreign-key \
2311 reference(s); aborted",
2312 violations.len()
2313 ))));
2314 }
2315 }
2316 sqlx::query(
2317 "INSERT INTO umbral_migrations (plugin, name, applied_at, snapshot_hash) \
2318 VALUES (?, ?, ?, ?)",
2319 )
2320 .bind(plugin)
2321 .bind(name)
2322 .bind(applied_at)
2323 .bind(snapshot_hash)
2324 .execute(&mut *tx)
2325 .await?;
2326 tx.commit().await?;
2327 Ok(())
2328 }
2329 .await;
2330
2331 if needs_fk_off {
2332 // Restore enforcement before the connection returns to the pool, even
2333 // on failure — but never mask the primary error with a pragma error.
2334 let _ = sqlx::query("PRAGMA foreign_keys=ON")
2335 .execute(&mut *conn)
2336 .await;
2337 }
2338 result
2339}
2340
2341/// A stable 64-bit key for the Postgres migration advisory lock, derived from a
2342/// fixed namespace + a `discriminator` (the pool alias or tenant schema). FNV-1a
2343/// with fixed constants — deterministic and process-independent, so every
2344/// migrator computes the SAME key for the same target and they mutually exclude.
2345/// Different aliases/schemas get different keys, so unrelated logical databases
2346/// migrate concurrently (audit_2 core-migrate #7).
2347fn pg_migration_lock_key(discriminator: &str) -> i64 {
2348 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
2349 for b in b"umbral_migrations\0"
2350 .iter()
2351 .copied()
2352 .chain(discriminator.bytes())
2353 {
2354 hash ^= b as u64;
2355 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
2356 }
2357 hash as i64
2358}
2359
2360/// How long a migrator waits for the advisory lock before giving up. Generous
2361/// (another replica's migration set can take a while), bounded so a deploy can't
2362/// hang forever on a wedged migrator. Override with
2363/// `UMBRAL_MIGRATION_LOCK_TIMEOUT_SECS`.
2364fn pg_migration_lock_timeout() -> std::time::Duration {
2365 let secs = std::env::var("UMBRAL_MIGRATION_LOCK_TIMEOUT_SECS")
2366 .ok()
2367 .and_then(|v| v.trim().parse::<u64>().ok())
2368 .filter(|&n| n > 0)
2369 .unwrap_or(300);
2370 std::time::Duration::from_secs(secs)
2371}
2372
2373/// Acquire the session-level Postgres advisory lock keyed on `discriminator`,
2374/// holding it on `conn` for the caller to release. Non-blocking polls
2375/// (`pg_try_advisory_lock`) with a short sleep between attempts, bounded by
2376/// [`pg_migration_lock_timeout`] — so a stuck migrator surfaces as a clear
2377/// [`MigrateError::MigrationLockTimeout`] instead of an indefinite hang, and a
2378/// crashed migrator's lock auto-releases (session locks die with the backend).
2379async fn acquire_pg_migration_lock(
2380 conn: &mut sqlx::PgConnection,
2381 key: i64,
2382 discriminator: &str,
2383) -> Result<(), MigrateError> {
2384 let timeout = pg_migration_lock_timeout();
2385 let start = std::time::Instant::now();
2386 let mut warned = false;
2387 loop {
2388 let got: bool = sqlx::query_scalar("SELECT pg_try_advisory_lock($1)")
2389 .bind(key)
2390 .fetch_one(&mut *conn)
2391 .await?;
2392 if got {
2393 return Ok(());
2394 }
2395 let waited = start.elapsed();
2396 if waited >= timeout {
2397 return Err(MigrateError::MigrationLockTimeout {
2398 discriminator: discriminator.to_string(),
2399 waited_secs: waited.as_secs(),
2400 });
2401 }
2402 if !warned {
2403 tracing::info!(
2404 discriminator,
2405 "umbral migrate: another process holds the migration lock; waiting for it to \
2406 finish before applying migrations…"
2407 );
2408 warned = true;
2409 }
2410 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2411 }
2412}
2413
2414/// Release the advisory lock acquired by [`acquire_pg_migration_lock`]. Best
2415/// effort — dropping `conn` also releases a session lock, so a failure here is
2416/// logged, not propagated (it must never mask the migration's own result).
2417async fn release_pg_migration_lock(conn: &mut sqlx::PgConnection, key: i64) {
2418 if let Err(e) = sqlx::query("SELECT pg_advisory_unlock($1)")
2419 .bind(key)
2420 .execute(&mut *conn)
2421 .await
2422 {
2423 tracing::warn!("umbral migrate: failed to release the migration advisory lock: {e}");
2424 }
2425}
2426
2427/// Postgres per-alias variant. Mirror of `run_in_sqlite_for_alias`.
2428///
2429/// Wraps the apply loop in a session advisory lock (audit_2 core-migrate #7) so
2430/// two replicas deploying at once can't both read the applied set and race the
2431/// same DDL (the loser errors "relation already exists" mid-deploy). The lock is
2432/// held on a dedicated connection for the whole run; the per-migration
2433/// transactions use their own pooled connections and are unaffected.
2434async fn run_in_postgres_for_alias(
2435 dir: &Path,
2436 alias: &str,
2437 pool: &sqlx::PgPool,
2438 shared_only: Option<&std::collections::HashSet<String>>,
2439) -> Result<u64, MigrateError> {
2440 let key = pg_migration_lock_key(alias);
2441 let mut lock_conn = pool.acquire().await?;
2442 acquire_pg_migration_lock(&mut lock_conn, key, alias).await?;
2443 let result = run_in_postgres_for_alias_locked(dir, alias, pool, shared_only).await;
2444 release_pg_migration_lock(&mut lock_conn, key).await;
2445 result
2446}
2447
2448/// Run `f` under the Postgres migration advisory lock keyed on `discriminator`.
2449/// Shared by the checked-run and schema-per-tenant apply paths so they get the
2450/// same cross-process serialization as [`run_in_postgres_for_alias`].
2451async fn with_pg_migration_lock<F, Fut>(
2452 pool: &sqlx::PgPool,
2453 discriminator: &str,
2454 f: F,
2455) -> Result<u64, MigrateError>
2456where
2457 F: FnOnce() -> Fut,
2458 Fut: std::future::Future<Output = Result<u64, MigrateError>>,
2459{
2460 let key = pg_migration_lock_key(discriminator);
2461 let mut lock_conn = pool.acquire().await?;
2462 acquire_pg_migration_lock(&mut lock_conn, key, discriminator).await?;
2463 let result = f().await;
2464 release_pg_migration_lock(&mut lock_conn, key).await;
2465 result
2466}
2467
2468/// The unlocked body of [`run_in_postgres_for_alias`] — runs while the caller
2469/// holds the migration advisory lock.
2470async fn run_in_postgres_for_alias_locked(
2471 dir: &Path,
2472 alias: &str,
2473 pool: &sqlx::PgPool,
2474 shared_only: Option<&std::collections::HashSet<String>>,
2475) -> Result<u64, MigrateError> {
2476 ensure_tracking_table_postgres(pool).await?;
2477 let applied = applied_names_postgres(pool).await?;
2478
2479 let mut applied_count: u64 = 0;
2480 for plugin in plugin_order() {
2481 // Shared-filtered public migrate (multitenancy): when a shared-app set
2482 // is given, migrate ONLY those plugins into this pool, so a tenant
2483 // app's tables (and its M2M junctions) are NOT created in `public` —
2484 // they belong only in each tenant schema. `None` = migrate everything
2485 // (the default single-DB behaviour, byte-identical to before).
2486 if let Some(shared) = shared_only {
2487 if !shared.contains(&plugin) {
2488 continue;
2489 }
2490 }
2491 let plugin_dir = dir.join(&plugin);
2492 let paths = list_migration_files(&plugin_dir)?;
2493
2494 // Plan squash decisions for the plugin's whole file set (gaps2 #100),
2495 // identical to the SQLite runner — `squash_plan` is backend-agnostic.
2496 let files: Vec<MigrationFile> = paths
2497 .iter()
2498 .map(|p| read_migration_file(p))
2499 .collect::<Result<_, _>>()?;
2500 let plan = squash_plan(&files, &applied)?;
2501
2502 for (file, decision) in files.iter().zip(plan) {
2503 if decision == ApplyDecision::Skip {
2504 continue;
2505 }
2506
2507 let ops_for_this_db: Vec<&Operation> = file
2508 .operations
2509 .iter()
2510 .filter(|op| op_targets_alias(op, alias))
2511 .collect();
2512 if ops_for_this_db.is_empty() {
2513 continue;
2514 }
2515
2516 let mut tx = pool.begin().await?;
2517 // RecordOnly (squash whose replaced set is already applied): its
2518 // schema exists, so run no DDL — just insert the tracking row.
2519 if decision != ApplyDecision::RecordOnly {
2520 for op in &ops_for_this_db {
2521 for sql in render_operation(op) {
2522 sqlx::query(&sql).execute(&mut *tx).await?;
2523 }
2524 }
2525 }
2526 let snapshot_hash = file.snapshot_after.hash();
2527 let applied_at = chrono::Utc::now().to_rfc3339();
2528 sqlx::query(
2529 "INSERT INTO umbral_migrations (plugin, name, applied_at, snapshot_hash) \
2530 VALUES ($1, $2, $3, $4)",
2531 )
2532 .bind(&file.plugin)
2533 .bind(&file.id)
2534 .bind(&applied_at)
2535 .bind(&snapshot_hash)
2536 .execute(&mut *tx)
2537 .await?;
2538 tx.commit().await?;
2539 applied_count += 1;
2540 }
2541 }
2542 Ok(applied_count)
2543}
2544
2545/// Migrate the **tenant** apps into a named Postgres schema (schema-per-tenant
2546/// style). The migration engine owns all schema DDL; this is the
2547/// sanctioned `CREATE SCHEMA` / `SET search_path` exception (a plugin calls this
2548/// rather than writing raw schema SQL itself).
2549///
2550/// Steps, all inside one transaction per migration file (mirroring
2551/// [`run_in_postgres_for_alias`]):
2552/// 1. `CREATE SCHEMA IF NOT EXISTS "<schema>"` (the `Schema` was already
2553/// validated to a safe PG identifier, but is still emitted quoted).
2554/// 2. `SET LOCAL search_path TO "<schema>"` so every unqualified
2555/// `CREATE TABLE` **and** the `umbral_migrations` ledger land *inside*
2556/// `<schema>` — per-schema migration tracking falls out for free.
2557/// 3. Apply pending migrations, **filtered to the tenant apps** — every plugin
2558/// NOT in `shared_apps` (those tables live in `public` and are migrated by
2559/// the normal [`run`]). A file with no tenant-app ops for this schema is
2560/// skipped without a tracking row.
2561///
2562/// Idempotent: re-running applies only the migrations the schema's own
2563/// `umbral_migrations` ledger hasn't recorded. Postgres-only — schemas don't
2564/// exist on SQLite, so a SQLite pool is a clear error
2565/// ([`MigrateError::SchemaUnsupportedOnSqlite`]).
2566pub async fn run_for_schema(
2567 schema: &crate::db::Schema,
2568 shared_apps: &std::collections::HashSet<String>,
2569) -> Result<u64, MigrateError> {
2570 run_for_schema_in(Path::new(MIGRATIONS_DIR), schema, shared_apps).await
2571}
2572
2573/// Same as [`run_for_schema`] but takes an explicit migrations base directory.
2574/// The entry tests drive.
2575pub async fn run_for_schema_in(
2576 dir: &Path,
2577 schema: &crate::db::Schema,
2578 shared_apps: &std::collections::HashSet<String>,
2579) -> Result<u64, MigrateError> {
2580 match crate::db::pool_dispatched() {
2581 crate::db::DbPool::Postgres(p) => {
2582 // audit_2 core-migrate #7: serialize concurrent migrators of THIS
2583 // tenant schema (keyed by schema name, so different tenants still
2584 // migrate concurrently). The shared/public run uses a different key
2585 // (its alias), so a tenant migrate and the public migrate don't
2586 // block each other.
2587 with_pg_migration_lock(p, schema.as_str(), || {
2588 run_tenant_apps_in_postgres_schema(dir, schema, shared_apps, p)
2589 })
2590 .await
2591 }
2592 crate::db::DbPool::Sqlite(_) => Err(MigrateError::SchemaUnsupportedOnSqlite {
2593 schema: schema.as_str().to_string(),
2594 }),
2595 }
2596}
2597
2598/// Postgres schema-scoped variant of [`run_in_postgres_for_alias`]. Creates the
2599/// schema, pins `search_path` to it for the transaction, and applies only the
2600/// tenant apps' migrations (plugins not in `shared_apps`). The `umbral_migrations`
2601/// ledger is read/written *inside* the schema (search_path is set first), so
2602/// tracking is per-schema with no extra book-keeping.
2603async fn run_tenant_apps_in_postgres_schema(
2604 dir: &Path,
2605 schema: &crate::db::Schema,
2606 shared_apps: &std::collections::HashSet<String>,
2607 pool: &sqlx::PgPool,
2608) -> Result<u64, MigrateError> {
2609 let quoted = format!("\"{}\"", schema.as_str());
2610
2611 // Create the schema once, outside the per-file loop. IF NOT EXISTS makes
2612 // the whole call idempotent.
2613 sqlx::query(&format!("CREATE SCHEMA IF NOT EXISTS {quoted}"))
2614 .execute(pool)
2615 .await?;
2616
2617 // Ensure + read the ledger INSIDE the schema. Each block runs in its own
2618 // transaction with `SET LOCAL search_path` so the tracking table is created
2619 // in (and read from) `<schema>`, not `public` — AND the search_path is
2620 // transaction-scoped, so the pooled connection is NOT left pinned to this
2621 // schema when it returns to the pool. A plain session-level `SET` here
2622 // pollutes the pool: the next unqualified ORM query that reuses the
2623 // connection would resolve against `<schema>` instead of `public` (e.g. an
2624 // insert into the public `tenant` registry failing with "relation does not
2625 // exist") — a real cross-tenant bug, caught only against live Postgres.
2626 {
2627 let mut tx = pool.begin().await?;
2628 sqlx::query(&format!("SET LOCAL search_path TO {quoted}"))
2629 .execute(&mut *tx)
2630 .await?;
2631 ensure_tracking_table_pg_conn(&mut tx).await?;
2632 tx.commit().await?;
2633 }
2634 let applied = {
2635 let mut tx = pool.begin().await?;
2636 sqlx::query(&format!("SET LOCAL search_path TO {quoted}"))
2637 .execute(&mut *tx)
2638 .await?;
2639 let rows: Vec<(String, String)> =
2640 sqlx::query_as("SELECT plugin, name FROM umbral_migrations")
2641 .fetch_all(&mut *tx)
2642 .await?;
2643 tx.commit().await?;
2644 rows.into_iter().collect::<std::collections::HashSet<_>>()
2645 };
2646
2647 let mut applied_count: u64 = 0;
2648 for plugin in plugin_order() {
2649 // Tenant apps only — shared apps live in `public`.
2650 if shared_apps.contains(&plugin) {
2651 continue;
2652 }
2653 let plugin_dir = dir.join(&plugin);
2654 let paths = list_migration_files(&plugin_dir)?;
2655
2656 // Squash-aware planning (gaps2 #100), same as every other apply loop.
2657 let files: Vec<MigrationFile> = paths
2658 .iter()
2659 .map(|p| read_migration_file(p))
2660 .collect::<Result<_, _>>()?;
2661 let plan = squash_plan(&files, &applied)?;
2662
2663 for (file, decision) in files.iter().zip(plan) {
2664 if decision == ApplyDecision::Skip {
2665 continue;
2666 }
2667 // Belt-and-braces: skip a file whose declared plugin is shared.
2668 if shared_apps.contains(&file.plugin) {
2669 continue;
2670 }
2671
2672 let mut tx = pool.begin().await?;
2673 // Pin search_path for THIS transaction, tenant schema FIRST with
2674 // `public` as a fallback. `CREATE TABLE` / `INSERT` still land in
2675 // the tenant schema (it's first), but an unqualified reference that
2676 // ISN'T in the tenant schema resolves against `public` — which is
2677 // what makes a CROSS-BOUNDARY foreign key work: a tenant-owned
2678 // table (or an M2M junction) with an FK `REFERENCES <shared_child>`
2679 // resolves the shared child in `public` instead of erroring
2680 // `relation does not exist`. It also lets a (future) RunSql data
2681 // migration in a tenant schema read SHARED/`public` lookup tables.
2682 // The tenant-first ordering means a tenant table still shadows a
2683 // same-named public table, so no behaviour changes for the common
2684 // case where tenant and shared table names are distinct.
2685 sqlx::query(&format!("SET LOCAL search_path TO {quoted}, public"))
2686 .execute(&mut *tx)
2687 .await?;
2688 // RecordOnly: schema already built by the replaced originals — run
2689 // no DDL, just record the squash.
2690 if decision != ApplyDecision::RecordOnly {
2691 for op in &file.operations {
2692 for sql in render_operation_for(op, "postgres") {
2693 sqlx::query(&sql).execute(&mut *tx).await?;
2694 }
2695 }
2696 }
2697 let snapshot_hash = file.snapshot_after.hash();
2698 let applied_at = chrono::Utc::now().to_rfc3339();
2699 sqlx::query(
2700 "INSERT INTO umbral_migrations (plugin, name, applied_at, snapshot_hash) \
2701 VALUES ($1, $2, $3, $4)",
2702 )
2703 .bind(&file.plugin)
2704 .bind(&file.id)
2705 .bind(&applied_at)
2706 .bind(&snapshot_hash)
2707 .execute(&mut *tx)
2708 .await?;
2709 tx.commit().await?;
2710 applied_count += 1;
2711 }
2712 }
2713 Ok(applied_count)
2714}
2715
2716/// Migrate the **tenant** apps into the pool registered under `alias`
2717/// (database-per-tenant). The db-per-tenant sibling of [`run_for_schema`]:
2718/// where the schema variant pins `search_path` inside one shared Postgres
2719/// database, this targets a *whole separate database/pool* registered at
2720/// runtime via [`register_tenant_pool`](crate::db::register_tenant_pool) and
2721/// resolved here through [`pool_for_dispatched`](crate::db::pool_for_dispatched)
2722/// (which sees dynamic pools). No schema games — per-database migration
2723/// tracking is just that database's own `umbral_migrations` table.
2724///
2725/// Like the schema variant it applies only the **tenant apps**: every plugin
2726/// NOT in `shared_apps` (the shared registry/auth tables live in the default
2727/// DB and are migrated there by the normal [`run`]). A migration file whose
2728/// declared plugin is shared is skipped without a tracking row. Idempotent:
2729/// re-running applies only what the tenant DB's own ledger hasn't recorded.
2730///
2731/// Works on both backends — a tenant pool can be Postgres (the production case)
2732/// or SQLite (tests). Unlike the alias-routed [`run_in`], this does NOT filter
2733/// ops by [`table_alias`]: a tenant-owned model's static alias is still
2734/// `"default"`, so the per-alias filter would wrongly exclude it from the
2735/// tenant DB. The shared/tenant split is the *only* filter here.
2736pub async fn migrate_apps_into_pool(
2737 alias: &str,
2738 shared_apps: &std::collections::HashSet<String>,
2739) -> Result<u64, MigrateError> {
2740 migrate_apps_into_pool_in(Path::new(MIGRATIONS_DIR), alias, shared_apps).await
2741}
2742
2743/// Same as [`migrate_apps_into_pool`] but takes an explicit migrations base
2744/// directory. The entry tests drive.
2745pub async fn migrate_apps_into_pool_in(
2746 dir: &Path,
2747 alias: &str,
2748 shared_apps: &std::collections::HashSet<String>,
2749) -> Result<u64, MigrateError> {
2750 match crate::db::pool_for_dispatched(alias) {
2751 crate::db::DbPool::Postgres(p) => {
2752 migrate_tenant_apps_into_pg_pool(dir, shared_apps, p).await
2753 }
2754 crate::db::DbPool::Sqlite(p) => {
2755 migrate_tenant_apps_into_sqlite_pool(dir, shared_apps, p).await
2756 }
2757 }
2758}
2759
2760/// Postgres tenant-DB apply loop. Mirrors [`run_in_postgres_for_alias`] but the
2761/// only filter is the shared/tenant split — every plugin not in `shared_apps`
2762/// is applied in full into this database.
2763async fn migrate_tenant_apps_into_pg_pool(
2764 dir: &Path,
2765 shared_apps: &std::collections::HashSet<String>,
2766 pool: &sqlx::PgPool,
2767) -> Result<u64, MigrateError> {
2768 ensure_tracking_table_postgres(pool).await?;
2769 let applied = applied_names_postgres(pool).await?;
2770
2771 let mut applied_count: u64 = 0;
2772 for plugin in plugin_order() {
2773 if shared_apps.contains(&plugin) {
2774 continue;
2775 }
2776 let plugin_dir = dir.join(&plugin);
2777 let files: Vec<MigrationFile> = list_migration_files(&plugin_dir)?
2778 .iter()
2779 .map(|p| read_migration_file(p))
2780 .collect::<Result<_, _>>()?;
2781 let plan = squash_plan(&files, &applied)?;
2782 for (file, decision) in files.iter().zip(plan) {
2783 if decision == ApplyDecision::Skip {
2784 continue;
2785 }
2786 if shared_apps.contains(&file.plugin) {
2787 continue;
2788 }
2789 let mut tx = pool.begin().await?;
2790 if decision != ApplyDecision::RecordOnly {
2791 for op in &file.operations {
2792 for sql in render_operation_for(op, "postgres") {
2793 sqlx::query(&sql).execute(&mut *tx).await?;
2794 }
2795 }
2796 }
2797 let snapshot_hash = file.snapshot_after.hash();
2798 let applied_at = chrono::Utc::now().to_rfc3339();
2799 sqlx::query(
2800 "INSERT INTO umbral_migrations (plugin, name, applied_at, snapshot_hash) \
2801 VALUES ($1, $2, $3, $4)",
2802 )
2803 .bind(&file.plugin)
2804 .bind(&file.id)
2805 .bind(&applied_at)
2806 .bind(&snapshot_hash)
2807 .execute(&mut *tx)
2808 .await?;
2809 tx.commit().await?;
2810 applied_count += 1;
2811 }
2812 }
2813 Ok(applied_count)
2814}
2815
2816/// SQLite tenant-DB apply loop (tests). Same shape as the Postgres variant.
2817async fn migrate_tenant_apps_into_sqlite_pool(
2818 dir: &Path,
2819 shared_apps: &std::collections::HashSet<String>,
2820 pool: &sqlx::SqlitePool,
2821) -> Result<u64, MigrateError> {
2822 ensure_tracking_table_sqlite(pool).await?;
2823 let applied = applied_names_sqlite(pool).await?;
2824
2825 let mut applied_count: u64 = 0;
2826 for plugin in plugin_order() {
2827 if shared_apps.contains(&plugin) {
2828 continue;
2829 }
2830 let plugin_dir = dir.join(&plugin);
2831 let files: Vec<MigrationFile> = list_migration_files(&plugin_dir)?
2832 .iter()
2833 .map(|p| read_migration_file(p))
2834 .collect::<Result<_, _>>()?;
2835 let plan = squash_plan(&files, &applied)?;
2836 for (file, decision) in files.iter().zip(plan) {
2837 if decision == ApplyDecision::Skip {
2838 continue;
2839 }
2840 if shared_apps.contains(&file.plugin) {
2841 continue;
2842 }
2843 let snapshot_hash = file.snapshot_after.hash();
2844 let applied_at = chrono::Utc::now().to_rfc3339();
2845 // RecordOnly → empty op slice: inserts the tracking row, no DDL.
2846 let ops: Vec<&Operation> = if decision == ApplyDecision::RecordOnly {
2847 Vec::new()
2848 } else {
2849 file.operations.iter().collect()
2850 };
2851 apply_sqlite_migration_tx(
2852 pool,
2853 &ops,
2854 &file.plugin,
2855 &file.id,
2856 &applied_at,
2857 &snapshot_hash,
2858 )
2859 .await?;
2860 applied_count += 1;
2861 }
2862 }
2863 Ok(applied_count)
2864}
2865
2866/// `ensure_tracking_table_postgres` against an explicit connection (so the
2867/// caller can pin `search_path` first and have the table created in the tenant
2868/// schema rather than `public`).
2869async fn ensure_tracking_table_pg_conn(conn: &mut sqlx::PgConnection) -> Result<(), MigrateError> {
2870 sqlx::query(
2871 "CREATE TABLE IF NOT EXISTS umbral_migrations (
2872 plugin TEXT NOT NULL,
2873 name TEXT NOT NULL,
2874 applied_at TEXT NOT NULL,
2875 snapshot_hash TEXT NOT NULL,
2876 PRIMARY KEY (plugin, name)
2877 )",
2878 )
2879 .execute(conn)
2880 .await?;
2881 Ok(())
2882}
2883
2884/// SQLite drift-checking path for `run_checked_in`.
2885///
2886/// Reads the applied set, runs `detect_all_drift`, and either errors
2887/// (if `allow_drift = false` and critical drift is found) or logs a
2888/// warning and proceeds (if `allow_drift = true`). Then delegates to
2889/// `run_in_sqlite` for the actual apply loop.
2890async fn run_in_sqlite_checked(
2891 dir: &Path,
2892 pool: &sqlx::SqlitePool,
2893 allow_drift: bool,
2894 alias: &str,
2895) -> Result<u64, MigrateError> {
2896 ensure_tracking_table_sqlite(pool).await?;
2897 let applied = applied_names_sqlite(pool).await?;
2898 let report = detect_all_drift(&applied, dir)?;
2899
2900 if report.has_critical_drift() {
2901 if allow_drift {
2902 let missing = report.missing_on_disk();
2903 for entry in &missing {
2904 eprintln!(
2905 "warning: umbral migrate --allow-drift: migration {}/{} is recorded in \
2906 the tracking table but the file is missing from disk; proceeding.",
2907 entry.plugin, entry.name
2908 );
2909 }
2910 } else {
2911 let missing: Vec<(String, String)> = report
2912 .missing_on_disk()
2913 .iter()
2914 .map(|e| (e.plugin.clone(), e.name.clone()))
2915 .collect();
2916 return Err(MigrateError::DriftDetected { missing });
2917 }
2918 }
2919
2920 // Emit warnings for out-of-order files.
2921 for entry in report
2922 .entries
2923 .iter()
2924 .filter(|e| e.status == MigrationStatus::OutOfOrder)
2925 {
2926 eprintln!(
2927 "warning: umbral migrate: migration {}/{} is on disk but appears before the \
2928 last applied migration for this plugin; it looks like a file was restored \
2929 after a teammate already applied later ones.",
2930 entry.plugin, entry.name
2931 );
2932 }
2933
2934 run_in_sqlite_for_alias(dir, alias, pool, None).await
2935}
2936
2937/// Postgres drift-checking path for `run_checked_in`. Same logic as
2938/// `run_in_sqlite_checked` but uses the Postgres applied-set reader.
2939async fn run_in_postgres_checked(
2940 dir: &Path,
2941 pool: &sqlx::PgPool,
2942 allow_drift: bool,
2943 alias: &str,
2944) -> Result<u64, MigrateError> {
2945 ensure_tracking_table_postgres(pool).await?;
2946 let applied = applied_names_postgres(pool).await?;
2947 let report = detect_all_drift(&applied, dir)?;
2948
2949 if report.has_critical_drift() {
2950 if allow_drift {
2951 let missing = report.missing_on_disk();
2952 for entry in &missing {
2953 eprintln!(
2954 "warning: umbral migrate --allow-drift: migration {}/{} is recorded in \
2955 the tracking table but the file is missing from disk; proceeding.",
2956 entry.plugin, entry.name
2957 );
2958 }
2959 } else {
2960 let missing: Vec<(String, String)> = report
2961 .missing_on_disk()
2962 .iter()
2963 .map(|e| (e.plugin.clone(), e.name.clone()))
2964 .collect();
2965 return Err(MigrateError::DriftDetected { missing });
2966 }
2967 }
2968
2969 for entry in report
2970 .entries
2971 .iter()
2972 .filter(|e| e.status == MigrationStatus::OutOfOrder)
2973 {
2974 eprintln!(
2975 "warning: umbral migrate: migration {}/{} is on disk but appears before the \
2976 last applied migration for this plugin; it looks like a file was restored \
2977 after a teammate already applied later ones.",
2978 entry.plugin, entry.name
2979 );
2980 }
2981
2982 run_in_postgres_for_alias(dir, alias, pool, None).await
2983}
2984
2985/// Record a migration as applied in the `umbral_migrations` tracking
2986/// table without running its operations. The "mark as applied" path
2987/// `inspectdb --mark-applied` uses to register the introspected
2988/// `0001_initial` against an already-populated database. Idempotent:
2989/// if the `(plugin, name)` row already exists, the call is a no-op.
2990pub async fn record_applied(
2991 plugin: &str,
2992 name: &str,
2993 snapshot_hash: &str,
2994) -> Result<(), MigrateError> {
2995 let applied_at = chrono::Utc::now().to_rfc3339();
2996 match crate::db::pool_dispatched() {
2997 crate::db::DbPool::Sqlite(pool) => {
2998 ensure_tracking_table_sqlite(pool).await?;
2999 sqlx::query(
3000 "INSERT OR IGNORE INTO umbral_migrations \
3001 (plugin, name, applied_at, snapshot_hash) \
3002 VALUES (?, ?, ?, ?)",
3003 )
3004 .bind(plugin)
3005 .bind(name)
3006 .bind(&applied_at)
3007 .bind(snapshot_hash)
3008 .execute(pool)
3009 .await?;
3010 }
3011 crate::db::DbPool::Postgres(pool) => {
3012 ensure_tracking_table_postgres(pool).await?;
3013 sqlx::query(
3014 "INSERT INTO umbral_migrations \
3015 (plugin, name, applied_at, snapshot_hash) \
3016 VALUES ($1, $2, $3, $4) \
3017 ON CONFLICT (plugin, name) DO NOTHING",
3018 )
3019 .bind(plugin)
3020 .bind(name)
3021 .bind(&applied_at)
3022 .bind(snapshot_hash)
3023 .execute(pool)
3024 .await?;
3025 }
3026 }
3027 Ok(())
3028}
3029
3030// =========================================================================
3031// Drift detection — gap 24.
3032// =========================================================================
3033
3034/// Compute the drift report for a single plugin directory. Compares the
3035/// set of `(plugin, name)` pairs recorded in the tracking table against
3036/// the migration files present on disk and classifies each into one of
3037/// the four [`MigrationStatus`] states.
3038///
3039/// `applied` is the full set of `(plugin, name)` tuples already read
3040/// from the tracking table (shared across plugins to avoid extra DB
3041/// round-trips). `plugin_dir` is the on-disk directory for this plugin;
3042/// an absent directory is treated the same as an empty one.
3043///
3044/// # Classification
3045///
3046/// - File present + in DB → `Applied`
3047/// - File absent + in DB → `AppliedButMissing`
3048/// - File present + not in DB + seq ≤ max_applied_seq → `OutOfOrder`
3049/// - File present + not in DB + seq > max_applied_seq → `Pending`
3050///
3051/// The sequence number is the numeric prefix of the migration name
3052/// (e.g. `0001` in `0001_create_post`). Absence of any applied
3053/// migration for this plugin means `max_applied_seq = 0`.
3054pub fn detect_drift(
3055 plugin: &str,
3056 applied: &std::collections::HashSet<(String, String)>,
3057 plugin_dir: &Path,
3058) -> Result<Vec<MigrationEntry>, MigrateError> {
3059 // Collect on-disk migration names (the id, not the full path).
3060 let paths = list_migration_files(plugin_dir)?;
3061 let mut on_disk: Vec<String> = Vec::new();
3062 for path in &paths {
3063 let file = read_migration_file(path)?;
3064 on_disk.push(file.id.clone());
3065 }
3066
3067 // Pull every tracking-table entry for this plugin.
3068 let plugin_applied: Vec<&str> = applied
3069 .iter()
3070 .filter(|(p, _)| p == plugin)
3071 .map(|(_, n)| n.as_str())
3072 .collect();
3073
3074 // Highest sequence number among applied migrations for this plugin.
3075 let max_applied_seq: u32 = plugin_applied
3076 .iter()
3077 .filter_map(|name| name.split('_').next()?.parse::<u32>().ok())
3078 .max()
3079 .unwrap_or(0);
3080
3081 let on_disk_set: std::collections::HashSet<&str> = on_disk.iter().map(|s| s.as_str()).collect();
3082
3083 let mut entries: Vec<MigrationEntry> = Vec::new();
3084
3085 // Walk on-disk files in order.
3086 for name in &on_disk {
3087 let key = (plugin.to_string(), name.clone());
3088 let status = if applied.contains(&key) {
3089 MigrationStatus::Applied
3090 } else {
3091 // Determine this migration's sequence number.
3092 let seq: u32 = name
3093 .split('_')
3094 .next()
3095 .and_then(|s| s.parse().ok())
3096 .unwrap_or(0);
3097 if seq <= max_applied_seq && max_applied_seq > 0 {
3098 MigrationStatus::OutOfOrder
3099 } else {
3100 MigrationStatus::Pending
3101 }
3102 };
3103 entries.push(MigrationEntry {
3104 plugin: plugin.to_string(),
3105 name: name.clone(),
3106 status,
3107 });
3108 }
3109
3110 // Walk applied entries not present on disk.
3111 for name in &plugin_applied {
3112 if !on_disk_set.contains(*name) {
3113 entries.push(MigrationEntry {
3114 plugin: plugin.to_string(),
3115 name: (*name).to_string(),
3116 status: MigrationStatus::AppliedButMissing,
3117 });
3118 }
3119 }
3120
3121 // Sort: applied-but-missing entries bubble after their expected
3122 // position is not determinable; sort all entries by name for a
3123 // deterministic order. In practice, applied-but-missing names
3124 // are still prefixed with the numeric sequence so lexical sort
3125 // yields the right display order.
3126 entries.sort_by(|a, b| a.name.cmp(&b.name));
3127
3128 Ok(entries)
3129}
3130
3131/// Detect drift across every registered plugin and return a combined
3132/// [`DriftReport`]. Called by `run_in_checked` before executing SQL
3133/// and by `show_in` when displaying the four-state list.
3134///
3135/// `applied` is already fetched from the DB; `dir` is the migrations
3136/// root directory.
3137pub fn detect_all_drift(
3138 applied: &std::collections::HashSet<(String, String)>,
3139 dir: &Path,
3140) -> Result<DriftReport, MigrateError> {
3141 let mut all_entries: Vec<MigrationEntry> = Vec::new();
3142
3143 // Also surface any tracking-table entries whose plugin directory
3144 // doesn't appear in the registered-plugins list — a plugin was
3145 // removed entirely but its DB rows remain.
3146 let mut seen_plugins: std::collections::HashSet<String> = std::collections::HashSet::new();
3147
3148 for plugin in plugin_order() {
3149 seen_plugins.insert(plugin.clone());
3150 let plugin_dir = dir.join(&plugin);
3151 let entries = detect_drift(&plugin, applied, &plugin_dir)?;
3152 all_entries.extend(entries);
3153 }
3154
3155 // Any applied entries whose plugin is not in the registered set at
3156 // all — treat them as AppliedButMissing (the whole plugin is gone).
3157 for (plugin, name) in applied {
3158 if !seen_plugins.contains(plugin.as_str()) {
3159 all_entries.push(MigrationEntry {
3160 plugin: plugin.clone(),
3161 name: name.clone(),
3162 status: MigrationStatus::AppliedButMissing,
3163 });
3164 }
3165 }
3166
3167 Ok(DriftReport {
3168 entries: all_entries,
3169 })
3170}
3171
3172/// Record a migration as applied in the tracking table WITHOUT running
3173/// its SQL operations. The `--fake` recovery path: the schema already
3174/// exists (e.g. the migration was run outside umbral, or the DB was
3175/// bootstrapped from a dump) and the operator wants to bring the
3176/// tracking table into sync without re-executing the DDL.
3177///
3178/// Idempotent: if `(plugin, name)` is already in the table the call
3179/// is a no-op (same behaviour as `record_applied`).
3180///
3181/// The snapshot hash is derived from the migration file on disk.
3182/// Returns `MigrateError::Io` if the file can't be found (the caller
3183/// should verify the name before calling this).
3184pub async fn fake_apply(plugin: &str, name: &str) -> Result<(), MigrateError> {
3185 fake_apply_in(plugin, name, Path::new(MIGRATIONS_DIR)).await
3186}
3187
3188/// Same as [`fake_apply`] but takes an explicit migrations base dir.
3189/// Used by tests and by the CLI when `--migrations-dir` is passed.
3190pub async fn fake_apply_in(plugin: &str, name: &str, dir: &Path) -> Result<(), MigrateError> {
3191 let path = dir.join(plugin).join(format!("{name}.json"));
3192 let file = read_migration_file(&path)?;
3193 let snapshot_hash = file.snapshot_after.hash();
3194 record_applied(plugin, name, &snapshot_hash).await
3195}
3196
3197/// For every registered plugin's first migration (`0001_*`), check
3198/// whether the tables that migration would create already exist in the
3199/// database. If they do, fake-apply the migration (mark it applied
3200/// without running its SQL).
3201///
3202/// This is the `--fake-initial` path: the operator has a database
3203/// bootstrapped outside umbral (a dump restore, a manual `CREATE TABLE`,
3204/// or a previous schema manager) and wants to bring the tracking table
3205/// into sync so subsequent `migrate` calls apply only the genuine
3206/// deltas.
3207///
3208/// Returns the number of plugins whose `0001_*` migration was
3209/// fake-applied. Zero means either no `0001_*` file exists or the
3210/// target tables were absent (in which case normal `migrate` should be
3211/// run to create them).
3212pub async fn fake_initial() -> Result<u64, MigrateError> {
3213 fake_initial_in(Path::new(MIGRATIONS_DIR)).await
3214}
3215
3216/// Same as [`fake_initial`] but takes an explicit migrations base dir.
3217pub async fn fake_initial_in(dir: &Path) -> Result<u64, MigrateError> {
3218 match crate::db::pool_dispatched() {
3219 crate::db::DbPool::Sqlite(pool) => fake_initial_sqlite(dir, pool).await,
3220 crate::db::DbPool::Postgres(pool) => fake_initial_postgres(dir, pool).await,
3221 }
3222}
3223
3224/// SQLite path for [`fake_initial_in`].
3225async fn fake_initial_sqlite(dir: &Path, pool: &sqlx::SqlitePool) -> Result<u64, MigrateError> {
3226 ensure_tracking_table_sqlite(pool).await?;
3227 let applied = applied_names_sqlite(pool).await?;
3228 let mut count: u64 = 0;
3229
3230 for plugin in plugin_order() {
3231 let plugin_dir = dir.join(&plugin);
3232 let paths = list_migration_files(&plugin_dir)?;
3233
3234 // Find the first migration file (lowest sequence number).
3235 let first = paths.first();
3236 let first = match first {
3237 Some(p) => p,
3238 None => continue,
3239 };
3240 let file = read_migration_file(first)?;
3241
3242 // Skip if already applied.
3243 if applied.contains(&(file.plugin.clone(), file.id.clone())) {
3244 continue;
3245 }
3246
3247 // Check whether the tables the first migration would create
3248 // already exist in the database.
3249 let tables_to_create: Vec<&str> = file
3250 .operations
3251 .iter()
3252 .filter_map(|op| match op {
3253 Operation::CreateTable { table, .. } => Some(table.as_str()),
3254 _ => None,
3255 })
3256 .collect();
3257
3258 if tables_to_create.is_empty() {
3259 continue;
3260 }
3261
3262 // All tables present → fake-apply.
3263 let mut all_present = true;
3264 for table in &tables_to_create {
3265 let exists: Option<(String,)> =
3266 sqlx::query_as("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
3267 .bind(*table)
3268 .fetch_optional(pool)
3269 .await?;
3270 if exists.is_none() {
3271 all_present = false;
3272 break;
3273 }
3274 }
3275
3276 if all_present {
3277 let snapshot_hash = file.snapshot_after.hash();
3278 let applied_at = chrono::Utc::now().to_rfc3339();
3279 sqlx::query(
3280 "INSERT OR IGNORE INTO umbral_migrations \
3281 (plugin, name, applied_at, snapshot_hash) VALUES (?, ?, ?, ?)",
3282 )
3283 .bind(&file.plugin)
3284 .bind(&file.id)
3285 .bind(&applied_at)
3286 .bind(&snapshot_hash)
3287 .execute(pool)
3288 .await?;
3289 count += 1;
3290 }
3291 }
3292
3293 Ok(count)
3294}
3295
3296/// Postgres path for [`fake_initial_in`].
3297async fn fake_initial_postgres(dir: &Path, pool: &sqlx::PgPool) -> Result<u64, MigrateError> {
3298 ensure_tracking_table_postgres(pool).await?;
3299 let applied = applied_names_postgres(pool).await?;
3300 let mut count: u64 = 0;
3301
3302 for plugin in plugin_order() {
3303 let plugin_dir = dir.join(&plugin);
3304 let paths = list_migration_files(&plugin_dir)?;
3305
3306 let first = paths.first();
3307 let first = match first {
3308 Some(p) => p,
3309 None => continue,
3310 };
3311 let file = read_migration_file(first)?;
3312
3313 if applied.contains(&(file.plugin.clone(), file.id.clone())) {
3314 continue;
3315 }
3316
3317 let tables_to_create: Vec<&str> = file
3318 .operations
3319 .iter()
3320 .filter_map(|op| match op {
3321 Operation::CreateTable { table, .. } => Some(table.as_str()),
3322 _ => None,
3323 })
3324 .collect();
3325
3326 if tables_to_create.is_empty() {
3327 continue;
3328 }
3329
3330 let mut all_present = true;
3331 for table in &tables_to_create {
3332 let exists: Option<(String,)> = sqlx::query_as(
3333 "SELECT table_name FROM information_schema.tables \
3334 WHERE table_schema = 'public' AND table_name = $1",
3335 )
3336 .bind(*table)
3337 .fetch_optional(pool)
3338 .await?;
3339 if exists.is_none() {
3340 all_present = false;
3341 break;
3342 }
3343 }
3344
3345 if all_present {
3346 let snapshot_hash = file.snapshot_after.hash();
3347 let applied_at = chrono::Utc::now().to_rfc3339();
3348 sqlx::query(
3349 "INSERT INTO umbral_migrations \
3350 (plugin, name, applied_at, snapshot_hash) VALUES ($1, $2, $3, $4) \
3351 ON CONFLICT (plugin, name) DO NOTHING",
3352 )
3353 .bind(&file.plugin)
3354 .bind(&file.id)
3355 .bind(&applied_at)
3356 .bind(&snapshot_hash)
3357 .execute(pool)
3358 .await?;
3359 count += 1;
3360 }
3361 }
3362
3363 Ok(count)
3364}
3365
3366/// Print the per-migration state, applied or pending. Output goes to
3367/// stdout; the return value is the count of pending migrations so a
3368/// CLI can `exit(n)` on need.
3369pub async fn show() -> Result<u64, MigrateError> {
3370 show_in(Path::new(MIGRATIONS_DIR)).await
3371}
3372
3373/// Read-only migration status for a health / readiness probe (Kikosi #5,
3374/// gaps3 #38).
3375///
3376/// Returns the same [`DriftReport`] `showmigrations` computes — one entry per
3377/// migration, categorised into the four [`MigrationStatus`] states — but prints
3378/// nothing, so it is safe to call on every `/readyz` hit. Reads the
3379/// `umbral_migrations` tracking table (ensuring it exists) and the on-disk
3380/// `migrations/` tree. Use [`DriftReport::pending`] to gate readiness.
3381pub async fn drift_report() -> Result<DriftReport, MigrateError> {
3382 drift_report_in(Path::new(MIGRATIONS_DIR)).await
3383}
3384
3385/// [`drift_report`] against an explicit migrations directory. The entry tests
3386/// drive this so they can point at a temp tree.
3387pub async fn drift_report_in(dir: &Path) -> Result<DriftReport, MigrateError> {
3388 let applied = match crate::db::pool_dispatched() {
3389 crate::db::DbPool::Sqlite(pool) => {
3390 ensure_tracking_table_sqlite(pool).await?;
3391 applied_names_sqlite(pool).await?
3392 }
3393 crate::db::DbPool::Postgres(pool) => {
3394 ensure_tracking_table_postgres(pool).await?;
3395 applied_names_postgres(pool).await?
3396 }
3397 };
3398 detect_all_drift(&applied, dir)
3399}
3400
3401/// Same as [`show`] but takes an explicit base directory. Walks every
3402/// registered plugin in sorted-by-name order, printing one section per
3403/// plugin that owns at least one migration file; empty plugins are
3404/// skipped silently rather than emitting a bare header.
3405///
3406/// Four-state output (gap 24):
3407///
3408/// - `[X]` applied and file present on disk (normal)
3409/// - `[ ]` pending (on disk, not yet applied, sequence after last applied)
3410/// - `[!]` applied but missing on disk (drift — tracking table ahead of VCS)
3411/// - `[?]` on disk but out of order (sequence before last applied, not in DB)
3412pub async fn show_in(dir: &Path) -> Result<u64, MigrateError> {
3413 let report = drift_report_in(dir).await?;
3414
3415 // Group by plugin for display.
3416 let mut by_plugin: std::collections::BTreeMap<&str, Vec<&MigrationEntry>> =
3417 std::collections::BTreeMap::new();
3418 for entry in &report.entries {
3419 by_plugin
3420 .entry(entry.plugin.as_str())
3421 .or_default()
3422 .push(entry);
3423 }
3424
3425 let mut pending: u64 = 0;
3426 for (plugin, entries) in &by_plugin {
3427 if entries.is_empty() {
3428 continue;
3429 }
3430 println!("# plugin: {plugin}");
3431 for entry in entries {
3432 let marker = match entry.status {
3433 MigrationStatus::Applied => "[X]",
3434 MigrationStatus::Pending => {
3435 pending += 1;
3436 "[ ]"
3437 }
3438 MigrationStatus::AppliedButMissing => "[!]",
3439 MigrationStatus::OutOfOrder => "[?]",
3440 };
3441 println!("{marker} {}/{}", entry.plugin, entry.name);
3442 }
3443 }
3444 Ok(pending)
3445}
3446
3447/// Safety classification for a single pending migration operation.
3448///
3449/// Feature #65 (blue-green / zero-downtime). The `checkmigrations`
3450/// command walks every pending operation and tags it so an operator
3451/// deploying without a maintenance window can tell which changes are safe
3452/// under a rolling deploy (old and new code serving traffic at once) and
3453/// which need the expand-contract dance. This is advisory triage — the
3454/// engine still *applies* every op exactly as written; nothing here gates
3455/// `migrate`.
3456#[derive(Debug, Clone, PartialEq, Eq)]
3457pub enum OpSafety {
3458 /// Additive and backward-compatible — safe while old code still runs.
3459 Safe,
3460 /// Applies cleanly but can break still-running old code, lock a large
3461 /// table, or fail against unexpected production data. Review first.
3462 Warning(String),
3463 /// Destroys data or is irreversible; old code referencing the dropped
3464 /// surface errors immediately.
3465 Unsafe(String),
3466}
3467
3468impl OpSafety {
3469 /// The advisory reason for a `Warning` / `Unsafe`; empty for `Safe`.
3470 pub fn reason(&self) -> &str {
3471 match self {
3472 OpSafety::Safe => "",
3473 OpSafety::Warning(r) | OpSafety::Unsafe(r) => r,
3474 }
3475 }
3476
3477 /// True for the destructive / irreversible tier only.
3478 pub fn is_unsafe(&self) -> bool {
3479 matches!(self, OpSafety::Unsafe(_))
3480 }
3481
3482 /// True for the review-before-deploy tier only.
3483 pub fn is_warning(&self) -> bool {
3484 matches!(self, OpSafety::Warning(_))
3485 }
3486}
3487
3488/// One pending operation tagged with its [`OpSafety`] and the migration
3489/// that introduced it. The unit of output for `checkmigrations`.
3490#[derive(Debug, Clone)]
3491pub struct ClassifiedOp {
3492 pub plugin: String,
3493 pub migration: String,
3494 pub op: Operation,
3495 pub safety: OpSafety,
3496}
3497
3498/// Classify one operation for zero-downtime safety. Pure — no DB access,
3499/// no file reads — so it is trivially unit-testable and reused by both
3500/// the CLI report and any plugin that wants to gate its own deploys.
3501pub fn classify_operation(op: &Operation) -> OpSafety {
3502 match op {
3503 // Brand-new tables touch no existing rows and no old code reads
3504 // them yet.
3505 Operation::CreateTable { .. } | Operation::CreateM2MTable { .. } => OpSafety::Safe,
3506
3507 // A column comment is metadata: no rows read or written, no lock worth
3508 // naming, and no code path — old or new — reads it. A docstring edit
3509 // must never gate a zero-downtime deploy.
3510 Operation::SetColumnComment { .. } => OpSafety::Safe,
3511
3512 // Adding a column is additive — unless it's NOT NULL with no
3513 // default, in which case old code inserting a row without the
3514 // column fails. (The engine refuses such an add against a
3515 // populated SQLite table at apply time; this surfaces the same
3516 // hazard *before* the operator runs it, and for Postgres too.)
3517 Operation::AddColumn { table, column } => {
3518 if !column.nullable && column.default.is_empty() {
3519 OpSafety::Warning(format!(
3520 "adds NOT NULL column `{}.{}` with no default — old code inserting without it will fail. Add it nullable (or with a default), backfill, then tighten",
3521 table, column.name
3522 ))
3523 } else {
3524 OpSafety::Safe
3525 }
3526 }
3527
3528 // A view holds no rows of its own (a materialized one holds only derived,
3529 // recomputable rows), so creating one is free and dropping one destroys
3530 // nothing. What a DropView CAN break is old code still selecting from it —
3531 // and since every view edit lowers to drop+create, classifying this Unsafe
3532 // would trip the zero-downtime gate on a routine SQL tweak. Warning is the
3533 // honest severity: worth reading, not worth blocking.
3534 Operation::CreateView { .. } => OpSafety::Safe,
3535 Operation::DropView { name, .. } => OpSafety::Warning(format!(
3536 "drops view `{name}` — no rows are lost (a view stores none), but old code \
3537 still selecting from it breaks until the recreate lands in the same migration"
3538 )),
3539
3540 // Destructive / irreversible: data loss the moment it runs.
3541 Operation::DropTable { table } => OpSafety::Unsafe(format!(
3542 "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"
3543 )),
3544 Operation::DropM2MTable { junction_table } => OpSafety::Unsafe(format!(
3545 "drops join table `{junction_table}` and every row in it — irreversible"
3546 )),
3547 Operation::DropColumn { table, column } => OpSafety::Unsafe(format!(
3548 "drops column `{table}.{column}` and its data — old code reading it breaks. Expand-contract: stop writing it, deploy, then drop"
3549 )),
3550
3551 // Renames apply atomically in the DB but NOT atomically with a
3552 // code deploy: between the migration and the rollout, one of the
3553 // two code versions references the missing name.
3554 Operation::RenameTable { from, to } => OpSafety::Warning(format!(
3555 "renames table `{from}` → `{to}` — not atomic with a code deploy; old code references `{from}`. Expand-contract: add `{to}`, dual-write, switch, then drop `{from}`"
3556 )),
3557 Operation::RenameColumn {
3558 table, from, to, ..
3559 } => OpSafety::Warning(format!(
3560 "renames column `{table}.{from}` → `{to}` — old code references `{from}`. Expand-contract: add `{to}`, backfill, switch reads, then drop `{from}`"
3561 )),
3562
3563 // An alter can rewrite a column (table lock on large data) and a
3564 // nullable→NOT NULL tightening fails on existing NULLs.
3565 Operation::AlterColumn { table, column, .. } => OpSafety::Warning(format!(
3566 "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"
3567 )),
3568
3569 // A hand-authored data migration runs arbitrary SQL — the
3570 // engine can't reason about its row impact, so flag it for
3571 // human review (it may rewrite or delete data, and re-running
3572 // the rollout while it's mid-flight can double-apply).
3573 Operation::RunSql { .. } => OpSafety::Warning(
3574 "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(),
3575 ),
3576
3577 // Adding a composite UNIQUE constraint fails at apply time if
3578 // existing rows already violate it — same hazard as a single-column
3579 // UNIQUE add. A plain (non-unique) index is purely additive.
3580 Operation::AddIndex {
3581 table,
3582 columns,
3583 unique: true,
3584 } => OpSafety::Warning(format!(
3585 "adds a composite UNIQUE constraint on `{table}` ({}) — fails on existing duplicate rows; de-duplicate first or the migration aborts",
3586 columns.join(", ")
3587 )),
3588 Operation::AddIndex { unique: false, .. } => OpSafety::Safe,
3589
3590 // Dropping an index / UNIQUE constraint touches no rows. It removes
3591 // a guarantee (a later duplicate becomes insertable) but that is the
3592 // intent when a `unique_together` is removed, and no data is lost.
3593 Operation::DropIndex { .. } => OpSafety::Safe,
3594 }
3595}
3596
3597/// Classify every operation across all pending migrations against the
3598/// ambient pool. Reads the same applied-set + on-disk diff that
3599/// `migrate` / `showmigrations` use, then loads each pending migration
3600/// file and classifies its operations in order. Powers `checkmigrations`.
3601pub async fn check_pending_safety() -> Result<Vec<ClassifiedOp>, MigrateError> {
3602 check_pending_safety_in(Path::new(MIGRATIONS_DIR)).await
3603}
3604
3605/// [`check_pending_safety`] against an explicit migrations directory.
3606/// The seam tests use to point at a fixture tree.
3607pub async fn check_pending_safety_in(dir: &Path) -> Result<Vec<ClassifiedOp>, MigrateError> {
3608 let applied = match crate::db::pool_dispatched() {
3609 crate::db::DbPool::Sqlite(pool) => {
3610 ensure_tracking_table_sqlite(pool).await?;
3611 applied_names_sqlite(pool).await?
3612 }
3613 crate::db::DbPool::Postgres(pool) => {
3614 ensure_tracking_table_postgres(pool).await?;
3615 applied_names_postgres(pool).await?
3616 }
3617 };
3618
3619 let report = detect_all_drift(&applied, dir)?;
3620
3621 let mut out: Vec<ClassifiedOp> = Vec::new();
3622 for entry in &report.entries {
3623 if entry.status != MigrationStatus::Pending {
3624 continue;
3625 }
3626 let path = dir.join(&entry.plugin).join(format!("{}.json", entry.name));
3627 let file = read_migration_file(&path)?;
3628 for op in &file.operations {
3629 out.push(ClassifiedOp {
3630 plugin: entry.plugin.clone(),
3631 migration: entry.name.clone(),
3632 op: op.clone(),
3633 safety: classify_operation(op),
3634 });
3635 }
3636 }
3637 Ok(out)
3638}
3639
3640// =========================================================================
3641// Internal helpers. Crate-private; the public surface above is the only
3642// thing the rest of umbral calls into.
3643// =========================================================================
3644
3645/// Return every `*.json` migration file in `plugin_dir`, sorted by
3646/// filename (lexical sort matches numeric order because the prefix is
3647/// zero-padded). Returns an empty vec if the directory is missing.
3648fn list_migration_files(plugin_dir: &Path) -> Result<Vec<PathBuf>, MigrateError> {
3649 if !plugin_dir.exists() {
3650 return Ok(Vec::new());
3651 }
3652 let mut paths: Vec<PathBuf> = Vec::new();
3653 for entry in std::fs::read_dir(plugin_dir)? {
3654 let entry = entry?;
3655 let path = entry.path();
3656 if path.extension().and_then(|s| s.to_str()) == Some("json") {
3657 paths.push(path);
3658 }
3659 }
3660 paths.sort();
3661 Ok(paths)
3662}
3663
3664/// Read and parse one migration file.
3665fn read_migration_file(path: &Path) -> Result<MigrationFile, MigrateError> {
3666 let text = std::fs::read_to_string(path)?;
3667 let file: MigrationFile = serde_json::from_str(&text)?;
3668 Ok(file)
3669}
3670
3671/// Diff the previous snapshot against the current one and produce the
3672/// ordered operation list.
3673///
3674/// Emits `CreateTable` / `DropTable` for whole-model changes (M5 v1),
3675/// and `AddColumn` / `DropColumn` for column-level changes on a model
3676/// that appears in both snapshots (M8 v1). A column whose name stays
3677/// the same but whose type or nullable flag changed surfaces as
3678/// [`MigrateError::UnsafeAlter`]: SQLite can't ALTER COLUMN TYPE in
3679/// place, and a nullable flip on a populated table is destructive.
3680///
3681/// Gap 30 adds two-pass rename detection. `Model::NAME` (the Rust struct
3682/// name) is the stable identity key across snapshots; the SQL table name
3683/// in `Model::TABLE` may change (e.g. via the `#[umbral(plugin = "...")]`
3684/// opt-in). The two passes are:
3685///
3686/// - **First pass — struct-name match.** If a model present in `current`
3687/// but absent from `previous` (by `Model::NAME`) has the same NAME as
3688/// a model present in `previous` but absent from `current`, the table
3689/// name changed: emit `RenameTable { from, to }` instead of DropTable +
3690/// CreateTable. A stdout message names the rename so the developer can
3691/// audit `makemigrations` output.
3692/// - **Second pass — column-shape match.** Among unpaired drops and
3693/// creates, if a drop candidate and a create candidate have bit-identical
3694/// column shapes (same column names, types, nullable, fk_target), emit
3695/// `RenameTable` and log a warning so the developer can verify the
3696/// intent. Struct names differ; the shape heuristic fills in for cases
3697/// like a wholesale model rename (Foo → Bar, identical fields).
3698/// - **No-match.** Drop and create as today.
3699///
3700/// `pub` (not `pub(crate)`) so integration tests can drive the diff
3701/// directly with hand-built snapshots. Spec 06 calls the diff the
3702/// engine's contract; exposing it lets the tests pin every scenario
3703/// without laundering snapshots through the process-wide registry.
3704/// audit_2 H23 — how `diff` should treat an ambiguous column-shape rename: an
3705/// unpaired dropped model and an unpaired created model with *identical* shapes.
3706/// Driven by `UMBRAL_MIGRATIONS_ASSUME_RENAMES`.
3707enum RenameIntent {
3708 /// Auto-pair every shape match into a `RenameTable` (the pre-H23 default).
3709 Assume,
3710 /// Treat the pair as unrelated: emit drop + create, no row transfer.
3711 Independent,
3712 /// Not configured → `diff` fails closed with `AmbiguousRename`.
3713 Undecided,
3714}
3715
3716fn rename_intent() -> RenameIntent {
3717 match std::env::var("UMBRAL_MIGRATIONS_ASSUME_RENAMES") {
3718 Ok(v) => match v.trim().to_ascii_lowercase().as_str() {
3719 "assume" | "1" | "true" | "yes" | "rename" => RenameIntent::Assume,
3720 "independent" | "0" | "false" | "no" | "drop" => RenameIntent::Independent,
3721 _ => RenameIntent::Undecided,
3722 },
3723 Err(_) => RenameIntent::Undecided,
3724 }
3725}
3726
3727/// Emit `AddIndex` / `DropIndex` ops for changes to a model's TABLE-level
3728/// `unique_together` and `indexes` between two snapshots.
3729///
3730/// Column-shape changes are handled by [`diff_columns`]; this covers the
3731/// constraint-only deltas it can't see. Before this existed, adding a
3732/// `unique_together` group or a multi-column `indexes` entry with no column
3733/// change produced NO migration at all — `makemigrations` said "no changes"
3734/// and the constraint was silently never created.
3735///
3736/// A group is identified by its ORDERED column list, so re-ordering a group
3737/// (`[a, b]` → `[b, a]`) reads as drop-old + add-new — correct, since column
3738/// order changes which queries the index serves. `AddIndex`/`DropIndex`
3739/// render `IF NOT EXISTS` / `IF EXISTS`, so when a same-table `AlterColumn`
3740/// in the same migration already rebuilt the table with the new constraint
3741/// set (the SQLite dance), these ops are harmless no-ops.
3742fn diff_indexes(previous: &ModelMeta, current: &ModelMeta) -> Vec<Operation> {
3743 use std::collections::BTreeSet;
3744
3745 let mut ops: Vec<Operation> = Vec::new();
3746
3747 let mut emit = |prev_groups: &[Vec<String>], curr_groups: &[Vec<String>], unique: bool| {
3748 let prev_set: BTreeSet<&Vec<String>> = prev_groups.iter().collect();
3749 let curr_set: BTreeSet<&Vec<String>> = curr_groups.iter().collect();
3750 // Added groups → AddIndex (on the current table name).
3751 for group in curr_set.difference(&prev_set) {
3752 ops.push(Operation::AddIndex {
3753 table: current.table.clone(),
3754 columns: (*group).clone(),
3755 unique,
3756 });
3757 }
3758 // Removed groups → DropIndex (on the previous table name — a rename
3759 // in the same diff emits its own RenameTable first, and the index
3760 // travels with the table, so the drop names the post-rename table;
3761 // use current.table for that reason).
3762 for group in prev_set.difference(&curr_set) {
3763 ops.push(Operation::DropIndex {
3764 table: current.table.clone(),
3765 columns: (*group).clone(),
3766 unique,
3767 });
3768 }
3769 };
3770
3771 emit(&previous.unique_together, ¤t.unique_together, true);
3772 emit(&previous.indexes, ¤t.indexes, false);
3773
3774 // Single-column `#[umbral(index)]` flag flips. A column that gains the
3775 // flag → AddIndex; one that loses it (but still exists) → DropIndex. PK
3776 // and UNIQUE columns are excluded — they carry their own index, matching
3777 // `should_emit_btree_index`. A column that was DROPPED entirely takes its
3778 // index with it (DROP COLUMN cascades on both backends), so we don't emit a
3779 // redundant DropIndex for it. The `idx_<table>_<col>` name matches the one
3780 // `create_index_stmt` uses at CreateTable time, so add/drop stay symmetric.
3781 let indexed = |m: &ModelMeta| -> std::collections::BTreeSet<String> {
3782 m.fields
3783 .iter()
3784 .filter(|c| c.index && !c.primary_key && !c.unique)
3785 .map(|c| c.name.clone())
3786 .collect()
3787 };
3788 let curr_col_names: std::collections::BTreeSet<&str> =
3789 current.fields.iter().map(|c| c.name.as_str()).collect();
3790 let prev_indexed = indexed(previous);
3791 let curr_indexed = indexed(current);
3792 for name in curr_indexed.difference(&prev_indexed) {
3793 ops.push(Operation::AddIndex {
3794 table: current.table.clone(),
3795 columns: vec![name.clone()],
3796 unique: false,
3797 });
3798 }
3799 for name in prev_indexed.difference(&curr_indexed) {
3800 if curr_col_names.contains(name.as_str()) {
3801 ops.push(Operation::DropIndex {
3802 table: current.table.clone(),
3803 columns: vec![name.clone()],
3804 unique: false,
3805 });
3806 }
3807 }
3808 ops
3809}
3810
3811pub fn diff(previous: &Snapshot, current: &Snapshot) -> Result<Vec<Operation>, MigrateError> {
3812 use std::collections::{BTreeMap, HashSet};
3813
3814 // features #73 — views are diffed on a separate track, for a reason that runs
3815 // deeper than tidiness: every pass below exists to AVOID destroying data (the
3816 // rename heuristic, the safe-cast table, the data-preserving ALTER). A view
3817 // stores nothing, so none of that machinery applies to it, and letting a view
3818 // through it would be actively wrong — Pass 1 could "detect a rename" between a
3819 // dropped table and a created view and emit an ALTER against a relation that has
3820 // no rows to alter.
3821 //
3822 // So: hide the views from the table passes entirely, then handle them in Pass 5
3823 // with the only two operations a view can ever need.
3824 let view_prev = previous;
3825 let view_curr = current;
3826 let prev_tables = Snapshot {
3827 models: previous
3828 .models
3829 .iter()
3830 .filter(|m| m.view.is_none())
3831 .cloned()
3832 .collect(),
3833 };
3834 let curr_tables = Snapshot {
3835 models: current
3836 .models
3837 .iter()
3838 .filter(|m| m.view.is_none())
3839 .cloned()
3840 .collect(),
3841 };
3842 let previous = &prev_tables;
3843 let current = &curr_tables;
3844
3845 let prev_by_name: BTreeMap<&str, &ModelMeta> = previous
3846 .models
3847 .iter()
3848 .map(|m| (m.name.as_str(), m))
3849 .collect();
3850 let curr_by_name: BTreeMap<&str, &ModelMeta> = current
3851 .models
3852 .iter()
3853 .map(|m| (m.name.as_str(), m))
3854 .collect();
3855
3856 let mut ops: Vec<Operation> = Vec::new();
3857
3858 // gaps.md #93: every table rename `diff` decides on (old_table → new_table),
3859 // recorded so Pass 4 can rename the parent's M2M junctions instead of
3860 // dropping + recreating them (which would destroy every relationship row).
3861 let mut renamed_tables: BTreeMap<String, String> = BTreeMap::new();
3862
3863 // ---- Pass 0: Walk models present in both snapshots (same NAME). ----
3864 // Same-name models with a different table produce a first-pass rename.
3865 // Same-name models with identical table+columns produce nothing.
3866 // Same-name models with column changes produce column-level ops.
3867
3868 let mut drop_candidates: Vec<&ModelMeta> = Vec::new(); // in prev, not curr
3869 let mut create_candidates: Vec<&ModelMeta> = Vec::new(); // in curr, not prev
3870
3871 // Creates and column-level diffs, in deterministic name order.
3872 for (name, curr) in &curr_by_name {
3873 match prev_by_name.get(name) {
3874 None => {
3875 // In current but not previous — might be a create or a first-pass rename.
3876 create_candidates.push(curr);
3877 }
3878 Some(prev) if prev.table != curr.table => {
3879 // Same struct name, different table name → first-pass rename.
3880 println!(
3881 "umbral makemigrations: rename detected (struct-name match): \
3882 table `{}` → `{}`",
3883 prev.table, curr.table
3884 );
3885 ops.push(Operation::RenameTable {
3886 from: prev.table.clone(),
3887 to: curr.table.clone(),
3888 });
3889 renamed_tables.insert(prev.table.clone(), curr.table.clone());
3890 // After the rename the columns might also have changed; diff them.
3891 let col_ops = diff_columns(name, prev, curr)?;
3892 ops.extend(col_ops);
3893 // ...and the table-level constraints (unique_together/indexes).
3894 ops.extend(diff_indexes(prev, curr));
3895 }
3896 Some(prev) if prev == curr => {}
3897 Some(prev) => {
3898 ops.extend(diff_columns(name, prev, curr)?);
3899 // Constraint-only deltas (a new/removed unique_together or
3900 // composite index with no column change) — otherwise silent.
3901 ops.extend(diff_indexes(prev, curr));
3902 }
3903 }
3904 }
3905
3906 // Drops — models in prev but not curr (by NAME).
3907 for (name, prev) in &prev_by_name {
3908 if !curr_by_name.contains_key(name) {
3909 drop_candidates.push(prev);
3910 }
3911 }
3912
3913 // ---- Pass 1: Column-shape heuristic for unpaired drops + creates. ----
3914 // A sorted, canonical serialisation of (name, ty, nullable, fk_target)
3915 // is the "shape" fingerprint. Bit-identical shapes → likely a model
3916 // rename where the struct name also changed.
3917
3918 // audit_2 H23 — a bit-identical column shape between a dropped and a
3919 // created model is genuinely ambiguous (rename vs. two unrelated models).
3920 // The pre-H23 code auto-emitted a `RenameTable` with only an `eprintln!`,
3921 // silently handing one model's rows to another and skipping the intended
3922 // drop. Now the ambiguity is resolved by explicit operator intent: `assume`
3923 // restores auto-pairing, `independent` treats the pair as unrelated
3924 // (drop + create), and the unset default fails closed so no destructive
3925 // guess is ever applied silently.
3926 let intent = rename_intent();
3927 let mut paired_drop_tables: HashSet<&str> = HashSet::new();
3928 let mut paired_create_tables: HashSet<&str> = HashSet::new();
3929
3930 'creates: for create in &create_candidates {
3931 let create_shape = column_shape(&create.fields);
3932 for drop in &drop_candidates {
3933 if paired_drop_tables.contains(drop.table.as_str()) {
3934 continue;
3935 }
3936 if column_shape(&drop.fields) != create_shape {
3937 continue;
3938 }
3939 match intent {
3940 RenameIntent::Assume => {
3941 eprintln!(
3942 "umbral makemigrations: rename ASSUMED (column-shape match): \
3943 `{}` → `{}` — moving the old table's rows to the new name. Set \
3944 UMBRAL_MIGRATIONS_ASSUME_RENAMES=independent if these are unrelated \
3945 models that merely share a shape.",
3946 drop.table, create.table
3947 );
3948 ops.push(Operation::RenameTable {
3949 from: drop.table.clone(),
3950 to: create.table.clone(),
3951 });
3952 renamed_tables.insert(drop.table.clone(), create.table.clone());
3953 paired_drop_tables.insert(drop.table.as_str());
3954 paired_create_tables.insert(create.table.as_str());
3955 continue 'creates;
3956 }
3957 RenameIntent::Independent => {
3958 eprintln!(
3959 "umbral makemigrations: column-shape match `{}` ↔ `{}` treated as \
3960 UNRELATED (drop + create) per UMBRAL_MIGRATIONS_ASSUME_RENAMES=\
3961 independent. Set it to `assume` (or hand-write a RenameTable) if this \
3962 is actually a rename — otherwise `{}`'s rows are dropped.",
3963 drop.table, create.table, drop.table
3964 );
3965 // Leave both unpaired → Pass 2 creates, Pass 3 drops.
3966 continue 'creates;
3967 }
3968 RenameIntent::Undecided => {
3969 return Err(MigrateError::AmbiguousRename {
3970 from_table: drop.table.clone(),
3971 to_table: create.table.clone(),
3972 });
3973 }
3974 }
3975 }
3976 }
3977
3978 // ---- Pass 2: Emit plain CreateTable for unpaired creates. ----
3979 //
3980 // Sort the create list topologically by FK dependency so that a
3981 // table referenced by another table in this batch is created first.
3982 // Without this, Postgres rejects the second CreateTable with
3983 // `relation "<target>" does not exist`. (SQLite tolerates the wrong
3984 // order when `foreign_keys=OFF`, the historical default; once
3985 // we turned foreign_keys ON in connect_sqlite, SQLite agrees with
3986 // Postgres on the order requirement.)
3987 //
3988 // Kahn's algorithm on (table → set of FK-target tables that are
3989 // ALSO in the create batch). Self-references and FK targets outside
3990 // the batch are skipped (they're either harmless or already exist
3991 // by the time this migration runs).
3992 let creates: Vec<&&ModelMeta> = create_candidates
3993 .iter()
3994 .filter(|c| !paired_create_tables.contains(c.table.as_str()))
3995 .collect();
3996 let batch_tables: HashSet<&str> = creates.iter().map(|c| c.table.as_str()).collect();
3997 let mut deps: BTreeMap<&str, HashSet<&str>> = BTreeMap::new();
3998 for create in &creates {
3999 let mut in_batch: HashSet<&str> = HashSet::new();
4000 for col in &create.fields {
4001 if let Some(target) = col.fk_target.as_deref()
4002 && target != create.table.as_str()
4003 && batch_tables.contains(target)
4004 {
4005 in_batch.insert(target);
4006 }
4007 }
4008 deps.insert(create.table.as_str(), in_batch);
4009 }
4010 // Kahn: repeatedly pop tables with no remaining deps in the batch.
4011 // BTreeMap iteration is alphabetical → ties break alphabetically,
4012 // keeping the output stable.
4013 let mut ordered: Vec<&&ModelMeta> = Vec::with_capacity(creates.len());
4014 while !deps.is_empty() {
4015 let ready: Vec<&str> = deps
4016 .iter()
4017 .filter(|(_, d)| d.is_empty())
4018 .map(|(t, _)| *t)
4019 .collect();
4020 if ready.is_empty() {
4021 // Cyclic FK or other unresolvable dep — fall through to
4022 // the original order rather than dropping models. A cycle
4023 // here means the user's schema can't be created with
4024 // plain CreateTable anyway (Postgres needs deferrable
4025 // constraints), so we surface the user-visible error at
4026 // apply time instead of silently looping.
4027 for create in &creates {
4028 if deps.contains_key(create.table.as_str()) {
4029 ordered.push(create);
4030 }
4031 }
4032 break;
4033 }
4034 for t in &ready {
4035 if let Some(create) = creates.iter().find(|c| c.table.as_str() == *t) {
4036 ordered.push(create);
4037 }
4038 deps.remove(t);
4039 }
4040 for (_, set) in deps.iter_mut() {
4041 for t in &ready {
4042 set.remove(t);
4043 }
4044 }
4045 }
4046 for create in ordered {
4047 ops.push(Operation::CreateTable {
4048 table: create.table.clone(),
4049 columns: create.fields.clone(),
4050 unique_together: create.unique_together.clone(),
4051 indexes: create.indexes.clone(),
4052 });
4053 }
4054
4055 // ---- Pass 3: Emit plain DropTable for unpaired drops. ----
4056 for drop in &drop_candidates {
4057 if !paired_drop_tables.contains(drop.table.as_str()) {
4058 ops.push(Operation::DropTable {
4059 table: drop.table.clone(),
4060 });
4061 }
4062 }
4063
4064 // ---- Pass 4: Diff M2M relations. Closes the remaining BUG-16 gap. ----
4065 //
4066 // Treat each (parent_table, field_name) pair as a junction-table identity.
4067 // Compare the flattened set across snapshots and emit CreateM2MTable /
4068 // DropM2MTable per delta.
4069 //
4070 // gaps.md #93: when the PARENT model was renamed (Pass 0/1), its junction's
4071 // key moves from `(old_table, field)` to `(new_table, field)` — which would
4072 // otherwise read as one junction dropped and a different one created,
4073 // destroying every relationship row. Detect that case via `renamed_tables`
4074 // and emit a plain `RenameTable` on the junction instead. The junction's
4075 // columns are generic (`parent_id`/`child_id`) and its FK to the parent is
4076 // auto-updated by the parent's own rename, so a table rename is sufficient.
4077 let prev_m2m = collect_m2m_pairs(previous);
4078 let curr_m2m = collect_m2m_pairs(current);
4079
4080 // Prev junction keys consumed by a junction rename below — skip their drop.
4081 let mut renamed_prev_junctions: HashSet<(String, String)> = HashSet::new();
4082
4083 for (key, spec) in &curr_m2m {
4084 if prev_m2m.contains_key(key) {
4085 continue;
4086 }
4087 let (new_parent, field) = key;
4088 // Was this junction's parent renamed FROM some old table? If the prior
4089 // snapshot has the same field on that old parent, targeting the same
4090 // table, it's a junction rename, not a fresh create.
4091 let renamed_from = renamed_tables
4092 .iter()
4093 .find(|(_, new)| *new == new_parent)
4094 .and_then(|(old, _)| {
4095 let old_key = (old.clone(), field.clone());
4096 prev_m2m.get(&old_key).and_then(|old_spec| {
4097 (old_spec.target_table == spec.target_table).then_some(old_key)
4098 })
4099 });
4100 if let Some(old_key) = renamed_from {
4101 let old_junction = prev_m2m[&old_key].junction_table.clone();
4102 ops.push(Operation::RenameTable {
4103 from: old_junction,
4104 to: spec.junction_table.clone(),
4105 });
4106 renamed_prev_junctions.insert(old_key);
4107 continue;
4108 }
4109 // New M2M field on an existing or new model. Resolve the
4110 // target's PK column from the current snapshot.
4111 match build_create_m2m_op(spec, current) {
4112 Ok(op) => ops.push(op),
4113 Err(e) => return Err(e),
4114 }
4115 }
4116 for (key, spec) in &prev_m2m {
4117 if curr_m2m.contains_key(key) || renamed_prev_junctions.contains(key) {
4118 continue;
4119 }
4120 // M2M field removed (or its parent was dropped). The junction
4121 // table goes away.
4122 ops.push(Operation::DropM2MTable {
4123 junction_table: spec.junction_table.clone(),
4124 });
4125 }
4126
4127 // ---- Pass 5: views (features #73). ----
4128 //
4129 // Ordering is the whole feature. Drops go BEFORE the table ops and creates go
4130 // AFTER, because Postgres refuses to drop or retype a column that a live view
4131 // selects from — so a migration that alters `matchday.score` while a view reads
4132 // it fails unless the view is out of the way first, and is only whole again once
4133 // the view is back.
4134 let (view_drops, view_creates) = diff_views(view_prev, view_curr, &ops)?;
4135 let mut all = view_drops;
4136 all.extend(ops);
4137 all.extend(view_creates);
4138 Ok(all)
4139}
4140
4141/// The relations a view's SQL selects from, recovered by scanning its body for the
4142/// names of relations we know about.
4143///
4144/// The SELECT body is an opaque string — nobody is writing a SQL parser here. But
4145/// the engine already knows every table and view name in the snapshot, so it can ask
4146/// the cheaper question: *does this view's SQL mention `matchday`?* A whole-word
4147/// match (so `matchday` does not match `matchday_team`) recovers the dependency edge
4148/// that ordering needs.
4149///
4150/// The failure modes are asymmetric, which is why this is worth doing:
4151/// - A **false positive** (the name appears in a string literal or a comment) costs
4152/// one needless `DROP VIEW` + `CREATE VIEW`. A view stores nothing. Nobody notices.
4153/// - A **false negative** would order a view before the table it reads, and the
4154/// migration fails at apply time with a clear "relation does not exist".
4155///
4156/// Both are safe. Neither can corrupt data. That asymmetry is what makes a heuristic
4157/// acceptable here and unacceptable in the table passes next door.
4158fn view_dependencies(sql: &str, known: &[String], self_table: &str) -> Vec<String> {
4159 let haystack = sql.to_ascii_lowercase();
4160 let bytes = haystack.as_bytes();
4161 let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
4162
4163 let mut deps = Vec::new();
4164 for name in known {
4165 if name == self_table {
4166 continue;
4167 }
4168 let needle = name.to_ascii_lowercase();
4169 if needle.is_empty() {
4170 continue;
4171 }
4172 let mut from = 0usize;
4173 while let Some(hit) = haystack[from..].find(&needle) {
4174 let start = from + hit;
4175 let end = start + needle.len();
4176 let before_ok = start == 0 || !is_word(bytes[start - 1]);
4177 let after_ok = end == bytes.len() || !is_word(bytes[end]);
4178 if before_ok && after_ok {
4179 deps.push(name.clone());
4180 break;
4181 }
4182 from = end;
4183 }
4184 }
4185 deps
4186}
4187
4188/// Diff the view models of two snapshots into (drops, creates).
4189///
4190/// `table_ops` is what the table passes decided; a view that reads from a table
4191/// those ops touch is recreated even if its own SQL never changed.
4192fn diff_views(
4193 previous: &Snapshot,
4194 current: &Snapshot,
4195 table_ops: &[Operation],
4196) -> Result<(Vec<Operation>, Vec<Operation>), MigrateError> {
4197 use std::collections::{BTreeMap, HashSet};
4198
4199 let view_models = |snap: &Snapshot| -> Vec<ModelMeta> {
4200 snap.models
4201 .iter()
4202 .filter(|m| m.view.is_some())
4203 .cloned()
4204 .collect()
4205 };
4206 let prev_views = view_models(previous);
4207 let curr_views = view_models(current);
4208 if prev_views.is_empty() && curr_views.is_empty() {
4209 return Ok((Vec::new(), Vec::new()));
4210 }
4211
4212 let touched: HashSet<&str> = table_ops.iter().map(|o| o.table_name()).collect();
4213 let known: Vec<String> = current.models.iter().map(|m| m.table.clone()).collect();
4214
4215 let deps: BTreeMap<&str, Vec<String>> = curr_views
4216 .iter()
4217 .map(|m| {
4218 (
4219 m.name.as_str(),
4220 view_dependencies(m.view.as_deref().unwrap_or(""), &known, &m.table),
4221 )
4222 })
4223 .collect();
4224
4225 let prev_by_name: BTreeMap<&str, &ModelMeta> =
4226 prev_views.iter().map(|m| (m.name.as_str(), m)).collect();
4227
4228 // Which views are stale? Either their own definition moved, or something they
4229 // read did.
4230 let mut recreate: HashSet<&str> = HashSet::new();
4231 for m in &curr_views {
4232 let definition_changed = match prev_by_name.get(m.name.as_str()) {
4233 None => true, // brand new, or was a table until this change
4234 Some(prev) => {
4235 prev.view != m.view || prev.materialized != m.materialized || prev.table != m.table
4236 }
4237 };
4238 let reads_a_touched_table = deps[m.name.as_str()]
4239 .iter()
4240 .any(|d| touched.contains(d.as_str()));
4241 if definition_changed || reads_a_touched_table {
4242 recreate.insert(m.name.as_str());
4243 }
4244 }
4245
4246 // A view built on a view that is being recreated is itself stale. Iterate to a
4247 // fixpoint rather than assuming one level of nesting.
4248 loop {
4249 let mut grew = false;
4250 for m in &curr_views {
4251 if recreate.contains(m.name.as_str()) {
4252 continue;
4253 }
4254 let reads_a_stale_view = deps[m.name.as_str()].iter().any(|d| {
4255 curr_views
4256 .iter()
4257 .any(|v| &v.table == d && recreate.contains(v.name.as_str()))
4258 });
4259 if reads_a_stale_view {
4260 recreate.insert(m.name.as_str());
4261 grew = true;
4262 }
4263 }
4264 if !grew {
4265 break;
4266 }
4267 }
4268
4269 // ---- Creates, in dependency order (a view after the views it reads). ----
4270 let mut to_create: Vec<&ModelMeta> = curr_views
4271 .iter()
4272 .filter(|m| recreate.contains(m.name.as_str()))
4273 .collect();
4274 to_create.sort_by(|a, b| a.name.cmp(&b.name)); // deterministic before topo
4275
4276 let mut creates: Vec<Operation> = Vec::new();
4277 let mut placed: HashSet<&str> = HashSet::new();
4278 while placed.len() < to_create.len() {
4279 let before = placed.len();
4280 for m in &to_create {
4281 if placed.contains(m.table.as_str()) {
4282 continue;
4283 }
4284 let blocked = deps[m.name.as_str()].iter().any(|d| {
4285 to_create
4286 .iter()
4287 .any(|other| &other.table == d && !placed.contains(other.table.as_str()))
4288 });
4289 if blocked {
4290 continue;
4291 }
4292 placed.insert(m.table.as_str());
4293 creates.push(Operation::CreateView {
4294 name: m.table.clone(),
4295 sql: m.view.clone().unwrap_or_default(),
4296 materialized: m.materialized,
4297 });
4298 }
4299 if placed.len() == before {
4300 // A cycle. Two views cannot select from each other, so this means the
4301 // SQL scan found a self-referential edge we cannot honour. Name the
4302 // views rather than looping forever.
4303 let stuck: Vec<&str> = to_create
4304 .iter()
4305 .filter(|m| !placed.contains(m.table.as_str()))
4306 .map(|m| m.table.as_str())
4307 .collect();
4308 return Err(MigrateError::UnsupportedChange(format!(
4309 "views {stuck:?} form a dependency cycle — each one's SQL selects from \
4310 another in the set, so there is no order in which they can be created. \
4311 Break the cycle by inlining one of the definitions."
4312 )));
4313 }
4314 }
4315
4316 // ---- Drops: every view we are about to recreate, plus every view that is gone
4317 // (removed, or converted back into a table). Emitted in reverse create order so
4318 // a dependant view is dropped before the view it reads.
4319 let mut drops: Vec<Operation> = Vec::new();
4320 let curr_view_names: HashSet<&str> = curr_views.iter().map(|m| m.name.as_str()).collect();
4321 let mut to_drop: Vec<&ModelMeta> = prev_views
4322 .iter()
4323 .filter(|m| {
4324 !curr_view_names.contains(m.name.as_str()) || recreate.contains(m.name.as_str())
4325 })
4326 .collect();
4327 // Reverse-topological by the CREATE order we just computed; anything not in it
4328 // (a removed view) sorts first, since nothing we keep can depend on it.
4329 let create_pos = |table: &str| -> usize {
4330 creates
4331 .iter()
4332 .position(|op| matches!(op, Operation::CreateView { name, .. } if name == table))
4333 .map(|i| usize::MAX - i)
4334 .unwrap_or(0)
4335 };
4336 to_drop.sort_by_key(|m| (create_pos(&m.table), m.table.clone()));
4337 for m in to_drop {
4338 drops.push(Operation::DropView {
4339 name: m.table.clone(),
4340 materialized: m.materialized,
4341 });
4342 }
4343
4344 Ok((drops, creates))
4345}
4346
4347/// A flat-resolved M2M descriptor used by [`diff`] to compare snapshots.
4348/// Owns its strings so it can be keyed in a map without lifetime
4349/// gymnastics.
4350#[derive(Debug, Clone)]
4351struct M2MPair {
4352 parent_table: String,
4353 parent_pk: String,
4354 field_name: String,
4355 target_table: String,
4356 junction_table: String,
4357}
4358
4359/// Walk a snapshot and produce one [`M2MPair`] per declared M2M field.
4360/// Keyed on `(parent_table, field_name)` since that uniquely identifies
4361/// a junction table — two models can't share the same parent_table, and
4362/// one model can't declare two M2M fields with the same name.
4363fn collect_m2m_pairs(snap: &Snapshot) -> std::collections::BTreeMap<(String, String), M2MPair> {
4364 let mut out = std::collections::BTreeMap::new();
4365 for model in &snap.models {
4366 let parent_pk = model
4367 .fields
4368 .iter()
4369 .find(|c| c.primary_key)
4370 .map(|c| c.name.clone())
4371 .unwrap_or_else(|| "id".to_string());
4372 for rel in &model.m2m_relations {
4373 let key = (model.table.clone(), rel.field_name.clone());
4374 out.insert(
4375 key,
4376 M2MPair {
4377 parent_table: model.table.clone(),
4378 parent_pk: parent_pk.clone(),
4379 field_name: rel.field_name.clone(),
4380 target_table: rel.target_table.clone(),
4381 junction_table: format!("{}_{}", model.table, rel.field_name),
4382 },
4383 );
4384 }
4385 }
4386 out
4387}
4388
4389/// Lift an [`M2MPair`] into a fully-specified [`Operation::CreateM2MTable`].
4390/// The target table's PK column name is resolved from `current` (the
4391/// snapshot the diff is computing toward) — without it the DDL would
4392/// reference a column the child table doesn't have.
4393fn build_create_m2m_op(spec: &M2MPair, current: &Snapshot) -> Result<Operation, MigrateError> {
4394 // Resolve the target's PK from the current snapshot, FALLING BACK to the
4395 // global model registry. Migrations are generated per-plugin, so a
4396 // CROSS-PLUGIN M2M (parent owned by app A, target model owned by app B —
4397 // e.g. a tenant model with an M2M to a SHARED lookup table, or any app's
4398 // M2M to `umbral-auth`'s `User`) has its target in a *different* plugin's
4399 // snapshot, absent from `current`. The global registry sees every
4400 // registered model, so the junction DDL resolves the child PK no matter
4401 // which plugin owns the target. (Cross-plugin FK ordering already lets the
4402 // junction migration run after the target table's own migration.)
4403 let pk_col_and_ty = |m: &ModelMeta| -> (String, crate::orm::SqlType) {
4404 let pk = m.fields.iter().find(|c| c.primary_key);
4405 (
4406 pk.map(|c| c.name.clone())
4407 .unwrap_or_else(|| "id".to_string()),
4408 pk.map(|c| c.ty).unwrap_or(crate::orm::SqlType::BigInt),
4409 )
4410 };
4411 let (child_pk_col, child_ty) = current
4412 .models
4413 .iter()
4414 .find(|m| m.table == spec.target_table)
4415 .map(&pk_col_and_ty)
4416 .or_else(|| {
4417 // Non-panicking global lookup. `registered_models()` panics if the
4418 // registry isn't initialised (unit tests that call `diff` directly,
4419 // with no `App::build`); a `None` registry simply yields no global
4420 // fallback, so a TRULY-unregistered target is still rejected below.
4421 REGISTRY.get().and_then(|reg| {
4422 reg.iter()
4423 .find(|(_, m)| m.table == spec.target_table)
4424 .map(|(_, m)| pk_col_and_ty(m))
4425 })
4426 })
4427 .ok_or_else(|| {
4428 MigrateError::UnsupportedChange(format!(
4429 "M2M `{}.{}` targets table `{}` which is not registered \
4430 anywhere — register the target model via \
4431 `AppBuilder::model::<{}>()` or its owning plugin.",
4432 spec.parent_table, spec.field_name, spec.target_table, spec.target_table,
4433 ))
4434 })?;
4435 let parent_model = current
4436 .models
4437 .iter()
4438 .find(|m| m.table == spec.parent_table)
4439 .expect("parent model exists in snapshot — collect_m2m_pairs iterated it");
4440 let parent_ty = parent_model
4441 .fields
4442 .iter()
4443 .find(|c| c.primary_key)
4444 .map(|c| c.ty)
4445 .unwrap_or(crate::orm::SqlType::BigInt);
4446 Ok(Operation::CreateM2MTable {
4447 junction_table: spec.junction_table.clone(),
4448 parent_table: spec.parent_table.clone(),
4449 parent_col: spec.parent_pk.clone(),
4450 child_table: spec.target_table.clone(),
4451 child_col: child_pk_col,
4452 parent_ty,
4453 child_ty,
4454 })
4455}
4456
4457/// Compute a canonical, sorted column-shape fingerprint for rename
4458/// heuristic detection in `diff`. Two models whose column fingerprints
4459/// are identical are candidates for a rename (second-pass detection).
4460///
4461/// The fingerprint is a sorted `Vec` of `(name, ty, nullable, fk_target)`
4462/// tuples. Sorting by name ensures the fingerprint is independent of
4463/// declaration order.
4464fn column_shape(fields: &[Column]) -> Vec<(String, SqlType, bool, Option<String>)> {
4465 let mut shape: Vec<(String, SqlType, bool, Option<String>)> = fields
4466 .iter()
4467 .map(|c| (c.name.clone(), c.ty, c.nullable, c.fk_target.clone()))
4468 .collect();
4469 shape.sort_by(|a, b| a.0.cmp(&b.0));
4470 shape
4471}
4472
4473/// Type changes the migration engine can apply without user
4474/// intervention. The contract: every entry in this whitelist must be
4475/// data-preserving on both backends.
4476///
4477/// SQLite handles every entry trivially via the table-recreation
4478/// dance: its dynamic typing means whatever lives in a column today
4479/// reads back fine under a new column type affinity. Postgres needs
4480/// `ALTER COLUMN ... TYPE new_type USING column::new_type`, which the
4481/// renderer emits when this returns `true`.
4482///
4483/// What's *not* here is deliberate:
4484/// - `Text -> BigInt` / numeric parses can fail at runtime on non-
4485/// numeric rows. Force the user to write the migration so they own
4486/// the validation.
4487/// - Bigger int -> smaller int truncates silently.
4488/// - `Text -> Date` / `Text -> Uuid` are format-dependent.
4489/// - Anything -> JSON. Even if existing rows are JSON-shaped, that's
4490/// the user's invariant to assert.
4491fn is_safe_cast(from: SqlType, to: SqlType) -> bool {
4492 use SqlType::*;
4493 if from == to {
4494 return true;
4495 }
4496 match (from, to) {
4497 // Stringify: every scalar serialises to text losslessly. Read-
4498 // path code that wants the typed value parses it back; the
4499 // cast itself never fails.
4500 (
4501 SmallInt | Integer | BigInt | Real | Double | Boolean | Date | Time | Timestamptz
4502 | Uuid | Inet | Cidr | MacAddr | ForeignKey,
4503 Text,
4504 ) => true,
4505 // Integer widening — no data loss.
4506 (SmallInt, Integer | BigInt) => true,
4507 (Integer, BigInt) => true,
4508 // Float widening.
4509 (Real, Double) => true,
4510 // ForeignKey is stored as BigInt under the hood, so the two
4511 // directions are storage-identical. The Rust-side type is
4512 // different but the bytes on disk are not.
4513 (ForeignKey, BigInt) => true,
4514 (BigInt, ForeignKey) => true,
4515 _ => false,
4516 }
4517}
4518
4519/// Postgres type name for an `ALTER COLUMN ... TYPE <name> USING …`
4520/// clause. Matches what sea-query's `PostgresQueryBuilder` emits for
4521/// the same `SqlType` inside a `CREATE TABLE`, so the resulting
4522/// schema after the alter is identical to a freshly created table.
4523fn postgres_type_name(ty: SqlType) -> &'static str {
4524 use SqlType::*;
4525 match ty {
4526 SmallInt => "smallint",
4527 Integer => "integer",
4528 BigInt | ForeignKey => "bigint",
4529 Real => "real",
4530 Double => "double precision",
4531 Boolean => "boolean",
4532 Text => "text",
4533 Date => "date",
4534 Time => "time",
4535 // sea-query's Postgres builder emits `timestamp with time zone`
4536 // for the equivalent column type; both spellings are accepted
4537 // by Postgres, but mirroring the builder keeps the surface
4538 // consistent if a test ever round-trips DDL.
4539 Timestamptz => "timestamp with time zone",
4540 Uuid => "uuid",
4541 Json => "jsonb",
4542 Inet => "inet",
4543 Cidr => "cidr",
4544 MacAddr => "macaddr",
4545 // gaps2 #70: text-backed Postgres types. `bit varying` mirrors
4546 // what sea-query's builder emits for the CREATE TABLE path.
4547 Xml => "xml",
4548 Ltree => "ltree",
4549 Bit => "bit varying",
4550 FullText => "tsvector",
4551 Bytes => "bytea",
4552 // BUG-10: NUMERIC(19, 4) — same dimensions as the CREATE TABLE
4553 // build path. Used by the `ALTER COLUMN ... TYPE ...` render
4554 // when the safe-cast diff allows transitioning to/from
4555 // Decimal.
4556 Decimal => "numeric(19, 4)",
4557 // Arrays render as `<inner>[]` in Postgres. The migration
4558 // engine doesn't model nested element types deeply enough to
4559 // emit a precise inner type here at v1; fall back to `text[]`
4560 // and rely on the column-def renderer for the real shape when
4561 // recreating the column.
4562 Array(_) => "text[]",
4563 }
4564}
4565
4566/// Per-model column diff. Same-name columns whose type or nullable
4567/// flag changed return `UnsafeAlter` (no `AlterColumn` until M8 v1.1
4568/// covers the table-recreation dance for SQLite plus native ALTER for
4569/// Postgres). New-named columns emit `AddColumn`; missing-name columns
4570/// emit `DropColumn`. The ordering is: drops first, then adds, so a
4571/// rename-as-drop+add doesn't violate a uniqueness constraint mid-
4572/// migration on a single-row table.
4573fn diff_columns(
4574 model: &str,
4575 previous: &ModelMeta,
4576 current: &ModelMeta,
4577) -> Result<Vec<Operation>, MigrateError> {
4578 use std::collections::BTreeMap;
4579
4580 let prev_cols: BTreeMap<&str, &Column> = previous
4581 .fields
4582 .iter()
4583 .map(|c| (c.name.as_str(), c))
4584 .collect();
4585 let curr_cols: BTreeMap<&str, &Column> = current
4586 .fields
4587 .iter()
4588 .map(|c| (c.name.as_str(), c))
4589 .collect();
4590
4591 // Walk the intersection by name. Two questions per shared column:
4592 // - did the type change? If so, is the change in the safe-cast
4593 // whitelist (e.g. BigInt -> Text, SmallInt -> Integer)? Safe
4594 // casts emit AlterColumn; unsafe ones still UnsafeAlter so the
4595 // user is forced to write the data-preserving migration by
4596 // hand.
4597 // - did the nullable flag flip? AlterColumn either way.
4598 // Primary-key changes still UnsafeAlter (a PK rebuild is its own
4599 // dance and isn't shipped yet).
4600 let mut alter_columns: Vec<&str> = Vec::new();
4601 // Columns whose `#[umbral(help = "...")]` changed (gaps3 #43).
4602 let mut comment_columns: Vec<&str> = Vec::new();
4603 for (name, prev_col) in &prev_cols {
4604 if let Some(curr_col) = curr_cols.get(name) {
4605 if prev_col.primary_key != curr_col.primary_key {
4606 return Err(MigrateError::UnsafeAlter {
4607 model: model.to_string(),
4608 column: (*name).to_string(),
4609 reason: "primary-key flips need a manual data-preserving migration".to_string(),
4610 });
4611 }
4612 let type_changed = prev_col.ty != curr_col.ty;
4613 if type_changed && !is_safe_cast(prev_col.ty, curr_col.ty) {
4614 return Err(MigrateError::UnsafeAlter {
4615 model: model.to_string(),
4616 column: (*name).to_string(),
4617 reason: format!(
4618 "type change {prev_ty:?} -> {curr_ty:?} is not in the safe-cast whitelist — write a data-preserving migration by hand",
4619 prev_ty = prev_col.ty,
4620 curr_ty = curr_col.ty,
4621 ),
4622 });
4623 }
4624 if prev_col.nullable && !curr_col.nullable && curr_col.default.is_empty() {
4625 return Err(MigrateError::UnsafeAlter {
4626 model: model.to_string(),
4627 column: (*name).to_string(),
4628 reason: "nullable → NOT NULL requires a default/backfill before tightening; otherwise existing NULL rows abort the migration".to_string(),
4629 });
4630 }
4631 if !prev_col.unique && curr_col.unique {
4632 return Err(MigrateError::UnsafeAlter {
4633 model: model.to_string(),
4634 column: (*name).to_string(),
4635 reason: "adding UNIQUE to an existing column requires a duplicate pre-check/backfill migration; otherwise existing duplicate values abort the migration".to_string(),
4636 });
4637 }
4638 // gaps3 #43: `help` DOES have a DB effect now — it renders as a
4639 // Postgres `COMMENT ON COLUMN` — but it is not a column rewrite.
4640 // It gets its own cheap op rather than dragging the column through
4641 // an `AlterColumn` (a full table-recreation dance on SQLite).
4642 if prev_col.help != curr_col.help {
4643 comment_columns.push(*name);
4644 }
4645
4646 // Any schema-meaningful field change triggers AlterColumn.
4647 // UI-only flags (`noform`, `noedit`, `max_length`,
4648 // `is_string_repr`, `is_multichoice`) are intentionally
4649 // excluded — they affect admin / OpenAPI rendering but
4650 // not the database schema, so emitting an ALTER would do
4651 // no DB work. The single-column `index` flag is ALSO excluded
4652 // here: an index add/remove is not a column rewrite. Folding it
4653 // into `AlterColumn` created no index on Postgres (its native
4654 // ALTER handles TYPE/nullable/UNIQUE/DEFAULT/FK/CHECK but not
4655 // indexes) and forced a full table-recreation dance on SQLite.
4656 // `diff_indexes` now emits a proper `AddIndex`/`DropIndex` for it,
4657 // which is correct and cheap on both backends.
4658 if type_changed
4659 || prev_col.nullable != curr_col.nullable
4660 || prev_col.fk_target != curr_col.fk_target
4661 || prev_col.unique != curr_col.unique
4662 || prev_col.default != curr_col.default
4663 || prev_col.choices != curr_col.choices
4664 || prev_col.choice_labels != curr_col.choice_labels
4665 || prev_col.on_delete != curr_col.on_delete
4666 || prev_col.on_update != curr_col.on_update
4667 {
4668 alter_columns.push(*name);
4669 }
4670 }
4671 }
4672
4673 let mut ops: Vec<Operation> = Vec::new();
4674
4675 // AlterColumn ops first, in name order. One AlterColumn per
4676 // changed column; each carries the full new schema so the render
4677 // can rebuild without further context. Multiple nullable flips on
4678 // one table generate multiple AlterColumns; the apply loop runs
4679 // them sequentially (each is a table-recreation, so back-to-back
4680 // alters drop and recreate twice; the cost is acceptable while
4681 // M5.1 ships the simple case).
4682 //
4683 // audit_2 H21: the SQLite recreation dance rebuilds the table by
4684 // `INSERT INTO tmp (new_columns) SELECT new_columns FROM <old>`, so
4685 // every name in `new_columns` MUST exist in the old table at alter
4686 // time. Using `current.fields` here broke any diff that combined an
4687 // alter with an add or drop on the same table: a newly-ADDED column
4688 // is in `current` but not the old table (the SELECT hits "no such
4689 // column"), and a DROPPED column is absent from `current` so the
4690 // rebuild removed it early — the subsequent `DropColumn` op then
4691 // failed on the already-gone column. Instead, shape `new_columns`
4692 // like the PREVIOUS table (exactly the old table's columns), but
4693 // apply the CURRENT definition to every column that survives so the
4694 // type/nullable/default change still lands. A to-be-dropped column
4695 // keeps its old definition and rides through the rebuild; its
4696 // `DropColumn` op (emitted below, so it runs after) removes it. A
4697 // to-be-added column is intentionally absent; its `AddColumn` op
4698 // (also below) adds it after. Ordering alter → drop → add is what
4699 // the existing op emission already does.
4700 let new_columns: Vec<Column> = previous
4701 .fields
4702 .iter()
4703 .map(|prev_col| {
4704 curr_cols
4705 .get(prev_col.name.as_str())
4706 .map(|c| (*c).clone())
4707 .unwrap_or_else(|| prev_col.clone())
4708 })
4709 .collect();
4710 let prev_columns_snapshot: Vec<Column> = previous.fields.clone();
4711 for name in alter_columns {
4712 ops.push(Operation::AlterColumn {
4713 table: current.table.clone(),
4714 column: name.to_string(),
4715 new_columns: new_columns.clone(),
4716 prev_columns: Some(prev_columns_snapshot.clone()),
4717 // audit_2 core-migrate #10: carry the table-level constraints so the
4718 // SQLite recreation dance re-creates them instead of dropping them.
4719 unique_together: current.unique_together.clone(),
4720 indexes: current.indexes.clone(),
4721 });
4722 }
4723
4724 // gaps3 #43: comment ops, in name order. After any `AlterColumn` on the same
4725 // column, because SQLite's alter recreates the table and Postgres's rewrites
4726 // the column — a comment set first would be describing a column that is
4727 // about to be replaced.
4728 for name in comment_columns {
4729 ops.push(Operation::SetColumnComment {
4730 table: current.table.clone(),
4731 column: name.to_string(),
4732 comment: curr_cols
4733 .get(name)
4734 .map(|c| c.help.clone())
4735 .unwrap_or_default(),
4736 });
4737 }
4738
4739 // Collect the dropped + added column names. We need both lists in
4740 // memory so the rename heuristic can pair them.
4741 let mut dropped: Vec<&Column> = Vec::new();
4742 let mut added: Vec<&Column> = Vec::new();
4743 for (name, prev_col) in &prev_cols {
4744 if !curr_cols.contains_key(name) {
4745 dropped.push(prev_col);
4746 }
4747 }
4748 for col in ¤t.fields {
4749 if !prev_cols.contains_key(col.name.as_str()) {
4750 added.push(col);
4751 }
4752 }
4753
4754 // Gap 88 — column rename detection. When the same diff yields
4755 // exactly one drop and one add whose column shapes (sans name)
4756 // match bit-for-bit, the most likely interpretation is a rename
4757 // rather than a coincidental drop+add of two unrelated columns.
4758 // Emit RenameColumn instead and warn the user so they can
4759 // verify. Anything more ambiguous (multiple drops or adds, or
4760 // mismatched shapes) falls back to the drop+add path so the
4761 // rename is never inferred against the user's actual intent.
4762 //
4763 // The heuristic deliberately stays conservative: some tools ask
4764 // interactively in this case; we don't have
4765 // a prompt at v1, so the conservative auto-pair is the safest
4766 // shape. Users can always override by writing the
4767 // `RenameColumn` op into the migration file by hand.
4768 let mut paired_drop: Option<&str> = None;
4769 let mut paired_add: Option<&str> = None;
4770 if dropped.len() == 1 && added.len() == 1 {
4771 let d = dropped[0];
4772 let a = added[0];
4773 if column_shape_matches(d, a) {
4774 eprintln!(
4775 "umbral makemigrations: column rename detected on `{}`: \
4776 `{}` → `{}` — verify this is a rename and not a coincidental \
4777 shape match; edit the migration file if it's wrong",
4778 current.table, d.name, a.name,
4779 );
4780 ops.push(Operation::RenameColumn {
4781 table: current.table.clone(),
4782 from: d.name.clone(),
4783 to: a.name.clone(),
4784 column: Some(a.clone()),
4785 });
4786 paired_drop = Some(d.name.as_str());
4787 paired_add = Some(a.name.as_str());
4788 }
4789 }
4790
4791 // Drops first so a same-position add can reuse the column slot.
4792 for col in &dropped {
4793 if Some(col.name.as_str()) == paired_drop {
4794 continue;
4795 }
4796 ops.push(Operation::DropColumn {
4797 table: current.table.clone(),
4798 column: col.name.clone(),
4799 });
4800 }
4801
4802 // Then adds, in current declaration order so the schema retains
4803 // the user-written column order even after re-runs.
4804 for col in &added {
4805 if Some(col.name.as_str()) == paired_add {
4806 continue;
4807 }
4808 // Gap 97 — refuse to add a NOT NULL column without a default
4809 // (and without `auto_now_add` / `auto_now`, which fill the
4810 // column server-side at insert). SQLite + Postgres both
4811 // reject the ADD on a non-empty table; we surface the same
4812 // failure at diff time with actionable guidance so the user
4813 // doesn't ship a migration that bricks every deploy.
4814 if !col.nullable
4815 && col.default.is_empty()
4816 && !col.auto_now_add
4817 && !col.auto_now
4818 && !col.primary_key
4819 {
4820 return Err(MigrateError::UnsafeAlter {
4821 model: model.to_string(),
4822 column: col.name.clone(),
4823 reason: format!(
4824 "adding NOT NULL column `{}` without a default to existing \
4825 table `{}` would fail on every populated row. Pick one: \
4826 (a) make the field `Option<T>`, (b) add `#[umbral(default = \
4827 \"...\")]` so the migration backfills, or (c) add \
4828 `#[umbral(auto_now_add)]` for timestamp columns",
4829 col.name, current.table,
4830 ),
4831 });
4832 }
4833 ops.push(Operation::AddColumn {
4834 table: current.table.clone(),
4835 column: (*col).clone(),
4836 });
4837 }
4838
4839 Ok(ops)
4840}
4841
4842/// Gap 88 helper: compare two column snapshots for shape identity (every
4843/// schema-meaningful attribute except `name`). Used by the rename-
4844/// detection heuristic — bit-identical attrs are the signal that a
4845/// dropped column matches an added column and the diff is actually a
4846/// rename. Excludes UI-only flags (`noform`, `noedit`, `max_length`,
4847/// `is_string_repr`, `help`, `example`, `slug_from`) for the same
4848/// reason the AlterColumn diff excludes them: they have no DB effect.
4849fn column_shape_matches(a: &Column, b: &Column) -> bool {
4850 a.ty == b.ty
4851 && a.primary_key == b.primary_key
4852 && a.nullable == b.nullable
4853 && a.fk_target == b.fk_target
4854 && a.choices == b.choices
4855 && a.choice_labels == b.choice_labels
4856 && a.default == b.default
4857 && a.is_multichoice == b.is_multichoice
4858 && a.unique == b.unique
4859 && a.on_delete == b.on_delete
4860 && a.on_update == b.on_update
4861 && a.index == b.index
4862 && a.auto_now_add == b.auto_now_add
4863 && a.auto_now == b.auto_now
4864 && a.min == b.min
4865 && a.max == b.max
4866 && a.text_format == b.text_format
4867}
4868
4869/// Pick the suffix used in a migration filename. Single-op migrations
4870/// get a descriptive suffix; multi-op migrations fall back to `auto`.
4871fn suffix_for(ops: &[Operation]) -> String {
4872 match ops {
4873 [Operation::CreateTable { table, .. }] => format!("create_{table}"),
4874 [Operation::DropTable { table }] => format!("drop_{table}"),
4875 // features #73. The drop+create pair is what an EDIT to a view's SQL looks
4876 // like — by far the commonest view migration — so name it for what the user
4877 // did, not for the two statements it lowers to.
4878 [Operation::CreateView { name, .. }] => format!("create_view_{name}"),
4879 [Operation::DropView { name, .. }] => format!("drop_view_{name}"),
4880 [
4881 Operation::DropView { name: dropped, .. },
4882 Operation::CreateView { name: created, .. },
4883 ] if dropped == created => format!("update_view_{created}"),
4884 [Operation::AddColumn { table, column }] => format!("add_{}_{}", table, column.name),
4885 [Operation::DropColumn { table, column }] => format!("drop_{table}_{column}"),
4886 [Operation::AlterColumn { table, column, .. }] => format!("alter_{table}_{column}"),
4887 [Operation::RenameTable { from, to }] => format!("rename_{from}_to_{to}"),
4888 [
4889 Operation::RenameColumn {
4890 table, from, to, ..
4891 },
4892 ] => format!("rename_{table}_{from}_to_{to}"),
4893 [Operation::SetColumnComment { table, column, .. }] => {
4894 format!("comment_{table}_{column}")
4895 }
4896 [Operation::RunSql { .. }] => "run_sql".to_string(),
4897 [Operation::AddIndex { table, columns, .. }] => {
4898 format!("add_index_{table}_{}", columns.join("_"))
4899 }
4900 [Operation::DropIndex { table, columns, .. }] => {
4901 format!("drop_index_{table}_{}", columns.join("_"))
4902 }
4903 _ => "auto".to_string(),
4904 }
4905}
4906
4907/// Create the tracking table if it isn't there already. The DDL is
4908/// dialect-neutral (TEXT + composite PK is valid SQL on both shipped
4909/// backends), but the executor type isn't — sqlx::query is generic
4910/// over the database, so each backend gets its own thin wrapper.
4911///
4912/// Kept inline because this table is a chicken-and-egg case: every
4913/// other migration needs the tracking row written, so the table
4914/// itself can't be a migration.
4915async fn ensure_tracking_table_sqlite(pool: &sqlx::SqlitePool) -> Result<(), MigrateError> {
4916 sqlx::query(
4917 "CREATE TABLE IF NOT EXISTS umbral_migrations (
4918 plugin TEXT NOT NULL,
4919 name TEXT NOT NULL,
4920 applied_at TEXT NOT NULL,
4921 snapshot_hash TEXT NOT NULL,
4922 PRIMARY KEY (plugin, name)
4923 )",
4924 )
4925 .execute(pool)
4926 .await?;
4927 Ok(())
4928}
4929
4930/// Postgres counterpart to [`ensure_tracking_table_sqlite`].
4931async fn ensure_tracking_table_postgres(pool: &sqlx::PgPool) -> Result<(), MigrateError> {
4932 sqlx::query(
4933 "CREATE TABLE IF NOT EXISTS umbral_migrations (
4934 plugin TEXT NOT NULL,
4935 name TEXT NOT NULL,
4936 applied_at TEXT NOT NULL,
4937 snapshot_hash TEXT NOT NULL,
4938 PRIMARY KEY (plugin, name)
4939 )",
4940 )
4941 .execute(pool)
4942 .await?;
4943 Ok(())
4944}
4945
4946/// Pull the set of `(plugin, name)` tuples already recorded in the
4947/// tracking table (SQLite).
4948async fn applied_names_sqlite(
4949 pool: &sqlx::SqlitePool,
4950) -> Result<std::collections::HashSet<(String, String)>, MigrateError> {
4951 let rows: Vec<(String, String)> = sqlx::query_as("SELECT plugin, name FROM umbral_migrations")
4952 .fetch_all(pool)
4953 .await?;
4954 Ok(rows.into_iter().collect())
4955}
4956
4957/// Postgres counterpart to [`applied_names_sqlite`].
4958async fn applied_names_postgres(
4959 pool: &sqlx::PgPool,
4960) -> Result<std::collections::HashSet<(String, String)>, MigrateError> {
4961 let rows: Vec<(String, String)> = sqlx::query_as("SELECT plugin, name FROM umbral_migrations")
4962 .fetch_all(pool)
4963 .await?;
4964 Ok(rows.into_iter().collect())
4965}
4966
4967/// Render one operation to a list of SQL statements via sea-query.
4968///
4969/// Dispatches on the ambient backend's [`crate::backend::active`]
4970/// name; SQLite and Postgres are the two shipped dialects. Most ops
4971/// produce one statement; `AlterColumn` produces either the SQLite
4972/// table-recreation dance (`CREATE _umbral_new` + `INSERT ... SELECT`
4973/// + `DROP` + `RENAME`) or a single native `ALTER TABLE ... ALTER
4974/// COLUMN ... SET/DROP NOT NULL` on Postgres.
4975///
4976/// The apply loop in `run_in` executes each statement in order inside
4977/// the same transaction.
4978///
4979/// `AddColumn` ignores the `primary_key` flag: neither SQLite nor
4980/// Postgres lets a primary key be added to an existing table without
4981/// a table-recreation step, and the autodetector won't route a
4982/// pk-flagged column through `AddColumn` anyway. A hand-edited
4983/// migration that sets the flag is taken to mean "the user is taking
4984/// responsibility".
4985fn render_operation(op: &Operation) -> Vec<String> {
4986 render_operation_for(op, crate::backend::active().name())
4987}
4988
4989fn should_emit_btree_index(col: &Column) -> bool {
4990 !col.primary_key
4991 && !col.unique
4992 && (col.index || matches!(col.ty, SqlType::ForeignKey) || col.name == "deleted_at")
4993}
4994
4995/// Render one operation against an explicit backend name. The
4996/// dispatching seam — the public [`render_operation`] is just
4997/// `render_operation_for(op, backend::active().name())`. Splitting
4998/// the two lets tests render Postgres DDL without installing the
4999/// process-wide ambient backend (the `OnceLock` can only be set once,
5000/// so `App::build` and tests would otherwise collide).
5001///
5002/// Panics on unknown backend names; only `"sqlite"` and `"postgres"`
5003/// are shipped in Phase 2.
5004pub fn render_operation_for(op: &Operation, backend_name: &str) -> Vec<String> {
5005 match backend_name {
5006 "sqlite" => render_operation_sqlite(op),
5007 "postgres" => render_operation_postgres(op),
5008 other => panic!(
5009 "umbral::migrate: no DDL renderer for backend `{other}`; \
5010 Phase 2 ships sqlite and postgres only"
5011 ),
5012 }
5013}
5014
5015/// SQLite-dialect rendering for one operation.
5016fn render_operation_sqlite(op: &Operation) -> Vec<String> {
5017 use sea_query::{Alias, SqliteQueryBuilder, Table};
5018
5019 match op {
5020 // features #73. A view is DDL the ORM cannot express as a sea-query
5021 // statement (there is no ViewCreateStatement), and the SELECT body is an
5022 // opaque string the user wrote. This is exception (1) in CLAUDE.md's raw-SQL
5023 // rule: schema DDL, owned by the migration engine.
5024 //
5025 // A MATERIALIZED view never reaches here — the `model.materialized_view`
5026 // system check fails the boot on SQLite. Rendering it as a plain view would
5027 // be the exact "SQLite branch that quietly diverges" the design principles
5028 // forbid: the same query, silently recomputed on every read.
5029 Operation::CreateView {
5030 name,
5031 sql,
5032 materialized,
5033 } => {
5034 debug_assert!(
5035 !materialized,
5036 "materialized views are Postgres-only; the model.materialized_view \
5037 system check should have failed the boot before reaching the renderer"
5038 );
5039 vec![format!("CREATE VIEW \"{name}\" AS {sql}")]
5040 }
5041 Operation::DropView { name, .. } => vec![format!("DROP VIEW IF EXISTS \"{name}\"")],
5042 Operation::CreateTable {
5043 table,
5044 columns,
5045 unique_together,
5046 indexes,
5047 } => {
5048 // sea-query's TableCreateStatement renders columns inline.
5049 let mut stmt = Table::create();
5050 stmt.table(Alias::new(table));
5051 for col in columns {
5052 let mut def = build_column_def_sqlite(col);
5053 stmt.col(&mut def);
5054 }
5055 let mut stmts = vec![stmt.build(SqliteQueryBuilder)];
5056 // `unique_together` groups render as follow-up
5057 // `CREATE UNIQUE INDEX` statements rather than inline table
5058 // constraints. A named unique index enforces the SAME
5059 // constraint, but — unlike an inline `UNIQUE(...)` (which becomes
5060 // an un-droppable auto-index on SQLite / an implicit constraint
5061 // on Postgres) — it can be added and DROPPED by name after the
5062 // table exists. That is what makes `unique_together`
5063 // autodetection reversible: the exact statement an `AddIndex`
5064 // would emit, so a later `DropIndex` names the same index.
5065 for group in unique_together {
5066 stmts.push(add_index_stmt(table, group, true));
5067 }
5068 // Single-column explicit indexes plus ORM-required helper
5069 // indexes follow the CREATE TABLE. FK columns need indexes
5070 // for reverse/select-related queries, and soft-delete
5071 // models read through `deleted_at IS NULL` by default.
5072 for col in columns {
5073 if should_emit_btree_index(col) {
5074 stmts.push(create_index_stmt(table, &col.name));
5075 }
5076 }
5077 // BUG-7: multi-column indexes follow as plain CREATE INDEX.
5078 for group in indexes {
5079 stmts.push(create_multi_index_stmt(table, group));
5080 }
5081 stmts
5082 }
5083 // gaps3 #60 — `IF EXISTS`, matching `DROP INDEX` and `DROP VIEW`, which have always
5084 // said it. A migration's job is to make a statement about the DESIRED end state:
5085 // "this table should not exist". Erroring because the table is already gone is a
5086 // complaint that the world already agrees with you.
5087 //
5088 // It is not masking a bug. Forward application has to be idempotent, or a ledger
5089 // that drifts even slightly — a table dropped out of band, a rename replayed, an
5090 // app whose migration history was rewritten — becomes permanently unmigratable,
5091 // and the only escape the operator can see is deleting the database.
5092 Operation::DropTable { table } => vec![
5093 Table::drop()
5094 .table(Alias::new(table))
5095 .if_exists()
5096 .build(SqliteQueryBuilder),
5097 ],
5098 Operation::AddColumn { table, column } => {
5099 // SQLite-specific limitation: `ALTER TABLE ADD COLUMN`
5100 // requires a CONSTANT default. `CURRENT_TIMESTAMP` is
5101 // non-constant ("Cannot add a column with non-constant
5102 // default"). So when we're adding a NOT NULL auto_now /
5103 // auto_now_add column on top of an existing table, we
5104 // emit a two-statement sequence:
5105 // 1. ADD COLUMN as NULLABLE (no default needed).
5106 // 2. UPDATE every existing row to `datetime('now')`.
5107 // The column ends up NULL-permitting at the DB level on
5108 // SQLite — but the Rust type stays `DateTime<Utc>` (not
5109 // Option), and every INSERT through the ORM supplies a
5110 // value via the macro-emitted auto_now arm. The DB-side
5111 // NOT NULL guarantee is lost only for direct-SQL writers,
5112 // which umbral already discourages (see CLAUDE.md "Plugins
5113 // use the ORM"). Postgres has no such restriction —
5114 // `DEFAULT now()` works there in ALTER, no backfill
5115 // statement needed (see the Postgres render below).
5116 let needs_backfill = (column.auto_now || column.auto_now_add)
5117 && !column.nullable
5118 && matches!(
5119 column.ty,
5120 SqlType::Timestamptz | SqlType::Date | SqlType::Time
5121 );
5122
5123 let mut stmts = if needs_backfill {
5124 let mut nullable_col = column.clone();
5125 nullable_col.nullable = true;
5126 let mut stmt = Table::alter();
5127 stmt.table(Alias::new(table));
5128 let mut def = build_column_def_sqlite(&nullable_col);
5129 stmt.add_column(&mut def);
5130 let add_sql = stmt.build(SqliteQueryBuilder);
5131
5132 // Manual UPDATE — sea-query's update builder is
5133 // overkill for a single SET col = datetime('now').
5134 let table_quoted = table.replace('"', "\"\"");
5135 let col_quoted = column.name.replace('"', "\"\"");
5136 let backfill_sql = format!(
5137 "UPDATE \"{table_quoted}\" SET \"{col_quoted}\" = datetime('now') \
5138 WHERE \"{col_quoted}\" IS NULL"
5139 );
5140 vec![add_sql, backfill_sql]
5141 } else {
5142 let mut stmt = Table::alter();
5143 stmt.table(Alias::new(table));
5144 let mut def = build_column_def_sqlite(column);
5145 stmt.add_column(&mut def);
5146 vec![stmt.build(SqliteQueryBuilder)]
5147 };
5148 if should_emit_btree_index(column) {
5149 stmts.push(create_index_stmt(table, &column.name));
5150 }
5151 stmts
5152 }
5153 Operation::DropColumn { table, column } => vec![
5154 Table::alter()
5155 .table(Alias::new(table))
5156 .drop_column(Alias::new(column))
5157 .build(SqliteQueryBuilder),
5158 ],
5159 // gaps3 #43: SQLite has no `COMMENT` statement — no column comments, no
5160 // table comments, nothing. Rendering zero statements is the whole
5161 // implementation. This is not a silent divergence of the kind the raw-SQL
5162 // rule warns about: the columns, types, constraints and rows are
5163 // identical on both backends. Only the `psql \d+` annotation is absent,
5164 // because SQLite has nowhere to put it.
5165 Operation::SetColumnComment { .. } => Vec::new(),
5166 Operation::AlterColumn {
5167 table,
5168 column,
5169 new_columns,
5170 prev_columns,
5171 unique_together,
5172 indexes,
5173 } => {
5174 // gaps3 #24: choices are Rust-enforced on SQLite (build_column_def_sqlite
5175 // emits no CHECK), so an alter that ONLY changes this column's
5176 // choices/labels is invisible to the schema. Skip the otherwise
5177 // byte-identical table-recreation dance. Postgres still swaps its
5178 // CHECK constraint via the Postgres renderer, so the op is still
5179 // emitted and recorded — only the SQLite render short-circuits.
5180 if prev_columns
5181 .as_ref()
5182 .is_some_and(|prev| alter_is_choices_only(column, prev, new_columns))
5183 {
5184 Vec::new()
5185 } else {
5186 render_alter_column_dance_sqlite(table, new_columns, unique_together, indexes)
5187 }
5188 }
5189 Operation::CreateM2MTable {
5190 junction_table,
5191 parent_table,
5192 parent_col,
5193 child_table,
5194 child_col,
5195 parent_ty,
5196 child_ty,
5197 } => {
5198 // Junction table for many-to-many: two FK columns + composite PK.
5199 // Column types follow the referenced PKs — `BigInt` → `INTEGER`
5200 // (SQLite affinity), `Text` → `TEXT`, `Uuid` → `TEXT` on SQLite
5201 // / `UUID` on Postgres. Raw DDL is the simplest expression of
5202 // the composite-PK + per-side cascade FK shape; sea-query's
5203 // builder can't express it cleanly in one call.
5204 vec![format!(
5205 r#"CREATE TABLE "{jt}" (
5206 "parent_id" {pty} NOT NULL REFERENCES "{pt}"("{pc}") ON DELETE CASCADE,
5207 "child_id" {cty} NOT NULL REFERENCES "{ct}"("{cc}") ON DELETE CASCADE,
5208 PRIMARY KEY ("parent_id", "child_id")
5209)"#,
5210 jt = junction_table.replace('"', "\"\""),
5211 pt = parent_table.replace('"', "\"\""),
5212 pc = parent_col.replace('"', "\"\""),
5213 ct = child_table.replace('"', "\"\""),
5214 cc = child_col.replace('"', "\"\""),
5215 pty = m2m_pk_sql_type_sqlite(*parent_ty),
5216 cty = m2m_pk_sql_type_sqlite(*child_ty),
5217 )]
5218 }
5219 // gaps3 #60 — idempotent, same reasoning as `DropTable`.
5220 Operation::DropM2MTable { junction_table } => vec![
5221 Table::drop()
5222 .table(Alias::new(junction_table))
5223 .if_exists()
5224 .build(SqliteQueryBuilder),
5225 ],
5226 Operation::RenameTable { from, to } => {
5227 use sea_query::{Alias, SqliteQueryBuilder, Table};
5228 vec![
5229 Table::rename()
5230 .table(Alias::new(from.as_str()), Alias::new(to.as_str()))
5231 .build(SqliteQueryBuilder),
5232 ]
5233 }
5234 Operation::RenameColumn {
5235 table, from, to, ..
5236 } => {
5237 // SQLite 3.25+ supports `ALTER TABLE ... RENAME COLUMN`
5238 // natively. Quote both sides to allow names that need
5239 // escaping; sea-query's column-rename builder isn't
5240 // exposed cleanly so we render the DDL string directly.
5241 let t = table.replace('"', "\"\"");
5242 let f = from.replace('"', "\"\"");
5243 let tn = to.replace('"', "\"\"");
5244 vec![format!(
5245 "ALTER TABLE \"{t}\" RENAME COLUMN \"{f}\" TO \"{tn}\""
5246 )]
5247 }
5248 // A data migration renders to its raw forward SQL verbatim —
5249 // the author owns portability across backends.
5250 Operation::RunSql { sql, .. } => vec![sql.clone()],
5251 // Composite index / UNIQUE constraint add + drop. The
5252 // `CREATE [UNIQUE] INDEX IF NOT EXISTS` / `DROP INDEX IF EXISTS`
5253 // forms are identical on SQLite and Postgres, so both render arms
5254 // share the same helpers.
5255 Operation::AddIndex {
5256 table,
5257 columns,
5258 unique,
5259 } => vec![add_index_stmt(table, columns, *unique)],
5260 Operation::DropIndex {
5261 table,
5262 columns,
5263 unique,
5264 } => vec![drop_index_stmt(&index_name(table, columns, *unique))],
5265 }
5266}
5267
5268/// Postgres-dialect rendering for one operation.
5269///
5270/// Postgres has native `ALTER COLUMN` so `AlterColumn` doesn't need
5271/// the SQLite table-recreation dance; it lowers to a single statement.
5272/// Integer primary keys use sea-query's `auto_increment()` flag, which
5273/// the Postgres query builder lowers to `BIGSERIAL` / `SERIAL` rather
5274/// than SQLite's `INTEGER PRIMARY KEY AUTOINCREMENT` quirk.
5275fn render_operation_postgres(op: &Operation) -> Vec<String> {
5276 use sea_query::{Alias, PostgresQueryBuilder, Table};
5277
5278 match op {
5279 // features #73 — see the SQLite renderer for why this is raw DDL.
5280 Operation::CreateView {
5281 name,
5282 sql,
5283 materialized,
5284 } => {
5285 let kind = if *materialized {
5286 "MATERIALIZED VIEW"
5287 } else {
5288 "VIEW"
5289 };
5290 vec![format!("CREATE {kind} \"{name}\" AS {sql}")]
5291 }
5292 Operation::DropView { name, materialized } => {
5293 let kind = if *materialized {
5294 "MATERIALIZED VIEW"
5295 } else {
5296 "VIEW"
5297 };
5298 // No CASCADE. A view another view depends on should fail loudly rather
5299 // than silently take its dependants down with it — the engine emits the
5300 // dependants' drops itself, in order, when it knows about them.
5301 vec![format!("DROP {kind} IF EXISTS \"{name}\"")]
5302 }
5303 Operation::CreateTable {
5304 table,
5305 columns,
5306 unique_together,
5307 indexes,
5308 } => {
5309 let mut stmt = Table::create();
5310 stmt.table(Alias::new(table));
5311 for col in columns {
5312 let mut def = build_column_def_postgres(col);
5313 stmt.col(&mut def);
5314 }
5315 let mut stmts = vec![stmt.build(PostgresQueryBuilder)];
5316 // gaps3 #35: a `#[umbral(case_insensitive)]` text column renders as
5317 // `citext`, which needs its extension. Emit the (idempotent) create
5318 // BEFORE the table so the citext type resolves. Requires a role with
5319 // CREATE privilege on the database — the operator pre-creates it once
5320 // if the runtime role is restricted.
5321 if columns
5322 .iter()
5323 .any(|c| c.case_insensitive && matches!(c.ty, crate::orm::SqlType::Text))
5324 {
5325 stmts.insert(0, "CREATE EXTENSION IF NOT EXISTS citext".to_string());
5326 }
5327 // `unique_together` as follow-up `CREATE UNIQUE INDEX` (not an
5328 // inline constraint) so it is droppable by name — see the SQLite
5329 // render arm for the full rationale.
5330 for group in unique_together {
5331 stmts.push(add_index_stmt(table, group, true));
5332 }
5333 for col in columns {
5334 if matches!(col.ty, crate::orm::SqlType::FullText) {
5335 // tsvector columns get an auto-GIN index (#33) — they're
5336 // useless for search without one, so the engine never
5337 // makes the caller hand-write it.
5338 stmts.push(create_gin_index_stmt(table, &col.name));
5339 } else if should_emit_btree_index(col) {
5340 stmts.push(create_index_stmt(table, &col.name));
5341 }
5342 }
5343 for group in indexes {
5344 stmts.push(create_multi_index_stmt(table, group));
5345 }
5346 // gaps3 #43: `#[umbral(help = "...")]` as a column comment. Postgres
5347 // has no inline column-comment syntax, so these follow the CREATE —
5348 // commenting a column that doesn't exist yet is an error.
5349 for col in columns {
5350 if !col.help.is_empty() {
5351 stmts.push(comment_on_column_stmt(table, &col.name, &col.help));
5352 }
5353 }
5354 stmts
5355 }
5356 // gaps3 #60 — see the SQLite renderer.
5357 Operation::DropTable { table } => vec![
5358 Table::drop()
5359 .table(Alias::new(table))
5360 .if_exists()
5361 .build(PostgresQueryBuilder),
5362 ],
5363 Operation::AddColumn { table, column } => {
5364 let mut stmt = Table::alter();
5365 stmt.table(Alias::new(table));
5366 let mut def = build_column_def_postgres(column);
5367 stmt.add_column(&mut def);
5368 let mut stmts = vec![stmt.build(PostgresQueryBuilder)];
5369 if matches!(column.ty, crate::orm::SqlType::FullText) {
5370 // Auto-GIN for a tsvector column added later (#33).
5371 stmts.push(create_gin_index_stmt(table, &column.name));
5372 } else if should_emit_btree_index(column) {
5373 stmts.push(create_index_stmt(table, &column.name));
5374 }
5375 if !column.help.is_empty() {
5376 stmts.push(comment_on_column_stmt(table, &column.name, &column.help));
5377 }
5378 stmts
5379 }
5380 Operation::SetColumnComment {
5381 table,
5382 column,
5383 comment,
5384 } => vec![comment_on_column_stmt(table, column, comment)],
5385 Operation::DropColumn { table, column } => vec![
5386 Table::alter()
5387 .table(Alias::new(table))
5388 .drop_column(Alias::new(column))
5389 .build(PostgresQueryBuilder),
5390 ],
5391 Operation::AlterColumn {
5392 table,
5393 column,
5394 new_columns,
5395 prev_columns,
5396 // Postgres alters in place — indexes/UNIQUE survive the ALTER, so
5397 // it doesn't re-create them (only the SQLite recreation dance does).
5398 unique_together: _,
5399 indexes: _,
5400 } => render_alter_column_postgres(table, column, new_columns, prev_columns.as_deref()),
5401 Operation::CreateM2MTable {
5402 junction_table,
5403 parent_table,
5404 parent_col,
5405 child_table,
5406 child_col,
5407 parent_ty,
5408 child_ty,
5409 } => {
5410 vec![format!(
5411 r#"CREATE TABLE "{jt}" (
5412 "parent_id" {pty} NOT NULL REFERENCES "{pt}"("{pc}") ON DELETE CASCADE,
5413 "child_id" {cty} NOT NULL REFERENCES "{ct}"("{cc}") ON DELETE CASCADE,
5414 PRIMARY KEY ("parent_id", "child_id")
5415)"#,
5416 jt = junction_table.replace('"', "\"\""),
5417 pt = parent_table.replace('"', "\"\""),
5418 pc = parent_col.replace('"', "\"\""),
5419 ct = child_table.replace('"', "\"\""),
5420 cc = child_col.replace('"', "\"\""),
5421 pty = m2m_pk_sql_type_postgres(*parent_ty),
5422 cty = m2m_pk_sql_type_postgres(*child_ty),
5423 )]
5424 }
5425 // gaps3 #60 — idempotent, same reasoning as `DropTable`.
5426 Operation::DropM2MTable { junction_table } => vec![
5427 Table::drop()
5428 .table(Alias::new(junction_table))
5429 .if_exists()
5430 .build(PostgresQueryBuilder),
5431 ],
5432 Operation::RenameTable { from, to } => {
5433 // Postgres: ALTER TABLE "<from>" RENAME TO "<to>"
5434 // sea-query's Table::rename() emits the right form.
5435 use sea_query::{Alias, PostgresQueryBuilder, Table};
5436 vec![
5437 Table::rename()
5438 .table(Alias::new(from.as_str()), Alias::new(to.as_str()))
5439 .build(PostgresQueryBuilder),
5440 ]
5441 }
5442 Operation::RenameColumn {
5443 table, from, to, ..
5444 } => {
5445 let t = table.replace('"', "\"\"");
5446 let f = from.replace('"', "\"\"");
5447 let tn = to.replace('"', "\"\"");
5448 vec![format!(
5449 "ALTER TABLE \"{t}\" RENAME COLUMN \"{f}\" TO \"{tn}\""
5450 )]
5451 }
5452 // A data migration renders to its raw forward SQL verbatim —
5453 // the author owns portability across backends.
5454 Operation::RunSql { sql, .. } => vec![sql.clone()],
5455 // Same `CREATE [UNIQUE] INDEX IF NOT EXISTS` / `DROP INDEX IF EXISTS`
5456 // as SQLite — Postgres accepts the identical form.
5457 Operation::AddIndex {
5458 table,
5459 columns,
5460 unique,
5461 } => vec![add_index_stmt(table, columns, *unique)],
5462 Operation::DropIndex {
5463 table,
5464 columns,
5465 unique,
5466 } => vec![drop_index_stmt(&index_name(table, columns, *unique))],
5467 }
5468}
5469
5470/// The SQLite table-recreation dance for `AlterColumn`. SQLite has no
5471/// in-place `ALTER COLUMN`, so the only safe way to flip a column's
5472/// nullable flag is to rebuild the table:
5473///
5474/// 1. `CREATE TABLE _umbral_new_<table>` with the new schema.
5475/// 2. `INSERT ... SELECT` to copy every row from the old table.
5476/// 3. `DROP TABLE <table>`.
5477/// 4. `ALTER TABLE _umbral_new_<table> RENAME TO <table>`.
5478///
5479/// Wrapped in a transaction by the caller, which — when the migration contains
5480/// an `AlterColumn` — brackets that transaction with `PRAGMA foreign_keys=OFF`
5481/// … `PRAGMA foreign_key_check` … `PRAGMA foreign_keys=ON` (SQLite's official
5482/// recipe), so step 3's `DROP TABLE` on a table with **inbound** FKs doesn't
5483/// trip `FOREIGN KEY constraint failed` (gaps3 #13). Indexes, triggers, and FK
5484/// targets aren't preserved at M5.1 because umbral-core's schema model
5485/// doesn't yet carry them; once it does, this routine picks them up
5486/// by rebuilding them at step 1.
5487///
5488/// Nullable `TRUE -> FALSE` fails at step 2 if any row holds NULL,
5489/// which is the correct data-integrity behaviour. Nullable
5490/// `FALSE -> TRUE` always succeeds.
5491/// gaps3 #24: true when an `AlterColumn` on `column` changes ONLY the closed-set
5492/// `choices` / `choice_labels` — every schema-relevant field (type, nullable,
5493/// unique, default, FK, on_delete/update, pk, length, multichoice) is identical.
5494/// On SQLite such a delta is a no-op: choices are enforced in Rust there, not by
5495/// a DB CHECK, so rebuilding the table would produce a byte-identical schema.
5496fn alter_is_choices_only(column: &str, prev: &[Column], new: &[Column]) -> bool {
5497 let (Some(p), Some(n)) = (
5498 prev.iter().find(|c| c.name == column),
5499 new.iter().find(|c| c.name == column),
5500 ) else {
5501 return false;
5502 };
5503 let choices_changed = p.choices != n.choices || p.choice_labels != n.choice_labels;
5504 let schema_same = p.ty == n.ty
5505 && p.nullable == n.nullable
5506 && p.unique == n.unique
5507 && p.default == n.default
5508 && p.fk_target == n.fk_target
5509 && p.on_delete == n.on_delete
5510 && p.on_update == n.on_update
5511 && p.primary_key == n.primary_key
5512 && p.max_length == n.max_length
5513 && p.is_multichoice == n.is_multichoice;
5514 choices_changed && schema_same
5515}
5516
5517fn render_alter_column_dance_sqlite(
5518 table: &str,
5519 new_columns: &[Column],
5520 unique_together: &[Vec<String>],
5521 indexes: &[Vec<String>],
5522) -> Vec<String> {
5523 use sea_query::{Alias, SqliteQueryBuilder, Table};
5524
5525 let tmp = format!("_umbral_new_{table}");
5526
5527 // Step 1 — CREATE TABLE _umbral_new_<table>.
5528 let mut create = Table::create();
5529 create.table(Alias::new(&tmp));
5530 for col in new_columns {
5531 let mut def = build_column_def_sqlite(col);
5532 create.col(&mut def);
5533 }
5534
5535 // Step 2 — INSERT ... SELECT. The INSERT target list is the plain column
5536 // names; each is double-quoted so SQLite identifier rules don't bite on
5537 // reserved words. The SELECT side backfills any NOT-NULL-with-default column
5538 // via `COALESCE(col, <default>)` (audit_2 core-migrate #5) — a
5539 // nullable→NOT NULL tightening whose existing rows hold NULL would otherwise
5540 // copy NULL into the new NOT NULL column and abort the rebuild. COALESCE is
5541 // a harmless no-op for a column that never held NULLs.
5542 let insert_cols = new_columns
5543 .iter()
5544 .map(|c| format!("\"{}\"", c.name.replace('"', "\"\"")))
5545 .collect::<Vec<_>>()
5546 .join(", ");
5547 let select_exprs = new_columns
5548 .iter()
5549 .map(|c| {
5550 let name = format!("\"{}\"", c.name.replace('"', "\"\""));
5551 if !c.nullable && !c.default.is_empty() {
5552 format!("COALESCE({name}, {})", default_sql_literal(c, false))
5553 } else {
5554 name
5555 }
5556 })
5557 .collect::<Vec<_>>()
5558 .join(", ");
5559 let insert_sql =
5560 format!("INSERT INTO \"{tmp}\" ({insert_cols}) SELECT {select_exprs} FROM \"{table}\"");
5561
5562 // Step 3 — DROP TABLE <table>.
5563 let drop_sql = Table::drop()
5564 .table(Alias::new(table))
5565 .build(SqliteQueryBuilder);
5566
5567 // Step 4 — ALTER TABLE _umbral_new_<table> RENAME TO <table>.
5568 let rename_sql = Table::rename()
5569 .table(Alias::new(&tmp), Alias::new(table))
5570 .build(SqliteQueryBuilder);
5571
5572 let mut stmts = vec![
5573 create.build(SqliteQueryBuilder),
5574 insert_sql,
5575 drop_sql,
5576 rename_sql,
5577 ];
5578 // Step 5 — audit_2 core-migrate #10: re-create the secondary indexes and
5579 // composite UNIQUE constraints the dropped table carried, or the rebuild
5580 // silently drops them (duplicates become insertable — integrity loss).
5581 // Single-column / FK / soft-delete indexes are derived from the columns
5582 // (same rule as CreateTable); `unique_together` re-emits as a named
5583 // `CREATE UNIQUE INDEX` and composite `indexes` as plain `CREATE INDEX`.
5584 // All are `IF NOT EXISTS`, so the step is idempotent.
5585 for col in new_columns {
5586 if should_emit_btree_index(col) {
5587 stmts.push(create_index_stmt(table, &col.name));
5588 }
5589 }
5590 for group in unique_together {
5591 stmts.push(add_index_stmt(table, group, true));
5592 }
5593 for group in indexes {
5594 stmts.push(create_multi_index_stmt(table, group));
5595 }
5596 stmts
5597}
5598
5599/// Native Postgres `AlterColumn`. Postgres supports
5600/// `ALTER TABLE x ALTER COLUMN y SET NOT NULL` and
5601/// `ALTER TABLE x ALTER COLUMN y DROP NOT NULL` in place, so the
5602/// SQLite table-recreation dance isn't needed. Lowers to a single
5603/// statement.
5604///
5605/// `SET NOT NULL` fails at the server if any row holds NULL on `y`,
5606/// matching SQLite's INSERT-time failure on the dance — the
5607/// data-integrity contract is identical between backends.
5608///
5609/// `column` is the field name that triggered the flip; `new_columns`
5610/// is the post-change schema (carried for parity with the SQLite
5611/// dance, though Postgres only needs the one column).
5612fn render_alter_column_postgres(
5613 table: &str,
5614 column: &str,
5615 new_columns: &[Column],
5616 prev_columns: Option<&[Column]>,
5617) -> Vec<String> {
5618 let new = new_columns.iter().find(|c| c.name == column).expect(
5619 "umbral::migrate: AlterColumn op references a column missing from new_columns; \
5620 this is a bug in `diff_columns`",
5621 );
5622 let prev = prev_columns.and_then(|cols| cols.iter().find(|c| c.name == column));
5623
5624 let q_table = quote_pg_ident(table);
5625 let q_column = quote_pg_ident(column);
5626
5627 let mut stmts: Vec<String> = Vec::new();
5628
5629 // TYPE change: only when we have a previous snapshot AND it differs
5630 // AND the change is in the safe-cast whitelist (diff_columns has
5631 // already gated unsafe ones). Emitted before nullable so a NOT
5632 // NULL flip against the just-cast column reads the new type.
5633 if let Some(prev_col) = prev {
5634 if prev_col.ty != new.ty && is_safe_cast(prev_col.ty, new.ty) {
5635 let new_ty_sql = postgres_type_name(new.ty);
5636 stmts.push(format!(
5637 "ALTER TABLE {q_table} ALTER COLUMN {q_column} TYPE {new_ty_sql} USING {q_column}::{new_ty_sql}"
5638 ));
5639 }
5640 }
5641
5642 // NULL-flag change: skipped when prev is None (legacy migrations
5643 // with no snapshot — preserve the old "emit unconditionally" path
5644 // because it's idempotent on Postgres). With a snapshot, only emit
5645 // when the flag actually flipped.
5646 let nullable_changed = match prev {
5647 Some(prev_col) => prev_col.nullable != new.nullable,
5648 None => true,
5649 };
5650 if nullable_changed {
5651 // audit_2 core-migrate #5: backfill existing NULLs before tightening.
5652 // A nullable→NOT NULL flip whose column carries a default would abort on
5653 // any pre-existing NULL row (bare `SET NOT NULL` doesn't backfill, and
5654 // `SET DEFAULT` only affects future inserts). Emit the backfill UPDATE
5655 // first so the subsequent `SET NOT NULL` succeeds.
5656 if !new.nullable && !new.default.is_empty() {
5657 let lit = default_sql_literal(new, true);
5658 stmts.push(format!(
5659 "UPDATE {q_table} SET {q_column} = {lit} WHERE {q_column} IS NULL"
5660 ));
5661 }
5662 let clause = if new.nullable {
5663 "DROP NOT NULL"
5664 } else {
5665 "SET NOT NULL"
5666 };
5667 stmts.push(format!(
5668 "ALTER TABLE {q_table} ALTER COLUMN {q_column} {clause}"
5669 ));
5670 }
5671
5672 // From here down — all the gap #65 follow-up changes. Each branch
5673 // checks if `prev` exists (legacy migrations with no snapshot
5674 // skip these, matching the historical behaviour) and emits the
5675 // matching ALTER on real flips.
5676 if let Some(prev_col) = prev {
5677 // UNIQUE flag flip. Postgres autogen for column-level UNIQUE
5678 // at CREATE TABLE is `<table>_<col>_key`; we use the same
5679 // name when ADDing so a subsequent DROP finds it.
5680 if prev_col.unique != new.unique {
5681 let cname = format!("{table}_{column}_key");
5682 if new.unique {
5683 stmts.push(format!(
5684 "ALTER TABLE {q_table} ADD CONSTRAINT \"{cname}\" UNIQUE ({q_column})"
5685 ));
5686 } else {
5687 stmts.push(format!(
5688 "ALTER TABLE {q_table} DROP CONSTRAINT IF EXISTS \"{cname}\""
5689 ));
5690 }
5691 }
5692
5693 // DEFAULT change. Empty string in either snapshot means "no
5694 // default"; the canonical SET / DROP pair fully expresses
5695 // the transition.
5696 if prev_col.default != new.default {
5697 if new.default.is_empty() {
5698 stmts.push(format!(
5699 "ALTER TABLE {q_table} ALTER COLUMN {q_column} DROP DEFAULT"
5700 ));
5701 } else {
5702 let escaped = new.default.replace('\'', "''");
5703 stmts.push(format!(
5704 "ALTER TABLE {q_table} ALTER COLUMN {q_column} SET DEFAULT '{escaped}'"
5705 ));
5706 }
5707 }
5708
5709 // FK target / on_delete / on_update — these are all carried
5710 // on the same constraint, so any one of them flipping
5711 // requires a DROP + readd of the whole FK. Autogen name
5712 // convention `<table>_<col>_fkey` matches Postgres at CREATE
5713 // TABLE time. Only emitted when the new column is still a
5714 // FK; if the column stopped being a FK (ty changed away
5715 // from ForeignKey), the type-change branch above handles
5716 // it indirectly via the column type rewrite.
5717 let fk_changed = prev_col.fk_target != new.fk_target
5718 || prev_col.on_delete != new.on_delete
5719 || prev_col.on_update != new.on_update;
5720 if fk_changed && matches!(new.ty, SqlType::ForeignKey) {
5721 let cname = format!("{table}_{column}_fkey");
5722 stmts.push(format!(
5723 "ALTER TABLE {q_table} DROP CONSTRAINT IF EXISTS \"{cname}\""
5724 ));
5725 // gaps2 #22: only re-add the physical constraint when the FK
5726 // still wants one. A `db_constraint = false` FK keeps the
5727 // DROP (so flipping the flag tears down any prior constraint)
5728 // but emits no ADD CONSTRAINT.
5729 if let Some(target) = &new.fk_target
5730 && new.db_constraint
5731 {
5732 let q_target = quote_pg_ident(target);
5733 // Resolve the referenced PK column from the target model's
5734 // registered meta instead of hardcoding `"id"`. String/Uuid
5735 // PKs (e.g. `Permission.codename`) are first-class post-lift;
5736 // the CreateTable path already resolves via `fk_target_pk`
5737 // (build_column_def_postgres), so the re-add must match or it
5738 // aborts the migration ("column id does not exist") / attaches
5739 // the constraint to the wrong column.
5740 let (pk_col, _pk_ty) = fk_target_pk(&target.replace('"', "\"\""));
5741 let q_pk = quote_pg_ident(&pk_col);
5742 let on_delete_clause = new
5743 .on_delete
5744 .sql_keyword()
5745 .map(|k| format!(" ON DELETE {k}"))
5746 .unwrap_or_default();
5747 let on_update_clause = new
5748 .on_update
5749 .sql_keyword()
5750 .map(|k| format!(" ON UPDATE {k}"))
5751 .unwrap_or_default();
5752 stmts.push(format!(
5753 "ALTER TABLE {q_table} ADD CONSTRAINT \"{cname}\" \
5754 FOREIGN KEY ({q_column}) REFERENCES {q_target}({q_pk})\
5755 {on_delete_clause}{on_update_clause}"
5756 ));
5757 }
5758 }
5759
5760 // CHECK constraint (single-valued choices) change. MultiChoice
5761 // uses CSV storage which can't be expressed as a column-level
5762 // IN constraint; the runtime sqlx Decode path is the guard.
5763 if prev_col.choices != new.choices && !new.is_multichoice {
5764 let cname = format!("{table}_{column}_check");
5765 stmts.push(format!(
5766 "ALTER TABLE {q_table} DROP CONSTRAINT IF EXISTS \"{cname}\""
5767 ));
5768 if !new.choices.is_empty() {
5769 let values_sql = new
5770 .choices
5771 .iter()
5772 .map(|v| format!("'{}'", v.replace('\'', "''")))
5773 .collect::<Vec<_>>()
5774 .join(", ");
5775 stmts.push(format!(
5776 "ALTER TABLE {q_table} ADD CONSTRAINT \"{cname}\" \
5777 CHECK ({q_column} IN ({values_sql}))"
5778 ));
5779 }
5780 }
5781 }
5782
5783 // Defensive: if we somehow produced no statements (shouldn't
5784 // happen — diff_columns gates on at least one schema-meaningful
5785 // flag changing), fall back to a single redundant SET NULL flip
5786 // to match the legacy contract. Tests cover both branches; this
5787 // is belt-and-braces.
5788 if stmts.is_empty() {
5789 let clause = if new.nullable {
5790 "DROP NOT NULL"
5791 } else {
5792 "SET NOT NULL"
5793 };
5794 stmts.push(format!(
5795 "ALTER TABLE {q_table} ALTER COLUMN {q_column} {clause}"
5796 ));
5797 }
5798
5799 stmts
5800}
5801
5802/// Quote a SQL identifier the Postgres way: wrap in double quotes,
5803/// escape inner double quotes by doubling them. Matches sea-query's
5804/// `PostgresQueryBuilder` output for identifiers so the rendered
5805/// statements look uniform.
5806fn quote_pg_ident(ident: &str) -> String {
5807 format!("\"{}\"", ident.replace('"', "\"\""))
5808}
5809
5810/// Build a SQLite `ColumnDef`. SQLite has one important quirk: its
5811/// ROWID-alias mechanic (which gives a primary-key column auto-
5812/// increment behaviour out of the box) only fires when the column's
5813/// type is the exact text `INTEGER` — case-insensitive but no other
5814/// variant. `BIGINT PRIMARY KEY`, even on a column the M3 derive
5815/// declared as `i64`, does NOT auto-increment, so an `INSERT INTO t
5816/// (other_col) VALUES (...)` without an explicit PK value fails the
5817/// NOT NULL constraint. Every umbral user with an `id: i64` model
5818/// would hit this without the override.
5819///
5820/// The fix: when a column is a primary key with an integer SqlType
5821/// (Integer or BigInt), force the rendered type to `Integer` and
5822/// attach `auto_increment()` so the generated DDL reads `"id" integer
5823/// NOT NULL PRIMARY KEY AUTOINCREMENT`. SQLite stores both `i32` and
5824/// `i64` as INTEGER affinity anyway, so the override is a no-op
5825/// semantically — the rows that round-trip through `sqlx::FromRow`
5826/// deserialize back into `i64` cleanly.
5827///
5828/// For `SqlType::Uuid` PKs: SQLite stores UUIDs as TEXT. No
5829/// `DEFAULT gen_random_uuid()` is emitted; the application must supply
5830/// the UUID at create time (or pass `Uuid::nil()` to trigger the
5831/// omit-on-insert sentinel that leaves the column to a future default).
5832///
5833/// For `SqlType::ForeignKey` columns: rendered as `BIGINT` with a
5834/// `REFERENCES "<target>"("id")` suffix appended via `.extra()`. The
5835/// target table name comes from `col.fk_target`.
5836/// Look up the FK target model's primary-key column name and SQL
5837/// type. Walks the registered ModelMeta set to find the model whose
5838/// table matches `fk_target_table`, then picks the first column
5839/// marked `primary_key = true`. Falls back to `("id", BigInteger)`
5840/// when the target isn't registered (cross-plugin lookup miss, or
5841/// the FK points outside the framework's model registry).
5842///
5843/// Used by both the SQLite and Postgres FK column-def builders so the
5844/// generated `<col> <type> REFERENCES <tbl>(<pk_col>)` matches the
5845/// target's actual PK shape — gap #60 made non-`id`, non-i64 PKs
5846/// (e.g. `Permission.codename: String`) a real case.
5847fn fk_target_pk(fk_target_table: &str) -> (String, sea_query::ColumnType) {
5848 use sea_query::ColumnType;
5849 let unesc = fk_target_table.replace("\"\"", "\"");
5850 // Non-panicking registry read — `registered_models()` itself
5851 // panics when called outside an `App::build()` context, but the
5852 // migration engine's unit tests construct snapshots by hand and
5853 // call into DDL emit without booting the framework. Fall through
5854 // to the historical "id"/BigInteger default in that case.
5855 let Some(metas) = REGISTRY.get() else {
5856 return ("id".to_string(), ColumnType::BigInteger);
5857 };
5858 for meta in metas.iter().map(|(_, m)| m) {
5859 if meta.table != unesc {
5860 continue;
5861 }
5862 if let Some(pk) = meta.fields.iter().find(|c| c.primary_key) {
5863 // Map the PK's SqlType to a sea-query ColumnType. We can't
5864 // route through `SqliteBackend::map_column` because that
5865 // wants a `Column` and applies max_length / choices
5866 // metadata which is irrelevant to a FK column. Hand-roll
5867 // the few cases the framework supports for PKs.
5868 let ct = match pk.ty {
5869 SqlType::BigInt | SqlType::Integer => ColumnType::BigInteger,
5870 SqlType::SmallInt => ColumnType::SmallInteger,
5871 SqlType::Text => ColumnType::Text,
5872 SqlType::Uuid => ColumnType::Uuid,
5873 // Other PK types fall back to BigInteger as the
5874 // historical default. The compile-time PrimaryKey
5875 // trait keeps this list closed in practice.
5876 _ => ColumnType::BigInteger,
5877 };
5878 return (pk.name.clone(), ct);
5879 }
5880 }
5881 ("id".to_string(), ColumnType::BigInteger)
5882}
5883
5884fn build_column_def_sqlite(col: &Column) -> sea_query::ColumnDef {
5885 use sea_query::{Alias, ColumnDef, ColumnType};
5886
5887 // ForeignKey gets a special path: column type + inline REFERENCES
5888 // clause both derived from the target model's PK column.
5889 if matches!(col.ty, SqlType::ForeignKey) {
5890 let fk_target = col
5891 .fk_target
5892 .as_deref()
5893 .unwrap_or("_unknown_")
5894 .replace('"', "\"\"");
5895 let (pk_col_name, pk_col_type) = fk_target_pk(&fk_target);
5896 let mut def = ColumnDef::new_with_type(Alias::new(&col.name), pk_col_type);
5897 if !col.nullable {
5898 def.not_null();
5899 }
5900 // BUG-15: `#[umbral(unique)]` on a FK column is the
5901 // OneToOne idiom — emit UNIQUE inline so the
5902 // referencing-row uniqueness is enforced at the DB.
5903 // The FK branch used to skip this because it returned
5904 // before the non-FK unique branch ran.
5905 if col.unique {
5906 def.unique_key();
5907 }
5908 // gaps2 #22: `#[umbral(db_constraint = false)]` keeps the logical
5909 // FK (column type derived from the target PK, above) but emits
5910 // NO physical `REFERENCES` clause. This is the only valid shape
5911 // for a cross-database FK. The default (`true`) emits the
5912 // constraint as before.
5913 if col.db_constraint {
5914 def.extra(format!(
5915 "REFERENCES \"{fk_target}\"(\"{pk_col_name}\"){}",
5916 fk_action_suffix(col),
5917 ));
5918 }
5919 return def;
5920 }
5921
5922 let is_int_pk = col.primary_key && matches!(col.ty, SqlType::Integer | SqlType::BigInt);
5923
5924 let column_type = if is_int_pk {
5925 ColumnType::Integer
5926 } else {
5927 crate::backend::SqliteBackend.map_column(col)
5928 };
5929
5930 let mut def = ColumnDef::new_with_type(Alias::new(&col.name), column_type);
5931 if !col.nullable {
5932 def.not_null();
5933 }
5934 if col.primary_key {
5935 def.primary_key();
5936 if is_int_pk {
5937 def.auto_increment();
5938 }
5939 }
5940 // gaps3 #35: `#[umbral(case_insensitive)]` on SQLite lifts to a column-level
5941 // `COLLATE NOCASE`, so `=`, `UNIQUE`, and `ORDER BY` fold case while storage
5942 // keeps the original casing. Emitted before UNIQUE so the auto-created
5943 // unique index inherits the column's NOCASE collation. NOCASE folds ASCII
5944 // A–Z only (a boot check warns about Unicode); Postgres uses `citext`.
5945 if col.case_insensitive && matches!(col.ty, SqlType::Text) {
5946 def.extra("COLLATE NOCASE".to_string());
5947 }
5948 // `#[umbral(unique)]` lifts to a column-level UNIQUE clause.
5949 // Skipped on PK columns (already unique) so the DDL stays tidy.
5950 if col.unique && !col.primary_key {
5951 def.unique_key();
5952 }
5953 // IMP-3: `#[umbral(min = N)]` / `#[umbral(max = N)]` lift to a
5954 // column-level CHECK clause. Both SQLite and Postgres accept the
5955 // same syntax. The pre-validation in `insert_json`/`update_json`
5956 // catches violations earlier with a friendlier error; the CHECK
5957 // is the DB-side safety net against direct-SQL writers.
5958 if let Some(check) = check_min_max_sql(col) {
5959 def.extra(check);
5960 }
5961 // User-declared `#[umbral(default = "...")]` lifts to a DDL DEFAULT
5962 // clause. Required when emitting `ALTER TABLE ADD COLUMN` for a
5963 // NOT NULL column against a non-empty table (SQLite rejects the
5964 // ADD otherwise); on CREATE TABLE it sets the column-level default
5965 // the database uses when an INSERT omits the value.
5966 //
5967 // SQLite stores booleans as INTEGER; the literal `'true'` /
5968 // `'false'` would land as a TEXT default that fails type checks
5969 // on reads. Translate Boolean defaults to `1` / `0` so the
5970 // stored representation matches what sqlx expects on hydration
5971 // (closes IMP-2 in bugs/tests/testBugs.md).
5972 if !col.default.is_empty() {
5973 if matches!(col.ty, SqlType::Boolean) {
5974 // Pass an integer to sea-query so the rendered SQL is
5975 // `DEFAULT 1` / `DEFAULT 0` instead of the quoted-string
5976 // `DEFAULT '1'` (which sqlx rejects as TEXT on read of
5977 // a BOOLEAN column).
5978 def.default(sqlite_bool_default(&col.default));
5979 } else {
5980 def.default(col.default.clone());
5981 }
5982 }
5983 // NOTE: auto_now / auto_now_add deliberately does NOT emit a
5984 // `DEFAULT CURRENT_TIMESTAMP` here. SQLite rejects non-constant
5985 // defaults in `ALTER TABLE ADD COLUMN` ("Cannot add a column
5986 // with non-constant default") and that's the path that matters
5987 // for evolving an existing table. The SQLite `AddColumn` render
5988 // path handles the auto_now backfill via a two-statement
5989 // sequence (nullable ADD + UPDATE backfill). On CREATE TABLE
5990 // we don't need a default at all because every INSERT goes
5991 // through the macro-emitted Rust path which always supplies the
5992 // value. See `Operation::AddColumn` render below.
5993 def
5994}
5995
5996/// Render a column's `#[umbral(default = ...)]` value as a raw SQL literal for
5997/// a hand-built statement (the NOT-NULL backfill, audit_2 core-migrate #5).
5998/// sea-query quotes literals itself in the column-def path, but the backfill
5999/// `UPDATE`/`COALESCE` is a raw `format!`, so it needs the literal here.
6000/// Numeric and boolean types render unquoted (`0`, `true`); everything else is
6001/// a single-quoted string with inner quotes doubled. `is_postgres` only affects
6002/// booleans (`true`/`false` on PG, `1`/`0` on SQLite, matching each backend's
6003/// boolean storage).
6004fn default_sql_literal(col: &Column, is_postgres: bool) -> String {
6005 use crate::orm::SqlType::*;
6006 match col.ty {
6007 Boolean => {
6008 let truthy = matches!(
6009 col.default.trim().to_ascii_lowercase().as_str(),
6010 "true" | "1" | "t" | "yes"
6011 );
6012 if is_postgres {
6013 if truthy { "true" } else { "false" }.to_string()
6014 } else if truthy {
6015 "1".to_string()
6016 } else {
6017 "0".to_string()
6018 }
6019 }
6020 SmallInt | Integer | BigInt | Real | Double | Decimal | ForeignKey => {
6021 // Numeric literal — validated at derive time; emit unquoted.
6022 col.default.clone()
6023 }
6024 _ => format!("'{}'", col.default.replace('\'', "''")),
6025 }
6026}
6027
6028/// Map a user-supplied boolean default string (`"true"` / `"false"`
6029/// / `"1"` / `"0"`, case-insensitive) to the SQLite integer literal
6030/// the column expects. Anything unrecognised falls through to `0`
6031/// — a developer-visible miss (default is wrong, not stored as
6032/// text) is friendlier than the runtime decode error the textual
6033/// path produces.
6034fn sqlite_bool_default(raw: &str) -> i32 {
6035 match raw.trim().to_ascii_lowercase().as_str() {
6036 "true" | "1" | "t" | "yes" => 1,
6037 _ => 0,
6038 }
6039}
6040
6041/// IMP-3: lower `#[umbral(min = N)]` / `#[umbral(max = N)]` to a
6042/// DDL CHECK clause. Returns `None` when the column declares
6043/// neither bound. The rendered SQL works on both SQLite and
6044/// Postgres (`"<col>" >= N`, `"<col>" <= N`, joined by `AND`).
6045/// Only applied to numeric columns — applying it to text would
6046/// compare strings lexicographically and surprise everyone.
6047fn check_min_max_sql(col: &Column) -> Option<String> {
6048 if col.min.is_none() && col.max.is_none() {
6049 return None;
6050 }
6051 if !matches!(
6052 col.ty,
6053 SqlType::SmallInt | SqlType::Integer | SqlType::BigInt | SqlType::Real | SqlType::Double
6054 ) {
6055 return None;
6056 }
6057 let name = col.name.replace('"', "\"\"");
6058 let mut parts = Vec::with_capacity(2);
6059 if let Some(n) = col.min {
6060 parts.push(format!("\"{name}\" >= {n}"));
6061 }
6062 if let Some(n) = col.max {
6063 parts.push(format!("\"{name}\" <= {n}"));
6064 }
6065 Some(format!("CHECK ({})", parts.join(" AND ")))
6066}
6067
6068/// Build a Postgres `ColumnDef`. Integer primary keys use the
6069/// standard `auto_increment()` flag — sea-query's `PostgresQueryBuilder`
6070/// lowers that to `BIGSERIAL` for `BigInt` and `SERIAL` for `Integer`.
6071/// No SQLite-style INTEGER-type override needed; Postgres has proper
6072/// `BIGSERIAL` / identity columns and respects the declared width.
6073///
6074/// For `SqlType::ForeignKey` columns: rendered as `BIGINT` with a
6075/// `REFERENCES "<target>"("id")` suffix. The target table name comes
6076/// from `col.fk_target`.
6077fn build_column_def_postgres(col: &Column) -> sea_query::ColumnDef {
6078 use sea_query::{Alias, ColumnDef};
6079
6080 // ForeignKey gets a special path: column type + inline REFERENCES
6081 // clause both derived from the target model's PK.
6082 if matches!(col.ty, SqlType::ForeignKey) {
6083 let fk_target = col
6084 .fk_target
6085 .as_deref()
6086 .unwrap_or("_unknown_")
6087 .replace('"', "\"\"");
6088 let (pk_col_name, pk_col_type) = fk_target_pk(&fk_target);
6089 // sea-query's ColumnType variants are dialect-agnostic; the
6090 // same value works for both SQLite and Postgres builders here.
6091 let mut def = ColumnDef::new_with_type(Alias::new(&col.name), pk_col_type);
6092 if !col.nullable {
6093 def.not_null();
6094 }
6095 // BUG-15: `#[umbral(unique)]` on a FK column is the
6096 // OneToOne idiom — emit UNIQUE inline so the
6097 // referencing-row uniqueness is enforced at the DB.
6098 // The FK branch used to skip this because it returned
6099 // before the non-FK unique branch ran.
6100 if col.unique {
6101 def.unique_key();
6102 }
6103 // gaps2 #22: skip the physical `REFERENCES` clause when the FK
6104 // opted out of the DB constraint (cross-database FK). The
6105 // logical column + `fk_target` stay intact.
6106 if col.db_constraint {
6107 def.extra(format!(
6108 "REFERENCES \"{fk_target}\"(\"{pk_col_name}\"){}",
6109 fk_action_suffix(col),
6110 ));
6111 }
6112 return def;
6113 }
6114
6115 let column_type = crate::backend::PostgresBackend.map_column(col);
6116
6117 let mut def = ColumnDef::new_with_type(Alias::new(&col.name), column_type);
6118 if !col.nullable {
6119 def.not_null();
6120 }
6121 if col.primary_key {
6122 def.primary_key();
6123 if matches!(
6124 col.ty,
6125 SqlType::Integer | SqlType::BigInt | SqlType::SmallInt
6126 ) {
6127 def.auto_increment();
6128 }
6129 }
6130 // `#[umbral(unique)]` lifts to a column-level UNIQUE clause on
6131 // Postgres too. Skipped for PK columns (already unique).
6132 if col.unique && !col.primary_key {
6133 def.unique_key();
6134 }
6135 // IMP-3: numeric bounds CHECK. Mirrors the SQLite branch.
6136 if let Some(check) = check_min_max_sql(col) {
6137 def.extra(check);
6138 }
6139 // Single-valued Choices: emit a CHECK constraint so a third-party
6140 // process writing directly to the DB can't insert a value the Rust
6141 // enum can't model. MultiChoice carries the same choices/labels
6142 // metadata but the stored value is a CSV — a single-value `IN (...)`
6143 // constraint would reject every legal CSV. Validating "every CSV
6144 // piece is a known variant" needs a regex with per-variant
6145 // escaping, which we leave to the sqlx Decode path at v1.
6146 if !col.choices.is_empty() && !col.is_multichoice {
6147 let col_name_escaped = col.name.replace('"', "\"\"");
6148 let values_sql = col
6149 .choices
6150 .iter()
6151 .map(|v| format!("'{}'", v.replace('\'', "''")))
6152 .collect::<Vec<_>>()
6153 .join(", ");
6154 def.extra(format!("CHECK (\"{col_name_escaped}\" IN ({values_sql}))"));
6155 }
6156 // User-declared `#[umbral(default = "...")]` lifts to a DDL DEFAULT
6157 // clause. Required for `ALTER TABLE ADD COLUMN` of a NOT NULL
6158 // column against a non-empty table — Postgres needs either a
6159 // default or a separate backfill.
6160 if !col.default.is_empty() {
6161 def.default(col.default.clone());
6162 } else if (col.auto_now || col.auto_now_add)
6163 && matches!(col.ty, SqlType::Timestamptz | SqlType::Date | SqlType::Time)
6164 {
6165 // Mirror of the SQLite branch above. Without a DEFAULT
6166 // Postgres rejects `ALTER TABLE ADD COLUMN ... NOT NULL`
6167 // on a populated table. `now()` evaluates per-row during
6168 // the backfill so every existing row gets a sane value;
6169 // future INSERTs override via the macro-emitted Rust path.
6170 def.default(sea_query::Expr::cust("now()"));
6171 }
6172 def
6173}
6174
6175#[cfg(test)]
6176mod tests {
6177 use super::*;
6178 use std::collections::HashSet;
6179
6180 // ---- gaps2 #100: squash_plan (pure squash-vs-originals decision) ----
6181
6182 /// A minimal migration file for `plugin`/`id`; `replaces` lists the ids it
6183 /// squashes (all within `plugin`). Empty `replaces` = an ordinary migration.
6184 fn mf(plugin: &str, id: &str, replaces: &[&str]) -> MigrationFile {
6185 MigrationFile {
6186 id: id.to_string(),
6187 plugin: plugin.to_string(),
6188 depends_on: Vec::new(),
6189 operations: Vec::new(),
6190 snapshot_after: Snapshot::default(),
6191 replaces: replaces
6192 .iter()
6193 .map(|m| MigrationRef {
6194 plugin: plugin.to_string(),
6195 migration: m.to_string(),
6196 })
6197 .collect(),
6198 }
6199 }
6200
6201 fn applied_set(plugin: &str, ids: &[&str]) -> HashSet<(String, String)> {
6202 ids.iter()
6203 .map(|id| (plugin.to_string(), id.to_string()))
6204 .collect()
6205 }
6206
6207 #[test]
6208 fn squash_plan_plain_migrations_apply_when_unrecorded_skip_when_applied() {
6209 let files = vec![mf("app", "0001", &[]), mf("app", "0002", &[])];
6210 let applied = applied_set("app", &["0001"]);
6211 let plan = squash_plan(&files, &applied).unwrap();
6212 assert_eq!(plan, vec![ApplyDecision::Skip, ApplyDecision::Apply]);
6213 }
6214
6215 #[test]
6216 fn squash_plan_fresh_db_applies_squash_and_shadows_its_originals() {
6217 // Both the squash and its originals are on disk (Django keeps both).
6218 // Nothing applied → run the squash once, skip every original.
6219 let files = vec![
6220 mf("app", "0001", &[]),
6221 mf("app", "0002", &[]),
6222 mf("app", "0001_squashed_0002", &["0001", "0002"]),
6223 ];
6224 let applied = applied_set("app", &[]);
6225 let plan = squash_plan(&files, &applied).unwrap();
6226 assert_eq!(
6227 plan,
6228 vec![
6229 ApplyDecision::Skip, // 0001 shadowed by the squash
6230 ApplyDecision::Skip, // 0002 shadowed by the squash
6231 ApplyDecision::Apply // the squash builds the whole schema
6232 ]
6233 );
6234 }
6235
6236 #[test]
6237 fn squash_plan_existing_db_with_full_history_record_only() {
6238 // Both originals already applied → the squash records itself without
6239 // running (its schema already exists), originals skipped.
6240 let files = vec![
6241 mf("app", "0001", &[]),
6242 mf("app", "0002", &[]),
6243 mf("app", "0001_squashed_0002", &["0001", "0002"]),
6244 ];
6245 let applied = applied_set("app", &["0001", "0002"]);
6246 let plan = squash_plan(&files, &applied).unwrap();
6247 assert_eq!(
6248 plan,
6249 vec![
6250 ApplyDecision::Skip,
6251 ApplyDecision::Skip,
6252 ApplyDecision::RecordOnly
6253 ]
6254 );
6255 }
6256
6257 #[test]
6258 fn squash_plan_already_recorded_squash_is_skipped() {
6259 // The squash ran on a previous migrate; it's in the tracking table.
6260 let files = vec![
6261 mf("app", "0001", &[]),
6262 mf("app", "0002", &[]),
6263 mf("app", "0001_squashed_0002", &["0001", "0002"]),
6264 ];
6265 let applied = applied_set("app", &["0001_squashed_0002"]);
6266 let plan = squash_plan(&files, &applied).unwrap();
6267 // originals never applied but shadowed by the (already-applied) squash.
6268 assert_eq!(
6269 plan,
6270 vec![
6271 ApplyDecision::Skip,
6272 ApplyDecision::Skip,
6273 ApplyDecision::Skip
6274 ]
6275 );
6276 }
6277
6278 #[test]
6279 fn squash_plan_partial_transition_falls_back_to_surviving_originals() {
6280 // 0001 applied, 0002 not; originals still on disk → ignore the squash,
6281 // finish 0002 individually.
6282 let files = vec![
6283 mf("app", "0001", &[]),
6284 mf("app", "0002", &[]),
6285 mf("app", "0001_squashed_0002", &["0001", "0002"]),
6286 ];
6287 let applied = applied_set("app", &["0001"]);
6288 let plan = squash_plan(&files, &applied).unwrap();
6289 assert_eq!(
6290 plan,
6291 vec![
6292 ApplyDecision::Skip, // 0001 already applied
6293 ApplyDecision::Apply, // 0002 finishes individually
6294 ApplyDecision::Skip // squash ignored during the transition
6295 ]
6296 );
6297 }
6298
6299 #[test]
6300 fn squash_plan_partial_transition_with_missing_original_errors() {
6301 // 0001 applied, 0002 not, and 0002's file is GONE → can't reconcile.
6302 let files = vec![
6303 mf("app", "0001", &[]),
6304 mf("app", "0001_squashed_0002", &["0001", "0002"]),
6305 ];
6306 let applied = applied_set("app", &["0001"]);
6307 let err = squash_plan(&files, &applied).unwrap_err();
6308 match err {
6309 MigrateError::SquashInconsistent {
6310 plugin,
6311 squash,
6312 missing,
6313 } => {
6314 assert_eq!(plugin, "app");
6315 assert_eq!(squash, "0001_squashed_0002");
6316 assert_eq!(missing, vec!["app/0002".to_string()]);
6317 }
6318 other => panic!("expected SquashInconsistent, got {other:?}"),
6319 }
6320 }
6321
6322 /// audit_2 core-migrate #7 — the advisory-lock key must be deterministic
6323 /// (every process computes the same key for the same target, so they
6324 /// mutually exclude) and distinct per discriminator (different aliases /
6325 /// schemas migrate concurrently). Pins the FNV constants so a refactor that
6326 /// changes the hash — silently breaking cross-process exclusion — fails.
6327 #[test]
6328 fn pg_migration_lock_key_is_deterministic_and_distinct() {
6329 // Deterministic: same input → same key, run to run, process to process.
6330 assert_eq!(
6331 pg_migration_lock_key("default"),
6332 pg_migration_lock_key("default"),
6333 );
6334 // Distinct: different aliases/schemas get different keys.
6335 assert_ne!(
6336 pg_migration_lock_key("default"),
6337 pg_migration_lock_key("replica"),
6338 );
6339 assert_ne!(
6340 pg_migration_lock_key("tenant_a"),
6341 pg_migration_lock_key("tenant_b"),
6342 );
6343 // Pin the exact value so the hash can't drift unnoticed (two binaries on
6344 // different umbral versions must still agree on the key).
6345 assert_eq!(
6346 pg_migration_lock_key("default"),
6347 {
6348 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
6349 for b in b"umbral_migrations\0"
6350 .iter()
6351 .copied()
6352 .chain(b"default".iter().copied())
6353 {
6354 hash ^= b as u64;
6355 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
6356 }
6357 hash as i64
6358 },
6359 "the lock-key hash changed — this breaks cross-process exclusion \
6360 between an old and a new migrator; bump deliberately if intended",
6361 );
6362 }
6363
6364 /// M8 — `plugin_order()` falls back to `registered_plugins()` when
6365 /// no topological order has been published. The fallback keeps the
6366 /// engine usable from low-level paths that drive `init_plugins`
6367 /// directly (the M5 / M6 tests that pre-date phase 1.5 of
6368 /// `App::build()`).
6369 ///
6370 /// Runs in the lib's unit-test binary, which is wholly separate
6371 /// from the integration test binaries and so owns its own copies
6372 /// of `REGISTRY` and `PLUGIN_ORDER`. This test seeds `REGISTRY` via
6373 /// `init_plugins`, never touches `init_plugin_order`, and pins the
6374 /// fallback to the sorted-by-name `registered_plugins()` output.
6375 /// As the only test that touches either OnceLock in this binary,
6376 /// it has them to itself.
6377 #[test]
6378 fn plugin_order_falls_back_to_registered_plugins_when_unpublished() {
6379 let mut per_plugin: std::collections::HashMap<String, Vec<ModelMeta>> =
6380 std::collections::HashMap::new();
6381 per_plugin.insert(
6382 "zeta".to_string(),
6383 vec![ModelMeta {
6384 view: None,
6385 materialized: false,
6386 name: "ZetaModel".to_string(),
6387 table: "zeta".to_string(),
6388 fields: Vec::new(),
6389 display: "ZetaModel".to_string(),
6390 icon: "database".to_string(),
6391 database: None,
6392 singleton: false,
6393 unique_together: Vec::new(),
6394 indexes: Vec::new(),
6395 ordering: Vec::new(),
6396 m2m_relations: Vec::new(),
6397 soft_delete: false,
6398 audited: false,
6399 app_label: "app".to_string(),
6400 }],
6401 );
6402 per_plugin.insert(
6403 "alpha".to_string(),
6404 vec![ModelMeta {
6405 view: None,
6406 materialized: false,
6407 name: "AlphaModel".to_string(),
6408 table: "alpha".to_string(),
6409 fields: Vec::new(),
6410 display: "AlphaModel".to_string(),
6411 icon: "database".to_string(),
6412 database: None,
6413 singleton: false,
6414 unique_together: Vec::new(),
6415 indexes: Vec::new(),
6416 ordering: Vec::new(),
6417 m2m_relations: Vec::new(),
6418 soft_delete: false,
6419 audited: false,
6420 app_label: "app".to_string(),
6421 }],
6422 );
6423 init_plugins(per_plugin);
6424
6425 // `init_plugin_order` was never called, so `plugin_order` must
6426 // return the sorted-by-name fallback.
6427 let order = plugin_order();
6428 assert_eq!(
6429 order,
6430 vec!["alpha".to_string(), "zeta".to_string()],
6431 "fallback should sort by name; got {order:?}",
6432 );
6433 assert_eq!(
6434 order,
6435 registered_plugins(),
6436 "fallback should exactly equal registered_plugins()",
6437 );
6438 }
6439
6440 /// Gap #65: `#[umbral(unique)]` lifts to a column-level UNIQUE in
6441 /// CREATE TABLE DDL on both backends. PK columns skip the clause
6442 /// because they're already unique by virtue of being the PK.
6443 #[test]
6444 fn unique_column_emits_unique_keyword_on_both_backends() {
6445 use sea_query::{Alias, PostgresQueryBuilder, SqliteQueryBuilder, Table};
6446
6447 let id = Column {
6448 name: "id".into(),
6449 ty: SqlType::BigInt,
6450 primary_key: true,
6451 nullable: false,
6452 fk_target: None,
6453 noform: false,
6454 privileged: false,
6455 private: false,
6456 secret: false,
6457 db_constraint: true,
6458 noedit: false,
6459 auto_user_add: false,
6460 auto_user: false,
6461 is_string_repr: false,
6462 max_length: 0,
6463 choices: vec![],
6464 choice_labels: vec![],
6465 default: String::new(),
6466 is_multichoice: false,
6467 // Set even though it's a PK so we can assert below that
6468 // the emit path drops the redundant clause.
6469 unique: true,
6470 on_delete: crate::orm::FkAction::NoAction,
6471 on_update: crate::orm::FkAction::NoAction,
6472 index: false,
6473 auto_now_add: false,
6474 auto_now: false,
6475 trim: false,
6476 lowercase: false,
6477 case_insensitive: false,
6478 help: String::new(),
6479 example: String::new(),
6480 widget: None,
6481 supported_backends: Vec::new(),
6482 min: None,
6483 max: None,
6484 text_format: ::core::option::Option::None,
6485 slug_from: ::core::option::Option::None,
6486 };
6487 let username = Column {
6488 name: "username".into(),
6489 ty: SqlType::Text,
6490 primary_key: false,
6491 nullable: false,
6492 fk_target: None,
6493 noform: false,
6494 privileged: false,
6495 private: false,
6496 secret: false,
6497 db_constraint: true,
6498 noedit: false,
6499 auto_user_add: false,
6500 auto_user: false,
6501 is_string_repr: false,
6502 max_length: 0,
6503 choices: vec![],
6504 choice_labels: vec![],
6505 default: String::new(),
6506 is_multichoice: false,
6507 unique: true,
6508 on_delete: crate::orm::FkAction::NoAction,
6509 on_update: crate::orm::FkAction::NoAction,
6510 index: false,
6511 auto_now_add: false,
6512 auto_now: false,
6513 trim: false,
6514 lowercase: false,
6515 case_insensitive: false,
6516 help: String::new(),
6517 example: String::new(),
6518 widget: None,
6519 supported_backends: Vec::new(),
6520 min: None,
6521 max: None,
6522 text_format: ::core::option::Option::None,
6523 slug_from: ::core::option::Option::None,
6524 };
6525 let email = Column {
6526 name: "email".into(),
6527 ty: SqlType::Text,
6528 primary_key: false,
6529 nullable: false,
6530 fk_target: None,
6531 noform: false,
6532 privileged: false,
6533 private: false,
6534 secret: false,
6535 db_constraint: true,
6536 noedit: false,
6537 auto_user_add: false,
6538 auto_user: false,
6539 is_string_repr: false,
6540 max_length: 0,
6541 choices: vec![],
6542 choice_labels: vec![],
6543 default: String::new(),
6544 is_multichoice: false,
6545 unique: false,
6546 on_delete: crate::orm::FkAction::NoAction,
6547 on_update: crate::orm::FkAction::NoAction,
6548 index: false,
6549 auto_now_add: false,
6550 auto_now: false,
6551 trim: false,
6552 lowercase: false,
6553 case_insensitive: false,
6554 help: String::new(),
6555 example: String::new(),
6556 widget: None,
6557 supported_backends: Vec::new(),
6558 min: None,
6559 max: None,
6560 text_format: ::core::option::Option::None,
6561 slug_from: ::core::option::Option::None,
6562 };
6563
6564 for backend in ["sqlite", "postgres"] {
6565 let mut stmt = Table::create();
6566 stmt.table(Alias::new("u"));
6567 for col in [&id, &username, &email] {
6568 let mut def = if backend == "sqlite" {
6569 build_column_def_sqlite(col)
6570 } else {
6571 build_column_def_postgres(col)
6572 };
6573 stmt.col(&mut def);
6574 }
6575 let sql = if backend == "sqlite" {
6576 stmt.to_string(SqliteQueryBuilder)
6577 } else {
6578 stmt.to_string(PostgresQueryBuilder)
6579 };
6580
6581 // UNIQUE on the explicitly-marked non-PK column.
6582 assert!(
6583 sql.contains("\"username\"") && sql.to_uppercase().contains("UNIQUE"),
6584 "{backend}: expected UNIQUE on username; got: {sql}",
6585 );
6586 // No UNIQUE on `email` (flag false).
6587 let email_clause = sql
6588 .split("\"email\"")
6589 .nth(1)
6590 .unwrap_or_default()
6591 .split(',')
6592 .next()
6593 .unwrap_or_default();
6594 assert!(
6595 !email_clause.to_uppercase().contains("UNIQUE"),
6596 "{backend}: email should not be UNIQUE; clause: {email_clause}",
6597 );
6598 // PK still PK; the redundant UNIQUE flag is dropped so we
6599 // don't double up the constraint.
6600 let id_clause = sql
6601 .split("\"id\"")
6602 .nth(1)
6603 .unwrap_or_default()
6604 .split(',')
6605 .next()
6606 .unwrap_or_default();
6607 assert!(
6608 id_clause.to_uppercase().contains("PRIMARY KEY"),
6609 "{backend}: id should still be PRIMARY KEY; clause: {id_clause}",
6610 );
6611 assert!(
6612 !id_clause.to_uppercase().contains("UNIQUE"),
6613 "{backend}: PK column should not also carry UNIQUE; clause: {id_clause}",
6614 );
6615 }
6616 }
6617
6618 /// Gap #68: `on_delete` / `on_update` lift to the `REFERENCES`
6619 /// tail in DDL. `NoAction` emits no clause (the SQL default);
6620 /// any other variant emits `ON DELETE <kw>` / `ON UPDATE <kw>`
6621 /// on both backends. The clause goes inside the same `extra(...)`
6622 /// string that already carries `REFERENCES "<target>"("id")` —
6623 /// the test asserts the full tail shape so a future refactor
6624 /// that splits the FK rendering won't silently regress.
6625 #[test]
6626 fn fk_action_lifts_to_references_clause_on_both_backends() {
6627 use sea_query::{Alias, PostgresQueryBuilder, SqliteQueryBuilder, Table};
6628
6629 // Need an FK target table; the DDL renderer looks up the
6630 // PK column type for `auth_user` via `fk_target_pk`.
6631 // Using "post" since it's already registered as a real
6632 // Model in the lib (resolves to BigInt id).
6633 let plain_fk = Column {
6634 name: "owner_id".into(),
6635 ty: SqlType::ForeignKey,
6636 primary_key: false,
6637 nullable: false,
6638 fk_target: Some("post".into()),
6639 noform: false,
6640 privileged: false,
6641 private: false,
6642 secret: false,
6643 db_constraint: true,
6644 noedit: false,
6645 auto_user_add: false,
6646 auto_user: false,
6647 is_string_repr: false,
6648 max_length: 0,
6649 choices: vec![],
6650 choice_labels: vec![],
6651 default: String::new(),
6652 is_multichoice: false,
6653 unique: false,
6654 on_delete: crate::orm::FkAction::NoAction,
6655 on_update: crate::orm::FkAction::NoAction,
6656 index: false,
6657 auto_now_add: false,
6658 auto_now: false,
6659 trim: false,
6660 lowercase: false,
6661 case_insensitive: false,
6662 help: String::new(),
6663 example: String::new(),
6664 widget: None,
6665 supported_backends: Vec::new(),
6666 min: None,
6667 max: None,
6668 text_format: ::core::option::Option::None,
6669 slug_from: ::core::option::Option::None,
6670 };
6671 let cascade_fk = Column {
6672 on_delete: crate::orm::FkAction::Cascade,
6673 on_update: crate::orm::FkAction::Cascade,
6674 index: false,
6675 auto_now_add: false,
6676 auto_now: false,
6677 trim: false,
6678 lowercase: false,
6679 case_insensitive: false,
6680 help: String::new(),
6681 example: String::new(),
6682 widget: None,
6683 supported_backends: Vec::new(),
6684 ..plain_fk.clone()
6685 };
6686 let restrict_fk = Column {
6687 on_delete: crate::orm::FkAction::Restrict,
6688 ..plain_fk.clone()
6689 };
6690 let set_null_fk = Column {
6691 nullable: true,
6692 on_delete: crate::orm::FkAction::SetNull,
6693 ..plain_fk.clone()
6694 };
6695
6696 for backend in ["sqlite", "postgres"] {
6697 let render_one = |col: &Column| -> String {
6698 let mut stmt = Table::create();
6699 stmt.table(Alias::new("t"));
6700 let mut def = if backend == "sqlite" {
6701 build_column_def_sqlite(col)
6702 } else {
6703 build_column_def_postgres(col)
6704 };
6705 stmt.col(&mut def);
6706 if backend == "sqlite" {
6707 stmt.to_string(SqliteQueryBuilder)
6708 } else {
6709 stmt.to_string(PostgresQueryBuilder)
6710 }
6711 };
6712
6713 // NoAction → REFERENCES with no tail clauses.
6714 let sql = render_one(&plain_fk);
6715 assert!(
6716 sql.contains("REFERENCES")
6717 && !sql.to_uppercase().contains("ON DELETE")
6718 && !sql.to_uppercase().contains("ON UPDATE"),
6719 "{backend}: NoAction should emit REFERENCES alone; got: {sql}",
6720 );
6721
6722 // Cascade on both ON DELETE and ON UPDATE.
6723 let sql = render_one(&cascade_fk);
6724 assert!(
6725 sql.to_uppercase().contains("ON DELETE CASCADE")
6726 && sql.to_uppercase().contains("ON UPDATE CASCADE"),
6727 "{backend}: Cascade should emit both clauses; got: {sql}",
6728 );
6729
6730 // Restrict on ON DELETE only; ON UPDATE is NoAction so
6731 // no clause appears.
6732 let sql = render_one(&restrict_fk);
6733 assert!(
6734 sql.to_uppercase().contains("ON DELETE RESTRICT"),
6735 "{backend}: Restrict missing; got: {sql}",
6736 );
6737 assert!(
6738 !sql.to_uppercase().contains("ON UPDATE"),
6739 "{backend}: ON UPDATE shouldn't appear for NoAction; got: {sql}",
6740 );
6741
6742 // SET NULL renders verbatim (two-word keyword).
6743 let sql = render_one(&set_null_fk);
6744 assert!(
6745 sql.to_uppercase().contains("ON DELETE SET NULL"),
6746 "{backend}: SET NULL missing; got: {sql}",
6747 );
6748 }
6749 }
6750
6751 /// Gap #65 follow-up: the diff engine detects changes to *every*
6752 /// schema-meaningful field, not just `ty` and `nullable`. Each
6753 /// branch builds a baseline column, mutates one field, runs
6754 /// `diff_columns`, and asserts an `AlterColumn` op is produced.
6755 /// Catches the regression where toggling `unique` or `on_delete`
6756 /// would silently leave the table unchanged.
6757 #[test]
6758 fn diff_detects_all_schema_meaningful_field_changes() {
6759 fn baseline() -> Column {
6760 Column {
6761 name: "x".into(),
6762 ty: SqlType::Text,
6763 primary_key: false,
6764 nullable: false,
6765 fk_target: None,
6766 noform: false,
6767 privileged: false,
6768 private: false,
6769 secret: false,
6770 db_constraint: true,
6771 noedit: false,
6772 auto_user_add: false,
6773 auto_user: false,
6774 is_string_repr: false,
6775 max_length: 0,
6776 choices: vec![],
6777 choice_labels: vec![],
6778 default: String::new(),
6779 is_multichoice: false,
6780 unique: false,
6781 on_delete: crate::orm::FkAction::NoAction,
6782 on_update: crate::orm::FkAction::NoAction,
6783 index: false,
6784 auto_now_add: false,
6785 auto_now: false,
6786 trim: false,
6787 lowercase: false,
6788 case_insensitive: false,
6789 help: String::new(),
6790 example: String::new(),
6791 widget: None,
6792 supported_backends: Vec::new(),
6793 min: None,
6794 max: None,
6795 text_format: ::core::option::Option::None,
6796 slug_from: ::core::option::Option::None,
6797 }
6798 }
6799 fn meta_with(col: Column) -> ModelMeta {
6800 ModelMeta {
6801 view: None,
6802 materialized: false,
6803 name: "M".into(),
6804 table: "m".into(),
6805 fields: vec![col],
6806 display: "M".into(),
6807 icon: "database".into(),
6808 database: None,
6809 singleton: false,
6810 unique_together: Vec::new(),
6811 indexes: Vec::new(),
6812 ordering: Vec::new(),
6813 m2m_relations: Vec::new(),
6814 soft_delete: false,
6815 audited: false,
6816 app_label: "app".into(),
6817 }
6818 }
6819 let prev = meta_with(baseline());
6820 // Safe-to-alter changes: each must surface as an `AlterColumn`.
6821 // (`nullable` here is false→true — a *loosening*, which is safe;
6822 // the tightening direction is guarded separately below.)
6823 let safe_mutations: Vec<(&str, fn(&mut Column))> = vec![
6824 ("default", |c| c.default = "hello".into()),
6825 ("choices", |c| {
6826 c.choices = vec!["a".into(), "b".into()];
6827 c.choice_labels = vec!["A".into(), "B".into()];
6828 }),
6829 ("nullable", |c| c.nullable = true),
6830 ];
6831 for (label, mutate) in safe_mutations {
6832 let mut col = baseline();
6833 mutate(&mut col);
6834 let current = meta_with(col);
6835 let ops = diff_columns("M", &prev, ¤t).expect("diff should succeed");
6836 assert!(
6837 !ops.is_empty(),
6838 "{label}: diff should produce at least one op; got none",
6839 );
6840 assert!(
6841 ops.iter()
6842 .any(|op| matches!(op, Operation::AlterColumn { column, .. } if column == "x")),
6843 "{label}: expected AlterColumn on `x`; got: {ops:?}",
6844 );
6845 }
6846
6847 // Adding UNIQUE to an existing column is detected too, but as an
6848 // `UnsafeAlter` guard rather than a bare `AlterColumn`: dropping a
6849 // UNIQUE constraint onto a populated column aborts the migration
6850 // if duplicates already exist, so the engine refuses it with a
6851 // duplicate-pre-check message instead of silently emitting it.
6852 let mut col = baseline();
6853 col.unique = true;
6854 let current = meta_with(col);
6855 match diff_columns("M", &prev, ¤t) {
6856 Err(MigrateError::UnsafeAlter { column, reason, .. }) => {
6857 assert_eq!(column, "x");
6858 assert!(
6859 reason.contains("UNIQUE"),
6860 "unsafe-alter reason should mention UNIQUE; got: {reason}",
6861 );
6862 }
6863 other => panic!("unique add should be an UnsafeAlter guard; got: {other:?}"),
6864 }
6865 }
6866
6867 /// Gap #65 follow-up: the Postgres `AlterColumn` render handles
6868 /// the new diff types (unique, default, choices, FK actions)
6869 /// with native `ALTER TABLE ... ADD/DROP CONSTRAINT` /
6870 /// `SET/DROP DEFAULT` statements. SQLite is unchanged — the
6871 /// rebuild dance already swallows any column metadata change.
6872 #[test]
6873 fn postgres_alter_column_renders_constraint_changes() {
6874 let baseline = Column {
6875 name: "x".into(),
6876 ty: SqlType::Text,
6877 primary_key: false,
6878 nullable: false,
6879 fk_target: None,
6880 noform: false,
6881 privileged: false,
6882 private: false,
6883 secret: false,
6884 db_constraint: true,
6885 noedit: false,
6886 auto_user_add: false,
6887 auto_user: false,
6888 is_string_repr: false,
6889 max_length: 0,
6890 choices: vec![],
6891 choice_labels: vec![],
6892 default: String::new(),
6893 is_multichoice: false,
6894 unique: false,
6895 on_delete: crate::orm::FkAction::NoAction,
6896 on_update: crate::orm::FkAction::NoAction,
6897 index: false,
6898 auto_now_add: false,
6899 auto_now: false,
6900 trim: false,
6901 lowercase: false,
6902 case_insensitive: false,
6903 help: String::new(),
6904 example: String::new(),
6905 widget: None,
6906 supported_backends: Vec::new(),
6907 min: None,
6908 max: None,
6909 text_format: ::core::option::Option::None,
6910 slug_from: ::core::option::Option::None,
6911 };
6912
6913 // unique false → true: emit ADD CONSTRAINT ... UNIQUE
6914 let mut new = baseline.clone();
6915 new.unique = true;
6916 let stmts = render_alter_column_postgres("m", "x", &[new], Some(&[baseline.clone()]));
6917 let joined = stmts.join("\n");
6918 assert!(
6919 joined.contains("ADD CONSTRAINT") && joined.contains("UNIQUE"),
6920 "unique add: expected ADD CONSTRAINT UNIQUE; got: {joined}",
6921 );
6922
6923 // unique true → false: emit DROP CONSTRAINT ... IF EXISTS
6924 let prev_unique = Column {
6925 unique: true,
6926 ..baseline.clone()
6927 };
6928 let stmts =
6929 render_alter_column_postgres("m", "x", &[baseline.clone()], Some(&[prev_unique]));
6930 let joined = stmts.join("\n");
6931 assert!(
6932 joined.contains("DROP CONSTRAINT IF EXISTS"),
6933 "unique drop: expected DROP CONSTRAINT IF EXISTS; got: {joined}",
6934 );
6935
6936 // default empty → "hello": SET DEFAULT 'hello'
6937 let mut new = baseline.clone();
6938 new.default = "hello".into();
6939 let stmts = render_alter_column_postgres("m", "x", &[new], Some(&[baseline.clone()]));
6940 let joined = stmts.join("\n");
6941 assert!(
6942 joined.contains("SET DEFAULT 'hello'"),
6943 "default set: expected SET DEFAULT; got: {joined}",
6944 );
6945
6946 // default "hello" → empty: DROP DEFAULT
6947 let prev_default = Column {
6948 default: "hello".into(),
6949 ..baseline.clone()
6950 };
6951 let stmts =
6952 render_alter_column_postgres("m", "x", &[baseline.clone()], Some(&[prev_default]));
6953 let joined = stmts.join("\n");
6954 assert!(
6955 joined.contains("DROP DEFAULT"),
6956 "default drop: expected DROP DEFAULT; got: {joined}",
6957 );
6958
6959 // FK on_delete change → DROP + readd FK with new clause
6960 let fk_baseline = Column {
6961 ty: SqlType::ForeignKey,
6962 fk_target: Some("other".into()),
6963 ..baseline.clone()
6964 };
6965 let fk_cascade = Column {
6966 on_delete: crate::orm::FkAction::Cascade,
6967 ..fk_baseline.clone()
6968 };
6969 let stmts = render_alter_column_postgres("m", "x", &[fk_cascade], Some(&[fk_baseline]));
6970 let joined = stmts.join("\n");
6971 assert!(
6972 joined.contains("DROP CONSTRAINT IF EXISTS")
6973 && joined.contains("FOREIGN KEY")
6974 && joined.contains("ON DELETE CASCADE"),
6975 "FK cascade add: expected drop+readd with ON DELETE CASCADE; got: {joined}",
6976 );
6977 }
6978
6979 /// IMP-2 from bugs/tests/testBugs.md: a `#[umbral(default = "true")]`
6980 /// on a boolean column used to land as `DEFAULT 'true'` on
6981 /// SQLite, which decode-fails on read (column type is INTEGER,
6982 /// the stored TEXT can't deserialize as `bool`). The SQLite
6983 /// renderer now maps the string to `1` / `0`.
6984 #[test]
6985 fn sqlite_bool_default_translates_to_integer_literal() {
6986 use sea_query::{Alias, SqliteQueryBuilder, Table};
6987
6988 let bool_col = Column {
6989 name: "is_active".into(),
6990 ty: SqlType::Boolean,
6991 primary_key: false,
6992 nullable: false,
6993 fk_target: None,
6994 noform: false,
6995 privileged: false,
6996 private: false,
6997 secret: false,
6998 db_constraint: true,
6999 noedit: false,
7000 auto_user_add: false,
7001 auto_user: false,
7002 is_string_repr: false,
7003 max_length: 0,
7004 choices: vec![],
7005 choice_labels: vec![],
7006 default: "true".into(),
7007 is_multichoice: false,
7008 unique: false,
7009 on_delete: crate::orm::FkAction::NoAction,
7010 on_update: crate::orm::FkAction::NoAction,
7011 index: false,
7012 auto_now_add: false,
7013 auto_now: false,
7014 trim: false,
7015 lowercase: false,
7016 case_insensitive: false,
7017 help: String::new(),
7018 example: String::new(),
7019 widget: None,
7020 supported_backends: Vec::new(),
7021 min: None,
7022 max: None,
7023 text_format: ::core::option::Option::None,
7024 slug_from: ::core::option::Option::None,
7025 };
7026 let mut stmt = Table::create();
7027 stmt.table(Alias::new("t"));
7028 let mut def = build_column_def_sqlite(&bool_col);
7029 stmt.col(&mut def);
7030 let sql = stmt.to_string(SqliteQueryBuilder);
7031 assert!(
7032 sql.contains("DEFAULT 1") && !sql.contains("DEFAULT 'true'"),
7033 "bool default 'true' on sqlite should render as DEFAULT 1; got: {sql}",
7034 );
7035
7036 // "false" → 0
7037 let mut bool_col_false = bool_col.clone();
7038 bool_col_false.default = "false".into();
7039 let mut stmt = Table::create();
7040 stmt.table(Alias::new("t"));
7041 let mut def = build_column_def_sqlite(&bool_col_false);
7042 stmt.col(&mut def);
7043 let sql = stmt.to_string(SqliteQueryBuilder);
7044 assert!(
7045 sql.contains("DEFAULT 0") && !sql.contains("DEFAULT 'false'"),
7046 "bool default 'false' on sqlite should render as DEFAULT 0; got: {sql}",
7047 );
7048
7049 // Non-bool columns are untouched (text default stays
7050 // single-quoted literal).
7051 let text_col = Column {
7052 name: "label".into(),
7053 ty: SqlType::Text,
7054 default: "hello".into(),
7055 ..bool_col.clone()
7056 };
7057 let mut stmt = Table::create();
7058 stmt.table(Alias::new("t"));
7059 let mut def = build_column_def_sqlite(&text_col);
7060 stmt.col(&mut def);
7061 let sql = stmt.to_string(SqliteQueryBuilder);
7062 assert!(
7063 sql.contains("DEFAULT 'hello'"),
7064 "text default should stay quoted; got: {sql}",
7065 );
7066 }
7067
7068 /// BUG-4 from bugs/tests/testBugs.md: `#[umbral(index)]` lifts
7069 /// to a `CREATE INDEX IF NOT EXISTS idx_<table>_<col>` statement
7070 /// alongside the `CREATE TABLE`. The index is skipped on PK
7071 /// and UNIQUE columns (those are already indexed by the
7072 /// constraint).
7073 #[test]
7074 fn index_attribute_emits_create_index_alongside_create_table() {
7075 let id = Column {
7076 name: "id".into(),
7077 ty: SqlType::BigInt,
7078 primary_key: true,
7079 nullable: false,
7080 fk_target: None,
7081 noform: false,
7082 privileged: false,
7083 private: false,
7084 secret: false,
7085 db_constraint: true,
7086 noedit: false,
7087 auto_user_add: false,
7088 auto_user: false,
7089 is_string_repr: false,
7090 max_length: 0,
7091 choices: vec![],
7092 choice_labels: vec![],
7093 default: String::new(),
7094 is_multichoice: false,
7095 unique: false,
7096 on_delete: crate::orm::FkAction::NoAction,
7097 on_update: crate::orm::FkAction::NoAction,
7098 // PK with index=true; the renderer should skip the
7099 // extra CREATE INDEX because the PK constraint
7100 // already covers it.
7101 index: true,
7102 auto_now_add: false,
7103 auto_now: false,
7104 trim: false,
7105 lowercase: false,
7106 case_insensitive: false,
7107 help: String::new(),
7108 example: String::new(),
7109 widget: None,
7110 supported_backends: Vec::new(),
7111 min: None,
7112 max: None,
7113 text_format: ::core::option::Option::None,
7114 slug_from: ::core::option::Option::None,
7115 };
7116 let slug = Column {
7117 name: "slug".into(),
7118 ty: SqlType::Text,
7119 primary_key: false,
7120 nullable: false,
7121 index: true,
7122 auto_now_add: false,
7123 auto_now: false,
7124 trim: false,
7125 lowercase: false,
7126 case_insensitive: false,
7127 help: String::new(),
7128 example: String::new(),
7129 widget: None,
7130 supported_backends: Vec::new(),
7131 ..id.clone()
7132 };
7133 let title = Column {
7134 name: "title".into(),
7135 ty: SqlType::Text,
7136 primary_key: false,
7137 nullable: false,
7138 index: false,
7139 auto_now_add: false,
7140 auto_now: false,
7141 trim: false,
7142 lowercase: false,
7143 case_insensitive: false,
7144 help: String::new(),
7145 example: String::new(),
7146 widget: None,
7147 supported_backends: Vec::new(),
7148 ..id.clone()
7149 };
7150 let op = Operation::CreateTable {
7151 table: "post".into(),
7152 columns: vec![id, slug, title],
7153 unique_together: Vec::new(),
7154 indexes: Vec::new(),
7155 };
7156
7157 for backend in ["sqlite", "postgres"] {
7158 let stmts = render_operation_for(&op, backend);
7159 assert!(
7160 stmts
7161 .iter()
7162 .any(|s| s.to_uppercase().contains("CREATE TABLE")),
7163 "{backend}: expected a CREATE TABLE; got: {stmts:?}",
7164 );
7165 let index_stmts: Vec<_> = stmts
7166 .iter()
7167 .filter(|s| s.to_uppercase().contains("CREATE INDEX"))
7168 .collect();
7169 assert_eq!(
7170 index_stmts.len(),
7171 1,
7172 "{backend}: expected exactly one CREATE INDEX (on `slug`); got {index_stmts:?}",
7173 );
7174 let ix = index_stmts[0];
7175 assert!(
7176 ix.contains("\"idx_post_slug\"") && ix.contains("(\"slug\")"),
7177 "{backend}: index should target post(slug); got: {ix}",
7178 );
7179 assert!(
7180 ix.to_uppercase().contains("IF NOT EXISTS"),
7181 "{backend}: should be idempotent via IF NOT EXISTS; got: {ix}",
7182 );
7183 }
7184 }
7185
7186 /// gaps3 #35: a `#[umbral(case_insensitive)]` text column renders
7187 /// per-backend — SQLite `COLLATE NOCASE`, Postgres `citext` with an
7188 /// idempotent `CREATE EXTENSION` emitted before the table.
7189 #[test]
7190 fn case_insensitive_column_renders_per_backend() {
7191 let id = Column {
7192 name: "id".into(),
7193 ty: SqlType::BigInt,
7194 primary_key: true,
7195 nullable: false,
7196 fk_target: None,
7197 noform: false,
7198 privileged: false,
7199 private: false,
7200 secret: false,
7201 db_constraint: true,
7202 noedit: false,
7203 auto_user_add: false,
7204 auto_user: false,
7205 is_string_repr: false,
7206 max_length: 0,
7207 choices: vec![],
7208 choice_labels: vec![],
7209 default: String::new(),
7210 is_multichoice: false,
7211 unique: false,
7212 on_delete: crate::orm::FkAction::NoAction,
7213 on_update: crate::orm::FkAction::NoAction,
7214 index: false,
7215 auto_now_add: false,
7216 auto_now: false,
7217 trim: false,
7218 lowercase: false,
7219 case_insensitive: false,
7220 help: String::new(),
7221 example: String::new(),
7222 widget: None,
7223 supported_backends: Vec::new(),
7224 min: None,
7225 max: None,
7226 text_format: None,
7227 slug_from: None,
7228 };
7229 let name = Column {
7230 name: "name".into(),
7231 ty: SqlType::Text,
7232 primary_key: false,
7233 unique: true,
7234 case_insensitive: true,
7235 ..id.clone()
7236 };
7237 let op = Operation::CreateTable {
7238 table: "handle".into(),
7239 columns: vec![id, name],
7240 unique_together: Vec::new(),
7241 indexes: Vec::new(),
7242 };
7243
7244 // SQLite: COLLATE NOCASE on the column; no extension.
7245 let sqlite = render_operation_for(&op, "sqlite");
7246 let create = sqlite
7247 .iter()
7248 .find(|s| s.to_uppercase().contains("CREATE TABLE"))
7249 .expect("a CREATE TABLE");
7250 assert!(
7251 create.to_uppercase().contains("COLLATE NOCASE"),
7252 "sqlite case_insensitive column must carry COLLATE NOCASE; got: {create}"
7253 );
7254 assert!(
7255 !sqlite
7256 .iter()
7257 .any(|s| s.to_uppercase().contains("EXTENSION")),
7258 "sqlite must not emit a citext extension; got: {sqlite:?}"
7259 );
7260
7261 // Postgres: citext type + CREATE EXTENSION IF NOT EXISTS citext, and the
7262 // extension statement precedes the CREATE TABLE.
7263 let pg = render_operation_for(&op, "postgres");
7264 let ext_idx = pg
7265 .iter()
7266 .position(|s| s.to_uppercase().contains("CREATE EXTENSION") && s.contains("citext"))
7267 .expect("a CREATE EXTENSION citext statement");
7268 let create_idx = pg
7269 .iter()
7270 .position(|s| s.to_uppercase().contains("CREATE TABLE"))
7271 .expect("a CREATE TABLE");
7272 assert!(
7273 ext_idx < create_idx,
7274 "the citext extension must be created before the table; got: {pg:?}"
7275 );
7276 assert!(
7277 pg[create_idx].to_lowercase().contains("citext"),
7278 "postgres case_insensitive column must render as citext; got: {}",
7279 pg[create_idx]
7280 );
7281 assert!(
7282 pg[ext_idx].to_uppercase().contains("IF NOT EXISTS"),
7283 "the extension create must be idempotent; got: {}",
7284 pg[ext_idx]
7285 );
7286 }
7287
7288 /// Regression: adding an `auto_now` / `auto_now_add` column to an
7289 /// existing populated table.
7290 ///
7291 /// - SQLite: a 2-statement sequence (nullable ADD + UPDATE
7292 /// backfill) since SQLite refuses non-constant defaults in
7293 /// ALTER. The column ends up nullable at the DB level;
7294 /// Rust still enforces non-null at the type level.
7295 /// - Postgres: a single ALTER with `DEFAULT now()` — Postgres
7296 /// allows the non-constant default and backfills inline.
7297 #[test]
7298 fn auto_now_add_column_renders_safe_backfill_per_backend() {
7299 for (label, auto_now, auto_now_add) in
7300 [("auto_now", true, false), ("auto_now_add", false, true)]
7301 {
7302 let col = Column {
7303 name: "updated_at".to_string(),
7304 ty: SqlType::Timestamptz,
7305 primary_key: false,
7306 nullable: false,
7307 fk_target: None,
7308 noform: false,
7309 privileged: false,
7310 private: false,
7311 secret: false,
7312 db_constraint: true,
7313 noedit: false,
7314 auto_user_add: false,
7315 auto_user: false,
7316 is_string_repr: false,
7317 max_length: 0,
7318 choices: Vec::new(),
7319 choice_labels: Vec::new(),
7320 default: String::new(),
7321 is_multichoice: false,
7322 unique: false,
7323 on_delete: crate::orm::FkAction::NoAction,
7324 on_update: crate::orm::FkAction::NoAction,
7325 index: false,
7326 auto_now_add,
7327 auto_now,
7328 trim: false,
7329 lowercase: false,
7330 case_insensitive: false,
7331 help: String::new(),
7332 example: String::new(),
7333 widget: None,
7334 supported_backends: Vec::new(),
7335 min: None,
7336 max: None,
7337 text_format: None,
7338 slug_from: None,
7339 };
7340
7341 // SQLite: the AddColumn op must produce TWO statements:
7342 // an ADD COLUMN nullable + an UPDATE backfill. The ADD
7343 // must NOT carry `NOT NULL` (otherwise SQLite rejects
7344 // it on the populated rows), and must NOT carry a
7345 // DEFAULT (otherwise SQLite rejects the non-constant).
7346 let op = Operation::AddColumn {
7347 table: "customer".to_string(),
7348 column: col.clone(),
7349 };
7350 let stmts = render_operation_sqlite(&op);
7351 assert_eq!(
7352 stmts.len(),
7353 2,
7354 "{label} SQLite: must emit ADD + UPDATE, got: {stmts:?}",
7355 );
7356 let add_sql = stmts[0].to_uppercase();
7357 assert!(
7358 add_sql.contains("ADD COLUMN"),
7359 "{label} SQLite: first stmt must be ADD COLUMN, got: {}",
7360 stmts[0],
7361 );
7362 assert!(
7363 !add_sql.contains("NOT NULL"),
7364 "{label} SQLite: ADD COLUMN must be nullable (NOT NULL = SQLite reject), got: {}",
7365 stmts[0],
7366 );
7367 assert!(
7368 !add_sql.contains("DEFAULT"),
7369 "{label} SQLite: ADD COLUMN must omit DEFAULT (non-constant = SQLite reject), got: {}",
7370 stmts[0],
7371 );
7372 let backfill_sql = &stmts[1];
7373 assert!(
7374 backfill_sql.contains("UPDATE") && backfill_sql.contains("datetime('now')"),
7375 "{label} SQLite: second stmt must be backfill UPDATE, got: {backfill_sql}",
7376 );
7377
7378 // Postgres: single ALTER with NOT NULL + DEFAULT now().
7379 let pstmts = render_operation_postgres(&op);
7380 assert_eq!(
7381 pstmts.len(),
7382 1,
7383 "{label} Postgres: single statement suffices, got: {pstmts:?}",
7384 );
7385 let p = &pstmts[0];
7386 assert!(
7387 p.to_lowercase().contains("default now()"),
7388 "{label} Postgres: expected DEFAULT now() in ALTER, got: {p}",
7389 );
7390 assert!(
7391 p.to_uppercase().contains("NOT NULL"),
7392 "{label} Postgres: keeps NOT NULL (Postgres allows non-constant defaults), got: {p}",
7393 );
7394 }
7395 }
7396
7397 /// Audit core-migrate #14 — raw DDL that interpolates
7398 /// developer-supplied identifiers must escape inner double quotes by
7399 /// doubling them (the quoting idiom used everywhere else), not strip
7400 /// or pass them through verbatim. A `"` in a table name previously
7401 /// produced malformed DDL in the multi-column index helper (ON-clause
7402 /// table was quote-stripped) and the M2M junction DDL (five raw
7403 /// interpolations).
7404 #[test]
7405 fn raw_ddl_escapes_quoted_identifiers() {
7406 // Multi-column index: the ON-clause table reference must carry
7407 // the doubled quote, not a stripped one.
7408 let idx = create_multi_index_stmt("we\"ird", &["a\"b".to_string(), "c".to_string()]);
7409 assert!(
7410 idx.contains("ON \"we\"\"ird\""),
7411 "multi-index ON clause must escape the quote (doubled); got: {idx}",
7412 );
7413 assert!(
7414 idx.contains("\"a\"\"b\""),
7415 "multi-index column list must escape the quote; got: {idx}",
7416 );
7417
7418 // M2M junction DDL: every interpolated identifier escapes its
7419 // inner quote. Check both backends.
7420 let op = Operation::CreateM2MTable {
7421 junction_table: "j\"t".to_string(),
7422 parent_table: "p\"t".to_string(),
7423 parent_col: "p\"c".to_string(),
7424 child_table: "c\"t".to_string(),
7425 child_col: "c\"c".to_string(),
7426 parent_ty: SqlType::BigInt,
7427 child_ty: SqlType::Text,
7428 };
7429 for backend in ["sqlite", "postgres"] {
7430 let sql = render_operation_for(&op, backend).join("\n");
7431 for (raw, escaped) in [
7432 ("j\"t", "\"j\"\"t\""),
7433 ("p\"t", "\"p\"\"t\""),
7434 ("p\"c", "\"p\"\"c\""),
7435 ("c\"t", "\"c\"\"t\""),
7436 ("c\"c", "\"c\"\"c\""),
7437 ] {
7438 assert!(
7439 sql.contains(escaped),
7440 "{backend}: identifier `{raw}` must render escaped as {escaped}; got: {sql}",
7441 );
7442 }
7443 }
7444 }
7445}
7446
7447/// A `#[derive(Model)]` type's self-registration (gaps3 #46).
7448///
7449/// The derive submits one of these into a link-time slice, so
7450/// `AppBuilder::auto_models()` can discover every model the binary links without
7451/// the app naming each one. It stores a *function* rather than a `ModelMeta`
7452/// because `ModelMeta::for_::<T>()` isn't const — the meta is built on demand at
7453/// boot.
7454pub struct ModelRegistration {
7455 /// Builds this model's meta.
7456 pub meta: fn() -> ModelMeta,
7457}
7458
7459inventory::collect!(ModelRegistration);
7460
7461/// Every model that self-registered via `#[derive(Model)]` in the linked binary.
7462pub fn link_registered_models() -> Vec<ModelMeta> {
7463 inventory::iter::<ModelRegistration>
7464 .into_iter()
7465 .map(|r| (r.meta)())
7466 .collect()
7467}