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