Skip to main content

nexql_tools/
schema_diff.rs

1//! Schema snapshot load + pure diff/migration (ported from core SchemaDiffEngine).
2
3use 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
511/// Migrate **source** schema toward **target** (source = current, target = desired).
512pub 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}