前言
随着reinamanager的逐步开发,我发现games表实在太臃肿了,为了以后能更好的,添加新的数据源、交叉显示游戏数据、发挥各数据源的特性等,于是我决定把games表拆分成多个表;由于项目初期使用了tauri-plugin-sql插件,games表拆分后会导致repository层多个game数据表的交互逻辑变得异常复杂,换言之就是sql语句会变得很复杂。再加上之前有人建议我使用orm代替纯sql查询issue。那就来吧!要说rust家族里的orm,那肯定首推seaorm。
追平与基线迁移
原来使用的是tauri-plugin-sql,想彻底重构到seaorm得做基线迁移,在基线迁移脚本中判断用户类型,新用户运行数据库初始化函数,创建全新的数据库结构,老用户先运行旧的迁移脚本(追平),然后将旧的迁移表_sqlx_migrations备份一份,以完成基线迁移。
使用sea-orm-migration来创建一个迁移crate:
cargo install sea-orm-cli
sea-orm-cli migrate init
sea-orm-cli migrate generate xxx
因为旧的迁移脚本是基于sqlx的,还有数据库放在AppData下,所以为migration crate添加sqlx、dirs-next、url等依赖:
[dependencies]
sqlx = { version = "0.8", features = [
"sqlite",
"runtime-tokio-native-tls",
"migrate",
] }
dirs-next = "2"
url = "2"
基线迁移脚本:
use sea_orm::{ConnectionTrait, DatabaseBackend, Statement};
use sea_orm_migration::prelude::*;
use sea_orm_migration::sea_orm::TransactionTrait;
# [derive(DeriveMigrationName)]
pub struct Migration;
# [async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let conn = manager.get_connection();
// 开启事务,保证所有操作的原子性
let txn = conn.begin().await?;
// 判断是否为新用户 - 检查是否存在任何遗留数据表
let is_new_user = !has_any_legacy_tables(&txn).await?;
if is_new_user {
println!("[MIGRATION] New user detected, creating modern split table structure");
create_modern_schema(&txn).await?;
} else {
println!("[MIGRATION] Existing user detected, running legacy migration catch-up");
run_legacy_migrations_with_sqlx().await?;
}
// 提交事务
txn.commit().await?;
println!("[MIGRATION] v1 baseline schema created successfully");
Ok(())
}
}
/// 检查是否存在任何遗留数据表或数据
async fn has_any_legacy_tables<C>(conn: &C) -> Result<bool, DbErr>
where
C: ConnectionTrait,
{
// 检查是否存在 tauri-plugin-sql 的迁移表
let legacy_migration_exists = conn
.query_one(Statement::from_string(
DatabaseBackend::Sqlite,
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='_sqlx_migrations'",
))
.await?
.is_some();
Ok(legacy_migration_exists)
}
/// 为新用户创建现代的拆分表结构
async fn create_modern_schema<C>(conn: &C) -> Result<(), DbErr>
where
C: ConnectionTrait,
{
......
// 5. 创建关联表
create_related_tables(conn).await?;
// 6. 创建现代结构的索引
create_modern_indexes(conn).await?;
Ok(())
}
/// 创建关联表(游戏会话、统计、存档等)
async fn create_related_tables<C>(conn: &C) -> Result<(), DbErr>
where
C: ConnectionTrait,
{
......
Ok(())
}
/// 为现代拆分结构创建索引
async fn create_modern_indexes<C>(conn: &C) -> Result<(), DbErr>
where
C: ConnectionTrait,
{
let indexes = [
// games 表索引
......
];
for (index_name, table_name, column_name) in &indexes {
conn.execute(Statement::from_string(
DatabaseBackend::Sqlite,
format!(
r#"CREATE INDEX IF NOT EXISTS "{}" ON "{}" ("{}")"#,
index_name, table_name, column_name
),
))
.await?;
}
Ok(())
}
/// 为现有用户运行旧的 tauri-plugin-sql 迁移,使用 sqlx 执行
async fn run_legacy_migrations_with_sqlx() -> Result<(), DbErr> {
println!("[MIGRATION] Running legacy migrations with sqlx...");
// 获取数据库连接 URL(从系统目录推导)
let database_url = get_db_path()?;
// 创建 sqlx 连接池
let pool = sqlx::SqlitePool::connect(&database_url)
.await
.map_err(|e| DbErr::Custom(format!("Failed to connect with sqlx: {}", e)))?;
// 检查并运行旧迁移
run_legacy_migration_001(&pool).await?;
run_legacy_migration_002(&pool).await?;
// 清理 sqlx 的迁移记录,因为我们转移到 SeaORM
cleanup_sqlx_migration_table(&pool).await?;
pool.close().await;
println!("[MIGRATION] Legacy migrations completed successfully");
Ok(())
}
/// 从系统目录推导数据库连接字符串(无需外部参数)
fn get_db_path() -> Result<String, DbErr> {
use std::path::PathBuf;
// 使用 config_dir (Roaming on Windows) 来匹配原先的 app_data_dir 行为
let base = dirs_next::config_dir()
.or_else(dirs_next::data_dir)
.ok_or_else(|| DbErr::Custom("Failed to resolve user data directory".to_string()))?;
let db_path: PathBuf = base
.join("com.reinamanager.dev")
.join("data")
.join("reina_manager.db");
// 使用 url::Url::from_file_path 保证路径格式正确
let db_url = url::Url::from_file_path(&db_path)
.map_err(|_| DbErr::Custom("Invalid database path".to_string()))?;
let conn = format!("sqlite:{}?mode=rwc", db_url.path());
Ok(conn)
}
/// 运行旧迁移 001 - 数据库初始化
async fn run_legacy_migration_001(pool: &sqlx::SqlitePool) -> Result<(), DbErr> {
println!("[MIGRATION] Checking legacy migration 001...");
// 检查是否已经执行过这个迁移
let migration_exists =
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM _sqlx_migrations WHERE version = 1")
.fetch_one(pool)
.await
.unwrap_or(0)
> 0;
if migration_exists {
println!("[MIGRATION] Migration 001 already applied, skipping");
return Ok(());
}
println!("[MIGRATION] Applying migration 001 - database initialization");
// 执行迁移 001 的 SQL
let migration_sql = include_str!("../old_migrations/001_database_initialization.sql");
sqlx::query(migration_sql)
.execute(pool)
.await
.map_err(|e| DbErr::Custom(format!("Failed to execute migration 001: {}", e)))?;
// 记录迁移
sqlx::query(
"INSERT INTO _sqlx_migrations (version, description, installed_on, success, checksum, execution_time)
VALUES (1, 'database_initialization', datetime('now'), 1, 0, 0)"
)
.execute(pool)
.await
.map_err(|e| DbErr::Custom(format!("Failed to record migration 001: {}", e)))?;
println!("[MIGRATION] Migration 001 applied successfully");
Ok(())
}
/// 运行旧迁移 002 - 添加自定义字段
async fn run_legacy_migration_002(pool: &sqlx::SqlitePool) -> Result<(), DbErr> {
println!("[MIGRATION] Checking legacy migration 002...");
// 检查是否已经执行过这个迁移
let migration_exists =
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM _sqlx_migrations WHERE version = 2")
.fetch_one(pool)
.await
.unwrap_or(0)
> 0;
if migration_exists {
println!("[MIGRATION] Migration 002 already applied, skipping");
return Ok(());
}
println!("[MIGRATION] Applying migration 002 - add custom fields");
// 执行迁移 002 的 SQL
let migration_sql = include_str!("../old_migrations/002_add_custom_fields.sql");
sqlx::query(migration_sql)
.execute(pool)
.await
.map_err(|e| DbErr::Custom(format!("Failed to execute migration 002: {}", e)))?;
// 记录迁移
sqlx::query(
"INSERT INTO _sqlx_migrations (version, description, installed_on, success, checksum, execution_time)
VALUES (2, 'add_custom_fields', datetime('now'), 1, 0, 0)"
)
.execute(pool)
.await
.map_err(|e| DbErr::Custom(format!("Failed to record migration 002: {}", e)))?;
println!("[MIGRATION] Migration 002 applied successfully");
Ok(())
}
/// 清理 sqlx 的迁移记录表,为转移到 SeaORM 做准备
async fn cleanup_sqlx_migration_table(pool: &sqlx::SqlitePool) -> Result<(), DbErr> {
println!("[MIGRATION] Cleaning up sqlx migration records...");
// 可选:保留迁移历史但重命名表
sqlx::query("ALTER TABLE _sqlx_migrations RENAME TO _legacy_sqlx_migrations")
.execute(pool)
.await
.map_err(|e| DbErr::Custom(format!("Failed to rename sqlx migrations table: {}", e)))?;
println!("[MIGRATION] sqlx migration table renamed to _legacy_sqlx_migrations");
Ok(())
}
games表拆分
先关闭外键约束,再创建新的核心games表,创建各个数据源的数据表,以及一个other_data表用于存放一些通用数据,其次用旧的games表数据填充新的数据表,然后备份、删除并重建受外键影响的表,再然后删除原games表并重命名新表,最后重新开启外键约束,重建数据库以回收空间并整理碎片。
games表拆分脚本:
use sea_orm::{ConnectionTrait, DatabaseBackend, Statement};
use sea_orm_migration::prelude::*;
use sea_orm_migration::sea_orm::TransactionTrait;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 检查是否已经拆分(通过检查 bgm_data 表是否存在)
let already_split = manager.has_table("bgm_data").await?;
if already_split {
// 已经拆分过,直接返回
return Ok(());
}
// 执行表拆分逻辑
split_games_table(manager).await?;
Ok(())
}
}
async fn split_games_table(manager: &SchemaManager<'_>) -> Result<(), DbErr> {
let conn = manager.get_connection();
// 0. 关闭外键约束
conn.execute(Statement::from_string(
DatabaseBackend::Sqlite,
"PRAGMA foreign_keys = OFF;",
))
.await?;
// 开启事务,保证所有操作的原子性
let txn = conn.begin().await?;
// 1. 创建新的核心 games 表(只保留本地管理相关字段)
// 2. 创建 BGM 数据表
// 3. 创建 VNDB 数据表
// 4. 创建其他数据表
// 5. 迁移数据从原 games 表到新表结构
// 5.1 迁移核心 games 数据
// 5.2 迁移 BGM 相关数据
// 5.3 迁移 VNDB 相关数据
// 5.4 迁移其他数据(custom, Whitecloud 等)
// 6. 备份、删除并重建受外键影响的表
// 6.1 处理 game_sessions 表
// 6.2 处理 game_statistics 表
// 6.3 处理 savedata 表
// 7. 删除原 games 表并重命名新表
// 8. 提交事务
txn.commit().await?;
// 9. 重新开启外键约束
conn.execute(Statement::from_string(
DatabaseBackend::Sqlite,
"PRAGMA foreign_keys = ON;",
))
.await?;
// 10. (推荐) 重建数据库以回收空间并整理碎片
conn.execute_unprepared("VACUUM;").await?;
Ok(())
}
相比tauri-plugin-sql的sql式迁移脚本,seaorm的rust迁移脚本可太好用了好吧。
迁移代码详情见migration
评论 (0)