Skip to main content

sql_splitter/differ/
schema.rs

1//! Schema comparison for diff command.
2
3use super::{parse_ignore_patterns, should_ignore_column, should_include_table, DiffConfig};
4use crate::schema::{Column, ColumnType, ForeignKey, IndexDef, Schema, TableSchema};
5use glob::Pattern;
6use serde::Serialize;
7
8/// Differences between two schemas
9#[derive(Debug, Serialize)]
10pub struct SchemaDiff {
11    /// Tables that exist only in the new schema
12    pub tables_added: Vec<TableInfo>,
13    /// Tables that exist only in the old schema
14    pub tables_removed: Vec<String>,
15    /// Tables that exist in both but have differences
16    pub tables_modified: Vec<TableModification>,
17}
18
19impl SchemaDiff {
20    /// Check if there are any differences
21    pub fn has_changes(&self) -> bool {
22        !self.tables_added.is_empty()
23            || !self.tables_removed.is_empty()
24            || !self.tables_modified.is_empty()
25    }
26}
27
28/// Basic info about a table for added tables
29#[derive(Debug, Serialize)]
30pub struct TableInfo {
31    pub name: String,
32    pub columns: Vec<ColumnInfo>,
33    pub primary_key: Vec<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub create_statement: Option<String>,
36}
37
38impl From<&TableSchema> for TableInfo {
39    fn from(t: &TableSchema) -> Self {
40        Self {
41            name: t.name.clone(),
42            columns: t.columns.iter().map(ColumnInfo::from).collect(),
43            primary_key: t
44                .primary_key
45                .iter()
46                .filter_map(|id| t.column(*id).map(|c| c.name.clone()))
47                .collect(),
48            create_statement: t.create_statement.clone(),
49        }
50    }
51}
52
53/// Column info for serialization
54#[derive(Debug, Serialize, Clone)]
55pub struct ColumnInfo {
56    pub name: String,
57    pub col_type: String,
58    pub is_nullable: bool,
59    pub is_primary_key: bool,
60}
61
62impl From<&Column> for ColumnInfo {
63    fn from(c: &Column) -> Self {
64        Self {
65            name: c.name.clone(),
66            col_type: format_column_type(&c.col_type),
67            is_nullable: c.is_nullable,
68            is_primary_key: c.is_primary_key,
69        }
70    }
71}
72
73fn format_column_type(ct: &ColumnType) -> String {
74    match ct {
75        ColumnType::Int => "INT".to_string(),
76        ColumnType::BigInt => "BIGINT".to_string(),
77        ColumnType::Text => "TEXT".to_string(),
78        ColumnType::Uuid => "UUID".to_string(),
79        ColumnType::Decimal => "DECIMAL".to_string(),
80        ColumnType::DateTime => "DATETIME".to_string(),
81        ColumnType::Bool => "BOOLEAN".to_string(),
82        ColumnType::Other(s) => s.clone(),
83    }
84}
85
86/// Modifications to an existing table
87#[derive(Debug, Serialize)]
88pub struct TableModification {
89    /// Table name
90    pub table_name: String,
91    /// Columns added in the new schema
92    pub columns_added: Vec<ColumnInfo>,
93    /// Columns removed in the new schema
94    pub columns_removed: Vec<ColumnInfo>,
95    /// Columns with type or nullability changes
96    pub columns_modified: Vec<ColumnChange>,
97    /// Whether the primary key changed
98    pub pk_changed: bool,
99    /// Old primary key columns (if changed)
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub old_pk: Option<Vec<String>>,
102    /// New primary key columns (if changed)
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub new_pk: Option<Vec<String>>,
105    /// Foreign keys added
106    pub fks_added: Vec<FkInfo>,
107    /// Foreign keys removed
108    pub fks_removed: Vec<FkInfo>,
109    /// Indexes added
110    pub indexes_added: Vec<IndexInfo>,
111    /// Indexes removed
112    pub indexes_removed: Vec<IndexInfo>,
113}
114
115impl TableModification {
116    /// Check if there are any modifications
117    pub fn has_changes(&self) -> bool {
118        !self.columns_added.is_empty()
119            || !self.columns_removed.is_empty()
120            || !self.columns_modified.is_empty()
121            || self.pk_changed
122            || !self.fks_added.is_empty()
123            || !self.fks_removed.is_empty()
124            || !self.indexes_added.is_empty()
125            || !self.indexes_removed.is_empty()
126    }
127}
128
129/// Change to a column definition
130#[derive(Debug, Serialize)]
131pub struct ColumnChange {
132    pub name: String,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub old_type: Option<String>,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub new_type: Option<String>,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub old_nullable: Option<bool>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub new_nullable: Option<bool>,
141}
142
143/// Foreign key info for serialization
144#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
145pub struct FkInfo {
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub name: Option<String>,
148    pub columns: Vec<String>,
149    pub referenced_table: String,
150    pub referenced_columns: Vec<String>,
151}
152
153impl From<&ForeignKey> for FkInfo {
154    fn from(fk: &ForeignKey) -> Self {
155        Self {
156            name: fk.name.clone(),
157            columns: fk.column_names.clone(),
158            referenced_table: fk.referenced_table.clone(),
159            referenced_columns: fk.referenced_columns.clone(),
160        }
161    }
162}
163
164/// Index info for serialization
165#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
166pub struct IndexInfo {
167    pub name: String,
168    pub columns: Vec<String>,
169    pub is_unique: bool,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub index_type: Option<String>,
172}
173
174impl From<&IndexDef> for IndexInfo {
175    fn from(idx: &IndexDef) -> Self {
176        Self {
177            name: idx.name.clone(),
178            columns: idx.columns.clone(),
179            is_unique: idx.is_unique,
180            index_type: idx.index_type.clone(),
181        }
182    }
183}
184
185/// Compare two schemas and return the differences
186pub fn compare_schemas(
187    old_schema: &Schema,
188    new_schema: &Schema,
189    config: &DiffConfig,
190) -> SchemaDiff {
191    let mut tables_added = Vec::new();
192    let mut tables_removed = Vec::new();
193    let mut tables_modified = Vec::new();
194
195    // Parse ignore patterns
196    let ignore_patterns = parse_ignore_patterns(&config.ignore_columns);
197
198    // Find tables in new but not in old (added)
199    for new_table in new_schema.iter() {
200        if !should_include_table(&new_table.name, &config.tables, &config.exclude) {
201            continue;
202        }
203
204        if old_schema.get_table(&new_table.name).is_none() {
205            // Filter out ignored columns from added table info
206            let mut table_info = TableInfo::from(new_table);
207            if !ignore_patterns.is_empty() {
208                table_info.columns.retain(|col| {
209                    !should_ignore_column(&new_table.name, &col.name, &ignore_patterns)
210                });
211            }
212            tables_added.push(table_info);
213        }
214    }
215
216    // Find tables in old but not in new (removed) and tables in both (check for modifications)
217    for old_table in old_schema.iter() {
218        if !should_include_table(&old_table.name, &config.tables, &config.exclude) {
219            continue;
220        }
221
222        match new_schema.get_table(&old_table.name) {
223            None => {
224                tables_removed.push(old_table.name.clone());
225            }
226            Some(new_table) => {
227                let modification =
228                    compare_tables(old_table, new_table, &old_table.name, &ignore_patterns);
229                if modification.has_changes() {
230                    tables_modified.push(modification);
231                }
232            }
233        }
234    }
235
236    SchemaDiff {
237        tables_added,
238        tables_removed,
239        tables_modified,
240    }
241}
242
243/// Compare two table schemas
244fn compare_tables(
245    old_table: &TableSchema,
246    new_table: &TableSchema,
247    table_name: &str,
248    ignore_patterns: &[Pattern],
249) -> TableModification {
250    let mut columns_added = Vec::new();
251    let mut columns_removed = Vec::new();
252    let mut columns_modified = Vec::new();
253
254    // Build column maps for efficient lookup
255    let old_columns: std::collections::HashMap<String, &Column> = old_table
256        .columns
257        .iter()
258        .map(|c| (c.name.to_lowercase(), c))
259        .collect();
260    let new_columns: std::collections::HashMap<String, &Column> = new_table
261        .columns
262        .iter()
263        .map(|c| (c.name.to_lowercase(), c))
264        .collect();
265
266    // Find added columns
267    for new_col in &new_table.columns {
268        // Skip ignored columns
269        if should_ignore_column(table_name, &new_col.name, ignore_patterns) {
270            continue;
271        }
272        let key = new_col.name.to_lowercase();
273        if !old_columns.contains_key(&key) {
274            columns_added.push(ColumnInfo::from(new_col));
275        }
276    }
277
278    // Find removed and modified columns
279    for old_col in &old_table.columns {
280        // Skip ignored columns
281        if should_ignore_column(table_name, &old_col.name, ignore_patterns) {
282            continue;
283        }
284        let key = old_col.name.to_lowercase();
285        match new_columns.get(&key) {
286            None => {
287                columns_removed.push(ColumnInfo::from(old_col));
288            }
289            Some(new_col) => {
290                if let Some(change) = compare_columns(old_col, new_col) {
291                    columns_modified.push(change);
292                }
293            }
294        }
295    }
296
297    // Compare primary keys
298    let old_pk: Vec<String> = old_table
299        .primary_key
300        .iter()
301        .filter_map(|id| old_table.column(*id).map(|c| c.name.clone()))
302        .collect();
303    let new_pk: Vec<String> = new_table
304        .primary_key
305        .iter()
306        .filter_map(|id| new_table.column(*id).map(|c| c.name.clone()))
307        .collect();
308
309    let pk_changed = old_pk != new_pk;
310
311    // Compare foreign keys
312    let old_fks: Vec<FkInfo> = old_table.foreign_keys.iter().map(FkInfo::from).collect();
313    let new_fks: Vec<FkInfo> = new_table.foreign_keys.iter().map(FkInfo::from).collect();
314
315    let fks_added: Vec<FkInfo> = new_fks
316        .iter()
317        .filter(|fk| !old_fks.contains(fk))
318        .cloned()
319        .collect();
320    let fks_removed: Vec<FkInfo> = old_fks
321        .iter()
322        .filter(|fk| !new_fks.contains(fk))
323        .cloned()
324        .collect();
325
326    // Compare indexes
327    let old_indexes: Vec<IndexInfo> = old_table.indexes.iter().map(IndexInfo::from).collect();
328    let new_indexes: Vec<IndexInfo> = new_table.indexes.iter().map(IndexInfo::from).collect();
329
330    let indexes_added: Vec<IndexInfo> = new_indexes
331        .iter()
332        .filter(|idx| !old_indexes.contains(idx))
333        .cloned()
334        .collect();
335    let indexes_removed: Vec<IndexInfo> = old_indexes
336        .iter()
337        .filter(|idx| !new_indexes.contains(idx))
338        .cloned()
339        .collect();
340
341    TableModification {
342        table_name: old_table.name.clone(),
343        columns_added,
344        columns_removed,
345        columns_modified,
346        pk_changed,
347        old_pk: if pk_changed { Some(old_pk) } else { None },
348        new_pk: if pk_changed { Some(new_pk) } else { None },
349        fks_added,
350        fks_removed,
351        indexes_added,
352        indexes_removed,
353    }
354}
355
356/// Compare two column definitions
357fn compare_columns(old_col: &Column, new_col: &Column) -> Option<ColumnChange> {
358    let type_changed = old_col.col_type != new_col.col_type;
359    let nullable_changed = old_col.is_nullable != new_col.is_nullable;
360
361    if !type_changed && !nullable_changed {
362        return None;
363    }
364
365    Some(ColumnChange {
366        name: old_col.name.clone(),
367        old_type: if type_changed {
368            Some(format_column_type(&old_col.col_type))
369        } else {
370            None
371        },
372        new_type: if type_changed {
373            Some(format_column_type(&new_col.col_type))
374        } else {
375            None
376        },
377        old_nullable: if nullable_changed {
378            Some(old_col.is_nullable)
379        } else {
380            None
381        },
382        new_nullable: if nullable_changed {
383            Some(new_col.is_nullable)
384        } else {
385            None
386        },
387    })
388}