mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 14:14:53 +08:00
fix(sqlite): 收口长期状态数据库版本化迁移契约
This commit is contained in:
+686
-140
@@ -3,6 +3,9 @@
|
||||
use crate::path_security::{
|
||||
ensure_safe_directory_path, lexical_absolute, set_file_mode, STATE_FILE_MODE,
|
||||
};
|
||||
use crate::sqlite_migration::{
|
||||
self, ExpectedColumn, ExpectedIndex, ExpectedTable, SqliteSchemaSnapshot,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use bat_core::domain::{
|
||||
evaluate_glossary, validate_glossary_draft, GlossaryEvaluation, GlossaryHistoryRecord,
|
||||
@@ -12,7 +15,7 @@ use bat_core::domain::{
|
||||
use bat_core::repositories::GlossaryRepository;
|
||||
use bat_core::{Error, Result};
|
||||
use serde::de::DeserializeOwned;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
@@ -76,15 +79,20 @@ impl SqliteGlossaryRepository {
|
||||
absolute.display()
|
||||
)));
|
||||
}
|
||||
if metadata.len() > 0 {
|
||||
let snapshot =
|
||||
sqlite_migration::read_only_preflight(&absolute, GLOSSARY_SCHEMA_COMPONENT)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
classify_glossary_schema(&snapshot)?;
|
||||
}
|
||||
}
|
||||
let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", absolute.display()))
|
||||
.map_err(|error| Error::Other(error.into()))?
|
||||
.create_if_missing(create_if_missing)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.busy_timeout(Duration::from_secs(30));
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
let pool = sqlite_migration::connect_writable_pool(options)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
if create_if_missing {
|
||||
@@ -97,119 +105,86 @@ impl SqliteGlossaryRepository {
|
||||
}
|
||||
|
||||
async fn init_schema(&self) -> Result<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
component TEXT PRIMARY KEY NOT NULL,
|
||||
version INTEGER NOT NULL CHECK(version >= 1)
|
||||
)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS glossary_terms (
|
||||
term_id TEXT PRIMARY KEY NOT NULL,
|
||||
source_term TEXT NOT NULL,
|
||||
aliases_json TEXT NOT NULL,
|
||||
recommended_translation TEXT NOT NULL,
|
||||
allowed_translations_json TEXT NOT NULL,
|
||||
source_language TEXT,
|
||||
target_language TEXT,
|
||||
category TEXT,
|
||||
priority INTEGER NOT NULL,
|
||||
scope_json TEXT NOT NULL,
|
||||
review_status TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL,
|
||||
source_ref TEXT,
|
||||
source_author TEXT,
|
||||
source_note TEXT,
|
||||
source_observed_unix_seconds INTEGER NOT NULL,
|
||||
created_unix_seconds INTEGER NOT NULL,
|
||||
updated_unix_seconds INTEGER NOT NULL,
|
||||
CHECK(length(term_id) > 0),
|
||||
CHECK(length(source_term) > 0),
|
||||
CHECK(length(recommended_translation) > 0),
|
||||
CHECK(review_status IN ('draft', 'approved', 'deprecated', 'rejected')),
|
||||
CHECK(source_kind IN ('manual', 'imported'))
|
||||
)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
ensure_column(
|
||||
&self.pool,
|
||||
"glossary_terms",
|
||||
"source_observed_unix_seconds",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS glossary_term_history (
|
||||
history_id TEXT PRIMARY KEY NOT NULL,
|
||||
term_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
reviewer TEXT,
|
||||
reason TEXT,
|
||||
source_json TEXT NOT NULL,
|
||||
review_status TEXT NOT NULL,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
observed_unix_seconds INTEGER NOT NULL,
|
||||
FOREIGN KEY(term_id) REFERENCES glossary_terms(term_id)
|
||||
)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS glossary_term_deletions (
|
||||
deletion_id TEXT PRIMARY KEY NOT NULL,
|
||||
term_id TEXT NOT NULL,
|
||||
reviewer TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
source_json TEXT NOT NULL,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
history_json TEXT NOT NULL,
|
||||
observed_unix_seconds INTEGER NOT NULL
|
||||
)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_glossary_status
|
||||
ON glossary_terms(review_status, priority DESC, term_id)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_glossary_source_term
|
||||
ON glossary_terms(source_term)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
let current: Option<i64> =
|
||||
sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1")
|
||||
.bind(GLOSSARY_SCHEMA_COMPONENT)
|
||||
.fetch_optional(&self.pool)
|
||||
self.init_schema_with_failure(None).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn init_schema_with_test_failure(&self, fail_after_step: usize) -> Result<()> {
|
||||
self.init_schema_with_failure(Some(fail_after_step)).await
|
||||
}
|
||||
|
||||
async fn init_schema_with_failure(&self, fail_after_step: Option<usize>) -> Result<()> {
|
||||
let mut transaction = sqlite_migration::begin_immediate(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
let result = self
|
||||
.migrate_in_transaction(&mut transaction, fail_after_step)
|
||||
.await;
|
||||
match result {
|
||||
Ok(()) => transaction.commit().await.map_err(db_error),
|
||||
Err(error) => {
|
||||
let _ = transaction.rollback().await;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn migrate_in_transaction(
|
||||
&self,
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
fail_after_step: Option<usize>,
|
||||
) -> Result<()> {
|
||||
let snapshot =
|
||||
sqlite_migration::snapshot_connection(transaction.as_mut(), GLOSSARY_SCHEMA_COMPONENT)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
if current.is_some_and(|version| version > i64::from(GLOSSARY_SCHEMA_VERSION)) {
|
||||
return Err(Error::InvalidArgument(format!(
|
||||
"不支持的 Glossary schema 版本:{}",
|
||||
current.unwrap_or_default()
|
||||
)));
|
||||
match classify_glossary_schema(&snapshot)? {
|
||||
GlossarySchemaState::Empty => {
|
||||
create_glossary_schema(transaction, fail_after_step).await?;
|
||||
sqlite_migration::write_component_version(
|
||||
transaction,
|
||||
GLOSSARY_SCHEMA_COMPONENT,
|
||||
GLOSSARY_SCHEMA_VERSION,
|
||||
)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
}
|
||||
GlossarySchemaState::Version(version) if version == GLOSSARY_SCHEMA_VERSION => {
|
||||
if snapshot.component_version.is_none() {
|
||||
if !snapshot
|
||||
.tables
|
||||
.contains_key(sqlite_migration::SCHEMA_MIGRATIONS_TABLE)
|
||||
{
|
||||
sqlite_migration::create_schema_migrations_table(transaction)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
}
|
||||
sqlite_migration::write_component_version(
|
||||
transaction,
|
||||
GLOSSARY_SCHEMA_COMPONENT,
|
||||
GLOSSARY_SCHEMA_VERSION,
|
||||
)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
}
|
||||
}
|
||||
GlossarySchemaState::Version(version) => {
|
||||
return Err(invalid_glossary_schema(format!(
|
||||
"内部不支持的迁移起点 {version}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let final_snapshot =
|
||||
sqlite_migration::snapshot_connection(transaction.as_mut(), GLOSSARY_SCHEMA_COMPONENT)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
if final_snapshot.component_version != Some(i64::from(GLOSSARY_SCHEMA_VERSION))
|
||||
|| !matches_glossary_fingerprint(&final_snapshot)
|
||||
{
|
||||
return Err(invalid_glossary_schema(
|
||||
"migration 结果与当前 schema fingerprint 不一致".to_string(),
|
||||
));
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO schema_migrations(component, version) VALUES (?1, ?2)
|
||||
ON CONFLICT(component) DO UPDATE SET version = excluded.version",
|
||||
)
|
||||
.bind(GLOSSARY_SCHEMA_COMPONENT)
|
||||
.bind(i64::from(GLOSSARY_SCHEMA_VERSION))
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -743,6 +718,450 @@ fn row_to_history(row: sqlx::sqlite::SqliteRow) -> Result<GlossaryHistoryRecord>
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum GlossarySchemaState {
|
||||
Empty,
|
||||
Version(u32),
|
||||
}
|
||||
|
||||
fn classify_glossary_schema(snapshot: &SqliteSchemaSnapshot) -> Result<GlossarySchemaState> {
|
||||
if snapshot.is_empty() {
|
||||
return Ok(GlossarySchemaState::Empty);
|
||||
}
|
||||
if let Some(observed) = snapshot.component_version {
|
||||
if observed > i64::from(GLOSSARY_SCHEMA_VERSION) {
|
||||
return Err(invalid_glossary_schema(format!(
|
||||
"不支持的 Glossary schema 版本:observed={observed}, supported={GLOSSARY_SCHEMA_VERSION}"
|
||||
)));
|
||||
}
|
||||
if observed < 1 {
|
||||
return Err(invalid_glossary_schema(format!(
|
||||
"schema version 无效:observed={observed}, supported=1..={GLOSSARY_SCHEMA_VERSION}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if (matches_glossary_fingerprint(snapshot)
|
||||
|| (snapshot.component_version.is_none()
|
||||
&& matches_glossary_component_fingerprint(snapshot)))
|
||||
&& snapshot
|
||||
.component_version
|
||||
.is_none_or(|observed| observed == i64::from(GLOSSARY_SCHEMA_VERSION))
|
||||
{
|
||||
return Ok(GlossarySchemaState::Version(GLOSSARY_SCHEMA_VERSION));
|
||||
}
|
||||
match snapshot.component_version {
|
||||
Some(observed) => Err(invalid_glossary_schema(format!(
|
||||
"schema version 与实际结构不一致:observed={observed}, supported={GLOSSARY_SCHEMA_VERSION}"
|
||||
))),
|
||||
None => Err(invalid_glossary_schema(
|
||||
"未识别的 legacy schema,拒绝静默修复".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_glossary_schema(detail: String) -> Error {
|
||||
Error::InvalidArgument(format!("Glossary schema 无效:{detail}"))
|
||||
}
|
||||
|
||||
fn matches_glossary_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool {
|
||||
let tables = [
|
||||
sqlite_migration::schema_migrations_table(),
|
||||
ExpectedTable {
|
||||
name: "glossary_terms",
|
||||
columns: &GLOSSARY_TERM_COLUMNS,
|
||||
},
|
||||
ExpectedTable {
|
||||
name: "glossary_term_history",
|
||||
columns: &GLOSSARY_HISTORY_COLUMNS,
|
||||
},
|
||||
ExpectedTable {
|
||||
name: "glossary_term_deletions",
|
||||
columns: &GLOSSARY_DELETION_COLUMNS,
|
||||
},
|
||||
];
|
||||
let indexes = [
|
||||
ExpectedIndex {
|
||||
table: "glossary_terms",
|
||||
name: "idx_glossary_status",
|
||||
columns: &["review_status", "priority", "term_id"],
|
||||
},
|
||||
ExpectedIndex {
|
||||
table: "glossary_terms",
|
||||
name: "idx_glossary_source_term",
|
||||
columns: &["source_term"],
|
||||
},
|
||||
];
|
||||
matches_glossary_fingerprint_with_tables(snapshot, &tables, &indexes)
|
||||
}
|
||||
|
||||
fn matches_glossary_component_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool {
|
||||
let tables = [
|
||||
ExpectedTable {
|
||||
name: "glossary_terms",
|
||||
columns: &GLOSSARY_TERM_COLUMNS,
|
||||
},
|
||||
ExpectedTable {
|
||||
name: "glossary_term_history",
|
||||
columns: &GLOSSARY_HISTORY_COLUMNS,
|
||||
},
|
||||
ExpectedTable {
|
||||
name: "glossary_term_deletions",
|
||||
columns: &GLOSSARY_DELETION_COLUMNS,
|
||||
},
|
||||
];
|
||||
let indexes = [
|
||||
ExpectedIndex {
|
||||
table: "glossary_terms",
|
||||
name: "idx_glossary_status",
|
||||
columns: &["review_status", "priority", "term_id"],
|
||||
},
|
||||
ExpectedIndex {
|
||||
table: "glossary_terms",
|
||||
name: "idx_glossary_source_term",
|
||||
columns: &["source_term"],
|
||||
},
|
||||
];
|
||||
matches_glossary_fingerprint_with_tables(snapshot, &tables, &indexes)
|
||||
}
|
||||
|
||||
fn matches_glossary_fingerprint_with_tables(
|
||||
snapshot: &SqliteSchemaSnapshot,
|
||||
tables: &[ExpectedTable<'_>],
|
||||
indexes: &[ExpectedIndex<'_>],
|
||||
) -> bool {
|
||||
sqlite_migration::matches_fingerprint(snapshot, tables, indexes)
|
||||
}
|
||||
|
||||
const GLOSSARY_TERM_COLUMNS: [ExpectedColumn<'static>; 18] = [
|
||||
ExpectedColumn {
|
||||
name: "term_id",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: true,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "source_term",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "aliases_json",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "recommended_translation",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "allowed_translations_json",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "source_language",
|
||||
data_type: "TEXT",
|
||||
not_null: false,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "target_language",
|
||||
data_type: "TEXT",
|
||||
not_null: false,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "category",
|
||||
data_type: "TEXT",
|
||||
not_null: false,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "priority",
|
||||
data_type: "INTEGER",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "scope_json",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "review_status",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "source_kind",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "source_ref",
|
||||
data_type: "TEXT",
|
||||
not_null: false,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "source_author",
|
||||
data_type: "TEXT",
|
||||
not_null: false,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "source_note",
|
||||
data_type: "TEXT",
|
||||
not_null: false,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "source_observed_unix_seconds",
|
||||
data_type: "INTEGER",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "created_unix_seconds",
|
||||
data_type: "INTEGER",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "updated_unix_seconds",
|
||||
data_type: "INTEGER",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
];
|
||||
|
||||
const GLOSSARY_HISTORY_COLUMNS: [ExpectedColumn<'static>; 9] = [
|
||||
ExpectedColumn {
|
||||
name: "history_id",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: true,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "term_id",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "action",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "reviewer",
|
||||
data_type: "TEXT",
|
||||
not_null: false,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "reason",
|
||||
data_type: "TEXT",
|
||||
not_null: false,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "source_json",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "review_status",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "snapshot_json",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "observed_unix_seconds",
|
||||
data_type: "INTEGER",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
];
|
||||
|
||||
const GLOSSARY_DELETION_COLUMNS: [ExpectedColumn<'static>; 8] = [
|
||||
ExpectedColumn {
|
||||
name: "deletion_id",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: true,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "term_id",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "reviewer",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "reason",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "source_json",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "snapshot_json",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "history_json",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "observed_unix_seconds",
|
||||
data_type: "INTEGER",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
];
|
||||
|
||||
async fn create_glossary_schema(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
fail_after_step: Option<usize>,
|
||||
) -> Result<()> {
|
||||
let steps = [
|
||||
"CREATE TABLE schema_migrations (
|
||||
component TEXT PRIMARY KEY NOT NULL,
|
||||
version INTEGER NOT NULL CHECK(version >= 1)
|
||||
)",
|
||||
"CREATE TABLE glossary_terms (
|
||||
term_id TEXT PRIMARY KEY NOT NULL,
|
||||
source_term TEXT NOT NULL,
|
||||
aliases_json TEXT NOT NULL,
|
||||
recommended_translation TEXT NOT NULL,
|
||||
allowed_translations_json TEXT NOT NULL,
|
||||
source_language TEXT,
|
||||
target_language TEXT,
|
||||
category TEXT,
|
||||
priority INTEGER NOT NULL,
|
||||
scope_json TEXT NOT NULL,
|
||||
review_status TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL,
|
||||
source_ref TEXT,
|
||||
source_author TEXT,
|
||||
source_note TEXT,
|
||||
source_observed_unix_seconds INTEGER NOT NULL,
|
||||
created_unix_seconds INTEGER NOT NULL,
|
||||
updated_unix_seconds INTEGER NOT NULL,
|
||||
CHECK(length(term_id) > 0),
|
||||
CHECK(length(source_term) > 0),
|
||||
CHECK(length(recommended_translation) > 0),
|
||||
CHECK(review_status IN ('draft', 'approved', 'deprecated', 'rejected')),
|
||||
CHECK(source_kind IN ('manual', 'imported'))
|
||||
)",
|
||||
"CREATE TABLE glossary_term_history (
|
||||
history_id TEXT PRIMARY KEY NOT NULL,
|
||||
term_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
reviewer TEXT,
|
||||
reason TEXT,
|
||||
source_json TEXT NOT NULL,
|
||||
review_status TEXT NOT NULL,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
observed_unix_seconds INTEGER NOT NULL,
|
||||
FOREIGN KEY(term_id) REFERENCES glossary_terms(term_id)
|
||||
)",
|
||||
"CREATE TABLE glossary_term_deletions (
|
||||
deletion_id TEXT PRIMARY KEY NOT NULL,
|
||||
term_id TEXT NOT NULL,
|
||||
reviewer TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
source_json TEXT NOT NULL,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
history_json TEXT NOT NULL,
|
||||
observed_unix_seconds INTEGER NOT NULL
|
||||
)",
|
||||
"CREATE INDEX idx_glossary_status
|
||||
ON glossary_terms(review_status, priority DESC, term_id)",
|
||||
"CREATE INDEX idx_glossary_source_term
|
||||
ON glossary_terms(source_term)",
|
||||
];
|
||||
for (index, statement) in steps.iter().enumerate() {
|
||||
sqlx::query(statement)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
if fail_after_step == Some(index + 1) {
|
||||
return Err(Error::Other(anyhow::anyhow!(
|
||||
"Glossary migration failed after step {}",
|
||||
index + 1
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_json<T: DeserializeOwned>(value: String) -> Result<T> {
|
||||
serde_json::from_str(&value).map_err(|error| Error::Serialization(error.to_string()))
|
||||
}
|
||||
@@ -769,35 +1188,10 @@ fn db_error(error: sqlx::Error) -> Error {
|
||||
Error::Other(error.into())
|
||||
}
|
||||
|
||||
async fn ensure_column(
|
||||
pool: &SqlitePool,
|
||||
table: &str,
|
||||
column: &str,
|
||||
definition: &str,
|
||||
) -> Result<()> {
|
||||
let columns = sqlx::query(&format!("PRAGMA table_info({table})"))
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
let exists = columns.iter().any(|row| {
|
||||
row.try_get::<String, _>("name")
|
||||
.map(|name| name == column)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if !exists {
|
||||
sqlx::query(&format!(
|
||||
"ALTER TABLE {table} ADD COLUMN {column} {definition}"
|
||||
))
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn draft(status: GlossaryReviewStatus) -> GlossaryTermDraft {
|
||||
@@ -825,6 +1219,158 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
async fn raw_repository(
|
||||
path: &std::path::Path,
|
||||
create_if_missing: bool,
|
||||
) -> SqliteGlossaryRepository {
|
||||
let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display()))
|
||||
.unwrap()
|
||||
.create_if_missing(create_if_missing);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.unwrap();
|
||||
SqliteGlossaryRepository { pool }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_glossary_preserves_data_across_reopen_and_missing_row() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let path = temp.path().join(GLOSSARY_REPOSITORY_FILE);
|
||||
let repository = SqliteGlossaryRepository::new(&path).await.unwrap();
|
||||
repository
|
||||
.add(draft(GlossaryReviewStatus::Draft))
|
||||
.await
|
||||
.unwrap();
|
||||
repository
|
||||
.review(
|
||||
"term-sensei",
|
||||
GlossaryReviewStatus::Approved,
|
||||
"reviewer",
|
||||
Some("accepted".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("DROP TABLE schema_migrations")
|
||||
.execute(&repository.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
repository.pool.close().await;
|
||||
|
||||
let reopened = SqliteGlossaryRepository::open(&path).await.unwrap();
|
||||
let term = reopened.find("term-sensei").await.unwrap();
|
||||
assert_eq!(term.definition.recommended_translation, "老师");
|
||||
assert_eq!(term.history.len(), 2);
|
||||
assert_eq!(term.history[1].action, "approved");
|
||||
assert_eq!(
|
||||
reopened.summary().await.unwrap().schema_version,
|
||||
GLOSSARY_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_glossary_future_schema_is_read_only_failure() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let path = temp.path().join(GLOSSARY_REPOSITORY_FILE);
|
||||
let repository = SqliteGlossaryRepository::new(&path).await.unwrap();
|
||||
sqlx::query("UPDATE schema_migrations SET version = ?2 WHERE component = ?1")
|
||||
.bind(GLOSSARY_SCHEMA_COMPONENT)
|
||||
.bind(i64::from(GLOSSARY_SCHEMA_VERSION) + 1)
|
||||
.execute(&repository.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
repository.pool.close().await;
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let wal_path = std::path::PathBuf::from(format!("{}-wal", path.display()));
|
||||
let shm_path = std::path::PathBuf::from(format!("{}-shm", path.display()));
|
||||
let wal_before = std::fs::read(&wal_path).ok();
|
||||
let shm_before = std::fs::read(&shm_path).ok();
|
||||
let error = SqliteGlossaryRepository::new(&path).await.unwrap_err();
|
||||
assert!(error.to_string().contains("不支持的 Glossary schema"));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
assert_eq!(std::fs::read(&wal_path).ok(), wal_before);
|
||||
assert_eq!(std::fs::read(&shm_path).ok(), shm_before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_glossary_unknown_schema_fails_closed() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let path = temp.path().join(GLOSSARY_REPOSITORY_FILE);
|
||||
let repository = raw_repository(&path, true).await;
|
||||
sqlx::query(
|
||||
"CREATE TABLE schema_migrations (
|
||||
component TEXT PRIMARY KEY NOT NULL,
|
||||
version INTEGER NOT NULL CHECK(version >= 1)
|
||||
)",
|
||||
)
|
||||
.execute(&repository.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO schema_migrations(component, version) VALUES (?1, 1)")
|
||||
.bind(GLOSSARY_SCHEMA_COMPONENT)
|
||||
.execute(&repository.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("CREATE TABLE glossary_terms (term_id TEXT PRIMARY KEY)")
|
||||
.execute(&repository.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
repository.pool.close().await;
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let error = SqliteGlossaryRepository::open(&path).await.unwrap_err();
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("schema version 与实际结构不一致"));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_glossary_failed_new_schema_rolls_back_and_retries() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let path = temp.path().join(GLOSSARY_REPOSITORY_FILE);
|
||||
let repository = raw_repository(&path, true).await;
|
||||
assert!(repository.init_schema_with_test_failure(3).await.is_err());
|
||||
let table_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'")
|
||||
.fetch_one(&repository.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(table_count, 0);
|
||||
repository.init_schema().await.unwrap();
|
||||
let version: i64 =
|
||||
sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1")
|
||||
.bind(GLOSSARY_SCHEMA_COMPONENT)
|
||||
.fetch_one(&repository.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(version, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_glossary_concurrent_new_open_has_one_current_schema() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let path = temp.path().join(GLOSSARY_REPOSITORY_FILE);
|
||||
let (left, right) = tokio::join!(
|
||||
SqliteGlossaryRepository::new(&path),
|
||||
SqliteGlossaryRepository::new(&path)
|
||||
);
|
||||
assert!(left.is_ok(), "left open failed: {left:?}");
|
||||
assert!(right.is_ok(), "right open failed: {right:?}");
|
||||
let repository = left.unwrap();
|
||||
drop(right);
|
||||
assert_eq!(
|
||||
repository.summary().await.unwrap().schema_version,
|
||||
GLOSSARY_SCHEMA_VERSION
|
||||
);
|
||||
repository.pool.close().await;
|
||||
let reopened = SqliteGlossaryRepository::open(&path).await.unwrap();
|
||||
assert_eq!(
|
||||
reopened.summary().await.unwrap().schema_version,
|
||||
GLOSSARY_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_glossary_preserves_history_and_only_approved_terms_match() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user