rusty_schema_diff/analyzer/
sql.rs

1//! SQL specific analyzer implementation
2//!
3//! This module provides functionality for analyzing SQL DDL changes and
4//! generating compatibility reports and migration paths.
5
6use sqlparser::ast::{Statement, ColumnDef, ColumnOption};
7use crate::analyzer::{SchemaAnalyzer, SchemaChange, ChangeType};
8use crate::{Schema, CompatibilityReport, MigrationPlan, ValidationResult, SchemaDiffError};
9use crate::error::Result;
10use crate::report::{CompatibilityIssue, IssueSeverity, ValidationError};
11use std::collections::HashMap;
12
13/// Analyzes SQL DDL changes and generates compatibility reports.
14pub struct SqlAnalyzer;
15
16impl SchemaAnalyzer for SqlAnalyzer {
17    /// Analyzes compatibility between two SQL DDL versions.
18    ///
19    /// # Arguments
20    ///
21    /// * `old` - The original SQL DDL version.
22    /// * `new` - The new SQL DDL version to compare against.
23    ///
24    /// # Returns
25    ///
26    /// A `CompatibilityReport` detailing the differences and compatibility status.
27    fn analyze_compatibility(&self, old: &Schema, new: &Schema) -> Result<CompatibilityReport> {
28        let metadata = HashMap::new();
29
30        let mut changes = Vec::new();
31        self.compare_schemas(old, new, &mut changes);
32
33        let compatibility_score = self.calculate_compatibility_score(&changes);
34        let validation_result = self.validate_changes(&changes)?;
35
36        Ok(CompatibilityReport {
37            changes,
38            compatibility_score,
39            is_compatible: compatibility_score >= 80,
40            issues: validation_result.errors.into_iter().map(|err| CompatibilityIssue {
41                severity: match err.code.as_str() {
42                    "SQL001" => IssueSeverity::Error,
43                    "SQL002" => IssueSeverity::Warning,
44                    _ => IssueSeverity::Info,
45                },
46                description: err.message,
47                location: err.path,
48            }).collect(),
49            metadata,
50        })
51    }
52
53    /// Generates a migration path between SQL DDL versions.
54    ///
55    /// # Arguments
56    ///
57    /// * `old` - The source SQL DDL version.
58    /// * `new` - The target SQL DDL version.
59    ///
60    /// # Returns
61    ///
62    /// A `MigrationPlan` detailing the required changes.
63    fn generate_migration_path(&self, old: &Schema, new: &Schema) -> Result<MigrationPlan> {
64        let mut changes = Vec::new();
65        self.compare_schemas(old, new, &mut changes);
66        
67        Ok(MigrationPlan::new(
68            old.version.to_string(),
69            new.version.to_string(),
70            changes,
71        ))
72    }
73
74    fn validate_changes(&self, changes: &[SchemaChange]) -> Result<ValidationResult> {
75        let mut errors = Vec::new();
76        
77        for change in changes {
78            if let Some(issue) = self.validate_change(change) {
79                errors.push(ValidationError {
80                    message: issue.description,
81                    path: issue.location,
82                    code: match issue.severity {
83                        IssueSeverity::Error => "SQL001",
84                        IssueSeverity::Warning => "SQL002",
85                        IssueSeverity::Info => "SQL003",
86                    }.to_string(),
87                });
88            }
89        }
90        
91        Ok(ValidationResult {
92            errors: errors.clone(),
93            is_valid: errors.is_empty(),
94            context: HashMap::new(),
95        })
96    }
97}
98
99impl SqlAnalyzer {
100    fn compare_schemas(&self, old: &Schema, new: &Schema, changes: &mut Vec<SchemaChange>) {
101        if let (Ok(old_tables), Ok(new_tables)) = (
102            self.parse_tables(&old.content),
103            self.parse_tables(&new.content)
104        ) {
105            // Compare existing tables
106            for old_table in old_tables.iter() {
107                if let Statement::CreateTable(ref old_table_data) = old_table {
108                    let name = &old_table_data.name;
109                    let old_columns = &old_table_data.columns;
110                    if let Some(new_table) = new_tables.iter().find(|t| {
111                        if let Statement::CreateTable(ref new_table_data) = t {
112                            &new_table_data.name == name
113                        } else {
114                            false
115                        }
116                    }) {
117                        if let Statement::CreateTable(ref new_table_data) = new_table {
118                            let new_columns = &new_table_data.columns;
119                            self.compare_columns(name.to_string(), old_columns, new_columns, changes);
120                        }
121                    } else {
122                        let mut metadata = HashMap::new();
123                        metadata.insert("table".to_string(), name.to_string());
124                        
125                        changes.push(SchemaChange::new(
126                            ChangeType::Removal,
127                            format!("table/{}", name),
128                            format!("Table '{}' was removed", name),
129                            metadata,
130                        ));
131                    }
132                }
133            }
134
135            // Check for new tables
136            for new_table in new_tables.iter() {
137                if let Statement::CreateTable(ref new_table_data) = new_table {
138                    let table_name = &new_table_data.name;
139                    if !old_tables.iter().any(|t| {
140                        if let Statement::CreateTable(ref old_table_data) = t {
141                            &old_table_data.name == table_name
142                        } else {
143                            false
144                        }
145                    }) {
146                        let mut metadata = HashMap::new();
147                        metadata.insert("table".to_string(), table_name.to_string());
148                        
149                        changes.push(SchemaChange::new(
150                            ChangeType::Addition,
151                            format!("table/{}", table_name),
152                            format!("New table '{}' was added", table_name),
153                            metadata,
154                        ));
155                    }
156                }
157            }
158        }
159    }
160
161    fn compare_columns(&self, table_name: String, old_columns: &[ColumnDef], new_columns: &[ColumnDef], changes: &mut Vec<SchemaChange>) {
162        for old_col in old_columns {
163            if let Some(new_col) = new_columns.iter().find(|c| c.name == old_col.name) {
164                // Compare data types
165                if old_col.data_type != new_col.data_type {
166                    let mut metadata = HashMap::new();
167                    metadata.insert("table".to_string(), table_name.clone());
168                    metadata.insert("column".to_string(), old_col.name.to_string());
169                    metadata.insert("old_type".to_string(), format!("{:?}", old_col.data_type));
170                    metadata.insert("new_type".to_string(), format!("{:?}", new_col.data_type));
171                    
172                    changes.push(SchemaChange::new(
173                        ChangeType::Modification,
174                        format!("{}/{}", table_name, old_col.name),
175                        format!("Column '{}' type changed from {:?} to {:?}", 
176                            old_col.name, old_col.data_type, new_col.data_type),
177                        metadata,
178                    ));
179                }
180
181                // Update this section to convert the types
182                let old_opts: Vec<ColumnOption> = old_col.options.iter()
183                    .map(|opt| opt.option.clone())
184                    .collect();
185                let new_opts: Vec<ColumnOption> = new_col.options.iter()
186                    .map(|opt| opt.option.clone())
187                    .collect();
188
189                // Now pass the converted options
190                self.compare_column_constraints(
191                    &table_name,
192                    &old_col.name.to_string(),
193                    &old_opts,
194                    &new_opts,
195                    changes,
196                );
197            } else {
198                let mut metadata = HashMap::new();
199                metadata.insert("table".to_string(), table_name.clone());
200                metadata.insert("column".to_string(), old_col.name.to_string());
201                
202                changes.push(SchemaChange::new(
203                    ChangeType::Removal,
204                    format!("{}/{}", table_name, old_col.name),
205                    format!("Column '{}' was removed", old_col.name),
206                    metadata,
207                ));
208            }
209        }
210
211        // Check for new columns
212        for new_col in new_columns {
213            if !old_columns.iter().any(|c| c.name == new_col.name) {
214                let mut metadata = HashMap::new();
215                metadata.insert("table".to_string(), table_name.clone());
216                metadata.insert("column".to_string(), new_col.name.to_string());
217                
218                changes.push(SchemaChange::new(
219                    ChangeType::Addition,
220                    format!("{}/{}", table_name, new_col.name),
221                    format!("New column '{}' was added", new_col.name),
222                    metadata,
223                ));
224            }
225        }
226    }
227
228    fn compare_column_constraints(
229        &self,
230        table_name: &str,
231        column_name: &str,
232        old_options: &[ColumnOption],
233        new_options: &[ColumnOption],
234        changes: &mut Vec<SchemaChange>,
235    ) {
236        // Compare constraints
237        for old_opt in old_options {
238            let found_in_new = new_options.iter().any(|new_opt| {
239                match (old_opt, new_opt) {
240                    (ColumnOption::NotNull, ColumnOption::NotNull) => true,
241                    (ColumnOption::Default(_), ColumnOption::Default(_)) => true,
242                    (ColumnOption::Unique { is_primary, characteristics: _ }, 
243                     ColumnOption::Unique { is_primary: new_primary, characteristics: _ }) => {
244                        is_primary == new_primary
245                    }
246                    _ => false,
247                }
248            });
249
250            if !found_in_new {
251                let mut metadata = HashMap::new();
252                metadata.insert("table".to_string(), table_name.to_string());
253                metadata.insert("column".to_string(), column_name.to_string());
254                metadata.insert("constraint".to_string(), format!("{:?}", old_opt));
255                
256                changes.push(SchemaChange::new(
257                    ChangeType::Removal,
258                    format!("{}/{}/constraints", table_name, column_name),
259                    format!("Constraint removed from column '{}': {:?}", column_name, old_opt),
260                    metadata,
261                ));
262            }
263        }
264
265        // Check for new constraints
266        for new_opt in new_options {
267            let found_in_old = old_options.iter().any(|old_opt| {
268                match (old_opt, new_opt) {
269                    (ColumnOption::NotNull, ColumnOption::NotNull) => true,
270                    (ColumnOption::Default(_), ColumnOption::Default(_)) => true,
271                    (ColumnOption::Unique { is_primary, characteristics: _ }, 
272                     ColumnOption::Unique { is_primary: new_primary, characteristics: _ }) => {
273                        is_primary == new_primary
274                    }
275                    _ => false,
276                }
277            });
278
279            if !found_in_old {
280                let mut metadata = HashMap::new();
281                metadata.insert("table".to_string(), table_name.to_string());
282                metadata.insert("column".to_string(), column_name.to_string());
283                metadata.insert("constraint".to_string(), format!("{:?}", new_opt));
284                
285                changes.push(SchemaChange::new(
286                    ChangeType::Addition,
287                    format!("{}/{}/constraints", table_name, column_name),
288                    format!("New constraint added to column '{}': {:?}", column_name, new_opt),
289                    metadata,
290                ));
291            }
292        }
293    }
294
295    fn calculate_compatibility_score(&self, changes: &[SchemaChange]) -> u8 {
296        let base_score: u8 = 100;
297        let mut deductions: u8 = 0;
298
299        for change in changes {
300            match change.change_type {
301                ChangeType::Addition => deductions = deductions.saturating_add(5),
302                ChangeType::Removal => deductions = deductions.saturating_add(15),
303                ChangeType::Modification => deductions = deductions.saturating_add(10),
304                ChangeType::Rename => deductions = deductions.saturating_add(8),
305            }
306        }
307
308        base_score.saturating_sub(deductions)
309    }
310
311    fn validate_change(&self, change: &SchemaChange) -> Option<CompatibilityIssue> {
312        match change.change_type {
313            ChangeType::Removal => Some(CompatibilityIssue {
314                severity: IssueSeverity::Error,
315                description: format!("Breaking change: {}", change.description),
316                location: change.location.clone(),
317            }),
318            ChangeType::Modification => {
319                if change.location.contains("type") {
320                    Some(CompatibilityIssue {
321                        severity: IssueSeverity::Warning,
322                        description: format!("Potential data loss: {}", change.description),
323                        location: change.location.clone(),
324                    })
325                } else {
326                    None
327                }
328            }
329            _ => None,
330        }
331    }
332
333    fn parse_tables(&self, sql: &str) -> Result<Vec<Statement>> {
334        use sqlparser::dialect::GenericDialect;
335        use sqlparser::parser::Parser;
336        
337        let dialect = GenericDialect {};
338        Parser::parse_sql(&dialect, sql)
339            .map_err(|e| SchemaDiffError::ParseError(format!("Failed to parse SQL: {}", e)))
340    }
341
342    #[allow(dead_code)]
343    fn generate_sql_for_change(&self, change: &SchemaChange) -> String {
344        match change.change_type {
345            ChangeType::Addition => {
346                if change.location.starts_with("table/") {
347                    format!("CREATE TABLE {} (...);", change.location.strip_prefix("table/").unwrap_or(""))
348                } else {
349                    format!("ALTER TABLE {} ADD COLUMN ...;", change.location)
350                }
351            }
352            ChangeType::Removal => {
353                if change.location.starts_with("table/") {
354                    format!("DROP TABLE {};", change.location.strip_prefix("table/").unwrap_or(""))
355                } else {
356                    format!("ALTER TABLE {} DROP COLUMN ...;", change.location)
357                }
358            }
359            ChangeType::Modification => {
360                format!("ALTER TABLE {} MODIFY COLUMN ...;", change.location)
361            }
362            ChangeType::Rename => {
363                format!("ALTER TABLE {} RENAME ...;", change.location)
364            }
365        }
366    }
367}