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