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 with configurable `IF NOT EXISTS`.
157///
158/// Kept private to `ddl` and its submodules (SQLite drop-column needs a
159/// strict create without IF NOT EXISTS for table recreation).
160fn generate_create_table_with_if_not_exists(
161    table: &TableInfo,
162    dialect: Dialect,
163    if_not_exists: bool,
164) -> String {
165    tracing::debug!(
166        dialect = %match dialect {
167            Dialect::Sqlite => "sqlite",
168            Dialect::Mysql => "mysql",
169            Dialect::Postgres => "postgres",
170        },
171        table = %table.name,
172        columns = table.columns.len(),
173        "Generating CREATE TABLE DDL"
174    );
175
176    let mut parts = Vec::new();
177
178    // Column definitions
179    for col in &table.columns {
180        parts.push(format!("  {}", format_column_def(col, dialect)));
181    }
182
183    // Primary key constraint (if not embedded in column definition)
184    if !table.primary_key.is_empty() {
185        let pk_cols: Vec<String> = table
186            .primary_key
187            .iter()
188            .map(|c| quote_identifier(c, dialect))
189            .collect();
190        parts.push(format!("  PRIMARY KEY ({})", pk_cols.join(", ")));
191    }
192
193    // Unique constraints
194    for unique in &table.unique_constraints {
195        parts.push(format!("  {}", format_unique_constraint(unique, dialect)));
196    }
197
198    // Foreign key constraints
199    for fk in &table.foreign_keys {
200        parts.push(format!("  {}", format_fk_constraint(fk, dialect)));
201    }
202
203    let table_name = quote_identifier(&table.name, dialect);
204    let ine = if if_not_exists { " IF NOT EXISTS" } else { "" };
205    let sql = format!(
206        "CREATE TABLE{} {} (\n{}\n)",
207        ine,
208        table_name,
209        parts.join(",\n")
210    );
211
212    tracing::trace!(sql = %sql, "Generated CREATE TABLE statement");
213    sql
214}
215
216/// Generate CREATE TABLE SQL (defaulting to `IF NOT EXISTS`).
217fn generate_create_table(table: &TableInfo, dialect: Dialect) -> String {
218    generate_create_table_with_if_not_exists(table, dialect, true)
219}
220
221/// Generate DROP TABLE SQL.
222fn generate_drop_table(table_name: &str, dialect: Dialect) -> String {
223    tracing::debug!(table = %table_name, "Generating DROP TABLE DDL");
224    format!(
225        "DROP TABLE IF EXISTS {}",
226        quote_identifier(table_name, dialect)
227    )
228}
229
230/// Generate RENAME TABLE SQL.
231fn generate_rename_table(from: &str, to: &str, dialect: Dialect) -> String {
232    tracing::debug!(from = %from, to = %to, "Generating RENAME TABLE DDL");
233    match dialect {
234        Dialect::Sqlite => format!(
235            "ALTER TABLE {} RENAME TO {}",
236            quote_identifier(from, dialect),
237            quote_identifier(to, dialect)
238        ),
239        Dialect::Mysql => format!(
240            "RENAME TABLE {} TO {}",
241            quote_identifier(from, dialect),
242            quote_identifier(to, dialect)
243        ),
244        Dialect::Postgres => format!(
245            "ALTER TABLE {} RENAME TO {}",
246            quote_identifier(from, dialect),
247            quote_identifier(to, dialect)
248        ),
249    }
250}
251
252/// Generate ADD COLUMN SQL.
253fn generate_add_column(table: &str, column: &ColumnInfo, dialect: Dialect) -> String {
254    tracing::debug!(table = %table, column = %column.name, "Generating ADD COLUMN DDL");
255    format!(
256        "ALTER TABLE {} ADD COLUMN {}",
257        quote_identifier(table, dialect),
258        format_column_def(column, dialect)
259    )
260}
261
262/// Generate RENAME COLUMN SQL.
263fn generate_rename_column(table: &str, from: &str, to: &str, dialect: Dialect) -> String {
264    tracing::debug!(table = %table, from = %from, to = %to, "Generating RENAME COLUMN DDL");
265    match dialect {
266        Dialect::Sqlite => {
267            // SQLite 3.25.0+ supports RENAME COLUMN
268            format!(
269                "ALTER TABLE {} RENAME COLUMN {} TO {}",
270                quote_identifier(table, dialect),
271                quote_identifier(from, dialect),
272                quote_identifier(to, dialect)
273            )
274        }
275        Dialect::Mysql => format!(
276            "ALTER TABLE {} RENAME COLUMN {} TO {}",
277            quote_identifier(table, dialect),
278            quote_identifier(from, dialect),
279            quote_identifier(to, dialect)
280        ),
281        Dialect::Postgres => format!(
282            "ALTER TABLE {} RENAME COLUMN {} TO {}",
283            quote_identifier(table, dialect),
284            quote_identifier(from, dialect),
285            quote_identifier(to, dialect)
286        ),
287    }
288}
289
290/// Generate CREATE INDEX SQL.
291fn generate_create_index(table: &str, index: &IndexInfo, dialect: Dialect) -> String {
292    tracing::debug!(
293        table = %table,
294        index = %index.name,
295        columns = ?index.columns,
296        unique = index.unique,
297        "Generating CREATE INDEX DDL"
298    );
299
300    let unique = if index.unique { "UNIQUE " } else { "" };
301    let cols: Vec<String> = index
302        .columns
303        .iter()
304        .map(|c| quote_identifier(c, dialect))
305        .collect();
306
307    // Include index type for databases that support it
308    let using = match dialect {
309        Dialect::Postgres => {
310            if let Some(ref idx_type) = index.index_type {
311                format!(" USING {}", idx_type)
312            } else {
313                String::new()
314            }
315        }
316        Dialect::Mysql => {
317            if let Some(ref idx_type) = index.index_type {
318                if idx_type.eq_ignore_ascii_case("BTREE") {
319                    String::new()
320                } else {
321                    format!(" USING {}", idx_type)
322                }
323            } else {
324                String::new()
325            }
326        }
327        Dialect::Sqlite => String::new(),
328    };
329
330    format!(
331        "CREATE {}INDEX {} ON {}{}({})",
332        unique,
333        quote_identifier(&index.name, dialect),
334        quote_identifier(table, dialect),
335        using,
336        cols.join(", ")
337    )
338}
339
340/// Generate DROP INDEX SQL.
341fn generate_drop_index(table: &str, index_name: &str, dialect: Dialect) -> String {
342    tracing::debug!(table = %table, index = %index_name, "Generating DROP INDEX DDL");
343    match dialect {
344        Dialect::Sqlite => format!(
345            "DROP INDEX IF EXISTS {}",
346            quote_identifier(index_name, dialect)
347        ),
348        Dialect::Mysql => format!(
349            "DROP INDEX {} ON {}",
350            quote_identifier(index_name, dialect),
351            quote_identifier(table, dialect)
352        ),
353        Dialect::Postgres => format!(
354            "DROP INDEX IF EXISTS {}",
355            quote_identifier(index_name, dialect)
356        ),
357    }
358}
359
360// ============================================================================
361// Unit Tests
362// ============================================================================
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use crate::introspect::ParsedSqlType;
368
369    fn make_column(name: &str, sql_type: &str, nullable: bool) -> ColumnInfo {
370        ColumnInfo {
371            name: name.to_string(),
372            sql_type: sql_type.to_string(),
373            parsed_type: ParsedSqlType::parse(sql_type),
374            nullable,
375            default: None,
376            primary_key: false,
377            auto_increment: false,
378            comment: None,
379        }
380    }
381
382    fn make_table(name: &str, columns: Vec<ColumnInfo>, pk: Vec<&str>) -> TableInfo {
383        TableInfo {
384            name: name.to_string(),
385            columns,
386            primary_key: pk.into_iter().map(String::from).collect(),
387            foreign_keys: Vec::new(),
388            unique_constraints: Vec::new(),
389            check_constraints: Vec::new(),
390            indexes: Vec::new(),
391            comment: None,
392        }
393    }
394
395    #[test]
396    fn test_quote_identifier_sqlite() {
397        assert_eq!(quote_identifier("name", Dialect::Sqlite), "\"name\"");
398        assert_eq!(quote_identifier("table", Dialect::Sqlite), "\"table\"");
399        assert_eq!(
400            quote_identifier("col\"name", Dialect::Sqlite),
401            "\"col\"\"name\""
402        );
403    }
404
405    #[test]
406    fn test_quote_identifier_mysql() {
407        assert_eq!(quote_identifier("name", Dialect::Mysql), "`name`");
408        assert_eq!(quote_identifier("table", Dialect::Mysql), "`table`");
409        assert_eq!(quote_identifier("col`name", Dialect::Mysql), "`col``name`");
410    }
411
412    #[test]
413    fn test_format_column_def_basic() {
414        let col = make_column("name", "TEXT", false);
415        let def = format_column_def(&col, Dialect::Sqlite);
416        assert!(def.contains("\"name\""));
417        assert!(def.contains("TEXT"));
418        assert!(def.contains("NOT NULL"));
419    }
420
421    #[test]
422    fn test_format_column_def_nullable() {
423        let col = make_column("name", "TEXT", true);
424        let def = format_column_def(&col, Dialect::Sqlite);
425        assert!(!def.contains("NOT NULL"));
426    }
427
428    #[test]
429    fn test_format_column_def_with_default() {
430        let mut col = make_column("status", "TEXT", false);
431        col.default = Some("'active'".to_string());
432        let def = format_column_def(&col, Dialect::Sqlite);
433        assert!(def.contains("DEFAULT 'active'"));
434    }
435
436    #[test]
437    fn test_format_column_def_auto_increment_mysql() {
438        let mut col = make_column("id", "INT", false);
439        col.auto_increment = true;
440        let def = format_column_def(&col, Dialect::Mysql);
441        assert!(def.contains("AUTO_INCREMENT"));
442    }
443
444    #[test]
445    fn test_generate_create_table_basic() {
446        let table = make_table(
447            "heroes",
448            vec![
449                make_column("id", "INTEGER", false),
450                make_column("name", "TEXT", false),
451            ],
452            vec!["id"],
453        );
454        let sql = generate_create_table(&table, Dialect::Sqlite);
455        assert!(sql.contains("CREATE TABLE IF NOT EXISTS"));
456        assert!(sql.contains("\"heroes\""));
457        assert!(sql.contains("\"id\""));
458        assert!(sql.contains("\"name\""));
459        assert!(sql.contains("PRIMARY KEY"));
460    }
461
462    #[test]
463    fn test_generate_create_table_with_fk() {
464        let mut table = make_table(
465            "heroes",
466            vec![
467                make_column("id", "INTEGER", false),
468                make_column("team_id", "INTEGER", true),
469            ],
470            vec!["id"],
471        );
472        table.foreign_keys.push(ForeignKeyInfo {
473            name: Some("fk_heroes_team".to_string()),
474            column: "team_id".to_string(),
475            foreign_table: "teams".to_string(),
476            foreign_column: "id".to_string(),
477            on_delete: Some("CASCADE".to_string()),
478            on_update: None,
479        });
480
481        let sql = generate_create_table(&table, Dialect::Sqlite);
482        assert!(sql.contains("FOREIGN KEY"));
483        assert!(sql.contains("REFERENCES"));
484        assert!(sql.contains("ON DELETE CASCADE"));
485    }
486
487    #[test]
488    fn test_generate_drop_table() {
489        let sql = generate_drop_table("heroes", Dialect::Sqlite);
490        assert_eq!(sql, "DROP TABLE IF EXISTS \"heroes\"");
491    }
492
493    #[test]
494    fn test_generate_rename_table_sqlite() {
495        let sql = generate_rename_table("old_name", "new_name", Dialect::Sqlite);
496        assert!(sql.contains("ALTER TABLE"));
497        assert!(sql.contains("RENAME TO"));
498    }
499
500    #[test]
501    fn test_generate_rename_table_mysql() {
502        let sql = generate_rename_table("old_name", "new_name", Dialect::Mysql);
503        assert!(sql.contains("RENAME TABLE"));
504    }
505
506    #[test]
507    fn test_generate_add_column() {
508        let col = make_column("age", "INTEGER", true);
509        let sql = generate_add_column("heroes", &col, Dialect::Sqlite);
510        assert!(sql.contains("ALTER TABLE"));
511        assert!(sql.contains("ADD COLUMN"));
512        assert!(sql.contains("\"age\""));
513    }
514
515    #[test]
516    fn test_generate_rename_column() {
517        let sql = generate_rename_column("heroes", "old_name", "new_name", Dialect::Postgres);
518        assert!(sql.contains("ALTER TABLE"));
519        assert!(sql.contains("RENAME COLUMN"));
520    }
521
522    #[test]
523    fn test_generate_create_index() {
524        let index = IndexInfo {
525            name: "idx_heroes_name".to_string(),
526            columns: vec!["name".to_string()],
527            unique: false,
528            index_type: None,
529            primary: false,
530        };
531        let sql = generate_create_index("heroes", &index, Dialect::Sqlite);
532        assert!(sql.contains("CREATE INDEX"));
533        assert!(sql.contains("\"idx_heroes_name\""));
534        assert!(sql.contains("ON \"heroes\""));
535    }
536
537    #[test]
538    fn test_generate_create_unique_index() {
539        let index = IndexInfo {
540            name: "idx_heroes_name_unique".to_string(),
541            columns: vec!["name".to_string()],
542            unique: true,
543            index_type: None,
544            primary: false,
545        };
546        let sql = generate_create_index("heroes", &index, Dialect::Sqlite);
547        assert!(sql.contains("CREATE UNIQUE INDEX"));
548    }
549
550    #[test]
551    fn test_generate_drop_index_sqlite() {
552        let sql = generate_drop_index("heroes", "idx_heroes_name", Dialect::Sqlite);
553        assert_eq!(sql, "DROP INDEX IF EXISTS \"idx_heroes_name\"");
554    }
555
556    #[test]
557    fn test_generate_drop_index_mysql() {
558        let sql = generate_drop_index("heroes", "idx_heroes_name", Dialect::Mysql);
559        assert!(sql.contains("DROP INDEX"));
560        assert!(sql.contains("ON `heroes`"));
561    }
562
563    #[test]
564    fn test_generator_for_dialect() {
565        let sqlite = generator_for_dialect(Dialect::Sqlite);
566        assert_eq!(sqlite.dialect(), "sqlite");
567
568        let mysql = generator_for_dialect(Dialect::Mysql);
569        assert_eq!(mysql.dialect(), "mysql");
570
571        let postgres = generator_for_dialect(Dialect::Postgres);
572        assert_eq!(postgres.dialect(), "postgres");
573    }
574
575    #[test]
576    fn test_referential_action_formatting() {
577        assert_eq!(
578            format_referential_action(Some(&"CASCADE".to_string())),
579            "CASCADE"
580        );
581        assert_eq!(
582            format_referential_action(Some(&"cascade".to_string())),
583            "CASCADE"
584        );
585        assert_eq!(
586            format_referential_action(Some(&"SET NULL".to_string())),
587            "SET NULL"
588        );
589        assert_eq!(format_referential_action(None), "NO ACTION");
590    }
591}