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!(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 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 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 assert!(!result.contains("truncated in display"));
586 }
587}