1use std::collections::BTreeMap;
17use std::path::PathBuf;
18
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum MigrationState {
26 Pending,
28 Applied,
30 Failed,
32}
33
34impl MigrationState {
35 pub fn as_str(self) -> &'static str {
37 match self {
38 Self::Pending => "pending",
39 Self::Applied => "applied",
40 Self::Failed => "failed",
41 }
42 }
43}
44
45impl std::fmt::Display for MigrationState {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 f.write_str(self.as_str())
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum MigrationDirection {
55 Up,
57 Down,
59}
60
61impl MigrationDirection {
62 pub fn as_str(self) -> &'static str {
64 match self {
65 Self::Up => "up",
66 Self::Down => "down",
67 }
68 }
69}
70
71impl std::fmt::Display for MigrationDirection {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 f.write_str(self.as_str())
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct Migration {
106 pub version: String,
108 pub description: String,
110 pub path: PathBuf,
112 pub up: Vec<String>,
114 pub down: Vec<String>,
116 pub checksum: Option<String>,
118 #[serde(default)]
120 pub depends_on: Vec<String>,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct MigrationHistory {
144 pub version: String,
146 pub description: String,
148 pub applied_at: DateTime<Utc>,
150 pub checksum: String,
152 pub execution_time_ms: Option<u64>,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct MigrationPlan {
174 pub migrations: Vec<Migration>,
176 pub direction: MigrationDirection,
178}
179
180impl MigrationPlan {
181 pub fn count(&self) -> usize {
183 self.migrations.len()
184 }
185
186 pub fn is_empty(&self) -> bool {
188 self.migrations.is_empty()
189 }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct MigrationMetadata {
212 pub version: String,
214 pub description: String,
216 #[serde(default = "MigrationMetadata::default_author")]
218 pub author: String,
219 #[serde(default)]
221 pub depends_on: Vec<String>,
222}
223
224impl MigrationMetadata {
225 pub fn default_author() -> String {
227 "surql".to_string()
228 }
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
259pub struct MigrationStatus {
260 pub migration: Migration,
262 pub state: MigrationState,
264 pub applied_at: Option<DateTime<Utc>>,
266 pub error: Option<String>,
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
272#[serde(rename_all = "snake_case")]
273pub enum DiffOperation {
274 AddTable,
276 DropTable,
278 AddField,
280 DropField,
282 ModifyField,
284 AddIndex,
286 DropIndex,
288 AddEvent,
290 DropEvent,
292 ModifyPermissions,
294}
295
296impl DiffOperation {
297 pub fn as_str(self) -> &'static str {
299 match self {
300 Self::AddTable => "add_table",
301 Self::DropTable => "drop_table",
302 Self::AddField => "add_field",
303 Self::DropField => "drop_field",
304 Self::ModifyField => "modify_field",
305 Self::AddIndex => "add_index",
306 Self::DropIndex => "drop_index",
307 Self::AddEvent => "add_event",
308 Self::DropEvent => "drop_event",
309 Self::ModifyPermissions => "modify_permissions",
310 }
311 }
312}
313
314impl std::fmt::Display for DiffOperation {
315 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
316 f.write_str(self.as_str())
317 }
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341pub struct SchemaDiff {
342 pub operation: DiffOperation,
344 pub table: String,
346 pub field: Option<String>,
348 pub index: Option<String>,
350 pub event: Option<String>,
352 pub description: String,
354 pub forward_sql: String,
356 pub backward_sql: String,
358 #[serde(default)]
360 pub details: BTreeMap<String, serde_json::Value>,
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 #[test]
368 fn migration_state_as_str_values() {
369 assert_eq!(MigrationState::Pending.as_str(), "pending");
370 assert_eq!(MigrationState::Applied.as_str(), "applied");
371 assert_eq!(MigrationState::Failed.as_str(), "failed");
372 }
373
374 #[test]
375 fn migration_state_display_matches_as_str() {
376 assert_eq!(MigrationState::Pending.to_string(), "pending");
377 assert_eq!(MigrationState::Applied.to_string(), "applied");
378 assert_eq!(MigrationState::Failed.to_string(), "failed");
379 }
380
381 #[test]
382 fn migration_direction_as_str_values() {
383 assert_eq!(MigrationDirection::Up.as_str(), "up");
384 assert_eq!(MigrationDirection::Down.as_str(), "down");
385 }
386
387 #[test]
388 fn migration_direction_display_matches_as_str() {
389 assert_eq!(MigrationDirection::Up.to_string(), "up");
390 assert_eq!(MigrationDirection::Down.to_string(), "down");
391 }
392
393 #[test]
394 fn migration_state_serde_roundtrip() {
395 let states = [
396 MigrationState::Pending,
397 MigrationState::Applied,
398 MigrationState::Failed,
399 ];
400 for s in states {
401 let j = serde_json::to_string(&s).unwrap();
402 let back: MigrationState = serde_json::from_str(&j).unwrap();
403 assert_eq!(s, back);
404 }
405 }
406
407 #[test]
408 fn migration_state_serializes_lowercase() {
409 let j = serde_json::to_string(&MigrationState::Applied).unwrap();
410 assert_eq!(j, "\"applied\"");
411 }
412
413 #[test]
414 fn migration_direction_serializes_lowercase() {
415 let j = serde_json::to_string(&MigrationDirection::Down).unwrap();
416 assert_eq!(j, "\"down\"");
417 }
418
419 fn sample_migration(version: &str) -> Migration {
420 Migration {
421 version: version.to_string(),
422 description: "test migration".into(),
423 path: PathBuf::from(format!("migrations/{version}_test.surql")),
424 up: vec!["DEFINE TABLE t SCHEMAFULL;".into()],
425 down: vec!["REMOVE TABLE t;".into()],
426 checksum: Some("deadbeef".into()),
427 depends_on: vec![],
428 }
429 }
430
431 #[test]
432 fn migration_fields_are_populated() {
433 let m = sample_migration("20260102_120000");
434 assert_eq!(m.version, "20260102_120000");
435 assert_eq!(m.description, "test migration");
436 assert_eq!(m.up.len(), 1);
437 assert_eq!(m.down.len(), 1);
438 assert_eq!(m.checksum.as_deref(), Some("deadbeef"));
439 assert!(m.depends_on.is_empty());
440 }
441
442 #[test]
443 fn migration_serde_roundtrip() {
444 let m = sample_migration("20260102_120000");
445 let j = serde_json::to_string(&m).unwrap();
446 let back: Migration = serde_json::from_str(&j).unwrap();
447 assert_eq!(m, back);
448 }
449
450 #[test]
451 fn migration_serde_missing_depends_on_defaults_empty() {
452 let j = r#"{
453 "version": "v1",
454 "description": "d",
455 "path": "p.surql",
456 "up": [],
457 "down": [],
458 "checksum": null
459 }"#;
460 let m: Migration = serde_json::from_str(j).unwrap();
461 assert!(m.depends_on.is_empty());
462 }
463
464 #[test]
465 fn migration_history_serde_roundtrip() {
466 let h = MigrationHistory {
467 version: "20260102_120000".into(),
468 description: "test".into(),
469 applied_at: DateTime::parse_from_rfc3339("2026-01-02T12:00:00Z")
470 .unwrap()
471 .with_timezone(&Utc),
472 checksum: "abc".into(),
473 execution_time_ms: Some(100),
474 };
475 let j = serde_json::to_string(&h).unwrap();
476 let back: MigrationHistory = serde_json::from_str(&j).unwrap();
477 assert_eq!(h, back);
478 }
479
480 #[test]
481 fn migration_history_execution_time_optional() {
482 let h = MigrationHistory {
483 version: "v1".into(),
484 description: "d".into(),
485 applied_at: Utc::now(),
486 checksum: "c".into(),
487 execution_time_ms: None,
488 };
489 let j = serde_json::to_string(&h).unwrap();
490 let back: MigrationHistory = serde_json::from_str(&j).unwrap();
491 assert_eq!(h, back);
492 assert!(back.execution_time_ms.is_none());
493 }
494
495 #[test]
496 fn migration_plan_empty_and_count() {
497 let plan = MigrationPlan {
498 migrations: vec![],
499 direction: MigrationDirection::Up,
500 };
501 assert!(plan.is_empty());
502 assert_eq!(plan.count(), 0);
503 }
504
505 #[test]
506 fn migration_plan_non_empty() {
507 let plan = MigrationPlan {
508 migrations: vec![sample_migration("v1"), sample_migration("v2")],
509 direction: MigrationDirection::Down,
510 };
511 assert!(!plan.is_empty());
512 assert_eq!(plan.count(), 2);
513 assert_eq!(plan.direction, MigrationDirection::Down);
514 }
515
516 #[test]
517 fn migration_plan_serde_roundtrip() {
518 let plan = MigrationPlan {
519 migrations: vec![sample_migration("v1")],
520 direction: MigrationDirection::Up,
521 };
522 let j = serde_json::to_string(&plan).unwrap();
523 let back: MigrationPlan = serde_json::from_str(&j).unwrap();
524 assert_eq!(plan, back);
525 }
526
527 #[test]
528 fn migration_metadata_default_author() {
529 assert_eq!(MigrationMetadata::default_author(), "surql");
530 }
531
532 #[test]
533 fn migration_metadata_serde_defaults() {
534 let j = r#"{"version":"v1","description":"d"}"#;
535 let meta: MigrationMetadata = serde_json::from_str(j).unwrap();
536 assert_eq!(meta.author, "surql");
537 assert!(meta.depends_on.is_empty());
538 }
539
540 #[test]
541 fn migration_metadata_serde_roundtrip() {
542 let meta = MigrationMetadata {
543 version: "v1".into(),
544 description: "d".into(),
545 author: "alice".into(),
546 depends_on: vec!["v0".into()],
547 };
548 let j = serde_json::to_string(&meta).unwrap();
549 let back: MigrationMetadata = serde_json::from_str(&j).unwrap();
550 assert_eq!(meta, back);
551 }
552
553 #[test]
554 fn migration_status_fields() {
555 let m = sample_migration("v1");
556 let s = MigrationStatus {
557 migration: m.clone(),
558 state: MigrationState::Applied,
559 applied_at: Some(Utc::now()),
560 error: None,
561 };
562 assert_eq!(s.migration, m);
563 assert_eq!(s.state, MigrationState::Applied);
564 assert!(s.applied_at.is_some());
565 assert!(s.error.is_none());
566 }
567
568 #[test]
569 fn migration_status_failure_captures_error() {
570 let s = MigrationStatus {
571 migration: sample_migration("v1"),
572 state: MigrationState::Failed,
573 applied_at: None,
574 error: Some("syntax error".into()),
575 };
576 assert_eq!(s.state, MigrationState::Failed);
577 assert_eq!(s.error.as_deref(), Some("syntax error"));
578 }
579
580 #[test]
581 fn migration_status_serde_roundtrip() {
582 let s = MigrationStatus {
583 migration: sample_migration("v1"),
584 state: MigrationState::Pending,
585 applied_at: None,
586 error: None,
587 };
588 let j = serde_json::to_string(&s).unwrap();
589 let back: MigrationStatus = serde_json::from_str(&j).unwrap();
590 assert_eq!(s, back);
591 }
592
593 #[test]
594 fn diff_operation_as_str_values() {
595 assert_eq!(DiffOperation::AddTable.as_str(), "add_table");
596 assert_eq!(DiffOperation::DropTable.as_str(), "drop_table");
597 assert_eq!(DiffOperation::AddField.as_str(), "add_field");
598 assert_eq!(DiffOperation::DropField.as_str(), "drop_field");
599 assert_eq!(DiffOperation::ModifyField.as_str(), "modify_field");
600 assert_eq!(DiffOperation::AddIndex.as_str(), "add_index");
601 assert_eq!(DiffOperation::DropIndex.as_str(), "drop_index");
602 assert_eq!(DiffOperation::AddEvent.as_str(), "add_event");
603 assert_eq!(DiffOperation::DropEvent.as_str(), "drop_event");
604 assert_eq!(
605 DiffOperation::ModifyPermissions.as_str(),
606 "modify_permissions"
607 );
608 }
609
610 #[test]
611 fn diff_operation_display_matches_as_str() {
612 assert_eq!(DiffOperation::AddTable.to_string(), "add_table");
613 assert_eq!(
614 DiffOperation::ModifyPermissions.to_string(),
615 "modify_permissions"
616 );
617 }
618
619 #[test]
620 fn diff_operation_serializes_snake_case() {
621 let j = serde_json::to_string(&DiffOperation::ModifyPermissions).unwrap();
622 assert_eq!(j, "\"modify_permissions\"");
623 }
624
625 #[test]
626 fn diff_operation_serde_roundtrip_all() {
627 let ops = [
628 DiffOperation::AddTable,
629 DiffOperation::DropTable,
630 DiffOperation::AddField,
631 DiffOperation::DropField,
632 DiffOperation::ModifyField,
633 DiffOperation::AddIndex,
634 DiffOperation::DropIndex,
635 DiffOperation::AddEvent,
636 DiffOperation::DropEvent,
637 DiffOperation::ModifyPermissions,
638 ];
639 for op in ops {
640 let j = serde_json::to_string(&op).unwrap();
641 let back: DiffOperation = serde_json::from_str(&j).unwrap();
642 assert_eq!(op, back);
643 }
644 }
645
646 #[test]
647 fn schema_diff_basic_construction() {
648 let diff = SchemaDiff {
649 operation: DiffOperation::AddTable,
650 table: "user".into(),
651 field: None,
652 index: None,
653 event: None,
654 description: "Add user table".into(),
655 forward_sql: "DEFINE TABLE user SCHEMAFULL;".into(),
656 backward_sql: "REMOVE TABLE user;".into(),
657 details: BTreeMap::new(),
658 };
659 assert_eq!(diff.operation, DiffOperation::AddTable);
660 assert_eq!(diff.table, "user");
661 assert!(diff.field.is_none());
662 }
663
664 #[test]
665 fn schema_diff_with_field() {
666 let diff = SchemaDiff {
667 operation: DiffOperation::AddField,
668 table: "user".into(),
669 field: Some("email".into()),
670 index: None,
671 event: None,
672 description: "Add email field".into(),
673 forward_sql: "DEFINE FIELD email ON TABLE user TYPE string;".into(),
674 backward_sql: "REMOVE FIELD email ON TABLE user;".into(),
675 details: BTreeMap::new(),
676 };
677 assert_eq!(diff.field.as_deref(), Some("email"));
678 }
679
680 #[test]
681 fn schema_diff_serde_roundtrip() {
682 let mut details = BTreeMap::new();
683 details.insert("old_type".to_string(), serde_json::json!("string"));
684 details.insert("new_type".to_string(), serde_json::json!("int"));
685 let diff = SchemaDiff {
686 operation: DiffOperation::ModifyField,
687 table: "user".into(),
688 field: Some("age".into()),
689 index: None,
690 event: None,
691 description: "change age".into(),
692 forward_sql: "DEFINE FIELD age ON TABLE user TYPE int;".into(),
693 backward_sql: "DEFINE FIELD age ON TABLE user TYPE string;".into(),
694 details,
695 };
696 let j = serde_json::to_string(&diff).unwrap();
697 let back: SchemaDiff = serde_json::from_str(&j).unwrap();
698 assert_eq!(diff, back);
699 }
700
701 #[test]
702 fn schema_diff_serde_missing_details_defaults_empty() {
703 let j = r#"{
704 "operation": "add_table",
705 "table": "t",
706 "field": null,
707 "index": null,
708 "event": null,
709 "description": "d",
710 "forward_sql": "f",
711 "backward_sql": "b"
712 }"#;
713 let diff: SchemaDiff = serde_json::from_str(j).unwrap();
714 assert!(diff.details.is_empty());
715 }
716}