1use crate::config::types::*;
5use crate::config::{validate, FullConfig};
6use crate::db::parse_canonical;
7use crate::db::pool::Pool;
8use crate::db::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
146pub async fn apply_migrations(
151 pool: &Pool,
152 config: &FullConfig,
153 schema_override: Option<&str>,
154 rls_tenant_column: Option<&str>,
155 dialect: &dyn Dialect,
156 cross_package_configs: &HashMap<String, FullConfig>,
157) -> Result<(), AppError> {
158 validate(config)?;
159 let default_sid = config
160 .schemas
161 .first()
162 .map(|s| s.id.as_str())
163 .ok_or_else(|| {
164 AppError::Config(crate::error::ConfigError::Validation(
165 "at least one schema required".into(),
166 ))
167 })?;
168
169 if dialect.supports_schemas() {
170 if let Some(s) = schema_override {
171 let name = quote(s);
172 sqlx::query(&format!("CREATE SCHEMA IF NOT EXISTS {}", name))
173 .execute(pool)
174 .await?;
175 }
176 }
177
178 let schemas_by_id: HashMap<_, _> = config.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
179 let tables_by_id: HashMap<_, _> = config.tables.iter().map(|t| (t.id.as_str(), t)).collect();
180 let columns_by_table: HashMap<_, Vec<&ColumnConfig>> =
181 config.columns.iter().fold(HashMap::new(), |mut m, c| {
182 m.entry(c.table_id.as_str()).or_default().push(c);
183 m
184 });
185
186 if schema_override.is_none() && dialect.supports_schemas() {
188 for s in &config.schemas {
189 let name = quote(&s.name);
190 let comment = s
191 .comment
192 .as_ref()
193 .map(|c| format!("COMMENT ON SCHEMA {} IS '{}'", name, c.replace('\'', "''")));
194 sqlx::query(&format!("CREATE SCHEMA IF NOT EXISTS {}", name))
195 .execute(pool)
196 .await?;
197 if let Some(sql) = comment {
198 let _ = sqlx::query(&sql).execute(pool).await;
199 }
200 }
201 }
202
203 for e in &config.enums {
204 let sid = e.schema_id.as_deref().unwrap_or(default_sid);
205 let schema = schemas_by_id.get(sid).ok_or_else(|| {
206 AppError::Config(crate::error::ConfigError::MissingReference {
207 kind: "schema",
208 id: sid.to_string(),
209 })
210 })?;
211 let schema_name = quote(schema_override.unwrap_or(&schema.name));
212 let type_name = quote(&e.name);
213 if dialect.supports_named_enum_types() {
214 let values: Vec<String> = e
215 .values
216 .iter()
217 .map(|v| format!("'{}'", v.replace('\'', "''")))
218 .collect();
219 let sql = format!(
220 "CREATE TYPE {}.{} AS ENUM ({})",
221 schema_name,
222 type_name,
223 values.join(", ")
224 );
225 let _ = sqlx::query(&sql).execute(pool).await;
226 }
227 }
228
229 for t in &config.tables {
230 let sid = t.schema_id.as_deref().unwrap_or(default_sid);
231 let schema = schemas_by_id.get(sid).ok_or_else(|| {
232 AppError::Config(crate::error::ConfigError::MissingReference {
233 kind: "schema",
234 id: sid.to_string(),
235 })
236 })?;
237 let schema_name = quote(schema_override.unwrap_or(&schema.name));
238 let table_name = quote(&t.name);
239 let full_name = format!("{}.{}", schema_name, table_name);
240
241 let cols = columns_by_table
242 .get(t.id.as_str())
243 .map(|v| v.as_slice())
244 .unwrap_or(&[]);
245 let mut col_defs: Vec<String> = Vec::new();
246 for c in cols {
247 let typ = dialect.ddl_type(&parse_canonical(&c.type_));
248 let mut def = format!("{} {}", quote(&c.name), typ);
249 if !c.nullable {
250 def.push_str(" NOT NULL");
251 }
252 if let Some(ref d) = c.default {
253 def.push_str(" DEFAULT ");
254 match d {
255 ColumnDefaultConfig::Literal(s) => def.push_str(s),
256 ColumnDefaultConfig::Expression { expression } => def.push_str(expression),
257 }
258 }
259 col_defs.push(def);
260 }
261
262 let config_col_names: HashSet<&str> = cols.iter().map(|c| c.name.as_str()).collect();
263 let ts_default = format!(
264 "{} NOT NULL DEFAULT {}",
265 dialect.sys_timestamp_type(),
266 dialect.now_fn()
267 );
268 let ts_nullable = dialect.sys_timestamp_type().to_string();
269 for (name, def_suffix) in [
270 ("created_at", ts_default.as_str()),
271 ("updated_at", ts_default.as_str()),
272 ("archived_at", ts_nullable.as_str()),
273 ("created_by", "TEXT"),
274 ("updated_by", "TEXT"),
275 ] {
276 if !config_col_names.contains(name) {
277 col_defs.push(format!("{} {}", quote(name), def_suffix));
278 }
279 }
280
281 let pk_cols = match &t.primary_key {
282 PrimaryKeyConfig::Single(s) => vec![quote(s)],
283 PrimaryKeyConfig::Composite(v) => v.iter().map(|s| quote(s)).collect::<Vec<_>>(),
284 };
285 let pk_def = format!("PRIMARY KEY ({})", pk_cols.join(", "));
286 col_defs.push(pk_def);
287
288 for u in &t.unique {
289 let cols: Vec<String> = u.iter().map(|s| quote(s)).collect();
290 col_defs.push(format!("UNIQUE ({})", cols.join(", ")));
291 }
292 for ch in &t.check {
293 col_defs.push(format!(
294 "CONSTRAINT {} CHECK ({})",
295 quote(&ch.name),
296 ch.expression
297 ));
298 }
299
300 let sql = format!(
301 "CREATE TABLE IF NOT EXISTS {} (\n {}\n)",
302 full_name,
303 col_defs.join(",\n ")
304 );
305 sqlx::query(&sql).execute(pool).await?;
306
307 if t.audit_log {
308 let schema_raw = schema_override.unwrap_or(&schema.name);
309 let audit_sql = audit_table_ddl(schema_raw, &t.name, cols, dialect);
310 sqlx::query(&audit_sql).execute(pool).await?;
311 let pk_col = match &t.primary_key {
312 PrimaryKeyConfig::Single(s) => s.clone(),
313 PrimaryKeyConfig::Composite(v) => v[0].clone(),
314 };
315 let audit_full = format!(
316 "{}.{}",
317 quote(schema_raw),
318 quote(&format!("{}_audit", t.name))
319 );
320 let idx_sql = format!(
321 "CREATE INDEX IF NOT EXISTS {} ON {} ({}, {})",
322 quote(&format!("{}_audit_record_idx", t.name)),
323 audit_full,
324 quote(&pk_col),
325 quote("audit_at")
326 );
327 let _ = sqlx::query(&idx_sql).execute(pool).await;
328 }
329
330 if t.versioning.as_ref().is_some_and(|v| v.enabled) {
331 let schema_raw = schema_override.unwrap_or(&schema.name);
332 let pk_col = match &t.primary_key {
333 PrimaryKeyConfig::Single(s) => s.clone(),
334 PrimaryKeyConfig::Composite(v) => v[0].clone(),
335 };
336 let history_ddl = history_table_ddl(schema_raw, &t.name, &pk_col, cols, dialect);
337 let create_only = history_ddl
339 .lines()
340 .take_while(|l| !l.trim_start().starts_with("-- index:"))
341 .collect::<Vec<_>>()
342 .join("\n");
343 sqlx::query(create_only.trim()).execute(pool).await?;
344 let idx_sql = history_index_ddl(schema_raw, &t.name, &pk_col);
345 let _ = sqlx::query(&idx_sql).execute(pool).await;
346 }
347 }
348
349 if let Some(col) = rls_tenant_column {
350 apply_rls_to_tables(pool, config, schema_override, col, dialect).await?;
351 }
352
353 for idx in &config.indexes {
354 let sid = idx.schema_id.as_deref().unwrap_or(default_sid);
355 let schema = schemas_by_id.get(sid).ok_or_else(|| {
356 AppError::Config(crate::error::ConfigError::MissingReference {
357 kind: "schema",
358 id: sid.to_string(),
359 })
360 })?;
361 let table = tables_by_id.get(idx.table_id.as_str()).ok_or_else(|| {
362 AppError::Config(crate::error::ConfigError::MissingReference {
363 kind: "table",
364 id: idx.table_id.clone(),
365 })
366 })?;
367 let schema_name = quote(schema_override.unwrap_or(&schema.name));
368 let table_name = quote(&table.name);
369 let full_table = format!("{}.{}", schema_name, table_name);
370 let index_name = quote(&idx.name);
371
372 let mut col_parts: Vec<String> = Vec::new();
373 for col in &idx.columns {
374 match col {
375 IndexColumnEntry::Name(n) => col_parts.push(quote(n)),
376 IndexColumnEntry::Spec {
377 name, direction, ..
378 } => {
379 let dir = direction
380 .as_deref()
381 .map(|d| format!(" {}", d.to_uppercase()))
382 .unwrap_or_default();
383 col_parts.push(format!("{}{}", quote(name), dir));
384 }
385 IndexColumnEntry::Expression { expression } => col_parts.push(expression.clone()),
386 }
387 }
388 let method = idx.method.as_deref().unwrap_or("btree");
389 let unique = if idx.unique { "UNIQUE " } else { "" };
390 let include: String = if idx.include.is_empty() {
391 String::new()
392 } else {
393 let inc: Vec<String> = idx.include.iter().map(|s| quote(s)).collect();
394 format!(" INCLUDE ({})", inc.join(", "))
395 };
396 let where_clause: String = idx
397 .where_
398 .as_ref()
399 .map(|w| format!(" WHERE {}", w))
400 .unwrap_or_default();
401
402 let sql = format!(
403 "CREATE {}INDEX IF NOT EXISTS {} ON {} USING {} ({}){}{}",
404 unique,
405 index_name,
406 full_table,
407 method,
408 col_parts.join(", "),
409 include,
410 where_clause
411 );
412 let _ = sqlx::query(&sql).execute(pool).await;
413 }
414
415 for rel in &config.relationships {
416 let from_sid = rel.from_schema_id.as_deref().unwrap_or(default_sid);
417 let from_schema = schemas_by_id.get(from_sid).ok_or_else(|| {
418 AppError::Config(crate::error::ConfigError::MissingReference {
419 kind: "schema",
420 id: from_sid.to_string(),
421 })
422 })?;
423 let from_table = tables_by_id
424 .get(rel.from_table_id.as_str())
425 .ok_or_else(|| {
426 AppError::Config(crate::error::ConfigError::MissingReference {
427 kind: "table",
428 id: rel.from_table_id.clone(),
429 })
430 })?;
431
432 let (to_schema_name_owned, to_table_name, to_col_name) = if let Some(pkg_id) =
434 rel.to_package_id.as_deref()
435 {
436 let foreign = cross_package_configs.get(pkg_id).ok_or_else(|| {
437 AppError::Config(crate::error::ConfigError::MissingReference {
438 kind: "cross_package",
439 id: pkg_id.to_string(),
440 })
441 })?;
442 let foreign_tables: HashMap<_, _> =
443 foreign.tables.iter().map(|t| (t.id.as_str(), t)).collect();
444 let foreign_schemas: HashMap<_, _> =
445 foreign.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
446 let to_tbl = foreign_tables
447 .get(rel.to_table_id.as_str())
448 .ok_or_else(|| {
449 AppError::Config(crate::error::ConfigError::MissingReference {
450 kind: "table",
451 id: rel.to_table_id.clone(),
452 })
453 })?;
454 let foreign_default_sid = foreign.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
455 let to_sid = rel.to_schema_id.as_deref().unwrap_or(foreign_default_sid);
456 let to_schema = foreign_schemas.get(to_sid).ok_or_else(|| {
457 AppError::Config(crate::error::ConfigError::MissingReference {
458 kind: "schema",
459 id: to_sid.to_string(),
460 })
461 })?;
462 let col_name = foreign
463 .columns
464 .iter()
465 .find(|c| c.id == rel.to_column_id)
466 .map(|c| c.name.clone())
467 .ok_or_else(|| {
468 AppError::Config(crate::error::ConfigError::MissingReference {
469 kind: "column",
470 id: rel.to_column_id.clone(),
471 })
472 })?;
473 (to_schema.name.clone(), to_tbl.name.clone(), col_name)
476 } else {
477 let to_sid = rel.to_schema_id.as_deref().unwrap_or(default_sid);
478 let to_schema = schemas_by_id.get(to_sid).ok_or_else(|| {
479 AppError::Config(crate::error::ConfigError::MissingReference {
480 kind: "schema",
481 id: to_sid.to_string(),
482 })
483 })?;
484 let to_table = tables_by_id.get(rel.to_table_id.as_str()).ok_or_else(|| {
485 AppError::Config(crate::error::ConfigError::MissingReference {
486 kind: "table",
487 id: rel.to_table_id.clone(),
488 })
489 })?;
490 let col_name = config
491 .columns
492 .iter()
493 .find(|c| c.id == rel.to_column_id)
494 .map(|c| c.name.clone())
495 .ok_or_else(|| {
496 AppError::Config(crate::error::ConfigError::MissingReference {
497 kind: "column",
498 id: rel.to_column_id.clone(),
499 })
500 })?;
501 (
502 schema_override.unwrap_or(&to_schema.name).to_string(),
503 to_table.name.clone(),
504 col_name,
505 )
506 };
507
508 let from_schema_name = schema_override.unwrap_or(&from_schema.name);
509 let from_col = config
510 .columns
511 .iter()
512 .find(|c| c.id == rel.from_column_id)
513 .map(|c| c.name.as_str())
514 .ok_or_else(|| {
515 AppError::Config(crate::error::ConfigError::MissingReference {
516 kind: "column",
517 id: rel.from_column_id.clone(),
518 })
519 })?;
520
521 let from_full = format!("{}.{}", quote(from_schema_name), quote(&from_table.name));
522 let to_full = format!("{}.{}", quote(&to_schema_name_owned), quote(&to_table_name));
523 let constraint_name = rel.name.as_deref().unwrap_or(&rel.id);
524 let on_update = rel.on_update.as_deref().unwrap_or("NO ACTION");
525 let on_delete = rel.on_delete.as_deref().unwrap_or("NO ACTION");
526
527 let sql = format!(
528 "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({}) ON UPDATE {} ON DELETE {}",
529 from_full,
530 quote(constraint_name),
531 quote(from_col),
532 to_full,
533 quote(&to_col_name),
534 on_update,
535 on_delete
536 );
537 let _ = sqlx::query(&sql).execute(pool).await;
538 }
539
540 Ok(())
541}
542
543pub async fn revert_migrations(
546 pool: &Pool,
547 config: &FullConfig,
548 schema_override: Option<&str>,
549) -> Result<(), AppError> {
550 let default_sid = config
551 .schemas
552 .first()
553 .map(|s| s.id.as_str())
554 .ok_or_else(|| {
555 AppError::Config(crate::error::ConfigError::Validation(
556 "at least one schema required".into(),
557 ))
558 })?;
559
560 let schemas_by_id: HashMap<_, _> = config.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
561
562 for t in &config.tables {
564 let sid = t.schema_id.as_deref().unwrap_or(default_sid);
565 let schema = schemas_by_id.get(sid).ok_or_else(|| {
566 AppError::Config(crate::error::ConfigError::MissingReference {
567 kind: "schema",
568 id: sid.to_string(),
569 })
570 })?;
571 let schema_raw = schema_override.unwrap_or(&schema.name);
572 let schema_name = quote(schema_raw);
573 let table_name = quote(&t.name);
574 let full_name = format!("{}.{}", schema_name, table_name);
575 if t.audit_log {
576 let audit_full = format!("{}.{}", schema_name, quote(&format!("{}_audit", t.name)));
577 let _ = sqlx::query(&format!("DROP TABLE IF EXISTS {} CASCADE", audit_full))
578 .execute(pool)
579 .await;
580 }
581 if t.versioning.as_ref().is_some_and(|v| v.enabled) {
582 let history_full = format!("{}.{}", schema_name, quote(&format!("{}_history", t.name)));
583 let _ = sqlx::query(&format!("DROP TABLE IF EXISTS {} CASCADE", history_full))
584 .execute(pool)
585 .await;
586 }
587 let drop_sql = format!("DROP TABLE IF EXISTS {} CASCADE", full_name);
588 let _ = sqlx::query(&drop_sql).execute(pool).await;
589 }
590
591 for e in &config.enums {
593 let sid = e.schema_id.as_deref().unwrap_or(default_sid);
594 let schema = schemas_by_id.get(sid).ok_or_else(|| {
595 AppError::Config(crate::error::ConfigError::MissingReference {
596 kind: "schema",
597 id: sid.to_string(),
598 })
599 })?;
600 let schema_name = quote(schema_override.unwrap_or(&schema.name));
601 let type_name = quote(&e.name);
602 let drop_sql = format!("DROP TYPE IF EXISTS {}.{} CASCADE", schema_name, type_name);
603 let _ = sqlx::query(&drop_sql).execute(pool).await;
604 }
605
606 if schema_override.is_none() {
608 for s in &config.schemas {
609 if s.name.eq_ignore_ascii_case("public") {
610 continue;
611 }
612 let schema_name = quote(&s.name);
613 let drop_sql = format!("DROP SCHEMA IF EXISTS {} CASCADE", schema_name);
614 let _ = sqlx::query(&drop_sql).execute(pool).await;
615 }
616 }
617
618 Ok(())
619}
620
621#[derive(Debug, Clone, Serialize, Deserialize)]
624#[serde(rename_all = "snake_case")]
625pub enum MigrationOperation {
626 CreateSchema,
627 CreateEnum,
628 DropEnum,
629 AddEnumValue,
630 RemoveEnumValue,
631 CreateTable,
632 DropTable,
633 AddColumn,
634 DropColumn,
635 RenameColumn,
636 AlterColumnType,
637 BackfillNulls,
638 SetNotNull,
639 DropNotNull,
640 SetDefault,
641 DropDefault,
642 CreateIndex,
643 DropIndex,
644 AddForeignKey,
645 DropForeignKey,
646}
647
648impl std::fmt::Display for MigrationOperation {
649 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
650 let s = serde_json::to_value(self)
651 .ok()
652 .and_then(|v| v.as_str().map(String::from))
653 .unwrap_or_else(|| format!("{:?}", self));
654 write!(f, "{}", s)
655 }
656}
657
658#[derive(Debug, Clone, Serialize, Deserialize)]
660#[serde(rename_all = "snake_case")]
661pub enum MigrationSafety {
662 Safe,
664 BestEffort,
666 WarnOnly,
668}
669
670#[derive(Debug, Clone, Serialize, Deserialize)]
672#[serde(rename_all = "snake_case")]
673pub enum MigrationRisk {
674 None,
675 MayFail,
677 ExistingNullsMustBeAbsent,
679 DataWillBeModified,
681 ManualActionRequired,
683}
684
685#[derive(Debug, Clone, Serialize, Deserialize)]
687pub struct MigrationStep {
688 pub step: usize,
689 pub operation: MigrationOperation,
690 pub schema: String,
691 pub table: Option<String>,
692 pub object: String,
694 pub object_type: String,
696 pub description: String,
697 pub ddl: Option<String>,
699 pub safety: MigrationSafety,
700 pub risk: MigrationRisk,
701 pub risk_detail: Option<String>,
702}
703
704#[derive(Debug, Clone, Serialize, Deserialize)]
706pub struct MigrationPlan {
707 pub steps: Vec<MigrationStep>,
708}
709
710#[derive(Debug, Clone, Serialize)]
711pub struct MigrationSummary {
712 pub total: usize,
713 pub safe: usize,
714 pub best_effort: usize,
715 pub warn_only: usize,
716}
717
718impl MigrationPlan {
719 pub fn summary(&self) -> MigrationSummary {
720 let (mut safe, mut best_effort, mut warn_only) = (0, 0, 0);
721 for s in &self.steps {
722 match s.safety {
723 MigrationSafety::Safe => safe += 1,
724 MigrationSafety::BestEffort => best_effort += 1,
725 MigrationSafety::WarnOnly => warn_only += 1,
726 }
727 }
728 MigrationSummary {
729 total: self.steps.len(),
730 safe,
731 best_effort,
732 warn_only,
733 }
734 }
735}
736
737pub struct MigrationExecutionResult {
739 pub applied: usize,
740 pub warned: usize,
741 pub warnings: Vec<String>,
742}
743
744fn default_str(d: &ColumnDefaultConfig) -> String {
745 match d {
746 ColumnDefaultConfig::Literal(s) => s.clone(),
747 ColumnDefaultConfig::Expression { expression } => expression.clone(),
748 }
749}
750
751struct EnumColumnRef {
754 schema: String,
755 table: String,
756 column: String,
757 default: Option<String>,
758 is_array: bool,
759}
760
761fn enum_type_name(t: &crate::db::CanonicalType) -> Option<(String, bool)> {
764 use crate::db::CanonicalType;
765 let unqualified = |s: &str| s.rsplit('.').next().unwrap_or(s).to_string();
766 match t {
767 CanonicalType::Custom(s) => Some((unqualified(s), false)),
768 CanonicalType::Array(inner) => match inner.as_ref() {
769 CanonicalType::Custom(s) => Some((unqualified(s), true)),
770 _ => None,
771 },
772 _ => None,
773 }
774}
775
776fn enum_dependent_columns(
779 new_enum: &EnumConfig,
780 new: &FullConfig,
781 new_tables: &HashMap<&str, &TableConfig>,
782 new_schemas: &HashMap<&str, &SchemaConfig>,
783 schema_override: Option<&str>,
784) -> Vec<EnumColumnRef> {
785 let default_sid = new.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
786 let mut out = Vec::new();
787 for c in &new.columns {
788 let Some((tyname, is_array)) = enum_type_name(&parse_canonical(&c.type_)) else {
789 continue;
790 };
791 if tyname != new_enum.name {
792 continue;
793 }
794 let Some(table) = new_tables.get(c.table_id.as_str()) else {
795 continue;
796 };
797 let tsid = table.schema_id.as_deref().unwrap_or(default_sid);
798 let schema = schema_override.map(String::from).unwrap_or_else(|| {
799 new_schemas
800 .get(tsid)
801 .map(|s| s.name.clone())
802 .unwrap_or_else(|| tsid.to_string())
803 });
804 out.push(EnumColumnRef {
805 schema,
806 table: table.name.clone(),
807 column: c.name.clone(),
808 default: c.default.as_ref().map(default_str),
809 is_array,
810 });
811 }
812 out
813}
814
815fn recreate_enum_steps(
822 steps: &mut Vec<MigrationStep>,
823 schema: &str,
824 new_enum: &EnumConfig,
825 removed: &[&str],
826 dependents: &[EnumColumnRef],
827) {
828 let type_q = format!("{}.{}", quote(schema), quote(&new_enum.name));
829 let tmp_name = format!("{}__arch_old", new_enum.name);
830 let values: Vec<String> = new_enum
831 .values
832 .iter()
833 .map(|v| format!("'{}'", v.replace('\'', "''")))
834 .collect();
835
836 steps.push(MigrationStep {
838 step: 0,
839 operation: MigrationOperation::RemoveEnumValue,
840 schema: schema.to_string(),
841 table: None,
842 object: format!("{}:{}", new_enum.name, removed.join(",")),
843 object_type: "enum".into(),
844 description: format!(
845 "Rebuild enum \"{}\".\"{}\" to remove value(s): {}",
846 schema,
847 new_enum.name,
848 removed.join(", ")
849 ),
850 ddl: None,
851 safety: MigrationSafety::WarnOnly,
852 risk: MigrationRisk::ManualActionRequired,
853 risk_detail: Some(format!(
854 "PostgreSQL cannot drop enum values in place. The type is rebuilt and {} dependent \
855 column(s) are recast via a text cast. Any existing row holding a removed value ({}) \
856 will make its recast fail — reassign those rows first.",
857 dependents.len(),
858 removed.join(", ")
859 )),
860 });
861
862 steps.push(MigrationStep {
864 step: 0,
865 operation: MigrationOperation::DropEnum,
866 schema: schema.to_string(),
867 table: None,
868 object: new_enum.name.clone(),
869 object_type: "enum".into(),
870 description: format!(
871 "Rename enum \"{}\".\"{}\" to \"{}\" before rebuild",
872 schema, new_enum.name, tmp_name
873 ),
874 ddl: Some(format!(
875 "ALTER TYPE {} RENAME TO {}",
876 type_q,
877 quote(&tmp_name)
878 )),
879 safety: MigrationSafety::BestEffort,
880 risk: MigrationRisk::None,
881 risk_detail: None,
882 });
883
884 steps.push(MigrationStep {
886 step: 0,
887 operation: MigrationOperation::CreateEnum,
888 schema: schema.to_string(),
889 table: None,
890 object: new_enum.name.clone(),
891 object_type: "enum".into(),
892 description: format!(
893 "Recreate enum \"{}\".\"{}\" with {} value(s)",
894 schema,
895 new_enum.name,
896 new_enum.values.len()
897 ),
898 ddl: Some(format!(
899 "CREATE TYPE {} AS ENUM ({})",
900 type_q,
901 values.join(", ")
902 )),
903 safety: MigrationSafety::BestEffort,
904 risk: MigrationRisk::None,
905 risk_detail: None,
906 });
907
908 for dep in dependents {
911 let table_q = format!("{}.{}", quote(&dep.schema), quote(&dep.table));
912 let col_q = quote(&dep.column);
913
914 if dep.default.is_some() {
915 steps.push(MigrationStep {
916 step: 0,
917 operation: MigrationOperation::DropDefault,
918 schema: dep.schema.clone(),
919 table: Some(dep.table.clone()),
920 object: dep.column.clone(),
921 object_type: "column".into(),
922 description: format!(
923 "Drop default on {}.{} before enum recast",
924 dep.table, dep.column
925 ),
926 ddl: Some(format!(
927 "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT",
928 table_q, col_q
929 )),
930 safety: MigrationSafety::BestEffort,
931 risk: MigrationRisk::None,
932 risk_detail: None,
933 });
934 }
935
936 let (col_type, using) = if dep.is_array {
937 (
938 format!("{}[]", type_q),
939 format!("{}::text[]::{}[]", col_q, type_q),
940 )
941 } else {
942 (type_q.clone(), format!("{}::text::{}", col_q, type_q))
943 };
944 steps.push(MigrationStep {
945 step: 0,
946 operation: MigrationOperation::AlterColumnType,
947 schema: dep.schema.clone(),
948 table: Some(dep.table.clone()),
949 object: dep.column.clone(),
950 object_type: "column".into(),
951 description: format!(
952 "Recast {}.{} onto rebuilt enum \"{}\"",
953 dep.table, dep.column, new_enum.name
954 ),
955 ddl: Some(format!(
956 "ALTER TABLE {} ALTER COLUMN {} TYPE {} USING {}",
957 table_q, col_q, col_type, using
958 )),
959 safety: MigrationSafety::BestEffort,
960 risk: MigrationRisk::MayFail,
961 risk_detail: Some(format!(
962 "Cast fails if any row holds a removed value ({}).",
963 removed.join(", ")
964 )),
965 });
966
967 if let Some(def) = &dep.default {
968 steps.push(MigrationStep {
969 step: 0,
970 operation: MigrationOperation::SetDefault,
971 schema: dep.schema.clone(),
972 table: Some(dep.table.clone()),
973 object: dep.column.clone(),
974 object_type: "column".into(),
975 description: format!(
976 "Restore default on {}.{} after enum recast",
977 dep.table, dep.column
978 ),
979 ddl: Some(format!(
980 "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}",
981 table_q, col_q, def
982 )),
983 safety: MigrationSafety::BestEffort,
984 risk: MigrationRisk::None,
985 risk_detail: None,
986 });
987 }
988 }
989
990 steps.push(MigrationStep {
992 step: 0,
993 operation: MigrationOperation::DropEnum,
994 schema: schema.to_string(),
995 table: None,
996 object: tmp_name.clone(),
997 object_type: "enum".into(),
998 description: format!("Drop superseded enum \"{}\".\"{}\"", schema, tmp_name),
999 ddl: Some(format!(
1000 "DROP TYPE IF EXISTS {}.{}",
1001 quote(schema),
1002 quote(&tmp_name)
1003 )),
1004 safety: MigrationSafety::BestEffort,
1005 risk: MigrationRisk::None,
1006 risk_detail: None,
1007 });
1008}
1009
1010pub fn compute_migration_plan(
1016 old: &FullConfig,
1017 new: &FullConfig,
1018 schema_override: Option<&str>,
1019 _rls_tenant_column: Option<&str>,
1020 dialect: &dyn Dialect,
1021 cross_package_configs: &HashMap<String, FullConfig>,
1022) -> Result<MigrationPlan, AppError> {
1023 validate(new)?;
1024
1025 let default_old_sid = old.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
1026 let default_new_sid = new.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
1027
1028 let old_schemas: HashMap<&str, &SchemaConfig> =
1029 old.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
1030 let new_schemas: HashMap<&str, &SchemaConfig> =
1031 new.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
1032 let old_tables: HashMap<&str, &TableConfig> =
1033 old.tables.iter().map(|t| (t.id.as_str(), t)).collect();
1034 let new_tables: HashMap<&str, &TableConfig> =
1035 new.tables.iter().map(|t| (t.id.as_str(), t)).collect();
1036 let old_columns: HashMap<&str, &ColumnConfig> =
1037 old.columns.iter().map(|c| (c.id.as_str(), c)).collect();
1038 let old_enums: HashMap<&str, &EnumConfig> =
1039 old.enums.iter().map(|e| (e.id.as_str(), e)).collect();
1040 let new_enums: HashMap<&str, &EnumConfig> =
1041 new.enums.iter().map(|e| (e.id.as_str(), e)).collect();
1042 let old_indexes: HashMap<&str, &IndexConfig> =
1043 old.indexes.iter().map(|i| (i.id.as_str(), i)).collect();
1044 let new_indexes: HashMap<&str, &IndexConfig> =
1045 new.indexes.iter().map(|i| (i.id.as_str(), i)).collect();
1046 let old_rels: HashMap<&str, &RelationshipConfig> = old
1047 .relationships
1048 .iter()
1049 .map(|r| (r.id.as_str(), r))
1050 .collect();
1051 let new_rels: HashMap<&str, &RelationshipConfig> = new
1052 .relationships
1053 .iter()
1054 .map(|r| (r.id.as_str(), r))
1055 .collect();
1056
1057 let mut steps: Vec<MigrationStep> = Vec::new();
1058
1059 let schema_name_for = |sid: &str, schemas: &HashMap<&str, &SchemaConfig>| -> String {
1060 schema_override.map(String::from).unwrap_or_else(|| {
1061 schemas
1062 .get(sid)
1063 .map(|s| s.name.clone())
1064 .unwrap_or_else(|| sid.to_string())
1065 })
1066 };
1067
1068 if schema_override.is_none() {
1070 for s in &new.schemas {
1071 if !old_schemas.contains_key(s.id.as_str()) {
1072 steps.push(MigrationStep {
1073 step: 0,
1074 operation: MigrationOperation::CreateSchema,
1075 schema: s.name.clone(),
1076 table: None,
1077 object: s.name.clone(),
1078 object_type: "schema".into(),
1079 description: format!("Create schema \"{}\"", s.name),
1080 ddl: Some(format!("CREATE SCHEMA IF NOT EXISTS {}", quote(&s.name))),
1081 safety: MigrationSafety::Safe,
1082 risk: MigrationRisk::None,
1083 risk_detail: None,
1084 });
1085 }
1086 }
1087 }
1088
1089 for new_enum in &new.enums {
1091 let sid = new_enum.schema_id.as_deref().unwrap_or(default_new_sid);
1092 let schema = schema_name_for(sid, &new_schemas);
1093
1094 if let Some(old_enum) = old_enums.get(new_enum.id.as_str()) {
1095 let old_vals: HashSet<&str> = old_enum.values.iter().map(String::as_str).collect();
1096 let new_vals: HashSet<&str> = new_enum.values.iter().map(String::as_str).collect();
1097 let removed: Vec<&str> = old_enum
1098 .values
1099 .iter()
1100 .map(String::as_str)
1101 .filter(|v| !new_vals.contains(v))
1102 .collect();
1103
1104 if removed.is_empty() {
1105 for val in new_enum
1107 .values
1108 .iter()
1109 .map(String::as_str)
1110 .filter(|v| !old_vals.contains(v))
1111 {
1112 steps.push(MigrationStep {
1113 step: 0,
1114 operation: MigrationOperation::AddEnumValue,
1115 schema: schema.clone(),
1116 table: None,
1117 object: format!("{}:{}", new_enum.name, val),
1118 object_type: "enum_value".into(),
1119 description: format!(
1120 "Add value '{}' to enum \"{}\".\"{}\"",
1121 val, schema, new_enum.name
1122 ),
1123 ddl: Some(format!(
1124 "ALTER TYPE {}.{} ADD VALUE IF NOT EXISTS '{}'",
1125 quote(&schema),
1126 quote(&new_enum.name),
1127 val.replace('\'', "''")
1128 )),
1129 safety: MigrationSafety::Safe,
1130 risk: MigrationRisk::None,
1131 risk_detail: None,
1132 });
1133 }
1134 } else {
1135 let dependents = enum_dependent_columns(
1139 new_enum,
1140 new,
1141 &new_tables,
1142 &new_schemas,
1143 schema_override,
1144 );
1145 recreate_enum_steps(&mut steps, &schema, new_enum, &removed, &dependents);
1146 }
1147 } else {
1148 let values: Vec<String> = new_enum
1149 .values
1150 .iter()
1151 .map(|v| format!("'{}'", v.replace('\'', "''")))
1152 .collect();
1153 steps.push(MigrationStep {
1154 step: 0,
1155 operation: MigrationOperation::CreateEnum,
1156 schema: schema.clone(),
1157 table: None,
1158 object: new_enum.name.clone(),
1159 object_type: "enum".into(),
1160 description: format!("Create enum type \"{}\".\"{}\"", schema, new_enum.name),
1161 ddl: Some(format!("CREATE TYPE {}.{} AS ENUM ({})", quote(&schema), quote(&new_enum.name), values.join(", "))),
1162 safety: MigrationSafety::BestEffort,
1163 risk: MigrationRisk::None,
1164 risk_detail: Some("PostgreSQL has no CREATE TYPE IF NOT EXISTS; ignored if the type already exists.".into()),
1165 });
1166 }
1167 }
1168 for old_enum in &old.enums {
1169 if !new_enums.contains_key(old_enum.id.as_str()) {
1170 let sid = old_enum.schema_id.as_deref().unwrap_or(default_old_sid);
1171 let schema = schema_name_for(sid, &old_schemas);
1172 steps.push(MigrationStep {
1173 step: 0,
1174 operation: MigrationOperation::DropEnum,
1175 schema: schema.clone(),
1176 table: None,
1177 object: old_enum.name.clone(),
1178 object_type: "enum".into(),
1179 description: format!("Enum \"{}\".\"{}\" removed from config", schema, old_enum.name),
1180 ddl: None,
1181 safety: MigrationSafety::WarnOnly,
1182 risk: MigrationRisk::ManualActionRequired,
1183 risk_detail: Some("Enum type NOT dropped from database (data safety). Run DROP TYPE manually if intended.".into()),
1184 });
1185 }
1186 }
1187
1188 let added_table_ids: HashSet<&str> = new
1190 .tables
1191 .iter()
1192 .filter(|t| !old_tables.contains_key(t.id.as_str()))
1193 .map(|t| t.id.as_str())
1194 .collect();
1195
1196 let cols_by_table: HashMap<&str, Vec<&ColumnConfig>> =
1197 new.columns.iter().fold(HashMap::new(), |mut m, c| {
1198 m.entry(c.table_id.as_str()).or_default().push(c);
1199 m
1200 });
1201
1202 for new_table in &new.tables {
1203 if !added_table_ids.contains(new_table.id.as_str()) {
1204 continue;
1205 }
1206 let sid = new_table.schema_id.as_deref().unwrap_or(default_new_sid);
1207 let schema = schema_name_for(sid, &new_schemas);
1208 let full = format!("{}.{}", quote(&schema), quote(&new_table.name));
1209
1210 let cols = cols_by_table
1211 .get(new_table.id.as_str())
1212 .map(|v| v.as_slice())
1213 .unwrap_or(&[]);
1214 let mut col_defs: Vec<String> = Vec::new();
1215 for c in cols {
1216 let typ = dialect.ddl_type(&parse_canonical(&c.type_));
1217 let mut def = format!("{} {}", quote(&c.name), typ);
1218 if !c.nullable {
1219 def.push_str(" NOT NULL");
1220 }
1221 if let Some(ref d) = c.default {
1222 def.push_str(" DEFAULT ");
1223 match d {
1224 ColumnDefaultConfig::Literal(s) => def.push_str(s),
1225 ColumnDefaultConfig::Expression { expression } => def.push_str(expression),
1226 }
1227 }
1228 col_defs.push(def);
1229 }
1230 let cfg_col_names: HashSet<&str> = cols.iter().map(|c| c.name.as_str()).collect();
1231 for (name, suf) in [
1236 ("created_at", "TIMESTAMPTZ NOT NULL DEFAULT NOW()"),
1237 ("updated_at", "TIMESTAMPTZ NOT NULL DEFAULT NOW()"),
1238 ("archived_at", "TIMESTAMPTZ"),
1239 ("created_by", "TEXT"),
1240 ("updated_by", "TEXT"),
1241 ] {
1242 if !cfg_col_names.contains(name) {
1243 col_defs.push(format!("{} {}", quote(name), suf));
1244 }
1245 }
1246 let pk_cols = match &new_table.primary_key {
1247 PrimaryKeyConfig::Single(s) => vec![quote(s)],
1248 PrimaryKeyConfig::Composite(v) => v.iter().map(|s| quote(s)).collect(),
1249 };
1250 col_defs.push(format!("PRIMARY KEY ({})", pk_cols.join(", ")));
1251 for u in &new_table.unique {
1252 col_defs.push(format!(
1253 "UNIQUE ({})",
1254 u.iter().map(|s| quote(s)).collect::<Vec<_>>().join(", ")
1255 ));
1256 }
1257 for ch in &new_table.check {
1258 col_defs.push(format!(
1259 "CONSTRAINT {} CHECK ({})",
1260 quote(&ch.name),
1261 ch.expression
1262 ));
1263 }
1264
1265 steps.push(MigrationStep {
1266 step: 0,
1267 operation: MigrationOperation::CreateTable,
1268 schema: schema.clone(),
1269 table: Some(new_table.name.clone()),
1270 object: new_table.name.clone(),
1271 object_type: "table".into(),
1272 description: format!("Create table \"{}\".\"{}\"", schema, new_table.name),
1273 ddl: Some(format!(
1274 "CREATE TABLE IF NOT EXISTS {} (\n {}\n)",
1275 full,
1276 col_defs.join(",\n ")
1277 )),
1278 safety: MigrationSafety::Safe,
1279 risk: MigrationRisk::None,
1280 risk_detail: None,
1281 });
1282 if new_table.audit_log {
1283 let audit_ddl = audit_table_ddl(&schema, &new_table.name, cols, dialect);
1284 steps.push(MigrationStep {
1285 step: 0,
1286 operation: MigrationOperation::CreateTable,
1287 schema: schema.clone(),
1288 table: Some(format!("{}_audit", new_table.name)),
1289 object: format!("{}_audit", new_table.name),
1290 object_type: "table".into(),
1291 description: format!(
1292 "Create audit table \"{}\".\"{}_audit\"",
1293 schema, new_table.name
1294 ),
1295 ddl: Some(audit_ddl),
1296 safety: MigrationSafety::Safe,
1297 risk: MigrationRisk::None,
1298 risk_detail: None,
1299 });
1300 }
1301 if new_table.versioning.as_ref().is_some_and(|v| v.enabled) {
1302 let pk_col = match &new_table.primary_key {
1303 PrimaryKeyConfig::Single(s) => s.clone(),
1304 PrimaryKeyConfig::Composite(v) => v[0].clone(),
1305 };
1306 let history_create = format!(
1308 "CREATE TABLE IF NOT EXISTS {}.{} (\n {}\n)",
1309 quote(&schema),
1310 quote(&format!("{}_history", new_table.name)),
1311 {
1312 let full_ddl =
1313 history_table_ddl(&schema, &new_table.name, &pk_col, cols, dialect);
1314 full_ddl
1315 .lines()
1316 .skip(1) .take_while(|l| !l.trim_start().starts_with("-- index:"))
1318 .collect::<Vec<_>>()
1319 .join("\n")
1320 .trim_end_matches(['\n', ',', ')'])
1321 .to_string()
1322 + "\n)"
1323 }
1324 );
1325 steps.push(MigrationStep {
1326 step: 0,
1327 operation: MigrationOperation::CreateTable,
1328 schema: schema.clone(),
1329 table: Some(format!("{}_history", new_table.name)),
1330 object: format!("{}_history", new_table.name),
1331 object_type: "table".into(),
1332 description: format!(
1333 "Create history table \"{}\".\"{}_history\" (versioning)",
1334 schema, new_table.name
1335 ),
1336 ddl: Some(history_create),
1337 safety: MigrationSafety::Safe,
1338 risk: MigrationRisk::None,
1339 risk_detail: None,
1340 });
1341 steps.push(MigrationStep {
1342 step: 0,
1343 operation: MigrationOperation::CreateIndex,
1344 schema: schema.clone(),
1345 table: Some(format!("{}_history", new_table.name)),
1346 object: format!("{}_history_{}_idx", new_table.name, pk_col),
1347 object_type: "index".into(),
1348 description: format!(
1349 "Create index on history table \"{}\".\"{}\" ({pk_col}, _version DESC)",
1350 schema, new_table.name
1351 ),
1352 ddl: Some(history_index_ddl(&schema, &new_table.name, &pk_col)),
1353 safety: MigrationSafety::Safe,
1354 risk: MigrationRisk::None,
1355 risk_detail: None,
1356 });
1357 }
1358 }
1359
1360 for new_table in &new.tables {
1362 if added_table_ids.contains(new_table.id.as_str()) {
1363 continue;
1364 }
1365 if let Some(old_table) = old_tables.get(new_table.id.as_str()) {
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 cols = cols_by_table
1369 .get(new_table.id.as_str())
1370 .map(|v| v.as_slice())
1371 .unwrap_or(&[]);
1372
1373 if !old_table.audit_log && new_table.audit_log {
1374 let audit_ddl = audit_table_ddl(&schema, &new_table.name, cols, dialect);
1375 steps.push(MigrationStep {
1376 step: 0,
1377 operation: MigrationOperation::CreateTable,
1378 schema: schema.clone(),
1379 table: Some(format!("{}_audit", new_table.name)),
1380 object: format!("{}_audit", new_table.name),
1381 object_type: "table".into(),
1382 description: format!(
1383 "Enable audit log: create \"{}\".\"{}_audit\"",
1384 schema, new_table.name
1385 ),
1386 ddl: Some(audit_ddl),
1387 safety: MigrationSafety::Safe,
1388 risk: MigrationRisk::None,
1389 risk_detail: None,
1390 });
1391 }
1392
1393 let old_versioning_enabled = old_table.versioning.as_ref().is_some_and(|v| v.enabled);
1394 let new_versioning_enabled = new_table.versioning.as_ref().is_some_and(|v| v.enabled);
1395 if !old_versioning_enabled && new_versioning_enabled {
1396 let pk_col = match &new_table.primary_key {
1397 PrimaryKeyConfig::Single(s) => s.clone(),
1398 PrimaryKeyConfig::Composite(v) => v[0].clone(),
1399 };
1400 let history_ddl =
1401 history_table_ddl(&schema, &new_table.name, &pk_col, cols, dialect);
1402 let create_only = history_ddl
1403 .lines()
1404 .take_while(|l| !l.trim_start().starts_with("-- index:"))
1405 .collect::<Vec<_>>()
1406 .join("\n");
1407 steps.push(MigrationStep {
1408 step: 0,
1409 operation: MigrationOperation::CreateTable,
1410 schema: schema.clone(),
1411 table: Some(format!("{}_history", new_table.name)),
1412 object: format!("{}_history", new_table.name),
1413 object_type: "table".into(),
1414 description: format!(
1415 "Enable versioning: create \"{}\".\"{}_history\"",
1416 schema, new_table.name
1417 ),
1418 ddl: Some(create_only.trim().to_string()),
1419 safety: MigrationSafety::Safe,
1420 risk: MigrationRisk::None,
1421 risk_detail: None,
1422 });
1423 steps.push(MigrationStep {
1424 step: 0,
1425 operation: MigrationOperation::CreateIndex,
1426 schema: schema.clone(),
1427 table: Some(format!("{}_history", new_table.name)),
1428 object: format!("{}_history_{}_idx", new_table.name, pk_col),
1429 object_type: "index".into(),
1430 description: format!(
1431 "Create history index on \"{}\".\"{}\"",
1432 schema, new_table.name
1433 ),
1434 ddl: Some(history_index_ddl(&schema, &new_table.name, &pk_col)),
1435 safety: MigrationSafety::Safe,
1436 risk: MigrationRisk::None,
1437 risk_detail: None,
1438 });
1439 }
1440 }
1441 }
1442
1443 for old_table in &old.tables {
1444 if !new_tables.contains_key(old_table.id.as_str()) {
1445 let sid = old_table.schema_id.as_deref().unwrap_or(default_old_sid);
1446 let schema = schema_name_for(sid, &old_schemas);
1447 steps.push(MigrationStep {
1448 step: 0,
1449 operation: MigrationOperation::DropTable,
1450 schema: schema.clone(),
1451 table: Some(old_table.name.clone()),
1452 object: old_table.name.clone(),
1453 object_type: "table".into(),
1454 description: format!("Table \"{}\".\"{}\" removed from config", schema, old_table.name),
1455 ddl: None,
1456 safety: MigrationSafety::WarnOnly,
1457 risk: MigrationRisk::ManualActionRequired,
1458 risk_detail: Some("Table NOT dropped from database (data safety). Run DROP TABLE manually if intended.".into()),
1459 });
1460 }
1461 }
1462
1463 for new_col in &new.columns {
1465 if added_table_ids.contains(new_col.table_id.as_str()) {
1466 continue;
1467 }
1468 let table = match new_tables.get(new_col.table_id.as_str()) {
1469 Some(t) => t,
1470 None => continue,
1471 };
1472 let sid = table.schema_id.as_deref().unwrap_or(default_new_sid);
1473 let schema = schema_name_for(sid, &new_schemas);
1474 let full = format!("{}.{}", quote(&schema), quote(&table.name));
1475
1476 if let Some(old_col) = old_columns.get(new_col.id.as_str()) {
1477 if old_col.table_id != new_col.table_id {
1478 steps.push(MigrationStep {
1479 step: 0,
1480 operation: MigrationOperation::AddColumn,
1481 schema: schema.clone(),
1482 table: Some(table.name.clone()),
1483 object: new_col.name.clone(),
1484 object_type: "column".into(),
1485 description: format!("Column \"{}\" (id: {}) appears to have moved tables — manual migration required", new_col.name, new_col.id),
1486 ddl: None,
1487 safety: MigrationSafety::WarnOnly,
1488 risk: MigrationRisk::ManualActionRequired,
1489 risk_detail: Some(format!("Cannot automate column move from table {} to {}.", old_col.table_id, new_col.table_id)),
1490 });
1491 continue;
1492 }
1493
1494 if old_col.name != new_col.name {
1496 steps.push(MigrationStep {
1497 step: 0,
1498 operation: MigrationOperation::RenameColumn,
1499 schema: schema.clone(),
1500 table: Some(table.name.clone()),
1501 object: new_col.name.clone(),
1502 object_type: "column".into(),
1503 description: format!(
1504 "Rename column \"{}\" → \"{}\" on \"{}\".\"{}\"",
1505 old_col.name, new_col.name, schema, table.name
1506 ),
1507 ddl: Some(format!(
1508 "ALTER TABLE {} RENAME COLUMN {} TO {}",
1509 full,
1510 quote(&old_col.name),
1511 quote(&new_col.name)
1512 )),
1513 safety: MigrationSafety::Safe,
1514 risk: MigrationRisk::None,
1515 risk_detail: None,
1516 });
1517 steps.extend(companion_column_steps(
1518 &schema,
1519 table,
1520 &CompanionColumnOp::Rename {
1521 old: &old_col.name,
1522 new: &new_col.name,
1523 },
1524 ));
1525 }
1526
1527 let old_type = dialect.ddl_type(&parse_canonical(&old_col.type_));
1529 let new_type = dialect.ddl_type(&parse_canonical(&new_col.type_));
1530 if old_type.to_uppercase() != new_type.to_uppercase() {
1531 let col_name = &new_col.name;
1532 steps.push(MigrationStep {
1533 step: 0,
1534 operation: MigrationOperation::AlterColumnType,
1535 schema: schema.clone(),
1536 table: Some(table.name.clone()),
1537 object: col_name.clone(),
1538 object_type: "column".into(),
1539 description: format!("Change type of \"{}\".\"{}\".\"{}\": {} → {}", schema, table.name, col_name, old_type, new_type),
1540 ddl: Some(format!("ALTER TABLE {} ALTER COLUMN {} TYPE {} USING {}::{}", full, quote(col_name), new_type, quote(col_name), new_type)),
1541 safety: MigrationSafety::BestEffort,
1542 risk: MigrationRisk::MayFail,
1543 risk_detail: Some(format!("USING {}::{} cast may fail for incompatible values. Provide a custom USING expression if needed.", col_name, new_type)),
1544 });
1545 steps.extend(companion_column_steps(
1546 &schema,
1547 table,
1548 &CompanionColumnOp::AlterType {
1549 name: col_name.as_str(),
1550 ty: &new_type,
1551 },
1552 ));
1553 }
1554
1555 if old_col.nullable && !new_col.nullable {
1557 if let Some(ref d) = new_col.default {
1558 let default_val = default_str(d);
1559 steps.push(MigrationStep {
1561 step: 0,
1562 operation: MigrationOperation::BackfillNulls,
1563 schema: schema.clone(),
1564 table: Some(table.name.clone()),
1565 object: new_col.name.clone(),
1566 object_type: "column".into(),
1567 description: format!("Backfill NULLs in \"{}\".\"{}\".\"{}\": SET {} = {} WHERE {} IS NULL", schema, table.name, new_col.name, new_col.name, default_val, new_col.name),
1568 ddl: Some(format!("UPDATE {} SET {} = {} WHERE {} IS NULL", full, quote(&new_col.name), default_val, quote(&new_col.name))),
1569 safety: MigrationSafety::Safe,
1570 risk: MigrationRisk::DataWillBeModified,
1571 risk_detail: Some(format!("Existing NULLs in column \"{}\" will be set to {} before NOT NULL is enforced.", new_col.name, default_val)),
1572 });
1573 steps.push(MigrationStep {
1575 step: 0,
1576 operation: MigrationOperation::SetNotNull,
1577 schema: schema.clone(),
1578 table: Some(table.name.clone()),
1579 object: new_col.name.clone(),
1580 object_type: "column".into(),
1581 description: format!("Set NOT NULL on \"{}\".\"{}\".\"{}\": NULLs pre-filled with default ({})", schema, table.name, new_col.name, default_val),
1582 ddl: Some(format!("ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", full, quote(&new_col.name))),
1583 safety: MigrationSafety::Safe,
1584 risk: MigrationRisk::None,
1585 risk_detail: None,
1586 });
1587 } else {
1588 steps.push(MigrationStep {
1590 step: 0,
1591 operation: MigrationOperation::SetNotNull,
1592 schema: schema.clone(),
1593 table: Some(table.name.clone()),
1594 object: new_col.name.clone(),
1595 object_type: "column".into(),
1596 description: format!("Set NOT NULL on \"{}\".\"{}\".\"{}\": no default configured — will fail if NULLs exist", schema, table.name, new_col.name),
1597 ddl: Some(format!("ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", full, quote(&new_col.name))),
1598 safety: MigrationSafety::BestEffort,
1599 risk: MigrationRisk::ExistingNullsMustBeAbsent,
1600 risk_detail: Some(format!(
1601 "No default value configured for column \"{}\". Add a default to the config to enable automatic NULL backfill before enforcing NOT NULL.",
1602 new_col.name
1603 )),
1604 });
1605 }
1606 }
1607
1608 if !old_col.nullable && new_col.nullable {
1610 steps.push(MigrationStep {
1611 step: 0,
1612 operation: MigrationOperation::DropNotNull,
1613 schema: schema.clone(),
1614 table: Some(table.name.clone()),
1615 object: new_col.name.clone(),
1616 object_type: "column".into(),
1617 description: format!(
1618 "Drop NOT NULL on \"{}\".\"{}\".\"{}\": column becomes nullable",
1619 schema, table.name, new_col.name
1620 ),
1621 ddl: Some(format!(
1622 "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL",
1623 full,
1624 quote(&new_col.name)
1625 )),
1626 safety: MigrationSafety::Safe,
1627 risk: MigrationRisk::None,
1628 risk_detail: None,
1629 });
1630 }
1631
1632 let old_def = old_col.default.as_ref().map(default_str);
1634 let new_def = new_col.default.as_ref().map(default_str);
1635 if old_def != new_def {
1636 match &new_col.default {
1637 Some(d) => {
1638 let val = default_str(d);
1639 steps.push(MigrationStep {
1640 step: 0,
1641 operation: MigrationOperation::SetDefault,
1642 schema: schema.clone(),
1643 table: Some(table.name.clone()),
1644 object: new_col.name.clone(),
1645 object_type: "column".into(),
1646 description: format!(
1647 "Set DEFAULT {} on \"{}\".\"{}\".\"{}\": was {}",
1648 val,
1649 schema,
1650 table.name,
1651 new_col.name,
1652 old_def.as_deref().unwrap_or("none")
1653 ),
1654 ddl: Some(format!(
1655 "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}",
1656 full,
1657 quote(&new_col.name),
1658 val
1659 )),
1660 safety: MigrationSafety::Safe,
1661 risk: MigrationRisk::None,
1662 risk_detail: None,
1663 });
1664 }
1665 None => {
1666 steps.push(MigrationStep {
1667 step: 0,
1668 operation: MigrationOperation::DropDefault,
1669 schema: schema.clone(),
1670 table: Some(table.name.clone()),
1671 object: new_col.name.clone(),
1672 object_type: "column".into(),
1673 description: format!(
1674 "Drop DEFAULT on \"{}\".\"{}\".\"{}\": was {}",
1675 schema,
1676 table.name,
1677 new_col.name,
1678 old_def.as_deref().unwrap_or("none")
1679 ),
1680 ddl: Some(format!(
1681 "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT",
1682 full,
1683 quote(&new_col.name)
1684 )),
1685 safety: MigrationSafety::Safe,
1686 risk: MigrationRisk::None,
1687 risk_detail: None,
1688 });
1689 }
1690 }
1691 }
1692 } else {
1693 let new_type = dialect.ddl_type(&parse_canonical(&new_col.type_));
1695 let mut col_def = format!("{} {}", quote(&new_col.name), new_type);
1696 if !new_col.nullable {
1697 col_def.push_str(" NOT NULL");
1698 }
1699 if let Some(ref d) = new_col.default {
1700 col_def.push_str(" DEFAULT ");
1701 match d {
1702 ColumnDefaultConfig::Literal(s) => col_def.push_str(s),
1703 ColumnDefaultConfig::Expression { expression } => col_def.push_str(expression),
1704 }
1705 }
1706 steps.push(MigrationStep {
1707 step: 0,
1708 operation: MigrationOperation::AddColumn,
1709 schema: schema.clone(),
1710 table: Some(table.name.clone()),
1711 object: new_col.name.clone(),
1712 object_type: "column".into(),
1713 description: format!(
1714 "Add column \"{}\" {} to \"{}\".\"{}\"",
1715 new_col.name, new_type, schema, table.name
1716 ),
1717 ddl: Some(format!("ALTER TABLE {} ADD COLUMN {}", full, col_def)),
1718 safety: MigrationSafety::Safe,
1719 risk: MigrationRisk::None,
1720 risk_detail: None,
1721 });
1722 steps.extend(companion_column_steps(
1723 &schema,
1724 table,
1725 &CompanionColumnOp::Add {
1726 name: &new_col.name,
1727 ty: &new_type,
1728 },
1729 ));
1730 }
1731 }
1732
1733 for old_col in &old.columns {
1735 if new.columns.iter().any(|c| c.id == old_col.id) {
1736 continue;
1737 }
1738 if !new_tables.contains_key(old_col.table_id.as_str()) {
1739 continue;
1740 }
1741 let table_name = old_tables
1742 .get(old_col.table_id.as_str())
1743 .map(|t| t.name.as_str())
1744 .unwrap_or(&old_col.table_id);
1745 let sid = old_tables
1746 .get(old_col.table_id.as_str())
1747 .and_then(|t| t.schema_id.as_deref())
1748 .unwrap_or(default_old_sid);
1749 let schema = schema_name_for(sid, &old_schemas);
1750 steps.push(MigrationStep {
1751 step: 0,
1752 operation: MigrationOperation::DropColumn,
1753 schema: schema.clone(),
1754 table: Some(table_name.to_string()),
1755 object: old_col.name.clone(),
1756 object_type: "column".into(),
1757 description: format!("Column \"{}\" removed from config on \"{}\".\"{}\"", old_col.name, schema, table_name),
1758 ddl: None,
1759 safety: MigrationSafety::WarnOnly,
1760 risk: MigrationRisk::ManualActionRequired,
1761 risk_detail: Some("Column NOT dropped from database (data safety). Run ALTER TABLE DROP COLUMN manually if intended.".into()),
1762 });
1763 }
1764
1765 for old_idx in &old.indexes {
1767 if !new_indexes.contains_key(old_idx.id.as_str()) {
1768 let sid = old_idx.schema_id.as_deref().unwrap_or(default_old_sid);
1769 let schema = schema_name_for(sid, &old_schemas);
1770 steps.push(MigrationStep {
1771 step: 0,
1772 operation: MigrationOperation::DropIndex,
1773 schema: schema.clone(),
1774 table: old_tables
1775 .get(old_idx.table_id.as_str())
1776 .map(|t| t.name.clone()),
1777 object: old_idx.name.clone(),
1778 object_type: "index".into(),
1779 description: format!("Drop index \"{}\" in schema \"{}\"", old_idx.name, schema),
1780 ddl: Some(format!(
1781 "DROP INDEX IF EXISTS {}.{}",
1782 quote(&schema),
1783 quote(&old_idx.name)
1784 )),
1785 safety: MigrationSafety::Safe,
1786 risk: MigrationRisk::None,
1787 risk_detail: None,
1788 });
1789 }
1790 }
1791 for new_idx in &new.indexes {
1792 if old_indexes.contains_key(new_idx.id.as_str())
1793 || added_table_ids.contains(new_idx.table_id.as_str())
1794 {
1795 continue;
1796 }
1797 let sid = new_idx.schema_id.as_deref().unwrap_or(default_new_sid);
1798 let schema = match new_schemas.get(sid) {
1799 Some(s) => schema_override.unwrap_or(&s.name).to_string(),
1800 None => continue,
1801 };
1802 let table = match new_tables.get(new_idx.table_id.as_str()) {
1803 Some(t) => t,
1804 None => continue,
1805 };
1806 let full_table = format!("{}.{}", quote(&schema), quote(&table.name));
1807 let mut col_parts: Vec<String> = Vec::new();
1808 for col in &new_idx.columns {
1809 match col {
1810 IndexColumnEntry::Name(n) => col_parts.push(quote(n)),
1811 IndexColumnEntry::Spec {
1812 name, direction, ..
1813 } => {
1814 let dir = direction
1815 .as_deref()
1816 .map(|d| format!(" {}", d.to_uppercase()))
1817 .unwrap_or_default();
1818 col_parts.push(format!("{}{}", quote(name), dir));
1819 }
1820 IndexColumnEntry::Expression { expression } => col_parts.push(expression.clone()),
1821 }
1822 }
1823 let method = new_idx.method.as_deref().unwrap_or("btree");
1824 let unique_kw = if new_idx.unique { "UNIQUE " } else { "" };
1825 let include = if new_idx.include.is_empty() {
1826 String::new()
1827 } else {
1828 format!(
1829 " INCLUDE ({})",
1830 new_idx
1831 .include
1832 .iter()
1833 .map(|s| quote(s))
1834 .collect::<Vec<_>>()
1835 .join(", ")
1836 )
1837 };
1838 let where_clause = new_idx
1839 .where_
1840 .as_ref()
1841 .map(|w| format!(" WHERE {}", w))
1842 .unwrap_or_default();
1843 steps.push(MigrationStep {
1844 step: 0,
1845 operation: MigrationOperation::CreateIndex,
1846 schema: schema.clone(),
1847 table: Some(table.name.clone()),
1848 object: new_idx.name.clone(),
1849 object_type: "index".into(),
1850 description: format!(
1851 "Create {}index \"{}\" on \"{}\".\"{}\"",
1852 if new_idx.unique { "unique " } else { "" },
1853 new_idx.name,
1854 schema,
1855 table.name
1856 ),
1857 ddl: Some(format!(
1858 "CREATE {}INDEX IF NOT EXISTS {} ON {} USING {} ({}){}{}",
1859 unique_kw,
1860 quote(&new_idx.name),
1861 full_table,
1862 method,
1863 col_parts.join(", "),
1864 include,
1865 where_clause
1866 )),
1867 safety: MigrationSafety::Safe,
1868 risk: MigrationRisk::None,
1869 risk_detail: None,
1870 });
1871 }
1872
1873 for old_rel in &old.relationships {
1875 if !new_rels.contains_key(old_rel.id.as_str()) {
1876 let from_sid_fallback = old_rel.from_schema_id.as_deref().unwrap_or(default_old_sid);
1877 let from_schema = old_schemas
1878 .get(from_sid_fallback)
1879 .map(|s| s.name.as_str())
1880 .unwrap_or(from_sid_fallback);
1881 let from_table = old_tables
1882 .get(old_rel.from_table_id.as_str())
1883 .map(|t| t.name.as_str())
1884 .unwrap_or(&old_rel.from_table_id);
1885 let constraint = old_rel.name.as_deref().unwrap_or(&old_rel.id);
1886 let schema_q = quote(schema_override.unwrap_or(from_schema));
1887 steps.push(MigrationStep {
1888 step: 0,
1889 operation: MigrationOperation::DropForeignKey,
1890 schema: schema_override.unwrap_or(from_schema).to_string(),
1891 table: Some(from_table.to_string()),
1892 object: constraint.to_string(),
1893 object_type: "foreign_key".into(),
1894 description: format!(
1895 "Drop FK \"{}\" from \"{}\".\"{}\"",
1896 constraint,
1897 schema_override.unwrap_or(from_schema),
1898 from_table
1899 ),
1900 ddl: Some(format!(
1901 "ALTER TABLE {}.{} DROP CONSTRAINT IF EXISTS {}",
1902 schema_q,
1903 quote(from_table),
1904 quote(constraint)
1905 )),
1906 safety: MigrationSafety::Safe,
1907 risk: MigrationRisk::None,
1908 risk_detail: None,
1909 });
1910 }
1911 }
1912 for new_rel in &new.relationships {
1913 if old_rels.contains_key(new_rel.id.as_str())
1914 || added_table_ids.contains(new_rel.from_table_id.as_str())
1915 || added_table_ids.contains(new_rel.to_table_id.as_str())
1916 {
1917 continue;
1918 }
1919 let from_sid = new_rel.from_schema_id.as_deref().unwrap_or(default_new_sid);
1920 let from_schema = match new_schemas.get(from_sid) {
1921 Some(s) => s,
1922 None => continue,
1923 };
1924 let from_table = match new_tables.get(new_rel.from_table_id.as_str()) {
1925 Some(t) => t,
1926 None => continue,
1927 };
1928 let from_col = new
1929 .columns
1930 .iter()
1931 .find(|c| c.id == new_rel.from_column_id)
1932 .map(|c| c.name.clone())
1933 .unwrap_or_else(|| new_rel.from_column_id.clone());
1934
1935 let (to_schema_name, to_table_name, to_col) =
1937 if let Some(pkg_id) = new_rel.to_package_id.as_deref() {
1938 match cross_package_configs.get(pkg_id) {
1939 Some(foreign) => {
1940 let foreign_tables: HashMap<_, _> =
1941 foreign.tables.iter().map(|t| (t.id.as_str(), t)).collect();
1942 let foreign_schemas: HashMap<_, _> =
1943 foreign.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
1944 let foreign_default_sid =
1945 foreign.schemas.first().map(|s| s.id.as_str()).unwrap_or("");
1946 let to_sid = new_rel
1947 .to_schema_id
1948 .as_deref()
1949 .unwrap_or(foreign_default_sid);
1950 let tbl = match foreign_tables.get(new_rel.to_table_id.as_str()) {
1951 Some(t) => t,
1952 None => continue,
1953 };
1954 let schema = match foreign_schemas.get(to_sid) {
1955 Some(s) => s,
1956 None => continue,
1957 };
1958 let col = foreign
1959 .columns
1960 .iter()
1961 .find(|c| c.id == new_rel.to_column_id)
1962 .map(|c| c.name.clone())
1963 .unwrap_or_else(|| new_rel.to_column_id.clone());
1964 (schema.name.clone(), tbl.name.clone(), col)
1965 }
1966 None => continue,
1967 }
1968 } else {
1969 let to_sid = new_rel.to_schema_id.as_deref().unwrap_or(default_new_sid);
1970 let to_schema = match new_schemas.get(to_sid) {
1971 Some(s) => s,
1972 None => continue,
1973 };
1974 let to_table = match new_tables.get(new_rel.to_table_id.as_str()) {
1975 Some(t) => t,
1976 None => continue,
1977 };
1978 let col = new
1979 .columns
1980 .iter()
1981 .find(|c| c.id == new_rel.to_column_id)
1982 .map(|c| c.name.clone())
1983 .unwrap_or_else(|| new_rel.to_column_id.clone());
1984 (
1985 schema_override.unwrap_or(&to_schema.name).to_string(),
1986 to_table.name.clone(),
1987 col,
1988 )
1989 };
1990
1991 let from_schema_str = schema_override.unwrap_or(&from_schema.name);
1992 let from_q = format!("{}.{}", quote(from_schema_str), quote(&from_table.name));
1993 let to_q = format!("{}.{}", quote(&to_schema_name), quote(&to_table_name));
1994 let constraint = new_rel.name.as_deref().unwrap_or(&new_rel.id);
1995 let on_update = new_rel.on_update.as_deref().unwrap_or("NO ACTION");
1996 let on_delete = new_rel.on_delete.as_deref().unwrap_or("NO ACTION");
1997 steps.push(MigrationStep {
1998 step: 0,
1999 operation: MigrationOperation::AddForeignKey,
2000 schema: from_schema_str.to_string(),
2001 table: Some(from_table.name.clone()),
2002 object: constraint.to_string(),
2003 object_type: "foreign_key".into(),
2004 description: format!(
2005 "Add FK \"{}\" on \"{}\".\"{}\" → \"{}\".\"{}\"",
2006 constraint, from_schema_str, from_table.name, to_schema_name, to_table_name
2007 ),
2008 ddl: Some(format!(
2009 "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({}) ON UPDATE {} ON DELETE {}",
2010 from_q, quote(constraint), quote(&from_col), to_q, quote(&to_col), on_update, on_delete
2011 )),
2012 safety: MigrationSafety::BestEffort,
2013 risk: MigrationRisk::None,
2014 risk_detail: Some("PostgreSQL has no ADD CONSTRAINT IF NOT EXISTS; ignored if constraint already exists.".into()),
2015 });
2016 }
2017
2018 for (i, s) in steps.iter_mut().enumerate() {
2020 s.step = i + 1;
2021 }
2022
2023 Ok(MigrationPlan { steps })
2024}
2025
2026#[allow(clippy::too_many_arguments)]
2032pub async fn execute_migration_plan(
2033 migration_pool: &Pool,
2034 config_pool: &Pool,
2035 plan: &MigrationPlan,
2036 migration_plan_id: &str,
2037 package_id: &str,
2038 tenant_id: &str,
2039 from_version: Option<&str>,
2040 to_version: &str,
2041) -> Result<MigrationExecutionResult, AppError> {
2042 let mut applied = 0usize;
2043 let mut warned = 0usize;
2044 let mut warnings: Vec<String> = Vec::new();
2045
2046 for step in &plan.steps {
2047 let op = step.operation.to_string();
2048 let safety_str = format!("{:?}", step.safety);
2049 let risk_str = format!("{:?}", step.risk);
2050
2051 match step.safety {
2052 MigrationSafety::WarnOnly => {
2053 let msg = step
2054 .risk_detail
2055 .clone()
2056 .unwrap_or_else(|| step.description.clone());
2057 tracing::warn!(step = step.step, %op, "migration plan warning (no DDL)");
2058 warnings.push(format!("[Step {}] {}", step.step, msg));
2059 let _ = crate::store::insert_migration_audit(
2060 config_pool,
2061 migration_plan_id,
2062 package_id,
2063 tenant_id,
2064 from_version,
2065 to_version,
2066 step.step as i32,
2067 &op,
2068 &step.schema,
2069 step.table.as_deref(),
2070 &step.object,
2071 &step.object_type,
2072 &step.description,
2073 step.ddl.as_deref(),
2074 &safety_str,
2075 &risk_str,
2076 "skipped",
2077 None,
2078 )
2079 .await;
2080 warned += 1;
2081 }
2082 MigrationSafety::Safe | MigrationSafety::BestEffort => {
2083 if let Some(ref sql) = step.ddl {
2084 tracing::info!(step = step.step, %op, %sql, "executing migration step");
2085 match sqlx::query(sql).execute(migration_pool).await {
2086 Ok(_) => {
2087 let _ = crate::store::insert_migration_audit(
2088 config_pool,
2089 migration_plan_id,
2090 package_id,
2091 tenant_id,
2092 from_version,
2093 to_version,
2094 step.step as i32,
2095 &op,
2096 &step.schema,
2097 step.table.as_deref(),
2098 &step.object,
2099 &step.object_type,
2100 &step.description,
2101 step.ddl.as_deref(),
2102 &safety_str,
2103 &risk_str,
2104 "applied",
2105 None,
2106 )
2107 .await;
2108 applied += 1;
2109 }
2110 Err(e) => {
2111 let err_str = e.to_string();
2112 if matches!(step.safety, MigrationSafety::BestEffort) {
2113 tracing::warn!(step = step.step, %op, error = %e, "migration step failed (best-effort, continuing)");
2114 let msg = format!(
2115 "[Step {}] {} — Error: {}",
2116 step.step, step.description, err_str
2117 );
2118 warnings.push(msg);
2119 let _ = crate::store::insert_migration_audit(
2120 config_pool,
2121 migration_plan_id,
2122 package_id,
2123 tenant_id,
2124 from_version,
2125 to_version,
2126 step.step as i32,
2127 &op,
2128 &step.schema,
2129 step.table.as_deref(),
2130 &step.object,
2131 &step.object_type,
2132 &step.description,
2133 step.ddl.as_deref(),
2134 &safety_str,
2135 &risk_str,
2136 "warned",
2137 Some(&err_str),
2138 )
2139 .await;
2140 warned += 1;
2141 } else {
2142 let _ = crate::store::insert_migration_audit(
2143 config_pool,
2144 migration_plan_id,
2145 package_id,
2146 tenant_id,
2147 from_version,
2148 to_version,
2149 step.step as i32,
2150 &op,
2151 &step.schema,
2152 step.table.as_deref(),
2153 &step.object,
2154 &step.object_type,
2155 &step.description,
2156 step.ddl.as_deref(),
2157 &safety_str,
2158 &risk_str,
2159 "failed",
2160 Some(&err_str),
2161 )
2162 .await;
2163 return Err(AppError::Db(e));
2164 }
2165 }
2166 }
2167 }
2168 }
2169 }
2170 }
2171
2172 Ok(MigrationExecutionResult {
2173 applied,
2174 warned,
2175 warnings,
2176 })
2177}
2178
2179pub fn history_table_ddl(
2183 schema_name: &str,
2184 table_name: &str,
2185 pk_col: &str,
2186 source_cols: &[&ColumnConfig],
2187 dialect: &dyn Dialect,
2188) -> String {
2189 let history_name = format!("{}_history", table_name);
2190 let history_full = format!("{}.{}", quote(schema_name), quote(&history_name));
2191
2192 let mut col_defs: Vec<String> = Vec::new();
2193 col_defs.push(format!(
2194 "{} {} NOT NULL DEFAULT {}",
2195 quote("_history_id"),
2196 "UUID",
2197 dialect.uuid_default_expr()
2198 ));
2199 col_defs.push(format!("{} BIGINT NOT NULL", quote("_version")));
2200 col_defs.push(format!("{} TEXT NOT NULL", quote("_operation")));
2201 col_defs.push(format!(
2202 "{} {} NOT NULL DEFAULT {}",
2203 quote("_recorded_at"),
2204 dialect.audit_timestamp_type(),
2205 dialect.now_fn()
2206 ));
2207 col_defs.push(format!(
2208 "{} {}",
2209 quote("_valid_from"),
2210 dialect.audit_timestamp_type()
2211 ));
2212 col_defs.push(format!(
2213 "{} {}",
2214 quote("_valid_to"),
2215 dialect.audit_timestamp_type()
2216 ));
2217
2218 let config_col_names: HashSet<&str> = source_cols.iter().map(|c| c.name.as_str()).collect();
2219 for c in source_cols {
2220 let typ = dialect.ddl_type(&parse_canonical(&c.type_));
2221 col_defs.push(format!("{} {}", quote(&c.name), typ));
2222 }
2223 let audit_ts = dialect.audit_timestamp_type();
2224 for (name, typ) in [
2225 ("created_at", audit_ts),
2226 ("updated_at", audit_ts),
2227 ("archived_at", audit_ts),
2228 ("created_by", "TEXT"),
2229 ("updated_by", "TEXT"),
2230 ] {
2231 if !config_col_names.contains(name) {
2232 col_defs.push(format!("{} {}", quote(name), typ));
2233 }
2234 }
2235 col_defs.push(format!("PRIMARY KEY ({})", quote("_history_id")));
2236
2237 let history_full_quoted = format!("{}.{}", quote(schema_name), quote(&history_name));
2238 let idx_sql = format!(
2239 "-- index: CREATE INDEX IF NOT EXISTS {} ON {} ({}, {})",
2240 quote(&format!("{}_history_{}_idx", table_name, pk_col)),
2241 history_full_quoted,
2242 quote(pk_col),
2243 quote("_version")
2244 );
2245
2246 format!(
2247 "CREATE TABLE IF NOT EXISTS {} (\n {}\n)\n{}",
2248 history_full,
2249 col_defs.join(",\n "),
2250 idx_sql
2251 )
2252}
2253
2254fn history_index_ddl(schema_name: &str, table_name: &str, pk_col: &str) -> String {
2256 format!(
2257 "CREATE INDEX IF NOT EXISTS {} ON {}.{} ({}, {} DESC)",
2258 quote(&format!("{}_history_{}_idx", table_name, pk_col)),
2259 quote(schema_name),
2260 quote(&format!("{}_history", table_name)),
2261 quote(pk_col),
2262 quote("_version")
2263 )
2264}
2265
2266enum CompanionColumnOp<'a> {
2268 Add { name: &'a str, ty: &'a str },
2270 Rename { old: &'a str, new: &'a str },
2272 AlterType { name: &'a str, ty: &'a str },
2274}
2275
2276fn enabled_companion_suffixes(table: &TableConfig) -> Vec<&'static str> {
2278 let mut suffixes = Vec::new();
2279 if table.audit_log {
2280 suffixes.push("audit");
2281 }
2282 if table.versioning.as_ref().is_some_and(|v| v.enabled) {
2283 suffixes.push("history");
2284 }
2285 suffixes
2286}
2287
2288fn companion_column_steps(
2295 schema: &str,
2296 table: &TableConfig,
2297 op: &CompanionColumnOp<'_>,
2298) -> Vec<MigrationStep> {
2299 let mut steps = Vec::new();
2300 for suffix in enabled_companion_suffixes(table) {
2301 let companion = format!("{}_{}", table.name, suffix);
2302 let full = format!("{}.{}", quote(schema), quote(&companion));
2303 let (operation, object, ddl, description, safety, risk, risk_detail) = match op {
2304 CompanionColumnOp::Add { name, ty } => (
2305 MigrationOperation::AddColumn,
2306 name.to_string(),
2307 format!(
2310 "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} {}",
2311 full,
2312 quote(name),
2313 ty
2314 ),
2315 format!(
2316 "Sync {} table: add column \"{}\" to \"{}\".\"{}\"",
2317 suffix, name, schema, companion
2318 ),
2319 MigrationSafety::Safe,
2320 MigrationRisk::None,
2321 None,
2322 ),
2323 CompanionColumnOp::Rename { old, new } => (
2324 MigrationOperation::RenameColumn,
2325 new.to_string(),
2326 format!(
2327 "ALTER TABLE {} RENAME COLUMN {} TO {}",
2328 full,
2329 quote(old),
2330 quote(new)
2331 ),
2332 format!(
2333 "Sync {} table: rename column \"{}\" → \"{}\" on \"{}\".\"{}\"",
2334 suffix, old, new, schema, companion
2335 ),
2336 MigrationSafety::Safe,
2337 MigrationRisk::None,
2338 None,
2339 ),
2340 CompanionColumnOp::AlterType { name, ty } => (
2341 MigrationOperation::AlterColumnType,
2342 name.to_string(),
2343 format!(
2344 "ALTER TABLE {} ALTER COLUMN {} TYPE {} USING {}::{}",
2345 full,
2346 quote(name),
2347 ty,
2348 quote(name),
2349 ty
2350 ),
2351 format!(
2352 "Sync {} table: change type of \"{}\".\"{}\".\"{}\" → {}",
2353 suffix, schema, companion, name, ty
2354 ),
2355 MigrationSafety::BestEffort,
2356 MigrationRisk::MayFail,
2357 Some(format!(
2358 "USING {}::{} cast may fail for incompatible values in the {} table.",
2359 name, ty, suffix
2360 )),
2361 ),
2362 };
2363 steps.push(MigrationStep {
2364 step: 0,
2365 operation,
2366 schema: schema.to_string(),
2367 table: Some(companion.clone()),
2368 object,
2369 object_type: "column".into(),
2370 description,
2371 ddl: Some(ddl),
2372 safety,
2373 risk,
2374 risk_detail,
2375 });
2376 }
2377 steps
2378}
2379
2380fn audit_table_ddl(
2384 schema_name: &str,
2385 table_name: &str,
2386 source_cols: &[&ColumnConfig],
2387 dialect: &dyn Dialect,
2388) -> String {
2389 let audit_name = format!("{}_audit", table_name);
2390 let audit_full = format!("{}.{}", quote(schema_name), quote(&audit_name));
2391
2392 let mut col_defs: Vec<String> = Vec::new();
2393 col_defs.push(format!(
2394 "{} {} NOT NULL DEFAULT {}",
2395 quote("audit_id"),
2396 "UUID",
2397 dialect.uuid_default_expr()
2398 ));
2399 col_defs.push(format!("{} TEXT NOT NULL", quote("audit_action")));
2400 col_defs.push(format!(
2401 "{} {} NOT NULL DEFAULT {}",
2402 quote("audit_at"),
2403 dialect.audit_timestamp_type(),
2404 dialect.now_fn()
2405 ));
2406 col_defs.push(format!("{} TEXT", quote("audit_by")));
2407 col_defs.push(format!(
2408 "{} {}",
2409 quote("changed_fields"),
2410 dialect.sys_json_type()
2411 ));
2412
2413 let config_col_names: HashSet<&str> = source_cols.iter().map(|c| c.name.as_str()).collect();
2414 for c in source_cols {
2415 let typ = dialect.ddl_type(&parse_canonical(&c.type_));
2416 col_defs.push(format!("{} {}", quote(&c.name), typ));
2417 }
2418 let audit_ts = dialect.audit_timestamp_type();
2419 for (name, typ) in [
2420 ("created_at", audit_ts),
2421 ("updated_at", audit_ts),
2422 ("archived_at", audit_ts),
2423 ("created_by", "TEXT"),
2424 ("updated_by", "TEXT"),
2425 ] {
2426 if !config_col_names.contains(name) {
2427 col_defs.push(format!("{} {}", quote(name), typ));
2428 }
2429 }
2430 col_defs.push(format!("PRIMARY KEY ({})", quote("audit_id")));
2431
2432 format!(
2433 "CREATE TABLE IF NOT EXISTS {} (\n {}\n)",
2434 audit_full,
2435 col_defs.join(",\n ")
2436 )
2437}
2438
2439#[cfg(test)]
2440mod enum_recreate_tests {
2441 use super::*;
2442
2443 fn schema(id: &str, name: &str) -> SchemaConfig {
2444 SchemaConfig {
2445 id: id.into(),
2446 name: name.into(),
2447 comment: None,
2448 }
2449 }
2450
2451 fn table(id: &str, name: &str, schema_id: &str) -> TableConfig {
2452 TableConfig {
2453 id: id.into(),
2454 schema_id: Some(schema_id.into()),
2455 name: name.into(),
2456 comment: None,
2457 primary_key: PrimaryKeyConfig::Single("id".into()),
2458 unique: vec![],
2459 check: vec![],
2460 audit_log: false,
2461 versioning: None,
2462 global: false,
2463 }
2464 }
2465
2466 fn col(id: &str, table_id: &str, name: &str, ty: &str, default: Option<&str>) -> ColumnConfig {
2467 ColumnConfig {
2468 id: id.into(),
2469 table_id: table_id.into(),
2470 name: name.into(),
2471 type_: ColumnTypeConfig::Simple(ty.into()),
2472 nullable: true,
2473 default: default.map(|d| ColumnDefaultConfig::Literal(d.into())),
2474 comment: None,
2475 asset: None,
2476 extensible: false,
2477 }
2478 }
2479
2480 fn enum_cfg(id: &str, name: &str, schema_id: &str, values: &[&str]) -> EnumConfig {
2481 EnumConfig {
2482 id: id.into(),
2483 schema_id: Some(schema_id.into()),
2484 name: name.into(),
2485 values: values.iter().map(|s| s.to_string()).collect(),
2486 comment: None,
2487 }
2488 }
2489
2490 fn ddls(steps: &[MigrationStep]) -> Vec<String> {
2491 steps.iter().filter_map(|s| s.ddl.clone()).collect()
2492 }
2493
2494 #[test]
2495 fn finds_scalar_and_array_dependent_columns() {
2496 let mut cfg = FullConfig::default();
2497 cfg.schemas = vec![schema("s1", "app")];
2498 cfg.tables = vec![table("t_orders", "orders", "s1")];
2499 cfg.columns = vec![
2500 col(
2501 "c1",
2502 "t_orders",
2503 "status",
2504 "order_status",
2505 Some("'pending'"),
2506 ),
2507 col("c2", "t_orders", "tags", "order_status[]", None),
2508 col("c3", "t_orders", "name", "text", None), ];
2510 let e = enum_cfg("e1", "order_status", "s1", &["pending", "shipped"]);
2511 let new_tables: HashMap<&str, &TableConfig> =
2512 cfg.tables.iter().map(|t| (t.id.as_str(), t)).collect();
2513 let new_schemas: HashMap<&str, &SchemaConfig> =
2514 cfg.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
2515
2516 let deps = enum_dependent_columns(&e, &cfg, &new_tables, &new_schemas, None);
2517 assert_eq!(deps.len(), 2);
2518 let scalar = deps.iter().find(|d| d.column == "status").unwrap();
2519 assert_eq!(scalar.schema, "app");
2520 assert_eq!(scalar.table, "orders");
2521 assert!(!scalar.is_array);
2522 assert_eq!(scalar.default.as_deref(), Some("'pending'"));
2523 let arr = deps.iter().find(|d| d.column == "tags").unwrap();
2524 assert!(arr.is_array);
2525 assert!(arr.default.is_none());
2526 }
2527
2528 #[test]
2529 fn recreate_sequence_emits_rename_create_recast_drop() {
2530 let e = enum_cfg("e1", "order_status", "s1", &["pending", "shipped"]);
2531 let deps = vec![
2532 EnumColumnRef {
2533 schema: "app".into(),
2534 table: "orders".into(),
2535 column: "status".into(),
2536 default: Some("'pending'".into()),
2537 is_array: false,
2538 },
2539 EnumColumnRef {
2540 schema: "app".into(),
2541 table: "orders".into(),
2542 column: "tags".into(),
2543 default: None,
2544 is_array: true,
2545 },
2546 ];
2547 let mut steps = Vec::new();
2548 recreate_enum_steps(&mut steps, "app", &e, &["cancelled"], &deps);
2549 let sql = ddls(&steps);
2550
2551 assert!(matches!(steps[0].safety, MigrationSafety::WarnOnly));
2553 assert!(steps[0].ddl.is_none());
2554
2555 assert_eq!(
2557 sql[0],
2558 r#"ALTER TYPE "app"."order_status" RENAME TO "order_status__arch_old""#
2559 );
2560 assert_eq!(
2561 sql[1],
2562 r#"CREATE TYPE "app"."order_status" AS ENUM ('pending', 'shipped')"#
2563 );
2564 assert_eq!(
2565 sql[2],
2566 r#"ALTER TABLE "app"."orders" ALTER COLUMN "status" DROP DEFAULT"#
2567 );
2568 assert_eq!(
2569 sql[3],
2570 r#"ALTER TABLE "app"."orders" ALTER COLUMN "status" TYPE "app"."order_status" USING "status"::text::"app"."order_status""#
2571 );
2572 assert_eq!(
2573 sql[4],
2574 r#"ALTER TABLE "app"."orders" ALTER COLUMN "status" SET DEFAULT 'pending'"#
2575 );
2576 assert_eq!(
2578 sql[5],
2579 r#"ALTER TABLE "app"."orders" ALTER COLUMN "tags" TYPE "app"."order_status"[] USING "tags"::text[]::"app"."order_status"[]"#
2580 );
2581 assert_eq!(
2582 *sql.last().unwrap(),
2583 r#"DROP TYPE IF EXISTS "app"."order_status__arch_old""#
2584 );
2585 }
2586}
2587
2588#[cfg(all(test, feature = "sqlite"))]
2589mod companion_sync_tests {
2590 use super::*;
2591 use crate::db::sqlite::SqliteDialect;
2592
2593 fn schema(id: &str, name: &str) -> SchemaConfig {
2594 SchemaConfig {
2595 id: id.into(),
2596 name: name.into(),
2597 comment: None,
2598 }
2599 }
2600
2601 fn table(id: &str, name: &str, audit: bool, versioning: bool) -> TableConfig {
2602 TableConfig {
2603 id: id.into(),
2604 schema_id: Some("s1".into()),
2605 name: name.into(),
2606 comment: None,
2607 primary_key: PrimaryKeyConfig::Single("id".into()),
2608 unique: vec![],
2609 check: vec![],
2610 audit_log: audit,
2611 versioning: versioning.then(|| VersioningConfig {
2612 enabled: true,
2613 keep_versions: None,
2614 }),
2615 global: false,
2616 }
2617 }
2618
2619 fn col(id: &str, name: &str, ty: &str) -> ColumnConfig {
2620 ColumnConfig {
2621 id: id.into(),
2622 table_id: "t1".into(),
2623 name: name.into(),
2624 type_: ColumnTypeConfig::Simple(ty.into()),
2625 nullable: true,
2626 default: None,
2627 comment: None,
2628 asset: None,
2629 extensible: false,
2630 }
2631 }
2632
2633 fn base(audit: bool, versioning: bool) -> FullConfig {
2634 let mut cfg = FullConfig::default();
2635 cfg.schemas = vec![schema("s1", "app")];
2636 cfg.tables = vec![table("t1", "orders", audit, versioning)];
2637 cfg.columns = vec![col("c0", "id", "uuid"), col("c1", "status", "text")];
2638 cfg
2639 }
2640
2641 fn plan(old: &FullConfig, new: &FullConfig) -> Vec<String> {
2642 let dialect = SqliteDialect;
2643 compute_migration_plan(old, new, None, None, &dialect, &HashMap::new())
2644 .unwrap()
2645 .steps
2646 .into_iter()
2647 .filter_map(|s| s.ddl)
2648 .collect()
2649 }
2650
2651 #[test]
2652 fn add_column_syncs_audit_and_history() {
2653 let old = base(true, true);
2654 let mut new = base(true, true);
2655 new.columns.push(col("c2", "note", "text"));
2656
2657 let sql = plan(&old, &new);
2658 assert!(sql
2659 .iter()
2660 .any(|s| s == r#"ALTER TABLE "app"."orders" ADD COLUMN "note" TEXT"#));
2661 assert!(sql.iter().any(
2662 |s| s == r#"ALTER TABLE "app"."orders_audit" ADD COLUMN IF NOT EXISTS "note" TEXT"#
2663 ));
2664 assert!(sql
2665 .iter()
2666 .any(|s| s
2667 == r#"ALTER TABLE "app"."orders_history" ADD COLUMN IF NOT EXISTS "note" TEXT"#));
2668 }
2669
2670 #[test]
2671 fn rename_column_syncs_companions() {
2672 let old = base(true, true);
2673 let mut new = base(true, true);
2674 new.columns[1].name = "state".into();
2675
2676 let sql = plan(&old, &new);
2677 assert!(sql
2678 .iter()
2679 .any(|s| s == r#"ALTER TABLE "app"."orders_audit" RENAME COLUMN "status" TO "state""#));
2680 assert!(sql.iter().any(
2681 |s| s == r#"ALTER TABLE "app"."orders_history" RENAME COLUMN "status" TO "state""#
2682 ));
2683 }
2684
2685 #[test]
2686 fn alter_type_syncs_companions() {
2687 let old = base(true, false);
2688 let mut new = base(true, false);
2689 new.columns[1].type_ = ColumnTypeConfig::Simple("integer".into());
2690
2691 let sql = plan(&old, &new);
2692 assert!(sql.iter().any(|s| s
2693 == r#"ALTER TABLE "app"."orders_audit" ALTER COLUMN "status" TYPE INTEGER USING "status"::INTEGER"#));
2694 assert!(!sql.iter().any(|s| s.contains("orders_history")));
2696 }
2697
2698 #[test]
2699 fn no_companion_steps_when_features_disabled() {
2700 let old = base(false, false);
2701 let mut new = base(false, false);
2702 new.columns.push(col("c2", "note", "text"));
2703
2704 let sql = plan(&old, &new);
2705 assert!(sql
2706 .iter()
2707 .any(|s| s == r#"ALTER TABLE "app"."orders" ADD COLUMN "note" TEXT"#));
2708 assert!(!sql.iter().any(|s| s.contains("orders_audit")));
2709 assert!(!sql.iter().any(|s| s.contains("orders_history")));
2710 }
2711
2712 #[test]
2713 fn nullability_change_does_not_touch_companions() {
2714 let old = base(true, true);
2715 let mut new = base(true, true);
2716 new.columns[1].nullable = false;
2717
2718 let sql = plan(&old, &new);
2719 assert!(sql.iter().any(|s| s.contains("SET NOT NULL")));
2721 assert!(!sql.iter().any(|s| s.contains("orders_audit")));
2722 assert!(!sql.iter().any(|s| s.contains("orders_history")));
2723 }
2724}