1use crate::config::types::*;
5use crate::config::{validate, FullConfig};
6use crate::db::parse_canonical;
7use crate::db::pool::Pool;
8use crate::db::{ColumnFacts, DbSnapshot, Dialect};
9use crate::error::AppError;
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12
13fn quote(s: &str) -> String {
14 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
15}
16
17pub const RLS_TENANT_COLUMN: &str = "tenant_id";
19
20pub async fn apply_rls_to_tables(
28 pool: &Pool,
29 config: &FullConfig,
30 schema_override: Option<&str>,
31 rls_tenant_column: &str,
32 dialect: &dyn Dialect,
33) -> Result<(), AppError> {
34 if !dialect.supports_rls() {
35 tracing::warn!(dialect = %dialect.name(), "RLS requested but not supported by this dialect; skipping");
36 return Ok(());
37 }
38 let default_sid = config.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
39 let platform_id = crate::tenant::platform_tenant_id();
40 let schemas_by_id: HashMap<_, _> = config.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
41 let columns_by_table: HashMap<_, Vec<&ColumnConfig>> =
42 config.columns.iter().fold(HashMap::new(), |mut m, c| {
43 m.entry(c.table_id.as_str()).or_default().push(c);
44 m
45 });
46 let col = rls_tenant_column;
47
48 for t in &config.tables {
49 let sid = t.schema_id.as_deref().unwrap_or(default_sid);
50 let schema = match schemas_by_id.get(sid) {
51 Some(s) => s,
52 None => continue,
53 };
54 let schema_name = quote(schema_override.unwrap_or(&schema.name));
55 let full_name = format!("{}.{}", schema_name, quote(&t.name));
56 let config_col_names: HashSet<&str> = columns_by_table
57 .get(t.id.as_str())
58 .map(|v| v.iter().map(|c| c.name.as_str()).collect())
59 .unwrap_or_default();
60
61 if !config_col_names.contains(col) {
62 let add_col = format!(
63 "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} TEXT",
64 full_name,
65 quote(col)
66 );
67 sqlx::query(&add_col).execute(pool).await?;
68 }
69 let enable_rls = format!("ALTER TABLE {} ENABLE ROW LEVEL SECURITY", full_name);
70 sqlx::query(&enable_rls).execute(pool).await?;
71 let q_col = quote(col);
72 let setting = "current_setting('app.tenant_id', true)";
73 let tenant_cond = format!("{} = {}", q_col, setting);
75 let admin_cond = format!("{} = '{}'", setting, platform_id.replace('\'', "''"));
78 let read_all = "true".to_string();
79 let policy_prefix = format!("rls_tenant_{}", t.name);
80 let policies: Vec<(&str, &str, Option<&str>, Option<&str>)> = if t.global {
83 vec![
84 ("select", "SELECT", Some(read_all.as_str()), None),
85 ("insert", "INSERT", None, Some(admin_cond.as_str())),
86 (
87 "update",
88 "UPDATE",
89 Some(admin_cond.as_str()),
90 Some(admin_cond.as_str()),
91 ),
92 ("delete", "DELETE", Some(admin_cond.as_str()), None),
93 ]
94 } else {
95 vec![
96 ("select", "SELECT", Some(tenant_cond.as_str()), None),
97 ("insert", "INSERT", None, Some(tenant_cond.as_str())),
98 (
99 "update",
100 "UPDATE",
101 Some(tenant_cond.as_str()),
102 Some(tenant_cond.as_str()),
103 ),
104 ("delete", "DELETE", Some(tenant_cond.as_str()), None),
105 ]
106 };
107 for (suffix, cmd, using_cond, with_check) in policies.iter() {
108 let policy_name = format!("{}_{}", policy_prefix, suffix);
109 let drop_sql = format!(
110 "DROP POLICY IF EXISTS {} ON {}",
111 quote(&policy_name),
112 full_name
113 );
114 let _ = sqlx::query(&drop_sql).execute(pool).await;
115 let create_sql = match (using_cond, with_check) {
116 (Some(u), Some(w)) => format!(
117 "CREATE POLICY {} ON {} FOR {} USING ( {} ) WITH CHECK ( {} )",
118 quote(&policy_name),
119 full_name,
120 cmd,
121 u,
122 w
123 ),
124 (Some(u), None) => format!(
125 "CREATE POLICY {} ON {} FOR {} USING ( {} )",
126 quote(&policy_name),
127 full_name,
128 cmd,
129 u
130 ),
131 (None, Some(w)) => format!(
132 "CREATE POLICY {} ON {} FOR {} WITH CHECK ( {} )",
133 quote(&policy_name),
134 full_name,
135 cmd,
136 w
137 ),
138 (None, None) => continue,
139 };
140 sqlx::query(&create_sql).execute(pool).await?;
141 }
142 }
143 Ok(())
144}
145
146async fn add_missing_columns(
156 pool: &Pool,
157 snapshot: &DbSnapshot,
158 dialect: &dyn Dialect,
159 schema: &str,
160 table: &str,
161 desired: &[(String, String)],
162) {
163 if !snapshot.introspected {
164 return;
165 }
166 let full = format!("{}.{}", quote(schema), quote(table));
167 for (name, def) in desired {
168 if snapshot.has_column(schema, table, name) {
169 continue;
170 }
171 let col_def = format!("{} {}", quote(name), def);
172 let sql = add_column_ddl(dialect, &full, &col_def);
173 match sqlx::query(&sql).execute(pool).await {
174 Ok(_) => {
175 tracing::info!(schema, table, column = %name, "added missing column to pre-existing table");
176 }
177 Err(e) => {
178 let upper = def.to_uppercase();
181 if upper.contains("NOT NULL") && !upper.contains("DEFAULT") {
182 let relaxed_def = def.replace("NOT NULL", "").trim().to_string();
183 let retry =
184 add_column_ddl(dialect, &full, &format!("{} {}", quote(name), relaxed_def));
185 if sqlx::query(&retry).execute(pool).await.is_ok() {
186 tracing::warn!(schema, table, column = %name, "added missing column as NULLABLE — NOT NULL could not be applied to the existing table");
187 continue;
188 }
189 }
190 tracing::warn!(schema, table, column = %name, error = %e, "could not add missing column to pre-existing table");
191 }
192 }
193 }
194}
195
196fn companion_source_columns(
199 source_cols: &[&ColumnConfig],
200 dialect: &dyn Dialect,
201) -> Vec<(String, String)> {
202 let mut out: Vec<(String, String)> = source_cols
203 .iter()
204 .map(|c| (c.name.clone(), dialect.ddl_type(&parse_canonical(&c.type_))))
205 .collect();
206 let config_col_names: HashSet<&str> = source_cols.iter().map(|c| c.name.as_str()).collect();
207 let audit_ts = dialect.audit_timestamp_type();
208 for (name, typ) in [
209 ("created_at", audit_ts),
210 ("updated_at", audit_ts),
211 ("archived_at", audit_ts),
212 ("created_by", "TEXT"),
213 ("updated_by", "TEXT"),
214 ] {
215 if !config_col_names.contains(name) {
216 out.push((name.to_string(), typ.to_string()));
217 }
218 }
219 out
220}
221
222pub async fn apply_migrations(
227 pool: &Pool,
228 config: &FullConfig,
229 schema_override: Option<&str>,
230 rls_tenant_column: Option<&str>,
231 dialect: &dyn Dialect,
232 cross_package_configs: &HashMap<String, FullConfig>,
233) -> Result<(), AppError> {
234 validate(config)?;
235 let default_sid = config
236 .schemas
237 .first()
238 .map(|s| s.id.as_str())
239 .ok_or_else(|| {
240 AppError::Config(crate::error::ConfigError::Validation(
241 "at least one schema required".into(),
242 ))
243 })?;
244
245 if dialect.supports_schemas() {
246 if let Some(s) = schema_override {
247 let name = quote(s);
248 sqlx::query(&format!("CREATE SCHEMA IF NOT EXISTS {}", name))
249 .execute(pool)
250 .await?;
251 }
252 }
253
254 let schemas_by_id: HashMap<_, _> = config.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
255 let tables_by_id: HashMap<_, _> = config.tables.iter().map(|t| (t.id.as_str(), t)).collect();
256 let columns_by_table: HashMap<_, Vec<&ColumnConfig>> =
257 config.columns.iter().fold(HashMap::new(), |mut m, c| {
258 m.entry(c.table_id.as_str()).or_default().push(c);
259 m
260 });
261
262 if schema_override.is_none() && dialect.supports_schemas() {
264 for s in &config.schemas {
265 let name = quote(&s.name);
266 let comment = s
267 .comment
268 .as_ref()
269 .map(|c| format!("COMMENT ON SCHEMA {} IS '{}'", name, c.replace('\'', "''")));
270 sqlx::query(&format!("CREATE SCHEMA IF NOT EXISTS {}", name))
271 .execute(pool)
272 .await?;
273 if let Some(sql) = comment {
274 let _ = sqlx::query(&sql).execute(pool).await;
275 }
276 }
277 }
278
279 for e in &config.enums {
280 let sid = e.schema_id.as_deref().unwrap_or(default_sid);
281 let schema = schemas_by_id.get(sid).ok_or_else(|| {
282 AppError::Config(crate::error::ConfigError::MissingReference {
283 kind: "schema",
284 id: sid.to_string(),
285 })
286 })?;
287 let schema_name = quote(schema_override.unwrap_or(&schema.name));
288 let type_name = quote(&e.name);
289 if dialect.supports_named_enum_types() {
290 let values: Vec<String> = e
291 .values
292 .iter()
293 .map(|v| format!("'{}'", v.replace('\'', "''")))
294 .collect();
295 let sql = format!(
296 "CREATE TYPE {}.{} AS ENUM ({})",
297 schema_name,
298 type_name,
299 values.join(", ")
300 );
301 let _ = sqlx::query(&sql).execute(pool).await;
302 }
303 }
304
305 let target_schemas: Vec<String> = match schema_override {
310 Some(o) => vec![o.to_string()],
311 None => config.schemas.iter().map(|s| s.name.clone()).collect(),
312 };
313 let pre_snapshot = crate::db::introspect(pool, dialect, &target_schemas).await;
314
315 for t in &config.tables {
316 let sid = t.schema_id.as_deref().unwrap_or(default_sid);
317 let schema = schemas_by_id.get(sid).ok_or_else(|| {
318 AppError::Config(crate::error::ConfigError::MissingReference {
319 kind: "schema",
320 id: sid.to_string(),
321 })
322 })?;
323 let schema_raw = schema_override.unwrap_or(&schema.name);
324 let schema_name = quote(schema_raw);
325 let table_name = quote(&t.name);
326 let full_name = format!("{}.{}", schema_name, table_name);
327 let table_pre_existed = pre_snapshot.has_table(schema_raw, &t.name);
328
329 let cols = columns_by_table
330 .get(t.id.as_str())
331 .map(|v| v.as_slice())
332 .unwrap_or(&[]);
333 let mut columns: Vec<(String, String)> = Vec::new();
336 for c in cols {
337 let typ = dialect.ddl_type(&parse_canonical(&c.type_));
338 let mut def = typ;
339 if !c.nullable {
340 def.push_str(" NOT NULL");
341 }
342 if let Some(ref d) = c.default {
343 def.push_str(" DEFAULT ");
344 match d {
345 ColumnDefaultConfig::Literal(s) => def.push_str(s),
346 ColumnDefaultConfig::Expression { expression } => def.push_str(expression),
347 }
348 }
349 columns.push((c.name.clone(), def));
350 }
351
352 let config_col_names: HashSet<&str> = cols.iter().map(|c| c.name.as_str()).collect();
353 let ts_default = format!(
354 "{} NOT NULL DEFAULT {}",
355 dialect.sys_timestamp_type(),
356 dialect.now_fn()
357 );
358 let ts_nullable = dialect.sys_timestamp_type().to_string();
359 for (name, def_suffix) in [
360 ("created_at", ts_default.as_str()),
361 ("updated_at", ts_default.as_str()),
362 ("archived_at", ts_nullable.as_str()),
363 ("created_by", "TEXT"),
364 ("updated_by", "TEXT"),
365 ] {
366 if !config_col_names.contains(name) {
367 columns.push((name.to_string(), def_suffix.to_string()));
368 }
369 }
370
371 let mut col_defs: Vec<String> = columns
372 .iter()
373 .map(|(name, def)| format!("{} {}", quote(name), def))
374 .collect();
375
376 let pk_cols = match &t.primary_key {
377 PrimaryKeyConfig::Single(s) => vec![quote(s)],
378 PrimaryKeyConfig::Composite(v) => v.iter().map(|s| quote(s)).collect::<Vec<_>>(),
379 };
380 let pk_def = format!("PRIMARY KEY ({})", pk_cols.join(", "));
381 col_defs.push(pk_def);
382
383 for u in &t.unique {
384 let cols: Vec<String> = u.iter().map(|s| quote(s)).collect();
385 col_defs.push(format!("UNIQUE ({})", cols.join(", ")));
386 }
387 for ch in &t.check {
388 col_defs.push(format!(
389 "CONSTRAINT {} CHECK ({})",
390 quote(&ch.name),
391 ch.expression
392 ));
393 }
394
395 let sql = format!(
396 "CREATE TABLE IF NOT EXISTS {} (\n {}\n)",
397 full_name,
398 col_defs.join(",\n ")
399 );
400 sqlx::query(&sql).execute(pool).await?;
401
402 if table_pre_existed {
403 add_missing_columns(pool, &pre_snapshot, dialect, schema_raw, &t.name, &columns).await;
404 }
405
406 if t.audit_log {
407 let audit_sql = audit_table_ddl(schema_raw, &t.name, cols, dialect);
408 sqlx::query(&audit_sql).execute(pool).await?;
409 let pk_col = match &t.primary_key {
410 PrimaryKeyConfig::Single(s) => s.clone(),
411 PrimaryKeyConfig::Composite(v) => v[0].clone(),
412 };
413 let audit_full = format!(
414 "{}.{}",
415 quote(schema_raw),
416 quote(&format!("{}_audit", t.name))
417 );
418 let idx_sql = format!(
419 "CREATE INDEX IF NOT EXISTS {} ON {} ({}, {})",
420 quote(&format!("{}_audit_record_idx", t.name)),
421 audit_full,
422 quote(&pk_col),
423 quote("audit_at")
424 );
425 let _ = sqlx::query(&idx_sql).execute(pool).await;
426
427 let audit_table = format!("{}_audit", t.name);
428 if pre_snapshot.has_table(schema_raw, &audit_table) {
429 add_missing_columns(
430 pool,
431 &pre_snapshot,
432 dialect,
433 schema_raw,
434 &audit_table,
435 &companion_source_columns(cols, dialect),
436 )
437 .await;
438 }
439 }
440
441 if t.versioning.as_ref().is_some_and(|v| v.enabled) {
442 let pk_col = match &t.primary_key {
443 PrimaryKeyConfig::Single(s) => s.clone(),
444 PrimaryKeyConfig::Composite(v) => v[0].clone(),
445 };
446 let history_ddl = history_table_ddl(schema_raw, &t.name, &pk_col, cols, dialect);
447 let create_only = history_ddl
449 .lines()
450 .take_while(|l| !l.trim_start().starts_with("-- index:"))
451 .collect::<Vec<_>>()
452 .join("\n");
453 sqlx::query(create_only.trim()).execute(pool).await?;
454 let idx_sql = history_index_ddl(schema_raw, &t.name, &pk_col);
455 let _ = sqlx::query(&idx_sql).execute(pool).await;
456
457 let history_table = format!("{}_history", t.name);
458 if pre_snapshot.has_table(schema_raw, &history_table) {
459 add_missing_columns(
460 pool,
461 &pre_snapshot,
462 dialect,
463 schema_raw,
464 &history_table,
465 &companion_source_columns(cols, dialect),
466 )
467 .await;
468 }
469 }
470 }
471
472 if let Some(col) = rls_tenant_column {
473 apply_rls_to_tables(pool, config, schema_override, col, dialect).await?;
474 }
475
476 for idx in &config.indexes {
477 let sid = idx.schema_id.as_deref().unwrap_or(default_sid);
478 let schema = schemas_by_id.get(sid).ok_or_else(|| {
479 AppError::Config(crate::error::ConfigError::MissingReference {
480 kind: "schema",
481 id: sid.to_string(),
482 })
483 })?;
484 let table = tables_by_id.get(idx.table_id.as_str()).ok_or_else(|| {
485 AppError::Config(crate::error::ConfigError::MissingReference {
486 kind: "table",
487 id: idx.table_id.clone(),
488 })
489 })?;
490 let schema_name = quote(schema_override.unwrap_or(&schema.name));
491 let table_name = quote(&table.name);
492 let full_table = format!("{}.{}", schema_name, table_name);
493 let index_name = quote(&idx.name);
494
495 let mut col_parts: Vec<String> = Vec::new();
496 for col in &idx.columns {
497 match col {
498 IndexColumnEntry::Name(n) => col_parts.push(quote(n)),
499 IndexColumnEntry::Spec {
500 name, direction, ..
501 } => {
502 let dir = direction
503 .as_deref()
504 .map(|d| format!(" {}", d.to_uppercase()))
505 .unwrap_or_default();
506 col_parts.push(format!("{}{}", quote(name), dir));
507 }
508 IndexColumnEntry::Expression { expression } => col_parts.push(expression.clone()),
509 }
510 }
511 let method = idx.method.as_deref().unwrap_or("btree");
512 let unique = if idx.unique { "UNIQUE " } else { "" };
513 let include: String = if idx.include.is_empty() {
514 String::new()
515 } else {
516 let inc: Vec<String> = idx.include.iter().map(|s| quote(s)).collect();
517 format!(" INCLUDE ({})", inc.join(", "))
518 };
519 let where_clause: String = idx
520 .where_
521 .as_ref()
522 .map(|w| format!(" WHERE {}", w))
523 .unwrap_or_default();
524
525 let sql = format!(
526 "CREATE {}INDEX IF NOT EXISTS {} ON {} USING {} ({}){}{}",
527 unique,
528 index_name,
529 full_table,
530 method,
531 col_parts.join(", "),
532 include,
533 where_clause
534 );
535 let _ = sqlx::query(&sql).execute(pool).await;
536 }
537
538 for rel in &config.relationships {
539 let from_sid = rel.from_schema_id.as_deref().unwrap_or(default_sid);
540 let from_schema = schemas_by_id.get(from_sid).ok_or_else(|| {
541 AppError::Config(crate::error::ConfigError::MissingReference {
542 kind: "schema",
543 id: from_sid.to_string(),
544 })
545 })?;
546 let from_table = tables_by_id
547 .get(rel.from_table_id.as_str())
548 .ok_or_else(|| {
549 AppError::Config(crate::error::ConfigError::MissingReference {
550 kind: "table",
551 id: rel.from_table_id.clone(),
552 })
553 })?;
554
555 let (to_schema_name_owned, to_table_name, to_col_name) = if let Some(pkg_id) =
557 rel.to_package_id.as_deref()
558 {
559 let foreign = cross_package_configs.get(pkg_id).ok_or_else(|| {
560 AppError::Config(crate::error::ConfigError::MissingReference {
561 kind: "cross_package",
562 id: pkg_id.to_string(),
563 })
564 })?;
565 let foreign_tables: HashMap<_, _> =
566 foreign.tables.iter().map(|t| (t.id.as_str(), t)).collect();
567 let foreign_schemas: HashMap<_, _> =
568 foreign.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
569 let to_tbl = foreign_tables
570 .get(rel.to_table_id.as_str())
571 .ok_or_else(|| {
572 AppError::Config(crate::error::ConfigError::MissingReference {
573 kind: "table",
574 id: rel.to_table_id.clone(),
575 })
576 })?;
577 let foreign_default_sid = foreign.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
578 let to_sid = rel.to_schema_id.as_deref().unwrap_or(foreign_default_sid);
579 let to_schema = foreign_schemas.get(to_sid).ok_or_else(|| {
580 AppError::Config(crate::error::ConfigError::MissingReference {
581 kind: "schema",
582 id: to_sid.to_string(),
583 })
584 })?;
585 let col_name = foreign
586 .columns
587 .iter()
588 .find(|c| c.id == rel.to_column_id)
589 .map(|c| c.name.clone())
590 .ok_or_else(|| {
591 AppError::Config(crate::error::ConfigError::MissingReference {
592 kind: "column",
593 id: rel.to_column_id.clone(),
594 })
595 })?;
596 (to_schema.name.clone(), to_tbl.name.clone(), col_name)
599 } else {
600 let to_sid = rel.to_schema_id.as_deref().unwrap_or(default_sid);
601 let to_schema = schemas_by_id.get(to_sid).ok_or_else(|| {
602 AppError::Config(crate::error::ConfigError::MissingReference {
603 kind: "schema",
604 id: to_sid.to_string(),
605 })
606 })?;
607 let to_table = tables_by_id.get(rel.to_table_id.as_str()).ok_or_else(|| {
608 AppError::Config(crate::error::ConfigError::MissingReference {
609 kind: "table",
610 id: rel.to_table_id.clone(),
611 })
612 })?;
613 let col_name = config
614 .columns
615 .iter()
616 .find(|c| c.id == rel.to_column_id)
617 .map(|c| c.name.clone())
618 .ok_or_else(|| {
619 AppError::Config(crate::error::ConfigError::MissingReference {
620 kind: "column",
621 id: rel.to_column_id.clone(),
622 })
623 })?;
624 (
625 schema_override.unwrap_or(&to_schema.name).to_string(),
626 to_table.name.clone(),
627 col_name,
628 )
629 };
630
631 let from_schema_name = schema_override.unwrap_or(&from_schema.name);
632 let from_col = config
633 .columns
634 .iter()
635 .find(|c| c.id == rel.from_column_id)
636 .map(|c| c.name.as_str())
637 .ok_or_else(|| {
638 AppError::Config(crate::error::ConfigError::MissingReference {
639 kind: "column",
640 id: rel.from_column_id.clone(),
641 })
642 })?;
643
644 let from_full = format!("{}.{}", quote(from_schema_name), quote(&from_table.name));
645 let to_full = format!("{}.{}", quote(&to_schema_name_owned), quote(&to_table_name));
646 let constraint_name = rel.name.as_deref().unwrap_or(&rel.id);
647 let on_update = rel.on_update.as_deref().unwrap_or("NO ACTION");
648 let on_delete = rel.on_delete.as_deref().unwrap_or("NO ACTION");
649
650 let sql = format!(
651 "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({}) ON UPDATE {} ON DELETE {}",
652 from_full,
653 quote(constraint_name),
654 quote(from_col),
655 to_full,
656 quote(&to_col_name),
657 on_update,
658 on_delete
659 );
660 let _ = sqlx::query(&sql).execute(pool).await;
661 }
662
663 Ok(())
664}
665
666pub async fn revert_migrations(
669 pool: &Pool,
670 config: &FullConfig,
671 schema_override: Option<&str>,
672) -> Result<(), AppError> {
673 let default_sid = config
674 .schemas
675 .first()
676 .map(|s| s.id.as_str())
677 .ok_or_else(|| {
678 AppError::Config(crate::error::ConfigError::Validation(
679 "at least one schema required".into(),
680 ))
681 })?;
682
683 let schemas_by_id: HashMap<_, _> = config.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
684
685 for t in &config.tables {
687 let sid = t.schema_id.as_deref().unwrap_or(default_sid);
688 let schema = schemas_by_id.get(sid).ok_or_else(|| {
689 AppError::Config(crate::error::ConfigError::MissingReference {
690 kind: "schema",
691 id: sid.to_string(),
692 })
693 })?;
694 let schema_raw = schema_override.unwrap_or(&schema.name);
695 let schema_name = quote(schema_raw);
696 let table_name = quote(&t.name);
697 let full_name = format!("{}.{}", schema_name, table_name);
698 if t.audit_log {
699 let audit_full = format!("{}.{}", schema_name, quote(&format!("{}_audit", t.name)));
700 let _ = sqlx::query(&format!("DROP TABLE IF EXISTS {} CASCADE", audit_full))
701 .execute(pool)
702 .await;
703 }
704 if t.versioning.as_ref().is_some_and(|v| v.enabled) {
705 let history_full = format!("{}.{}", schema_name, quote(&format!("{}_history", t.name)));
706 let _ = sqlx::query(&format!("DROP TABLE IF EXISTS {} CASCADE", history_full))
707 .execute(pool)
708 .await;
709 }
710 let drop_sql = format!("DROP TABLE IF EXISTS {} CASCADE", full_name);
711 let _ = sqlx::query(&drop_sql).execute(pool).await;
712 }
713
714 for e in &config.enums {
716 let sid = e.schema_id.as_deref().unwrap_or(default_sid);
717 let schema = schemas_by_id.get(sid).ok_or_else(|| {
718 AppError::Config(crate::error::ConfigError::MissingReference {
719 kind: "schema",
720 id: sid.to_string(),
721 })
722 })?;
723 let schema_name = quote(schema_override.unwrap_or(&schema.name));
724 let type_name = quote(&e.name);
725 let drop_sql = format!("DROP TYPE IF EXISTS {}.{} CASCADE", schema_name, type_name);
726 let _ = sqlx::query(&drop_sql).execute(pool).await;
727 }
728
729 if schema_override.is_none() {
731 for s in &config.schemas {
732 if s.name.eq_ignore_ascii_case("public") {
733 continue;
734 }
735 let schema_name = quote(&s.name);
736 let drop_sql = format!("DROP SCHEMA IF EXISTS {} CASCADE", schema_name);
737 let _ = sqlx::query(&drop_sql).execute(pool).await;
738 }
739 }
740
741 Ok(())
742}
743
744fn add_column_ddl(dialect: &dyn Dialect, full_table: &str, col_def: &str) -> String {
751 if dialect.supports_add_column_if_not_exists() {
752 format!(
753 "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {}",
754 full_table, col_def
755 )
756 } else {
757 format!("ALTER TABLE {} ADD COLUMN {}", full_table, col_def)
758 }
759}
760
761#[derive(Debug, Clone, Serialize, Deserialize)]
764#[serde(rename_all = "snake_case")]
765pub enum MigrationOperation {
766 CreateSchema,
767 CreateEnum,
768 DropEnum,
769 AddEnumValue,
770 RemoveEnumValue,
771 CreateTable,
772 DropTable,
773 AddColumn,
774 DropColumn,
775 RenameColumn,
776 AlterColumnType,
777 BackfillNulls,
778 SetNotNull,
779 DropNotNull,
780 SetDefault,
781 DropDefault,
782 CreateIndex,
783 DropIndex,
784 AddForeignKey,
785 DropForeignKey,
786}
787
788impl std::fmt::Display for MigrationOperation {
789 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
790 let s = serde_json::to_value(self)
791 .ok()
792 .and_then(|v| v.as_str().map(String::from))
793 .unwrap_or_else(|| format!("{:?}", self));
794 write!(f, "{}", s)
795 }
796}
797
798#[derive(Debug, Clone, Serialize, Deserialize)]
800#[serde(rename_all = "snake_case")]
801pub enum MigrationSafety {
802 Safe,
804 BestEffort,
806 WarnOnly,
808}
809
810#[derive(Debug, Clone, Serialize, Deserialize)]
812#[serde(rename_all = "snake_case")]
813pub enum MigrationRisk {
814 None,
815 MayFail,
817 ExistingNullsMustBeAbsent,
819 DataWillBeModified,
821 ManualActionRequired,
823}
824
825#[derive(Debug, Clone, Serialize, Deserialize)]
827pub struct MigrationStep {
828 pub step: usize,
829 pub operation: MigrationOperation,
830 pub schema: String,
831 pub table: Option<String>,
832 pub object: String,
834 #[serde(default, skip_serializing_if = "Option::is_none")]
838 pub from_object: Option<String>,
839 pub object_type: String,
841 pub description: String,
842 pub ddl: Option<String>,
844 pub safety: MigrationSafety,
845 pub risk: MigrationRisk,
846 pub risk_detail: Option<String>,
847}
848
849#[derive(Debug, Clone, Serialize, Deserialize)]
851pub struct MigrationPlan {
852 pub steps: Vec<MigrationStep>,
853}
854
855#[derive(Debug, Clone, Serialize)]
856pub struct MigrationSummary {
857 pub total: usize,
858 pub safe: usize,
859 pub best_effort: usize,
860 pub warn_only: usize,
861}
862
863impl MigrationPlan {
864 pub fn summary(&self) -> MigrationSummary {
865 let (mut safe, mut best_effort, mut warn_only) = (0, 0, 0);
866 for s in &self.steps {
867 match s.safety {
868 MigrationSafety::Safe => safe += 1,
869 MigrationSafety::BestEffort => best_effort += 1,
870 MigrationSafety::WarnOnly => warn_only += 1,
871 }
872 }
873 MigrationSummary {
874 total: self.steps.len(),
875 safe,
876 best_effort,
877 warn_only,
878 }
879 }
880}
881
882pub struct MigrationExecutionResult {
884 pub applied: usize,
885 pub warned: usize,
886 pub skipped: usize,
888 pub warnings: Vec<String>,
889 pub skips: Vec<String>,
891}
892
893fn default_str(d: &ColumnDefaultConfig) -> String {
894 match d {
895 ColumnDefaultConfig::Literal(s) => s.clone(),
896 ColumnDefaultConfig::Expression { expression } => expression.clone(),
897 }
898}
899
900struct EnumColumnRef {
903 schema: String,
904 table: String,
905 column: String,
906 default: Option<String>,
907 is_array: bool,
908}
909
910fn enum_type_name(t: &crate::db::CanonicalType) -> Option<(String, bool)> {
913 use crate::db::CanonicalType;
914 let unqualified = |s: &str| s.rsplit('.').next().unwrap_or(s).to_string();
915 match t {
916 CanonicalType::Custom(s) => Some((unqualified(s), false)),
917 CanonicalType::Array(inner) => match inner.as_ref() {
918 CanonicalType::Custom(s) => Some((unqualified(s), true)),
919 _ => None,
920 },
921 _ => None,
922 }
923}
924
925fn enum_dependent_columns(
928 new_enum: &EnumConfig,
929 new: &FullConfig,
930 new_tables: &HashMap<&str, &TableConfig>,
931 new_schemas: &HashMap<&str, &SchemaConfig>,
932 schema_override: Option<&str>,
933) -> Vec<EnumColumnRef> {
934 let default_sid = new.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
935 let mut out = Vec::new();
936 for c in &new.columns {
937 let Some((tyname, is_array)) = enum_type_name(&parse_canonical(&c.type_)) else {
938 continue;
939 };
940 if tyname != new_enum.name {
941 continue;
942 }
943 let Some(table) = new_tables.get(c.table_id.as_str()) else {
944 continue;
945 };
946 let tsid = table.schema_id.as_deref().unwrap_or(default_sid);
947 let schema = schema_override.map(String::from).unwrap_or_else(|| {
948 new_schemas
949 .get(tsid)
950 .map(|s| s.name.clone())
951 .unwrap_or_else(|| tsid.to_string())
952 });
953 out.push(EnumColumnRef {
954 schema,
955 table: table.name.clone(),
956 column: c.name.clone(),
957 default: c.default.as_ref().map(default_str),
958 is_array,
959 });
960 }
961 out
962}
963
964fn recreate_enum_steps(
971 steps: &mut Vec<MigrationStep>,
972 schema: &str,
973 new_enum: &EnumConfig,
974 removed: &[&str],
975 dependents: &[EnumColumnRef],
976) {
977 let type_q = format!("{}.{}", quote(schema), quote(&new_enum.name));
978 let tmp_name = format!("{}__arch_old", new_enum.name);
979 let values: Vec<String> = new_enum
980 .values
981 .iter()
982 .map(|v| format!("'{}'", v.replace('\'', "''")))
983 .collect();
984
985 steps.push(MigrationStep {
987 step: 0,
988 operation: MigrationOperation::RemoveEnumValue,
989 schema: schema.to_string(),
990 table: None,
991 object: format!("{}:{}", new_enum.name, removed.join(",")),
992 object_type: "enum".into(),
993 from_object: None,
994 description: format!(
995 "Rebuild enum \"{}\".\"{}\" to remove value(s): {}",
996 schema,
997 new_enum.name,
998 removed.join(", ")
999 ),
1000 ddl: None,
1001 safety: MigrationSafety::WarnOnly,
1002 risk: MigrationRisk::ManualActionRequired,
1003 risk_detail: Some(format!(
1004 "PostgreSQL cannot drop enum values in place. The type is rebuilt and {} dependent \
1005 column(s) are recast via a text cast. Any existing row holding a removed value ({}) \
1006 will make its recast fail — reassign those rows first.",
1007 dependents.len(),
1008 removed.join(", ")
1009 )),
1010 });
1011
1012 steps.push(MigrationStep {
1014 step: 0,
1015 operation: MigrationOperation::DropEnum,
1016 schema: schema.to_string(),
1017 table: None,
1018 object: new_enum.name.clone(),
1019 object_type: "enum".into(),
1020 from_object: None,
1021 description: format!(
1022 "Rename enum \"{}\".\"{}\" to \"{}\" before rebuild",
1023 schema, new_enum.name, tmp_name
1024 ),
1025 ddl: Some(format!(
1026 "ALTER TYPE {} RENAME TO {}",
1027 type_q,
1028 quote(&tmp_name)
1029 )),
1030 safety: MigrationSafety::BestEffort,
1031 risk: MigrationRisk::None,
1032 risk_detail: None,
1033 });
1034
1035 steps.push(MigrationStep {
1037 step: 0,
1038 operation: MigrationOperation::CreateEnum,
1039 schema: schema.to_string(),
1040 table: None,
1041 object: new_enum.name.clone(),
1042 object_type: "enum".into(),
1043 from_object: None,
1044 description: format!(
1045 "Recreate enum \"{}\".\"{}\" with {} value(s)",
1046 schema,
1047 new_enum.name,
1048 new_enum.values.len()
1049 ),
1050 ddl: Some(format!(
1051 "CREATE TYPE {} AS ENUM ({})",
1052 type_q,
1053 values.join(", ")
1054 )),
1055 safety: MigrationSafety::BestEffort,
1056 risk: MigrationRisk::None,
1057 risk_detail: None,
1058 });
1059
1060 for dep in dependents {
1063 let table_q = format!("{}.{}", quote(&dep.schema), quote(&dep.table));
1064 let col_q = quote(&dep.column);
1065
1066 if dep.default.is_some() {
1067 steps.push(MigrationStep {
1068 step: 0,
1069 operation: MigrationOperation::DropDefault,
1070 schema: dep.schema.clone(),
1071 table: Some(dep.table.clone()),
1072 object: dep.column.clone(),
1073 object_type: "column".into(),
1074 from_object: None,
1075 description: format!(
1076 "Drop default on {}.{} before enum recast",
1077 dep.table, dep.column
1078 ),
1079 ddl: Some(format!(
1080 "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT",
1081 table_q, col_q
1082 )),
1083 safety: MigrationSafety::BestEffort,
1084 risk: MigrationRisk::None,
1085 risk_detail: None,
1086 });
1087 }
1088
1089 let (col_type, using) = if dep.is_array {
1090 (
1091 format!("{}[]", type_q),
1092 format!("{}::text[]::{}[]", col_q, type_q),
1093 )
1094 } else {
1095 (type_q.clone(), format!("{}::text::{}", col_q, type_q))
1096 };
1097 steps.push(MigrationStep {
1098 step: 0,
1099 operation: MigrationOperation::AlterColumnType,
1100 schema: dep.schema.clone(),
1101 table: Some(dep.table.clone()),
1102 object: dep.column.clone(),
1103 object_type: "column".into(),
1104 from_object: None,
1105 description: format!(
1106 "Recast {}.{} onto rebuilt enum \"{}\"",
1107 dep.table, dep.column, new_enum.name
1108 ),
1109 ddl: Some(format!(
1110 "ALTER TABLE {} ALTER COLUMN {} TYPE {} USING {}",
1111 table_q, col_q, col_type, using
1112 )),
1113 safety: MigrationSafety::BestEffort,
1114 risk: MigrationRisk::MayFail,
1115 risk_detail: Some(format!(
1116 "Cast fails if any row holds a removed value ({}).",
1117 removed.join(", ")
1118 )),
1119 });
1120
1121 if let Some(def) = &dep.default {
1122 steps.push(MigrationStep {
1123 step: 0,
1124 operation: MigrationOperation::SetDefault,
1125 schema: dep.schema.clone(),
1126 table: Some(dep.table.clone()),
1127 object: dep.column.clone(),
1128 object_type: "column".into(),
1129 from_object: None,
1130 description: format!(
1131 "Restore default on {}.{} after enum recast",
1132 dep.table, dep.column
1133 ),
1134 ddl: Some(format!(
1135 "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}",
1136 table_q, col_q, def
1137 )),
1138 safety: MigrationSafety::BestEffort,
1139 risk: MigrationRisk::None,
1140 risk_detail: None,
1141 });
1142 }
1143 }
1144
1145 steps.push(MigrationStep {
1147 step: 0,
1148 operation: MigrationOperation::DropEnum,
1149 schema: schema.to_string(),
1150 table: None,
1151 object: tmp_name.clone(),
1152 object_type: "enum".into(),
1153 from_object: None,
1154 description: format!("Drop superseded enum \"{}\".\"{}\"", schema, tmp_name),
1155 ddl: Some(format!(
1156 "DROP TYPE IF EXISTS {}.{}",
1157 quote(schema),
1158 quote(&tmp_name)
1159 )),
1160 safety: MigrationSafety::BestEffort,
1161 risk: MigrationRisk::None,
1162 risk_detail: None,
1163 });
1164}
1165
1166pub fn compute_migration_plan(
1172 old: &FullConfig,
1173 new: &FullConfig,
1174 schema_override: Option<&str>,
1175 _rls_tenant_column: Option<&str>,
1176 dialect: &dyn Dialect,
1177 cross_package_configs: &HashMap<String, FullConfig>,
1178) -> Result<MigrationPlan, AppError> {
1179 validate(new)?;
1180
1181 let default_old_sid = old.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
1182 let default_new_sid = new.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
1183
1184 let old_schemas: HashMap<&str, &SchemaConfig> =
1185 old.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
1186 let new_schemas: HashMap<&str, &SchemaConfig> =
1187 new.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
1188 let old_tables: HashMap<&str, &TableConfig> =
1189 old.tables.iter().map(|t| (t.id.as_str(), t)).collect();
1190 let new_tables: HashMap<&str, &TableConfig> =
1191 new.tables.iter().map(|t| (t.id.as_str(), t)).collect();
1192 let old_columns: HashMap<&str, &ColumnConfig> =
1193 old.columns.iter().map(|c| (c.id.as_str(), c)).collect();
1194 let old_enums: HashMap<&str, &EnumConfig> =
1195 old.enums.iter().map(|e| (e.id.as_str(), e)).collect();
1196 let new_enums: HashMap<&str, &EnumConfig> =
1197 new.enums.iter().map(|e| (e.id.as_str(), e)).collect();
1198 let old_indexes: HashMap<&str, &IndexConfig> =
1199 old.indexes.iter().map(|i| (i.id.as_str(), i)).collect();
1200 let new_indexes: HashMap<&str, &IndexConfig> =
1201 new.indexes.iter().map(|i| (i.id.as_str(), i)).collect();
1202 let old_rels: HashMap<&str, &RelationshipConfig> = old
1203 .relationships
1204 .iter()
1205 .map(|r| (r.id.as_str(), r))
1206 .collect();
1207 let new_rels: HashMap<&str, &RelationshipConfig> = new
1208 .relationships
1209 .iter()
1210 .map(|r| (r.id.as_str(), r))
1211 .collect();
1212
1213 let mut steps: Vec<MigrationStep> = Vec::new();
1214
1215 let schema_name_for = |sid: &str, schemas: &HashMap<&str, &SchemaConfig>| -> String {
1216 schema_override.map(String::from).unwrap_or_else(|| {
1217 schemas
1218 .get(sid)
1219 .map(|s| s.name.clone())
1220 .unwrap_or_else(|| sid.to_string())
1221 })
1222 };
1223
1224 if schema_override.is_none() {
1226 for s in &new.schemas {
1227 if !old_schemas.contains_key(s.id.as_str()) {
1228 steps.push(MigrationStep {
1229 step: 0,
1230 operation: MigrationOperation::CreateSchema,
1231 schema: s.name.clone(),
1232 table: None,
1233 object: s.name.clone(),
1234 object_type: "schema".into(),
1235 from_object: None,
1236 description: format!("Create schema \"{}\"", s.name),
1237 ddl: Some(format!("CREATE SCHEMA IF NOT EXISTS {}", quote(&s.name))),
1238 safety: MigrationSafety::Safe,
1239 risk: MigrationRisk::None,
1240 risk_detail: None,
1241 });
1242 }
1243 }
1244 }
1245
1246 for new_enum in &new.enums {
1248 let sid = new_enum.schema_id.as_deref().unwrap_or(default_new_sid);
1249 let schema = schema_name_for(sid, &new_schemas);
1250
1251 if let Some(old_enum) = old_enums.get(new_enum.id.as_str()) {
1252 let old_vals: HashSet<&str> = old_enum.values.iter().map(String::as_str).collect();
1253 let new_vals: HashSet<&str> = new_enum.values.iter().map(String::as_str).collect();
1254 let removed: Vec<&str> = old_enum
1255 .values
1256 .iter()
1257 .map(String::as_str)
1258 .filter(|v| !new_vals.contains(v))
1259 .collect();
1260
1261 if removed.is_empty() {
1262 for val in new_enum
1264 .values
1265 .iter()
1266 .map(String::as_str)
1267 .filter(|v| !old_vals.contains(v))
1268 {
1269 steps.push(MigrationStep {
1270 step: 0,
1271 operation: MigrationOperation::AddEnumValue,
1272 schema: schema.clone(),
1273 table: None,
1274 object: format!("{}:{}", new_enum.name, val),
1275 object_type: "enum_value".into(),
1276 from_object: None,
1277 description: format!(
1278 "Add value '{}' to enum \"{}\".\"{}\"",
1279 val, schema, new_enum.name
1280 ),
1281 ddl: Some(format!(
1282 "ALTER TYPE {}.{} ADD VALUE IF NOT EXISTS '{}'",
1283 quote(&schema),
1284 quote(&new_enum.name),
1285 val.replace('\'', "''")
1286 )),
1287 safety: MigrationSafety::Safe,
1288 risk: MigrationRisk::None,
1289 risk_detail: None,
1290 });
1291 }
1292 } else {
1293 let dependents = enum_dependent_columns(
1297 new_enum,
1298 new,
1299 &new_tables,
1300 &new_schemas,
1301 schema_override,
1302 );
1303 recreate_enum_steps(&mut steps, &schema, new_enum, &removed, &dependents);
1304 }
1305 } else {
1306 let values: Vec<String> = new_enum
1307 .values
1308 .iter()
1309 .map(|v| format!("'{}'", v.replace('\'', "''")))
1310 .collect();
1311 steps.push(MigrationStep {
1312 step: 0,
1313 operation: MigrationOperation::CreateEnum,
1314 schema: schema.clone(),
1315 table: None,
1316 object: new_enum.name.clone(),
1317 object_type: "enum".into(),
1318 from_object: None,
1319 description: format!("Create enum type \"{}\".\"{}\"", schema, new_enum.name),
1320 ddl: Some(format!("CREATE TYPE {}.{} AS ENUM ({})", quote(&schema), quote(&new_enum.name), values.join(", "))),
1321 safety: MigrationSafety::BestEffort,
1322 risk: MigrationRisk::None,
1323 risk_detail: Some("PostgreSQL has no CREATE TYPE IF NOT EXISTS; ignored if the type already exists.".into()),
1324 });
1325 }
1326 }
1327 for old_enum in &old.enums {
1328 if !new_enums.contains_key(old_enum.id.as_str()) {
1329 let sid = old_enum.schema_id.as_deref().unwrap_or(default_old_sid);
1330 let schema = schema_name_for(sid, &old_schemas);
1331 steps.push(MigrationStep {
1332 step: 0,
1333 operation: MigrationOperation::DropEnum,
1334 schema: schema.clone(),
1335 table: None,
1336 object: old_enum.name.clone(),
1337 object_type: "enum".into(),
1338 from_object: None,
1339 description: format!("Enum \"{}\".\"{}\" removed from config", schema, old_enum.name),
1340 ddl: None,
1341 safety: MigrationSafety::WarnOnly,
1342 risk: MigrationRisk::ManualActionRequired,
1343 risk_detail: Some("Enum type NOT dropped from database (data safety). Run DROP TYPE manually if intended.".into()),
1344 });
1345 }
1346 }
1347
1348 let added_table_ids: HashSet<&str> = new
1350 .tables
1351 .iter()
1352 .filter(|t| !old_tables.contains_key(t.id.as_str()))
1353 .map(|t| t.id.as_str())
1354 .collect();
1355
1356 let cols_by_table: HashMap<&str, Vec<&ColumnConfig>> =
1357 new.columns.iter().fold(HashMap::new(), |mut m, c| {
1358 m.entry(c.table_id.as_str()).or_default().push(c);
1359 m
1360 });
1361
1362 for new_table in &new.tables {
1363 if !added_table_ids.contains(new_table.id.as_str()) {
1364 continue;
1365 }
1366 let sid = new_table.schema_id.as_deref().unwrap_or(default_new_sid);
1367 let schema = schema_name_for(sid, &new_schemas);
1368 let full = format!("{}.{}", quote(&schema), quote(&new_table.name));
1369
1370 let cols = cols_by_table
1371 .get(new_table.id.as_str())
1372 .map(|v| v.as_slice())
1373 .unwrap_or(&[]);
1374 let mut col_defs: Vec<String> = Vec::new();
1375 for c in cols {
1376 let typ = dialect.ddl_type(&parse_canonical(&c.type_));
1377 let mut def = format!("{} {}", quote(&c.name), typ);
1378 if !c.nullable {
1379 def.push_str(" NOT NULL");
1380 }
1381 if let Some(ref d) = c.default {
1382 def.push_str(" DEFAULT ");
1383 match d {
1384 ColumnDefaultConfig::Literal(s) => def.push_str(s),
1385 ColumnDefaultConfig::Expression { expression } => def.push_str(expression),
1386 }
1387 }
1388 col_defs.push(def);
1389 }
1390 let cfg_col_names: HashSet<&str> = cols.iter().map(|c| c.name.as_str()).collect();
1391 for (name, suf) in [
1396 ("created_at", "TIMESTAMPTZ NOT NULL DEFAULT NOW()"),
1397 ("updated_at", "TIMESTAMPTZ NOT NULL DEFAULT NOW()"),
1398 ("archived_at", "TIMESTAMPTZ"),
1399 ("created_by", "TEXT"),
1400 ("updated_by", "TEXT"),
1401 ] {
1402 if !cfg_col_names.contains(name) {
1403 col_defs.push(format!("{} {}", quote(name), suf));
1404 }
1405 }
1406 let pk_cols = match &new_table.primary_key {
1407 PrimaryKeyConfig::Single(s) => vec![quote(s)],
1408 PrimaryKeyConfig::Composite(v) => v.iter().map(|s| quote(s)).collect(),
1409 };
1410 col_defs.push(format!("PRIMARY KEY ({})", pk_cols.join(", ")));
1411 for u in &new_table.unique {
1412 col_defs.push(format!(
1413 "UNIQUE ({})",
1414 u.iter().map(|s| quote(s)).collect::<Vec<_>>().join(", ")
1415 ));
1416 }
1417 for ch in &new_table.check {
1418 col_defs.push(format!(
1419 "CONSTRAINT {} CHECK ({})",
1420 quote(&ch.name),
1421 ch.expression
1422 ));
1423 }
1424
1425 steps.push(MigrationStep {
1426 step: 0,
1427 operation: MigrationOperation::CreateTable,
1428 schema: schema.clone(),
1429 table: Some(new_table.name.clone()),
1430 object: new_table.name.clone(),
1431 object_type: "table".into(),
1432 from_object: None,
1433 description: format!("Create table \"{}\".\"{}\"", schema, new_table.name),
1434 ddl: Some(format!(
1435 "CREATE TABLE IF NOT EXISTS {} (\n {}\n)",
1436 full,
1437 col_defs.join(",\n ")
1438 )),
1439 safety: MigrationSafety::Safe,
1440 risk: MigrationRisk::None,
1441 risk_detail: None,
1442 });
1443 if new_table.audit_log {
1444 let audit_ddl = audit_table_ddl(&schema, &new_table.name, cols, dialect);
1445 steps.push(MigrationStep {
1446 step: 0,
1447 operation: MigrationOperation::CreateTable,
1448 schema: schema.clone(),
1449 table: Some(format!("{}_audit", new_table.name)),
1450 object: format!("{}_audit", new_table.name),
1451 object_type: "table".into(),
1452 from_object: None,
1453 description: format!(
1454 "Create audit table \"{}\".\"{}_audit\"",
1455 schema, new_table.name
1456 ),
1457 ddl: Some(audit_ddl),
1458 safety: MigrationSafety::Safe,
1459 risk: MigrationRisk::None,
1460 risk_detail: None,
1461 });
1462 }
1463 if new_table.versioning.as_ref().is_some_and(|v| v.enabled) {
1464 let pk_col = match &new_table.primary_key {
1465 PrimaryKeyConfig::Single(s) => s.clone(),
1466 PrimaryKeyConfig::Composite(v) => v[0].clone(),
1467 };
1468 let history_create = format!(
1470 "CREATE TABLE IF NOT EXISTS {}.{} (\n {}\n)",
1471 quote(&schema),
1472 quote(&format!("{}_history", new_table.name)),
1473 {
1474 let full_ddl =
1475 history_table_ddl(&schema, &new_table.name, &pk_col, cols, dialect);
1476 full_ddl
1477 .lines()
1478 .skip(1) .take_while(|l| !l.trim_start().starts_with("-- index:"))
1480 .collect::<Vec<_>>()
1481 .join("\n")
1482 .trim_end_matches(['\n', ',', ')'])
1483 .to_string()
1484 + "\n)"
1485 }
1486 );
1487 steps.push(MigrationStep {
1488 step: 0,
1489 operation: MigrationOperation::CreateTable,
1490 schema: schema.clone(),
1491 table: Some(format!("{}_history", new_table.name)),
1492 object: format!("{}_history", new_table.name),
1493 object_type: "table".into(),
1494 from_object: None,
1495 description: format!(
1496 "Create history table \"{}\".\"{}_history\" (versioning)",
1497 schema, new_table.name
1498 ),
1499 ddl: Some(history_create),
1500 safety: MigrationSafety::Safe,
1501 risk: MigrationRisk::None,
1502 risk_detail: None,
1503 });
1504 steps.push(MigrationStep {
1505 step: 0,
1506 operation: MigrationOperation::CreateIndex,
1507 schema: schema.clone(),
1508 table: Some(format!("{}_history", new_table.name)),
1509 object: format!("{}_history_{}_idx", new_table.name, pk_col),
1510 object_type: "index".into(),
1511 from_object: None,
1512 description: format!(
1513 "Create index on history table \"{}\".\"{}\" ({pk_col}, _version DESC)",
1514 schema, new_table.name
1515 ),
1516 ddl: Some(history_index_ddl(&schema, &new_table.name, &pk_col)),
1517 safety: MigrationSafety::Safe,
1518 risk: MigrationRisk::None,
1519 risk_detail: None,
1520 });
1521 }
1522 }
1523
1524 for new_table in &new.tables {
1526 if added_table_ids.contains(new_table.id.as_str()) {
1527 continue;
1528 }
1529 if let Some(old_table) = old_tables.get(new_table.id.as_str()) {
1530 let sid = new_table.schema_id.as_deref().unwrap_or(default_new_sid);
1531 let schema = schema_name_for(sid, &new_schemas);
1532 let cols = cols_by_table
1533 .get(new_table.id.as_str())
1534 .map(|v| v.as_slice())
1535 .unwrap_or(&[]);
1536
1537 if !old_table.audit_log && new_table.audit_log {
1538 let audit_ddl = audit_table_ddl(&schema, &new_table.name, cols, dialect);
1539 steps.push(MigrationStep {
1540 step: 0,
1541 operation: MigrationOperation::CreateTable,
1542 schema: schema.clone(),
1543 table: Some(format!("{}_audit", new_table.name)),
1544 object: format!("{}_audit", new_table.name),
1545 object_type: "table".into(),
1546 from_object: None,
1547 description: format!(
1548 "Enable audit log: create \"{}\".\"{}_audit\"",
1549 schema, new_table.name
1550 ),
1551 ddl: Some(audit_ddl),
1552 safety: MigrationSafety::Safe,
1553 risk: MigrationRisk::None,
1554 risk_detail: None,
1555 });
1556 }
1557
1558 let old_versioning_enabled = old_table.versioning.as_ref().is_some_and(|v| v.enabled);
1559 let new_versioning_enabled = new_table.versioning.as_ref().is_some_and(|v| v.enabled);
1560 if !old_versioning_enabled && new_versioning_enabled {
1561 let pk_col = match &new_table.primary_key {
1562 PrimaryKeyConfig::Single(s) => s.clone(),
1563 PrimaryKeyConfig::Composite(v) => v[0].clone(),
1564 };
1565 let history_ddl =
1566 history_table_ddl(&schema, &new_table.name, &pk_col, cols, dialect);
1567 let create_only = history_ddl
1568 .lines()
1569 .take_while(|l| !l.trim_start().starts_with("-- index:"))
1570 .collect::<Vec<_>>()
1571 .join("\n");
1572 steps.push(MigrationStep {
1573 step: 0,
1574 operation: MigrationOperation::CreateTable,
1575 schema: schema.clone(),
1576 table: Some(format!("{}_history", new_table.name)),
1577 object: format!("{}_history", new_table.name),
1578 object_type: "table".into(),
1579 from_object: None,
1580 description: format!(
1581 "Enable versioning: create \"{}\".\"{}_history\"",
1582 schema, new_table.name
1583 ),
1584 ddl: Some(create_only.trim().to_string()),
1585 safety: MigrationSafety::Safe,
1586 risk: MigrationRisk::None,
1587 risk_detail: None,
1588 });
1589 steps.push(MigrationStep {
1590 step: 0,
1591 operation: MigrationOperation::CreateIndex,
1592 schema: schema.clone(),
1593 table: Some(format!("{}_history", new_table.name)),
1594 object: format!("{}_history_{}_idx", new_table.name, pk_col),
1595 object_type: "index".into(),
1596 from_object: None,
1597 description: format!(
1598 "Create history index on \"{}\".\"{}\"",
1599 schema, new_table.name
1600 ),
1601 ddl: Some(history_index_ddl(&schema, &new_table.name, &pk_col)),
1602 safety: MigrationSafety::Safe,
1603 risk: MigrationRisk::None,
1604 risk_detail: None,
1605 });
1606 }
1607 }
1608 }
1609
1610 for old_table in &old.tables {
1611 if !new_tables.contains_key(old_table.id.as_str()) {
1612 let sid = old_table.schema_id.as_deref().unwrap_or(default_old_sid);
1613 let schema = schema_name_for(sid, &old_schemas);
1614 steps.push(MigrationStep {
1615 step: 0,
1616 operation: MigrationOperation::DropTable,
1617 schema: schema.clone(),
1618 table: Some(old_table.name.clone()),
1619 object: old_table.name.clone(),
1620 object_type: "table".into(),
1621 from_object: None,
1622 description: format!("Table \"{}\".\"{}\" removed from config", schema, old_table.name),
1623 ddl: None,
1624 safety: MigrationSafety::WarnOnly,
1625 risk: MigrationRisk::ManualActionRequired,
1626 risk_detail: Some("Table NOT dropped from database (data safety). Run DROP TABLE manually if intended.".into()),
1627 });
1628 }
1629 }
1630
1631 for new_col in &new.columns {
1633 if added_table_ids.contains(new_col.table_id.as_str()) {
1634 continue;
1635 }
1636 let table = match new_tables.get(new_col.table_id.as_str()) {
1637 Some(t) => t,
1638 None => continue,
1639 };
1640 let sid = table.schema_id.as_deref().unwrap_or(default_new_sid);
1641 let schema = schema_name_for(sid, &new_schemas);
1642 let full = format!("{}.{}", quote(&schema), quote(&table.name));
1643
1644 if let Some(old_col) = old_columns.get(new_col.id.as_str()) {
1645 if old_col.table_id != new_col.table_id {
1646 steps.push(MigrationStep {
1647 step: 0,
1648 operation: MigrationOperation::AddColumn,
1649 schema: schema.clone(),
1650 table: Some(table.name.clone()),
1651 object: new_col.name.clone(),
1652 object_type: "column".into(),
1653 from_object: None,
1654 description: format!("Column \"{}\" (id: {}) appears to have moved tables — manual migration required", new_col.name, new_col.id),
1655 ddl: None,
1656 safety: MigrationSafety::WarnOnly,
1657 risk: MigrationRisk::ManualActionRequired,
1658 risk_detail: Some(format!("Cannot automate column move from table {} to {}.", old_col.table_id, new_col.table_id)),
1659 });
1660 continue;
1661 }
1662
1663 if old_col.name != new_col.name {
1665 steps.push(MigrationStep {
1666 step: 0,
1667 operation: MigrationOperation::RenameColumn,
1668 schema: schema.clone(),
1669 table: Some(table.name.clone()),
1670 object: new_col.name.clone(),
1671 object_type: "column".into(),
1672 from_object: Some(old_col.name.clone()),
1673 description: format!(
1674 "Rename column \"{}\" → \"{}\" on \"{}\".\"{}\"",
1675 old_col.name, new_col.name, schema, table.name
1676 ),
1677 ddl: Some(format!(
1678 "ALTER TABLE {} RENAME COLUMN {} TO {}",
1679 full,
1680 quote(&old_col.name),
1681 quote(&new_col.name)
1682 )),
1683 safety: MigrationSafety::Safe,
1684 risk: MigrationRisk::None,
1685 risk_detail: None,
1686 });
1687 steps.extend(companion_column_steps(
1688 &schema,
1689 table,
1690 &CompanionColumnOp::Rename {
1691 old: &old_col.name,
1692 new: &new_col.name,
1693 },
1694 dialect,
1695 ));
1696 }
1697
1698 let old_type = dialect.ddl_type(&parse_canonical(&old_col.type_));
1700 let new_type = dialect.ddl_type(&parse_canonical(&new_col.type_));
1701 if old_type.to_uppercase() != new_type.to_uppercase() {
1702 let col_name = &new_col.name;
1703 steps.push(MigrationStep {
1704 step: 0,
1705 operation: MigrationOperation::AlterColumnType,
1706 schema: schema.clone(),
1707 table: Some(table.name.clone()),
1708 object: col_name.clone(),
1709 object_type: "column".into(),
1710 from_object: None,
1711 description: format!("Change type of \"{}\".\"{}\".\"{}\": {} → {}", schema, table.name, col_name, old_type, new_type),
1712 ddl: Some(format!("ALTER TABLE {} ALTER COLUMN {} TYPE {} USING {}::{}", full, quote(col_name), new_type, quote(col_name), new_type)),
1713 safety: MigrationSafety::BestEffort,
1714 risk: MigrationRisk::MayFail,
1715 risk_detail: Some(format!("USING {}::{} cast may fail for incompatible values. Provide a custom USING expression if needed.", col_name, new_type)),
1716 });
1717 steps.extend(companion_column_steps(
1718 &schema,
1719 table,
1720 &CompanionColumnOp::AlterType {
1721 name: col_name.as_str(),
1722 ty: &new_type,
1723 },
1724 dialect,
1725 ));
1726 }
1727
1728 if old_col.nullable && !new_col.nullable {
1730 if let Some(ref d) = new_col.default {
1731 let default_val = default_str(d);
1732 steps.push(MigrationStep {
1734 step: 0,
1735 operation: MigrationOperation::BackfillNulls,
1736 schema: schema.clone(),
1737 table: Some(table.name.clone()),
1738 object: new_col.name.clone(),
1739 object_type: "column".into(),
1740 from_object: None,
1741 description: format!("Backfill NULLs in \"{}\".\"{}\".\"{}\": SET {} = {} WHERE {} IS NULL", schema, table.name, new_col.name, new_col.name, default_val, new_col.name),
1742 ddl: Some(format!("UPDATE {} SET {} = {} WHERE {} IS NULL", full, quote(&new_col.name), default_val, quote(&new_col.name))),
1743 safety: MigrationSafety::Safe,
1744 risk: MigrationRisk::DataWillBeModified,
1745 risk_detail: Some(format!("Existing NULLs in column \"{}\" will be set to {} before NOT NULL is enforced.", new_col.name, default_val)),
1746 });
1747 steps.push(MigrationStep {
1749 step: 0,
1750 operation: MigrationOperation::SetNotNull,
1751 schema: schema.clone(),
1752 table: Some(table.name.clone()),
1753 object: new_col.name.clone(),
1754 object_type: "column".into(),
1755 from_object: None,
1756 description: format!("Set NOT NULL on \"{}\".\"{}\".\"{}\": NULLs pre-filled with default ({})", schema, table.name, new_col.name, default_val),
1757 ddl: Some(format!("ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", full, quote(&new_col.name))),
1758 safety: MigrationSafety::Safe,
1759 risk: MigrationRisk::None,
1760 risk_detail: None,
1761 });
1762 } else {
1763 steps.push(MigrationStep {
1765 step: 0,
1766 operation: MigrationOperation::SetNotNull,
1767 schema: schema.clone(),
1768 table: Some(table.name.clone()),
1769 object: new_col.name.clone(),
1770 object_type: "column".into(),
1771 from_object: None,
1772 description: format!("Set NOT NULL on \"{}\".\"{}\".\"{}\": no default configured — will fail if NULLs exist", schema, table.name, new_col.name),
1773 ddl: Some(format!("ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", full, quote(&new_col.name))),
1774 safety: MigrationSafety::BestEffort,
1775 risk: MigrationRisk::ExistingNullsMustBeAbsent,
1776 risk_detail: Some(format!(
1777 "No default value configured for column \"{}\". Add a default to the config to enable automatic NULL backfill before enforcing NOT NULL.",
1778 new_col.name
1779 )),
1780 });
1781 }
1782 }
1783
1784 if !old_col.nullable && new_col.nullable {
1786 steps.push(MigrationStep {
1787 step: 0,
1788 operation: MigrationOperation::DropNotNull,
1789 schema: schema.clone(),
1790 table: Some(table.name.clone()),
1791 object: new_col.name.clone(),
1792 object_type: "column".into(),
1793 from_object: None,
1794 description: format!(
1795 "Drop NOT NULL on \"{}\".\"{}\".\"{}\": column becomes nullable",
1796 schema, table.name, new_col.name
1797 ),
1798 ddl: Some(format!(
1799 "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL",
1800 full,
1801 quote(&new_col.name)
1802 )),
1803 safety: MigrationSafety::Safe,
1804 risk: MigrationRisk::None,
1805 risk_detail: None,
1806 });
1807 }
1808
1809 let old_def = old_col.default.as_ref().map(default_str);
1811 let new_def = new_col.default.as_ref().map(default_str);
1812 if old_def != new_def {
1813 match &new_col.default {
1814 Some(d) => {
1815 let val = default_str(d);
1816 steps.push(MigrationStep {
1817 step: 0,
1818 operation: MigrationOperation::SetDefault,
1819 schema: schema.clone(),
1820 table: Some(table.name.clone()),
1821 object: new_col.name.clone(),
1822 object_type: "column".into(),
1823 from_object: None,
1824 description: format!(
1825 "Set DEFAULT {} on \"{}\".\"{}\".\"{}\": was {}",
1826 val,
1827 schema,
1828 table.name,
1829 new_col.name,
1830 old_def.as_deref().unwrap_or("none")
1831 ),
1832 ddl: Some(format!(
1833 "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}",
1834 full,
1835 quote(&new_col.name),
1836 val
1837 )),
1838 safety: MigrationSafety::Safe,
1839 risk: MigrationRisk::None,
1840 risk_detail: None,
1841 });
1842 }
1843 None => {
1844 steps.push(MigrationStep {
1845 step: 0,
1846 operation: MigrationOperation::DropDefault,
1847 schema: schema.clone(),
1848 table: Some(table.name.clone()),
1849 object: new_col.name.clone(),
1850 object_type: "column".into(),
1851 from_object: None,
1852 description: format!(
1853 "Drop DEFAULT on \"{}\".\"{}\".\"{}\": was {}",
1854 schema,
1855 table.name,
1856 new_col.name,
1857 old_def.as_deref().unwrap_or("none")
1858 ),
1859 ddl: Some(format!(
1860 "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT",
1861 full,
1862 quote(&new_col.name)
1863 )),
1864 safety: MigrationSafety::Safe,
1865 risk: MigrationRisk::None,
1866 risk_detail: None,
1867 });
1868 }
1869 }
1870 }
1871 } else {
1872 let new_type = dialect.ddl_type(&parse_canonical(&new_col.type_));
1874 let mut col_def = format!("{} {}", quote(&new_col.name), new_type);
1875 if !new_col.nullable {
1876 col_def.push_str(" NOT NULL");
1877 }
1878 if let Some(ref d) = new_col.default {
1879 col_def.push_str(" DEFAULT ");
1880 match d {
1881 ColumnDefaultConfig::Literal(s) => col_def.push_str(s),
1882 ColumnDefaultConfig::Expression { expression } => col_def.push_str(expression),
1883 }
1884 }
1885 steps.push(MigrationStep {
1886 step: 0,
1887 operation: MigrationOperation::AddColumn,
1888 schema: schema.clone(),
1889 table: Some(table.name.clone()),
1890 object: new_col.name.clone(),
1891 object_type: "column".into(),
1892 from_object: None,
1893 description: format!(
1894 "Add column \"{}\" {} to \"{}\".\"{}\"",
1895 new_col.name, new_type, schema, table.name
1896 ),
1897 ddl: Some(add_column_ddl(dialect, &full, &col_def)),
1898 safety: MigrationSafety::Safe,
1899 risk: MigrationRisk::None,
1900 risk_detail: None,
1901 });
1902 steps.extend(companion_column_steps(
1903 &schema,
1904 table,
1905 &CompanionColumnOp::Add {
1906 name: &new_col.name,
1907 ty: &new_type,
1908 },
1909 dialect,
1910 ));
1911 }
1912 }
1913
1914 for old_col in &old.columns {
1916 if new.columns.iter().any(|c| c.id == old_col.id) {
1917 continue;
1918 }
1919 if !new_tables.contains_key(old_col.table_id.as_str()) {
1920 continue;
1921 }
1922 let table_name = old_tables
1923 .get(old_col.table_id.as_str())
1924 .map(|t| t.name.as_str())
1925 .unwrap_or(&old_col.table_id);
1926 let sid = old_tables
1927 .get(old_col.table_id.as_str())
1928 .and_then(|t| t.schema_id.as_deref())
1929 .unwrap_or(default_old_sid);
1930 let schema = schema_name_for(sid, &old_schemas);
1931 steps.push(MigrationStep {
1932 step: 0,
1933 operation: MigrationOperation::DropColumn,
1934 schema: schema.clone(),
1935 table: Some(table_name.to_string()),
1936 object: old_col.name.clone(),
1937 object_type: "column".into(),
1938 from_object: None,
1939 description: format!("Column \"{}\" removed from config on \"{}\".\"{}\"", old_col.name, schema, table_name),
1940 ddl: None,
1941 safety: MigrationSafety::WarnOnly,
1942 risk: MigrationRisk::ManualActionRequired,
1943 risk_detail: Some("Column NOT dropped from database (data safety). Run ALTER TABLE DROP COLUMN manually if intended.".into()),
1944 });
1945 }
1946
1947 for old_idx in &old.indexes {
1949 if !new_indexes.contains_key(old_idx.id.as_str()) {
1950 let sid = old_idx.schema_id.as_deref().unwrap_or(default_old_sid);
1951 let schema = schema_name_for(sid, &old_schemas);
1952 steps.push(MigrationStep {
1953 step: 0,
1954 operation: MigrationOperation::DropIndex,
1955 schema: schema.clone(),
1956 table: old_tables
1957 .get(old_idx.table_id.as_str())
1958 .map(|t| t.name.clone()),
1959 object: old_idx.name.clone(),
1960 object_type: "index".into(),
1961 from_object: None,
1962 description: format!("Drop index \"{}\" in schema \"{}\"", old_idx.name, schema),
1963 ddl: Some(format!(
1964 "DROP INDEX IF EXISTS {}.{}",
1965 quote(&schema),
1966 quote(&old_idx.name)
1967 )),
1968 safety: MigrationSafety::Safe,
1969 risk: MigrationRisk::None,
1970 risk_detail: None,
1971 });
1972 }
1973 }
1974 for new_idx in &new.indexes {
1975 if old_indexes.contains_key(new_idx.id.as_str())
1976 || added_table_ids.contains(new_idx.table_id.as_str())
1977 {
1978 continue;
1979 }
1980 let sid = new_idx.schema_id.as_deref().unwrap_or(default_new_sid);
1981 let schema = match new_schemas.get(sid) {
1982 Some(s) => schema_override.unwrap_or(&s.name).to_string(),
1983 None => continue,
1984 };
1985 let table = match new_tables.get(new_idx.table_id.as_str()) {
1986 Some(t) => t,
1987 None => continue,
1988 };
1989 let full_table = format!("{}.{}", quote(&schema), quote(&table.name));
1990 let mut col_parts: Vec<String> = Vec::new();
1991 for col in &new_idx.columns {
1992 match col {
1993 IndexColumnEntry::Name(n) => col_parts.push(quote(n)),
1994 IndexColumnEntry::Spec {
1995 name, direction, ..
1996 } => {
1997 let dir = direction
1998 .as_deref()
1999 .map(|d| format!(" {}", d.to_uppercase()))
2000 .unwrap_or_default();
2001 col_parts.push(format!("{}{}", quote(name), dir));
2002 }
2003 IndexColumnEntry::Expression { expression } => col_parts.push(expression.clone()),
2004 }
2005 }
2006 let method = new_idx.method.as_deref().unwrap_or("btree");
2007 let unique_kw = if new_idx.unique { "UNIQUE " } else { "" };
2008 let include = if new_idx.include.is_empty() {
2009 String::new()
2010 } else {
2011 format!(
2012 " INCLUDE ({})",
2013 new_idx
2014 .include
2015 .iter()
2016 .map(|s| quote(s))
2017 .collect::<Vec<_>>()
2018 .join(", ")
2019 )
2020 };
2021 let where_clause = new_idx
2022 .where_
2023 .as_ref()
2024 .map(|w| format!(" WHERE {}", w))
2025 .unwrap_or_default();
2026 steps.push(MigrationStep {
2027 step: 0,
2028 operation: MigrationOperation::CreateIndex,
2029 schema: schema.clone(),
2030 table: Some(table.name.clone()),
2031 object: new_idx.name.clone(),
2032 object_type: "index".into(),
2033 from_object: None,
2034 description: format!(
2035 "Create {}index \"{}\" on \"{}\".\"{}\"",
2036 if new_idx.unique { "unique " } else { "" },
2037 new_idx.name,
2038 schema,
2039 table.name
2040 ),
2041 ddl: Some(format!(
2042 "CREATE {}INDEX IF NOT EXISTS {} ON {} USING {} ({}){}{}",
2043 unique_kw,
2044 quote(&new_idx.name),
2045 full_table,
2046 method,
2047 col_parts.join(", "),
2048 include,
2049 where_clause
2050 )),
2051 safety: MigrationSafety::Safe,
2052 risk: MigrationRisk::None,
2053 risk_detail: None,
2054 });
2055 }
2056
2057 for old_rel in &old.relationships {
2059 if !new_rels.contains_key(old_rel.id.as_str()) {
2060 let from_sid_fallback = old_rel.from_schema_id.as_deref().unwrap_or(default_old_sid);
2061 let from_schema = old_schemas
2062 .get(from_sid_fallback)
2063 .map(|s| s.name.as_str())
2064 .unwrap_or(from_sid_fallback);
2065 let from_table = old_tables
2066 .get(old_rel.from_table_id.as_str())
2067 .map(|t| t.name.as_str())
2068 .unwrap_or(&old_rel.from_table_id);
2069 let constraint = old_rel.name.as_deref().unwrap_or(&old_rel.id);
2070 let schema_q = quote(schema_override.unwrap_or(from_schema));
2071 steps.push(MigrationStep {
2072 step: 0,
2073 operation: MigrationOperation::DropForeignKey,
2074 schema: schema_override.unwrap_or(from_schema).to_string(),
2075 table: Some(from_table.to_string()),
2076 object: constraint.to_string(),
2077 object_type: "foreign_key".into(),
2078 from_object: None,
2079 description: format!(
2080 "Drop FK \"{}\" from \"{}\".\"{}\"",
2081 constraint,
2082 schema_override.unwrap_or(from_schema),
2083 from_table
2084 ),
2085 ddl: Some(format!(
2086 "ALTER TABLE {}.{} DROP CONSTRAINT IF EXISTS {}",
2087 schema_q,
2088 quote(from_table),
2089 quote(constraint)
2090 )),
2091 safety: MigrationSafety::Safe,
2092 risk: MigrationRisk::None,
2093 risk_detail: None,
2094 });
2095 }
2096 }
2097 for new_rel in &new.relationships {
2098 if old_rels.contains_key(new_rel.id.as_str())
2099 || added_table_ids.contains(new_rel.from_table_id.as_str())
2100 || added_table_ids.contains(new_rel.to_table_id.as_str())
2101 {
2102 continue;
2103 }
2104 let from_sid = new_rel.from_schema_id.as_deref().unwrap_or(default_new_sid);
2105 let from_schema = match new_schemas.get(from_sid) {
2106 Some(s) => s,
2107 None => continue,
2108 };
2109 let from_table = match new_tables.get(new_rel.from_table_id.as_str()) {
2110 Some(t) => t,
2111 None => continue,
2112 };
2113 let from_col = new
2114 .columns
2115 .iter()
2116 .find(|c| c.id == new_rel.from_column_id)
2117 .map(|c| c.name.clone())
2118 .unwrap_or_else(|| new_rel.from_column_id.clone());
2119
2120 let (to_schema_name, to_table_name, to_col) =
2122 if let Some(pkg_id) = new_rel.to_package_id.as_deref() {
2123 match cross_package_configs.get(pkg_id) {
2124 Some(foreign) => {
2125 let foreign_tables: HashMap<_, _> =
2126 foreign.tables.iter().map(|t| (t.id.as_str(), t)).collect();
2127 let foreign_schemas: HashMap<_, _> =
2128 foreign.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
2129 let foreign_default_sid =
2130 foreign.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
2131 let to_sid = new_rel
2132 .to_schema_id
2133 .as_deref()
2134 .unwrap_or(foreign_default_sid);
2135 let tbl = match foreign_tables.get(new_rel.to_table_id.as_str()) {
2136 Some(t) => t,
2137 None => continue,
2138 };
2139 let schema = match foreign_schemas.get(to_sid) {
2140 Some(s) => s,
2141 None => continue,
2142 };
2143 let col = foreign
2144 .columns
2145 .iter()
2146 .find(|c| c.id == new_rel.to_column_id)
2147 .map(|c| c.name.clone())
2148 .unwrap_or_else(|| new_rel.to_column_id.clone());
2149 (schema.name.clone(), tbl.name.clone(), col)
2150 }
2151 None => continue,
2152 }
2153 } else {
2154 let to_sid = new_rel.to_schema_id.as_deref().unwrap_or(default_new_sid);
2155 let to_schema = match new_schemas.get(to_sid) {
2156 Some(s) => s,
2157 None => continue,
2158 };
2159 let to_table = match new_tables.get(new_rel.to_table_id.as_str()) {
2160 Some(t) => t,
2161 None => continue,
2162 };
2163 let col = new
2164 .columns
2165 .iter()
2166 .find(|c| c.id == new_rel.to_column_id)
2167 .map(|c| c.name.clone())
2168 .unwrap_or_else(|| new_rel.to_column_id.clone());
2169 (
2170 schema_override.unwrap_or(&to_schema.name).to_string(),
2171 to_table.name.clone(),
2172 col,
2173 )
2174 };
2175
2176 let from_schema_str = schema_override.unwrap_or(&from_schema.name);
2177 let from_q = format!("{}.{}", quote(from_schema_str), quote(&from_table.name));
2178 let to_q = format!("{}.{}", quote(&to_schema_name), quote(&to_table_name));
2179 let constraint = new_rel.name.as_deref().unwrap_or(&new_rel.id);
2180 let on_update = new_rel.on_update.as_deref().unwrap_or("NO ACTION");
2181 let on_delete = new_rel.on_delete.as_deref().unwrap_or("NO ACTION");
2182 steps.push(MigrationStep {
2183 step: 0,
2184 operation: MigrationOperation::AddForeignKey,
2185 schema: from_schema_str.to_string(),
2186 table: Some(from_table.name.clone()),
2187 object: constraint.to_string(),
2188 object_type: "foreign_key".into(),
2189 from_object: None,
2190 description: format!(
2191 "Add FK \"{}\" on \"{}\".\"{}\" → \"{}\".\"{}\"",
2192 constraint, from_schema_str, from_table.name, to_schema_name, to_table_name
2193 ),
2194 ddl: Some(format!(
2195 "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({}) ON UPDATE {} ON DELETE {}",
2196 from_q, quote(constraint), quote(&from_col), to_q, quote(&to_col), on_update, on_delete
2197 )),
2198 safety: MigrationSafety::BestEffort,
2199 risk: MigrationRisk::None,
2200 risk_detail: Some("PostgreSQL has no ADD CONSTRAINT IF NOT EXISTS; ignored if constraint already exists.".into()),
2201 });
2202 }
2203
2204 for (i, s) in steps.iter_mut().enumerate() {
2206 s.step = i + 1;
2207 }
2208
2209 Ok(MigrationPlan { steps })
2210}
2211
2212#[derive(Debug, Clone, PartialEq, Eq)]
2216pub enum StepDecision {
2217 Execute,
2219 Skip(String),
2221}
2222
2223pub fn reconcile_step(step: &MigrationStep, snap: &DbSnapshot) -> StepDecision {
2234 if step.ddl.is_none() || !snap.introspected {
2235 return StepDecision::Execute;
2236 }
2237 let schema = step.schema.as_str();
2238 let object = step.object.as_str();
2239 let Some(table) = step.table.as_deref() else {
2240 return StepDecision::Execute;
2241 };
2242 let table_known = snap.has_table(schema, table);
2243 let column = snap.column(schema, table, object);
2244 let column_gone = table_known && column.is_none();
2247 let missing_column = || {
2248 format!(
2249 "column \"{}\" does not exist on \"{}\".\"{}\"",
2250 object, schema, table
2251 )
2252 };
2253
2254 use MigrationOperation as Op;
2255 let reason: Option<String> = match step.operation {
2256 Op::CreateTable if table_known => {
2257 Some(format!("table \"{}\".\"{}\" already exists", schema, table))
2258 }
2259 Op::AddColumn if column.is_some() => Some(format!(
2260 "column \"{}\" already exists on \"{}\".\"{}\"",
2261 object, schema, table
2262 )),
2263 Op::RenameColumn => rename_skip_reason(step, snap, schema, table, table_known),
2264
2265 Op::AlterColumnType | Op::BackfillNulls | Op::SetDefault if column_gone => {
2267 Some(missing_column())
2268 }
2269
2270 Op::SetNotNull if column.is_some_and(|f| !f.nullable) => Some(format!(
2271 "column \"{}\".\"{}\".\"{}\" is already NOT NULL",
2272 schema, table, object
2273 )),
2274 Op::DropNotNull if column.is_some_and(|f| f.nullable) => Some(format!(
2275 "column \"{}\".\"{}\".\"{}\" is already nullable",
2276 schema, table, object
2277 )),
2278 Op::DropDefault if column.is_some_and(|f| !f.has_default) => Some(format!(
2279 "column \"{}\".\"{}\".\"{}\" has no DEFAULT",
2280 schema, table, object
2281 )),
2282 Op::SetNotNull | Op::DropNotNull | Op::DropDefault if column_gone => Some(missing_column()),
2283
2284 Op::CreateIndex if snap.indexes_known && snap.has_index(schema, object) => Some(format!(
2285 "index \"{}\".\"{}\" already exists",
2286 schema, object
2287 )),
2288 Op::AddForeignKey
2289 if snap.constraints_known && snap.has_constraint(schema, table, object) =>
2290 {
2291 Some(format!(
2292 "constraint \"{}\" already exists on \"{}\".\"{}\"",
2293 object, schema, table
2294 ))
2295 }
2296
2297 _ => None,
2300 };
2301
2302 match reason {
2303 Some(r) => StepDecision::Skip(r),
2304 None => StepDecision::Execute,
2305 }
2306}
2307
2308fn rename_skip_reason(
2310 step: &MigrationStep,
2311 snap: &DbSnapshot,
2312 schema: &str,
2313 table: &str,
2314 table_known: bool,
2315) -> Option<String> {
2316 let from = step.from_object.as_deref()?;
2319 if !table_known {
2320 return None;
2321 }
2322 let to = step.object.as_str();
2323 match (
2324 snap.has_column(schema, table, from),
2325 snap.has_column(schema, table, to),
2326 ) {
2327 (false, true) => Some(format!(
2328 "column \"{}\".\"{}\".\"{}\" is already named \"{}\"",
2329 schema, table, from, to
2330 )),
2331 (false, false) => Some(format!(
2332 "neither \"{}\" nor \"{}\" exists on \"{}\".\"{}\" — nothing to rename",
2333 from, to, schema, table
2334 )),
2335 _ => None,
2336 }
2337}
2338
2339fn apply_step_to_snapshot(step: &MigrationStep, snap: &mut DbSnapshot) {
2345 let schema = step.schema.as_str();
2346 let object = step.object.as_str();
2347 let table = step.table.as_deref();
2348 match (step.operation.clone(), table, step.from_object.as_deref()) {
2350 (MigrationOperation::AddColumn, Some(t), _) => {
2351 let ddl = step.ddl.as_deref().unwrap_or("").to_uppercase();
2352 snap.add_column(
2353 schema,
2354 t,
2355 object,
2356 ColumnFacts {
2357 data_type: String::new(),
2358 nullable: !ddl.contains(" NOT NULL"),
2359 has_default: ddl.contains(" DEFAULT "),
2360 },
2361 );
2362 }
2363 (MigrationOperation::RenameColumn, Some(t), Some(from)) => {
2364 snap.rename_column(schema, t, from, object)
2365 }
2366 (MigrationOperation::SetNotNull, Some(t), _) => snap.set_nullable(schema, t, object, false),
2367 (MigrationOperation::DropNotNull, Some(t), _) => snap.set_nullable(schema, t, object, true),
2368 (MigrationOperation::SetDefault, Some(t), _) => {
2369 snap.set_has_default(schema, t, object, true)
2370 }
2371 (MigrationOperation::DropDefault, Some(t), _) => {
2372 snap.set_has_default(schema, t, object, false)
2373 }
2374 (MigrationOperation::CreateIndex, _, _) => snap.add_index(schema, object),
2375 (MigrationOperation::DropIndex, _, _) => snap.remove_index(schema, object),
2376 (MigrationOperation::AddForeignKey, Some(t), _) => snap.add_constraint(schema, t, object),
2377 (MigrationOperation::DropForeignKey, Some(t), _) => {
2378 snap.remove_constraint(schema, t, object)
2379 }
2380 _ => {}
2381 }
2382}
2383
2384fn duplicate_object_reason(dialect: &dyn Dialect, e: &sqlx::Error) -> Option<String> {
2390 let dbe = e.as_database_error()?;
2391 let duplicate_code = dbe
2392 .code()
2393 .is_some_and(|code| dialect.is_duplicate_object_code(code.as_ref()));
2394 if duplicate_code {
2395 return Some(format!(
2396 "object already exists (SQLSTATE {}): {}",
2397 dbe.code().unwrap_or_default(),
2398 dbe.message()
2399 ));
2400 }
2401 let msg = dbe.message().to_ascii_lowercase();
2402 if msg.contains("already exists")
2403 || msg.contains("duplicate column name")
2404 || msg.contains("duplicate key name")
2405 {
2406 return Some(format!("object already exists: {}", dbe.message()));
2407 }
2408 None
2409}
2410
2411struct AuditContext<'a> {
2415 migration_plan_id: &'a str,
2416 package_id: &'a str,
2417 tenant_id: &'a str,
2418 from_version: Option<&'a str>,
2419 to_version: &'a str,
2420}
2421
2422async fn audit_step(
2423 config_pool: &Pool,
2424 ctx: &AuditContext<'_>,
2425 step: &MigrationStep,
2426 status: &str,
2427 error_message: Option<&str>,
2428) {
2429 let _ = crate::store::insert_migration_audit(
2430 config_pool,
2431 ctx.migration_plan_id,
2432 ctx.package_id,
2433 ctx.tenant_id,
2434 ctx.from_version,
2435 ctx.to_version,
2436 step.step as i32,
2437 &step.operation.to_string(),
2438 &step.schema,
2439 step.table.as_deref(),
2440 &step.object,
2441 &step.object_type,
2442 &step.description,
2443 step.ddl.as_deref(),
2444 &format!("{:?}", step.safety),
2445 &format!("{:?}", step.risk),
2446 status,
2447 error_message,
2448 )
2449 .await;
2450}
2451
2452#[allow(clippy::too_many_arguments)]
2459pub async fn execute_migration_plan(
2460 migration_pool: &Pool,
2461 config_pool: &Pool,
2462 plan: &MigrationPlan,
2463 migration_plan_id: &str,
2464 package_id: &str,
2465 tenant_id: &str,
2466 from_version: Option<&str>,
2467 to_version: &str,
2468 dialect: &dyn Dialect,
2469) -> Result<MigrationExecutionResult, AppError> {
2470 let mut applied = 0usize;
2471 let mut warned = 0usize;
2472 let mut skipped = 0usize;
2473 let mut warnings: Vec<String> = Vec::new();
2474 let mut skips: Vec<String> = Vec::new();
2475
2476 let ctx = AuditContext {
2477 migration_plan_id,
2478 package_id,
2479 tenant_id,
2480 from_version,
2481 to_version,
2482 };
2483
2484 let schemas: Vec<String> = {
2487 let mut seen: Vec<String> = Vec::new();
2488 for step in &plan.steps {
2489 if step.ddl.is_some() && !seen.iter().any(|s| s == &step.schema) {
2490 seen.push(step.schema.clone());
2491 }
2492 }
2493 seen
2494 };
2495 let mut snapshot = crate::db::introspect(migration_pool, dialect, &schemas).await;
2496
2497 for step in &plan.steps {
2498 let op = step.operation.to_string();
2499
2500 match step.safety {
2501 MigrationSafety::WarnOnly => {
2502 let msg = step
2503 .risk_detail
2504 .clone()
2505 .unwrap_or_else(|| step.description.clone());
2506 tracing::warn!(step = step.step, %op, "migration plan warning (no DDL)");
2507 warnings.push(format!("[Step {}] {}", step.step, msg));
2508 audit_step(config_pool, &ctx, step, "skipped", None).await;
2509 warned += 1;
2510 }
2511 MigrationSafety::Safe | MigrationSafety::BestEffort => {
2512 let Some(ref sql) = step.ddl else {
2513 continue;
2514 };
2515
2516 if let StepDecision::Skip(reason) = reconcile_step(step, &snapshot) {
2517 tracing::info!(step = step.step, %op, %reason, "migration step already satisfied — skipping");
2518 skips.push(format!(
2519 "[Step {}] {} — {}",
2520 step.step, step.description, reason
2521 ));
2522 audit_step(config_pool, &ctx, step, "skipped_exists", Some(&reason)).await;
2523 skipped += 1;
2524 continue;
2525 }
2526
2527 tracing::info!(step = step.step, %op, %sql, "executing migration step");
2528 match sqlx::query(sql).execute(migration_pool).await {
2529 Ok(_) => {
2530 apply_step_to_snapshot(step, &mut snapshot);
2531 audit_step(config_pool, &ctx, step, "applied", None).await;
2532 applied += 1;
2533 }
2534 Err(e) => {
2535 if let Some(reason) = duplicate_object_reason(dialect, &e) {
2538 tracing::info!(step = step.step, %op, %reason, "migration step already satisfied — skipping");
2539 skips.push(format!(
2540 "[Step {}] {} — {}",
2541 step.step, step.description, reason
2542 ));
2543 audit_step(config_pool, &ctx, step, "skipped_exists", Some(&reason))
2544 .await;
2545 apply_step_to_snapshot(step, &mut snapshot);
2546 skipped += 1;
2547 continue;
2548 }
2549
2550 let err_str = e.to_string();
2551 if matches!(step.safety, MigrationSafety::BestEffort) {
2552 tracing::warn!(step = step.step, %op, error = %e, "migration step failed (best-effort, continuing)");
2553 warnings.push(format!(
2554 "[Step {}] {} — Error: {}",
2555 step.step, step.description, err_str
2556 ));
2557 audit_step(config_pool, &ctx, step, "warned", Some(&err_str)).await;
2558 warned += 1;
2559 } else {
2560 audit_step(config_pool, &ctx, step, "failed", Some(&err_str)).await;
2561 return Err(AppError::Db(e));
2562 }
2563 }
2564 }
2565 }
2566 }
2567 }
2568
2569 Ok(MigrationExecutionResult {
2570 applied,
2571 warned,
2572 skipped,
2573 warnings,
2574 skips,
2575 })
2576}
2577
2578pub fn history_table_ddl(
2582 schema_name: &str,
2583 table_name: &str,
2584 pk_col: &str,
2585 source_cols: &[&ColumnConfig],
2586 dialect: &dyn Dialect,
2587) -> String {
2588 let history_name = format!("{}_history", table_name);
2589 let history_full = format!("{}.{}", quote(schema_name), quote(&history_name));
2590
2591 let mut col_defs: Vec<String> = Vec::new();
2592 col_defs.push(format!(
2593 "{} {} NOT NULL DEFAULT {}",
2594 quote("_history_id"),
2595 "UUID",
2596 dialect.uuid_default_expr()
2597 ));
2598 col_defs.push(format!("{} BIGINT NOT NULL", quote("_version")));
2599 col_defs.push(format!("{} TEXT NOT NULL", quote("_operation")));
2600 col_defs.push(format!(
2601 "{} {} NOT NULL DEFAULT {}",
2602 quote("_recorded_at"),
2603 dialect.audit_timestamp_type(),
2604 dialect.now_fn()
2605 ));
2606 col_defs.push(format!(
2607 "{} {}",
2608 quote("_valid_from"),
2609 dialect.audit_timestamp_type()
2610 ));
2611 col_defs.push(format!(
2612 "{} {}",
2613 quote("_valid_to"),
2614 dialect.audit_timestamp_type()
2615 ));
2616
2617 let config_col_names: HashSet<&str> = source_cols.iter().map(|c| c.name.as_str()).collect();
2618 for c in source_cols {
2619 let typ = dialect.ddl_type(&parse_canonical(&c.type_));
2620 col_defs.push(format!("{} {}", quote(&c.name), typ));
2621 }
2622 let audit_ts = dialect.audit_timestamp_type();
2623 for (name, typ) in [
2624 ("created_at", audit_ts),
2625 ("updated_at", audit_ts),
2626 ("archived_at", audit_ts),
2627 ("created_by", "TEXT"),
2628 ("updated_by", "TEXT"),
2629 ] {
2630 if !config_col_names.contains(name) {
2631 col_defs.push(format!("{} {}", quote(name), typ));
2632 }
2633 }
2634 col_defs.push(format!("PRIMARY KEY ({})", quote("_history_id")));
2635
2636 let history_full_quoted = format!("{}.{}", quote(schema_name), quote(&history_name));
2637 let idx_sql = format!(
2638 "-- index: CREATE INDEX IF NOT EXISTS {} ON {} ({}, {})",
2639 quote(&format!("{}_history_{}_idx", table_name, pk_col)),
2640 history_full_quoted,
2641 quote(pk_col),
2642 quote("_version")
2643 );
2644
2645 format!(
2646 "CREATE TABLE IF NOT EXISTS {} (\n {}\n)\n{}",
2647 history_full,
2648 col_defs.join(",\n "),
2649 idx_sql
2650 )
2651}
2652
2653fn history_index_ddl(schema_name: &str, table_name: &str, pk_col: &str) -> String {
2655 format!(
2656 "CREATE INDEX IF NOT EXISTS {} ON {}.{} ({}, {} DESC)",
2657 quote(&format!("{}_history_{}_idx", table_name, pk_col)),
2658 quote(schema_name),
2659 quote(&format!("{}_history", table_name)),
2660 quote(pk_col),
2661 quote("_version")
2662 )
2663}
2664
2665enum CompanionColumnOp<'a> {
2667 Add { name: &'a str, ty: &'a str },
2669 Rename { old: &'a str, new: &'a str },
2671 AlterType { name: &'a str, ty: &'a str },
2673}
2674
2675fn enabled_companion_suffixes(table: &TableConfig) -> Vec<&'static str> {
2677 let mut suffixes = Vec::new();
2678 if table.audit_log {
2679 suffixes.push("audit");
2680 }
2681 if table.versioning.as_ref().is_some_and(|v| v.enabled) {
2682 suffixes.push("history");
2683 }
2684 suffixes
2685}
2686
2687fn companion_column_steps(
2694 schema: &str,
2695 table: &TableConfig,
2696 op: &CompanionColumnOp<'_>,
2697 dialect: &dyn Dialect,
2698) -> Vec<MigrationStep> {
2699 let mut steps = Vec::new();
2700 for suffix in enabled_companion_suffixes(table) {
2701 let companion = format!("{}_{}", table.name, suffix);
2702 let full = format!("{}.{}", quote(schema), quote(&companion));
2703 let (operation, object, from_object, ddl, description, safety, risk, risk_detail) = match op
2704 {
2705 CompanionColumnOp::Add { name, ty } => (
2706 MigrationOperation::AddColumn,
2707 name.to_string(),
2708 None,
2709 add_column_ddl(dialect, &full, &format!("{} {}", quote(name), ty)),
2712 format!(
2713 "Sync {} table: add column \"{}\" to \"{}\".\"{}\"",
2714 suffix, name, schema, companion
2715 ),
2716 MigrationSafety::Safe,
2717 MigrationRisk::None,
2718 None,
2719 ),
2720 CompanionColumnOp::Rename { old, new } => (
2721 MigrationOperation::RenameColumn,
2722 new.to_string(),
2723 Some(old.to_string()),
2724 format!(
2725 "ALTER TABLE {} RENAME COLUMN {} TO {}",
2726 full,
2727 quote(old),
2728 quote(new)
2729 ),
2730 format!(
2731 "Sync {} table: rename column \"{}\" → \"{}\" on \"{}\".\"{}\"",
2732 suffix, old, new, schema, companion
2733 ),
2734 MigrationSafety::Safe,
2735 MigrationRisk::None,
2736 None,
2737 ),
2738 CompanionColumnOp::AlterType { name, ty } => (
2739 MigrationOperation::AlterColumnType,
2740 name.to_string(),
2741 None,
2742 format!(
2743 "ALTER TABLE {} ALTER COLUMN {} TYPE {} USING {}::{}",
2744 full,
2745 quote(name),
2746 ty,
2747 quote(name),
2748 ty
2749 ),
2750 format!(
2751 "Sync {} table: change type of \"{}\".\"{}\".\"{}\" → {}",
2752 suffix, schema, companion, name, ty
2753 ),
2754 MigrationSafety::BestEffort,
2755 MigrationRisk::MayFail,
2756 Some(format!(
2757 "USING {}::{} cast may fail for incompatible values in the {} table.",
2758 name, ty, suffix
2759 )),
2760 ),
2761 };
2762 steps.push(MigrationStep {
2763 step: 0,
2764 operation,
2765 schema: schema.to_string(),
2766 table: Some(companion.clone()),
2767 object,
2768 object_type: "column".into(),
2769 from_object,
2770 description,
2771 ddl: Some(ddl),
2772 safety,
2773 risk,
2774 risk_detail,
2775 });
2776 }
2777 steps
2778}
2779
2780fn audit_table_ddl(
2784 schema_name: &str,
2785 table_name: &str,
2786 source_cols: &[&ColumnConfig],
2787 dialect: &dyn Dialect,
2788) -> String {
2789 let audit_name = format!("{}_audit", table_name);
2790 let audit_full = format!("{}.{}", quote(schema_name), quote(&audit_name));
2791
2792 let mut col_defs: Vec<String> = Vec::new();
2793 col_defs.push(format!(
2794 "{} {} NOT NULL DEFAULT {}",
2795 quote("audit_id"),
2796 "UUID",
2797 dialect.uuid_default_expr()
2798 ));
2799 col_defs.push(format!("{} TEXT NOT NULL", quote("audit_action")));
2800 col_defs.push(format!(
2801 "{} {} NOT NULL DEFAULT {}",
2802 quote("audit_at"),
2803 dialect.audit_timestamp_type(),
2804 dialect.now_fn()
2805 ));
2806 col_defs.push(format!("{} TEXT", quote("audit_by")));
2807 col_defs.push(format!(
2808 "{} {}",
2809 quote("changed_fields"),
2810 dialect.sys_json_type()
2811 ));
2812
2813 let config_col_names: HashSet<&str> = source_cols.iter().map(|c| c.name.as_str()).collect();
2814 for c in source_cols {
2815 let typ = dialect.ddl_type(&parse_canonical(&c.type_));
2816 col_defs.push(format!("{} {}", quote(&c.name), typ));
2817 }
2818 let audit_ts = dialect.audit_timestamp_type();
2819 for (name, typ) in [
2820 ("created_at", audit_ts),
2821 ("updated_at", audit_ts),
2822 ("archived_at", audit_ts),
2823 ("created_by", "TEXT"),
2824 ("updated_by", "TEXT"),
2825 ] {
2826 if !config_col_names.contains(name) {
2827 col_defs.push(format!("{} {}", quote(name), typ));
2828 }
2829 }
2830 col_defs.push(format!("PRIMARY KEY ({})", quote("audit_id")));
2831
2832 format!(
2833 "CREATE TABLE IF NOT EXISTS {} (\n {}\n)",
2834 audit_full,
2835 col_defs.join(",\n ")
2836 )
2837}
2838
2839#[cfg(test)]
2840mod enum_recreate_tests {
2841 use super::*;
2842
2843 fn schema(id: &str, name: &str) -> SchemaConfig {
2844 SchemaConfig {
2845 id: id.into(),
2846 name: name.into(),
2847 comment: None,
2848 }
2849 }
2850
2851 fn table(id: &str, name: &str, schema_id: &str) -> TableConfig {
2852 TableConfig {
2853 id: id.into(),
2854 schema_id: Some(schema_id.into()),
2855 name: name.into(),
2856 comment: None,
2857 primary_key: PrimaryKeyConfig::Single("id".into()),
2858 unique: vec![],
2859 check: vec![],
2860 audit_log: false,
2861 versioning: None,
2862 global: false,
2863 }
2864 }
2865
2866 fn col(id: &str, table_id: &str, name: &str, ty: &str, default: Option<&str>) -> ColumnConfig {
2867 ColumnConfig {
2868 id: id.into(),
2869 table_id: table_id.into(),
2870 name: name.into(),
2871 type_: ColumnTypeConfig::Simple(ty.into()),
2872 nullable: true,
2873 default: default.map(|d| ColumnDefaultConfig::Literal(d.into())),
2874 comment: None,
2875 asset: None,
2876 extensible: false,
2877 }
2878 }
2879
2880 fn enum_cfg(id: &str, name: &str, schema_id: &str, values: &[&str]) -> EnumConfig {
2881 EnumConfig {
2882 id: id.into(),
2883 schema_id: Some(schema_id.into()),
2884 name: name.into(),
2885 values: values.iter().map(|s| s.to_string()).collect(),
2886 comment: None,
2887 }
2888 }
2889
2890 fn ddls(steps: &[MigrationStep]) -> Vec<String> {
2891 steps.iter().filter_map(|s| s.ddl.clone()).collect()
2892 }
2893
2894 #[test]
2895 fn finds_scalar_and_array_dependent_columns() {
2896 let mut cfg = FullConfig::default();
2897 cfg.schemas = vec![schema("s1", "app")];
2898 cfg.tables = vec![table("t_orders", "orders", "s1")];
2899 cfg.columns = vec![
2900 col(
2901 "c1",
2902 "t_orders",
2903 "status",
2904 "order_status",
2905 Some("'pending'"),
2906 ),
2907 col("c2", "t_orders", "tags", "order_status[]", None),
2908 col("c3", "t_orders", "name", "text", None), ];
2910 let e = enum_cfg("e1", "order_status", "s1", &["pending", "shipped"]);
2911 let new_tables: HashMap<&str, &TableConfig> =
2912 cfg.tables.iter().map(|t| (t.id.as_str(), t)).collect();
2913 let new_schemas: HashMap<&str, &SchemaConfig> =
2914 cfg.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
2915
2916 let deps = enum_dependent_columns(&e, &cfg, &new_tables, &new_schemas, None);
2917 assert_eq!(deps.len(), 2);
2918 let scalar = deps.iter().find(|d| d.column == "status").unwrap();
2919 assert_eq!(scalar.schema, "app");
2920 assert_eq!(scalar.table, "orders");
2921 assert!(!scalar.is_array);
2922 assert_eq!(scalar.default.as_deref(), Some("'pending'"));
2923 let arr = deps.iter().find(|d| d.column == "tags").unwrap();
2924 assert!(arr.is_array);
2925 assert!(arr.default.is_none());
2926 }
2927
2928 #[test]
2929 fn recreate_sequence_emits_rename_create_recast_drop() {
2930 let e = enum_cfg("e1", "order_status", "s1", &["pending", "shipped"]);
2931 let deps = vec![
2932 EnumColumnRef {
2933 schema: "app".into(),
2934 table: "orders".into(),
2935 column: "status".into(),
2936 default: Some("'pending'".into()),
2937 is_array: false,
2938 },
2939 EnumColumnRef {
2940 schema: "app".into(),
2941 table: "orders".into(),
2942 column: "tags".into(),
2943 default: None,
2944 is_array: true,
2945 },
2946 ];
2947 let mut steps = Vec::new();
2948 recreate_enum_steps(&mut steps, "app", &e, &["cancelled"], &deps);
2949 let sql = ddls(&steps);
2950
2951 assert!(matches!(steps[0].safety, MigrationSafety::WarnOnly));
2953 assert!(steps[0].ddl.is_none());
2954
2955 assert_eq!(
2957 sql[0],
2958 r#"ALTER TYPE "app"."order_status" RENAME TO "order_status__arch_old""#
2959 );
2960 assert_eq!(
2961 sql[1],
2962 r#"CREATE TYPE "app"."order_status" AS ENUM ('pending', 'shipped')"#
2963 );
2964 assert_eq!(
2965 sql[2],
2966 r#"ALTER TABLE "app"."orders" ALTER COLUMN "status" DROP DEFAULT"#
2967 );
2968 assert_eq!(
2969 sql[3],
2970 r#"ALTER TABLE "app"."orders" ALTER COLUMN "status" TYPE "app"."order_status" USING "status"::text::"app"."order_status""#
2971 );
2972 assert_eq!(
2973 sql[4],
2974 r#"ALTER TABLE "app"."orders" ALTER COLUMN "status" SET DEFAULT 'pending'"#
2975 );
2976 assert_eq!(
2978 sql[5],
2979 r#"ALTER TABLE "app"."orders" ALTER COLUMN "tags" TYPE "app"."order_status"[] USING "tags"::text[]::"app"."order_status"[]"#
2980 );
2981 assert_eq!(
2982 *sql.last().unwrap(),
2983 r#"DROP TYPE IF EXISTS "app"."order_status__arch_old""#
2984 );
2985 }
2986}
2987
2988#[cfg(all(test, feature = "sqlite"))]
2989mod companion_sync_tests {
2990 use super::*;
2991 use crate::db::sqlite::SqliteDialect;
2992
2993 fn schema(id: &str, name: &str) -> SchemaConfig {
2994 SchemaConfig {
2995 id: id.into(),
2996 name: name.into(),
2997 comment: None,
2998 }
2999 }
3000
3001 fn table(id: &str, name: &str, audit: bool, versioning: bool) -> TableConfig {
3002 TableConfig {
3003 id: id.into(),
3004 schema_id: Some("s1".into()),
3005 name: name.into(),
3006 comment: None,
3007 primary_key: PrimaryKeyConfig::Single("id".into()),
3008 unique: vec![],
3009 check: vec![],
3010 audit_log: audit,
3011 versioning: versioning.then(|| VersioningConfig {
3012 enabled: true,
3013 keep_versions: None,
3014 }),
3015 global: false,
3016 }
3017 }
3018
3019 fn col(id: &str, name: &str, ty: &str) -> ColumnConfig {
3020 ColumnConfig {
3021 id: id.into(),
3022 table_id: "t1".into(),
3023 name: name.into(),
3024 type_: ColumnTypeConfig::Simple(ty.into()),
3025 nullable: true,
3026 default: None,
3027 comment: None,
3028 asset: None,
3029 extensible: false,
3030 }
3031 }
3032
3033 fn base(audit: bool, versioning: bool) -> FullConfig {
3034 let mut cfg = FullConfig::default();
3035 cfg.schemas = vec![schema("s1", "app")];
3036 cfg.tables = vec![table("t1", "orders", audit, versioning)];
3037 cfg.columns = vec![col("c0", "id", "uuid"), col("c1", "status", "text")];
3038 cfg
3039 }
3040
3041 fn plan(old: &FullConfig, new: &FullConfig) -> Vec<String> {
3042 let dialect = SqliteDialect;
3043 compute_migration_plan(old, new, None, None, &dialect, &HashMap::new())
3044 .unwrap()
3045 .steps
3046 .into_iter()
3047 .filter_map(|s| s.ddl)
3048 .collect()
3049 }
3050
3051 #[test]
3052 fn add_column_syncs_audit_and_history() {
3053 let old = base(true, true);
3054 let mut new = base(true, true);
3055 new.columns.push(col("c2", "note", "text"));
3056
3057 let sql = plan(&old, &new);
3059 assert!(sql
3060 .iter()
3061 .any(|s| s == r#"ALTER TABLE "app"."orders" ADD COLUMN "note" TEXT"#));
3062 assert!(sql
3063 .iter()
3064 .any(|s| s == r#"ALTER TABLE "app"."orders_audit" ADD COLUMN "note" TEXT"#));
3065 assert!(sql
3066 .iter()
3067 .any(|s| s == r#"ALTER TABLE "app"."orders_history" ADD COLUMN "note" TEXT"#));
3068 }
3069
3070 #[test]
3071 fn rename_column_syncs_companions() {
3072 let old = base(true, true);
3073 let mut new = base(true, true);
3074 new.columns[1].name = "state".into();
3075
3076 let sql = plan(&old, &new);
3077 assert!(sql
3078 .iter()
3079 .any(|s| s == r#"ALTER TABLE "app"."orders_audit" RENAME COLUMN "status" TO "state""#));
3080 assert!(sql.iter().any(
3081 |s| s == r#"ALTER TABLE "app"."orders_history" RENAME COLUMN "status" TO "state""#
3082 ));
3083 }
3084
3085 #[test]
3086 fn alter_type_syncs_companions() {
3087 let old = base(true, false);
3088 let mut new = base(true, false);
3089 new.columns[1].type_ = ColumnTypeConfig::Simple("integer".into());
3090
3091 let sql = plan(&old, &new);
3092 assert!(sql.iter().any(|s| s
3093 == r#"ALTER TABLE "app"."orders_audit" ALTER COLUMN "status" TYPE INTEGER USING "status"::INTEGER"#));
3094 assert!(!sql.iter().any(|s| s.contains("orders_history")));
3096 }
3097
3098 #[test]
3099 fn no_companion_steps_when_features_disabled() {
3100 let old = base(false, false);
3101 let mut new = base(false, false);
3102 new.columns.push(col("c2", "note", "text"));
3103
3104 let sql = plan(&old, &new);
3105 assert!(sql
3106 .iter()
3107 .any(|s| s == r#"ALTER TABLE "app"."orders" ADD COLUMN "note" TEXT"#));
3108 assert!(!sql.iter().any(|s| s.contains("orders_audit")));
3109 assert!(!sql.iter().any(|s| s.contains("orders_history")));
3110 }
3111
3112 #[test]
3113 fn nullability_change_does_not_touch_companions() {
3114 let old = base(true, true);
3115 let mut new = base(true, true);
3116 new.columns[1].nullable = false;
3117
3118 let sql = plan(&old, &new);
3119 assert!(sql.iter().any(|s| s.contains("SET NOT NULL")));
3121 assert!(!sql.iter().any(|s| s.contains("orders_audit")));
3122 assert!(!sql.iter().any(|s| s.contains("orders_history")));
3123 }
3124}
3125
3126#[cfg(test)]
3127mod reconcile_tests {
3128 use super::*;
3129
3130 fn snapshot() -> DbSnapshot {
3131 let mut snap = DbSnapshot::default();
3132 snap.introspected = true;
3133 snap.indexes_known = true;
3134 snap.constraints_known = true;
3135 snap
3136 }
3137
3138 fn facts(nullable: bool, has_default: bool) -> ColumnFacts {
3139 ColumnFacts {
3140 data_type: "text".into(),
3141 nullable,
3142 has_default,
3143 }
3144 }
3145
3146 fn step(op: MigrationOperation, table: Option<&str>, object: &str) -> MigrationStep {
3147 MigrationStep {
3148 step: 1,
3149 operation: op,
3150 schema: "app".into(),
3151 table: table.map(String::from),
3152 object: object.into(),
3153 object_type: "column".into(),
3154 from_object: None,
3155 description: "test step".into(),
3156 ddl: Some("SELECT 1".into()),
3157 safety: MigrationSafety::Safe,
3158 risk: MigrationRisk::None,
3159 risk_detail: None,
3160 }
3161 }
3162
3163 fn skipped(d: StepDecision) -> bool {
3164 matches!(d, StepDecision::Skip(_))
3165 }
3166
3167 #[test]
3168 fn add_column_is_skipped_when_the_column_already_exists() {
3169 let mut snap = snapshot();
3172 snap.add_column("app", "transport_units", "project_id", facts(true, false));
3173
3174 let s = step(
3175 MigrationOperation::AddColumn,
3176 Some("transport_units"),
3177 "project_id",
3178 );
3179 assert!(skipped(reconcile_step(&s, &snap)));
3180 }
3181
3182 #[test]
3183 fn add_column_runs_when_the_column_is_absent() {
3184 let mut snap = snapshot();
3185 snap.add_column("app", "transport_units", "id", facts(false, false));
3186
3187 let s = step(
3188 MigrationOperation::AddColumn,
3189 Some("transport_units"),
3190 "project_id",
3191 );
3192 assert_eq!(reconcile_step(&s, &snap), StepDecision::Execute);
3193 }
3194
3195 #[test]
3196 fn nothing_is_skipped_when_introspection_produced_nothing() {
3197 let snap = DbSnapshot::default();
3199 for op in [
3200 MigrationOperation::AddColumn,
3201 MigrationOperation::SetNotNull,
3202 MigrationOperation::CreateIndex,
3203 ] {
3204 let s = step(op, Some("orders"), "note");
3205 assert_eq!(reconcile_step(&s, &snap), StepDecision::Execute);
3206 }
3207 }
3208
3209 #[test]
3210 fn create_table_is_skipped_when_the_table_exists() {
3211 let mut snap = snapshot();
3212 snap.add_column("app", "orders", "id", facts(false, false));
3213
3214 let s = step(MigrationOperation::CreateTable, Some("orders"), "orders");
3215 assert!(skipped(reconcile_step(&s, &snap)));
3216 }
3217
3218 #[test]
3219 fn rename_is_skipped_once_it_has_been_applied() {
3220 let mut snap = snapshot();
3221 snap.add_column("app", "orders", "state", facts(true, false));
3222
3223 let mut s = step(MigrationOperation::RenameColumn, Some("orders"), "state");
3224 s.from_object = Some("status".into());
3225 assert!(skipped(reconcile_step(&s, &snap)));
3226 }
3227
3228 #[test]
3229 fn rename_runs_while_the_old_column_is_still_there() {
3230 let mut snap = snapshot();
3231 snap.add_column("app", "orders", "status", facts(true, false));
3232
3233 let mut s = step(MigrationOperation::RenameColumn, Some("orders"), "state");
3234 s.from_object = Some("status".into());
3235 assert_eq!(reconcile_step(&s, &snap), StepDecision::Execute);
3236 }
3237
3238 #[test]
3239 fn rename_is_skipped_when_neither_name_exists() {
3240 let mut snap = snapshot();
3241 snap.add_column("app", "orders", "id", facts(false, false));
3242
3243 let mut s = step(MigrationOperation::RenameColumn, Some("orders"), "state");
3244 s.from_object = Some("status".into());
3245 assert!(skipped(reconcile_step(&s, &snap)));
3246 }
3247
3248 #[test]
3249 fn nullability_and_default_steps_respect_the_current_column_state() {
3250 let mut snap = snapshot();
3251 snap.add_column("app", "orders", "not_null_col", facts(false, false));
3252 snap.add_column("app", "orders", "nullable_col", facts(true, true));
3253
3254 assert!(skipped(reconcile_step(
3255 &step(
3256 MigrationOperation::SetNotNull,
3257 Some("orders"),
3258 "not_null_col"
3259 ),
3260 &snap
3261 )));
3262 assert_eq!(
3263 reconcile_step(
3264 &step(
3265 MigrationOperation::SetNotNull,
3266 Some("orders"),
3267 "nullable_col"
3268 ),
3269 &snap
3270 ),
3271 StepDecision::Execute
3272 );
3273 assert!(skipped(reconcile_step(
3274 &step(
3275 MigrationOperation::DropNotNull,
3276 Some("orders"),
3277 "nullable_col"
3278 ),
3279 &snap
3280 )));
3281 assert!(skipped(reconcile_step(
3282 &step(
3283 MigrationOperation::DropDefault,
3284 Some("orders"),
3285 "not_null_col"
3286 ),
3287 &snap
3288 )));
3289 assert_eq!(
3290 reconcile_step(
3291 &step(
3292 MigrationOperation::DropDefault,
3293 Some("orders"),
3294 "nullable_col"
3295 ),
3296 &snap
3297 ),
3298 StepDecision::Execute
3299 );
3300 }
3301
3302 #[test]
3303 fn column_steps_are_skipped_when_the_column_is_gone_from_a_known_table() {
3304 let mut snap = snapshot();
3305 snap.add_column("app", "orders", "id", facts(false, false));
3306
3307 for op in [
3308 MigrationOperation::AlterColumnType,
3309 MigrationOperation::SetDefault,
3310 MigrationOperation::SetNotNull,
3311 MigrationOperation::BackfillNulls,
3312 ] {
3313 assert!(
3314 skipped(reconcile_step(&step(op, Some("orders"), "dropped"), &snap)),
3315 "expected a skip for a column that no longer exists"
3316 );
3317 }
3318 }
3319
3320 #[test]
3321 fn index_and_foreign_key_steps_are_skipped_when_the_object_exists() {
3322 let mut snap = snapshot();
3323 snap.add_column("app", "orders", "user_id", facts(true, false));
3324 snap.add_index("app", "orders_user_idx");
3325 snap.add_constraint("app", "orders", "fk_orders_user");
3326
3327 let mut idx = step(
3328 MigrationOperation::CreateIndex,
3329 Some("orders"),
3330 "orders_user_idx",
3331 );
3332 idx.object_type = "index".into();
3333 assert!(skipped(reconcile_step(&idx, &snap)));
3334
3335 let mut fk = step(
3336 MigrationOperation::AddForeignKey,
3337 Some("orders"),
3338 "fk_orders_user",
3339 );
3340 fk.object_type = "foreign_key".into();
3341 assert!(skipped(reconcile_step(&fk, &snap)));
3342 }
3343
3344 #[test]
3345 fn index_steps_run_when_the_dialect_cannot_report_indexes() {
3346 let mut snap = snapshot();
3347 snap.indexes_known = false;
3348 snap.add_column("app", "orders", "user_id", facts(true, false));
3349
3350 let s = step(
3351 MigrationOperation::CreateIndex,
3352 Some("orders"),
3353 "orders_user_idx",
3354 );
3355 assert_eq!(reconcile_step(&s, &snap), StepDecision::Execute);
3356 }
3357
3358 #[test]
3359 fn executed_steps_are_folded_into_the_snapshot() {
3360 let mut snap = snapshot();
3363 snap.add_table("app", "orders");
3364
3365 let mut add = step(MigrationOperation::AddColumn, Some("orders"), "note");
3366 add.ddl = Some(r#"ALTER TABLE "app"."orders" ADD COLUMN "note" TEXT NOT NULL"#.into());
3367 apply_step_to_snapshot(&add, &mut snap);
3368 assert!(snap.has_column("app", "orders", "note"));
3369
3370 assert!(skipped(reconcile_step(&add, &snap)));
3372
3373 assert!(skipped(reconcile_step(
3375 &step(MigrationOperation::SetNotNull, Some("orders"), "note"),
3376 &snap
3377 )));
3378 }
3379
3380 #[test]
3381 fn drops_and_enum_steps_are_never_skipped() {
3382 let mut snap = snapshot();
3383 snap.add_column("app", "orders", "note", facts(true, false));
3384
3385 for op in [
3386 MigrationOperation::DropIndex,
3387 MigrationOperation::DropForeignKey,
3388 MigrationOperation::CreateEnum,
3389 MigrationOperation::AddEnumValue,
3390 ] {
3391 assert_eq!(
3392 reconcile_step(&step(op, Some("orders"), "note"), &snap),
3393 StepDecision::Execute
3394 );
3395 }
3396 }
3397}
3398
3399#[cfg(all(test, feature = "postgres"))]
3400mod postgres_idempotent_ddl_tests {
3401 use super::*;
3402 use crate::db::postgres::PostgresDialect;
3403
3404 #[test]
3405 fn add_column_carries_an_if_not_exists_guard() {
3406 let ddl = add_column_ddl(&PostgresDialect, r#""app"."orders""#, r#""note" TEXT"#);
3407 assert_eq!(
3408 ddl,
3409 r#"ALTER TABLE "app"."orders" ADD COLUMN IF NOT EXISTS "note" TEXT"#
3410 );
3411 }
3412
3413 #[test]
3414 fn duplicate_object_sqlstates_are_recognised() {
3415 let d = PostgresDialect;
3416 assert!(d.is_duplicate_object_code("42701"));
3418 assert!(d.is_duplicate_object_code("42P07"));
3420 assert!(d.is_duplicate_object_code("42710"));
3421 assert!(!d.is_duplicate_object_code("42703"));
3423 }
3424}