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 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 fill_with: Option<String>,
53 },
54 ModifyColumnDefault {
55 table: TableName,
56 column: ColumnName,
57 new_default: Option<String>,
59 },
60 ModifyColumnComment {
61 table: TableName,
62 column: ColumnName,
63 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 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 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 assert!(!result.contains("truncated in display"));
592 }
593}