fix: improve database schema handling and add migration feature gate

- Fix column name quoting in ALTER TABLE statements to prevent SQL
  syntax errors with reserved keywords
- Use case-insensitive table name matching in table_exists()
- Change type declarations from INTEGER to BOOLEAN for semantic clarity
- Add CC_SWITCH_ENABLE_JSON_DB_MIGRATION env var to gate JSON→SQLite
  migration (disabled by default until feature is stable)
- Refactor tests with shared legacy schema and column info helpers
- Add comprehensive test for column types and default values alignment
This commit is contained in:
Jason
2025-11-24 22:52:58 +08:00
parent f93b21c97e
commit ea54b7d010
2 changed files with 172 additions and 76 deletions
+152 -72
View File
@@ -219,14 +219,21 @@ impl Database {
fn table_exists(conn: &Connection, table: &str) -> Result<bool, AppError> {
Self::validate_identifier(table, "表名")?;
let sql = "SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?1 LIMIT 1;";
match conn.query_row(sql, params![table], |row| row.get::<_, i64>(0)) {
Ok(_) => Ok(true),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
Err(e) => Err(AppError::Database(format!(
"检查表是否存在失败: {e}"
))),
let mut stmt = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.map_err(|e| AppError::Database(format!("读取表名失败: {e}")))?;
let mut rows = stmt
.query([])
.map_err(|e| AppError::Database(format!("查询表名失败: {e}")))?;
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
let name: String = row
.get(0)
.map_err(|e| AppError::Database(format!("解析表名失败: {e}")))?;
if name.eq_ignore_ascii_case(table) {
return Ok(true);
}
}
Ok(false)
}
fn has_column(conn: &Connection, table: &str, column: &str) -> Result<bool, AppError> {
@@ -269,7 +276,7 @@ impl Database {
return Ok(false);
}
let sql = format!("ALTER TABLE \"{table}\" ADD COLUMN {definition};");
let sql = format!("ALTER TABLE \"{table}\" ADD COLUMN \"{column}\" {definition};");
conn.execute(&sql, [])
.map_err(|e| AppError::Database(format!("为表 {table} 添加列 {column} 失败: {e}")))?;
log::info!("已为表 {table} 添加缺失列 {column}");
@@ -311,7 +318,7 @@ impl Database {
conn,
"providers",
"is_current",
"INTEGER NOT NULL DEFAULT 0",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
Self::add_column_if_missing(
@@ -334,13 +341,13 @@ impl Database {
conn,
"mcp_servers",
"enabled_codex",
"INTEGER NOT NULL DEFAULT 0",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
Self::add_column_if_missing(
conn,
"mcp_servers",
"enabled_gemini",
"INTEGER NOT NULL DEFAULT 0",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
Self::add_column_if_missing(conn, "prompts", "description", "TEXT")?;
@@ -348,7 +355,7 @@ impl Database {
conn,
"prompts",
"enabled",
"INTEGER NOT NULL DEFAULT 1",
"BOOLEAN NOT NULL DEFAULT 1",
)?;
Self::add_column_if_missing(conn, "prompts", "created_at", "INTEGER")?;
Self::add_column_if_missing(conn, "prompts", "updated_at", "INTEGER")?;
@@ -370,7 +377,7 @@ impl Database {
conn,
"skill_repos",
"enabled",
"INTEGER NOT NULL DEFAULT 1",
"BOOLEAN NOT NULL DEFAULT 1",
)?;
Self::add_column_if_missing(conn, "skill_repos", "skills_path", "TEXT")?;
@@ -1456,45 +1463,7 @@ impl Database {
mod tests {
use super::*;
#[test]
fn migration_sets_user_version_when_missing() {
let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables");
assert_eq!(
Database::get_user_version(&conn).expect("read version before"),
0
);
Database::apply_schema_migrations_on_conn(&conn).expect("apply migration");
assert_eq!(
Database::get_user_version(&conn).expect("read version after"),
SCHEMA_VERSION
);
}
#[test]
fn migration_rejects_future_version() {
let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables");
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
let err = Database::apply_schema_migrations_on_conn(&conn)
.expect_err("should reject higher version");
assert!(
err.to_string().contains("数据库版本过新"),
"unexpected error: {err}"
);
}
#[test]
fn migration_adds_missing_columns_for_providers() {
let conn = Connection::open_in_memory().expect("open memory db");
// 创建旧版 providers 表,缺少新增列
conn.execute_batch(
r#"
const LEGACY_SCHEMA_SQL: &str = r#"
CREATE TABLE providers (
id TEXT NOT NULL,
app_type TEXT NOT NULL,
@@ -1533,9 +1502,80 @@ mod tests {
key TEXT PRIMARY KEY,
value TEXT
);
"#,
)
.expect("seed old schema");
"#;
#[derive(Debug)]
struct ColumnInfo {
name: String,
r#type: String,
notnull: i64,
default: Option<String>,
}
fn get_column_info(conn: &Connection, table: &str, column: &str) -> ColumnInfo {
let mut stmt = conn
.prepare(&format!("PRAGMA table_info(\"{table}\");"))
.expect("prepare pragma");
let mut rows = stmt.query([]).expect("query pragma");
while let Some(row) = rows.next().expect("read row") {
let name: String = row.get(1).expect("name");
if name.eq_ignore_ascii_case(column) {
return ColumnInfo {
name,
r#type: row.get::<_, String>(2).expect("type"),
notnull: row.get::<_, i64>(3).expect("notnull"),
default: row.get::<_, Option<String>>(4).ok().flatten(),
};
}
}
panic!("column {table}.{column} not found");
}
fn normalize_default(default: &Option<String>) -> Option<String> {
default
.as_ref()
.map(|s| s.trim_matches('\'').trim_matches('"').to_string())
}
#[test]
fn migration_sets_user_version_when_missing() {
let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables");
assert_eq!(
Database::get_user_version(&conn).expect("read version before"),
0
);
Database::apply_schema_migrations_on_conn(&conn).expect("apply migration");
assert_eq!(
Database::get_user_version(&conn).expect("read version after"),
SCHEMA_VERSION
);
}
#[test]
fn migration_rejects_future_version() {
let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables");
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
let err = Database::apply_schema_migrations_on_conn(&conn)
.expect_err("should reject higher version");
assert!(
err.to_string().contains("数据库版本过新"),
"unexpected error: {err}"
);
}
#[test]
fn migration_adds_missing_columns_for_providers() {
let conn = Connection::open_in_memory().expect("open memory db");
// 创建旧版 providers 表,缺少新增列
conn.execute_batch(LEGACY_SCHEMA_SQL)
.expect("seed old schema");
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
@@ -1556,27 +1596,67 @@ mod tests {
}
// 验证 meta 列约束保持一致
let mut stmt = conn
.prepare("PRAGMA table_info(\"providers\");")
.expect("prepare pragma");
let mut rows = stmt.query([]).expect("query pragma");
let mut meta_not_null = None;
let mut meta_default = None;
while let Some(row) = rows.next().expect("read row") {
let name: String = row.get(1).expect("name");
if name == "meta" {
meta_not_null = Some(row.get::<_, i64>(3).expect("notnull"));
meta_default = row.get::<_, Option<String>>(4).ok().flatten();
break;
}
}
assert_eq!(meta_not_null, Some(1), "meta should be NOT NULL");
let normalized_default = meta_default.map(|s| s.trim_matches('\'').to_string());
assert_eq!(normalized_default.as_deref(), Some("{}"), "meta default should be '{}'");
let meta = get_column_info(&conn, "providers", "meta");
assert_eq!(meta.notnull, 1, "meta should be NOT NULL");
assert_eq!(
normalize_default(&meta.default).as_deref(),
Some("{}"),
"meta default should be '{{}}'"
);
assert_eq!(
Database::get_user_version(&conn).expect("version after migration"),
SCHEMA_VERSION
);
}
#[test]
fn migration_aligns_column_defaults_and_types() {
let conn = Connection::open_in_memory().expect("open memory db");
conn.execute_batch(LEGACY_SCHEMA_SQL)
.expect("seed old schema");
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
let is_current = get_column_info(&conn, "providers", "is_current");
assert_eq!(is_current.r#type, "BOOLEAN");
assert_eq!(is_current.notnull, 1);
assert_eq!(
normalize_default(&is_current.default).as_deref(),
Some("0")
);
let tags = get_column_info(&conn, "mcp_servers", "tags");
assert_eq!(tags.r#type, "TEXT");
assert_eq!(tags.notnull, 1);
assert_eq!(normalize_default(&tags.default).as_deref(), Some("[]"));
let enabled = get_column_info(&conn, "prompts", "enabled");
assert_eq!(enabled.r#type, "BOOLEAN");
assert_eq!(enabled.notnull, 1);
assert_eq!(
normalize_default(&enabled.default).as_deref(),
Some("1")
);
let installed_at = get_column_info(&conn, "skills", "installed_at");
assert_eq!(installed_at.r#type, "INTEGER");
assert_eq!(installed_at.notnull, 1);
assert_eq!(
normalize_default(&installed_at.default).as_deref(),
Some("0")
);
let branch = get_column_info(&conn, "skill_repos", "branch");
assert_eq!(branch.r#type, "TEXT");
assert_eq!(normalize_default(&branch.default).as_deref(), Some("main"));
let skill_repo_enabled = get_column_info(&conn, "skill_repos", "enabled");
assert_eq!(skill_repo_enabled.r#type, "BOOLEAN");
assert_eq!(skill_repo_enabled.notnull, 1);
assert_eq!(
normalize_default(&skill_repo_enabled.default).as_deref(),
Some("1")
);
}
}
+20 -4
View File
@@ -113,6 +113,17 @@ const TRAY_SECTIONS: [TrayAppSection; 3] = [
},
];
/// 读取类似布尔的环境变量(1/true/yes/on 视为开启)
fn env_flag_enabled(key: &str) -> bool {
match std::env::var(key) {
Ok(val) => matches!(
val.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
),
Err(_) => false,
}
}
fn append_provider_section<'a>(
app: &'a tauri::AppHandle,
mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,
@@ -542,8 +553,9 @@ pub fn run() {
let db_path = app_config_dir.join("cc-switch.db");
let json_path = app_config_dir.join("config.json");
// Check if migration is needed (DB doesn't exist but JSON does)
let migration_needed = !db_path.exists() && json_path.exists();
// 检查是否需要从 config.json 迁移到 SQLite,功能尚未正式发布,默认关闭
let migration_opt_in = env_flag_enabled("CC_SWITCH_ENABLE_JSON_DB_MIGRATION");
let migration_needed = !db_path.exists() && json_path.exists() && migration_opt_in;
let db = match crate::database::Database::init() {
Ok(db) => Arc::new(db),
@@ -555,8 +567,12 @@ pub fn run() {
}
};
if migration_needed {
log::info!("Starting migration from config.json to SQLite...");
if !migration_opt_in && !db_path.exists() && json_path.exists() {
log::warn!(
"检测到 config.json,但 JSON→数据库迁移功能尚未正式发布,默认跳过。设置 CC_SWITCH_ENABLE_JSON_DB_MIGRATION=1 后再尝试。"
);
} else if migration_needed {
log::info!("Starting migration from config.json to SQLite (opt-in)...");
match crate::app_config::MultiAppConfig::load() {
Ok(config) => {
if let Err(e) = db.migrate_from_json(&config) {