Skip to main content

surql/migration/
models.rs

1//! Migration data models and types.
2//!
3//! Port of `surql/migration/models.py`. This module defines the core data
4//! structures for the migration system, including migration metadata,
5//! state tracking, and execution plans.
6//!
7//! ## Deviation from Python
8//!
9//! In Python, a [`Migration`] embeds two `Callable[[], list[str]]` objects
10//! (`up` / `down`) loaded dynamically from a `.py` file via `importlib`.
11//! Rust cannot execute arbitrary Python at runtime, so migrations are
12//! represented as pure data: both [`Migration::up`] and [`Migration::down`]
13//! are `Vec<String>` of SurrealQL statements parsed from the migration
14//! file (see [`crate::migration::discovery`] for the file format).
15
16use std::collections::BTreeMap;
17use std::path::PathBuf;
18
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21
22/// State of a migration in the execution lifecycle.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum MigrationState {
26    /// The migration has not yet been applied.
27    Pending,
28    /// The migration has been applied successfully.
29    Applied,
30    /// The migration failed while being applied.
31    Failed,
32}
33
34impl MigrationState {
35    /// Render the state as a lowercase string (matches Python `.value`).
36    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/// Direction of migration execution.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum MigrationDirection {
55    /// Forward migration (apply).
56    Up,
57    /// Backward migration (rollback).
58    Down,
59}
60
61impl MigrationDirection {
62    /// Render the direction as a lowercase string.
63    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/// Immutable migration definition.
78///
79/// Represents a single migration file with its metadata and SurrealQL
80/// statements for both the forward (`up`) and backward (`down`) directions.
81///
82/// Unlike the Python original, the `up` and `down` directions are stored
83/// as plain `Vec<String>` (pre-parsed SurrealQL statements) rather than
84/// callables, because migration files in the Rust port are flat text
85/// (see the module-level deviation note).
86///
87/// ## Examples
88///
89/// ```
90/// use std::path::PathBuf;
91/// use surql::migration::Migration;
92///
93/// let m = Migration {
94///     version: "20260102_120000".into(),
95///     description: "Create user table".into(),
96///     path: PathBuf::from("migrations/20260102_120000_create_user.surql"),
97///     up: vec!["DEFINE TABLE user SCHEMAFULL;".into()],
98///     down: vec!["REMOVE TABLE user;".into()],
99///     checksum: Some("abc123".into()),
100///     depends_on: vec![],
101/// };
102/// assert_eq!(m.version, "20260102_120000");
103/// ```
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct Migration {
106    /// Migration version (timestamp-based, e.g. `YYYYMMDD_HHMMSS`).
107    pub version: String,
108    /// Human-readable description of what the migration does.
109    pub description: String,
110    /// Path to the migration file on disk.
111    pub path: PathBuf,
112    /// SurrealQL statements for the forward (`up`) direction.
113    pub up: Vec<String>,
114    /// SurrealQL statements for the backward (`down`) direction.
115    pub down: Vec<String>,
116    /// Content checksum (SHA-256 hex) of the source file, if computed.
117    pub checksum: Option<String>,
118    /// Versions of other migrations this migration depends on.
119    #[serde(default)]
120    pub depends_on: Vec<String>,
121}
122
123/// Migration history record stored in the database.
124///
125/// Represents a migration that has been applied to the database.
126///
127/// ## Examples
128///
129/// ```
130/// use chrono::Utc;
131/// use surql::migration::MigrationHistory;
132///
133/// let h = MigrationHistory {
134///     version: "20260102_120000".into(),
135///     description: "Create user table".into(),
136///     applied_at: Utc::now(),
137///     checksum: "abc123".into(),
138///     execution_time_ms: Some(42),
139/// };
140/// assert_eq!(h.version, "20260102_120000");
141/// ```
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct MigrationHistory {
144    /// Migration version.
145    pub version: String,
146    /// Migration description.
147    pub description: String,
148    /// Timestamp at which the migration was applied.
149    pub applied_at: DateTime<Utc>,
150    /// Content checksum (SHA-256 hex) captured at apply time.
151    pub checksum: String,
152    /// Wall-clock execution time in milliseconds, if measured.
153    pub execution_time_ms: Option<u64>,
154}
155
156/// Execution plan for a set of migrations.
157///
158/// Represents the ordered list of migrations to execute and their direction.
159///
160/// ## Examples
161///
162/// ```
163/// use surql::migration::{MigrationDirection, MigrationPlan};
164///
165/// let plan = MigrationPlan {
166///     migrations: vec![],
167///     direction: MigrationDirection::Up,
168/// };
169/// assert!(plan.is_empty());
170/// assert_eq!(plan.count(), 0);
171/// ```
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct MigrationPlan {
174    /// Ordered list of migrations to execute (sorted by version).
175    pub migrations: Vec<Migration>,
176    /// Execution direction (up or down).
177    pub direction: MigrationDirection,
178}
179
180impl MigrationPlan {
181    /// Number of migrations in the plan.
182    pub fn count(&self) -> usize {
183        self.migrations.len()
184    }
185
186    /// `true` if the plan has no migrations.
187    pub fn is_empty(&self) -> bool {
188        self.migrations.is_empty()
189    }
190}
191
192/// Metadata for a migration file.
193///
194/// This is the data structure expected in the `-- @metadata` section
195/// of a migration file.
196///
197/// ## Examples
198///
199/// ```
200/// use surql::migration::MigrationMetadata;
201///
202/// let meta = MigrationMetadata {
203///     version: "20260102_120000".into(),
204///     description: "Create user table".into(),
205///     author: MigrationMetadata::default_author(),
206///     depends_on: vec![],
207/// };
208/// assert_eq!(meta.author, "surql");
209/// ```
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct MigrationMetadata {
212    /// Migration version.
213    pub version: String,
214    /// Human-readable description.
215    pub description: String,
216    /// Author string (defaults to `"surql"`).
217    #[serde(default = "MigrationMetadata::default_author")]
218    pub author: String,
219    /// Versions of other migrations this one depends on.
220    #[serde(default)]
221    pub depends_on: Vec<String>,
222}
223
224impl MigrationMetadata {
225    /// Default author string used when the migration file omits `author`.
226    pub fn default_author() -> String {
227        "surql".to_string()
228    }
229}
230
231/// Status information for a migration.
232///
233/// Combines a migration definition with its current runtime state.
234///
235/// ## Examples
236///
237/// ```
238/// use std::path::PathBuf;
239/// use surql::migration::{Migration, MigrationState, MigrationStatus};
240///
241/// let m = Migration {
242///     version: "v1".into(),
243///     description: "demo".into(),
244///     path: PathBuf::from("v1.surql"),
245///     up: vec![],
246///     down: vec![],
247///     checksum: None,
248///     depends_on: vec![],
249/// };
250/// let s = MigrationStatus {
251///     migration: m,
252///     state: MigrationState::Pending,
253///     applied_at: None,
254///     error: None,
255/// };
256/// assert_eq!(s.state, MigrationState::Pending);
257/// ```
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
259pub struct MigrationStatus {
260    /// Underlying migration definition.
261    pub migration: Migration,
262    /// Current state of the migration.
263    pub state: MigrationState,
264    /// Timestamp at which the migration was applied, if any.
265    pub applied_at: Option<DateTime<Utc>>,
266    /// Error message describing the failure, if any.
267    pub error: Option<String>,
268}
269
270/// Type of schema change operation captured by a [`SchemaDiff`].
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
272#[serde(rename_all = "snake_case")]
273pub enum DiffOperation {
274    /// A new table was added.
275    AddTable,
276    /// An existing table was removed.
277    DropTable,
278    /// A new field was added to an existing table.
279    AddField,
280    /// An existing field was removed.
281    DropField,
282    /// An existing field had its type or constraints changed.
283    ModifyField,
284    /// A new index was added.
285    AddIndex,
286    /// An existing index was removed.
287    DropIndex,
288    /// A new event was added.
289    AddEvent,
290    /// An existing event was removed.
291    DropEvent,
292    /// Permissions were modified on a table or field.
293    ModifyPermissions,
294}
295
296impl DiffOperation {
297    /// Render the operation as its snake-case string form.
298    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/// Represents a difference between two schema versions.
321///
322/// ## Examples
323///
324/// ```
325/// use surql::migration::{DiffOperation, SchemaDiff};
326///
327/// let diff = SchemaDiff {
328///     operation: DiffOperation::AddTable,
329///     table: "user".into(),
330///     field: None,
331///     index: None,
332///     event: None,
333///     description: "Add user table".into(),
334///     forward_sql: "DEFINE TABLE user SCHEMAFULL;".into(),
335///     backward_sql: "REMOVE TABLE user;".into(),
336///     details: Default::default(),
337/// };
338/// assert_eq!(diff.operation, DiffOperation::AddTable);
339/// ```
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341pub struct SchemaDiff {
342    /// The kind of schema change.
343    pub operation: DiffOperation,
344    /// Table name affected by the change.
345    pub table: String,
346    /// Field name, if the change targets a field.
347    pub field: Option<String>,
348    /// Index name, if the change targets an index.
349    pub index: Option<String>,
350    /// Event name, if the change targets an event.
351    pub event: Option<String>,
352    /// Human-readable description.
353    pub description: String,
354    /// SurrealQL that applies the change (forward).
355    pub forward_sql: String,
356    /// SurrealQL that reverts the change (backward).
357    pub backward_sql: String,
358    /// Extra operation-specific details.
359    #[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}