Skip to main content

rust_ef/migration/
engine_sql.rs

1//! `MigrationEngine` SQL generation — Up/Down DDL from `SchemaChange` lists.
2
3use super::dialect::MigrationDialect;
4use super::diff::index_name;
5use super::engine::MigrationEngine;
6use super::history::{MIGRATION_HISTORY_TABLE, PRODUCT_VERSION};
7use super::schema_change::SchemaChange;
8use super::types::{ModelSnapshot, SnapshotColumn};
9
10impl MigrationEngine {
11    pub(crate) fn initial_create_with_fks(&self, current: &ModelSnapshot) -> Vec<SchemaChange> {
12        let mut changes = self.initial_create(current);
13        for et in &current.entity_types {
14            Self::append_create_table_fks(&mut changes, &et.table_name, &et.columns);
15            Self::append_create_table_indexes(&mut changes, &et.table_name, &et.columns);
16        }
17        changes
18    }
19
20    // -----------------------------------------------------------------------
21    // SQL generation
22    // -----------------------------------------------------------------------
23
24    /// Standard foreign-key constraint name used by Add/DropForeignKey.
25    pub fn foreign_key_name(table: &str, column: &str, referenced_table: &str) -> String {
26        format!("fk_{}_{}_{}", table, column, referenced_table)
27    }
28
29    /// Standard index name used by CreateIndex/DropIndex.
30    pub fn index_name(table: &str, column: &str) -> String {
31        format!("ix_{}_{}", table, column)
32    }
33
34    /// Returns the column type and whether MySQL `AUTO_INCREMENT` must be
35    /// emitted after the nullability clause.
36    ///
37    /// MySQL's column grammar is `type [NOT NULL|NULL] ... [AUTO_INCREMENT]`;
38    /// emitting `INT AUTO_INCREMENT NOT NULL` makes the primary key column
39    /// nullable (error 1171).
40    fn column_type(&self, col: &SnapshotColumn) -> (String, bool) {
41        let col_type = self.dialect.map_column_type(col);
42        if self.dialect == MigrationDialect::MySql && (col.is_auto_increment || col.is_sequence) {
43            let stripped = col_type
44                .strip_suffix(" AUTO_INCREMENT")
45                .unwrap_or(&col_type)
46                .to_string();
47            (stripped, true)
48        } else {
49            (col_type, false)
50        }
51    }
52
53    pub fn generate_alter_column_sql(
54        &self,
55        table: &str,
56        column_name: &str,
57        new: &SnapshotColumn,
58    ) -> String {
59        let q = |s: &str| self.dialect.quote(s);
60        let (col_type, mysql_auto_increment) = self.column_type(new);
61        match self.dialect {
62            MigrationDialect::Postgres => {
63                let nullable = if new.is_required || new.is_primary_key {
64                    "SET NOT NULL"
65                } else {
66                    "DROP NOT NULL"
67                };
68                format!(
69                    "ALTER TABLE {} ALTER COLUMN {} TYPE {};\nALTER TABLE {} ALTER COLUMN {} {};\n",
70                    q(table),
71                    q(column_name),
72                    col_type,
73                    q(table),
74                    q(column_name),
75                    nullable
76                )
77            }
78            MigrationDialect::MySql => {
79                let nullable = if new.is_required || new.is_primary_key {
80                    "NOT NULL"
81                } else {
82                    "NULL"
83                };
84                let auto_increment = if mysql_auto_increment {
85                    " AUTO_INCREMENT"
86                } else {
87                    ""
88                };
89                format!(
90                    "ALTER TABLE {} MODIFY COLUMN {} {} {}{};\n",
91                    q(table),
92                    q(column_name),
93                    col_type,
94                    nullable,
95                    auto_increment
96                )
97            }
98            MigrationDialect::Sqlite => format!(
99                "-- SQLite does not support ALTER COLUMN type changes; \
100                 rebuild table manually for {}.{}\n",
101                q(table),
102                q(column_name)
103            ),
104        }
105    }
106
107    pub(crate) fn generate_up_sql(&self, changes: &[SchemaChange]) -> String {
108        self.generate_up_sql_inner(changes, true)
109    }
110
111    pub(crate) fn generate_ddl_sql(&self, changes: &[SchemaChange]) -> String {
112        self.generate_up_sql_inner(changes, false)
113    }
114
115    fn generate_up_sql_inner(&self, changes: &[SchemaChange], record_history: bool) -> String {
116        let mut sql = String::from("-- Up Migration (auto-generated by rust-ef)\n\n");
117        let q = |s: &str| self.dialect.quote(s);
118        let create_kw = if record_history {
119            "CREATE TABLE"
120        } else {
121            "CREATE TABLE IF NOT EXISTS"
122        };
123
124        for change in changes {
125            match change {
126                SchemaChange::CreateTable { table, columns } => {
127                    // PostgreSQL: create sequences before the table
128                    if self.dialect == MigrationDialect::Postgres {
129                        for c in columns {
130                            if c.is_sequence {
131                                if let Some(seq_name) = &c.sequence_name {
132                                    sql.push_str(&format!(
133                                        "CREATE SEQUENCE IF NOT EXISTS {};\n",
134                                        q(seq_name)
135                                    ));
136                                }
137                            }
138                        }
139                    }
140
141                    sql.push_str(&format!("{} {} (\n", create_kw, q(table)));
142
143                    // Separate primary key columns from regular columns
144                    let pk_columns: Vec<&str> = columns
145                        .iter()
146                        .filter(|c| c.is_primary_key)
147                        .map(|c| c.column_name.as_str())
148                        .collect();
149
150                    let col_defs: Vec<String> = columns
151                        .iter()
152                        .map(|c| {
153                            let nullable = if c.is_required || c.is_primary_key {
154                                "NOT NULL"
155                            } else {
156                                "NULL"
157                            };
158                            let (col_type, mysql_auto_increment) = self.column_type(c);
159                            // PostgreSQL sequence column: add DEFAULT nextval('seq_name')
160                            if c.is_sequence && self.dialect == MigrationDialect::Postgres {
161                                if let Some(seq_name) = &c.sequence_name {
162                                    return format!(
163                                        "{} {} DEFAULT nextval('{}') {}",
164                                        q(&c.column_name),
165                                        col_type,
166                                        seq_name,
167                                        nullable
168                                    );
169                                }
170                            }
171                            // Don't put PRIMARY KEY on individual columns; handle separately
172                            let mut parts = vec![q(&c.column_name), col_type, nullable.to_string()];
173                            if mysql_auto_increment {
174                                parts.push("AUTO_INCREMENT".to_string());
175                            }
176                            parts.join(" ")
177                        })
178                        .collect();
179                    sql.push_str(&format!("    {}\n", col_defs.join(",\n    ")));
180
181                    // Composite primary key constraint
182                    if pk_columns.len() == 1 {
183                        sql.push_str(&format!("    ,PRIMARY KEY ({})\n", q(pk_columns[0])));
184                    } else if pk_columns.len() > 1 {
185                        let pk_list: Vec<String> = pk_columns.iter().map(|c| q(c)).collect();
186                        sql.push_str(&format!("    ,PRIMARY KEY ({})\n", pk_list.join(", ")));
187                    }
188
189                    sql.push_str(");\n\n");
190                }
191                SchemaChange::DropTable { table } => {
192                    sql.push_str(&format!("DROP TABLE IF EXISTS {};\n", q(table)));
193                }
194                SchemaChange::AddColumn { table, column } => {
195                    let (col_type, mysql_auto_increment) = self.column_type(column);
196                    let nullable = if column.is_required || column.is_primary_key {
197                        "NOT NULL"
198                    } else {
199                        "NULL"
200                    };
201                    let auto_increment = if mysql_auto_increment {
202                        " AUTO_INCREMENT"
203                    } else {
204                        ""
205                    };
206                    sql.push_str(&format!(
207                        "ALTER TABLE {} ADD COLUMN {} {} {}{};\n",
208                        q(table),
209                        q(&column.column_name),
210                        col_type,
211                        nullable,
212                        auto_increment
213                    ));
214                }
215                SchemaChange::DropColumn { table, column_name } => {
216                    sql.push_str(&format!(
217                        "ALTER TABLE {} DROP COLUMN IF EXISTS {};\n",
218                        q(table),
219                        q(column_name)
220                    ));
221                }
222                SchemaChange::AlterColumn {
223                    table,
224                    column_name,
225                    old: _,
226                    new,
227                } => {
228                    sql.push_str(&self.generate_alter_column_sql(table, column_name, new));
229                }
230                SchemaChange::AddForeignKey {
231                    table,
232                    column,
233                    referenced_table,
234                    referenced_column,
235                    on_delete,
236                } => {
237                    let fk_name = Self::foreign_key_name(table, column, referenced_table);
238                    let on_delete_clause = on_delete
239                        .as_deref()
240                        .map(|c| format!(" ON DELETE {}", c))
241                        .unwrap_or_default();
242                    sql.push_str(&format!(
243                        "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({}){};\n",
244                        q(table),
245                        q(&fk_name),
246                        q(column),
247                        q(referenced_table),
248                        q(referenced_column),
249                        on_delete_clause
250                    ));
251                }
252                SchemaChange::DropForeignKey {
253                    table,
254                    column,
255                    referenced_table,
256                } => {
257                    let fk_name = Self::foreign_key_name(table, column, referenced_table);
258                    match self.dialect {
259                        MigrationDialect::MySql => sql.push_str(&format!(
260                            "ALTER TABLE {} DROP FOREIGN KEY {};\n",
261                            q(table),
262                            q(&fk_name)
263                        )),
264                        _ => sql.push_str(&format!(
265                            "ALTER TABLE {} DROP CONSTRAINT IF EXISTS {};\n",
266                            q(table),
267                            q(&fk_name)
268                        )),
269                    }
270                }
271                SchemaChange::CreateIndex {
272                    table,
273                    column,
274                    is_unique,
275                } => {
276                    let unique_kw = if *is_unique { "UNIQUE " } else { "" };
277                    let idx_name = index_name(table, column);
278                    sql.push_str(&format!(
279                        "CREATE {unique_kw}INDEX {} ON {} ({});\n",
280                        q(&idx_name),
281                        q(table),
282                        q(column)
283                    ));
284                }
285                SchemaChange::DropIndex {
286                    table,
287                    column,
288                    is_unique: _,
289                } => {
290                    let idx_name = index_name(table, column);
291                    match self.dialect {
292                        MigrationDialect::MySql => {
293                            sql.push_str(&format!("DROP INDEX {} ON {};\n", q(&idx_name), q(table)))
294                        }
295                        _ => sql.push_str(&format!("DROP INDEX IF EXISTS {};\n", q(&idx_name))),
296                    }
297                }
298            }
299        }
300
301        if record_history {
302            sql.push_str(&format!(
303                "INSERT INTO {}(migration_id, product_version) VALUES ('{{migration_id}}', '{}');\n",
304                q(MIGRATION_HISTORY_TABLE),
305                PRODUCT_VERSION
306            ));
307        }
308
309        sql
310    }
311
312    pub(super) fn generate_down_sql(&self, changes: &[SchemaChange]) -> String {
313        let mut sql = String::from("-- Down Migration (auto-generated by rust-ef)\n\n");
314        let q = |s: &str| self.dialect.quote(s);
315
316        // Reverse the changes for the down migration
317        for change in changes.iter().rev() {
318            match change {
319                SchemaChange::CreateTable { table, .. } => {
320                    sql.push_str(&format!("DROP TABLE IF EXISTS {};\n", q(table)));
321                }
322                SchemaChange::DropTable { table } => {
323                    sql.push_str(&format!(
324                        "-- WARNING: Cannot restore table {} (original schema unknown)\n",
325                        q(table)
326                    ));
327                }
328                SchemaChange::AddColumn { table, column } => {
329                    sql.push_str(&format!(
330                        "ALTER TABLE {} DROP COLUMN IF EXISTS {};\n",
331                        q(table),
332                        q(&column.column_name)
333                    ));
334                }
335                SchemaChange::DropColumn { table, column_name } => {
336                    sql.push_str(&format!(
337                        "-- WARNING: Cannot restore column {} on {} (original type unknown)\n",
338                        q(column_name),
339                        q(table)
340                    ));
341                }
342                SchemaChange::AlterColumn {
343                    table,
344                    column_name,
345                    old,
346                    new: _,
347                } => {
348                    sql.push_str(&self.generate_alter_column_sql(table, column_name, old));
349                }
350                SchemaChange::AddForeignKey {
351                    table,
352                    column,
353                    referenced_table,
354                    ..
355                } => {
356                    let fk_name = Self::foreign_key_name(table, column, referenced_table);
357                    match self.dialect {
358                        MigrationDialect::MySql => sql.push_str(&format!(
359                            "ALTER TABLE {} DROP FOREIGN KEY {};\n",
360                            q(table),
361                            q(&fk_name)
362                        )),
363                        _ => sql.push_str(&format!(
364                            "ALTER TABLE {} DROP CONSTRAINT IF EXISTS {};\n",
365                            q(table),
366                            q(&fk_name)
367                        )),
368                    }
369                }
370                SchemaChange::DropForeignKey {
371                    table,
372                    column,
373                    referenced_table,
374                    ..
375                } => {
376                    sql.push_str(&format!(
377                        "-- WARNING: Cannot restore foreign key constraint {} on {}.{}\n",
378                        q(&Self::foreign_key_name(table, column, referenced_table)),
379                        q(table),
380                        q(column)
381                    ));
382                }
383                SchemaChange::CreateIndex {
384                    table,
385                    column,
386                    is_unique: _,
387                } => {
388                    let idx_name = index_name(table, column);
389                    match self.dialect {
390                        MigrationDialect::MySql => {
391                            sql.push_str(&format!("DROP INDEX {} ON {};\n", q(&idx_name), q(table)))
392                        }
393                        _ => sql.push_str(&format!("DROP INDEX IF EXISTS {};\n", q(&idx_name))),
394                    }
395                }
396                SchemaChange::DropIndex {
397                    table,
398                    column,
399                    is_unique,
400                } => {
401                    let unique_kw = if *is_unique { "UNIQUE " } else { "" };
402                    let idx_name = index_name(table, column);
403                    sql.push_str(&format!(
404                        "CREATE {unique_kw}INDEX {} ON {} ({});\n",
405                        q(&idx_name),
406                        q(table),
407                        q(column)
408                    ));
409                }
410            }
411        }
412
413        sql.push_str(&format!(
414            "DELETE FROM {} WHERE migration_id = '{{migration_id}}';\n",
415            q(MIGRATION_HISTORY_TABLE)
416        ));
417
418        sql
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use crate::migration::schema_change::SchemaChange;
426    use crate::migration::types::SnapshotColumn;
427
428    #[test]
429    fn mysql_auto_increment_follows_nullability() {
430        let engine = MigrationEngine::new(MigrationDialect::MySql);
431        let column = SnapshotColumn {
432            column_name: "id".into(),
433            type_name: "i32".into(),
434            is_primary_key: true,
435            is_required: true,
436            is_auto_increment: true,
437            ..Default::default()
438        };
439        let sql = engine.generate_ddl_sql(&[SchemaChange::CreateTable {
440            table: "items".into(),
441            columns: vec![column],
442        }]);
443        assert!(
444            sql.contains("`id` INT NOT NULL AUTO_INCREMENT"),
445            "unexpected DDL: {sql}"
446        );
447        assert!(
448            !sql.contains("INT AUTO_INCREMENT NOT NULL"),
449            "unexpected DDL: {sql}"
450        );
451    }
452
453    #[test]
454    fn primary_key_columns_are_not_null_even_without_required() {
455        let engine = MigrationEngine::new(MigrationDialect::Postgres);
456        let column = SnapshotColumn {
457            column_name: "id".into(),
458            type_name: "i32".into(),
459            is_primary_key: true,
460            is_required: false,
461            is_auto_increment: true,
462            ..Default::default()
463        };
464        let sql = engine.generate_ddl_sql(&[SchemaChange::CreateTable {
465            table: "items".into(),
466            columns: vec![column],
467        }]);
468        assert!(
469            sql.contains("\"id\" SERIAL NOT NULL"),
470            "unexpected DDL: {sql}"
471        );
472    }
473}