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