vespertide_core/
action.rs

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