Skip to main content

sqlmodel_schema/ddl/
mod.rs

1//! DDL (Data Definition Language) generation from schema operations.
2//!
3//! This module converts `SchemaOperation`s from the diff engine into executable
4//! SQL statements for each supported database dialect (SQLite, MySQL, PostgreSQL).
5
6mod mysql;
7mod postgres;
8mod sqlite;
9
10pub use mysql::MysqlDdlGenerator;
11pub use postgres::PostgresDdlGenerator;
12pub use sqlite::SqliteDdlGenerator;
13
14use crate::diff::SchemaOperation;
15use crate::introspect::{
16    ColumnInfo, Dialect, ForeignKeyInfo, IndexInfo, TableInfo, UniqueConstraintInfo,
17};
18
19/// Generates DDL SQL statements from schema operations.
20pub trait DdlGenerator {
21    /// The database dialect name.
22    fn dialect(&self) -> &'static str;
23
24    /// Generate DDL statement(s) for a single schema operation.
25    ///
26    /// Some operations (like SQLite DROP COLUMN) may produce multiple statements.
27    fn generate(&self, op: &SchemaOperation) -> Vec<String>;
28
29    /// Generate DDL statements for multiple operations.
30    fn generate_all(&self, ops: &[SchemaOperation]) -> Vec<String> {
31        ops.iter().flat_map(|op| self.generate(op)).collect()
32    }
33
34    /// Generate rollback DDL statements (inverse operations).
35    ///
36    /// Returns statements in reverse order, suitable for undoing the original operations.
37    /// Operations without an inverse (like DROP TABLE) are skipped.
38    fn generate_rollback(&self, ops: &[SchemaOperation]) -> Vec<String> {
39        ops.iter()
40            .rev()
41            .filter_map(|op| op.inverse())
42            .flat_map(|op| self.generate(&op))
43            .collect()
44    }
45}
46
47/// Create a DDL generator for the given dialect.
48pub fn generator_for_dialect(dialect: Dialect) -> Box<dyn DdlGenerator> {
49    match dialect {
50        Dialect::Sqlite => Box::new(SqliteDdlGenerator),
51        Dialect::Mysql => Box::new(MysqlDdlGenerator),
52        Dialect::Postgres => Box::new(PostgresDdlGenerator),
53    }
54}
55
56// ============================================================================
57// Shared Helpers
58// ============================================================================
59
60/// Quote an identifier (table/column name) for SQL.
61///
62/// Different dialects use different quote characters:
63/// - SQLite/PostgreSQL: double quotes
64/// - MySQL: backticks
65fn quote_identifier(name: &str, dialect: Dialect) -> String {
66    match dialect {
67        Dialect::Mysql => format!("`{}`", name.replace('`', "``")),
68        Dialect::Sqlite | Dialect::Postgres => format!("\"{}\"", name.replace('"', "\"\"")),
69    }
70}
71
72/// Format a column definition for CREATE TABLE or ADD COLUMN.
73fn format_column_def(col: &ColumnInfo, dialect: Dialect) -> String {
74    let mut parts = vec![quote_identifier(&col.name, dialect), col.sql_type.clone()];
75
76    if !col.nullable {
77        parts.push("NOT NULL".to_string());
78    }
79
80    if let Some(ref default) = col.default {
81        parts.push(format!("DEFAULT {}", default));
82    }
83
84    // Auto-increment handling varies by dialect
85    match dialect {
86        Dialect::Sqlite => {
87            // SQLite: INTEGER PRIMARY KEY implies AUTOINCREMENT
88            // Explicit AUTOINCREMENT keyword is rarely needed
89        }
90        Dialect::Mysql => {
91            if col.auto_increment {
92                parts.push("AUTO_INCREMENT".to_string());
93            }
94        }
95        Dialect::Postgres => {
96            // PostgreSQL uses SERIAL types or GENERATED AS IDENTITY
97            // The sql_type should already contain SERIAL/BIGSERIAL if auto-increment
98        }
99    }
100
101    parts.join(" ")
102}
103
104/// Format the ON DELETE/UPDATE action for foreign keys.
105fn format_referential_action(action: Option<&String>) -> &str {
106    match action.map(|s| s.to_uppercase()).as_deref() {
107        Some("CASCADE") => "CASCADE",
108        Some("SET NULL") => "SET NULL",
109        Some("SET DEFAULT") => "SET DEFAULT",
110        Some("RESTRICT") => "RESTRICT",
111        _ => "NO ACTION",
112    }
113}
114
115/// Format a foreign key constraint clause.
116fn format_fk_constraint(fk: &ForeignKeyInfo, dialect: Dialect) -> String {
117    let mut sql = format!(
118        "FOREIGN KEY ({}) REFERENCES {}({})",
119        quote_identifier(&fk.column, dialect),
120        quote_identifier(&fk.foreign_table, dialect),
121        quote_identifier(&fk.foreign_column, dialect),
122    );
123
124    let on_delete = format_referential_action(fk.on_delete.as_ref());
125    let on_update = format_referential_action(fk.on_update.as_ref());
126
127    if on_delete != "NO ACTION" {
128        sql.push_str(&format!(" ON DELETE {}", on_delete));
129    }
130    if on_update != "NO ACTION" {
131        sql.push_str(&format!(" ON UPDATE {}", on_update));
132    }
133
134    sql
135}
136
137/// Format a unique constraint clause.
138fn format_unique_constraint(unique: &UniqueConstraintInfo, dialect: Dialect) -> String {
139    let cols: Vec<String> = unique
140        .columns
141        .iter()
142        .map(|c| quote_identifier(c, dialect))
143        .collect();
144
145    if let Some(ref name) = unique.name {
146        format!(
147            "CONSTRAINT {} UNIQUE ({})",
148            quote_identifier(name, dialect),
149            cols.join(", ")
150        )
151    } else {
152        format!("UNIQUE ({})", cols.join(", "))
153    }
154}
155
156/// Generate CREATE TABLE SQL.
157fn generate_create_table(table: &TableInfo, dialect: Dialect) -> String {
158    tracing::debug!(
159        dialect = %match dialect {
160            Dialect::Sqlite => "sqlite",
161            Dialect::Mysql => "mysql",
162            Dialect::Postgres => "postgres",
163        },
164        table = %table.name,
165        columns = table.columns.len(),
166        "Generating CREATE TABLE DDL"
167    );
168
169    let mut parts = Vec::new();
170
171    // Column definitions
172    for col in &table.columns {
173        parts.push(format!("  {}", format_column_def(col, dialect)));
174    }
175
176    // Primary key constraint (if not embedded in column definition)
177    if !table.primary_key.is_empty() {
178        let pk_cols: Vec<String> = table
179            .primary_key
180            .iter()
181            .map(|c| quote_identifier(c, dialect))
182            .collect();
183        parts.push(format!("  PRIMARY KEY ({})", pk_cols.join(", ")));
184    }
185
186    // Unique constraints
187    for unique in &table.unique_constraints {
188        parts.push(format!("  {}", format_unique_constraint(unique, dialect)));
189    }
190
191    // Foreign key constraints
192    for fk in &table.foreign_keys {
193        parts.push(format!("  {}", format_fk_constraint(fk, dialect)));
194    }
195
196    let table_name = quote_identifier(&table.name, dialect);
197    let sql = format!(
198        "CREATE TABLE IF NOT EXISTS {} (\n{}\n)",
199        table_name,
200        parts.join(",\n")
201    );
202
203    tracing::trace!(sql = %sql, "Generated CREATE TABLE statement");
204    sql
205}
206
207/// Generate DROP TABLE SQL.
208fn generate_drop_table(table_name: &str, dialect: Dialect) -> String {
209    tracing::debug!(table = %table_name, "Generating DROP TABLE DDL");
210    format!(
211        "DROP TABLE IF EXISTS {}",
212        quote_identifier(table_name, dialect)
213    )
214}
215
216/// Generate RENAME TABLE SQL.
217fn generate_rename_table(from: &str, to: &str, dialect: Dialect) -> String {
218    tracing::debug!(from = %from, to = %to, "Generating RENAME TABLE DDL");
219    match dialect {
220        Dialect::Sqlite => format!(
221            "ALTER TABLE {} RENAME TO {}",
222            quote_identifier(from, dialect),
223            quote_identifier(to, dialect)
224        ),
225        Dialect::Mysql => format!(
226            "RENAME TABLE {} TO {}",
227            quote_identifier(from, dialect),
228            quote_identifier(to, dialect)
229        ),
230        Dialect::Postgres => format!(
231            "ALTER TABLE {} RENAME TO {}",
232            quote_identifier(from, dialect),
233            quote_identifier(to, dialect)
234        ),
235    }
236}
237
238/// Generate ADD COLUMN SQL.
239fn generate_add_column(table: &str, column: &ColumnInfo, dialect: Dialect) -> String {
240    tracing::debug!(table = %table, column = %column.name, "Generating ADD COLUMN DDL");
241    format!(
242        "ALTER TABLE {} ADD COLUMN {}",
243        quote_identifier(table, dialect),
244        format_column_def(column, dialect)
245    )
246}
247
248/// Generate RENAME COLUMN SQL.
249fn generate_rename_column(table: &str, from: &str, to: &str, dialect: Dialect) -> String {
250    tracing::debug!(table = %table, from = %from, to = %to, "Generating RENAME COLUMN DDL");
251    match dialect {
252        Dialect::Sqlite => {
253            // SQLite 3.25.0+ supports RENAME COLUMN
254            format!(
255                "ALTER TABLE {} RENAME COLUMN {} TO {}",
256                quote_identifier(table, dialect),
257                quote_identifier(from, dialect),
258                quote_identifier(to, dialect)
259            )
260        }
261        Dialect::Mysql => format!(
262            "ALTER TABLE {} RENAME COLUMN {} TO {}",
263            quote_identifier(table, dialect),
264            quote_identifier(from, dialect),
265            quote_identifier(to, dialect)
266        ),
267        Dialect::Postgres => format!(
268            "ALTER TABLE {} RENAME COLUMN {} TO {}",
269            quote_identifier(table, dialect),
270            quote_identifier(from, dialect),
271            quote_identifier(to, dialect)
272        ),
273    }
274}
275
276/// Generate CREATE INDEX SQL.
277fn generate_create_index(table: &str, index: &IndexInfo, dialect: Dialect) -> String {
278    tracing::debug!(
279        table = %table,
280        index = %index.name,
281        columns = ?index.columns,
282        unique = index.unique,
283        "Generating CREATE INDEX DDL"
284    );
285
286    let unique = if index.unique { "UNIQUE " } else { "" };
287    let cols: Vec<String> = index
288        .columns
289        .iter()
290        .map(|c| quote_identifier(c, dialect))
291        .collect();
292
293    // Include index type for databases that support it
294    let using = match dialect {
295        Dialect::Postgres => {
296            if let Some(ref idx_type) = index.index_type {
297                format!(" USING {}", idx_type)
298            } else {
299                String::new()
300            }
301        }
302        Dialect::Mysql => {
303            if let Some(ref idx_type) = index.index_type {
304                if idx_type.eq_ignore_ascii_case("BTREE") {
305                    String::new()
306                } else {
307                    format!(" USING {}", idx_type)
308                }
309            } else {
310                String::new()
311            }
312        }
313        Dialect::Sqlite => String::new(),
314    };
315
316    format!(
317        "CREATE {}INDEX {} ON {}{}({})",
318        unique,
319        quote_identifier(&index.name, dialect),
320        quote_identifier(table, dialect),
321        using,
322        cols.join(", ")
323    )
324}
325
326/// Generate DROP INDEX SQL.
327fn generate_drop_index(table: &str, index_name: &str, dialect: Dialect) -> String {
328    tracing::debug!(table = %table, index = %index_name, "Generating DROP INDEX DDL");
329    match dialect {
330        Dialect::Sqlite => format!(
331            "DROP INDEX IF EXISTS {}",
332            quote_identifier(index_name, dialect)
333        ),
334        Dialect::Mysql => format!(
335            "DROP INDEX {} ON {}",
336            quote_identifier(index_name, dialect),
337            quote_identifier(table, dialect)
338        ),
339        Dialect::Postgres => format!(
340            "DROP INDEX IF EXISTS {}",
341            quote_identifier(index_name, dialect)
342        ),
343    }
344}
345
346// ============================================================================
347// Unit Tests
348// ============================================================================
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::introspect::ParsedSqlType;
354
355    fn make_column(name: &str, sql_type: &str, nullable: bool) -> ColumnInfo {
356        ColumnInfo {
357            name: name.to_string(),
358            sql_type: sql_type.to_string(),
359            parsed_type: ParsedSqlType::parse(sql_type),
360            nullable,
361            default: None,
362            primary_key: false,
363            auto_increment: false,
364            comment: None,
365        }
366    }
367
368    fn make_table(name: &str, columns: Vec<ColumnInfo>, pk: Vec<&str>) -> TableInfo {
369        TableInfo {
370            name: name.to_string(),
371            columns,
372            primary_key: pk.into_iter().map(String::from).collect(),
373            foreign_keys: Vec::new(),
374            unique_constraints: Vec::new(),
375            check_constraints: Vec::new(),
376            indexes: Vec::new(),
377            comment: None,
378        }
379    }
380
381    #[test]
382    fn test_quote_identifier_sqlite() {
383        assert_eq!(quote_identifier("name", Dialect::Sqlite), "\"name\"");
384        assert_eq!(quote_identifier("table", Dialect::Sqlite), "\"table\"");
385        assert_eq!(
386            quote_identifier("col\"name", Dialect::Sqlite),
387            "\"col\"\"name\""
388        );
389    }
390
391    #[test]
392    fn test_quote_identifier_mysql() {
393        assert_eq!(quote_identifier("name", Dialect::Mysql), "`name`");
394        assert_eq!(quote_identifier("table", Dialect::Mysql), "`table`");
395        assert_eq!(quote_identifier("col`name", Dialect::Mysql), "`col``name`");
396    }
397
398    #[test]
399    fn test_format_column_def_basic() {
400        let col = make_column("name", "TEXT", false);
401        let def = format_column_def(&col, Dialect::Sqlite);
402        assert!(def.contains("\"name\""));
403        assert!(def.contains("TEXT"));
404        assert!(def.contains("NOT NULL"));
405    }
406
407    #[test]
408    fn test_format_column_def_nullable() {
409        let col = make_column("name", "TEXT", true);
410        let def = format_column_def(&col, Dialect::Sqlite);
411        assert!(!def.contains("NOT NULL"));
412    }
413
414    #[test]
415    fn test_format_column_def_with_default() {
416        let mut col = make_column("status", "TEXT", false);
417        col.default = Some("'active'".to_string());
418        let def = format_column_def(&col, Dialect::Sqlite);
419        assert!(def.contains("DEFAULT 'active'"));
420    }
421
422    #[test]
423    fn test_format_column_def_auto_increment_mysql() {
424        let mut col = make_column("id", "INT", false);
425        col.auto_increment = true;
426        let def = format_column_def(&col, Dialect::Mysql);
427        assert!(def.contains("AUTO_INCREMENT"));
428    }
429
430    #[test]
431    fn test_generate_create_table_basic() {
432        let table = make_table(
433            "heroes",
434            vec![
435                make_column("id", "INTEGER", false),
436                make_column("name", "TEXT", false),
437            ],
438            vec!["id"],
439        );
440        let sql = generate_create_table(&table, Dialect::Sqlite);
441        assert!(sql.contains("CREATE TABLE IF NOT EXISTS"));
442        assert!(sql.contains("\"heroes\""));
443        assert!(sql.contains("\"id\""));
444        assert!(sql.contains("\"name\""));
445        assert!(sql.contains("PRIMARY KEY"));
446    }
447
448    #[test]
449    fn test_generate_create_table_with_fk() {
450        let mut table = make_table(
451            "heroes",
452            vec![
453                make_column("id", "INTEGER", false),
454                make_column("team_id", "INTEGER", true),
455            ],
456            vec!["id"],
457        );
458        table.foreign_keys.push(ForeignKeyInfo {
459            name: Some("fk_heroes_team".to_string()),
460            column: "team_id".to_string(),
461            foreign_table: "teams".to_string(),
462            foreign_column: "id".to_string(),
463            on_delete: Some("CASCADE".to_string()),
464            on_update: None,
465        });
466
467        let sql = generate_create_table(&table, Dialect::Sqlite);
468        assert!(sql.contains("FOREIGN KEY"));
469        assert!(sql.contains("REFERENCES"));
470        assert!(sql.contains("ON DELETE CASCADE"));
471    }
472
473    #[test]
474    fn test_generate_drop_table() {
475        let sql = generate_drop_table("heroes", Dialect::Sqlite);
476        assert_eq!(sql, "DROP TABLE IF EXISTS \"heroes\"");
477    }
478
479    #[test]
480    fn test_generate_rename_table_sqlite() {
481        let sql = generate_rename_table("old_name", "new_name", Dialect::Sqlite);
482        assert!(sql.contains("ALTER TABLE"));
483        assert!(sql.contains("RENAME TO"));
484    }
485
486    #[test]
487    fn test_generate_rename_table_mysql() {
488        let sql = generate_rename_table("old_name", "new_name", Dialect::Mysql);
489        assert!(sql.contains("RENAME TABLE"));
490    }
491
492    #[test]
493    fn test_generate_add_column() {
494        let col = make_column("age", "INTEGER", true);
495        let sql = generate_add_column("heroes", &col, Dialect::Sqlite);
496        assert!(sql.contains("ALTER TABLE"));
497        assert!(sql.contains("ADD COLUMN"));
498        assert!(sql.contains("\"age\""));
499    }
500
501    #[test]
502    fn test_generate_rename_column() {
503        let sql = generate_rename_column("heroes", "old_name", "new_name", Dialect::Postgres);
504        assert!(sql.contains("ALTER TABLE"));
505        assert!(sql.contains("RENAME COLUMN"));
506    }
507
508    #[test]
509    fn test_generate_create_index() {
510        let index = IndexInfo {
511            name: "idx_heroes_name".to_string(),
512            columns: vec!["name".to_string()],
513            unique: false,
514            index_type: None,
515            primary: false,
516        };
517        let sql = generate_create_index("heroes", &index, Dialect::Sqlite);
518        assert!(sql.contains("CREATE INDEX"));
519        assert!(sql.contains("\"idx_heroes_name\""));
520        assert!(sql.contains("ON \"heroes\""));
521    }
522
523    #[test]
524    fn test_generate_create_unique_index() {
525        let index = IndexInfo {
526            name: "idx_heroes_name_unique".to_string(),
527            columns: vec!["name".to_string()],
528            unique: true,
529            index_type: None,
530            primary: false,
531        };
532        let sql = generate_create_index("heroes", &index, Dialect::Sqlite);
533        assert!(sql.contains("CREATE UNIQUE INDEX"));
534    }
535
536    #[test]
537    fn test_generate_drop_index_sqlite() {
538        let sql = generate_drop_index("heroes", "idx_heroes_name", Dialect::Sqlite);
539        assert_eq!(sql, "DROP INDEX IF EXISTS \"idx_heroes_name\"");
540    }
541
542    #[test]
543    fn test_generate_drop_index_mysql() {
544        let sql = generate_drop_index("heroes", "idx_heroes_name", Dialect::Mysql);
545        assert!(sql.contains("DROP INDEX"));
546        assert!(sql.contains("ON `heroes`"));
547    }
548
549    #[test]
550    fn test_generator_for_dialect() {
551        let sqlite = generator_for_dialect(Dialect::Sqlite);
552        assert_eq!(sqlite.dialect(), "sqlite");
553
554        let mysql = generator_for_dialect(Dialect::Mysql);
555        assert_eq!(mysql.dialect(), "mysql");
556
557        let postgres = generator_for_dialect(Dialect::Postgres);
558        assert_eq!(postgres.dialect(), "postgres");
559    }
560
561    #[test]
562    fn test_referential_action_formatting() {
563        assert_eq!(
564            format_referential_action(Some(&"CASCADE".to_string())),
565            "CASCADE"
566        );
567        assert_eq!(
568            format_referential_action(Some(&"cascade".to_string())),
569            "CASCADE"
570        );
571        assert_eq!(
572            format_referential_action(Some(&"SET NULL".to_string())),
573            "SET NULL"
574        );
575        assert_eq!(format_referential_action(None), "NO ACTION");
576    }
577}