mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 12:14:56 +08:00
2015 lines
74 KiB
Rust
2015 lines
74 KiB
Rust
//! Project-level Glossary SQLite persistence schema V2 repository.
|
||
|
||
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,
|
||
GlossaryReviewStatus, GlossarySourceKind, GlossarySourceRecord, GlossarySummary, GlossaryTerm,
|
||
GlossaryTermDraft, GlossaryTermSnapshot, TranslationMemoryContext,
|
||
};
|
||
use bat_core::repositories::GlossaryRepository;
|
||
use bat_core::{Error, Result};
|
||
use serde::de::DeserializeOwned;
|
||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode};
|
||
use sqlx::{Row, SqlitePool};
|
||
use std::path::{Path, PathBuf};
|
||
use std::str::FromStr;
|
||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||
|
||
/// Glossary SQLite schema version.
|
||
pub const GLOSSARY_SCHEMA_VERSION: u32 = 2;
|
||
/// Schema migration component.
|
||
pub const GLOSSARY_SCHEMA_COMPONENT: &str = "glossary";
|
||
/// Default project-level glossary file.
|
||
pub const GLOSSARY_REPOSITORY_FILE: &str = "glossary.sqlite";
|
||
|
||
/// SQLite-backed project Glossary repository.
|
||
#[derive(Debug, Clone)]
|
||
pub struct SqliteGlossaryRepository {
|
||
pool: SqlitePool,
|
||
}
|
||
|
||
impl SqliteGlossaryRepository {
|
||
/// Creates or opens a glossary database.
|
||
pub async fn new(path: impl AsRef<Path>) -> Result<Self> {
|
||
Self::open_with(path.as_ref(), true).await
|
||
}
|
||
|
||
/// Opens an existing glossary database without creating it.
|
||
pub async fn open(path: impl AsRef<Path>) -> Result<Self> {
|
||
Self::open_with(path.as_ref(), false).await
|
||
}
|
||
|
||
/// Returns the project-level glossary path for an official release root.
|
||
pub fn repository_path(resource_root: &Path) -> PathBuf {
|
||
if resource_root
|
||
.parent()
|
||
.and_then(Path::file_name)
|
||
.is_some_and(|name| name == "versions")
|
||
{
|
||
if let Some(output_root) = resource_root.parent().and_then(Path::parent) {
|
||
return output_root.join(GLOSSARY_REPOSITORY_FILE);
|
||
}
|
||
}
|
||
resource_root.join(GLOSSARY_REPOSITORY_FILE)
|
||
}
|
||
|
||
async fn open_with(path: &Path, create_if_missing: bool) -> Result<Self> {
|
||
let absolute = lexical_absolute(path).map_err(Error::InvalidArgument)?;
|
||
let parent = absolute.parent().ok_or_else(|| {
|
||
Error::InvalidArgument(format!("Glossary 数据库缺少父目录:{}", absolute.display()))
|
||
})?;
|
||
ensure_safe_directory_path(parent, "Glossary 数据库").map_err(Error::InvalidArgument)?;
|
||
if create_if_missing {
|
||
tokio::fs::create_dir_all(parent).await?;
|
||
ensure_safe_directory_path(parent, "Glossary 数据库")
|
||
.map_err(Error::InvalidArgument)?;
|
||
} else if !absolute.is_file() {
|
||
return Err(Error::NotFound(absolute.display().to_string()));
|
||
}
|
||
if let Ok(metadata) = std::fs::symlink_metadata(&absolute) {
|
||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||
return Err(Error::InvalidArgument(format!(
|
||
"Glossary 数据库必须是普通文件:{}",
|
||
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 = sqlite_migration::connect_writable_pool(options)
|
||
.await
|
||
.map_err(db_error)?;
|
||
if create_if_missing {
|
||
set_file_mode(&absolute, STATE_FILE_MODE, "Glossary 数据库")
|
||
.map_err(Error::InvalidArgument)?;
|
||
}
|
||
let repository = Self { pool };
|
||
repository.init_schema().await?;
|
||
Ok(repository)
|
||
}
|
||
|
||
async fn init_schema(&self) -> Result<()> {
|
||
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)?;
|
||
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::HistoricalV1Original => {
|
||
if !matches_glossary_v1_original_fingerprint(&snapshot)
|
||
&& !matches_glossary_v1_original_component_fingerprint(&snapshot)
|
||
{
|
||
return Err(invalid_glossary_schema(
|
||
"事务内的 V1-A fingerprint 已发生变化".to_string(),
|
||
));
|
||
}
|
||
if !snapshot
|
||
.tables
|
||
.contains_key(sqlite_migration::SCHEMA_MIGRATIONS_TABLE)
|
||
{
|
||
sqlite_migration::create_schema_migrations_table(transaction)
|
||
.await
|
||
.map_err(db_error)?;
|
||
}
|
||
create_glossary_deletions_table(transaction).await?;
|
||
if fail_after_step == Some(1) {
|
||
return Err(Error::Other(anyhow::anyhow!(
|
||
"Glossary V1-A migration failed after deletion table creation"
|
||
)));
|
||
}
|
||
let migrated_snapshot = sqlite_migration::snapshot_connection(
|
||
transaction.as_mut(),
|
||
GLOSSARY_SCHEMA_COMPONENT,
|
||
)
|
||
.await
|
||
.map_err(db_error)?;
|
||
if !matches_glossary_v2_fingerprint(&migrated_snapshot) {
|
||
return Err(invalid_glossary_schema(
|
||
"V1-A migration 未达到 V2 fingerprint".to_string(),
|
||
));
|
||
}
|
||
sqlite_migration::write_component_version(
|
||
transaction,
|
||
GLOSSARY_SCHEMA_COMPONENT,
|
||
GLOSSARY_SCHEMA_VERSION,
|
||
)
|
||
.await
|
||
.map_err(db_error)?;
|
||
}
|
||
GlossarySchemaState::HistoricalV1DeletionDrift => {
|
||
if !matches_glossary_v2_component_fingerprint(&snapshot)
|
||
&& !matches_glossary_v2_fingerprint(&snapshot)
|
||
{
|
||
return Err(invalid_glossary_schema(
|
||
"事务内的 V1-B drift fingerprint 已发生变化".to_string(),
|
||
));
|
||
}
|
||
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::V2 => {
|
||
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)?;
|
||
}
|
||
}
|
||
}
|
||
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_v2_fingerprint(&final_snapshot)
|
||
{
|
||
return Err(invalid_glossary_schema(
|
||
"migration 结果与当前 schema fingerprint 不一致".to_string(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Returns one term with complete history.
|
||
pub async fn find(&self, term_id: &str) -> Result<GlossaryTerm> {
|
||
let row = sqlx::query(
|
||
"SELECT term_id, source_term, aliases_json, recommended_translation,
|
||
allowed_translations_json, source_language, target_language, category,
|
||
priority, scope_json, review_status, source_kind, source_ref,
|
||
source_author, source_note, source_observed_unix_seconds,
|
||
created_unix_seconds, updated_unix_seconds
|
||
FROM glossary_terms WHERE term_id = ?1",
|
||
)
|
||
.bind(term_id)
|
||
.fetch_optional(&self.pool)
|
||
.await
|
||
.map_err(db_error)?
|
||
.ok_or_else(|| Error::NotFound(term_id.to_string()))?;
|
||
let mut term = row_to_term(row)?;
|
||
let history_rows = sqlx::query(
|
||
"SELECT history_id, action, reviewer, reason, source_json, review_status,
|
||
snapshot_json, observed_unix_seconds
|
||
FROM glossary_term_history WHERE term_id = ?1
|
||
ORDER BY observed_unix_seconds ASC, history_id ASC",
|
||
)
|
||
.bind(term_id)
|
||
.fetch_all(&self.pool)
|
||
.await
|
||
.map_err(db_error)?;
|
||
term.history = history_rows
|
||
.into_iter()
|
||
.map(row_to_history)
|
||
.collect::<Result<Vec<_>>>()?;
|
||
Ok(term)
|
||
}
|
||
|
||
/// Queries terms, including non-approved terms for review.
|
||
pub async fn query(
|
||
&self,
|
||
source_text: Option<&str>,
|
||
category: Option<&str>,
|
||
review_status: Option<GlossaryReviewStatus>,
|
||
limit: usize,
|
||
) -> Result<Vec<GlossaryTerm>> {
|
||
if !(1..=1000).contains(&limit) {
|
||
return Err(Error::InvalidArgument(
|
||
"Glossary query limit 必须在 1..=1000 范围内".to_string(),
|
||
));
|
||
}
|
||
let candidates = self.load_terms(category, review_status).await?;
|
||
let mut terms = Vec::new();
|
||
for term in candidates {
|
||
if source_text.is_none_or(|source| {
|
||
let mut spellings = vec![term.definition.source_term.as_str()];
|
||
spellings.extend(term.definition.aliases.iter().map(String::as_str));
|
||
spellings
|
||
.into_iter()
|
||
.filter(|spelling| !spelling.is_empty())
|
||
.any(|spelling| source.contains(spelling))
|
||
}) {
|
||
terms.push(term);
|
||
if terms.len() >= limit {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
Ok(terms)
|
||
}
|
||
|
||
async fn load_terms(
|
||
&self,
|
||
category: Option<&str>,
|
||
review_status: Option<GlossaryReviewStatus>,
|
||
) -> Result<Vec<GlossaryTerm>> {
|
||
let rows = sqlx::query(
|
||
"SELECT term_id FROM glossary_terms
|
||
WHERE (?1 IS NULL OR category = ?1)
|
||
AND (?2 IS NULL OR review_status = ?2)
|
||
ORDER BY priority DESC, term_id ASC",
|
||
)
|
||
.bind(category)
|
||
.bind(review_status.map(|value| value.as_str()))
|
||
.fetch_all(&self.pool)
|
||
.await
|
||
.map_err(db_error)?;
|
||
let mut terms = Vec::new();
|
||
for row in rows {
|
||
let term_id: String = row.try_get("term_id").map_err(db_error)?;
|
||
terms.push(self.find(&term_id).await?);
|
||
}
|
||
Ok(terms)
|
||
}
|
||
|
||
/// Returns review-state counts.
|
||
pub async fn summary(&self) -> Result<GlossarySummary> {
|
||
let row = sqlx::query(
|
||
"SELECT COUNT(*) AS term_count,
|
||
SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count,
|
||
SUM(CASE WHEN review_status = 'draft' THEN 1 ELSE 0 END) AS draft_count,
|
||
SUM(CASE WHEN review_status = 'deprecated' THEN 1 ELSE 0 END) AS deprecated_count,
|
||
SUM(CASE WHEN review_status = 'rejected' THEN 1 ELSE 0 END) AS rejected_count
|
||
FROM glossary_terms",
|
||
)
|
||
.fetch_one(&self.pool)
|
||
.await
|
||
.map_err(db_error)?;
|
||
Ok(GlossarySummary {
|
||
schema_version: GLOSSARY_SCHEMA_VERSION,
|
||
term_count: row.try_get::<i64, _>("term_count").map_err(db_error)? as u64,
|
||
approved_count: row.try_get::<i64, _>("approved_count").map_err(db_error)? as u64,
|
||
draft_count: row.try_get::<i64, _>("draft_count").map_err(db_error)? as u64,
|
||
deprecated_count: row
|
||
.try_get::<i64, _>("deprecated_count")
|
||
.map_err(db_error)? as u64,
|
||
rejected_count: row.try_get::<i64, _>("rejected_count").map_err(db_error)? as u64,
|
||
})
|
||
}
|
||
|
||
/// Adds a term and records its source snapshot.
|
||
pub async fn add(&self, draft: GlossaryTermDraft) -> Result<GlossaryTerm> {
|
||
validate_glossary_draft(&draft)?;
|
||
if draft.review_status != GlossaryReviewStatus::Draft {
|
||
return Err(Error::InvalidArgument(
|
||
"Glossary add 只能创建 draft;请通过 review/approve 使术语生效".to_string(),
|
||
));
|
||
}
|
||
let now = draft.source.observed_unix_seconds;
|
||
let term = term_from_draft(&draft, now, now);
|
||
let mut transaction = self.pool.begin().await.map_err(db_error)?;
|
||
let existing: Option<String> =
|
||
sqlx::query_scalar("SELECT term_id FROM glossary_terms WHERE term_id = ?1")
|
||
.bind(&draft.term_id)
|
||
.fetch_optional(&mut *transaction)
|
||
.await
|
||
.map_err(db_error)?;
|
||
if existing.is_some() {
|
||
return Err(Error::InvalidArgument(format!(
|
||
"Glossary term_id 已存在:{}",
|
||
draft.term_id
|
||
)));
|
||
}
|
||
insert_term(&mut transaction, &term).await?;
|
||
insert_history(&mut transaction, &term, "created", None, None, now).await?;
|
||
transaction.commit().await.map_err(db_error)?;
|
||
Ok(term)
|
||
}
|
||
|
||
/// Replaces a term definition and records the previous source history.
|
||
pub async fn update(
|
||
&self,
|
||
draft: GlossaryTermDraft,
|
||
reviewer: &str,
|
||
reason: Option<String>,
|
||
) -> Result<GlossaryTerm> {
|
||
validate_glossary_draft(&draft)?;
|
||
if draft.review_status != GlossaryReviewStatus::Draft {
|
||
return Err(Error::InvalidArgument(
|
||
"Glossary update 只能写入 draft;修改 approved 术语后必须重新 approve".to_string(),
|
||
));
|
||
}
|
||
if reviewer.trim().is_empty() {
|
||
return Err(Error::InvalidArgument(
|
||
"Glossary update reviewer 不能为空".to_string(),
|
||
));
|
||
}
|
||
let current = self.find(&draft.term_id).await?;
|
||
let now = draft.source.observed_unix_seconds;
|
||
let term = term_from_draft(&draft, current.created_unix_seconds, now);
|
||
let mut transaction = self.pool.begin().await.map_err(db_error)?;
|
||
update_term(&mut transaction, &term).await?;
|
||
insert_history(
|
||
&mut transaction,
|
||
&term,
|
||
"updated",
|
||
Some(reviewer.trim()),
|
||
reason.as_deref(),
|
||
now,
|
||
)
|
||
.await?;
|
||
transaction.commit().await.map_err(db_error)?;
|
||
self.find(&draft.term_id).await
|
||
}
|
||
|
||
/// Permanently removes a term and its stored history after explicit review.
|
||
pub async fn delete(
|
||
&self,
|
||
term_id: &str,
|
||
reviewer: &str,
|
||
reason: &str,
|
||
) -> Result<GlossaryTerm> {
|
||
if reviewer.trim().is_empty() {
|
||
return Err(Error::InvalidArgument(
|
||
"Glossary delete reviewer 不能为空".to_string(),
|
||
));
|
||
}
|
||
if reason.trim().is_empty() {
|
||
return Err(Error::InvalidArgument(
|
||
"Glossary delete reason 不能为空".to_string(),
|
||
));
|
||
}
|
||
let term = self.find(term_id).await?;
|
||
let mut transaction = self.pool.begin().await.map_err(db_error)?;
|
||
let source_json = json(&term.source)?;
|
||
let snapshot_json = json(&term.definition)?;
|
||
let history_json = json(&term.history)?;
|
||
let observed = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_secs();
|
||
let mut deletion_hasher = blake3::Hasher::new();
|
||
for value in [term_id, reviewer.trim(), reason.trim()] {
|
||
deletion_hasher.update(value.as_bytes());
|
||
deletion_hasher.update(&[0]);
|
||
}
|
||
deletion_hasher.update(&observed.to_le_bytes());
|
||
deletion_hasher.update(
|
||
&SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_nanos()
|
||
.to_le_bytes(),
|
||
);
|
||
sqlx::query(
|
||
"INSERT INTO glossary_term_deletions (
|
||
deletion_id, term_id, reviewer, reason, source_json,
|
||
snapshot_json, history_json, observed_unix_seconds
|
||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||
)
|
||
.bind(format!("gld-{}", deletion_hasher.finalize().to_hex()))
|
||
.bind(term_id)
|
||
.bind(reviewer.trim())
|
||
.bind(reason.trim())
|
||
.bind(source_json)
|
||
.bind(snapshot_json)
|
||
.bind(history_json)
|
||
.bind(i64::try_from(observed).unwrap_or(i64::MAX))
|
||
.execute(&mut *transaction)
|
||
.await
|
||
.map_err(db_error)?;
|
||
sqlx::query("DELETE FROM glossary_term_history WHERE term_id = ?1")
|
||
.bind(term_id)
|
||
.execute(&mut *transaction)
|
||
.await
|
||
.map_err(db_error)?;
|
||
sqlx::query("DELETE FROM glossary_terms WHERE term_id = ?1")
|
||
.bind(term_id)
|
||
.execute(&mut *transaction)
|
||
.await
|
||
.map_err(db_error)?;
|
||
transaction.commit().await.map_err(db_error)?;
|
||
Ok(term)
|
||
}
|
||
|
||
/// Changes review state and records a source/review history entry.
|
||
pub async fn review(
|
||
&self,
|
||
term_id: &str,
|
||
status: GlossaryReviewStatus,
|
||
reviewer: &str,
|
||
reason: Option<String>,
|
||
) -> Result<GlossaryTerm> {
|
||
if reviewer.trim().is_empty() {
|
||
return Err(Error::InvalidArgument(
|
||
"Glossary reviewer 不能为空".to_string(),
|
||
));
|
||
}
|
||
if !matches!(
|
||
status,
|
||
GlossaryReviewStatus::Approved
|
||
| GlossaryReviewStatus::Deprecated
|
||
| GlossaryReviewStatus::Rejected
|
||
) {
|
||
return Err(Error::InvalidArgument(
|
||
"Glossary review 只允许 approved、deprecated 或 rejected".to_string(),
|
||
));
|
||
}
|
||
let mut term = self.find(term_id).await?;
|
||
let now = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_secs();
|
||
term.review_status = status;
|
||
term.updated_unix_seconds = now;
|
||
let mut transaction = self.pool.begin().await.map_err(db_error)?;
|
||
sqlx::query(
|
||
"UPDATE glossary_terms SET review_status = ?2, updated_unix_seconds = ?3
|
||
WHERE term_id = ?1",
|
||
)
|
||
.bind(term_id)
|
||
.bind(status.as_str())
|
||
.bind(i64::try_from(now).unwrap_or(i64::MAX))
|
||
.execute(&mut *transaction)
|
||
.await
|
||
.map_err(db_error)?;
|
||
insert_history(
|
||
&mut transaction,
|
||
&term,
|
||
status.as_str(),
|
||
Some(reviewer.trim()),
|
||
reason.as_deref(),
|
||
now,
|
||
)
|
||
.await?;
|
||
transaction.commit().await.map_err(db_error)?;
|
||
self.find(term_id).await
|
||
}
|
||
|
||
/// Evaluates approved terms for one TextUnit.
|
||
pub async fn diagnose(
|
||
&self,
|
||
source_text: &str,
|
||
context: &TranslationMemoryContext,
|
||
) -> Result<GlossaryEvaluation> {
|
||
let terms = self
|
||
.load_terms(None, Some(GlossaryReviewStatus::Approved))
|
||
.await?;
|
||
Ok(evaluate_glossary(&terms, source_text, context))
|
||
}
|
||
}
|
||
|
||
#[async_trait]
|
||
impl GlossaryRepository for SqliteGlossaryRepository {
|
||
async fn evaluate(
|
||
&self,
|
||
source_text: &str,
|
||
context: &TranslationMemoryContext,
|
||
) -> Result<GlossaryEvaluation> {
|
||
self.diagnose(source_text, context).await
|
||
}
|
||
}
|
||
|
||
fn term_from_draft(draft: &GlossaryTermDraft, created: u64, updated: u64) -> GlossaryTerm {
|
||
GlossaryTerm {
|
||
term_id: draft.term_id.clone(),
|
||
definition: draft.definition.clone(),
|
||
review_status: draft.review_status,
|
||
source: draft.source.clone(),
|
||
history: Vec::new(),
|
||
created_unix_seconds: created,
|
||
updated_unix_seconds: updated,
|
||
}
|
||
}
|
||
|
||
async fn insert_term(
|
||
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||
term: &GlossaryTerm,
|
||
) -> Result<()> {
|
||
sqlx::query(
|
||
"INSERT INTO glossary_terms (
|
||
term_id, source_term, aliases_json, recommended_translation,
|
||
allowed_translations_json, source_language, target_language, category,
|
||
priority, scope_json, review_status, source_kind, source_ref,
|
||
source_author, source_note, source_observed_unix_seconds,
|
||
created_unix_seconds, updated_unix_seconds
|
||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
|
||
)
|
||
.bind(&term.term_id)
|
||
.bind(&term.definition.source_term)
|
||
.bind(json(&term.definition.aliases)?)
|
||
.bind(&term.definition.recommended_translation)
|
||
.bind(json(&term.definition.allowed_translations)?)
|
||
.bind(&term.definition.source_language)
|
||
.bind(&term.definition.target_language)
|
||
.bind(&term.definition.category)
|
||
.bind(term.definition.priority)
|
||
.bind(json(&term.definition.scope)?)
|
||
.bind(term.review_status.as_str())
|
||
.bind(term.source.source_kind.as_str())
|
||
.bind(&term.source.source_ref)
|
||
.bind(&term.source.source_author)
|
||
.bind(&term.source.source_note)
|
||
.bind(i64::try_from(term.source.observed_unix_seconds).unwrap_or(i64::MAX))
|
||
.bind(i64::try_from(term.created_unix_seconds).unwrap_or(i64::MAX))
|
||
.bind(i64::try_from(term.updated_unix_seconds).unwrap_or(i64::MAX))
|
||
.execute(&mut **transaction)
|
||
.await
|
||
.map_err(db_error)?;
|
||
Ok(())
|
||
}
|
||
|
||
async fn update_term(
|
||
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||
term: &GlossaryTerm,
|
||
) -> Result<()> {
|
||
sqlx::query(
|
||
"UPDATE glossary_terms SET source_term = ?2, aliases_json = ?3,
|
||
recommended_translation = ?4, allowed_translations_json = ?5,
|
||
source_language = ?6, target_language = ?7, category = ?8,
|
||
priority = ?9, scope_json = ?10, review_status = ?11, source_kind = ?12,
|
||
source_ref = ?13, source_author = ?14, source_note = ?15,
|
||
source_observed_unix_seconds = ?16, updated_unix_seconds = ?17 WHERE term_id = ?1",
|
||
)
|
||
.bind(&term.term_id)
|
||
.bind(&term.definition.source_term)
|
||
.bind(json(&term.definition.aliases)?)
|
||
.bind(&term.definition.recommended_translation)
|
||
.bind(json(&term.definition.allowed_translations)?)
|
||
.bind(&term.definition.source_language)
|
||
.bind(&term.definition.target_language)
|
||
.bind(&term.definition.category)
|
||
.bind(term.definition.priority)
|
||
.bind(json(&term.definition.scope)?)
|
||
.bind(term.review_status.as_str())
|
||
.bind(term.source.source_kind.as_str())
|
||
.bind(&term.source.source_ref)
|
||
.bind(&term.source.source_author)
|
||
.bind(&term.source.source_note)
|
||
.bind(i64::try_from(term.source.observed_unix_seconds).unwrap_or(i64::MAX))
|
||
.bind(i64::try_from(term.updated_unix_seconds).unwrap_or(i64::MAX))
|
||
.execute(&mut **transaction)
|
||
.await
|
||
.map_err(db_error)?;
|
||
Ok(())
|
||
}
|
||
|
||
async fn insert_history(
|
||
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||
term: &GlossaryTerm,
|
||
action: &str,
|
||
reviewer: Option<&str>,
|
||
reason: Option<&str>,
|
||
observed: u64,
|
||
) -> Result<()> {
|
||
let source_json = json(&term.source)?;
|
||
let snapshot_json = json(&term.definition)?;
|
||
let mut history_hasher = blake3::Hasher::new();
|
||
for value in [
|
||
term.term_id.as_str(),
|
||
action,
|
||
reviewer.unwrap_or_default(),
|
||
reason.unwrap_or_default(),
|
||
source_json.as_str(),
|
||
snapshot_json.as_str(),
|
||
] {
|
||
history_hasher.update(value.as_bytes());
|
||
history_hasher.update(&[0]);
|
||
}
|
||
history_hasher.update(&observed.to_le_bytes());
|
||
let nonce = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_nanos();
|
||
history_hasher.update(&nonce.to_le_bytes());
|
||
let history_id = format!("glh-{}", history_hasher.finalize().to_hex());
|
||
sqlx::query(
|
||
"INSERT INTO glossary_term_history (
|
||
history_id, term_id, action, reviewer, reason, source_json,
|
||
review_status, snapshot_json, observed_unix_seconds
|
||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||
)
|
||
.bind(history_id)
|
||
.bind(&term.term_id)
|
||
.bind(action)
|
||
.bind(reviewer)
|
||
.bind(reason.filter(|value| !value.trim().is_empty()))
|
||
.bind(source_json)
|
||
.bind(term.review_status.as_str())
|
||
.bind(snapshot_json)
|
||
.bind(i64::try_from(observed).unwrap_or(i64::MAX))
|
||
.execute(&mut **transaction)
|
||
.await
|
||
.map_err(db_error)?;
|
||
Ok(())
|
||
}
|
||
|
||
fn row_to_term(row: sqlx::sqlite::SqliteRow) -> Result<GlossaryTerm> {
|
||
Ok(GlossaryTerm {
|
||
term_id: row.try_get("term_id").map_err(db_error)?,
|
||
definition: GlossaryTermSnapshot {
|
||
source_term: row.try_get("source_term").map_err(db_error)?,
|
||
aliases: parse_json(row.try_get("aliases_json").map_err(db_error)?)?,
|
||
recommended_translation: row.try_get("recommended_translation").map_err(db_error)?,
|
||
allowed_translations: parse_json(
|
||
row.try_get("allowed_translations_json").map_err(db_error)?,
|
||
)?,
|
||
source_language: row.try_get("source_language").map_err(db_error)?,
|
||
target_language: row.try_get("target_language").map_err(db_error)?,
|
||
category: row.try_get("category").map_err(db_error)?,
|
||
priority: row.try_get("priority").map_err(db_error)?,
|
||
scope: parse_json(row.try_get("scope_json").map_err(db_error)?)?,
|
||
},
|
||
review_status: parse_review_status(
|
||
row.try_get::<String, _>("review_status")
|
||
.map_err(db_error)?
|
||
.as_str(),
|
||
)?,
|
||
source: GlossarySourceRecord {
|
||
source_kind: parse_source_kind(
|
||
row.try_get::<String, _>("source_kind")
|
||
.map_err(db_error)?
|
||
.as_str(),
|
||
)?,
|
||
source_ref: row.try_get("source_ref").map_err(db_error)?,
|
||
source_author: row.try_get("source_author").map_err(db_error)?,
|
||
source_note: row.try_get("source_note").map_err(db_error)?,
|
||
observed_unix_seconds: to_u64(
|
||
row.try_get("source_observed_unix_seconds")
|
||
.map_err(db_error)?,
|
||
"source",
|
||
)?,
|
||
},
|
||
history: Vec::new(),
|
||
created_unix_seconds: to_u64(
|
||
row.try_get("created_unix_seconds").map_err(db_error)?,
|
||
"created",
|
||
)?,
|
||
updated_unix_seconds: to_u64(
|
||
row.try_get("updated_unix_seconds").map_err(db_error)?,
|
||
"updated",
|
||
)?,
|
||
})
|
||
}
|
||
|
||
fn row_to_history(row: sqlx::sqlite::SqliteRow) -> Result<GlossaryHistoryRecord> {
|
||
Ok(GlossaryHistoryRecord {
|
||
history_id: row.try_get("history_id").map_err(db_error)?,
|
||
action: row.try_get("action").map_err(db_error)?,
|
||
reviewer: row.try_get("reviewer").map_err(db_error)?,
|
||
reason: row.try_get("reason").map_err(db_error)?,
|
||
source: parse_json(row.try_get("source_json").map_err(db_error)?)?,
|
||
review_status: parse_review_status(
|
||
row.try_get::<String, _>("review_status")
|
||
.map_err(db_error)?
|
||
.as_str(),
|
||
)?,
|
||
snapshot: parse_json(row.try_get("snapshot_json").map_err(db_error)?)?,
|
||
observed_unix_seconds: to_u64(
|
||
row.try_get("observed_unix_seconds").map_err(db_error)?,
|
||
"history",
|
||
)?,
|
||
})
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum GlossarySchemaState {
|
||
Empty,
|
||
HistoricalV1Original,
|
||
HistoricalV1DeletionDrift,
|
||
V2,
|
||
}
|
||
|
||
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}"
|
||
)));
|
||
}
|
||
}
|
||
|
||
let is_v1_original = matches_glossary_v1_original_fingerprint(snapshot)
|
||
|| (snapshot.component_version.is_none()
|
||
&& matches_glossary_v1_original_component_fingerprint(snapshot));
|
||
let is_v1_deletion_drift = matches_glossary_v2_fingerprint(snapshot)
|
||
|| (snapshot.component_version.is_none()
|
||
&& matches_glossary_v2_component_fingerprint(snapshot));
|
||
|
||
match snapshot.component_version {
|
||
Some(1) if is_v1_original => Ok(GlossarySchemaState::HistoricalV1Original),
|
||
Some(1) if is_v1_deletion_drift => {
|
||
Ok(GlossarySchemaState::HistoricalV1DeletionDrift)
|
||
}
|
||
Some(2) if is_v1_deletion_drift => Ok(GlossarySchemaState::V2),
|
||
None if is_v1_original => Ok(GlossarySchemaState::HistoricalV1Original),
|
||
None if is_v1_deletion_drift => Ok(GlossarySchemaState::HistoricalV1DeletionDrift),
|
||
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_v2_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,
|
||
},
|
||
];
|
||
matches_glossary_fingerprint_with_tables(snapshot, &tables, &GLOSSARY_INDEXES)
|
||
}
|
||
|
||
fn matches_glossary_v2_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,
|
||
},
|
||
];
|
||
matches_glossary_fingerprint_with_tables(snapshot, &tables, &GLOSSARY_INDEXES)
|
||
}
|
||
|
||
fn matches_glossary_v1_original_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,
|
||
},
|
||
];
|
||
matches_glossary_fingerprint_with_tables(snapshot, &tables, &GLOSSARY_INDEXES)
|
||
}
|
||
|
||
fn matches_glossary_v1_original_component_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool {
|
||
let tables = [
|
||
ExpectedTable {
|
||
name: "glossary_terms",
|
||
columns: &GLOSSARY_TERM_COLUMNS,
|
||
},
|
||
ExpectedTable {
|
||
name: "glossary_term_history",
|
||
columns: &GLOSSARY_HISTORY_COLUMNS,
|
||
},
|
||
];
|
||
matches_glossary_fingerprint_with_tables(snapshot, &tables, &GLOSSARY_INDEXES)
|
||
}
|
||
|
||
fn matches_glossary_fingerprint_with_tables(
|
||
snapshot: &SqliteSchemaSnapshot,
|
||
tables: &[ExpectedTable<'_>],
|
||
indexes: &[ExpectedIndex<'_>],
|
||
) -> bool {
|
||
sqlite_migration::matches_fingerprint(snapshot, tables, indexes)
|
||
}
|
||
|
||
const GLOSSARY_INDEXES: [ExpectedIndex<'static>; 2] = [
|
||
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"],
|
||
},
|
||
];
|
||
|
||
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_deletions_table(
|
||
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||
) -> Result<()> {
|
||
sqlx::query(
|
||
"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
|
||
)",
|
||
)
|
||
.execute(&mut **transaction)
|
||
.await
|
||
.map_err(db_error)?;
|
||
Ok(())
|
||
}
|
||
|
||
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()))
|
||
}
|
||
|
||
fn json<T: serde::Serialize>(value: &T) -> Result<String> {
|
||
serde_json::to_string(value).map_err(|error| Error::Serialization(error.to_string()))
|
||
}
|
||
|
||
fn parse_review_status(value: &str) -> Result<GlossaryReviewStatus> {
|
||
GlossaryReviewStatus::parse(value)
|
||
.ok_or_else(|| Error::Serialization(format!("未知 Glossary review status:{value}")))
|
||
}
|
||
|
||
fn parse_source_kind(value: &str) -> Result<GlossarySourceKind> {
|
||
GlossarySourceKind::parse(value)
|
||
.ok_or_else(|| Error::Serialization(format!("未知 Glossary source kind:{value}")))
|
||
}
|
||
|
||
fn to_u64(value: i64, label: &str) -> Result<u64> {
|
||
u64::try_from(value).map_err(|_| Error::Serialization(format!("Glossary {label} 时间无效")))
|
||
}
|
||
|
||
fn db_error(error: sqlx::Error) -> Error {
|
||
Error::Other(error.into())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use sqlx::sqlite::SqlitePoolOptions;
|
||
use std::collections::BTreeMap;
|
||
|
||
fn draft(status: GlossaryReviewStatus) -> GlossaryTermDraft {
|
||
GlossaryTermDraft {
|
||
term_id: "term-sensei".to_string(),
|
||
definition: GlossaryTermSnapshot {
|
||
source_term: "Sensei".to_string(),
|
||
aliases: vec!["Teacher".to_string()],
|
||
recommended_translation: "老师".to_string(),
|
||
allowed_translations: vec!["老师大人".to_string()],
|
||
source_language: Some("en".to_string()),
|
||
target_language: Some("zh-Hans".to_string()),
|
||
category: Some("person".to_string()),
|
||
priority: 10,
|
||
scope: BTreeMap::new(),
|
||
},
|
||
review_status: status,
|
||
source: GlossarySourceRecord {
|
||
source_kind: GlossarySourceKind::Manual,
|
||
source_ref: Some("test".to_string()),
|
||
source_author: Some("tester".to_string()),
|
||
source_note: None,
|
||
observed_unix_seconds: 1,
|
||
},
|
||
}
|
||
}
|
||
|
||
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 }
|
||
}
|
||
|
||
// Fixture derived from commit 94483ff: the original Glossary V1 had no
|
||
// deletion audit table.
|
||
async fn create_historical_glossary_v1_original(
|
||
path: &std::path::Path,
|
||
with_schema_migrations: bool,
|
||
with_component_version: bool,
|
||
) {
|
||
create_historical_glossary(path, false, with_schema_migrations, with_component_version)
|
||
.await;
|
||
}
|
||
|
||
// Fixture derived from the post-0275a890 schema: deletion auditing was
|
||
// added while the persisted component version incorrectly remained 1.
|
||
async fn create_historical_glossary_v1_deletion_drift(
|
||
path: &std::path::Path,
|
||
with_schema_migrations: bool,
|
||
with_component_version: bool,
|
||
) {
|
||
create_historical_glossary(path, true, with_schema_migrations, with_component_version)
|
||
.await;
|
||
}
|
||
|
||
async fn create_historical_glossary(
|
||
path: &std::path::Path,
|
||
with_deletions: bool,
|
||
with_schema_migrations: bool,
|
||
with_component_version: bool,
|
||
) {
|
||
let repository = raw_repository(path, true).await;
|
||
if with_schema_migrations {
|
||
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(
|
||
"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'))
|
||
)",
|
||
)
|
||
.execute(&repository.pool)
|
||
.await
|
||
.unwrap();
|
||
sqlx::query(
|
||
"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)
|
||
)",
|
||
)
|
||
.execute(&repository.pool)
|
||
.await
|
||
.unwrap();
|
||
if with_deletions {
|
||
sqlx::query(
|
||
"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
|
||
)",
|
||
)
|
||
.execute(&repository.pool)
|
||
.await
|
||
.unwrap();
|
||
}
|
||
sqlx::query(
|
||
"CREATE INDEX idx_glossary_status
|
||
ON glossary_terms(review_status, priority DESC, term_id)",
|
||
)
|
||
.execute(&repository.pool)
|
||
.await
|
||
.unwrap();
|
||
sqlx::query(
|
||
"CREATE INDEX idx_glossary_source_term
|
||
ON glossary_terms(source_term)",
|
||
)
|
||
.execute(&repository.pool)
|
||
.await
|
||
.unwrap();
|
||
sqlx::query(
|
||
"INSERT INTO glossary_terms (
|
||
term_id, source_term, aliases_json, recommended_translation,
|
||
allowed_translations_json, source_language, target_language, category,
|
||
priority, scope_json, review_status, source_kind, source_ref,
|
||
source_author, source_note, source_observed_unix_seconds,
|
||
created_unix_seconds, updated_unix_seconds
|
||
) VALUES (
|
||
'historical-term', 'Sensei', '[\"Teacher\",\"Master\"]', '老师',
|
||
'[\"老师大人\",\"老师\"]', 'en', 'zh-Hans', 'person',
|
||
10, '{\"destination\":\"Bundles/story.bundle\"}', 'approved', 'manual',
|
||
'historical-fixture', 'reviewer', 'legacy', 11, 12, 13
|
||
)",
|
||
)
|
||
.execute(&repository.pool)
|
||
.await
|
||
.unwrap();
|
||
sqlx::query(
|
||
"INSERT INTO glossary_term_history (
|
||
history_id, term_id, action, reviewer, reason, source_json,
|
||
review_status, snapshot_json, observed_unix_seconds
|
||
) VALUES
|
||
('history-created', 'historical-term', 'created', NULL, NULL,
|
||
'{\"source_kind\":\"manual\",\"source_ref\":\"historical-fixture\",\"source_author\":\"reviewer\",\"source_note\":\"legacy\",\"observed_unix_seconds\":11}', 'draft',
|
||
'{\"source_term\":\"Sensei\",\"aliases\":[\"Teacher\",\"Master\"],\"recommended_translation\":\"老师\",\"allowed_translations\":[\"老师大人\",\"老师\"],\"source_language\":\"en\",\"target_language\":\"zh-Hans\",\"category\":\"person\",\"priority\":10,\"scope\":{\"destination\":\"Bundles/story.bundle\"}}', 12),
|
||
('history-approved', 'historical-term', 'approved', 'reviewer',
|
||
'legacy approval', '{\"source_kind\":\"manual\",\"source_ref\":\"historical-fixture\",\"source_author\":\"reviewer\",\"source_note\":\"legacy\",\"observed_unix_seconds\":11}', 'approved',
|
||
'{\"source_term\":\"Sensei\",\"aliases\":[\"Teacher\",\"Master\"],\"recommended_translation\":\"老师\",\"allowed_translations\":[\"老师大人\",\"老师\"],\"source_language\":\"en\",\"target_language\":\"zh-Hans\",\"category\":\"person\",\"priority\":10,\"scope\":{\"destination\":\"Bundles/story.bundle\"}}', 13)",
|
||
)
|
||
.execute(&repository.pool)
|
||
.await
|
||
.unwrap();
|
||
if with_deletions {
|
||
sqlx::query(
|
||
"INSERT INTO glossary_term_deletions (
|
||
deletion_id, term_id, reviewer, reason, source_json,
|
||
snapshot_json, history_json, observed_unix_seconds
|
||
) VALUES (
|
||
'deletion-legacy-1', 'historical-term', 'deleter',
|
||
'legacy cleanup', '{\"source_kind\":\"manual\",\"source_ref\":\"historical-fixture\",\"source_author\":\"reviewer\",\"source_note\":\"legacy\",\"observed_unix_seconds\":11}',
|
||
'{\"source_term\":\"Sensei\",\"aliases\":[\"Teacher\",\"Master\"],\"recommended_translation\":\"老师\",\"allowed_translations\":[\"老师大人\",\"老师\"],\"source_language\":\"en\",\"target_language\":\"zh-Hans\",\"category\":\"person\",\"priority\":10,\"scope\":{\"destination\":\"Bundles/story.bundle\"}}',
|
||
'[{\"history_id\":\"history-approved\"}]', 14
|
||
)",
|
||
)
|
||
.execute(&repository.pool)
|
||
.await
|
||
.unwrap();
|
||
}
|
||
if with_schema_migrations && with_component_version {
|
||
sqlx::query(
|
||
"INSERT INTO schema_migrations(component, version)
|
||
VALUES (?1, 1)",
|
||
)
|
||
.bind(GLOSSARY_SCHEMA_COMPONENT)
|
||
.execute(&repository.pool)
|
||
.await
|
||
.unwrap();
|
||
}
|
||
repository.pool.close().await;
|
||
}
|
||
|
||
#[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_migrates_historical_v1_original_and_preserves_business_data() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let path = temp.path().join(GLOSSARY_REPOSITORY_FILE);
|
||
create_historical_glossary_v1_original(&path, true, true).await;
|
||
|
||
let repository = SqliteGlossaryRepository::open(&path).await.unwrap();
|
||
let term = repository.find("historical-term").await.unwrap();
|
||
assert_eq!(term.definition.aliases, vec!["Teacher", "Master"]);
|
||
assert_eq!(term.definition.recommended_translation, "老师");
|
||
assert_eq!(
|
||
term.definition.allowed_translations,
|
||
vec!["老师大人", "老师"]
|
||
);
|
||
assert_eq!(term.definition.priority, 10);
|
||
assert_eq!(
|
||
term.definition.scope.get("destination"),
|
||
Some(&"Bundles/story.bundle".to_string())
|
||
);
|
||
assert_eq!(
|
||
term.source.source_ref.as_deref(),
|
||
Some("historical-fixture")
|
||
);
|
||
assert_eq!(term.source.observed_unix_seconds, 11);
|
||
assert_eq!(term.created_unix_seconds, 12);
|
||
assert_eq!(term.updated_unix_seconds, 13);
|
||
assert_eq!(term.history.len(), 2);
|
||
assert_eq!(term.history[1].history_id, "history-approved");
|
||
|
||
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, 2);
|
||
let deletion_count: i64 =
|
||
sqlx::query_scalar("SELECT COUNT(*) FROM glossary_term_deletions")
|
||
.fetch_one(&repository.pool)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(deletion_count, 0);
|
||
assert_eq!(repository.summary().await.unwrap().schema_version, 2);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn sqlite_glossary_migrates_v1_original_without_component_row() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let path = temp.path().join(GLOSSARY_REPOSITORY_FILE);
|
||
create_historical_glossary_v1_original(&path, true, false).await;
|
||
|
||
let repository = SqliteGlossaryRepository::open(&path).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, 2);
|
||
assert_eq!(
|
||
repository
|
||
.find("historical-term")
|
||
.await
|
||
.unwrap()
|
||
.history
|
||
.len(),
|
||
2
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn sqlite_glossary_migrates_v1_original_without_schema_migrations() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let path = temp.path().join(GLOSSARY_REPOSITORY_FILE);
|
||
create_historical_glossary_v1_original(&path, false, false).await;
|
||
|
||
let repository = SqliteGlossaryRepository::open(&path).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, 2);
|
||
assert_eq!(
|
||
repository
|
||
.find("historical-term")
|
||
.await
|
||
.unwrap()
|
||
.history
|
||
.len(),
|
||
2
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn sqlite_glossary_migrates_historical_v1_deletion_drift_and_preserves_audit() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let path = temp.path().join(GLOSSARY_REPOSITORY_FILE);
|
||
create_historical_glossary_v1_deletion_drift(&path, true, true).await;
|
||
|
||
let repository = SqliteGlossaryRepository::open(&path).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, 2);
|
||
let row = sqlx::query(
|
||
"SELECT deletion_id, term_id, reviewer, reason, source_json,
|
||
snapshot_json, history_json, observed_unix_seconds
|
||
FROM glossary_term_deletions",
|
||
)
|
||
.fetch_one(&repository.pool)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(row.get::<String, _>("deletion_id"), "deletion-legacy-1");
|
||
assert_eq!(row.get::<String, _>("term_id"), "historical-term");
|
||
assert_eq!(row.get::<String, _>("reviewer"), "deleter");
|
||
assert_eq!(row.get::<String, _>("reason"), "legacy cleanup");
|
||
assert_eq!(
|
||
row.get::<String, _>("source_json"),
|
||
"{\"source_kind\":\"manual\",\"source_ref\":\"historical-fixture\",\"source_author\":\"reviewer\",\"source_note\":\"legacy\",\"observed_unix_seconds\":11}"
|
||
);
|
||
assert_eq!(
|
||
row.get::<String, _>("snapshot_json"),
|
||
"{\"source_term\":\"Sensei\",\"aliases\":[\"Teacher\",\"Master\"],\"recommended_translation\":\"老师\",\"allowed_translations\":[\"老师大人\",\"老师\"],\"source_language\":\"en\",\"target_language\":\"zh-Hans\",\"category\":\"person\",\"priority\":10,\"scope\":{\"destination\":\"Bundles/story.bundle\"}}"
|
||
);
|
||
assert_eq!(
|
||
row.get::<String, _>("history_json"),
|
||
"[{\"history_id\":\"history-approved\"}]"
|
||
);
|
||
assert_eq!(row.get::<i64, _>("observed_unix_seconds"), 14);
|
||
assert_eq!(
|
||
repository
|
||
.find("historical-term")
|
||
.await
|
||
.unwrap()
|
||
.history
|
||
.len(),
|
||
2
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn sqlite_glossary_migrates_deletion_drift_without_component_row_or_table() {
|
||
for with_schema_migrations in [true, false] {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let path = temp.path().join(GLOSSARY_REPOSITORY_FILE);
|
||
create_historical_glossary_v1_deletion_drift(&path, with_schema_migrations, false)
|
||
.await;
|
||
|
||
let repository = SqliteGlossaryRepository::open(&path).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, 2);
|
||
assert_eq!(
|
||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM glossary_term_deletions")
|
||
.fetch_one(&repository.pool)
|
||
.await
|
||
.unwrap(),
|
||
1
|
||
);
|
||
}
|
||
}
|
||
|
||
#[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_v2_malformed_schema_fails_closed() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let path = temp.path().join(GLOSSARY_REPOSITORY_FILE);
|
||
let repository = SqliteGlossaryRepository::new(&path).await.unwrap();
|
||
sqlx::query("DROP INDEX idx_glossary_status")
|
||
.execute(&repository.pool)
|
||
.await
|
||
.unwrap();
|
||
sqlx::query("UPDATE schema_migrations SET version = 2 WHERE component = ?1")
|
||
.bind(GLOSSARY_SCHEMA_COMPONENT)
|
||
.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_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, 2);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn sqlite_glossary_v1_original_migration_rolls_back_and_retries() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let path = temp.path().join(GLOSSARY_REPOSITORY_FILE);
|
||
create_historical_glossary_v1_original(&path, true, true).await;
|
||
let repository = raw_repository(&path, false).await;
|
||
|
||
assert!(repository.init_schema_with_test_failure(1).await.is_err());
|
||
assert_eq!(
|
||
sqlx::query_scalar::<_, i64>(
|
||
"SELECT COUNT(*) FROM sqlite_master
|
||
WHERE type = 'table' AND name = 'glossary_term_deletions'"
|
||
)
|
||
.fetch_one(&repository.pool)
|
||
.await
|
||
.unwrap(),
|
||
0
|
||
);
|
||
assert_eq!(
|
||
sqlx::query_scalar::<_, i64>(
|
||
"SELECT version FROM schema_migrations WHERE component = ?1"
|
||
)
|
||
.bind(GLOSSARY_SCHEMA_COMPONENT)
|
||
.fetch_one(&repository.pool)
|
||
.await
|
||
.unwrap(),
|
||
1
|
||
);
|
||
assert_eq!(
|
||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM glossary_terms")
|
||
.fetch_one(&repository.pool)
|
||
.await
|
||
.unwrap(),
|
||
1
|
||
);
|
||
assert_eq!(
|
||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM glossary_term_history")
|
||
.fetch_one(&repository.pool)
|
||
.await
|
||
.unwrap(),
|
||
2
|
||
);
|
||
|
||
repository.init_schema().await.unwrap();
|
||
assert_eq!(
|
||
sqlx::query_scalar::<_, i64>(
|
||
"SELECT version FROM schema_migrations WHERE component = ?1"
|
||
)
|
||
.bind(GLOSSARY_SCHEMA_COMPONENT)
|
||
.fetch_one(&repository.pool)
|
||
.await
|
||
.unwrap(),
|
||
2
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn sqlite_glossary_v2_reopen_is_idempotent() {
|
||
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();
|
||
let before_objects: Vec<(String, String, Option<String>)> = sqlx::query(
|
||
"SELECT type, name, sql FROM sqlite_master
|
||
WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name",
|
||
)
|
||
.fetch_all(&repository.pool)
|
||
.await
|
||
.unwrap()
|
||
.into_iter()
|
||
.map(|row| (row.get("type"), row.get("name"), row.get("sql")))
|
||
.collect();
|
||
repository.pool.close().await;
|
||
|
||
let reopened = SqliteGlossaryRepository::open(&path).await.unwrap();
|
||
let after_objects: Vec<(String, String, Option<String>)> = sqlx::query(
|
||
"SELECT type, name, sql FROM sqlite_master
|
||
WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name",
|
||
)
|
||
.fetch_all(&reopened.pool)
|
||
.await
|
||
.unwrap()
|
||
.into_iter()
|
||
.map(|row| (row.get("type"), row.get("name"), row.get("sql")))
|
||
.collect();
|
||
assert_eq!(before_objects, after_objects);
|
||
assert_eq!(
|
||
sqlx::query_scalar::<_, i64>(
|
||
"SELECT version FROM schema_migrations WHERE component = ?1"
|
||
)
|
||
.bind(GLOSSARY_SCHEMA_COMPONENT)
|
||
.fetch_one(&reopened.pool)
|
||
.await
|
||
.unwrap(),
|
||
2
|
||
);
|
||
assert_eq!(reopened.find("term-sensei").await.unwrap().history.len(), 1);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn sqlite_glossary_v1_original_concurrent_migration_is_consistent() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let path = temp.path().join(GLOSSARY_REPOSITORY_FILE);
|
||
create_historical_glossary_v1_original(&path, true, true).await;
|
||
let (left, right) = tokio::join!(
|
||
SqliteGlossaryRepository::open(&path),
|
||
SqliteGlossaryRepository::open(&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, 2);
|
||
repository.pool.close().await;
|
||
let reopened = SqliteGlossaryRepository::open(&path).await.unwrap();
|
||
assert_eq!(reopened.summary().await.unwrap().schema_version, 2);
|
||
assert_eq!(
|
||
reopened
|
||
.find("historical-term")
|
||
.await
|
||
.unwrap()
|
||
.history
|
||
.len(),
|
||
2
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn sqlite_glossary_preserves_history_and_only_approved_terms_match() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let repository = SqliteGlossaryRepository::new(temp.path().join("glossary.sqlite"))
|
||
.await
|
||
.unwrap();
|
||
assert!(repository
|
||
.add(draft(GlossaryReviewStatus::Approved))
|
||
.await
|
||
.is_err());
|
||
repository
|
||
.add(draft(GlossaryReviewStatus::Draft))
|
||
.await
|
||
.unwrap();
|
||
let before = repository
|
||
.diagnose("Sensei", &BTreeMap::new())
|
||
.await
|
||
.unwrap();
|
||
assert!(before.constraints.is_empty());
|
||
|
||
repository
|
||
.review(
|
||
"term-sensei",
|
||
GlossaryReviewStatus::Approved,
|
||
"reviewer",
|
||
Some("ok".to_string()),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
let after = repository
|
||
.diagnose("Sensei", &BTreeMap::new())
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(after.constraints.len(), 1);
|
||
|
||
let term = repository.find("term-sensei").await.unwrap();
|
||
assert_eq!(term.history.len(), 2);
|
||
assert_eq!(term.history[1].action, "approved");
|
||
let summary = repository.summary().await.unwrap();
|
||
assert_eq!(summary.approved_count, 1);
|
||
let deleted = repository
|
||
.delete("term-sensei", "reviewer", "remove duplicate")
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(deleted.history.len(), 2);
|
||
assert!(repository.find("term-sensei").await.is_err());
|
||
assert_eq!(repository.summary().await.unwrap().term_count, 0);
|
||
let deletion_count: i64 =
|
||
sqlx::query_scalar("SELECT COUNT(*) FROM glossary_term_deletions")
|
||
.fetch_one(&repository.pool)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(deletion_count, 1);
|
||
}
|
||
}
|