vespertide_core/
action.rs

1use crate::schema::{
2    ColumnDef, ColumnName, ColumnType, IndexDef, IndexName, TableConstraint, TableName,
3};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
9#[serde(rename_all = "snake_case")]
10pub struct MigrationPlan {
11    pub comment: Option<String>,
12    #[serde(default)]
13    pub created_at: Option<String>,
14    pub version: u32,
15    pub actions: Vec<MigrationAction>,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
19#[serde(tag = "type", rename_all = "snake_case")]
20pub enum MigrationAction {
21    CreateTable {
22        table: TableName,
23        columns: Vec<ColumnDef>,
24        constraints: Vec<TableConstraint>,
25    },
26    DeleteTable {
27        table: TableName,
28    },
29    AddColumn {
30        table: TableName,
31        column: Box<ColumnDef>,
32        /// Optional fill value to backfill existing rows when adding NOT NULL without default.
33        fill_with: Option<String>,
34    },
35    RenameColumn {
36        table: TableName,
37        from: ColumnName,
38        to: ColumnName,
39    },
40    DeleteColumn {
41        table: TableName,
42        column: ColumnName,
43    },
44    ModifyColumnType {
45        table: TableName,
46        column: ColumnName,
47        new_type: ColumnType,
48    },
49    AddIndex {
50        table: TableName,
51        index: IndexDef,
52    },
53    RemoveIndex {
54        table: TableName,
55        name: IndexName,
56    },
57    AddConstraint {
58        table: TableName,
59        constraint: TableConstraint,
60    },
61    RemoveConstraint {
62        table: TableName,
63        constraint: TableConstraint,
64    },
65    RenameTable {
66        from: TableName,
67        to: TableName,
68    },
69    RawSql {
70        sql: String,
71    },
72}
73
74impl fmt::Display for MigrationAction {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        match self {
77            MigrationAction::CreateTable { table, .. } => {
78                write!(f, "CreateTable: {}", table)
79            }
80            MigrationAction::DeleteTable { table } => {
81                write!(f, "DeleteTable: {}", table)
82            }
83            MigrationAction::AddColumn { table, column, .. } => {
84                write!(f, "AddColumn: {}.{}", table, column.name)
85            }
86            MigrationAction::RenameColumn { table, from, to } => {
87                write!(f, "RenameColumn: {}.{} -> {}", table, from, to)
88            }
89            MigrationAction::DeleteColumn { table, column } => {
90                write!(f, "DeleteColumn: {}.{}", table, column)
91            }
92            MigrationAction::ModifyColumnType { table, column, .. } => {
93                write!(f, "ModifyColumnType: {}.{}", table, column)
94            }
95            MigrationAction::AddIndex { table, index } => {
96                write!(f, "AddIndex: {}.{}", table, index.name)
97            }
98            MigrationAction::RemoveIndex { name, .. } => {
99                write!(f, "RemoveIndex: {}", name)
100            }
101            MigrationAction::AddConstraint { table, constraint } => {
102                let constraint_name = match constraint {
103                    TableConstraint::PrimaryKey { .. } => "PRIMARY KEY",
104                    TableConstraint::Unique { name, .. } => {
105                        if let Some(n) = name {
106                            return write!(f, "AddConstraint: {}.{} (UNIQUE)", table, n);
107                        }
108                        "UNIQUE"
109                    }
110                    TableConstraint::ForeignKey { name, .. } => {
111                        if let Some(n) = name {
112                            return write!(f, "AddConstraint: {}.{} (FOREIGN KEY)", table, n);
113                        }
114                        "FOREIGN KEY"
115                    }
116                    TableConstraint::Check { name, .. } => {
117                        return write!(f, "AddConstraint: {}.{} (CHECK)", table, name);
118                    }
119                };
120                write!(f, "AddConstraint: {}.{}", table, constraint_name)
121            }
122            MigrationAction::RemoveConstraint { table, constraint } => {
123                let constraint_name = match constraint {
124                    TableConstraint::PrimaryKey { .. } => "PRIMARY KEY",
125                    TableConstraint::Unique { name, .. } => {
126                        if let Some(n) = name {
127                            return write!(f, "RemoveConstraint: {}.{} (UNIQUE)", table, n);
128                        }
129                        "UNIQUE"
130                    }
131                    TableConstraint::ForeignKey { name, .. } => {
132                        if let Some(n) = name {
133                            return write!(f, "RemoveConstraint: {}.{} (FOREIGN KEY)", table, n);
134                        }
135                        "FOREIGN KEY"
136                    }
137                    TableConstraint::Check { name, .. } => {
138                        return write!(f, "RemoveConstraint: {}.{} (CHECK)", table, name);
139                    }
140                };
141                write!(f, "RemoveConstraint: {}.{}", table, constraint_name)
142            }
143            MigrationAction::RenameTable { from, to } => {
144                write!(f, "RenameTable: {} -> {}", from, to)
145            }
146            MigrationAction::RawSql { sql } => {
147                // Truncate SQL if too long for display
148                let display_sql = if sql.len() > 50 {
149                    format!("{}...", &sql[..47])
150                } else {
151                    sql.clone()
152                };
153                write!(f, "RawSql: {}", display_sql)
154            }
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::schema::{IndexDef, ReferenceAction, SimpleColumnType};
163    use rstest::rstest;
164
165    fn default_column() -> ColumnDef {
166        ColumnDef {
167            name: "email".into(),
168            r#type: ColumnType::Simple(SimpleColumnType::Text),
169            nullable: true,
170            default: None,
171            comment: None,
172            primary_key: None,
173            unique: None,
174            index: None,
175            foreign_key: None,
176        }
177    }
178
179    #[rstest]
180    #[case::create_table(
181        MigrationAction::CreateTable {
182            table: "users".into(),
183            columns: vec![],
184            constraints: vec![],
185        },
186        "CreateTable: users"
187    )]
188    #[case::delete_table(
189        MigrationAction::DeleteTable {
190            table: "users".into(),
191        },
192        "DeleteTable: users"
193    )]
194    #[case::add_column(
195        MigrationAction::AddColumn {
196            table: "users".into(),
197            column: Box::new(default_column()),
198            fill_with: None,
199        },
200        "AddColumn: users.email"
201    )]
202    #[case::rename_column(
203        MigrationAction::RenameColumn {
204            table: "users".into(),
205            from: "old_name".into(),
206            to: "new_name".into(),
207        },
208        "RenameColumn: users.old_name -> new_name"
209    )]
210    #[case::delete_column(
211        MigrationAction::DeleteColumn {
212            table: "users".into(),
213            column: "email".into(),
214        },
215        "DeleteColumn: users.email"
216    )]
217    #[case::modify_column_type(
218        MigrationAction::ModifyColumnType {
219            table: "users".into(),
220            column: "age".into(),
221            new_type: ColumnType::Simple(SimpleColumnType::Integer),
222        },
223        "ModifyColumnType: users.age"
224    )]
225    #[case::add_index(
226        MigrationAction::AddIndex {
227            table: "users".into(),
228            index: IndexDef {
229                name: "idx_email".into(),
230                columns: vec!["email".into()],
231                unique: false,
232            },
233        },
234        "AddIndex: users.idx_email"
235    )]
236    #[case::remove_index(
237        MigrationAction::RemoveIndex {
238            table: "users".into(),
239            name: "idx_email".into(),
240        },
241        "RemoveIndex: idx_email"
242    )]
243    #[case::rename_table(
244        MigrationAction::RenameTable {
245            from: "old_table".into(),
246            to: "new_table".into(),
247        },
248        "RenameTable: old_table -> new_table"
249    )]
250    fn test_display_basic_actions(#[case] action: MigrationAction, #[case] expected: &str) {
251        assert_eq!(action.to_string(), expected);
252    }
253
254    #[rstest]
255    #[case::add_constraint_primary_key(
256        MigrationAction::AddConstraint {
257            table: "users".into(),
258            constraint: TableConstraint::PrimaryKey {
259                auto_increment: false,
260                columns: vec!["id".into()],
261            },
262        },
263        "AddConstraint: users.PRIMARY KEY"
264    )]
265    #[case::add_constraint_unique_with_name(
266        MigrationAction::AddConstraint {
267            table: "users".into(),
268            constraint: TableConstraint::Unique {
269                name: Some("uq_email".into()),
270                columns: vec!["email".into()],
271            },
272        },
273        "AddConstraint: users.uq_email (UNIQUE)"
274    )]
275    #[case::add_constraint_unique_without_name(
276        MigrationAction::AddConstraint {
277            table: "users".into(),
278            constraint: TableConstraint::Unique {
279                name: None,
280                columns: vec!["email".into()],
281            },
282        },
283        "AddConstraint: users.UNIQUE"
284    )]
285    #[case::add_constraint_foreign_key_with_name(
286        MigrationAction::AddConstraint {
287            table: "posts".into(),
288            constraint: TableConstraint::ForeignKey {
289                name: Some("fk_user".into()),
290                columns: vec!["user_id".into()],
291                ref_table: "users".into(),
292                ref_columns: vec!["id".into()],
293                on_delete: Some(ReferenceAction::Cascade),
294                on_update: None,
295            },
296        },
297        "AddConstraint: posts.fk_user (FOREIGN KEY)"
298    )]
299    #[case::add_constraint_foreign_key_without_name(
300        MigrationAction::AddConstraint {
301            table: "posts".into(),
302            constraint: TableConstraint::ForeignKey {
303                name: None,
304                columns: vec!["user_id".into()],
305                ref_table: "users".into(),
306                ref_columns: vec!["id".into()],
307                on_delete: None,
308                on_update: None,
309            },
310        },
311        "AddConstraint: posts.FOREIGN KEY"
312    )]
313    #[case::add_constraint_check(
314        MigrationAction::AddConstraint {
315            table: "users".into(),
316            constraint: TableConstraint::Check {
317                name: "chk_age".into(),
318                expr: "age > 0".into(),
319            },
320        },
321        "AddConstraint: users.chk_age (CHECK)"
322    )]
323    fn test_display_add_constraint(#[case] action: MigrationAction, #[case] expected: &str) {
324        assert_eq!(action.to_string(), expected);
325    }
326
327    #[rstest]
328    #[case::remove_constraint_primary_key(
329        MigrationAction::RemoveConstraint {
330            table: "users".into(),
331            constraint: TableConstraint::PrimaryKey {
332                auto_increment: false,
333                columns: vec!["id".into()],
334            },
335        },
336        "RemoveConstraint: users.PRIMARY KEY"
337    )]
338    #[case::remove_constraint_unique_with_name(
339        MigrationAction::RemoveConstraint {
340            table: "users".into(),
341            constraint: TableConstraint::Unique {
342                name: Some("uq_email".into()),
343                columns: vec!["email".into()],
344            },
345        },
346        "RemoveConstraint: users.uq_email (UNIQUE)"
347    )]
348    #[case::remove_constraint_unique_without_name(
349        MigrationAction::RemoveConstraint {
350            table: "users".into(),
351            constraint: TableConstraint::Unique {
352                name: None,
353                columns: vec!["email".into()],
354            },
355        },
356        "RemoveConstraint: users.UNIQUE"
357    )]
358    #[case::remove_constraint_foreign_key_with_name(
359        MigrationAction::RemoveConstraint {
360            table: "posts".into(),
361            constraint: TableConstraint::ForeignKey {
362                name: Some("fk_user".into()),
363                columns: vec!["user_id".into()],
364                ref_table: "users".into(),
365                ref_columns: vec!["id".into()],
366                on_delete: None,
367                on_update: None,
368            },
369        },
370        "RemoveConstraint: posts.fk_user (FOREIGN KEY)"
371    )]
372    #[case::remove_constraint_foreign_key_without_name(
373        MigrationAction::RemoveConstraint {
374            table: "posts".into(),
375            constraint: TableConstraint::ForeignKey {
376                name: None,
377                columns: vec!["user_id".into()],
378                ref_table: "users".into(),
379                ref_columns: vec!["id".into()],
380                on_delete: None,
381                on_update: None,
382            },
383        },
384        "RemoveConstraint: posts.FOREIGN KEY"
385    )]
386    #[case::remove_constraint_check(
387        MigrationAction::RemoveConstraint {
388            table: "users".into(),
389            constraint: TableConstraint::Check {
390                name: "chk_age".into(),
391                expr: "age > 0".into(),
392            },
393        },
394        "RemoveConstraint: users.chk_age (CHECK)"
395    )]
396    fn test_display_remove_constraint(#[case] action: MigrationAction, #[case] expected: &str) {
397        assert_eq!(action.to_string(), expected);
398    }
399
400    #[rstest]
401    #[case::raw_sql_short(
402        MigrationAction::RawSql {
403            sql: "SELECT 1".into(),
404        },
405        "RawSql: SELECT 1"
406    )]
407    fn test_display_raw_sql_short(#[case] action: MigrationAction, #[case] expected: &str) {
408        assert_eq!(action.to_string(), expected);
409    }
410
411    #[test]
412    fn test_display_raw_sql_long() {
413        let action = MigrationAction::RawSql {
414            sql:
415                "SELECT * FROM users WHERE id = 1 AND name = 'test' AND email = 'test@example.com'"
416                    .into(),
417        };
418        let result = action.to_string();
419        assert!(result.starts_with("RawSql: "));
420        assert!(result.ends_with("..."));
421        assert!(result.len() > 10);
422    }
423}