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    ModifyColumnNullable {
48        table: TableName,
49        column: ColumnName,
50        nullable: bool,
51        /// Required when changing from nullable to non-nullable to backfill existing NULL values.
52        fill_with: Option<String>,
53    },
54    ModifyColumnDefault {
55        table: TableName,
56        column: ColumnName,
57        /// The new default value, or None to remove the default.
58        new_default: Option<String>,
59    },
60    ModifyColumnComment {
61        table: TableName,
62        column: ColumnName,
63        /// The new comment, or None to remove the comment.
64        new_comment: Option<String>,
65    },
66    AddConstraint {
67        table: TableName,
68        constraint: TableConstraint,
69    },
70    RemoveConstraint {
71        table: TableName,
72        constraint: TableConstraint,
73    },
74    RenameTable {
75        from: TableName,
76        to: TableName,
77    },
78    RawSql {
79        sql: String,
80    },
81}
82
83impl fmt::Display for MigrationAction {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match self {
86            MigrationAction::CreateTable { table, .. } => {
87                write!(f, "CreateTable: {}", table)
88            }
89            MigrationAction::DeleteTable { table } => {
90                write!(f, "DeleteTable: {}", table)
91            }
92            MigrationAction::AddColumn { table, column, .. } => {
93                write!(f, "AddColumn: {}.{}", table, column.name)
94            }
95            MigrationAction::RenameColumn { table, from, to } => {
96                write!(f, "RenameColumn: {}.{} -> {}", table, from, to)
97            }
98            MigrationAction::DeleteColumn { table, column } => {
99                write!(f, "DeleteColumn: {}.{}", table, column)
100            }
101            MigrationAction::ModifyColumnType { table, column, .. } => {
102                write!(f, "ModifyColumnType: {}.{}", table, column)
103            }
104            MigrationAction::ModifyColumnNullable {
105                table,
106                column,
107                nullable,
108                ..
109            } => {
110                let nullability = if *nullable { "NULL" } else { "NOT NULL" };
111                write!(f, "ModifyColumnNullable: {}.{} -> {}", table, column, nullability)
112            }
113            MigrationAction::ModifyColumnDefault {
114                table,
115                column,
116                new_default,
117            } => {
118                if let Some(default) = new_default {
119                    write!(f, "ModifyColumnDefault: {}.{} -> {}", table, column, default)
120                } else {
121                    write!(f, "ModifyColumnDefault: {}.{} -> (none)", table, column)
122                }
123            }
124            MigrationAction::ModifyColumnComment {
125                table,
126                column,
127                new_comment,
128            } => {
129                if let Some(comment) = new_comment {
130                    let display = if comment.len() > 30 {
131                        format!("{}...", &comment[..27])
132                    } else {
133                        comment.clone()
134                    };
135                    write!(f, "ModifyColumnComment: {}.{} -> '{}'", table, column, display)
136                } else {
137                    write!(f, "ModifyColumnComment: {}.{} -> (none)", table, column)
138                }
139            }
140            MigrationAction::AddConstraint { table, constraint } => {
141                let constraint_name = match constraint {
142                    TableConstraint::PrimaryKey { .. } => "PRIMARY KEY",
143                    TableConstraint::Unique { name, .. } => {
144                        if let Some(n) = name {
145                            return write!(f, "AddConstraint: {}.{} (UNIQUE)", table, n);
146                        }
147                        "UNIQUE"
148                    }
149                    TableConstraint::ForeignKey { name, .. } => {
150                        if let Some(n) = name {
151                            return write!(f, "AddConstraint: {}.{} (FOREIGN KEY)", table, n);
152                        }
153                        "FOREIGN KEY"
154                    }
155                    TableConstraint::Check { name, .. } => {
156                        return write!(f, "AddConstraint: {}.{} (CHECK)", table, name);
157                    }
158                    TableConstraint::Index { name, .. } => {
159                        if let Some(n) = name {
160                            return write!(f, "AddConstraint: {}.{} (INDEX)", table, n);
161                        }
162                        "INDEX"
163                    }
164                };
165                write!(f, "AddConstraint: {}.{}", table, constraint_name)
166            }
167            MigrationAction::RemoveConstraint { table, constraint } => {
168                let constraint_name = match constraint {
169                    TableConstraint::PrimaryKey { .. } => "PRIMARY KEY",
170                    TableConstraint::Unique { name, .. } => {
171                        if let Some(n) = name {
172                            return write!(f, "RemoveConstraint: {}.{} (UNIQUE)", table, n);
173                        }
174                        "UNIQUE"
175                    }
176                    TableConstraint::ForeignKey { name, .. } => {
177                        if let Some(n) = name {
178                            return write!(f, "RemoveConstraint: {}.{} (FOREIGN KEY)", table, n);
179                        }
180                        "FOREIGN KEY"
181                    }
182                    TableConstraint::Check { name, .. } => {
183                        return write!(f, "RemoveConstraint: {}.{} (CHECK)", table, name);
184                    }
185                    TableConstraint::Index { name, .. } => {
186                        if let Some(n) = name {
187                            return write!(f, "RemoveConstraint: {}.{} (INDEX)", table, n);
188                        }
189                        "INDEX"
190                    }
191                };
192                write!(f, "RemoveConstraint: {}.{}", table, constraint_name)
193            }
194            MigrationAction::RenameTable { from, to } => {
195                write!(f, "RenameTable: {} -> {}", from, to)
196            }
197            MigrationAction::RawSql { sql } => {
198                // Truncate SQL if too long for display
199                let display_sql = if sql.len() > 50 {
200                    format!("{}...", &sql[..47])
201                } else {
202                    sql.clone()
203                };
204                write!(f, "RawSql: {}", display_sql)
205            }
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::schema::{ReferenceAction, SimpleColumnType};
214    use rstest::rstest;
215
216    fn default_column() -> ColumnDef {
217        ColumnDef {
218            name: "email".into(),
219            r#type: ColumnType::Simple(SimpleColumnType::Text),
220            nullable: true,
221            default: None,
222            comment: None,
223            primary_key: None,
224            unique: None,
225            index: None,
226            foreign_key: None,
227        }
228    }
229
230    #[rstest]
231    #[case::create_table(
232        MigrationAction::CreateTable {
233            table: "users".into(),
234            columns: vec![],
235            constraints: vec![],
236        },
237        "CreateTable: users"
238    )]
239    #[case::delete_table(
240        MigrationAction::DeleteTable {
241            table: "users".into(),
242        },
243        "DeleteTable: users"
244    )]
245    #[case::add_column(
246        MigrationAction::AddColumn {
247            table: "users".into(),
248            column: Box::new(default_column()),
249            fill_with: None,
250        },
251        "AddColumn: users.email"
252    )]
253    #[case::rename_column(
254        MigrationAction::RenameColumn {
255            table: "users".into(),
256            from: "old_name".into(),
257            to: "new_name".into(),
258        },
259        "RenameColumn: users.old_name -> new_name"
260    )]
261    #[case::delete_column(
262        MigrationAction::DeleteColumn {
263            table: "users".into(),
264            column: "email".into(),
265        },
266        "DeleteColumn: users.email"
267    )]
268    #[case::modify_column_type(
269        MigrationAction::ModifyColumnType {
270            table: "users".into(),
271            column: "age".into(),
272            new_type: ColumnType::Simple(SimpleColumnType::Integer),
273        },
274        "ModifyColumnType: users.age"
275    )]
276    #[case::add_constraint_index_with_name(
277        MigrationAction::AddConstraint {
278            table: "users".into(),
279            constraint: TableConstraint::Index {
280                name: Some("ix_users__email".into()),
281                columns: vec!["email".into()],
282            },
283        },
284        "AddConstraint: users.ix_users__email (INDEX)"
285    )]
286    #[case::add_constraint_index_without_name(
287        MigrationAction::AddConstraint {
288            table: "users".into(),
289            constraint: TableConstraint::Index {
290                name: None,
291                columns: vec!["email".into()],
292            },
293        },
294        "AddConstraint: users.INDEX"
295    )]
296    #[case::remove_constraint_index_with_name(
297        MigrationAction::RemoveConstraint {
298            table: "users".into(),
299            constraint: TableConstraint::Index {
300                name: Some("ix_users__email".into()),
301                columns: vec!["email".into()],
302            },
303        },
304        "RemoveConstraint: users.ix_users__email (INDEX)"
305    )]
306    #[case::remove_constraint_index_without_name(
307        MigrationAction::RemoveConstraint {
308            table: "users".into(),
309            constraint: TableConstraint::Index {
310                name: None,
311                columns: vec!["email".into()],
312            },
313        },
314        "RemoveConstraint: users.INDEX"
315    )]
316    #[case::rename_table(
317        MigrationAction::RenameTable {
318            from: "old_table".into(),
319            to: "new_table".into(),
320        },
321        "RenameTable: old_table -> new_table"
322    )]
323    fn test_display_basic_actions(#[case] action: MigrationAction, #[case] expected: &str) {
324        assert_eq!(action.to_string(), expected);
325    }
326
327    #[rstest]
328    #[case::add_constraint_primary_key(
329        MigrationAction::AddConstraint {
330            table: "users".into(),
331            constraint: TableConstraint::PrimaryKey {
332                auto_increment: false,
333                columns: vec!["id".into()],
334            },
335        },
336        "AddConstraint: users.PRIMARY KEY"
337    )]
338    #[case::add_constraint_unique_with_name(
339        MigrationAction::AddConstraint {
340            table: "users".into(),
341            constraint: TableConstraint::Unique {
342                name: Some("uq_email".into()),
343                columns: vec!["email".into()],
344            },
345        },
346        "AddConstraint: users.uq_email (UNIQUE)"
347    )]
348    #[case::add_constraint_unique_without_name(
349        MigrationAction::AddConstraint {
350            table: "users".into(),
351            constraint: TableConstraint::Unique {
352                name: None,
353                columns: vec!["email".into()],
354            },
355        },
356        "AddConstraint: users.UNIQUE"
357    )]
358    #[case::add_constraint_foreign_key_with_name(
359        MigrationAction::AddConstraint {
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: Some(ReferenceAction::Cascade),
367                on_update: None,
368            },
369        },
370        "AddConstraint: posts.fk_user (FOREIGN KEY)"
371    )]
372    #[case::add_constraint_foreign_key_without_name(
373        MigrationAction::AddConstraint {
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        "AddConstraint: posts.FOREIGN KEY"
385    )]
386    #[case::add_constraint_check(
387        MigrationAction::AddConstraint {
388            table: "users".into(),
389            constraint: TableConstraint::Check {
390                name: "chk_age".into(),
391                expr: "age > 0".into(),
392            },
393        },
394        "AddConstraint: users.chk_age (CHECK)"
395    )]
396    fn test_display_add_constraint(#[case] action: MigrationAction, #[case] expected: &str) {
397        assert_eq!(action.to_string(), expected);
398    }
399
400    #[rstest]
401    #[case::remove_constraint_primary_key(
402        MigrationAction::RemoveConstraint {
403            table: "users".into(),
404            constraint: TableConstraint::PrimaryKey {
405                auto_increment: false,
406                columns: vec!["id".into()],
407            },
408        },
409        "RemoveConstraint: users.PRIMARY KEY"
410    )]
411    #[case::remove_constraint_unique_with_name(
412        MigrationAction::RemoveConstraint {
413            table: "users".into(),
414            constraint: TableConstraint::Unique {
415                name: Some("uq_email".into()),
416                columns: vec!["email".into()],
417            },
418        },
419        "RemoveConstraint: users.uq_email (UNIQUE)"
420    )]
421    #[case::remove_constraint_unique_without_name(
422        MigrationAction::RemoveConstraint {
423            table: "users".into(),
424            constraint: TableConstraint::Unique {
425                name: None,
426                columns: vec!["email".into()],
427            },
428        },
429        "RemoveConstraint: users.UNIQUE"
430    )]
431    #[case::remove_constraint_foreign_key_with_name(
432        MigrationAction::RemoveConstraint {
433            table: "posts".into(),
434            constraint: TableConstraint::ForeignKey {
435                name: Some("fk_user".into()),
436                columns: vec!["user_id".into()],
437                ref_table: "users".into(),
438                ref_columns: vec!["id".into()],
439                on_delete: None,
440                on_update: None,
441            },
442        },
443        "RemoveConstraint: posts.fk_user (FOREIGN KEY)"
444    )]
445    #[case::remove_constraint_foreign_key_without_name(
446        MigrationAction::RemoveConstraint {
447            table: "posts".into(),
448            constraint: TableConstraint::ForeignKey {
449                name: None,
450                columns: vec!["user_id".into()],
451                ref_table: "users".into(),
452                ref_columns: vec!["id".into()],
453                on_delete: None,
454                on_update: None,
455            },
456        },
457        "RemoveConstraint: posts.FOREIGN KEY"
458    )]
459    #[case::remove_constraint_check(
460        MigrationAction::RemoveConstraint {
461            table: "users".into(),
462            constraint: TableConstraint::Check {
463                name: "chk_age".into(),
464                expr: "age > 0".into(),
465            },
466        },
467        "RemoveConstraint: users.chk_age (CHECK)"
468    )]
469    fn test_display_remove_constraint(#[case] action: MigrationAction, #[case] expected: &str) {
470        assert_eq!(action.to_string(), expected);
471    }
472
473    #[rstest]
474    #[case::raw_sql_short(
475        MigrationAction::RawSql {
476            sql: "SELECT 1".into(),
477        },
478        "RawSql: SELECT 1"
479    )]
480    fn test_display_raw_sql_short(#[case] action: MigrationAction, #[case] expected: &str) {
481        assert_eq!(action.to_string(), expected);
482    }
483
484    #[test]
485    fn test_display_raw_sql_long() {
486        let action = MigrationAction::RawSql {
487            sql:
488                "SELECT * FROM users WHERE id = 1 AND name = 'test' AND email = 'test@example.com'"
489                    .into(),
490        };
491        let result = action.to_string();
492        assert!(result.starts_with("RawSql: "));
493        assert!(result.ends_with("..."));
494        assert!(result.len() > 10);
495    }
496
497    #[rstest]
498    #[case::modify_column_nullable_to_not_null(
499        MigrationAction::ModifyColumnNullable {
500            table: "users".into(),
501            column: "email".into(),
502            nullable: false,
503            fill_with: None,
504        },
505        "ModifyColumnNullable: users.email -> NOT NULL"
506    )]
507    #[case::modify_column_nullable_to_null(
508        MigrationAction::ModifyColumnNullable {
509            table: "users".into(),
510            column: "email".into(),
511            nullable: true,
512            fill_with: None,
513        },
514        "ModifyColumnNullable: users.email -> NULL"
515    )]
516    fn test_display_modify_column_nullable(
517        #[case] action: MigrationAction,
518        #[case] expected: &str,
519    ) {
520        assert_eq!(action.to_string(), expected);
521    }
522
523    #[rstest]
524    #[case::modify_column_default_set(
525        MigrationAction::ModifyColumnDefault {
526            table: "users".into(),
527            column: "status".into(),
528            new_default: Some("'active'".into()),
529        },
530        "ModifyColumnDefault: users.status -> 'active'"
531    )]
532    #[case::modify_column_default_drop(
533        MigrationAction::ModifyColumnDefault {
534            table: "users".into(),
535            column: "status".into(),
536            new_default: None,
537        },
538        "ModifyColumnDefault: users.status -> (none)"
539    )]
540    fn test_display_modify_column_default(
541        #[case] action: MigrationAction,
542        #[case] expected: &str,
543    ) {
544        assert_eq!(action.to_string(), expected);
545    }
546
547    #[rstest]
548    #[case::modify_column_comment_set(
549        MigrationAction::ModifyColumnComment {
550            table: "users".into(),
551            column: "email".into(),
552            new_comment: Some("User email address".into()),
553        },
554        "ModifyColumnComment: users.email -> 'User email address'"
555    )]
556    #[case::modify_column_comment_drop(
557        MigrationAction::ModifyColumnComment {
558            table: "users".into(),
559            column: "email".into(),
560            new_comment: None,
561        },
562        "ModifyColumnComment: users.email -> (none)"
563    )]
564    fn test_display_modify_column_comment(
565        #[case] action: MigrationAction,
566        #[case] expected: &str,
567    ) {
568        assert_eq!(action.to_string(), expected);
569    }
570
571    #[test]
572    fn test_display_modify_column_comment_long() {
573        // Test truncation for long comments (> 30 chars)
574        let action = MigrationAction::ModifyColumnComment {
575            table: "users".into(),
576            column: "email".into(),
577            new_comment: Some(
578                "This is a very long comment that should be truncated in display".into(),
579            ),
580        };
581        let result = action.to_string();
582        assert!(result.contains("..."));
583        assert!(result.contains("This is a very long comment"));
584        // Should be truncated at 27 chars + "..."
585        assert!(!result.contains("truncated in display"));
586    }
587}