use super::types::*;
use crate::foundation::config::DatabaseType;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ColumnType {
Integer,
BigInteger,
String(Option<u32>),
Text,
Boolean,
Float,
Double,
Date,
Time,
DateTime,
Timestamp,
Json,
Binary,
Custom(String),
}
impl ColumnType {
pub fn to_sql(&self, db_type: DatabaseType) -> String {
match self {
ColumnType::Integer => "INTEGER".to_string(),
ColumnType::BigInteger => match db_type {
DatabaseType::Sqlite => "INTEGER".to_string(),
_ => "BIGINT".to_string(),
},
ColumnType::String(None) => match db_type {
DatabaseType::MySql => "VARCHAR(255)".to_string(),
DatabaseType::Postgres => "VARCHAR(255)".to_string(),
DatabaseType::Sqlite => "TEXT".to_string(),
DatabaseType::DuckDb => "VARCHAR(255)".to_string(),
},
ColumnType::String(Some(len)) => match db_type {
DatabaseType::MySql => format!("VARCHAR({})", len),
DatabaseType::Postgres => format!("VARCHAR({})", len),
DatabaseType::Sqlite => "TEXT".to_string(),
DatabaseType::DuckDb => format!("VARCHAR({})", len),
},
ColumnType::Text => "TEXT".to_string(),
ColumnType::Boolean => match db_type {
DatabaseType::MySql => "BOOLEAN".to_string(),
DatabaseType::Postgres => "BOOLEAN".to_string(),
DatabaseType::Sqlite => "INTEGER".to_string(),
DatabaseType::DuckDb => "BOOLEAN".to_string(),
},
ColumnType::Float => "FLOAT".to_string(),
ColumnType::Double => "DOUBLE PRECISION".to_string(),
ColumnType::Date => "DATE".to_string(),
ColumnType::Time => "TIME".to_string(),
ColumnType::DateTime => match db_type {
DatabaseType::MySql => "DATETIME".to_string(),
DatabaseType::Postgres => "TIMESTAMP".to_string(),
DatabaseType::Sqlite => "TEXT".to_string(),
DatabaseType::DuckDb => "TIMESTAMP".to_string(),
},
ColumnType::Timestamp => "TIMESTAMP".to_string(),
ColumnType::Json => match db_type {
DatabaseType::MySql => "JSON".to_string(),
DatabaseType::Postgres => "JSONB".to_string(),
DatabaseType::Sqlite => "TEXT".to_string(),
DatabaseType::DuckDb => "JSON".to_string(),
},
ColumnType::Binary => "BLOB".to_string(),
ColumnType::Custom(name) => name.to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Column {
pub name: String,
pub column_type: ColumnType,
pub is_primary_key: bool,
pub is_nullable: bool,
pub has_default: bool,
pub default_value: Option<String>,
pub is_auto_increment: bool,
pub comment: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Table {
pub name: String,
pub columns: Vec<Column>,
pub primary_key_columns: Vec<String>,
pub indexes: Vec<Index>,
pub foreign_keys: Vec<ForeignKey>,
pub comment: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Index {
pub name: String,
pub table_name: String,
pub columns: Vec<String>,
pub is_unique: bool,
pub is_constraint: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForeignKey {
pub name: String,
pub table_name: String,
pub column_name: String,
pub referenced_table_name: String,
pub referenced_column_name: String,
pub on_delete: Option<ForeignKeyAction>,
pub on_update: Option<ForeignKeyAction>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ForeignKeyAction {
Cascade,
SetNull,
SetDefault,
Restrict,
NoAction,
}
impl fmt::Display for ForeignKeyAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ForeignKeyAction::Cascade => write!(f, "CASCADE"),
ForeignKeyAction::SetNull => write!(f, "SET NULL"),
ForeignKeyAction::SetDefault => write!(f, "SET DEFAULT"),
ForeignKeyAction::Restrict => write!(f, "RESTRICT"),
ForeignKeyAction::NoAction => write!(f, "NO ACTION"),
}
}
}
#[derive(Debug, Clone)]
pub struct Schema {
pub database_type: DatabaseType,
pub tables: Vec<Table>,
table_index: HashMap<String, usize>,
}
impl Default for Schema {
fn default() -> Self {
Self {
database_type: DatabaseType::Sqlite,
tables: Vec::new(),
table_index: HashMap::new(),
}
}
}
impl Schema {
pub fn new(database_type: DatabaseType) -> Self {
Self {
database_type,
tables: Vec::new(),
table_index: HashMap::new(),
}
}
pub fn add_table(&mut self, table: Table) {
let index = self.tables.len();
self.table_index.insert(table.name.clone(), index);
self.tables.push(table);
}
pub fn get_table(&self, name: &str) -> Option<&Table> {
if let Some(&index) = self.table_index.get(name) {
self.tables.get(index)
} else {
None
}
}
pub fn get_table_mut(&mut self, name: &str) -> Option<&mut Table> {
if let Some(&index) = self.table_index.get(name) {
self.tables.get_mut(index)
} else {
None
}
}
pub fn has_table(&self, name: &str) -> bool {
self.table_index.contains_key(name)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Migration {
pub version: u32,
pub description: String,
pub table_changes: Vec<TableChange>,
pub sql: Option<String>,
pub timestamp: Option<time::OffsetDateTime>,
}
impl Migration {
pub fn new(version: u32, description: String) -> Self {
Self {
version,
description,
table_changes: Vec::new(),
sql: None,
timestamp: Some(time::OffsetDateTime::now_utc()),
}
}
pub fn add_table_change(&mut self, change: TableChange) {
self.table_changes.push(change);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::foundation::config::DatabaseType;
#[test]
fn column_type_to_sql_integer() {
assert_eq!(ColumnType::Integer.to_sql(DatabaseType::Sqlite), "INTEGER");
assert_eq!(ColumnType::Integer.to_sql(DatabaseType::Postgres), "INTEGER");
assert_eq!(ColumnType::Integer.to_sql(DatabaseType::MySql), "INTEGER");
}
#[test]
fn column_type_to_sql_string() {
assert_eq!(ColumnType::String(None).to_sql(DatabaseType::Sqlite), "TEXT");
assert_eq!(ColumnType::String(None).to_sql(DatabaseType::Postgres), "VARCHAR(255)");
assert_eq!(ColumnType::String(Some(64)).to_sql(DatabaseType::MySql), "VARCHAR(64)");
}
#[test]
fn column_type_to_sql_boolean() {
assert_eq!(ColumnType::Boolean.to_sql(DatabaseType::Sqlite), "INTEGER");
assert_eq!(ColumnType::Boolean.to_sql(DatabaseType::Postgres), "BOOLEAN");
}
#[test]
fn column_type_to_sql_json() {
assert_eq!(ColumnType::Json.to_sql(DatabaseType::Sqlite), "TEXT");
assert_eq!(ColumnType::Json.to_sql(DatabaseType::Postgres), "JSONB");
assert_eq!(ColumnType::Json.to_sql(DatabaseType::MySql), "JSON");
}
#[test]
fn column_type_to_sql_custom() {
assert_eq!(ColumnType::Custom("UUID".into()).to_sql(DatabaseType::Sqlite), "UUID");
}
#[test]
fn schema_new_empty() {
let s = Schema::new(DatabaseType::Sqlite);
assert_eq!(s.database_type, DatabaseType::Sqlite);
assert!(s.tables.is_empty());
}
#[test]
fn schema_add_and_get_table() {
let mut s = Schema::new(DatabaseType::Sqlite);
let table = Table {
name: "users".into(),
columns: vec![Column {
name: "id".into(),
column_type: ColumnType::Integer,
is_primary_key: true,
is_nullable: false,
has_default: false,
default_value: None,
is_auto_increment: true,
comment: None,
}],
primary_key_columns: vec!["id".into()],
indexes: vec![],
foreign_keys: vec![],
comment: None,
};
s.add_table(table);
assert!(s.has_table("users"));
assert!(s.get_table("users").is_some());
assert!(s.get_table("nonexistent").is_none());
}
#[test]
fn migration_new_and_add_change() {
let mut m = Migration::new(1, "initial".into());
assert_eq!(m.version, 1);
assert_eq!(m.description, "initial");
m.add_table_change(TableChange::CreateTable(Table {
name: "t".into(),
columns: vec![],
primary_key_columns: vec![],
indexes: vec![],
foreign_keys: vec![],
comment: None,
}));
assert_eq!(m.table_changes.len(), 1);
}
#[test]
fn migration_history_ordering() {
let mut h = MigrationHistory::new();
assert!(h.applied_migrations.is_empty());
let v1 = MigrationVersion {
version: 2,
description: "v2".into(),
applied_at: time::OffsetDateTime::now_utc(),
file_path: "m2.sql".into(),
};
let v2 = MigrationVersion {
version: 1,
description: "v1".into(),
applied_at: time::OffsetDateTime::now_utc(),
file_path: "m1.sql".into(),
};
h.add_migration(v1);
h.add_migration(v2);
assert_eq!(h.applied_migrations.len(), 2);
assert_eq!(h.get_latest_version(), Some(2));
}
#[test]
fn migration_history_pending() {
let mut h = MigrationHistory::new();
h.add_migration(MigrationVersion {
version: 1,
description: "v1".into(),
applied_at: time::OffsetDateTime::now_utc(),
file_path: "m1.sql".into(),
});
let all = [
Migration::new(1, "v1".into()),
Migration::new(2, "v2".into()),
Migration::new(3, "v3".into()),
];
let pending = h.get_pending_migrations(&all);
assert_eq!(pending.len(), 2);
assert_eq!(pending[0].version, 2);
assert_eq!(pending[1].version, 3);
}
#[test]
fn foreign_key_action_display() {
assert_eq!(ForeignKeyAction::Cascade.to_string(), "CASCADE");
assert_eq!(ForeignKeyAction::SetNull.to_string(), "SET NULL");
assert_eq!(ForeignKeyAction::SetDefault.to_string(), "SET DEFAULT");
assert_eq!(ForeignKeyAction::Restrict.to_string(), "RESTRICT");
assert_eq!(ForeignKeyAction::NoAction.to_string(), "NO ACTION");
}
#[test]
fn test_column_type_to_sql_big_integer() {
assert_eq!(ColumnType::BigInteger.to_sql(DatabaseType::Sqlite), "INTEGER");
assert_eq!(ColumnType::BigInteger.to_sql(DatabaseType::Postgres), "BIGINT");
assert_eq!(ColumnType::BigInteger.to_sql(DatabaseType::MySql), "BIGINT");
}
#[test]
fn test_column_type_to_sql_float_double() {
assert_eq!(ColumnType::Float.to_sql(DatabaseType::Postgres), "FLOAT");
assert_eq!(ColumnType::Double.to_sql(DatabaseType::Postgres), "DOUBLE PRECISION");
assert_eq!(ColumnType::Double.to_sql(DatabaseType::MySql), "DOUBLE PRECISION");
}
#[test]
fn test_column_type_to_sql_date_time_types() {
assert_eq!(ColumnType::Date.to_sql(DatabaseType::Postgres), "DATE");
assert_eq!(ColumnType::Time.to_sql(DatabaseType::Postgres), "TIME");
assert_eq!(ColumnType::Timestamp.to_sql(DatabaseType::Postgres), "TIMESTAMP");
assert_eq!(ColumnType::DateTime.to_sql(DatabaseType::MySql), "DATETIME");
assert_eq!(ColumnType::DateTime.to_sql(DatabaseType::Postgres), "TIMESTAMP");
assert_eq!(ColumnType::DateTime.to_sql(DatabaseType::Sqlite), "TEXT");
}
#[test]
fn test_column_type_to_sql_binary() {
assert_eq!(ColumnType::Binary.to_sql(DatabaseType::Postgres), "BLOB");
assert_eq!(ColumnType::Binary.to_sql(DatabaseType::Sqlite), "BLOB");
}
#[test]
fn test_column_type_to_sql_text() {
assert_eq!(ColumnType::Text.to_sql(DatabaseType::Postgres), "TEXT");
assert_eq!(ColumnType::Text.to_sql(DatabaseType::MySql), "TEXT");
assert_eq!(ColumnType::Text.to_sql(DatabaseType::Sqlite), "TEXT");
}
#[test]
fn test_schema_default() {
let s = Schema::default();
assert_eq!(s.database_type, DatabaseType::Sqlite);
assert!(s.tables.is_empty());
}
#[test]
fn test_schema_get_table_mut() {
let mut s = Schema::new(DatabaseType::Sqlite);
let table = Table {
name: "users".into(),
columns: vec![Column {
name: "id".into(),
column_type: ColumnType::Integer,
is_primary_key: true,
is_nullable: false,
has_default: false,
default_value: None,
is_auto_increment: true,
comment: None,
}],
primary_key_columns: vec!["id".into()],
indexes: vec![],
foreign_keys: vec![],
comment: None,
};
s.add_table(table);
{
let users = s.get_table_mut("users").expect("table should exist");
users.comment = Some("updated comment".to_string());
}
let users = s.get_table("users").expect("table should exist");
assert_eq!(users.comment.as_deref(), Some("updated comment"));
assert!(s.get_table_mut("nonexistent").is_none());
}
#[test]
fn test_migration_history_default() {
let h = MigrationHistory::default();
assert!(h.applied_migrations.is_empty());
}
#[test]
fn test_migration_history_is_version_applied() {
let mut h = MigrationHistory::new();
assert!(!h.is_version_applied(1));
h.add_migration(MigrationVersion {
version: 1,
description: "v1".into(),
applied_at: time::OffsetDateTime::now_utc(),
file_path: "m1.sql".into(),
});
assert!(h.is_version_applied(1));
assert!(!h.is_version_applied(2));
}
#[test]
fn test_migration_history_get_latest_version_empty() {
let h = MigrationHistory::new();
assert_eq!(h.get_latest_version(), None);
}
#[test]
fn test_migration_history_get_pending_migrations_empty() {
let h = MigrationHistory::new();
let all: Vec<Migration> = vec![];
let pending = h.get_pending_migrations(&all);
assert!(pending.is_empty());
}
#[test]
fn test_migration_history_get_pending_migrations_all_applied() {
let mut h = MigrationHistory::new();
h.add_migration(MigrationVersion {
version: 1,
description: "v1".into(),
applied_at: time::OffsetDateTime::now_utc(),
file_path: "m1.sql".into(),
});
h.add_migration(MigrationVersion {
version: 2,
description: "v2".into(),
applied_at: time::OffsetDateTime::now_utc(),
file_path: "m2.sql".into(),
});
let all = [Migration::new(1, "v1".into()), Migration::new(2, "v2".into())];
let pending = h.get_pending_migrations(&all);
assert!(pending.is_empty());
}
#[test]
fn test_serializable_migration_version_round_trip() {
let original = MigrationVersion {
version: 42,
description: "test migration".to_string(),
applied_at: time::OffsetDateTime::now_utc(),
file_path: "migrations/042_test.sql".to_string(),
};
let serializable: SerializableMigrationVersion = original.clone().into();
assert_eq!(serializable.version, 42);
assert_eq!(serializable.description, "test migration");
assert_eq!(serializable.file_path, "migrations/042_test.sql");
assert!(!serializable.applied_at.is_empty());
let restored: MigrationVersion = serializable.into();
assert_eq!(restored.version, 42);
assert_eq!(restored.description, "test migration");
assert_eq!(restored.file_path, "migrations/042_test.sql");
}
#[test]
fn test_serializable_migration_version_invalid_timestamp_fallback() {
let serializable = SerializableMigrationVersion {
version: 1,
description: "bad timestamp".to_string(),
applied_at: "not-a-valid-timestamp".to_string(),
file_path: "m1.sql".to_string(),
};
let restored: MigrationVersion = serializable.into();
assert_eq!(restored.version, 1);
assert_eq!(restored.description, "bad timestamp");
let now = time::OffsetDateTime::now_utc();
let diff = restored.applied_at - now;
assert!(diff.whole_seconds().abs() < 5, "timestamp should be close to now");
}
}
#[derive(Debug, Clone)]
pub struct MigrationVersion {
pub version: u32,
pub description: String,
pub applied_at: time::OffsetDateTime,
pub file_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SerializableMigrationVersion {
pub version: u32,
pub description: String,
pub applied_at: String, pub file_path: String,
}
impl From<MigrationVersion> for SerializableMigrationVersion {
fn from(mv: MigrationVersion) -> Self {
Self {
version: mv.version,
description: mv.description,
applied_at: mv.applied_at.to_string(),
file_path: mv.file_path,
}
}
}
impl From<SerializableMigrationVersion> for MigrationVersion {
fn from(sm: SerializableMigrationVersion) -> Self {
let applied_at =
match time::OffsetDateTime::parse(&sm.applied_at, &time::format_description::well_known::Rfc3339) {
Ok(dt) => dt,
Err(_) => {
time::OffsetDateTime::now_utc()
}
};
Self {
version: sm.version,
description: sm.description,
applied_at,
file_path: sm.file_path,
}
}
}
#[derive(Debug, Clone)]
pub struct MigrationHistory {
pub applied_migrations: Vec<MigrationVersion>,
}
impl Default for MigrationHistory {
fn default() -> Self {
Self::new()
}
}
impl MigrationHistory {
pub fn new() -> Self {
Self {
applied_migrations: Vec::new(),
}
}
pub fn add_migration(&mut self, migration: MigrationVersion) {
self.applied_migrations.push(migration);
self.applied_migrations.sort_by_key(|m| m.version);
}
pub fn is_version_applied(&self, version: u32) -> bool {
self.applied_migrations.iter().any(|m| m.version == version)
}
pub fn get_latest_version(&self) -> Option<u32> {
self.applied_migrations.iter().map(|m| m.version).max()
}
pub fn get_pending_migrations<'a>(&self, all_migrations: &'a [Migration]) -> Vec<&'a Migration> {
all_migrations
.iter()
.filter(|m| !self.is_version_applied(m.version))
.collect()
}
}