1use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5use tokio_postgres::Client;
6
7use crate::error::ToolError;
8use crate::sql::is_safe_ident;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11#[serde(rename_all = "camelCase")]
12pub struct SchemaSnapshot {
13 pub tables: Vec<TableSnapshot>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(rename_all = "camelCase")]
18pub struct TableSnapshot {
19 pub name: String,
20 pub schema: String,
21 pub columns: Vec<ColumnSnapshot>,
22 pub constraints: Vec<ConstraintSnapshot>,
23 pub indexes: Vec<IndexSnapshot>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct ColumnSnapshot {
28 pub column_name: String,
29 pub data_type: String,
30 pub not_null: bool,
31 pub default_value: Option<String>,
32 pub ordinal: i32,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36pub struct ConstraintSnapshot {
37 pub name: String,
38 #[serde(rename = "type")]
39 pub type_: String,
40 pub definition: String,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44pub struct IndexSnapshot {
45 pub name: String,
46 pub definition: String,
47 pub is_unique: bool,
48 pub is_primary: bool,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum DiffStatus {
54 Added,
55 Removed,
56 Changed,
57 Unchanged,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(rename_all = "camelCase")]
62pub struct TableDiff {
63 pub name: String,
64 pub status: DiffStatus,
65 pub column_diffs: Vec<ColumnDiff>,
66 pub constraint_diffs: Vec<ConstraintDiff>,
67 pub index_diffs: Vec<IndexDiff>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct ColumnDiff {
73 pub name: String,
74 pub status: DiffStatus,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub before: Option<ColumnSnapshot>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub after: Option<ColumnSnapshot>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct ConstraintDiff {
84 pub name: String,
85 pub status: DiffStatus,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub before: Option<ConstraintSnapshot>,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub after: Option<ConstraintSnapshot>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct IndexDiff {
95 pub name: String,
96 pub status: DiffStatus,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub before: Option<IndexSnapshot>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub after: Option<IndexSnapshot>,
101}
102
103pub fn require_safe_schema(schema: &str) -> Result<(), ToolError> {
104 if !is_safe_ident(schema) {
105 return Err(ToolError::InvalidArgs(format!(
106 "Invalid schema name \"{schema}\""
107 )));
108 }
109 Ok(())
110}
111
112pub async fn load_schema_snapshot(
113 client: &Client,
114 schema: &str,
115) -> Result<SchemaSnapshot, ToolError> {
116 require_safe_schema(schema)?;
117 let table_rows = client
118 .query(
119 r#"
120 SELECT c.relname AS name
121 FROM pg_class c
122 JOIN pg_namespace n ON n.oid = c.relnamespace
123 WHERE n.nspname = $1
124 AND c.relkind = 'r'
125 AND NOT c.relispartition
126 ORDER BY c.relname
127 "#,
128 &[&schema],
129 )
130 .await?;
131
132 let mut tables = Vec::with_capacity(table_rows.len());
133 for row in &table_rows {
134 let name: String = row.get("name");
135 let columns = load_columns(client, schema, &name).await?;
136 let constraints = load_constraints(client, schema, &name).await?;
137 let indexes = load_indexes(client, schema, &name).await?;
138 tables.push(TableSnapshot {
139 name,
140 schema: schema.to_owned(),
141 columns,
142 constraints,
143 indexes,
144 });
145 }
146 Ok(SchemaSnapshot { tables })
147}
148
149async fn load_columns(
150 client: &Client,
151 schema: &str,
152 table: &str,
153) -> Result<Vec<ColumnSnapshot>, ToolError> {
154 let rows = client
155 .query(
156 r#"
157 SELECT
158 a.attname AS column_name,
159 pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
160 a.attnotnull AS not_null,
161 pg_get_expr(ad.adbin, ad.adrelid) AS default_value,
162 a.attnum AS ordinal
163 FROM pg_attribute a
164 JOIN pg_class c ON c.oid = a.attrelid
165 JOIN pg_namespace n ON n.oid = c.relnamespace
166 LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
167 WHERE n.nspname = $1
168 AND c.relname = $2
169 AND a.attnum > 0
170 AND NOT a.attisdropped
171 ORDER BY a.attnum
172 "#,
173 &[&schema, &table],
174 )
175 .await?;
176 Ok(rows
177 .iter()
178 .map(|r| ColumnSnapshot {
179 column_name: r.get("column_name"),
180 data_type: r.get("data_type"),
181 not_null: r.get("not_null"),
182 default_value: r.get("default_value"),
183 ordinal: r.get::<_, i16>("ordinal") as i32,
184 })
185 .collect())
186}
187
188async fn load_constraints(
189 client: &Client,
190 schema: &str,
191 table: &str,
192) -> Result<Vec<ConstraintSnapshot>, ToolError> {
193 let rows = client
194 .query(
195 r#"
196 SELECT
197 con.conname AS name,
198 con.contype::text AS type,
199 pg_get_constraintdef(con.oid, true) AS definition
200 FROM pg_constraint con
201 JOIN pg_class c ON c.oid = con.conrelid
202 JOIN pg_namespace n ON n.oid = c.relnamespace
203 WHERE n.nspname = $1
204 AND c.relname = $2
205 ORDER BY con.conname
206 "#,
207 &[&schema, &table],
208 )
209 .await?;
210 Ok(rows
211 .iter()
212 .map(|r| ConstraintSnapshot {
213 name: r.get("name"),
214 type_: r.get("type"),
215 definition: r.get("definition"),
216 })
217 .collect())
218}
219
220async fn load_indexes(
221 client: &Client,
222 schema: &str,
223 table: &str,
224) -> Result<Vec<IndexSnapshot>, ToolError> {
225 let rows = client
226 .query(
227 r#"
228 SELECT
229 i.relname AS name,
230 pg_get_indexdef(i.oid) AS definition,
231 ix.indisunique AS is_unique,
232 ix.indisprimary AS is_primary
233 FROM pg_index ix
234 JOIN pg_class t ON t.oid = ix.indrelid
235 JOIN pg_namespace n ON n.oid = t.relnamespace
236 JOIN pg_class i ON i.oid = ix.indexrelid
237 WHERE n.nspname = $1
238 AND t.relname = $2
239 ORDER BY i.relname
240 "#,
241 &[&schema, &table],
242 )
243 .await?;
244 Ok(rows
245 .iter()
246 .map(|r| IndexSnapshot {
247 name: r.get("name"),
248 definition: r.get("definition"),
249 is_unique: r.get("is_unique"),
250 is_primary: r.get("is_primary"),
251 })
252 .collect())
253}
254
255pub fn compute_schema_diff(source: &SchemaSnapshot, target: &SchemaSnapshot) -> Vec<TableDiff> {
256 let source_map: std::collections::HashMap<&str, &TableSnapshot> =
257 source.tables.iter().map(|t| (t.name.as_str(), t)).collect();
258 let target_map: std::collections::HashMap<&str, &TableSnapshot> =
259 target.tables.iter().map(|t| (t.name.as_str(), t)).collect();
260
261 let mut names: Vec<&str> = source_map
262 .keys()
263 .chain(target_map.keys())
264 .copied()
265 .collect();
266 names.sort();
267 names.dedup();
268
269 let mut diffs = Vec::new();
270 for table_name in names {
271 let src = source_map.get(table_name).copied();
272 let tgt = target_map.get(table_name).copied();
273 match (src, tgt) {
274 (None, Some(tgt_table)) => diffs.push(TableDiff {
275 name: table_name.to_owned(),
276 status: DiffStatus::Added,
277 column_diffs: tgt_table
278 .columns
279 .iter()
280 .map(|c| ColumnDiff {
281 name: c.column_name.clone(),
282 status: DiffStatus::Added,
283 before: None,
284 after: Some(c.clone()),
285 })
286 .collect(),
287 constraint_diffs: tgt_table
288 .constraints
289 .iter()
290 .map(|c| ConstraintDiff {
291 name: c.name.clone(),
292 status: DiffStatus::Added,
293 before: None,
294 after: Some(c.clone()),
295 })
296 .collect(),
297 index_diffs: tgt_table
298 .indexes
299 .iter()
300 .map(|i| IndexDiff {
301 name: i.name.clone(),
302 status: DiffStatus::Added,
303 before: None,
304 after: Some(i.clone()),
305 })
306 .collect(),
307 }),
308 (Some(src_table), None) => diffs.push(TableDiff {
309 name: table_name.to_owned(),
310 status: DiffStatus::Removed,
311 column_diffs: src_table
312 .columns
313 .iter()
314 .map(|c| ColumnDiff {
315 name: c.column_name.clone(),
316 status: DiffStatus::Removed,
317 before: Some(c.clone()),
318 after: None,
319 })
320 .collect(),
321 constraint_diffs: src_table
322 .constraints
323 .iter()
324 .map(|c| ConstraintDiff {
325 name: c.name.clone(),
326 status: DiffStatus::Removed,
327 before: Some(c.clone()),
328 after: None,
329 })
330 .collect(),
331 index_diffs: src_table
332 .indexes
333 .iter()
334 .map(|i| IndexDiff {
335 name: i.name.clone(),
336 status: DiffStatus::Removed,
337 before: Some(i.clone()),
338 after: None,
339 })
340 .collect(),
341 }),
342 (Some(src_table), Some(tgt_table)) => {
343 let column_diffs = diff_columns(&src_table.columns, &tgt_table.columns);
344 let constraint_diffs =
345 diff_constraints(&src_table.constraints, &tgt_table.constraints);
346 let index_diffs = diff_indexes(&src_table.indexes, &tgt_table.indexes);
347 let has_changes = column_diffs
348 .iter()
349 .any(|d| d.status != DiffStatus::Unchanged)
350 || constraint_diffs
351 .iter()
352 .any(|d| d.status != DiffStatus::Unchanged)
353 || index_diffs
354 .iter()
355 .any(|d| d.status != DiffStatus::Unchanged);
356 diffs.push(TableDiff {
357 name: table_name.to_owned(),
358 status: if has_changes {
359 DiffStatus::Changed
360 } else {
361 DiffStatus::Unchanged
362 },
363 column_diffs,
364 constraint_diffs,
365 index_diffs,
366 });
367 }
368 (None, None) => {}
369 }
370 }
371
372 let order = |s: DiffStatus| match s {
373 DiffStatus::Changed => 0,
374 DiffStatus::Added => 1,
375 DiffStatus::Removed => 2,
376 DiffStatus::Unchanged => 3,
377 };
378 diffs.sort_by_key(|d| order(d.status));
379 diffs
380}
381
382fn diff_columns(src: &[ColumnSnapshot], tgt: &[ColumnSnapshot]) -> Vec<ColumnDiff> {
383 let src_map: std::collections::HashMap<&str, &ColumnSnapshot> =
384 src.iter().map(|c| (c.column_name.as_str(), c)).collect();
385 let tgt_map: std::collections::HashMap<&str, &ColumnSnapshot> =
386 tgt.iter().map(|c| (c.column_name.as_str(), c)).collect();
387 let mut diffs = Vec::new();
388 for (name, src_col) in &src_map {
389 match tgt_map.get(name) {
390 None => diffs.push(ColumnDiff {
391 name: (*name).to_owned(),
392 status: DiffStatus::Removed,
393 before: Some((*src_col).clone()),
394 after: None,
395 }),
396 Some(tgt_col) => {
397 let changed = src_col.data_type != tgt_col.data_type
398 || src_col.not_null != tgt_col.not_null
399 || src_col.default_value.as_deref().unwrap_or("")
400 != tgt_col.default_value.as_deref().unwrap_or("");
401 diffs.push(ColumnDiff {
402 name: (*name).to_owned(),
403 status: if changed {
404 DiffStatus::Changed
405 } else {
406 DiffStatus::Unchanged
407 },
408 before: Some((*src_col).clone()),
409 after: Some((*tgt_col).clone()),
410 });
411 }
412 }
413 }
414 for (name, tgt_col) in &tgt_map {
415 if !src_map.contains_key(name) {
416 diffs.push(ColumnDiff {
417 name: (*name).to_owned(),
418 status: DiffStatus::Added,
419 before: None,
420 after: Some((*tgt_col).clone()),
421 });
422 }
423 }
424 diffs
425}
426
427fn diff_constraints(src: &[ConstraintSnapshot], tgt: &[ConstraintSnapshot]) -> Vec<ConstraintDiff> {
428 let src_map: std::collections::HashMap<&str, &ConstraintSnapshot> =
429 src.iter().map(|c| (c.name.as_str(), c)).collect();
430 let tgt_map: std::collections::HashMap<&str, &ConstraintSnapshot> =
431 tgt.iter().map(|c| (c.name.as_str(), c)).collect();
432 let mut diffs = Vec::new();
433 for (name, src_con) in &src_map {
434 match tgt_map.get(name) {
435 None => diffs.push(ConstraintDiff {
436 name: (*name).to_owned(),
437 status: DiffStatus::Removed,
438 before: Some((*src_con).clone()),
439 after: None,
440 }),
441 Some(tgt_con) => {
442 let changed = src_con.definition != tgt_con.definition;
443 diffs.push(ConstraintDiff {
444 name: (*name).to_owned(),
445 status: if changed {
446 DiffStatus::Changed
447 } else {
448 DiffStatus::Unchanged
449 },
450 before: Some((*src_con).clone()),
451 after: Some((*tgt_con).clone()),
452 });
453 }
454 }
455 }
456 for (name, tgt_con) in &tgt_map {
457 if !src_map.contains_key(name) {
458 diffs.push(ConstraintDiff {
459 name: (*name).to_owned(),
460 status: DiffStatus::Added,
461 before: None,
462 after: Some((*tgt_con).clone()),
463 });
464 }
465 }
466 diffs
467}
468
469fn diff_indexes(src: &[IndexSnapshot], tgt: &[IndexSnapshot]) -> Vec<IndexDiff> {
470 let src_map: std::collections::HashMap<&str, &IndexSnapshot> =
471 src.iter().map(|i| (i.name.as_str(), i)).collect();
472 let tgt_map: std::collections::HashMap<&str, &IndexSnapshot> =
473 tgt.iter().map(|i| (i.name.as_str(), i)).collect();
474 let mut diffs = Vec::new();
475 for (name, src_idx) in &src_map {
476 match tgt_map.get(name) {
477 None => diffs.push(IndexDiff {
478 name: (*name).to_owned(),
479 status: DiffStatus::Removed,
480 before: Some((*src_idx).clone()),
481 after: None,
482 }),
483 Some(tgt_idx) => {
484 let changed = src_idx.definition != tgt_idx.definition;
485 diffs.push(IndexDiff {
486 name: (*name).to_owned(),
487 status: if changed {
488 DiffStatus::Changed
489 } else {
490 DiffStatus::Unchanged
491 },
492 before: Some((*src_idx).clone()),
493 after: Some((*tgt_idx).clone()),
494 });
495 }
496 }
497 }
498 for (name, tgt_idx) in &tgt_map {
499 if !src_map.contains_key(name) {
500 diffs.push(IndexDiff {
501 name: (*name).to_owned(),
502 status: DiffStatus::Added,
503 before: None,
504 after: Some((*tgt_idx).clone()),
505 });
506 }
507 }
508 diffs
509}
510
511pub fn build_migration_statements(
513 source_schema: &str,
514 target_schema: &str,
515 diffs: &[TableDiff],
516) -> Vec<String> {
517 let mut stmts = Vec::new();
518 for table in diffs {
519 if table.status == DiffStatus::Unchanged {
520 continue;
521 }
522 if table.status == DiffStatus::Added {
523 let cols: Vec<String> = table
524 .column_diffs
525 .iter()
526 .filter(|c| c.status == DiffStatus::Added)
527 .filter_map(|c| c.after.as_ref())
528 .map(|c| {
529 let nn = if c.not_null { " NOT NULL" } else { "" };
530 let def = c
531 .default_value
532 .as_ref()
533 .map(|d| format!(" DEFAULT {d}"))
534 .unwrap_or_default();
535 format!(" \"{}\" {}{}{}", c.column_name, c.data_type, nn, def)
536 })
537 .collect();
538 stmts.push(format!(
539 "-- Table added in {target_schema}\nCREATE TABLE \"{source_schema}\".\"{}\" (\n{}\n);",
540 table.name,
541 cols.join(",\n")
542 ));
543 continue;
544 }
545 if table.status == DiffStatus::Removed {
546 stmts.push(format!(
547 "-- Table removed in {target_schema}\n-- DROP TABLE \"{source_schema}\".\"{}\"; -- Uncomment to drop",
548 table.name
549 ));
550 continue;
551 }
552
553 stmts.push(format!("-- Changes for table: {}", table.name));
554 for col in &table.column_diffs {
555 match col.status {
556 DiffStatus::Added => {
557 if let Some(after) = &col.after {
558 let nn = if after.not_null { " NOT NULL" } else { "" };
559 let def = after
560 .default_value
561 .as_ref()
562 .map(|d| format!(" DEFAULT {d}"))
563 .unwrap_or_default();
564 stmts.push(format!(
565 "ALTER TABLE \"{source_schema}\".\"{}\"\n ADD COLUMN \"{}\" {}{}{};",
566 table.name, col.name, after.data_type, nn, def
567 ));
568 }
569 }
570 DiffStatus::Removed => stmts.push(format!(
571 "-- ALTER TABLE \"{source_schema}\".\"{}\"\n-- DROP COLUMN \"{}\"; -- Uncomment to drop",
572 table.name, col.name
573 )),
574 DiffStatus::Changed => {
575 if let (Some(before), Some(after)) = (&col.before, &col.after) {
576 if before.data_type != after.data_type {
577 stmts.push(format!(
578 "ALTER TABLE \"{source_schema}\".\"{}\"\n ALTER COLUMN \"{}\" TYPE {};",
579 table.name, col.name, after.data_type
580 ));
581 }
582 if before.not_null != after.not_null {
583 let op = if after.not_null { "SET" } else { "DROP" };
584 stmts.push(format!(
585 "ALTER TABLE \"{source_schema}\".\"{}\"\n ALTER COLUMN \"{}\" {op} NOT NULL;",
586 table.name, col.name
587 ));
588 }
589 let before_def = before.default_value.as_deref().unwrap_or("");
590 let after_def = after.default_value.as_deref().unwrap_or("");
591 if before_def != after_def {
592 if after_def.is_empty() {
593 stmts.push(format!(
594 "ALTER TABLE \"{source_schema}\".\"{}\"\n ALTER COLUMN \"{}\" DROP DEFAULT;",
595 table.name, col.name
596 ));
597 } else {
598 stmts.push(format!(
599 "ALTER TABLE \"{source_schema}\".\"{}\"\n ALTER COLUMN \"{}\" SET DEFAULT {after_def};",
600 table.name, col.name
601 ));
602 }
603 }
604 }
605 }
606 DiffStatus::Unchanged => {}
607 }
608 }
609 for con in &table.constraint_diffs {
610 match con.status {
611 DiffStatus::Added => {
612 if let Some(after) = &con.after {
613 stmts.push(format!(
614 "ALTER TABLE \"{source_schema}\".\"{}\"\n ADD CONSTRAINT \"{}\" {};",
615 table.name, con.name, after.definition
616 ));
617 }
618 }
619 DiffStatus::Removed => stmts.push(format!(
620 "-- ALTER TABLE \"{source_schema}\".\"{}\"\n-- DROP CONSTRAINT \"{}\"; -- Uncomment to drop",
621 table.name, con.name
622 )),
623 _ => {}
624 }
625 }
626 for idx in &table.index_diffs {
627 match idx.status {
628 DiffStatus::Added => {
629 if let Some(after) = &idx.after {
630 let rewritten = after.definition.replace(
631 &format!("ON {target_schema}."),
632 &format!("ON {source_schema}."),
633 );
634 stmts.push(format!("{rewritten};"));
635 }
636 }
637 DiffStatus::Removed => {
638 stmts.push(format!(
639 "-- DROP INDEX \"{}\"; -- Uncomment to drop",
640 idx.name
641 ));
642 }
643 _ => {}
644 }
645 }
646 }
647 stmts
648}
649
650pub fn diffs_to_json(diffs: &[TableDiff]) -> Value {
651 json!(diffs)
652}
653
654#[cfg(test)]
655mod tests {
656 use super::*;
657
658 fn col(name: &str, ty: &str) -> ColumnSnapshot {
659 ColumnSnapshot {
660 column_name: name.into(),
661 data_type: ty.into(),
662 not_null: false,
663 default_value: None,
664 ordinal: 1,
665 }
666 }
667
668 #[test]
669 fn detects_added_table() {
670 let source = SchemaSnapshot { tables: vec![] };
671 let target = SchemaSnapshot {
672 tables: vec![TableSnapshot {
673 name: "orders".into(),
674 schema: "public".into(),
675 columns: vec![col("id", "integer")],
676 constraints: vec![],
677 indexes: vec![],
678 }],
679 };
680 let diffs = compute_schema_diff(&source, &target);
681 assert_eq!(diffs.len(), 1);
682 assert_eq!(diffs[0].status, DiffStatus::Added);
683 let stmts = build_migration_statements("public", "public", &diffs);
684 assert!(stmts[0].contains("CREATE TABLE"));
685 }
686
687 #[test]
688 fn detects_column_type_change() {
689 let source = SchemaSnapshot {
690 tables: vec![TableSnapshot {
691 name: "t".into(),
692 schema: "public".into(),
693 columns: vec![col("n", "integer")],
694 constraints: vec![],
695 indexes: vec![],
696 }],
697 };
698 let mut tgt_col = col("n", "bigint");
699 tgt_col.ordinal = 1;
700 let target = SchemaSnapshot {
701 tables: vec![TableSnapshot {
702 name: "t".into(),
703 schema: "public".into(),
704 columns: vec![tgt_col],
705 constraints: vec![],
706 indexes: vec![],
707 }],
708 };
709 let diffs = compute_schema_diff(&source, &target);
710 assert_eq!(diffs[0].status, DiffStatus::Changed);
711 let stmts = build_migration_statements("public", "public", &diffs);
712 assert!(stmts.iter().any(|s| s.contains("TYPE bigint")));
713 }
714}