use std::collections::BTreeMap;
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MigrationState {
Pending,
Applied,
Failed,
}
impl MigrationState {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Applied => "applied",
Self::Failed => "failed",
}
}
}
impl std::fmt::Display for MigrationState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MigrationDirection {
Up,
Down,
}
impl MigrationDirection {
pub fn as_str(self) -> &'static str {
match self {
Self::Up => "up",
Self::Down => "down",
}
}
}
impl std::fmt::Display for MigrationDirection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Migration {
pub version: String,
pub description: String,
pub path: PathBuf,
pub up: Vec<String>,
pub down: Vec<String>,
pub checksum: Option<String>,
#[serde(default)]
pub depends_on: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationHistory {
pub version: String,
pub description: String,
pub applied_at: DateTime<Utc>,
pub checksum: String,
pub execution_time_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationPlan {
pub migrations: Vec<Migration>,
pub direction: MigrationDirection,
}
impl MigrationPlan {
pub fn count(&self) -> usize {
self.migrations.len()
}
pub fn is_empty(&self) -> bool {
self.migrations.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationMetadata {
pub version: String,
pub description: String,
#[serde(default = "MigrationMetadata::default_author")]
pub author: String,
#[serde(default)]
pub depends_on: Vec<String>,
}
impl MigrationMetadata {
pub fn default_author() -> String {
"surql".to_string()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationStatus {
pub migration: Migration,
pub state: MigrationState,
pub applied_at: Option<DateTime<Utc>>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DiffOperation {
AddTable,
DropTable,
AddField,
DropField,
ModifyField,
AddIndex,
DropIndex,
AddEvent,
DropEvent,
ModifyPermissions,
}
impl DiffOperation {
pub fn as_str(self) -> &'static str {
match self {
Self::AddTable => "add_table",
Self::DropTable => "drop_table",
Self::AddField => "add_field",
Self::DropField => "drop_field",
Self::ModifyField => "modify_field",
Self::AddIndex => "add_index",
Self::DropIndex => "drop_index",
Self::AddEvent => "add_event",
Self::DropEvent => "drop_event",
Self::ModifyPermissions => "modify_permissions",
}
}
}
impl std::fmt::Display for DiffOperation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SchemaDiff {
pub operation: DiffOperation,
pub table: String,
pub field: Option<String>,
pub index: Option<String>,
pub event: Option<String>,
pub description: String,
pub forward_sql: String,
pub backward_sql: String,
#[serde(default)]
pub details: BTreeMap<String, serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn migration_state_as_str_values() {
assert_eq!(MigrationState::Pending.as_str(), "pending");
assert_eq!(MigrationState::Applied.as_str(), "applied");
assert_eq!(MigrationState::Failed.as_str(), "failed");
}
#[test]
fn migration_state_display_matches_as_str() {
assert_eq!(MigrationState::Pending.to_string(), "pending");
assert_eq!(MigrationState::Applied.to_string(), "applied");
assert_eq!(MigrationState::Failed.to_string(), "failed");
}
#[test]
fn migration_direction_as_str_values() {
assert_eq!(MigrationDirection::Up.as_str(), "up");
assert_eq!(MigrationDirection::Down.as_str(), "down");
}
#[test]
fn migration_direction_display_matches_as_str() {
assert_eq!(MigrationDirection::Up.to_string(), "up");
assert_eq!(MigrationDirection::Down.to_string(), "down");
}
#[test]
fn migration_state_serde_roundtrip() {
let states = [
MigrationState::Pending,
MigrationState::Applied,
MigrationState::Failed,
];
for s in states {
let j = serde_json::to_string(&s).unwrap();
let back: MigrationState = serde_json::from_str(&j).unwrap();
assert_eq!(s, back);
}
}
#[test]
fn migration_state_serializes_lowercase() {
let j = serde_json::to_string(&MigrationState::Applied).unwrap();
assert_eq!(j, "\"applied\"");
}
#[test]
fn migration_direction_serializes_lowercase() {
let j = serde_json::to_string(&MigrationDirection::Down).unwrap();
assert_eq!(j, "\"down\"");
}
fn sample_migration(version: &str) -> Migration {
Migration {
version: version.to_string(),
description: "test migration".into(),
path: PathBuf::from(format!("migrations/{version}_test.surql")),
up: vec!["DEFINE TABLE t SCHEMAFULL;".into()],
down: vec!["REMOVE TABLE t;".into()],
checksum: Some("deadbeef".into()),
depends_on: vec![],
}
}
#[test]
fn migration_fields_are_populated() {
let m = sample_migration("20260102_120000");
assert_eq!(m.version, "20260102_120000");
assert_eq!(m.description, "test migration");
assert_eq!(m.up.len(), 1);
assert_eq!(m.down.len(), 1);
assert_eq!(m.checksum.as_deref(), Some("deadbeef"));
assert!(m.depends_on.is_empty());
}
#[test]
fn migration_serde_roundtrip() {
let m = sample_migration("20260102_120000");
let j = serde_json::to_string(&m).unwrap();
let back: Migration = serde_json::from_str(&j).unwrap();
assert_eq!(m, back);
}
#[test]
fn migration_serde_missing_depends_on_defaults_empty() {
let j = r#"{
"version": "v1",
"description": "d",
"path": "p.surql",
"up": [],
"down": [],
"checksum": null
}"#;
let m: Migration = serde_json::from_str(j).unwrap();
assert!(m.depends_on.is_empty());
}
#[test]
fn migration_history_serde_roundtrip() {
let h = MigrationHistory {
version: "20260102_120000".into(),
description: "test".into(),
applied_at: DateTime::parse_from_rfc3339("2026-01-02T12:00:00Z")
.unwrap()
.with_timezone(&Utc),
checksum: "abc".into(),
execution_time_ms: Some(100),
};
let j = serde_json::to_string(&h).unwrap();
let back: MigrationHistory = serde_json::from_str(&j).unwrap();
assert_eq!(h, back);
}
#[test]
fn migration_history_execution_time_optional() {
let h = MigrationHistory {
version: "v1".into(),
description: "d".into(),
applied_at: Utc::now(),
checksum: "c".into(),
execution_time_ms: None,
};
let j = serde_json::to_string(&h).unwrap();
let back: MigrationHistory = serde_json::from_str(&j).unwrap();
assert_eq!(h, back);
assert!(back.execution_time_ms.is_none());
}
#[test]
fn migration_plan_empty_and_count() {
let plan = MigrationPlan {
migrations: vec![],
direction: MigrationDirection::Up,
};
assert!(plan.is_empty());
assert_eq!(plan.count(), 0);
}
#[test]
fn migration_plan_non_empty() {
let plan = MigrationPlan {
migrations: vec![sample_migration("v1"), sample_migration("v2")],
direction: MigrationDirection::Down,
};
assert!(!plan.is_empty());
assert_eq!(plan.count(), 2);
assert_eq!(plan.direction, MigrationDirection::Down);
}
#[test]
fn migration_plan_serde_roundtrip() {
let plan = MigrationPlan {
migrations: vec![sample_migration("v1")],
direction: MigrationDirection::Up,
};
let j = serde_json::to_string(&plan).unwrap();
let back: MigrationPlan = serde_json::from_str(&j).unwrap();
assert_eq!(plan, back);
}
#[test]
fn migration_metadata_default_author() {
assert_eq!(MigrationMetadata::default_author(), "surql");
}
#[test]
fn migration_metadata_serde_defaults() {
let j = r#"{"version":"v1","description":"d"}"#;
let meta: MigrationMetadata = serde_json::from_str(j).unwrap();
assert_eq!(meta.author, "surql");
assert!(meta.depends_on.is_empty());
}
#[test]
fn migration_metadata_serde_roundtrip() {
let meta = MigrationMetadata {
version: "v1".into(),
description: "d".into(),
author: "alice".into(),
depends_on: vec!["v0".into()],
};
let j = serde_json::to_string(&meta).unwrap();
let back: MigrationMetadata = serde_json::from_str(&j).unwrap();
assert_eq!(meta, back);
}
#[test]
fn migration_status_fields() {
let m = sample_migration("v1");
let s = MigrationStatus {
migration: m.clone(),
state: MigrationState::Applied,
applied_at: Some(Utc::now()),
error: None,
};
assert_eq!(s.migration, m);
assert_eq!(s.state, MigrationState::Applied);
assert!(s.applied_at.is_some());
assert!(s.error.is_none());
}
#[test]
fn migration_status_failure_captures_error() {
let s = MigrationStatus {
migration: sample_migration("v1"),
state: MigrationState::Failed,
applied_at: None,
error: Some("syntax error".into()),
};
assert_eq!(s.state, MigrationState::Failed);
assert_eq!(s.error.as_deref(), Some("syntax error"));
}
#[test]
fn migration_status_serde_roundtrip() {
let s = MigrationStatus {
migration: sample_migration("v1"),
state: MigrationState::Pending,
applied_at: None,
error: None,
};
let j = serde_json::to_string(&s).unwrap();
let back: MigrationStatus = serde_json::from_str(&j).unwrap();
assert_eq!(s, back);
}
#[test]
fn diff_operation_as_str_values() {
assert_eq!(DiffOperation::AddTable.as_str(), "add_table");
assert_eq!(DiffOperation::DropTable.as_str(), "drop_table");
assert_eq!(DiffOperation::AddField.as_str(), "add_field");
assert_eq!(DiffOperation::DropField.as_str(), "drop_field");
assert_eq!(DiffOperation::ModifyField.as_str(), "modify_field");
assert_eq!(DiffOperation::AddIndex.as_str(), "add_index");
assert_eq!(DiffOperation::DropIndex.as_str(), "drop_index");
assert_eq!(DiffOperation::AddEvent.as_str(), "add_event");
assert_eq!(DiffOperation::DropEvent.as_str(), "drop_event");
assert_eq!(
DiffOperation::ModifyPermissions.as_str(),
"modify_permissions"
);
}
#[test]
fn diff_operation_display_matches_as_str() {
assert_eq!(DiffOperation::AddTable.to_string(), "add_table");
assert_eq!(
DiffOperation::ModifyPermissions.to_string(),
"modify_permissions"
);
}
#[test]
fn diff_operation_serializes_snake_case() {
let j = serde_json::to_string(&DiffOperation::ModifyPermissions).unwrap();
assert_eq!(j, "\"modify_permissions\"");
}
#[test]
fn diff_operation_serde_roundtrip_all() {
let ops = [
DiffOperation::AddTable,
DiffOperation::DropTable,
DiffOperation::AddField,
DiffOperation::DropField,
DiffOperation::ModifyField,
DiffOperation::AddIndex,
DiffOperation::DropIndex,
DiffOperation::AddEvent,
DiffOperation::DropEvent,
DiffOperation::ModifyPermissions,
];
for op in ops {
let j = serde_json::to_string(&op).unwrap();
let back: DiffOperation = serde_json::from_str(&j).unwrap();
assert_eq!(op, back);
}
}
#[test]
fn schema_diff_basic_construction() {
let diff = SchemaDiff {
operation: DiffOperation::AddTable,
table: "user".into(),
field: None,
index: None,
event: None,
description: "Add user table".into(),
forward_sql: "DEFINE TABLE user SCHEMAFULL;".into(),
backward_sql: "REMOVE TABLE user;".into(),
details: BTreeMap::new(),
};
assert_eq!(diff.operation, DiffOperation::AddTable);
assert_eq!(diff.table, "user");
assert!(diff.field.is_none());
}
#[test]
fn schema_diff_with_field() {
let diff = SchemaDiff {
operation: DiffOperation::AddField,
table: "user".into(),
field: Some("email".into()),
index: None,
event: None,
description: "Add email field".into(),
forward_sql: "DEFINE FIELD email ON TABLE user TYPE string;".into(),
backward_sql: "REMOVE FIELD email ON TABLE user;".into(),
details: BTreeMap::new(),
};
assert_eq!(diff.field.as_deref(), Some("email"));
}
#[test]
fn schema_diff_serde_roundtrip() {
let mut details = BTreeMap::new();
details.insert("old_type".to_string(), serde_json::json!("string"));
details.insert("new_type".to_string(), serde_json::json!("int"));
let diff = SchemaDiff {
operation: DiffOperation::ModifyField,
table: "user".into(),
field: Some("age".into()),
index: None,
event: None,
description: "change age".into(),
forward_sql: "DEFINE FIELD age ON TABLE user TYPE int;".into(),
backward_sql: "DEFINE FIELD age ON TABLE user TYPE string;".into(),
details,
};
let j = serde_json::to_string(&diff).unwrap();
let back: SchemaDiff = serde_json::from_str(&j).unwrap();
assert_eq!(diff, back);
}
#[test]
fn schema_diff_serde_missing_details_defaults_empty() {
let j = r#"{
"operation": "add_table",
"table": "t",
"field": null,
"index": null,
"event": null,
"description": "d",
"forward_sql": "f",
"backward_sql": "b"
}"#;
let diff: SchemaDiff = serde_json::from_str(j).unwrap();
assert!(diff.details.is_empty());
}
}