Skip to main content

sql_splitter/differ/
schema.rs

1//! Schema comparison for diff command.
2
3use super::{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/// Parse ignore column patterns into compiled Pattern objects
186fn parse_ignore_patterns(patterns: &[String]) -> Vec<Pattern> {
187    patterns
188        .iter()
189        .filter_map(|p| Pattern::new(&p.to_lowercase()).ok())
190        .collect()
191}
192
193/// Check if a column should be ignored based on patterns
194fn should_ignore_column(table: &str, column: &str, patterns: &[Pattern]) -> bool {
195    let full_name = format!("{}.{}", table.to_lowercase(), column.to_lowercase());
196    patterns.iter().any(|p| p.matches(&full_name))
197}
198
199/// Compare two schemas and return the differences
200pub fn compare_schemas(
201    old_schema: &Schema,
202    new_schema: &Schema,
203    config: &DiffConfig,
204) -> SchemaDiff {
205    let mut tables_added = Vec::new();
206    let mut tables_removed = Vec::new();
207    let mut tables_modified = Vec::new();
208
209    // Parse ignore patterns
210    let ignore_patterns = parse_ignore_patterns(&config.ignore_columns);
211
212    // Find tables in new but not in old (added)
213    for new_table in new_schema.iter() {
214        if !should_include_table(&new_table.name, &config.tables, &config.exclude) {
215            continue;
216        }
217
218        if old_schema.get_table(&new_table.name).is_none() {
219            // Filter out ignored columns from added table info
220            let mut table_info = TableInfo::from(new_table);
221            if !ignore_patterns.is_empty() {
222                table_info.columns.retain(|col| {
223                    !should_ignore_column(&new_table.name, &col.name, &ignore_patterns)
224                });
225            }
226            tables_added.push(table_info);
227        }
228    }
229
230    // Find tables in old but not in new (removed) and tables in both (check for modifications)
231    for old_table in old_schema.iter() {
232        if !should_include_table(&old_table.name, &config.tables, &config.exclude) {
233            continue;
234        }
235
236        match new_schema.get_table(&old_table.name) {
237            None => {
238                tables_removed.push(old_table.name.clone());
239            }
240            Some(new_table) => {
241                let modification =
242                    compare_tables(old_table, new_table, &old_table.name, &ignore_patterns);
243                if modification.has_changes() {
244                    tables_modified.push(modification);
245                }
246            }
247        }
248    }
249
250    SchemaDiff {
251        tables_added,
252        tables_removed,
253        tables_modified,
254    }
255}
256
257/// Compare two table schemas
258fn compare_tables(
259    old_table: &TableSchema,
260    new_table: &TableSchema,
261    table_name: &str,
262    ignore_patterns: &[Pattern],
263) -> TableModification {
264    let mut columns_added = Vec::new();
265    let mut columns_removed = Vec::new();
266    let mut columns_modified = Vec::new();
267
268    // Build column maps for efficient lookup
269    let old_columns: std::collections::HashMap<String, &Column> = old_table
270        .columns
271        .iter()
272        .map(|c| (c.name.to_lowercase(), c))
273        .collect();
274    let new_columns: std::collections::HashMap<String, &Column> = new_table
275        .columns
276        .iter()
277        .map(|c| (c.name.to_lowercase(), c))
278        .collect();
279
280    // Find added columns
281    for new_col in &new_table.columns {
282        // Skip ignored columns
283        if should_ignore_column(table_name, &new_col.name, ignore_patterns) {
284            continue;
285        }
286        let key = new_col.name.to_lowercase();
287        if !old_columns.contains_key(&key) {
288            columns_added.push(ColumnInfo::from(new_col));
289        }
290    }
291
292    // Find removed and modified columns
293    for old_col in &old_table.columns {
294        // Skip ignored columns
295        if should_ignore_column(table_name, &old_col.name, ignore_patterns) {
296            continue;
297        }
298        let key = old_col.name.to_lowercase();
299        match new_columns.get(&key) {
300            None => {
301                columns_removed.push(ColumnInfo::from(old_col));
302            }
303            Some(new_col) => {
304                if let Some(change) = compare_columns(old_col, new_col) {
305                    columns_modified.push(change);
306                }
307            }
308        }
309    }
310
311    // Compare primary keys
312    let old_pk: Vec<String> = old_table
313        .primary_key
314        .iter()
315        .filter_map(|id| old_table.column(*id).map(|c| c.name.clone()))
316        .collect();
317    let new_pk: Vec<String> = new_table
318        .primary_key
319        .iter()
320        .filter_map(|id| new_table.column(*id).map(|c| c.name.clone()))
321        .collect();
322
323    let pk_changed = old_pk != new_pk;
324
325    // Compare foreign keys
326    let old_fks: Vec<FkInfo> = old_table.foreign_keys.iter().map(FkInfo::from).collect();
327    let new_fks: Vec<FkInfo> = new_table.foreign_keys.iter().map(FkInfo::from).collect();
328
329    let fks_added: Vec<FkInfo> = new_fks
330        .iter()
331        .filter(|fk| !old_fks.contains(fk))
332        .cloned()
333        .collect();
334    let fks_removed: Vec<FkInfo> = old_fks
335        .iter()
336        .filter(|fk| !new_fks.contains(fk))
337        .cloned()
338        .collect();
339
340    // Compare indexes
341    let old_indexes: Vec<IndexInfo> = old_table.indexes.iter().map(IndexInfo::from).collect();
342    let new_indexes: Vec<IndexInfo> = new_table.indexes.iter().map(IndexInfo::from).collect();
343
344    let indexes_added: Vec<IndexInfo> = new_indexes
345        .iter()
346        .filter(|idx| !old_indexes.contains(idx))
347        .cloned()
348        .collect();
349    let indexes_removed: Vec<IndexInfo> = old_indexes
350        .iter()
351        .filter(|idx| !new_indexes.contains(idx))
352        .cloned()
353        .collect();
354
355    TableModification {
356        table_name: old_table.name.clone(),
357        columns_added,
358        columns_removed,
359        columns_modified,
360        pk_changed,
361        old_pk: if pk_changed { Some(old_pk) } else { None },
362        new_pk: if pk_changed { Some(new_pk) } else { None },
363        fks_added,
364        fks_removed,
365        indexes_added,
366        indexes_removed,
367    }
368}
369
370/// Compare two column definitions
371fn compare_columns(old_col: &Column, new_col: &Column) -> Option<ColumnChange> {
372    let type_changed = old_col.col_type != new_col.col_type;
373    let nullable_changed = old_col.is_nullable != new_col.is_nullable;
374
375    if !type_changed && !nullable_changed {
376        return None;
377    }
378
379    Some(ColumnChange {
380        name: old_col.name.clone(),
381        old_type: if type_changed {
382            Some(format_column_type(&old_col.col_type))
383        } else {
384            None
385        },
386        new_type: if type_changed {
387            Some(format_column_type(&new_col.col_type))
388        } else {
389            None
390        },
391        old_nullable: if nullable_changed {
392            Some(old_col.is_nullable)
393        } else {
394            None
395        },
396        new_nullable: if nullable_changed {
397            Some(new_col.is_nullable)
398        } else {
399            None
400        },
401    })
402}