5364 lines
191 KiB
Rust
5364 lines
191 KiB
Rust
use std::cell::RefCell;
|
|
use std::sync::Arc;
|
|
|
|
use chusql_core_storage::runtime::Storage;
|
|
|
|
// 进程内存储集成测试:协议请求、索引、账号与配置。
|
|
|
|
/// 检查点损坏不冒充零 LSN且可恢复读取
|
|
#[test]
|
|
fn system_status_reports_checkpoint_read_errors() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_server, data) = start_server();
|
|
let mut connection = connect()?;
|
|
let checkpoint = data.path().join("system/wal.checkpoint");
|
|
let initial: serde_json::Value =
|
|
serde_json::from_str(&send_raw(&mut connection, r#"{"method":"system_status"}"#))?;
|
|
assert!(initial["last_lsn"].as_u64().unwrap() > 0);
|
|
request_ok(
|
|
&mut connection,
|
|
serde_json::json!({"method":"object_acl_replace","expected":null,"payload":"{}"}),
|
|
)?;
|
|
let original = std::fs::read(&checkpoint)?;
|
|
let before: serde_json::Value =
|
|
serde_json::from_str(&send_raw(&mut connection, r#"{"method":"system_status"}"#))?;
|
|
assert_eq!(before["status"], "system");
|
|
assert!(before["last_lsn"].as_u64().unwrap() > 0);
|
|
std::fs::write(&checkpoint, b"bad")?;
|
|
let broken: serde_json::Value =
|
|
serde_json::from_str(&send_raw(&mut connection, r#"{"method":"system_status"}"#))?;
|
|
assert_eq!(broken["status"], "error");
|
|
assert!(
|
|
broken["message"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("invalid WAL checkpoint length")
|
|
);
|
|
assert!(broken.get("last_lsn").is_none());
|
|
assert!(send_raw(&mut connection, r#"{"method":"ping"}"#).contains("pong"));
|
|
std::fs::write(&checkpoint, &original)?;
|
|
let restored: serde_json::Value =
|
|
serde_json::from_str(&send_raw(&mut connection, r#"{"method":"system_status"}"#))?;
|
|
assert_eq!(restored, before);
|
|
std::fs::remove_file(&checkpoint)?;
|
|
std::fs::create_dir(&checkpoint)?;
|
|
let unreadable: serde_json::Value =
|
|
serde_json::from_str(&send_raw(&mut connection, r#"{"method":"system_status"}"#))?;
|
|
assert_eq!(unreadable["status"], "error");
|
|
assert!(unreadable.get("last_lsn").is_none());
|
|
std::fs::remove_dir(&checkpoint)?;
|
|
std::fs::write(&checkpoint, original)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// 权限快照支持跨页文本并拒绝旧版本覆盖
|
|
#[test]
|
|
fn object_acl_snapshots_are_private_and_compare_versions() -> Result<(), Box<dyn std::error::Error>>
|
|
{
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
let payload = serde_json::json!({"grants": "权限\"\\".repeat(4000)}).to_string();
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"object_acl_replace","expected":null,"payload":payload}),
|
|
)?;
|
|
let current: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"object_acl_read"}"#))?;
|
|
assert_eq!(current["rows"][0]["payload"], payload);
|
|
let conflict = send(
|
|
&mut c,
|
|
r#"{"method":"object_acl_replace","expected":null,"payload":"{}"}"#,
|
|
);
|
|
assert!(conflict.contains("changed concurrently"), "{conflict}");
|
|
assert!(
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"scan","database":"system","table":"__system_object_acl"}"#
|
|
)
|
|
.contains("reserved system table")
|
|
);
|
|
assert!(!send(&mut c, r#"{"method":"all_catalogs"}"#).contains("__system_object_acl"));
|
|
drop(c);
|
|
drop(server);
|
|
let _restarted = restart_at(data.path())?;
|
|
let mut c = connect()?;
|
|
let reloaded: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"object_acl_read"}"#))?;
|
|
assert_eq!(reloaded, current);
|
|
Ok(())
|
|
}
|
|
|
|
/// 权限并发替换只能有一个版本提交
|
|
#[test]
|
|
fn concurrent_object_acl_replacement_preserves_one_winner() -> Result<(), Box<dyn std::error::Error>>
|
|
{
|
|
let (_server, _data) = start_server();
|
|
let mut c = connect()?;
|
|
let workers: Vec<_> = (0..8).map(|index| {
|
|
let storage = Arc::clone(&c.0);
|
|
std::thread::spawn(move || storage.request_line(&serde_json::json!({
|
|
"method":"object_acl_replace","expected":null,"payload":serde_json::json!({"winner":index}).to_string()
|
|
}).to_string()))
|
|
}).collect();
|
|
let replies: Vec<serde_json::Value> = workers
|
|
.into_iter()
|
|
.map(|worker| serde_json::from_str(&worker.join().unwrap()).unwrap())
|
|
.collect();
|
|
assert_eq!(
|
|
replies
|
|
.iter()
|
|
.filter(|reply| reply["status"] == "ok")
|
|
.count(),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
replies
|
|
.iter()
|
|
.filter(|reply| reply["message"]
|
|
.as_str()
|
|
.is_some_and(|message| message.contains("changed concurrently")))
|
|
.count(),
|
|
7
|
|
);
|
|
let loaded: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"object_acl_read"}"#))?;
|
|
assert!(
|
|
serde_json::from_str::<serde_json::Value>(loaded["rows"][0]["payload"].as_str().unwrap())?
|
|
["winner"]
|
|
.is_number()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 权限快照落盘失败后重放完整替换
|
|
#[test]
|
|
fn object_acl_commit_failure_replays_the_complete_snapshot()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
let pending = data.path().join("system/catalog.json.pending");
|
|
std::fs::create_dir(&pending)?;
|
|
let failed = send(
|
|
&mut c,
|
|
r#"{"method":"object_acl_replace","expected":null,"payload":"{\"version\":1}"}"#,
|
|
);
|
|
assert!(failed.contains("error"), "{failed}");
|
|
assert!(send(&mut c, r#"{"method":"object_acl_read"}"#).contains("recovery required"));
|
|
drop(c);
|
|
drop(server);
|
|
std::fs::remove_dir(pending)?;
|
|
let _restarted = restart_at(data.path())?;
|
|
let mut c = connect()?;
|
|
let loaded: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"object_acl_read"}"#))?;
|
|
assert_eq!(loaded["rows"][0]["payload"], "{\"version\":1}");
|
|
Ok(())
|
|
}
|
|
|
|
/// 对象编号区分跨库同名及删除重建
|
|
#[test]
|
|
fn object_identity_tracks_database_and_table_incarnations() -> Result<(), Box<dyn std::error::Error>>
|
|
{
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_database","database":"other"}),
|
|
)?;
|
|
for database in ["main", "other"] {
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","database":database,"table":"items","columns":[{"name":"id","ty":"int"}]}),
|
|
)?;
|
|
}
|
|
let old: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"resolve_object","database":"main","table":"items"}"#,
|
|
))?;
|
|
let other: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"resolve_object","database":"other","table":"items"}"#,
|
|
))?;
|
|
assert_eq!(old["status"], "object");
|
|
assert_ne!(old["database_id"], other["database_id"]);
|
|
assert!(old["object_id"].as_u64().unwrap() > 0);
|
|
let catalog: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"list_catalog","database":"main"}"#,
|
|
))?;
|
|
let info = catalog["schemas"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|entry| entry["table"] == "items")
|
|
.unwrap();
|
|
assert_eq!(info["database_id"], old["database_id"]);
|
|
assert_eq!(info["object_id"], old["object_id"]);
|
|
drop(c);
|
|
drop(server);
|
|
let restarted = restart_at(data.path())?;
|
|
let mut c = connect()?;
|
|
let stable: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"resolve_object","database":"main","table":"items"}"#,
|
|
))?;
|
|
assert_eq!(stable, old);
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"drop_table","database":"main","table":"items"}),
|
|
)?;
|
|
assert!(
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"resolve_object","database":"main","table":"items"}"#
|
|
)
|
|
.contains("unknown table")
|
|
);
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","database":"main","table":"items","columns":[{"name":"id","ty":"int"}]}),
|
|
)?;
|
|
let new: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"resolve_object","database":"main","table":"items"}"#,
|
|
))?;
|
|
assert_eq!(new["database_id"], old["database_id"]);
|
|
assert_ne!(new["object_id"], old["object_id"]);
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"drop_database","database":"other"}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_database","database":"other"}),
|
|
)?;
|
|
let new_database: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"resolve_object","database":"other"}"#,
|
|
))?;
|
|
assert_ne!(new_database["database_id"], other["database_id"]);
|
|
assert!(new_database["object_id"].is_null());
|
|
drop(c);
|
|
drop(restarted);
|
|
Ok(())
|
|
}
|
|
|
|
thread_local! {
|
|
/// 当前线程的存储实例:一个用例一个线程,用例内部按顺序起/停
|
|
static CURRENT: RefCell<Option<Arc<Storage>>> = const { RefCell::new(None) };
|
|
}
|
|
|
|
/// 建表应用失败后按日志恢复原编号
|
|
#[test]
|
|
fn object_id_survives_committed_create_recovery() -> Result<(), Box<dyn std::error::Error>> {
|
|
use chusql_core_storage::wal::{Wal, WalOp};
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
let directory = data.path().join("databases/main");
|
|
let pending = directory.join("catalog.json.pending");
|
|
std::fs::create_dir(&pending)?;
|
|
assert!(
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"create_table","table":"recovered","columns":[{"name":"id","ty":"int"}]}"#
|
|
)
|
|
.contains("error")
|
|
);
|
|
assert!(
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"resolve_object","database":"main","table":"recovered"}"#
|
|
)
|
|
.contains("recovery required")
|
|
);
|
|
let wal = Wal::new(directory.join("wal.log"));
|
|
let scan = wal.read_all()?;
|
|
let id = scan
|
|
.records
|
|
.iter()
|
|
.find_map(|(_, op)| match op {
|
|
WalOp::CreateTable {
|
|
table, object_id, ..
|
|
} if table == "recovered" => *object_id,
|
|
_ => None,
|
|
})
|
|
.unwrap();
|
|
drop(wal);
|
|
drop(c);
|
|
drop(server);
|
|
std::fs::remove_dir(pending)?;
|
|
let _restarted = restart_at(data.path())?;
|
|
let mut c = connect()?;
|
|
let resolved: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"resolve_object","database":"main","table":"recovered"}"#,
|
|
))?;
|
|
assert_eq!(resolved["object_id"], id);
|
|
Ok(())
|
|
}
|
|
|
|
/// 读取复查跨库依赖而非只复查扫描表
|
|
#[test]
|
|
fn bound_reads_recheck_dependencies_in_other_databases() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_server, _data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"source","columns":[]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_database","database":"dependency"}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","database":"dependency","table":"related","columns":[]}),
|
|
)?;
|
|
let catalog: serde_json::Value =
|
|
serde_json::from_str(&send_raw(&mut c, r#"{"method":"all_catalogs"}"#))?;
|
|
let bindings: Vec<_> = catalog["schemas"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|entry| entry["table"] == "main.source" || entry["table"] == "dependency.related")
|
|
.map(|entry| {
|
|
let mut binding = entry.clone();
|
|
binding["database_id"] = binding["database_id"].as_u64().unwrap().to_string().into();
|
|
binding["object_id"] = binding["object_id"].as_u64().unwrap().to_string().into();
|
|
binding
|
|
})
|
|
.collect();
|
|
assert_eq!(bindings.len(), 2);
|
|
let request = serde_json::json!({"method":"scan","table":"source","schema_guard":bindings});
|
|
let valid: serde_json::Value = serde_json::from_str(&send(&mut c, &request.to_string()))?;
|
|
assert_eq!(valid["status"], "guarded");
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"replace_schema","database":"dependency","table":"related","columns":[{"name":"new","ty":"int"}],"rows":[]}),
|
|
)?;
|
|
let invalid: serde_json::Value = serde_json::from_str(&send(&mut c, &request.to_string()))?;
|
|
assert_eq!(invalid["status"], "error");
|
|
assert!(
|
|
invalid["message"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("bound query schema changed: dependency.related")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 每种读取都复查守卫,拒绝旧版本与库管理
|
|
#[test]
|
|
fn bound_reads_validate_dependencies_and_acknowledge_guards()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
let (_server, _data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"guarded","columns":[{"name":"id","ty":"int"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"guarded","row":{"id":7}}),
|
|
)?;
|
|
let catalog: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"list_catalog"}"#))?;
|
|
let mut binding = catalog["schemas"][0].clone();
|
|
binding["database_id"] = binding["database_id"].as_u64().unwrap().to_string().into();
|
|
binding["object_id"] = binding["object_id"].as_u64().unwrap().to_string().into();
|
|
for mut request in [
|
|
serde_json::json!({"method":"scan","table":"guarded"}),
|
|
serde_json::json!({"method":"scan_shard","table":"guarded","shard":0,"shards":1}),
|
|
serde_json::json!({"method":"lookup_by_index","table":"guarded","column":"id","key":7}),
|
|
serde_json::json!({"method":"range_by_index","table":"guarded","column":"id","lo":null,"hi":null}),
|
|
] {
|
|
request["schema_guard"] = serde_json::json!([binding]);
|
|
let response: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, &request.to_string()))?;
|
|
assert_eq!(response["status"], "guarded", "{response}");
|
|
assert!(
|
|
response["response"]["status"] == "rows"
|
|
|| response["response"]["status"] == "no_index",
|
|
"{response}"
|
|
);
|
|
}
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"replace_schema","table":"guarded","columns":[{"name":"id","ty":"int"},{"name":"extra","ty":"str"}],"rows":[{"id":7,"extra":"x"}]}),
|
|
)?;
|
|
let stale: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
&serde_json::json!({"method":"scan","table":"guarded","schema_guard":[binding]})
|
|
.to_string(),
|
|
))?;
|
|
assert_eq!(stale["status"], "error");
|
|
assert!(
|
|
stale["message"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("bound query schema changed")
|
|
);
|
|
let write: serde_json::Value = serde_json::from_str(&send(&mut c, &serde_json::json!({"method":"insert","table":"guarded","row":{"id":9},"schema_guard":[binding]}).to_string()))?;
|
|
assert_eq!(write["status"], "error");
|
|
let management: serde_json::Value = serde_json::from_str(&send_raw(
|
|
&mut c,
|
|
&serde_json::json!({"method":"create_database","database":"blocked","schema_guard":[]})
|
|
.to_string(),
|
|
))?;
|
|
assert_eq!(management["status"], "error");
|
|
let databases: serde_json::Value =
|
|
serde_json::from_str(&send_raw(&mut c, r#"{"method":"list_databases"}"#))?;
|
|
assert!(
|
|
!databases["tables"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|database| database == "blocked")
|
|
);
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"guarded"}"#))?;
|
|
assert_eq!(rows["rows"].as_array().unwrap().len(), 1);
|
|
Ok(())
|
|
}
|
|
|
|
/// 写入持独占门并拒绝过期或不含目标的守卫
|
|
#[test]
|
|
fn bound_writes_validate_before_publishing() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_server, _data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"writes","columns":[{"name":"id","ty":"int"}]}),
|
|
)?;
|
|
let catalog: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"list_catalog"}"#))?;
|
|
let mut binding = catalog["schemas"][0].clone();
|
|
binding["database_id"] = binding["database_id"].as_u64().unwrap().to_string().into();
|
|
binding["object_id"] = binding["object_id"].as_u64().unwrap().to_string().into();
|
|
let ready: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
&serde_json::json!({"method":"validate_write","table":"writes","schema_guard":[binding]})
|
|
.to_string(),
|
|
))?;
|
|
assert_eq!(ready["status"], "guarded");
|
|
assert_eq!(ready["response"]["status"], "ok");
|
|
let unguarded: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"validate_write","table":"writes"}"#,
|
|
))?;
|
|
assert_eq!(unguarded["status"], "error");
|
|
for mut request in [
|
|
serde_json::json!({"method":"insert","table":"writes","row":{"id":1}}),
|
|
serde_json::json!({"method":"insert_batch","table":"writes","rows":[{"id":2}]}),
|
|
serde_json::json!({"method":"replace_all","table":"writes","rows":[{"id":3}]}),
|
|
serde_json::json!({"method":"delete_keys","table":"writes","keys":[3]}),
|
|
] {
|
|
request["schema_guard"] = serde_json::json!([binding]);
|
|
let response: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, &request.to_string()))?;
|
|
assert_eq!(response["status"], "guarded", "{response}");
|
|
assert_eq!(response["response"]["status"], "ok", "{response}");
|
|
}
|
|
let missing: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"writes","row":{"id":9},"schema_guard":[]}"#,
|
|
))?;
|
|
assert_eq!(missing["status"], "error");
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"drop_table","table":"writes"}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"writes","columns":[{"name":"id","ty":"int"}]}),
|
|
)?;
|
|
let stale: serde_json::Value = serde_json::from_str(&send(&mut c, &serde_json::json!({"method":"insert","table":"writes","row":{"id":9},"schema_guard":[binding]}).to_string()))?;
|
|
assert_eq!(stale["status"], "error");
|
|
assert!(
|
|
stale["message"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("bound query object changed")
|
|
);
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"writes"}"#))?;
|
|
assert_eq!(rows["rows"], serde_json::json!([]));
|
|
Ok(())
|
|
}
|
|
|
|
/// 互相依赖的跨库写入按统一顺序取门
|
|
#[test]
|
|
fn guarded_cross_database_writes_do_not_deadlock() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_server, _data) = start_server();
|
|
let mut c = connect()?;
|
|
for database in ["guard_a", "guard_b"] {
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_database","database":database}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","database":database,"table":"items","columns":[{"name":"id","ty":"int"}]}),
|
|
)?;
|
|
}
|
|
let catalog: serde_json::Value =
|
|
serde_json::from_str(&send_raw(&mut c, r#"{"method":"all_catalogs"}"#))?;
|
|
let bindings: Vec<_> = catalog["schemas"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|entry| entry["table"] == "guard_a.items" || entry["table"] == "guard_b.items")
|
|
.map(|entry| {
|
|
let mut binding = entry.clone();
|
|
binding["database_id"] = binding["database_id"].as_u64().unwrap().to_string().into();
|
|
binding["object_id"] = binding["object_id"].as_u64().unwrap().to_string().into();
|
|
binding
|
|
})
|
|
.collect();
|
|
assert_eq!(bindings.len(), 2);
|
|
let barrier = Arc::new(std::sync::Barrier::new(2));
|
|
let (sender, receiver) = std::sync::mpsc::channel();
|
|
let workers: Vec<_> = ["guard_a", "guard_b"].into_iter().map(|database| {
|
|
let storage = Arc::clone(&c.0);
|
|
let barrier = Arc::clone(&barrier);
|
|
let sender = sender.clone();
|
|
let bindings = bindings.clone();
|
|
std::thread::spawn(move || {
|
|
for id in 0..16 {
|
|
barrier.wait();
|
|
let response = storage.request_line(&serde_json::json!({
|
|
"method":"insert","database":database,"table":"items","row":{"id":id},"schema_guard":bindings
|
|
}).to_string());
|
|
sender.send(response).unwrap();
|
|
}
|
|
})
|
|
}).collect();
|
|
drop(sender);
|
|
for _ in 0..32 {
|
|
let response: serde_json::Value =
|
|
serde_json::from_str(&receiver.recv_timeout(std::time::Duration::from_secs(10))?)?;
|
|
assert_eq!(response["status"], "guarded", "{response}");
|
|
assert_eq!(response["response"]["status"], "ok", "{response}");
|
|
}
|
|
for worker in workers {
|
|
worker.join().unwrap();
|
|
}
|
|
for database in ["guard_a", "guard_b"] {
|
|
let rows: serde_json::Value = serde_json::from_str(&send_raw(
|
|
&mut c,
|
|
&serde_json::json!({"method":"scan","database":database,"table":"items"}).to_string(),
|
|
))?;
|
|
assert_eq!(rows["rows"].as_array().unwrap().len(), 16);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 描述与目录均透传精确版本字符串
|
|
#[test]
|
|
fn catalog_schema_versions_are_exact_and_validated() -> Result<(), Box<dyn std::error::Error>> {
|
|
use chusql_core_storage::protocol::TableSchemaWire;
|
|
let (_server, _data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"versioned","columns":[{"name":"id","ty":"int"}]}),
|
|
)?;
|
|
let describe: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"describe_table","table":"versioned"}"#,
|
|
))?;
|
|
assert_eq!(describe["schema_version"], "1");
|
|
let catalog: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"list_catalog"}"#))?;
|
|
assert_eq!(catalog["schemas"][0]["schema_version"], "1");
|
|
let wire: TableSchemaWire = serde_json::from_value(
|
|
serde_json::json!({"table":"t","columns":[],"schema_version":u64::MAX.to_string()}),
|
|
)?;
|
|
assert_eq!(wire.schema_version, u64::MAX);
|
|
assert_eq!(
|
|
serde_json::to_value(wire)?["schema_version"],
|
|
u64::MAX.to_string()
|
|
);
|
|
for version in [
|
|
serde_json::json!(-1),
|
|
serde_json::json!(1.5),
|
|
serde_json::json!("18446744073709551616"),
|
|
serde_json::json!("+1"),
|
|
] {
|
|
assert!(
|
|
serde_json::from_value::<TableSchemaWire>(
|
|
serde_json::json!({"table":"t","columns":[],"schema_version":version})
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 检查点失败及反复恢复保留结构版本
|
|
#[test]
|
|
fn schema_versions_survive_checkpoint_failure_and_repeated_recovery()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
use chusql_core_storage::catalog::Catalog;
|
|
use chusql_core_storage::wal::{Wal, WalOp};
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"versioned","columns":[{"name":"id","ty":"int"}]}),
|
|
)?;
|
|
let directory = data.path().join("databases/main");
|
|
let pending = directory.join("wal.checkpoint.pending");
|
|
std::fs::create_dir(&pending)?;
|
|
let response = send(
|
|
&mut c,
|
|
r#"{"method":"replace_schema","table":"versioned","columns":[{"name":"id","ty":"int"},{"name":"tag","ty":"str"}],"rows":[]}"#,
|
|
);
|
|
assert!(response.contains("error"), "{response}");
|
|
let catalog = Catalog::load(directory.join("catalog.json"))?;
|
|
let version = catalog.describe("versioned").unwrap().schema_version;
|
|
assert_eq!(version, 2);
|
|
let wal = Wal::new(directory.join("wal.log"));
|
|
let records = wal.read_all()?.records;
|
|
assert!(records.iter().any(|(_, op)| matches!(
|
|
op,
|
|
WalOp::ReplaceSchema {
|
|
schema_version: Some(2),
|
|
..
|
|
}
|
|
)));
|
|
let columns = catalog.describe("versioned").unwrap().columns.clone();
|
|
drop(wal);
|
|
drop(c);
|
|
drop(server);
|
|
std::fs::remove_dir(pending)?;
|
|
for _ in 0..2 {
|
|
let wal = Wal::new(directory.join("wal.log"));
|
|
wal.append_group(&[
|
|
WalOp::ReplaceSchema {
|
|
table: "versioned".into(),
|
|
columns: vec![],
|
|
rows: vec![],
|
|
schema_version: Some(1),
|
|
},
|
|
WalOp::ReplaceSchema {
|
|
table: "versioned".into(),
|
|
columns: columns.clone(),
|
|
rows: vec![],
|
|
schema_version: Some(version),
|
|
},
|
|
])?;
|
|
drop(wal);
|
|
let restarted = restart_at(data.path())?;
|
|
let catalog = Catalog::load(directory.join("catalog.json"))?;
|
|
assert_eq!(
|
|
catalog.describe("versioned").unwrap().schema_version,
|
|
version
|
|
);
|
|
assert_eq!(catalog.describe("versioned").unwrap().columns, columns);
|
|
assert_eq!(std::fs::metadata(directory.join("wal.log"))?.len(), 0);
|
|
drop(restarted);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 并发建表分配互不重复的编号
|
|
#[test]
|
|
fn concurrent_table_creation_allocates_distinct_object_ids()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
let (_server, _data) = start_server();
|
|
let mut c = connect()?;
|
|
let workers: Vec<_> = (0..16)
|
|
.map(|index| {
|
|
let storage = Arc::clone(&c.0);
|
|
std::thread::spawn(move || {
|
|
storage.request_line(&serde_json::json!({
|
|
"method":"create_table", "database":"main", "table":format!("concurrent_{index}"),
|
|
"columns":[{"name":"id","ty":"int"}]
|
|
}).to_string())
|
|
})
|
|
})
|
|
.collect();
|
|
for worker in workers {
|
|
let result: serde_json::Value = serde_json::from_str(&worker.join().unwrap())?;
|
|
assert_eq!(result["status"], "ok");
|
|
}
|
|
let response: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"list_catalog","database":"main"}"#,
|
|
))?;
|
|
let ids: std::collections::HashSet<_> = response["schemas"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|schema| schema["table"].as_str().unwrap().starts_with("concurrent_"))
|
|
.map(|schema| schema["object_id"].as_u64().unwrap())
|
|
.collect();
|
|
assert_eq!(ids.len(), 16);
|
|
assert!(!ids.contains(&0));
|
|
Ok(())
|
|
}
|
|
|
|
/// 定点写入检查舍入和整批原子拒绝
|
|
#[test]
|
|
fn decimal_writes_enforce_precision_before_wal() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"amounts","columns":[{"name":"id","ty":"int"},{"name":"value","ty":"decimal(4,2)"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"amounts","row":{"id":1,"value":99.994}}),
|
|
)?;
|
|
let snapshot = send(&mut c, r#"{"method":"scan","table":"amounts"}"#);
|
|
let rows: serde_json::Value = serde_json::from_str(&snapshot)?;
|
|
assert_eq!(rows["rows"][0]["value"], serde_json::json!(99.99));
|
|
let checkpoint = std::fs::read(data.path().join("databases/main/wal.checkpoint"))?;
|
|
for value in [99.995, -99.995, 100.0] {
|
|
let response = send(&mut c, &serde_json::json!({"method":"insert_batch","table":"amounts","rows":[{"id":2,"value":1.2},{"id":3,"value":value}]}).to_string());
|
|
assert!(response.contains("exceeds precision"), "{response}");
|
|
let remaining: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"amounts"}"#))?;
|
|
assert_eq!(remaining, rows);
|
|
assert_eq!(
|
|
std::fs::read(data.path().join("databases/main/wal.checkpoint"))?,
|
|
checkpoint
|
|
);
|
|
}
|
|
let invalid = send(&mut c, &serde_json::json!({"method":"create_table","table":"invalid","columns":[{"name":"value","ty":"decimal(2,3)"}]}).to_string());
|
|
assert!(invalid.contains("invalid decimal"), "{invalid}");
|
|
Ok(())
|
|
}
|
|
|
|
/// 检查点失败封闭请求并在重启恢复
|
|
#[test]
|
|
fn checkpoint_failure_requires_recovery_and_preserves_committed_rows()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"checkpointed","columns":[{"name":"id","ty":"int"}]}),
|
|
)?;
|
|
let pending = data.path().join("databases/main/wal.checkpoint.pending");
|
|
std::fs::create_dir(&pending)?;
|
|
let failed = send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"checkpointed","row":{"id":1}}"#,
|
|
);
|
|
assert!(failed.contains("error"), "{failed}");
|
|
assert!(
|
|
send(&mut c, r#"{"method":"scan","table":"checkpointed"}"#).contains("recovery required")
|
|
);
|
|
assert!(std::fs::metadata(data.path().join("databases/main/wal.log"))?.len() > 0);
|
|
drop(c);
|
|
drop(server);
|
|
std::fs::remove_dir(pending)?;
|
|
let _restarted = restart_at(data.path())?;
|
|
let mut c = connect()?;
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"checkpointed"}"#))?;
|
|
assert_eq!(rows["rows"], serde_json::json!([{"id":1}]));
|
|
Ok(())
|
|
}
|
|
|
|
/// 应用失败封闭读写并重放完整提交组
|
|
#[test]
|
|
fn committed_apply_failures_require_recovery() -> Result<(), Box<dyn std::error::Error>> {
|
|
for operation in [
|
|
serde_json::json!({"method":"create_table","table":"created","columns":[{"name":"id","ty":"int"}]}),
|
|
serde_json::json!({"method":"replace_all","table":"items","rows":[{"id":2}]}),
|
|
serde_json::json!({"method":"apply_transaction","ops":[{"op":"delete","table":"items","ids":[1]},{"op":"replace","table":"items","rows":[{"id":2}]}]}),
|
|
] {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"items","columns":[{"name":"id","ty":"int"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":1}}),
|
|
)?;
|
|
let directory = data.path().join("databases/main");
|
|
let checkpoint = std::fs::read(directory.join("wal.checkpoint"))?;
|
|
let pending = directory.join("catalog.json.pending");
|
|
std::fs::create_dir(&pending)?;
|
|
let failed = send(&mut c, &operation.to_string());
|
|
assert!(failed.contains("error"), "{operation}: {failed}");
|
|
for request in [
|
|
r#"{"method":"scan","table":"items"}"#,
|
|
r#"{"method":"insert","table":"items","row":{"id":3}}"#,
|
|
] {
|
|
let blocked = send(&mut c, request);
|
|
assert!(blocked.contains("recovery required"), "{blocked}");
|
|
}
|
|
assert_eq!(std::fs::read(directory.join("wal.checkpoint"))?, checkpoint);
|
|
assert!(std::fs::metadata(directory.join("wal.log"))?.len() > 0);
|
|
drop(c);
|
|
drop(server);
|
|
std::fs::remove_dir(pending)?;
|
|
let restarted = restart_at(data.path())?;
|
|
let mut c = connect()?;
|
|
let table = if operation["method"] == "create_table" {
|
|
"created"
|
|
} else {
|
|
"items"
|
|
};
|
|
let result: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
&serde_json::json!({"method":"scan","table":table}).to_string(),
|
|
))?;
|
|
let expected = if table == "created" {
|
|
serde_json::json!([])
|
|
} else {
|
|
serde_json::json!([{"id":2}])
|
|
};
|
|
assert_eq!(result["rows"], expected, "{operation}");
|
|
assert_eq!(std::fs::metadata(directory.join("wal.log"))?.len(), 0);
|
|
drop(c);
|
|
drop(restarted);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 首帧撕裂恢复后新日志仍可重放
|
|
#[test]
|
|
fn torn_first_frame_is_removed_before_new_writes() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"torn","columns":[{"name":"id","ty":"int"}]}),
|
|
)?;
|
|
drop(c);
|
|
drop(server);
|
|
std::fs::write(data.path().join("databases/main/wal.log"), [20, 0, 0, 0, 1])?;
|
|
let restarted = restart_at(data.path())?;
|
|
assert_eq!(
|
|
std::fs::metadata(data.path().join("databases/main/wal.log"))?.len(),
|
|
0
|
|
);
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"torn","row":{"id":1}}),
|
|
)?;
|
|
drop(c);
|
|
drop(restarted);
|
|
let _again = restart_at(data.path())?;
|
|
let mut c = connect()?;
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"torn"}"#))?;
|
|
assert_eq!(rows["rows"], serde_json::json!([{"id":1}]));
|
|
Ok(())
|
|
}
|
|
|
|
/// 校验预装类型幂等与冲突保护
|
|
#[test]
|
|
fn preinstalled_types_are_idempotent_hidden_and_conflict_safe()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
let (_server, data) = start_server();
|
|
let mut c = connect()?;
|
|
let premature = send(&mut c, &type_seed_request().to_string());
|
|
assert!(premature.contains("login superuser"), "{premature}");
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"bootstrap_system","user":"root","password_hash":"original-hash"}"#,
|
|
);
|
|
let accounts = send(&mut c, r#"{"method":"accounts_list"}"#);
|
|
let request = type_seed_request();
|
|
let seeded: serde_json::Value = serde_json::from_str(&send(&mut c, &request.to_string()))?;
|
|
assert_eq!(seeded["status"], "rows", "{seeded}");
|
|
assert_eq!(seeded["rows"].as_array().map(Vec::len), Some(2));
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&send(&mut c, &request.to_string()))?,
|
|
seeded
|
|
);
|
|
let mut conflict = request;
|
|
conflict["types"][1]["base_type"] = "int".into();
|
|
let rejected = send(&mut c, &conflict.to_string());
|
|
assert!(rejected.contains("definition conflict: text"), "{rejected}");
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&send(&mut c, &type_seed_request().to_string()))?,
|
|
seeded
|
|
);
|
|
assert_eq!(send(&mut c, r#"{"method":"accounts_list"}"#), accounts);
|
|
let catalog: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(
|
|
data.path().join("system/catalog.json"),
|
|
)?)?;
|
|
assert_eq!(catalog["tables"]["__system_types"]["system"], true);
|
|
assert_eq!(catalog["tables"]["__system_types"]["row_count"], 2);
|
|
let denied = send(
|
|
&mut c,
|
|
r#"{"method":"scan","database":"system","table":"__system_types"}"#,
|
|
);
|
|
assert!(denied.contains("reserved system table"), "{denied}");
|
|
Ok(())
|
|
}
|
|
|
|
/// 类型预装失败保留日志并在重启后恢复
|
|
#[test]
|
|
fn preinstalled_types_recover_after_catalog_write_failure() -> Result<(), Box<dyn std::error::Error>>
|
|
{
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"bootstrap_system","user":"root","password_hash":"hash"}"#,
|
|
);
|
|
std::fs::create_dir(data.path().join("system/catalog.json.pending"))?;
|
|
let failed = send(&mut c, &type_seed_request().to_string());
|
|
assert!(failed.contains("error"), "{failed}");
|
|
assert!(std::fs::metadata(data.path().join("system/wal.log"))?.len() > 0);
|
|
assert!(send(&mut c, &type_seed_request().to_string()).contains("recovery required"));
|
|
drop(c);
|
|
drop(server);
|
|
std::fs::remove_dir(data.path().join("system/catalog.json.pending"))?;
|
|
let _restarted = restart_at(data.path())?;
|
|
let mut c = connect()?;
|
|
let restored: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, &type_seed_request().to_string()))?;
|
|
assert_eq!(
|
|
restored["rows"].as_array().map(Vec::len),
|
|
Some(2),
|
|
"{restored}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 类型目录不覆盖未登记的现有文件
|
|
#[test]
|
|
fn preinstalled_types_reject_reserved_file_collision() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_server, data) = start_server();
|
|
let mut c = connect()?;
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"bootstrap_system","user":"root","password_hash":"hash"}"#,
|
|
);
|
|
let path = data.path().join("system/__system_types.db");
|
|
std::fs::write(&path, b"preserved")?;
|
|
let rejected = send(&mut c, &type_seed_request().to_string());
|
|
assert!(
|
|
rejected.contains("reserved type file collision"),
|
|
"{rejected}"
|
|
);
|
|
assert_eq!(std::fs::read(path)?, b"preserved");
|
|
Ok(())
|
|
}
|
|
|
|
/// 生成两项预装类型定义
|
|
fn type_seed_request() -> serde_json::Value {
|
|
serde_json::json!({"method":"bootstrap_types","types":[
|
|
{"name":"int","base_type":"int","parameterized":false},
|
|
{"name":"text","base_type":"str","parameterized":false}
|
|
]})
|
|
}
|
|
|
|
/// 引导保留已用身份编号
|
|
#[test]
|
|
fn bootstrap_respects_previous_identity_ids() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_server, _data) = start_server();
|
|
let mut c = connect()?;
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"account_create","user":"alice","password_hash":"hash"}"#,
|
|
);
|
|
let deleted: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"account_drop","user":"alice"}"#))?;
|
|
assert_eq!(deleted["accounts"], serde_json::json!([]));
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"bootstrap_system","user":"root","password_hash":"hash"}"#,
|
|
);
|
|
let seeded: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"accounts_list"}"#))?;
|
|
assert_eq!(seeded["accounts"][0]["id"], 2);
|
|
Ok(())
|
|
}
|
|
|
|
/// 旧账号日志迁移只提升初始管理员
|
|
#[test]
|
|
fn legacy_account_wal_migrates_without_promoting_other_names()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
use chusql_core_storage::wal::Wal;
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","database":"system","table":"__system_roles","columns":[{"name":"name","ty":"str"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","database":"system","table":"__system_roles","row":{"name":"reader"}}),
|
|
)?;
|
|
drop(c);
|
|
drop(server);
|
|
let path = data.path().join("system/wal.log");
|
|
let lsn = Wal::new(&path).read_checkpoint()?.unwrap_or(0) + 1;
|
|
let legacy = serde_json::json!([
|
|
{"id":7,"user":"owner","password_hash":"owner-hash","revision":3,"registered_at":"2020-01-01 00:00:00","last_login_at":null},
|
|
{"id":9,"user":"alice","password_hash":"alice-hash","revision":2,"registered_at":"2020-01-02 00:00:00","last_login_at":null}
|
|
]);
|
|
let table = b"__system_users";
|
|
let mut body = lsn.to_le_bytes().to_vec();
|
|
body.push(6);
|
|
body.extend_from_slice(&(table.len() as u16).to_le_bytes());
|
|
body.extend_from_slice(table);
|
|
body.extend_from_slice(&serde_json::to_vec(&legacy)?);
|
|
let mut frame = (body.len() as u32).to_le_bytes().to_vec();
|
|
frame.extend_from_slice(&body);
|
|
std::fs::write(&path, frame)?;
|
|
let _restarted = restart_at(data.path())?;
|
|
let mut c = connect()?;
|
|
let wrong = send(
|
|
&mut c,
|
|
r#"{"method":"identity_initialize","administrator":"missing"}"#,
|
|
);
|
|
assert!(wrong.contains("last enabled login superuser"), "{wrong}");
|
|
let migrated: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"identity_initialize","administrator":"owner"}"#,
|
|
))?;
|
|
assert_eq!(migrated["status"], "accounts", "{migrated}");
|
|
assert_eq!(migrated["accounts"][0]["id"], 7);
|
|
assert_eq!(migrated["accounts"][0]["is_superuser"], true);
|
|
assert_eq!(migrated["accounts"][0]["password_hash"], "owner-hash");
|
|
assert_eq!(migrated["accounts"][1]["is_superuser"], false);
|
|
assert_eq!(migrated["accounts"][2]["user"], "reader");
|
|
assert_eq!(migrated["accounts"][2]["id"], 10);
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&send(
|
|
&mut c,
|
|
r#"{"method":"identity_initialize","administrator":"alice"}"#
|
|
))?,
|
|
migrated
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 读取并校验身份命令响应
|
|
fn account_response(
|
|
c: &mut Conn,
|
|
request: serde_json::Value,
|
|
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
|
let response: serde_json::Value = serde_json::from_str(&send(c, &request.to_string()))?;
|
|
assert_eq!(response["status"], "accounts", "{response}");
|
|
Ok(response)
|
|
}
|
|
|
|
/// 本机认证开关独立持久化并推进会话版本
|
|
#[test]
|
|
fn sudo_auth_is_independent_revocable_and_persistent() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
let original = account_response(
|
|
&mut c,
|
|
serde_json::json!({"method":"account_create","user":"local_user","password_hash":"hash"}),
|
|
)?;
|
|
let created = original["accounts"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|a| a["user"] == "local_user")
|
|
.unwrap();
|
|
assert_eq!(created["allow_sudo_auth"], false);
|
|
let changed = account_response(
|
|
&mut c,
|
|
serde_json::json!({"method":"identity_alter","user":"local_user","allow_sudo_auth":true}),
|
|
)?;
|
|
let local = changed["accounts"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|a| a["user"] == "local_user")
|
|
.unwrap();
|
|
assert_eq!(local["allow_sudo_auth"], true);
|
|
assert_eq!(local["is_superuser"], false);
|
|
assert_eq!(local["system_catalog_manager"], false);
|
|
assert_eq!(local["revision"], 2);
|
|
for command in [
|
|
serde_json::json!({"method":"identity_alter","user":"local_user","allow_sudo_auth":false}),
|
|
serde_json::json!({"method":"account_reset","user":"local_user","password_hash":"other"}),
|
|
serde_json::json!({"method":"identity_alter","user":"root","allow_sudo_auth":true}),
|
|
] {
|
|
let denied = send(
|
|
&mut c,
|
|
&serde_json::json!({"method":"catalog_manage","command":command}).to_string(),
|
|
);
|
|
assert!(denied.contains("superuser required"), "{denied}");
|
|
}
|
|
drop(c);
|
|
drop(server);
|
|
let _restarted = restart_at(data.path())?;
|
|
let mut c = connect()?;
|
|
assert_eq!(
|
|
account_response(&mut c, serde_json::json!({"method":"accounts_list"}))?,
|
|
changed
|
|
);
|
|
let revoked = account_response(
|
|
&mut c,
|
|
serde_json::json!({"method":"identity_alter","user":"local_user","allow_sudo_auth":false}),
|
|
)?;
|
|
let local = revoked["accounts"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|a| a["user"] == "local_user")
|
|
.unwrap();
|
|
assert_eq!(local["allow_sudo_auth"], false);
|
|
assert_eq!(local["revision"], 3);
|
|
Ok(())
|
|
}
|
|
|
|
/// 升级缺失本机认证列的旧目录
|
|
#[test]
|
|
fn sudo_auth_upgrades_old_identity_schema() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
drop(server);
|
|
CURRENT.with(|slot| *slot.borrow_mut() = None);
|
|
let path = data.path().join("system/catalog.json");
|
|
let mut catalog: serde_json::Value = serde_json::from_slice(&std::fs::read(&path)?)?;
|
|
catalog["tables"]["__system_identities"]["columns"]
|
|
.as_array_mut()
|
|
.unwrap()
|
|
.retain(|column| column["name"] != "allow_sudo_auth");
|
|
std::fs::write(&path, serde_json::to_vec(&catalog)?)?;
|
|
let _restarted = restart_at(data.path())?;
|
|
let mut c = connect()?;
|
|
let upgraded = account_response(
|
|
&mut c,
|
|
serde_json::json!({"method":"identity_initialize","administrator":"root"}),
|
|
)?;
|
|
assert!(
|
|
upgraded["accounts"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.all(|a| a["allow_sudo_auth"] == false)
|
|
);
|
|
let catalog: serde_json::Value = serde_json::from_slice(&std::fs::read(&path)?)?;
|
|
assert!(
|
|
catalog["tables"]["__system_identities"]["columns"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|column| column["name"] == "allow_sudo_auth")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 身份属性重启保留且禁止删除最后管理员
|
|
#[test]
|
|
fn identity_attributes_survive_restart_and_protect_superuser()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"bootstrap_system","user":"root","password_hash":"hash"}"#,
|
|
);
|
|
let created: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"role_create","user":"Reader"}"#))?;
|
|
assert_eq!(created["status"], "accounts", "{created}");
|
|
assert_eq!(created["accounts"][1]["can_login"], false);
|
|
assert_eq!(created["accounts"][1]["enabled"], true);
|
|
for change in [
|
|
serde_json::json!({"enabled":false}),
|
|
serde_json::json!({"can_login":false}),
|
|
serde_json::json!({"is_superuser":false}),
|
|
] {
|
|
let mut request = change;
|
|
request["method"] = "identity_alter".into();
|
|
request["user"] = "root".into();
|
|
let denied = send(&mut c, &request.to_string());
|
|
assert!(denied.contains("last enabled login superuser"), "{denied}");
|
|
}
|
|
let collision = send(
|
|
&mut c,
|
|
r#"{"method":"account_create","user":"READER","password_hash":"hash"}"#,
|
|
);
|
|
assert!(collision.contains("already exists"), "{collision}");
|
|
let changed: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"identity_alter","user":"reader","can_login":true,"is_superuser":true}"#,
|
|
))?;
|
|
assert_eq!(changed["accounts"][1]["revision"], 2);
|
|
drop(c);
|
|
drop(server);
|
|
let _restarted = restart_at(data.path())?;
|
|
let mut c = connect()?;
|
|
let restored: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"accounts_list"}"#))?;
|
|
assert_eq!(restored, changed);
|
|
let denied = send(
|
|
&mut c,
|
|
r#"{"method":"scan","database":"system","table":"__system_identities"}"#,
|
|
);
|
|
assert!(denied.contains("reserved system table"), "{denied}");
|
|
Ok(())
|
|
}
|
|
|
|
/// 旧角色迁移与重名拒绝不破坏原数据
|
|
#[test]
|
|
fn identity_migration_preserves_roles_and_rejects_collisions()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
let (_server, _data) = start_server();
|
|
let mut c = connect()?;
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"bootstrap_system","user":"root","password_hash":"hash"}"#,
|
|
);
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","database":"system","table":"__system_roles","columns":[{"name":"name","ty":"str"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","database":"system","table":"__system_roles","row":{"name":"root"}}),
|
|
)?;
|
|
let before = send(&mut c, r#"{"method":"accounts_list"}"#);
|
|
let rejected = send(
|
|
&mut c,
|
|
r#"{"method":"identity_initialize","administrator":"root"}"#,
|
|
);
|
|
assert!(
|
|
rejected.contains("identity name collision: root"),
|
|
"{rejected}"
|
|
);
|
|
assert_eq!(send(&mut c, r#"{"method":"accounts_list"}"#), before);
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"replace_all","database":"system","table":"__system_roles","rows":[{"name":"reader"}]}),
|
|
)?;
|
|
let migrated: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"identity_initialize","administrator":"root"}"#,
|
|
))?;
|
|
assert_eq!(migrated["accounts"][1]["user"], "reader");
|
|
assert_eq!(migrated["accounts"][1]["can_login"], false);
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&send(
|
|
&mut c,
|
|
r#"{"method":"identity_initialize","administrator":"reader"}"#
|
|
))?,
|
|
migrated
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 删除身份清理授权并在恢复后保留编号上界
|
|
#[test]
|
|
fn identity_drop_cleanup_replays_without_reusing_ids() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"bootstrap_system","user":"root","password_hash":"hash"}"#,
|
|
);
|
|
send(&mut c, r#"{"method":"role_create","user":"reader"}"#);
|
|
for table in [
|
|
"__system_grants",
|
|
"__system_grant_options",
|
|
"__system_members",
|
|
] {
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","database":"system","table":table,"columns":[{"name":"role","ty":"str"},{"name":"member","ty":"str"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","database":"system","table":table,"row":{"role":"reader","member":"root"}}),
|
|
)?;
|
|
}
|
|
std::fs::create_dir(data.path().join("system/catalog.json.pending"))?;
|
|
let failed = send(&mut c, r#"{"method":"account_drop","user":"reader"}"#);
|
|
assert!(failed.contains("error"), "{failed}");
|
|
drop(c);
|
|
drop(server);
|
|
std::fs::remove_dir(data.path().join("system/catalog.json.pending"))?;
|
|
let _restarted = restart_at(data.path())?;
|
|
let mut c = connect()?;
|
|
for table in [
|
|
"__system_grants",
|
|
"__system_grant_options",
|
|
"__system_members",
|
|
] {
|
|
let rows: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
&serde_json::json!({"method":"scan","database":"system","table":table}).to_string(),
|
|
))?;
|
|
assert_eq!(rows["rows"], serde_json::json!([]));
|
|
}
|
|
let recreated: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"role_create","user":"reader"}"#))?;
|
|
assert_eq!(recreated["accounts"][1]["id"], 3);
|
|
Ok(())
|
|
}
|
|
|
|
/// 命名类型引用与删除依赖在写入时校验
|
|
#[test]
|
|
fn domain_dependencies_are_checked_before_wal() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_srv, _data) = start_server();
|
|
let mut connection = connect()?;
|
|
let columns = serde_json::json!([{"name":"code","ty":"domain(code,int)"}]);
|
|
let missing = serde_json::json!({"method":"create_table","table":"typed","columns":columns});
|
|
let response: serde_json::Value =
|
|
serde_json::from_str(&send(&mut connection, &missing.to_string()))?;
|
|
assert_eq!(response["status"], "error");
|
|
request_ok(
|
|
&mut connection,
|
|
serde_json::json!({"method":"create_table","table":"__system_domain_code","columns":[{"name":"base","ty":"int"}]}),
|
|
)?;
|
|
let forged = serde_json::json!({"method":"create_table","table":"forged","columns":[{"name":"code","ty":"domain(code,str)"}]});
|
|
let response: serde_json::Value =
|
|
serde_json::from_str(&send(&mut connection, &forged.to_string()))?;
|
|
assert_eq!(response["status"], "error");
|
|
request_ok(&mut connection, missing)?;
|
|
let response: serde_json::Value = serde_json::from_str(&send(
|
|
&mut connection,
|
|
r#"{"method":"drop_table","table":"__system_domain_code"}"#,
|
|
))?;
|
|
assert_eq!(response["status"], "error");
|
|
request_ok(
|
|
&mut connection,
|
|
serde_json::json!({"method":"drop_table","table":"typed"}),
|
|
)?;
|
|
request_ok(
|
|
&mut connection,
|
|
serde_json::json!({"method":"drop_table","table":"__system_domain_code"}),
|
|
)?;
|
|
let response: serde_json::Value = serde_json::from_str(&send(
|
|
&mut connection,
|
|
r#"{"method":"create_table","table":"stale","columns":[{"name":"code","ty":"domain(code,int)"}]}"#,
|
|
))?;
|
|
assert_eq!(response["status"], "error");
|
|
Ok(())
|
|
}
|
|
|
|
/// 领域发布复查快照且通过现有日志恢复
|
|
#[test]
|
|
fn domain_alter_snapshot_conflicts_and_recovers() -> Result<(), Box<dyn std::error::Error>> {
|
|
use chusql_core_storage::wal::{Wal, WalOp};
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"__system_domain_qty","columns":[{"name":"base","ty":"int"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"items","columns":[{"name":"qty","ty":"domain(qty,int)"}]}),
|
|
)?;
|
|
let before: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"domain_snapshot"}"#))?;
|
|
assert_eq!(before["status"], "domain_snapshot");
|
|
assert!(!before.to_string().contains("__system_users"));
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"qty":4}}),
|
|
)?;
|
|
let columns = serde_json::json!([{"name":"base","ty":"int","nullable":false,"default":3},
|
|
{"name":"check_positive","ty":"bool","check":"VALUE > 0"}]);
|
|
let mut alteration = serde_json::json!({"method":"alter_domain","table":"__system_domain_qty","columns":columns,"checkpoint":before["checkpoint"]});
|
|
let stale: serde_json::Value = serde_json::from_str(&send(&mut c, &alteration.to_string()))?;
|
|
assert!(
|
|
stale["message"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("serialization_failure")
|
|
);
|
|
let current: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"domain_snapshot"}"#))?;
|
|
assert_eq!(
|
|
current["rows"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|row| row[0] == "items")
|
|
.unwrap()[1],
|
|
serde_json::json!([{"qty":4}])
|
|
);
|
|
alteration["checkpoint"] = current["checkpoint"].clone();
|
|
let durable_paths = ["catalog.json", "wal.log", "wal.checkpoint"]
|
|
.map(|name| data.path().join("databases/main").join(name));
|
|
let durable_before = durable_paths
|
|
.iter()
|
|
.map(std::fs::read)
|
|
.collect::<std::io::Result<Vec<_>>>()?;
|
|
let mut forged = alteration.clone();
|
|
forged["columns"][0]["ty"] = "str".into();
|
|
let rejected: serde_json::Value = serde_json::from_str(&send(&mut c, &forged.to_string()))?;
|
|
assert!(rejected["message"].as_str().unwrap().contains("base type"));
|
|
let durable_after = durable_paths
|
|
.iter()
|
|
.map(std::fs::read)
|
|
.collect::<std::io::Result<Vec<_>>>()?;
|
|
assert_eq!(durable_before, durable_after);
|
|
request_ok(&mut c, alteration)?;
|
|
let changed: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"domain_snapshot"}"#))?;
|
|
let definition = changed["schemas"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|info| info["table"] == "__system_domain_qty")
|
|
.unwrap();
|
|
assert_eq!(definition["schema_version"], "2");
|
|
assert_eq!(definition["columns"][0]["nullable"], false);
|
|
drop(c);
|
|
drop(server);
|
|
// 模拟日志已提交、目录尚未应用的中断。
|
|
let wal = Wal::new(data.path().join("databases/main/wal.log"));
|
|
let recovered_columns =
|
|
serde_json::from_value(serde_json::json!([{"name":"base","ty":"int","default":6},
|
|
{"name":"check_positive","ty":"bool","check":"VALUE > 0"}]))?;
|
|
wal.append_group(&[WalOp::ReplaceSchema {
|
|
table: "__system_domain_qty".into(),
|
|
columns: recovered_columns,
|
|
rows: vec![],
|
|
schema_version: Some(3),
|
|
}])?;
|
|
let restarted = restart_at(data.path())?;
|
|
let mut c = connect()?;
|
|
let recovered: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"describe_table","table":"__system_domain_qty"}"#,
|
|
))?;
|
|
assert_eq!(recovered["schema_version"], "3");
|
|
assert_eq!(recovered["columns"][0]["default"], 6);
|
|
assert_eq!(recovered["columns"][1]["check"], "VALUE > 0");
|
|
drop(c);
|
|
drop(restarted);
|
|
let _again = restart_at(data.path())?;
|
|
let mut c = connect()?;
|
|
let repeated: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"describe_table","table":"__system_domain_qty"}"#,
|
|
))?;
|
|
assert_eq!(repeated, recovered);
|
|
Ok(())
|
|
}
|
|
|
|
/// 嵌套类型依赖与重启持久化
|
|
#[test]
|
|
fn nested_domains_preserve_dependencies_across_restart() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_srv, data) = start_server();
|
|
let mut connection = connect()?;
|
|
request_ok(
|
|
&mut connection,
|
|
serde_json::json!({"method":"create_table","table":"__system_domain_root","columns":[{"name":"base","ty":"int"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut connection,
|
|
serde_json::json!({"method":"create_table","table":"__system_domain_child","columns":[{"name":"base","ty":"domain(root,int)"}]}),
|
|
)?;
|
|
let rejected: serde_json::Value = serde_json::from_str(&send(
|
|
&mut connection,
|
|
r#"{"method":"drop_table","table":"__system_domain_root"}"#,
|
|
))?;
|
|
assert_eq!(rejected["status"], "error");
|
|
request_ok(
|
|
&mut connection,
|
|
serde_json::json!({"method":"create_table","table":"typed","columns":[{"name":"id","ty":"domain(child,domain(root,int))"}]}),
|
|
)?;
|
|
let forged: serde_json::Value = serde_json::from_str(&send(
|
|
&mut connection,
|
|
r#"{"method":"create_table","table":"forged","columns":[{"name":"id","ty":"domain(child,domain(root,str))"}]}"#,
|
|
))?;
|
|
assert_eq!(forged["status"], "error");
|
|
drop(connection);
|
|
CURRENT.with(|c| *c.borrow_mut() = None);
|
|
drop(_srv);
|
|
let _reopened = start_server_in(data.path());
|
|
let mut connection = connect()?;
|
|
let schema: serde_json::Value = serde_json::from_str(&send(
|
|
&mut connection,
|
|
r#"{"method":"describe_table","table":"typed"}"#,
|
|
))?;
|
|
assert_eq!(schema["status"], "schema");
|
|
assert_eq!(schema["columns"][0]["ty"], "domain(child,domain(root,int))");
|
|
request_ok(
|
|
&mut connection,
|
|
serde_json::json!({"method":"drop_table","table":"typed"}),
|
|
)?;
|
|
request_ok(
|
|
&mut connection,
|
|
serde_json::json!({"method":"drop_table","table":"__system_domain_child"}),
|
|
)?;
|
|
request_ok(
|
|
&mut connection,
|
|
serde_json::json!({"method":"drop_table","table":"__system_domain_root"}),
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// 重置与结构修复保留业务文件及系统数据
|
|
#[test]
|
|
fn offline_maintenance_preserves_data_and_rejects_live_storage()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
let data = tempfile::tempdir()?;
|
|
let config = write_config(data.path());
|
|
let business = data.path().join("databases/business");
|
|
std::fs::create_dir_all(&business)?;
|
|
std::fs::write(business.join("sentinel"), b"business data")?;
|
|
let types = type_seed_request()["types"].clone();
|
|
let request = serde_json::json!({"mode":"reset","user":"root","password_hash":"original-hash","types":types});
|
|
Storage::maintenance(config.to_str(), &request.to_string())?;
|
|
let system = data.path().join("system");
|
|
let credentials = std::fs::read(system.join("__system_users.db"))?;
|
|
let mut catalog: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(system.join("catalog.json"))?)?;
|
|
catalog["tables"]["__system_identities"]["columns"] = serde_json::json!([]);
|
|
std::fs::write(system.join("catalog.json"), serde_json::to_vec(&catalog)?)?;
|
|
let repair = serde_json::json!({"mode":"repair","types":types});
|
|
Storage::maintenance(config.to_str(), &repair.to_string())?;
|
|
assert_eq!(
|
|
std::fs::read(system.join("__system_users.db"))?,
|
|
credentials
|
|
);
|
|
assert_eq!(std::fs::read(business.join("sentinel"))?, b"business data");
|
|
let opened = Storage::open(config.to_str())?;
|
|
assert!(
|
|
Storage::maintenance(config.to_str(), &request.to_string())
|
|
.unwrap_err()
|
|
.contains("data directory is in use")
|
|
);
|
|
drop(opened);
|
|
std::fs::remove_file(system.join("__system_users.db"))?;
|
|
let before = std::fs::read(system.join("catalog.json"))?;
|
|
let failure = Storage::maintenance(config.to_str(), &repair.to_string()).unwrap_err();
|
|
assert!(failure.contains("repair would require"));
|
|
assert_eq!(std::fs::read(system.join("catalog.json"))?, before);
|
|
assert!(!system.join("__system_users.db").exists());
|
|
Ok(())
|
|
}
|
|
|
|
/// 从账号 WAL 恢复缺失凭据文件
|
|
#[test]
|
|
fn offline_recovery_replays_account_wal() -> Result<(), Box<dyn std::error::Error>> {
|
|
let data = tempfile::tempdir()?;
|
|
let config = write_config(data.path());
|
|
let types = type_seed_request()["types"].clone();
|
|
Storage::maintenance(config.to_str(), &serde_json::json!({"mode":"reset","user":"root","password_hash":"original-hash","types":types}).to_string())?;
|
|
let storage = Storage::open(config.to_str())?;
|
|
let accounts: serde_json::Value =
|
|
serde_json::from_str(&storage.request_line(r#"{"method":"accounts_list"}"#))?;
|
|
let saved = serde_json::from_value(accounts["accounts"].clone())?;
|
|
drop(storage);
|
|
let system = data.path().join("system");
|
|
let wal = chusql_core_storage::wal::Wal::new(system.join("wal.log"));
|
|
wal.append(&chusql_core_storage::wal::WalOp::Accounts {
|
|
accounts: saved,
|
|
removed: Vec::new(),
|
|
migrate_roles: false,
|
|
})?;
|
|
wal.append(&chusql_core_storage::wal::WalOp::Commit)?;
|
|
drop(wal);
|
|
std::fs::remove_file(system.join("__system_users.db"))?;
|
|
Storage::maintenance(
|
|
config.to_str(),
|
|
&serde_json::json!({"mode":"recover","types":types}).to_string(),
|
|
)?;
|
|
let storage = Storage::open(config.to_str())?;
|
|
let restored: serde_json::Value =
|
|
serde_json::from_str(&storage.request_line(r#"{"method":"accounts_list"}"#))?;
|
|
assert_eq!(restored["accounts"], accounts["accounts"]);
|
|
drop(storage);
|
|
std::fs::write(system.join("catalog.json"), b"corrupted catalog")?;
|
|
Storage::maintenance(
|
|
config.to_str(),
|
|
&serde_json::json!({"mode":"recover","types":types}).to_string(),
|
|
)?;
|
|
let storage = Storage::open(config.to_str())?;
|
|
let reconstructed: serde_json::Value =
|
|
serde_json::from_str(&storage.request_line(r#"{"method":"accounts_list"}"#))?;
|
|
assert_eq!(reconstructed["accounts"], accounts["accounts"]);
|
|
Ok(())
|
|
}
|
|
|
|
/// 一次会话就是当前线程的存储句柄
|
|
struct Conn(Arc<Storage>);
|
|
|
|
/// 扫描能按列投影,空列返回空行
|
|
#[test]
|
|
fn scan_projects_columns_and_preserves_empty_rows() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"items","columns":[{"name":"id","ty":"int"},{"name":"name","ty":"str"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert_batch","table":"items","rows":[{"id":1,"name":"one"},{"id":2,"name":null}]}),
|
|
)?;
|
|
for (columns, expected) in [
|
|
(
|
|
serde_json::json!(["name"]),
|
|
serde_json::json!([{"name":"one"},{"name":null}]),
|
|
),
|
|
(serde_json::json!([]), serde_json::json!([{}, {}])),
|
|
] {
|
|
let result: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
&serde_json::json!({"method":"scan","table":"items","columns":columns}).to_string(),
|
|
))?;
|
|
assert_eq!(result["rows"], expected);
|
|
}
|
|
let bad: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"scan","table":"items","columns":["missing"]}"#,
|
|
))?;
|
|
assert_eq!(bad["status"], "error");
|
|
Ok(())
|
|
}
|
|
|
|
/// 分片扫描按页序拼接后与整表扫描逐行一致
|
|
#[test]
|
|
fn scan_shard_concatenates_to_the_whole_scan() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_srv, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"wide","columns":[{"name":"id","ty":"int"},{"name":"name","ty":"str"}]}),
|
|
)?;
|
|
let rows: Vec<serde_json::Value> = (1..=1200)
|
|
.map(|i| serde_json::json!({"id": i, "name": format!("row-{i:04}")}))
|
|
.collect();
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert_batch","table":"wide","rows":rows}),
|
|
)?;
|
|
let heap_bytes = std::fs::metadata(data.path().join("databases/main/wide.db"))?.len();
|
|
assert!(
|
|
heap_bytes > 4 * 4096,
|
|
"分片要有意义得多页,实际只有 {heap_bytes} 字节"
|
|
);
|
|
let whole: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"wide"}"#))?;
|
|
let all = whole["rows"].as_array().expect("rows").clone();
|
|
assert_eq!(all.len(), 1200);
|
|
for shards in [1, 3, 4, 7, 16] {
|
|
let mut merged = Vec::new();
|
|
for shard in 0..shards {
|
|
let request = serde_json::json!({"method":"scan_shard","table":"wide","shard":shard,"shards":shards});
|
|
let part: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, &request.to_string()))?;
|
|
assert_eq!(
|
|
part["status"], "rows",
|
|
"shards={shards} shard={shard}: {part}"
|
|
);
|
|
merged.extend(part["rows"].as_array().expect("rows").iter().cloned());
|
|
}
|
|
assert_eq!(
|
|
serde_json::Value::Array(merged),
|
|
serde_json::Value::Array(all.clone()),
|
|
"{shards} 个分片拼接后必须与整表同序"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 分片扫描支持投影与空列,越界分片和未知名都明确报错
|
|
#[test]
|
|
fn scan_shard_projects_columns_and_rejects_bad_ranges() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"items","columns":[{"name":"id","ty":"int"},{"name":"name","ty":"str"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert_batch","table":"items","rows":[{"id":1,"name":"one"},{"id":2,"name":null}]}),
|
|
)?;
|
|
for (columns, expected) in [
|
|
(
|
|
serde_json::json!(["name"]),
|
|
serde_json::json!([{"name":"one"},{"name":null}]),
|
|
),
|
|
(serde_json::json!([]), serde_json::json!([{}, {}])),
|
|
] {
|
|
let mut merged = Vec::new();
|
|
for shard in 0..3 {
|
|
let request = serde_json::json!({"method":"scan_shard","table":"items","columns":columns,"shard":shard,"shards":3});
|
|
let part: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, &request.to_string()))?;
|
|
assert_eq!(part["status"], "rows", "{part}");
|
|
merged.extend(part["rows"].as_array().expect("rows").iter().cloned());
|
|
}
|
|
assert_eq!(serde_json::Value::Array(merged), expected);
|
|
}
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"blank","columns":[{"name":"id","ty":"int"}]}),
|
|
)?;
|
|
for (line, why) in [
|
|
(
|
|
r#"{"method":"scan_shard","table":"blank","shard":0,"shards":4}"#,
|
|
"空表",
|
|
),
|
|
(
|
|
r#"{"method":"scan_shard","table":"items","shard":3,"shards":3}"#,
|
|
"分片号越界",
|
|
),
|
|
(
|
|
r#"{"method":"scan_shard","table":"items","shard":0,"shards":0}"#,
|
|
"分片数为零",
|
|
),
|
|
(
|
|
r#"{"method":"scan_shard","table":"missing","shard":0,"shards":2}"#,
|
|
"未知表",
|
|
),
|
|
(
|
|
r#"{"method":"scan_shard","table":"items","columns":["missing"],"shard":0,"shards":2}"#,
|
|
"未知列",
|
|
),
|
|
] {
|
|
let result: serde_json::Value = serde_json::from_str(&send(&mut c, line))?;
|
|
if why == "空表" {
|
|
assert_eq!(result["rows"], serde_json::json!([]), "{line}");
|
|
} else {
|
|
assert_eq!(result["status"], "error", "{why}: {line} -> {result}");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 多线程并发分片读同一实例,各分片结果与整表一致
|
|
#[test]
|
|
fn concurrent_shard_reads_share_one_instance() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"wide","columns":[{"name":"id","ty":"int"},{"name":"name","ty":"str"}]}),
|
|
)?;
|
|
let rows: Vec<serde_json::Value> = (1..=600)
|
|
.map(|i| serde_json::json!({"id": i, "name": format!("row-{i:04}")}))
|
|
.collect();
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert_batch","table":"wide","rows":rows}),
|
|
)?;
|
|
|
|
let storage = CURRENT.with(|slot| slot.borrow().as_ref().cloned().expect("storage opened"));
|
|
let shards = 4;
|
|
let parts = std::thread::scope(|scope| {
|
|
let handles: Vec<_> = (0..shards)
|
|
.map(|shard| {
|
|
let storage = Arc::clone(&storage);
|
|
scope.spawn(move || {
|
|
let request = format!(
|
|
r#"{{"method":"scan_shard","database":"{FIXTURE_DATABASE}","table":"wide","shard":{shard},"shards":{shards}}}"#
|
|
);
|
|
let mut rows = Vec::new();
|
|
for _ in 0..4 {
|
|
let reply = storage.request_line(&request);
|
|
let value: serde_json::Value = serde_json::from_str(&reply).expect("json");
|
|
assert_eq!(value["status"], "rows", "{reply}");
|
|
rows = value["rows"].as_array().expect("rows").clone();
|
|
}
|
|
rows
|
|
})
|
|
})
|
|
.collect();
|
|
handles
|
|
.into_iter()
|
|
.map(|h| h.join().expect("worker thread"))
|
|
.collect::<Vec<_>>()
|
|
});
|
|
|
|
let whole: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"wide"}"#))?;
|
|
let merged: Vec<serde_json::Value> = parts.into_iter().flatten().collect();
|
|
assert_eq!(merged.len(), 600, "并发分片不能丢行");
|
|
assert_eq!(serde_json::Value::Array(merged), whole["rows"]);
|
|
Ok(())
|
|
}
|
|
|
|
/// 分片读与同表写并发时读者只看到一致快照
|
|
#[test]
|
|
fn concurrent_shard_reads_exclude_writers() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"wide","columns":[{"name":"id","ty":"int"},{"name":"name","ty":"str"}]}),
|
|
)?;
|
|
let rows: Vec<serde_json::Value> = (1..=400)
|
|
.map(|i| serde_json::json!({"id": i, "name": format!("row-{i:04}")}))
|
|
.collect();
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert_batch","table":"wide","rows":rows}),
|
|
)?;
|
|
|
|
let storage = CURRENT.with(|slot| slot.borrow().as_ref().cloned().expect("storage opened"));
|
|
let shards = 3;
|
|
let inserted = 20;
|
|
std::thread::scope(|scope| {
|
|
let writer = {
|
|
let storage = Arc::clone(&storage);
|
|
scope.spawn(move || {
|
|
for i in 401..=400 + inserted {
|
|
let request = format!(
|
|
r#"{{"method":"insert","database":"{FIXTURE_DATABASE}","table":"wide","row":{{"id":{i},"name":"row-{i:04}"}}}}"#
|
|
);
|
|
let reply = storage.request_line(&request);
|
|
let value: serde_json::Value = serde_json::from_str(&reply).expect("json");
|
|
assert_eq!(value["status"], "ok", "{reply}");
|
|
}
|
|
})
|
|
};
|
|
let readers: Vec<_> = (0..3)
|
|
.map(|_| {
|
|
let storage = Arc::clone(&storage);
|
|
scope.spawn(move || {
|
|
let mut seen = Vec::new();
|
|
for _ in 0..8 {
|
|
let mut total = 0usize;
|
|
for shard in 0..shards {
|
|
let request = format!(
|
|
r#"{{"method":"scan_shard","database":"{FIXTURE_DATABASE}","table":"wide","shard":{shard},"shards":{shards}}}"#
|
|
);
|
|
let reply = storage.request_line(&request);
|
|
let value: serde_json::Value = serde_json::from_str(&reply).expect("json");
|
|
assert_eq!(value["status"], "rows", "{reply}");
|
|
total += value["rows"].as_array().expect("rows").len();
|
|
}
|
|
seen.push(total);
|
|
}
|
|
seen
|
|
})
|
|
})
|
|
.collect();
|
|
for reader in readers {
|
|
let seen: Vec<usize> = reader.join().expect("reader thread");
|
|
assert!(
|
|
seen.iter().all(|n| (400..=400 + inserted).contains(n)),
|
|
"分片读看到越界行数: {seen:?}"
|
|
);
|
|
assert!(
|
|
seen.windows(2).all(|pair| pair[0] <= pair[1]),
|
|
"并发写时快照行数倒退: {seen:?}"
|
|
);
|
|
}
|
|
writer.join().expect("writer thread");
|
|
});
|
|
|
|
let whole: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"wide"}"#))?;
|
|
assert_eq!(
|
|
whole["rows"].as_array().expect("rows").len(),
|
|
400 + inserted
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 跨库并发互不串数据,目录查询不被写饿死
|
|
#[test]
|
|
fn concurrent_requests_across_databases_stay_isolated() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect()?;
|
|
let request =
|
|
|c: &mut Conn, value: serde_json::Value| -> Result<serde_json::Value, serde_json::Error> {
|
|
serde_json::from_str(&send(c, &value.to_string()))
|
|
};
|
|
let databases = ["alpha", "beta"];
|
|
for database in databases {
|
|
assert_eq!(
|
|
request(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_database","database":database})
|
|
)?["status"],
|
|
"ok"
|
|
);
|
|
assert_eq!(
|
|
request(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","database":database,"table":"items","columns":[{"name":"id","ty":"int"}]})
|
|
)?["status"],
|
|
"ok"
|
|
);
|
|
}
|
|
|
|
let storage = CURRENT.with(|slot| slot.borrow().as_ref().cloned().expect("storage opened"));
|
|
let written = 40;
|
|
std::thread::scope(|scope| {
|
|
let writers: Vec<_> = databases
|
|
.iter()
|
|
.map(|database| {
|
|
let storage = Arc::clone(&storage);
|
|
let database = database.to_string();
|
|
scope.spawn(move || {
|
|
for i in 1..=written {
|
|
let line = format!(
|
|
r#"{{"method":"insert","database":"{database}","table":"items","row":{{"id":{i}}}}}"#
|
|
);
|
|
let value: serde_json::Value = serde_json::from_str(&storage.request_line(&line)).expect("json");
|
|
assert_eq!(value["status"], "ok", "{database}: {value}");
|
|
}
|
|
database
|
|
})
|
|
})
|
|
.collect();
|
|
let catalog = {
|
|
let storage = Arc::clone(&storage);
|
|
scope.spawn(move || {
|
|
for _ in 0..40 {
|
|
for line in [
|
|
r#"{"method":"list_databases"}"#,
|
|
r#"{"method":"all_catalogs"}"#,
|
|
] {
|
|
let value: serde_json::Value =
|
|
serde_json::from_str(&storage.request_line(line)).expect("json");
|
|
assert_ne!(value["status"], "error", "{line}: {value}");
|
|
}
|
|
}
|
|
})
|
|
};
|
|
for writer in writers {
|
|
writer.join().expect("writer thread");
|
|
}
|
|
catalog.join().expect("catalog thread");
|
|
});
|
|
|
|
for database in databases {
|
|
let rows = request(
|
|
&mut c,
|
|
serde_json::json!({"method":"scan","database":database,"table":"items"}),
|
|
)?["rows"]
|
|
.clone();
|
|
assert_eq!(
|
|
rows.as_array().expect("rows").len(),
|
|
written,
|
|
"{database} 行数不对"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 删列不改堆字节,重启后不再暴露该列
|
|
#[test]
|
|
fn drop_column_keeps_heap_bytes_and_hides_values_after_restart()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"items","columns":[{"name":"id","ty":"int"},{"name":"name","ty":"str"},{"name":"age","ty":"int"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert_batch","table":"items","rows":[{"id":1,"name":"old","age":20},{"id":2,"name":"secret","age":30}]}),
|
|
)?;
|
|
for column in ["name", "age"] {
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_index","table":"items","column":column}),
|
|
)?;
|
|
}
|
|
let heap = data.path().join("databases/main/items.db");
|
|
let before = std::fs::read(&heap)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"drop_column","table":"items","column":"name"}),
|
|
)?;
|
|
assert_eq!(
|
|
std::fs::read(&heap)?,
|
|
before,
|
|
"DROP COLUMN must not rewrite heap pages"
|
|
);
|
|
drop(c);
|
|
drop(server);
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
for request in [
|
|
serde_json::json!({"method":"scan","table":"items"}),
|
|
serde_json::json!({"method":"range_by_index","table":"items","column":"age","lo":0}),
|
|
] {
|
|
let result: serde_json::Value = serde_json::from_str(&send(&mut c, &request.to_string()))?;
|
|
assert_eq!(
|
|
result["rows"],
|
|
serde_json::json!([{"id":1,"age":20},{"id":2,"age":30}])
|
|
);
|
|
}
|
|
let lookup: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"items","column":"id","key":1}"#,
|
|
))?;
|
|
assert_eq!(lookup["rows"], serde_json::json!([{"id":1,"age":20}]));
|
|
let schema: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"describe_table","table":"items"}"#,
|
|
))?;
|
|
assert_eq!(schema["row_count"], 2);
|
|
assert!(
|
|
schema["columns"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.all(|col| col["name"] != "name")
|
|
);
|
|
assert!(
|
|
schema["stats"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.all(|col| col["name"] != "name")
|
|
);
|
|
let bad: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"items","row":{"id":3,"name":"resurrect"}}"#,
|
|
))?;
|
|
assert_eq!(bad["status"], "error");
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":3,"age":40}}),
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// 各库字典与行互相隔离,删库不影响别库
|
|
#[test]
|
|
fn databases_isolate_catalog_and_rows() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_srv, data) = start_server();
|
|
let mut c = connect()?;
|
|
let request =
|
|
|c: &mut Conn, value: serde_json::Value| -> Result<serde_json::Value, serde_json::Error> {
|
|
serde_json::from_str(&send(c, &value.to_string()))
|
|
};
|
|
for database in ["alpha", "beta"] {
|
|
assert_eq!(
|
|
request(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_database","database":database})
|
|
)?["status"],
|
|
"ok"
|
|
);
|
|
assert_eq!(
|
|
request(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","database":database,"table":"items","columns":[{"name":"id","ty":"int"}]})
|
|
)?["status"],
|
|
"ok"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
request(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","database":"alpha","table":"items","row":{"id":1}})
|
|
)?["status"],
|
|
"ok"
|
|
);
|
|
assert_eq!(
|
|
request(
|
|
&mut c,
|
|
serde_json::json!({"method":"scan","database":"alpha","table":"items"})
|
|
)?["rows"],
|
|
serde_json::json!([{"id":1}])
|
|
);
|
|
assert_eq!(
|
|
request(
|
|
&mut c,
|
|
serde_json::json!({"method":"scan","database":"beta","table":"items"})
|
|
)?["rows"],
|
|
serde_json::json!([])
|
|
);
|
|
assert_eq!(
|
|
request(&mut c, serde_json::json!({"method":"list_tables"}))?["tables"],
|
|
serde_json::json!([])
|
|
);
|
|
assert!(data.path().join("databases/main/catalog.json").is_file());
|
|
assert!(data.path().join("databases/alpha/catalog.json").is_file());
|
|
assert!(data.path().join("databases/beta/catalog.json").is_file());
|
|
assert_eq!(
|
|
request(
|
|
&mut c,
|
|
serde_json::json!({"method":"drop_database","database":"alpha"})
|
|
)?["status"],
|
|
"ok"
|
|
);
|
|
assert_eq!(
|
|
request(
|
|
&mut c,
|
|
serde_json::json!({"method":"scan","database":"alpha","table":"items"})
|
|
)?["status"],
|
|
"error"
|
|
);
|
|
assert_eq!(
|
|
request(
|
|
&mut c,
|
|
serde_json::json!({"method":"scan","database":"beta","table":"items"})
|
|
)?["rows"],
|
|
serde_json::json!([])
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 隐藏列的 WAL 重放两次幂等,且不动堆文件
|
|
#[test]
|
|
fn hidden_column_wal_replays_twice_without_touching_heap() -> Result<(), Box<dyn std::error::Error>>
|
|
{
|
|
use chusql_core_storage::wal::{Wal, WalOp};
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":1,"secret":"old"}}),
|
|
)?;
|
|
drop(c);
|
|
drop(server);
|
|
let heap = data.path().join("databases/main/items.db");
|
|
let mut compacted: Option<Vec<u8>> = None;
|
|
for round in 0..2 {
|
|
let wal = Wal::new(data.path().join("databases/main/wal.log"));
|
|
let op = WalOp::HideColumn {
|
|
table: "items".into(),
|
|
column: "secret".into(),
|
|
schema_version: None,
|
|
};
|
|
wal.truncate()?;
|
|
wal.append(&op)?;
|
|
assert_eq!(wal.read()?, Some(op));
|
|
assert!(std::fs::metadata(wal.path())?.len() < 100);
|
|
drop(wal);
|
|
let server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let result: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(result["rows"], serde_json::json!([{"id":1}]));
|
|
let after = std::fs::read(&heap)?;
|
|
assert!(
|
|
!after.windows(6).any(|w| w == &b"secret"[..]),
|
|
"启动整理要把隐藏列的字节清掉"
|
|
);
|
|
if round == 1 {
|
|
assert_eq!(
|
|
Some(after.clone()),
|
|
compacted,
|
|
"整理过的表第二次启动不该再重写"
|
|
);
|
|
}
|
|
compacted = Some(after);
|
|
assert_eq!(
|
|
std::fs::metadata(data.path().join("databases/main/wal.log"))?.len(),
|
|
0
|
|
);
|
|
drop(c);
|
|
drop(server);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 删列失败时保留 WAL,重启后补做并放行
|
|
#[test]
|
|
fn hidden_column_failure_preserves_wal_until_recovery() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":1,"secret":"old"}}),
|
|
)?;
|
|
let index = data.path().join("databases/main/items.secret.idx");
|
|
std::fs::create_dir(&index)?;
|
|
let failed: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"drop_column","table":"items","column":"secret"}"#,
|
|
))?;
|
|
assert_eq!(failed["status"], "error");
|
|
assert!(std::fs::metadata(data.path().join("databases/main/wal.log"))?.len() > 0);
|
|
let blocked: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"items","row":{"id":2}}"#,
|
|
))?;
|
|
assert_eq!(blocked["status"], "error");
|
|
assert!(
|
|
blocked["message"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("recovery required")
|
|
);
|
|
drop(c);
|
|
drop(server);
|
|
std::fs::remove_dir(index)?;
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(rows["rows"], serde_json::json!([{"id":1}]));
|
|
Ok(())
|
|
}
|
|
|
|
/// 删带索引的列会连索引文件一起删掉
|
|
#[test]
|
|
fn drop_column_with_a_live_index_removes_the_index_file() -> Result<(), Box<dyn std::error::Error>>
|
|
{
|
|
let (_srv, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":1,"code":7}}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_index","table":"items","column":"code"}),
|
|
)?;
|
|
let index = data.path().join("databases/main/items.code.idx");
|
|
assert!(index.is_file(), "index file exists after create_index");
|
|
|
|
let dropped: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"drop_column","table":"items","column":"code"}"#,
|
|
))?;
|
|
assert_eq!(
|
|
dropped["status"], "ok",
|
|
"drop_column with a live index: {dropped}"
|
|
);
|
|
assert!(!index.exists(), "index file is removed");
|
|
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(rows["rows"], serde_json::json!([{"id":1}]));
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":2}}),
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// 整理表会重写页面,清掉隐藏列字节
|
|
#[test]
|
|
fn compact_reclaims_hidden_column_bytes() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
for id in 1..=3 {
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":id,"secret":"padding","note":"n"}}),
|
|
)?;
|
|
}
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"drop_column","table":"items","column":"secret"}),
|
|
)?;
|
|
let heap = data.path().join("databases/main/items.db");
|
|
assert!(
|
|
std::fs::read(&heap)?
|
|
.windows(6)
|
|
.any(|w| w == &b"secret"[..]),
|
|
"fast path keeps the bytes"
|
|
);
|
|
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"compact","table":"items"}),
|
|
)?;
|
|
let heap = active_heap(data.path(), "items")?;
|
|
assert!(
|
|
!std::fs::read(&heap)?
|
|
.windows(6)
|
|
.any(|w| w == &b"secret"[..]),
|
|
"compaction rewrites the pages"
|
|
);
|
|
assert_eq!(
|
|
std::fs::metadata(data.path().join("databases/main/wal.log"))?.len(),
|
|
0
|
|
);
|
|
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(
|
|
rows["rows"],
|
|
serde_json::json!([{"id":1,"note":"n"},{"id":2,"note":"n"},{"id":3,"note":"n"}])
|
|
);
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":4,"note":"x"}}),
|
|
)?;
|
|
drop(c);
|
|
drop(server);
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(
|
|
rows["rows"].as_array().map(Vec::len),
|
|
Some(4),
|
|
"rows survive compaction: {}",
|
|
rows
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 启动时自动整理带隐藏列的表
|
|
#[test]
|
|
fn startup_compaction_reclaims_dropped_columns() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":1,"secret":"padding"}}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"drop_column","table":"items","column":"secret"}),
|
|
)?;
|
|
let heap = data.path().join("databases/main/items.db");
|
|
assert!(
|
|
std::fs::read(&heap)?
|
|
.windows(6)
|
|
.any(|w| w == &b"secret"[..])
|
|
);
|
|
drop(c);
|
|
drop(server);
|
|
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
assert!(
|
|
!std::fs::read(&heap)?
|
|
.windows(6)
|
|
.any(|w| w == &b"secret"[..]),
|
|
"startup must compact tables that still carry hidden columns"
|
|
);
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(rows["rows"], serde_json::json!([{"id":1}]));
|
|
Ok(())
|
|
}
|
|
|
|
/// 没有隐藏列时整理表只原地回收页内空间
|
|
#[test]
|
|
fn compact_defragments_pages_without_dropped_columns() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_srv, data) = start_server();
|
|
let mut c = connect()?;
|
|
let pad = "p".repeat(200);
|
|
for id in 1..=5 {
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":id,"note":pad}}),
|
|
)?;
|
|
}
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":6,"note":"doomed-row-marker"}}),
|
|
)?;
|
|
for id in 7..=11 {
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":id,"note":pad}}),
|
|
)?;
|
|
}
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"delete_keys","table":"items","keys":[6]}),
|
|
)?;
|
|
|
|
let heap = data.path().join("databases/main/items.db");
|
|
assert!(
|
|
std::fs::read(&heap)?
|
|
.windows(17)
|
|
.any(|w| w == &b"doomed-row-marker"[..]),
|
|
"删行只留墓碑,字节还在页里"
|
|
);
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"compact","table":"items"}),
|
|
)?;
|
|
let heap = active_heap(data.path(), "items")?;
|
|
assert!(
|
|
!std::fs::read(&heap)?
|
|
.windows(17)
|
|
.any(|w| w == &b"doomed-row-marker"[..]),
|
|
"原地整理要把墓碑字节收掉"
|
|
);
|
|
assert_eq!(
|
|
std::fs::metadata(data.path().join("databases/main/wal.log"))?.len(),
|
|
0
|
|
);
|
|
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(
|
|
rows["rows"].as_array().map(Vec::len),
|
|
Some(10),
|
|
"rows survive compaction: {}",
|
|
rows
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 非法库名被拒;system 是唯一保留库
|
|
#[test]
|
|
fn databases_reject_unsafe_names_and_keep_only_system_reserved()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
let (_srv, data) = start_server();
|
|
let mut c = connect()?;
|
|
for database in ["", "../escape", "a/b", "a.b", "CON", "__system_users"] {
|
|
let response: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
&serde_json::json!({"method":"create_database","database":database}).to_string(),
|
|
))?;
|
|
assert_eq!(response["status"], "error", "{database}: {response}");
|
|
}
|
|
// system 是唯一的保留库:不能建、不能删
|
|
for method in ["create_database", "drop_database"] {
|
|
let response: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
&serde_json::json!({"method":method,"database":"system"}).to_string(),
|
|
))?;
|
|
assert_eq!(response["status"], "error", "{method}: {response}");
|
|
}
|
|
// test 不再是保留名:能建、也能删(不再有默认工作库)
|
|
let created: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
&serde_json::json!({"method":"create_database","database":"test"}).to_string(),
|
|
))?;
|
|
assert_eq!(created["status"], "ok", "{created}");
|
|
assert!(data.path().join("databases/test/catalog.json").is_file());
|
|
let dropped: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
&serde_json::json!({"method":"drop_database","database":"test"}).to_string(),
|
|
))?;
|
|
assert_eq!(dropped["status"], "ok", "{dropped}");
|
|
assert!(!data.path().join("databases/test").exists());
|
|
assert!(data.path().join("system/catalog.json").is_file());
|
|
Ok(())
|
|
}
|
|
|
|
/// 账号只存在 system 库,默认工作库不存在
|
|
#[test]
|
|
fn system_database_owns_accounts_and_nothing_is_default() -> Result<(), Box<dyn std::error::Error>>
|
|
{
|
|
let (_srv, data) = start_server();
|
|
let mut c = connect()?;
|
|
// 启动只建 system,另一个是测试自己建的 main
|
|
let listed: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"list_databases"}"#))?;
|
|
assert_eq!(listed["tables"], serde_json::json!(["main", "system"]));
|
|
let created: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"account_create","user":"alice","password_hash":"hash-1"}"#,
|
|
))?;
|
|
assert_eq!(created["status"], "accounts", "{created}");
|
|
assert!(data.path().join("system/__system_users.db").is_file());
|
|
// 账号是全局的:请求里带的库名对账号操作不起作用
|
|
let accounts: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"accounts_list","database":"sales"}"#,
|
|
))?;
|
|
assert_eq!(accounts["status"], "accounts");
|
|
assert_eq!(accounts["accounts"][0]["user"], "alice");
|
|
// 裸表名落到选中的库(测试补的是 main)
|
|
assert_eq!(
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"items","columns":[{"name":"id","ty":"int"}]})
|
|
)?,
|
|
()
|
|
);
|
|
assert!(data.path().join("databases/main/items.db").is_file());
|
|
assert!(!data.path().join("items.db").exists());
|
|
assert!(!data.path().join("test").exists());
|
|
Ok(())
|
|
}
|
|
|
|
/// 裸表名没选库会被拒;限定名不用选库
|
|
#[test]
|
|
fn unqualified_requests_need_a_selected_database() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect()?;
|
|
let response: serde_json::Value =
|
|
serde_json::from_str(&send_raw(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(response["status"], "error", "{response}");
|
|
assert!(
|
|
response["message"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("no database selected"),
|
|
"{response}"
|
|
);
|
|
let created: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"create_table","table":"items","columns":[{"name":"id","ty":"int"}]}"#,
|
|
))?;
|
|
assert_eq!(created["status"], "ok", "{created}");
|
|
let qualified: serde_json::Value = serde_json::from_str(&send_raw(
|
|
&mut c,
|
|
r#"{"method":"scan","table":"main.items"}"#,
|
|
))?;
|
|
assert_eq!(qualified["status"], "rows", "{qualified}");
|
|
let unknown: serde_json::Value = serde_json::from_str(&send_raw(
|
|
&mut c,
|
|
r#"{"method":"scan","table":"ghost.items"}"#,
|
|
))?;
|
|
assert_eq!(unknown["status"], "error", "{unknown}");
|
|
assert!(
|
|
unknown["message"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("unknown database"),
|
|
"{unknown}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 发一条请求并断言状态是 ok
|
|
fn request_ok(c: &mut Conn, value: serde_json::Value) -> Result<(), Box<dyn std::error::Error>> {
|
|
let response: serde_json::Value = serde_json::from_str(&send(c, &value.to_string()))?;
|
|
assert_eq!(response["status"], "ok", "{value}: {response}");
|
|
Ok(())
|
|
}
|
|
|
|
/// 一次存储实例的持有者
|
|
struct ServerProc;
|
|
|
|
impl Drop for ServerProc {
|
|
/// 实例由本线程 CURRENT 管,这里不用清理
|
|
fn drop(&mut self) {}
|
|
}
|
|
|
|
/// 起存储,返回句柄与数据目录
|
|
fn start_server() -> (ServerProc, tempfile::TempDir) {
|
|
let data = tempfile::tempdir().unwrap();
|
|
let server = start_server_in(data.path());
|
|
(server, data)
|
|
}
|
|
|
|
/// 在指定数据目录起存储、引导系统目录并建测试库
|
|
fn start_server_in(data: &std::path::Path) -> ServerProc {
|
|
open_storage(&write_config(data));
|
|
bootstrap_system();
|
|
create_fixture_database();
|
|
ServerProc
|
|
}
|
|
|
|
/// 按配置文件打开存储,替换本线程上一个实例
|
|
fn open_storage(config: &std::path::Path) {
|
|
CURRENT.with(|slot| *slot.borrow_mut() = None);
|
|
let storage = Arc::new(Storage::open(Some(config.to_str().unwrap())).expect("open storage"));
|
|
CURRENT.with(|slot| *slot.borrow_mut() = Some(storage));
|
|
}
|
|
|
|
/// 生成只写数据目录的临时配置文件
|
|
fn write_config(data: &std::path::Path) -> std::path::PathBuf {
|
|
let id = std::process::id();
|
|
let ns = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
let path = data.join(format!("chusql-{}-{}.toml", id, ns));
|
|
let text = format!("[storage]\ndata_dir = '{}'\n", data.display());
|
|
std::fs::write(&path, text).unwrap();
|
|
path
|
|
}
|
|
|
|
/// 命名库重启后仍在,账号仍是全局的
|
|
#[test]
|
|
fn named_database_survives_restart_and_accounts_stay_global()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
for request in [
|
|
serde_json::json!({"method":"create_database","database":"sales"}),
|
|
serde_json::json!({"method":"create_table","database":"sales","table":"items","columns":[{"name":"id","ty":"int"}]}),
|
|
serde_json::json!({"method":"insert","database":"sales","table":"items","row":{"id":7}}),
|
|
] {
|
|
let result: serde_json::Value = serde_json::from_str(&send(&mut c, &request.to_string()))?;
|
|
assert_eq!(result["status"], "ok");
|
|
}
|
|
drop(c);
|
|
drop(server);
|
|
let _restarted = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"sales.items"}"#))?;
|
|
assert_eq!(rows["rows"], serde_json::json!([{"id":7}]));
|
|
let accounts: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"accounts_list","database":"sales"}"#,
|
|
))?;
|
|
assert_eq!(accounts["status"], "accounts");
|
|
let hidden: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"scan","table":"sales.__system_users"}"#,
|
|
))?;
|
|
assert_eq!(hidden["status"], "error");
|
|
Ok(())
|
|
}
|
|
|
|
/// 连到本线程当前的存储:进程内实现就是本线程当前句柄
|
|
fn connect() -> std::io::Result<Conn> {
|
|
CURRENT
|
|
.with(|slot| slot.borrow().clone())
|
|
.map(Conn)
|
|
.ok_or_else(|| std::io::Error::other("storage not started"))
|
|
}
|
|
|
|
/// 测试用的库名:system 之外的库由测试自建
|
|
const FIXTURE_DATABASE: &str = "main";
|
|
|
|
/// 请求没写库名时补上测试库
|
|
fn with_default_database(line: &str) -> String {
|
|
match serde_json::from_str::<serde_json::Value>(line) {
|
|
Ok(serde_json::Value::Object(mut object)) => {
|
|
object
|
|
.entry("database")
|
|
.or_insert_with(|| serde_json::Value::String(FIXTURE_DATABASE.to_string()));
|
|
serde_json::Value::Object(object).to_string()
|
|
}
|
|
_ => line.to_string(),
|
|
}
|
|
}
|
|
|
|
/// 连通后显式建测试库;重启时库已存在,报错忽略
|
|
fn create_fixture_database() {
|
|
if let Ok(mut c) = connect() {
|
|
let _ = send_raw(
|
|
&mut c,
|
|
&format!(r#"{{"method":"create_database","database":"{FIXTURE_DATABASE}"}}"#),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 引导系统目录:建账号表,不塞账号
|
|
fn bootstrap_system() {
|
|
let reply = CURRENT.with(|slot| {
|
|
let storage = slot.borrow().as_ref().cloned().expect("storage opened");
|
|
storage.request_line(r#"{"method":"bootstrap_system"}"#)
|
|
});
|
|
assert!(reply.contains(r#""status":"system""#), "{reply}");
|
|
}
|
|
|
|
/// 发一行请求读一行响应(没写库名就补测试库)
|
|
fn send(c: &mut Conn, line: &str) -> String {
|
|
send_raw(c, &with_default_database(line))
|
|
}
|
|
|
|
/// 原样发一行请求,不补库名——用来验证"没选库"的行为
|
|
fn send_raw(c: &mut Conn, line: &str) -> String {
|
|
c.0.request_line(line).trim().to_string()
|
|
}
|
|
|
|
/// 只有字典登记的表才算存在,孤儿文件不算
|
|
#[test]
|
|
fn catalog_is_the_only_table_authority() -> std::io::Result<()> {
|
|
let (_srv, data) = start_server();
|
|
std::fs::write(data.path().join("databases/main/orphan.db"), b"orphan")?;
|
|
let mut c = connect()?;
|
|
for method in ["scan", "describe_table"] {
|
|
let result = send(
|
|
&mut c,
|
|
&format!(r#"{{"method":"{method}","table":"orphan"}}"#),
|
|
);
|
|
assert!(result.contains("unknown table"), "{result}");
|
|
}
|
|
let result = send(&mut c, r#"{"method":"list_tables"}"#);
|
|
assert!(!result.contains("orphan"), "{result}");
|
|
Ok(())
|
|
}
|
|
|
|
/// 保留系统表拒绝所有通用操作
|
|
#[test]
|
|
fn reserved_tables_reject_every_generic_operation() -> std::io::Result<()> {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect()?;
|
|
for method in [
|
|
"scan",
|
|
"insert",
|
|
"insert_batch",
|
|
"delete_keys",
|
|
"lookup_by_index",
|
|
"replace_all",
|
|
"describe_table",
|
|
"create_table",
|
|
"drop_table",
|
|
"create_index",
|
|
"drop_index",
|
|
"drop_column",
|
|
] {
|
|
let request = serde_json::json!({"method": method, "table": "__system_users",
|
|
"column": "id", "key": 1, "keys": [], "row": {"id": 1}, "rows": [], "columns": []});
|
|
let result = send(&mut c, &request.to_string());
|
|
assert!(
|
|
result.contains("reserved system table"),
|
|
"{method}: {result}"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 通用接口拒绝表名与列名里的路径别名
|
|
#[test]
|
|
fn generic_ipc_rejects_path_aliases() -> std::io::Result<()> {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect()?;
|
|
for table in ["../__system_users", "__SYSTEM_USERS", "__system_users."] {
|
|
let result = send(
|
|
&mut c,
|
|
&serde_json::json!({"method":"scan", "table":table}).to_string(),
|
|
);
|
|
assert!(result.contains("error"), "{result}");
|
|
}
|
|
let result = send(
|
|
&mut c,
|
|
r#"{"method":"create_index","table":"business","column":"../__system_users"}"#,
|
|
);
|
|
assert!(result.contains("invalid column name"), "{result}");
|
|
Ok(())
|
|
}
|
|
|
|
/// 所有写入入口拒绝非法字段名且不发布事务
|
|
#[test]
|
|
fn write_requests_reject_invalid_column_names() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
let original = serde_json::json!({"id":1,"name":"old"});
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":original}),
|
|
)?;
|
|
let directory = data.path().join("databases/main");
|
|
let mut files = std::fs::read_dir(&directory)?
|
|
.map(|entry| entry.map(|entry| entry.path()))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
files.sort();
|
|
let before = files
|
|
.iter()
|
|
.map(std::fs::read)
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
let columns = serde_json::json!([{"name":"id","ty":"int"},{"name":"name","ty":"str"}]);
|
|
for name in [
|
|
"", "../bad", "a.b", "a/b", "a\\b", "a-b", " bad", "坏列", "\0",
|
|
] {
|
|
let mut row = serde_json::json!({"id":2});
|
|
row[name] = serde_json::json!("bad field");
|
|
for request in [
|
|
serde_json::json!({"method":"insert","table":"items","row":row}),
|
|
serde_json::json!({"method":"insert_batch","table":"items","rows":[{"id":3},row]}),
|
|
serde_json::json!({"method":"replace_all","table":"items","rows":[row]}),
|
|
serde_json::json!({"method":"replace_schema","table":"items","columns":columns,"rows":[row]}),
|
|
serde_json::json!({"method":"apply_transaction","ops":[
|
|
{"op":"delete","table":"items","ids":[1]},
|
|
{"op":"upsert","table":"items","rows":[row]}
|
|
]}),
|
|
serde_json::json!({"method":"apply_transaction","ops":[
|
|
{"op":"delete","table":"items","ids":[1]},
|
|
{"op":"replace","table":"items","rows":[row]}
|
|
]}),
|
|
] {
|
|
let rejected: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, &request.to_string()))?;
|
|
assert_eq!(rejected["status"], "error", "{name:?}: {rejected}");
|
|
assert!(
|
|
rejected["message"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("invalid column name"),
|
|
"{rejected}"
|
|
);
|
|
let after = files
|
|
.iter()
|
|
.map(std::fs::read)
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
assert!(
|
|
after == before,
|
|
"invalid field name changed persisted files"
|
|
);
|
|
}
|
|
}
|
|
let valid =
|
|
serde_json::json!({"id":2,"_note_2":"正文 ../a\\b\0", "payload":{"a/b":"nested value"}});
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":valid}),
|
|
)?;
|
|
drop(c);
|
|
drop(server);
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
let mut expected_original = original;
|
|
expected_original["_note_2"] = serde_json::Value::Null;
|
|
let mut expected_valid = valid;
|
|
expected_valid["name"] = serde_json::Value::Null;
|
|
assert_eq!(
|
|
rows["rows"],
|
|
serde_json::json!([expected_original, expected_valid])
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 新名称校验不阻断旧日志重放及原始读取
|
|
#[test]
|
|
fn legacy_column_names_remain_recoverable() -> Result<(), Box<dyn std::error::Error>> {
|
|
use chusql_core_storage::wal::{Wal, WalOp};
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":1}}),
|
|
)?;
|
|
drop(c);
|
|
drop(server);
|
|
CURRENT.with(|slot| *slot.borrow_mut() = None);
|
|
let directory = data.path().join("databases/main");
|
|
let legacy: chusql_core_storage::protocol::Row =
|
|
serde_json::from_value(serde_json::json!({"id":2,"old.name":"legacy"}))?;
|
|
Wal::new(directory.join("wal.log")).append_group(&[WalOp::Insert {
|
|
table: "items".into(),
|
|
row: legacy.clone(),
|
|
}])?;
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(
|
|
rows["rows"],
|
|
serde_json::json!([{"id":1,"old.name":null},legacy])
|
|
);
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":3,"name":"valid"}}),
|
|
)?;
|
|
assert_eq!(std::fs::metadata(directory.join("wal.log"))?.len(), 0);
|
|
Ok(())
|
|
}
|
|
|
|
/// 账号增改删,且普通列表接口看不到账号表
|
|
#[test]
|
|
fn account_create_reset_drop_and_hidden_from_ordinary_api() -> Result<(), Box<dyn std::error::Error>>
|
|
{
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect()?;
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&send(&mut c, r#"{"method":"accounts_list"}"#))?
|
|
["accounts"],
|
|
serde_json::json!([])
|
|
);
|
|
let create = r#"{"method":"account_create","user":"Alice","password_hash":"hash-one"}"#;
|
|
let created: serde_json::Value = serde_json::from_str(&send(&mut c, create))?;
|
|
assert_eq!(created["accounts"].as_array().map(Vec::len), Some(1));
|
|
assert_eq!(created["accounts"][0]["user"], "alice");
|
|
assert_eq!(created["accounts"][0]["revision"], 1);
|
|
assert_eq!(created["accounts"][0].get("administrator"), None);
|
|
assert!(send(&mut c, create).contains("already exists"));
|
|
let reset = r#"{"method":"account_reset","user":"alice","password_hash":"hash-two"}"#;
|
|
let changed: serde_json::Value = serde_json::from_str(&send(&mut c, reset))?;
|
|
assert_eq!(changed["accounts"][0]["revision"], 2);
|
|
assert_eq!(changed["accounts"][0]["password_hash"], "hash-two");
|
|
assert!(
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"account_reset","user":"nobody","password_hash":"hash-x"}"#
|
|
)
|
|
.contains("unknown account")
|
|
);
|
|
assert!(
|
|
send(&mut c, r#"{"method":"account_drop","user":"nobody"}"#).contains("unknown account")
|
|
);
|
|
let dropped: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"account_drop","user":"alice"}"#))?;
|
|
assert_eq!(dropped["accounts"].as_array().map(Vec::len), Some(0));
|
|
let empty: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"accounts_list"}"#))?;
|
|
assert_eq!(empty["accounts"], serde_json::json!([]));
|
|
for method in ["list_tables", "list_catalog"] {
|
|
let result = send(&mut c, &serde_json::json!({"method":method}).to_string());
|
|
assert!(!result.contains("__system_users"), "{result}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 用同一份数据目录重开一次存储
|
|
fn restart_at(data: &std::path::Path) -> std::io::Result<ServerProc> {
|
|
open_storage(&write_config(data));
|
|
bootstrap_system();
|
|
create_fixture_database();
|
|
Ok(ServerProc)
|
|
}
|
|
|
|
/// 账号快照 WAL 重放两次不产生重复账号
|
|
#[test]
|
|
fn account_snapshot_replays_twice_without_duplicating_accounts()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
use chusql_core_storage::{
|
|
protocol::Account,
|
|
wal::{Wal, WalOp},
|
|
};
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
let initial = send(
|
|
&mut c,
|
|
r#"{"method":"account_create","user":"alice","password_hash":"hash-one"}"#,
|
|
);
|
|
assert!(initial.contains("accounts"), "{initial}");
|
|
let changed: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"account_reset","user":"alice","password_hash":"hash-two"}"#,
|
|
))?;
|
|
let accounts: Vec<Account> = serde_json::from_value(changed["accounts"].clone())?;
|
|
drop(c);
|
|
drop(server);
|
|
for _ in 0..2 {
|
|
let wal = Wal::new(data.path().join("system/wal.log"));
|
|
wal.truncate()?;
|
|
wal.append(&WalOp::Accounts {
|
|
accounts: accounts.clone(),
|
|
removed: Vec::new(),
|
|
migrate_roles: false,
|
|
})?;
|
|
drop(wal);
|
|
std::fs::write(data.path().join("system/__system_users.idx"), b"torn index")?;
|
|
std::fs::write(data.path().join("system/__system_users.db"), b"torn heap")?;
|
|
let restarted = restart_at(data.path())?;
|
|
let mut connection = connect()?;
|
|
let actual: serde_json::Value =
|
|
serde_json::from_str(&send(&mut connection, r#"{"method":"accounts_list"}"#))?;
|
|
assert_eq!(actual, changed);
|
|
assert_eq!(
|
|
std::fs::metadata(data.path().join("system/wal.log"))?.len(),
|
|
0
|
|
);
|
|
drop(connection);
|
|
drop(restarted);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 账号操作失败时保留 WAL 并挡住写入
|
|
#[test]
|
|
fn account_failure_preserves_wal_and_blocks_writes() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
std::fs::create_dir(data.path().join("system/catalog.json.pending"))?;
|
|
let result = send(
|
|
&mut c,
|
|
r#"{"method":"account_create","user":"alice","password_hash":"hash-one"}"#,
|
|
);
|
|
assert!(result.contains("error"), "{result}");
|
|
assert!(std::fs::metadata(data.path().join("system/wal.log"))?.len() > 0);
|
|
for request in [
|
|
r#"{"method":"accounts_list"}"#,
|
|
r#"{"method":"account_create","user":"bob","password_hash":"hash-two"}"#,
|
|
] {
|
|
assert!(
|
|
send(&mut c, request).contains("recovery required"),
|
|
"{request}"
|
|
);
|
|
}
|
|
let created = send(
|
|
&mut c,
|
|
r#"{"method":"create_table","table":"business","columns":[{"name":"id","ty":"int"}]}"#,
|
|
);
|
|
assert!(created.contains(r#""status":"ok""#), "{created}");
|
|
drop(c);
|
|
drop(server);
|
|
std::fs::remove_dir(data.path().join("system/catalog.json.pending"))?;
|
|
let _restarted = restart_at(data.path())?;
|
|
let mut c = connect()?;
|
|
let recovered = send(&mut c, r#"{"method":"accounts_list"}"#);
|
|
assert!(recovered.contains("hash-one"), "{recovered}");
|
|
let status: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"system_status"}"#))?;
|
|
assert!(
|
|
status["last_lsn"].as_u64().unwrap_or(0) >= 1,
|
|
"重放后检查点要推进: {status}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 账号文件已存在时拒绝建账号
|
|
#[test]
|
|
fn account_create_rejects_reserved_file_collision() -> std::io::Result<()> {
|
|
let data = tempfile::tempdir()?;
|
|
open_storage(&write_config(data.path()));
|
|
std::fs::write(data.path().join("system/__system_users.db"), b"existing")?;
|
|
let mut c = connect()?;
|
|
let result = send(
|
|
&mut c,
|
|
r#"{"method":"account_create","user":"alice","password_hash":"hash-one"}"#,
|
|
);
|
|
assert!(result.contains("collision"), "{result}");
|
|
assert_eq!(
|
|
std::fs::read(data.path().join("system/__system_users.db"))?,
|
|
b"existing"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 超长密码哈希在写 WAL 之前就被拒
|
|
#[test]
|
|
fn oversized_account_hash_is_rejected_before_wal() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_server, data) = start_server();
|
|
let mut c = connect()?;
|
|
let result = send(
|
|
&mut c,
|
|
&serde_json::json!({"method":"account_create", "user":"alice",
|
|
"password_hash":"a".repeat(300)})
|
|
.to_string(),
|
|
);
|
|
assert!(result.contains("invalid account credential"), "{result}");
|
|
let wal = data.path().join("system/wal.log");
|
|
assert_eq!(
|
|
if wal.exists() {
|
|
std::fs::metadata(&wal)?.len()
|
|
} else {
|
|
0
|
|
},
|
|
0
|
|
);
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&send(&mut c, r#"{"method":"accounts_list"}"#))?
|
|
["accounts"],
|
|
serde_json::json!([])
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 空哈希表示免密,允许建立
|
|
#[test]
|
|
fn account_create_accepts_empty_hash() -> std::io::Result<()> {
|
|
let (_server, _data) = start_server();
|
|
let mut c = connect()?;
|
|
let created: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"account_create","user":"root","password_hash":""}"#,
|
|
))?;
|
|
assert_eq!(
|
|
created["accounts"][0]["password_hash"],
|
|
serde_json::json!("")
|
|
);
|
|
let listed = send(&mut c, r#"{"method":"accounts_list"}"#);
|
|
assert!(listed.contains("root"), "{listed}");
|
|
Ok(())
|
|
}
|
|
|
|
/// 字典里已有同名普通表时拒绝建账号
|
|
#[test]
|
|
fn account_create_rejects_existing_ordinary_catalog_entry() -> std::io::Result<()> {
|
|
let data = tempfile::tempdir()?;
|
|
let mut catalog = chusql_core_storage::catalog::Catalog::default();
|
|
catalog.create_table("__system_users", Vec::new())?;
|
|
std::fs::create_dir(data.path().join("system"))?;
|
|
catalog.save(data.path().join("system/catalog.json"))?;
|
|
open_storage(&write_config(data.path()));
|
|
let mut c = connect()?;
|
|
let result = send(
|
|
&mut c,
|
|
r#"{"method":"account_create","user":"alice","password_hash":"hash-one"}"#,
|
|
);
|
|
assert!(result.contains("collision"), "{result}");
|
|
Ok(())
|
|
}
|
|
|
|
/// 没引导过就不能动账号
|
|
#[test]
|
|
fn account_requests_require_bootstrap() -> std::io::Result<()> {
|
|
let data = tempfile::tempdir()?;
|
|
open_storage(&write_config(data.path()));
|
|
let mut c = connect()?;
|
|
let status = send(&mut c, r#"{"method":"system_status"}"#);
|
|
assert!(status.contains(r#""initialized":false"#), "{status}");
|
|
for request in [
|
|
r#"{"method":"accounts_list"}"#,
|
|
r#"{"method":"account_create","user":"alice","password_hash":"hash-one"}"#,
|
|
] {
|
|
let result = send(&mut c, request);
|
|
assert!(result.contains("csql-bootstrap"), "{request}: {result}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 引导幂等:再跑一次不改已有管理员的口令
|
|
#[test]
|
|
fn bootstrap_is_idempotent_and_keeps_the_password() -> std::io::Result<()> {
|
|
let data = tempfile::tempdir()?;
|
|
open_storage(&write_config(data.path()));
|
|
let mut c = connect()?;
|
|
let created = send(
|
|
&mut c,
|
|
r#"{"method":"bootstrap_system","user":"root","password_hash":"hash-one"}"#,
|
|
);
|
|
assert!(created.contains(r#""initialized":true"#), "{created}");
|
|
let again = send(
|
|
&mut c,
|
|
r#"{"method":"bootstrap_system","user":"root","password_hash":"hash-two"}"#,
|
|
);
|
|
assert!(again.contains(r#""initialized":true"#), "{again}");
|
|
let listed = send(&mut c, r#"{"method":"accounts_list"}"#);
|
|
assert!(listed.contains("hash-one"), "{listed}");
|
|
assert!(!listed.contains("hash-two"), "{listed}");
|
|
assert_eq!(
|
|
std::fs::metadata(data.path().join("system/wal.log"))?.len(),
|
|
0
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 引导程序不给已经播过种的表塞新账号
|
|
#[test]
|
|
fn bootstrap_does_not_add_an_account_to_a_seeded_table() -> std::io::Result<()> {
|
|
let data = tempfile::tempdir()?;
|
|
open_storage(&write_config(data.path()));
|
|
let mut c = connect()?;
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"bootstrap_system","user":"root","password_hash":"hash-one"}"#,
|
|
);
|
|
let sneaky = send(
|
|
&mut c,
|
|
r#"{"method":"bootstrap_system","user":"eve","password_hash":"raw-hash"}"#,
|
|
);
|
|
assert!(sneaky.contains(r#""initialized":true"#), "{sneaky}");
|
|
let listed = send(&mut c, r#"{"method":"accounts_list"}"#);
|
|
assert!(!listed.contains("eve"), "{listed}");
|
|
assert!(listed.contains("hash-one"), "{listed}");
|
|
Ok(())
|
|
}
|
|
|
|
/// 空表还能被重新播种:先只建表,再补管理员
|
|
#[test]
|
|
fn bootstrap_seeds_an_empty_account_table() -> std::io::Result<()> {
|
|
let data = tempfile::tempdir()?;
|
|
open_storage(&write_config(data.path()));
|
|
let mut c = connect()?;
|
|
let bare = send(&mut c, r#"{"method":"bootstrap_system"}"#);
|
|
assert!(bare.contains(r#""initialized":true"#), "{bare}");
|
|
let empty = send(&mut c, r#"{"method":"accounts_list"}"#);
|
|
assert!(empty.contains(r#""accounts":[]"#), "{empty}");
|
|
let seeded = send(
|
|
&mut c,
|
|
r#"{"method":"bootstrap_system","user":"root","password_hash":"hash-again"}"#,
|
|
);
|
|
assert!(seeded.contains(r#""initialized":true"#), "{seeded}");
|
|
let listed = send(&mut c, r#"{"method":"accounts_list"}"#);
|
|
assert!(listed.contains("hash-again"), "{listed}");
|
|
Ok(())
|
|
}
|
|
|
|
/// 列出已建的表
|
|
#[test]
|
|
fn list_tables_returns_created_tables() {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect().unwrap();
|
|
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"users","row":{"id":1}}"#,
|
|
);
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"orders","row":{"id":1}}"#,
|
|
);
|
|
|
|
let r = send(&mut c, r#"{"method":"list_tables"}"#);
|
|
assert!(r.contains("users"), "got {}", r);
|
|
assert!(r.contains("orders"), "got {}", r);
|
|
}
|
|
|
|
/// 整表替换只剩新行
|
|
#[test]
|
|
fn replace_all_replaces_rows() {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect().unwrap();
|
|
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"users","row":{"id":1,"name":"Alice"}}"#,
|
|
);
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"users","row":{"id":2,"name":"Bob"}}"#,
|
|
);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"replace_all","table":"users","rows":[{"id":9,"name":"Zoe"}]}"#,
|
|
);
|
|
assert!(
|
|
r.contains(r#""status":"ok""#),
|
|
"replace_all response: {}",
|
|
r
|
|
);
|
|
|
|
let r = send(&mut c, r#"{"method":"scan","table":"users"}"#);
|
|
assert!(r.contains("Zoe"), "got {}", r);
|
|
assert!(!r.contains("Alice"), "old row should be gone: {}", r);
|
|
}
|
|
|
|
/// 按 id 查(列名走默认值 `id`,老客户端不用改)
|
|
#[test]
|
|
fn insert_then_lookup_by_index() {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect().unwrap();
|
|
|
|
for (id, name) in [(1, "Alice"), (2, "Bob"), (3, "Carol")] {
|
|
let req = format!(
|
|
r#"{{"method":"insert","table":"idx_users","row":{{"id":{},"name":"{}"}}}}"#,
|
|
id, name
|
|
);
|
|
let r = send(&mut c, &req);
|
|
assert!(r.contains(r#""status":"ok""#), "insert: {}", r);
|
|
}
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"idx_users","key":2}"#,
|
|
);
|
|
assert!(r.contains("Bob"), "lookup: {}", r);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"idx_users","key":99}"#,
|
|
);
|
|
assert!(r.contains(r#""rows":[]"#), "miss: {}", r);
|
|
}
|
|
|
|
/// 同一个 id 插两次要报错(索引列是唯一的)
|
|
#[test]
|
|
fn duplicate_id_is_rejected() {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect().unwrap();
|
|
|
|
let req = r#"{"method":"insert","table":"dup_id","row":{"id":1,"name":"A"}}"#;
|
|
assert!(send(&mut c, req).contains(r#""status":"ok""#));
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"dup_id","row":{"id":1,"name":"B"}}"#,
|
|
);
|
|
assert!(r.contains(r#""status":"error""#), "got: {}", r);
|
|
assert!(r.contains("duplicate value on the id index"), "got: {}", r);
|
|
}
|
|
|
|
/// 整值浮点与整数是同一个索引键
|
|
#[test]
|
|
fn integral_float_uses_the_same_index_key() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"float_id","columns":[{"name":"id","ty":"int"},{"name":"name","ty":"str"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"float_id","row":{"id":7,"name":"A"}}),
|
|
)?;
|
|
|
|
let found: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"float_id","key":7.0}"#,
|
|
))?;
|
|
assert_eq!(
|
|
found["rows"],
|
|
serde_json::json!([{"id":7,"name":"A"}]),
|
|
"lookup by 7.0: {}",
|
|
found
|
|
);
|
|
|
|
let dup: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"float_id","row":{"id":7.0,"name":"B"}}"#,
|
|
))?;
|
|
assert_eq!(dup["status"], "error", "7.0 must clash with 7: {}", dup);
|
|
assert!(
|
|
dup["message"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("duplicate value on the id index"),
|
|
"got: {}",
|
|
dup
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 批量插入一次全进
|
|
#[test]
|
|
fn insert_batch_writes_all_rows() {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect().unwrap();
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"insert_batch","table":"batch_t","rows":[{"id":1,"name":"A"},{"id":2,"name":"B"},{"id":3,"name":"C"}]}"#,
|
|
);
|
|
assert!(r.contains(r#""status":"ok""#), "batch: {}", r);
|
|
|
|
let r = send(&mut c, r#"{"method":"scan","table":"batch_t"}"#);
|
|
assert!(
|
|
r.contains("A") && r.contains("B") && r.contains("C"),
|
|
"scan: {}",
|
|
r
|
|
);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"batch_t","key":2}"#,
|
|
);
|
|
assert!(r.contains("B"), "index after batch: {}", r);
|
|
}
|
|
|
|
/// 行级删除:行没了、索引条目也没了、别人不受影响
|
|
#[test]
|
|
fn delete_keys_removes_row_and_index_entry() {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect().unwrap();
|
|
|
|
for (id, name) in [(1, "Alice"), (2, "Bob"), (3, "Carol")] {
|
|
let req = format!(
|
|
r#"{{"method":"insert","table":"del_t","row":{{"id":{},"name":"{}"}}}}"#,
|
|
id, name
|
|
);
|
|
send(&mut c, &req);
|
|
}
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"delete_keys","table":"del_t","keys":[2]}"#,
|
|
);
|
|
assert!(r.contains(r#""status":"ok""#), "delete: {}", r);
|
|
|
|
let r = send(&mut c, r#"{"method":"scan","table":"del_t"}"#);
|
|
assert!(!r.contains("Bob"), "row should be gone: {}", r);
|
|
assert!(
|
|
r.contains("Alice") && r.contains("Carol"),
|
|
"others stay: {}",
|
|
r
|
|
);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"del_t","key":2}"#,
|
|
);
|
|
assert!(
|
|
r.contains(r#""rows":[]"#),
|
|
"index entry should be gone: {}",
|
|
r
|
|
);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"del_t","row":{"id":2,"name":"Bob2"}}"#,
|
|
);
|
|
assert!(
|
|
r.contains(r#""status":"ok""#),
|
|
"id 2 should be insertable again: {}",
|
|
r
|
|
);
|
|
}
|
|
|
|
/// 事务提交:一批写操作在同一个请求里落地
|
|
#[test]
|
|
fn apply_transaction_lands_upserts_and_deletes_together() {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect().unwrap();
|
|
|
|
for (id, name) in [(1, "Alice"), (2, "Bob")] {
|
|
let req = format!(
|
|
r#"{{"method":"insert","table":"txn_t","row":{{"id":{},"name":"{}"}}}}"#,
|
|
id, name
|
|
);
|
|
send(&mut c, &req);
|
|
}
|
|
|
|
let batch = r#"{"method":"apply_transaction","ops":[
|
|
{"op":"delete","table":"txn_t","ids":[1]},
|
|
{"op":"upsert","table":"txn_t","rows":[{"id":3,"name":"Carol"}]}
|
|
]}"#;
|
|
let r = send(&mut c, batch);
|
|
assert!(r.contains(r#""status":"ok""#), "batch: {}", r);
|
|
|
|
let r = send(&mut c, r#"{"method":"scan","table":"txn_t"}"#);
|
|
assert!(!r.contains("Alice"), "deleted row should be gone: {}", r);
|
|
assert!(
|
|
r.contains("Bob") && r.contains("Carol"),
|
|
"kept and added rows: {}",
|
|
r
|
|
);
|
|
|
|
for line in [
|
|
r#"{"method":"apply_transaction","ops":[{"op":"upsert","table":"__system_users","rows":[{"id":1}]}]}"#,
|
|
r#"{"method":"apply_transaction","ops":[{"op":"delete","table":"../evil","ids":[1]}]}"#,
|
|
] {
|
|
let r = send(&mut c, line);
|
|
assert!(
|
|
r.contains(r#""status":"error""#),
|
|
"bad op rejected: {} -> {}",
|
|
line,
|
|
r
|
|
);
|
|
}
|
|
|
|
// 没有整数 id 的表用整表替换提交
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"txn_no_id","row":{"name":"old"}}"#,
|
|
);
|
|
assert!(r.contains(r#""status":"ok""#), "insert: {}", r);
|
|
let replace = r#"{"method":"apply_transaction","ops":[{"op":"replace","table":"txn_no_id","rows":[{"name":"only"}]}]}"#;
|
|
let r = send(&mut c, replace);
|
|
assert!(r.contains(r#""status":"ok""#), "replace: {}", r);
|
|
let r = send(&mut c, r#"{"method":"scan","table":"txn_no_id"}"#);
|
|
assert!(
|
|
r.contains("only") && !r.contains("old"),
|
|
"replaced rows: {}",
|
|
r
|
|
);
|
|
}
|
|
|
|
/// 超页写入不提交日志、不改数据且可继续使用
|
|
#[test]
|
|
fn oversized_writes_are_rejected_before_commit() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
let original = serde_json::json!({"id":1,"secret":"old"});
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":original}),
|
|
)?;
|
|
let directory = data.path().join("databases/main");
|
|
let mut files = std::fs::read_dir(&directory)?
|
|
.map(|entry| entry.map(|entry| entry.path()))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
files.sort();
|
|
let before = files
|
|
.iter()
|
|
.map(std::fs::read)
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
let oversized = serde_json::json!({"id":2,"secret":"x".repeat(10_000)});
|
|
let valid = serde_json::json!({"id":3,"secret":"pending"});
|
|
let columns = serde_json::json!([{"name":"id","ty":"int"},{"name":"secret","ty":"str"}]);
|
|
let requests = [
|
|
serde_json::json!({"method":"insert","table":"items","row":oversized}),
|
|
serde_json::json!({"method":"insert_batch","table":"items","rows":[valid,oversized]}),
|
|
serde_json::json!({"method":"replace_all","table":"items","rows":[valid,oversized]}),
|
|
serde_json::json!({"method":"replace_schema","table":"items","columns":columns,"rows":[valid,oversized]}),
|
|
serde_json::json!({"method":"apply_transaction","ops":[
|
|
{"op":"delete","table":"items","ids":[1]},
|
|
{"op":"upsert","table":"items","rows":[valid]},
|
|
{"op":"upsert","table":"items","rows":[oversized]}
|
|
]}),
|
|
serde_json::json!({"method":"apply_transaction","ops":[
|
|
{"op":"replace","table":"items","rows":[valid,oversized]}
|
|
]}),
|
|
];
|
|
for request in requests {
|
|
let result: serde_json::Value = serde_json::from_str(&send(&mut c, &request.to_string()))?;
|
|
assert_eq!(result["status"], "error", "{result}");
|
|
assert!(
|
|
result["message"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("tuple too large"),
|
|
"{result}"
|
|
);
|
|
let after = files
|
|
.iter()
|
|
.map(std::fs::read)
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
assert!(after == before, "rejected write changed persisted files");
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(rows["rows"], serde_json::json!([original]), "{rows}");
|
|
let indexed: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"items","column":"id","key":1}"#,
|
|
))?;
|
|
assert_eq!(indexed["rows"], serde_json::json!([original]), "{indexed}");
|
|
}
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":valid}),
|
|
)?;
|
|
drop(c);
|
|
drop(server);
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(rows["rows"], serde_json::json!([original, valid]));
|
|
Ok(())
|
|
}
|
|
|
|
/// 超长索引键在提交前拒绝且不影响后续读写
|
|
#[test]
|
|
fn oversized_index_keys_are_rejected_before_commit() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
let original = serde_json::json!({"id":1,"secret":"old"});
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":original}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_index","table":"items","column":"secret"}),
|
|
)?;
|
|
let directory = data.path().join("databases/main");
|
|
let mut files = std::fs::read_dir(&directory)?
|
|
.map(|entry| entry.map(|entry| entry.path()))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
files.sort();
|
|
let before = files
|
|
.iter()
|
|
.map(std::fs::read)
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
let oversized = serde_json::json!({"id":2,"secret":"界".repeat(43)});
|
|
let valid = serde_json::json!({"id":3,"secret":"界".repeat(21) + "x"});
|
|
for request in [
|
|
serde_json::json!({"method":"insert","table":"items","row":oversized}),
|
|
serde_json::json!({"method":"insert_batch","table":"items","rows":[valid,oversized]}),
|
|
serde_json::json!({"method":"replace_all","table":"items","rows":[valid,oversized]}),
|
|
serde_json::json!({"method":"apply_transaction","ops":[
|
|
{"op":"delete","table":"items","ids":[1]},
|
|
{"op":"upsert","table":"items","rows":[valid,oversized]}
|
|
]}),
|
|
serde_json::json!({"method":"apply_transaction","ops":[
|
|
{"op":"replace","table":"items","rows":[valid,oversized]}
|
|
]}),
|
|
] {
|
|
let result: serde_json::Value = serde_json::from_str(&send(&mut c, &request.to_string()))?;
|
|
assert_eq!(result["status"], "error", "{result}");
|
|
assert!(
|
|
result["message"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("index key is 129 bytes"),
|
|
"{result}"
|
|
);
|
|
let after = files
|
|
.iter()
|
|
.map(std::fs::read)
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
assert!(after == before, "rejected write changed persisted files");
|
|
let indexed: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"items","column":"secret","key":"old"}"#,
|
|
))?;
|
|
assert_eq!(indexed["rows"], serde_json::json!([original]), "{indexed}");
|
|
}
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":valid}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":4,"secret":null}}),
|
|
)?;
|
|
drop(c);
|
|
drop(server);
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let indexed: serde_json::Value = serde_json::from_str(&send(&mut c,
|
|
&serde_json::json!({"method":"lookup_by_index","table":"items","column":"secret","key":valid["secret"]}).to_string()))?;
|
|
assert_eq!(indexed["rows"], serde_json::json!([valid]));
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(
|
|
rows["rows"],
|
|
serde_json::json!([original,valid,{"id":4,"secret":null}])
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 建索引拒绝超长旧值且结构替换可移除索引
|
|
#[test]
|
|
fn oversized_existing_keys_do_not_commit_index_creation() -> Result<(), Box<dyn std::error::Error>>
|
|
{
|
|
let (_server, data) = start_server();
|
|
let mut c = connect()?;
|
|
let original = serde_json::json!({"id":1,"secret":"x".repeat(129)});
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":original}),
|
|
)?;
|
|
let directory = data.path().join("databases/main");
|
|
let wal = std::fs::read(directory.join("wal.log"))?;
|
|
let checkpoint = std::fs::read(directory.join("wal.checkpoint"))?;
|
|
let catalog = std::fs::read(directory.join("catalog.json"))?;
|
|
let rejected: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"create_index","table":"items","column":"secret"}"#,
|
|
))?;
|
|
assert_eq!(rejected["status"], "error", "{rejected}");
|
|
assert!(
|
|
rejected["message"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("index key is 129 bytes"),
|
|
"{rejected}"
|
|
);
|
|
assert_eq!(std::fs::read(directory.join("wal.log"))?, wal);
|
|
assert_eq!(std::fs::read(directory.join("wal.checkpoint"))?, checkpoint);
|
|
assert_eq!(std::fs::read(directory.join("catalog.json"))?, catalog);
|
|
assert!(!directory.join("items.secret.idx").exists());
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(rows["rows"], serde_json::json!([original]));
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"replace_all","table":"items","rows":[{"id":1,"secret":"short"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_index","table":"items","column":"secret"}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"replace_schema","table":"items",
|
|
"columns":[{"name":"id","ty":"int"},{"name":"secret","ty":"str"}],"rows":[original]}),
|
|
)?;
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(rows["rows"], serde_json::json!([original]));
|
|
assert!(!directory.join("items.secret.idx").exists());
|
|
Ok(())
|
|
}
|
|
|
|
/// 重复身份键在提交前拒绝且不会发布半批数据
|
|
#[test]
|
|
fn duplicate_ids_are_rejected_before_commit() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
let original = serde_json::json!({"id":1,"secret":"old"});
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":original}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"other","row":{"id":1}}),
|
|
)?;
|
|
let directory = data.path().join("databases/main");
|
|
let mut files = std::fs::read_dir(&directory)?
|
|
.map(|entry| entry.map(|entry| entry.path()))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
files.sort();
|
|
let before = files
|
|
.iter()
|
|
.map(std::fs::read)
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
for request in [
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":1.0}}),
|
|
serde_json::json!({"method":"insert_batch","table":"items","rows":[{"id":2},{"id":1}]}),
|
|
serde_json::json!({"method":"insert_batch","table":"items","rows":[{"id":2},{"id":2.0}]}),
|
|
serde_json::json!({"method":"apply_transaction","ops":[
|
|
{"op":"delete","table":"other","ids":[1]},
|
|
{"op":"upsert","table":"items","rows":[{"id":2}]},
|
|
{"op":"upsert","table":"items","rows":[{"id":2.0}]}
|
|
]}),
|
|
serde_json::json!({"method":"apply_transaction","ops":[
|
|
{"op":"replace","table":"items","rows":[{"id":3}]},
|
|
{"op":"upsert","table":"items","rows":[{"id":3}]}
|
|
]}),
|
|
] {
|
|
let rejected: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, &request.to_string()))?;
|
|
assert_eq!(rejected["status"], "error", "{rejected}");
|
|
assert!(
|
|
rejected["message"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("duplicate value on the id index"),
|
|
"{rejected}"
|
|
);
|
|
let after = files
|
|
.iter()
|
|
.map(std::fs::read)
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
assert!(after == before, "duplicate input changed persisted files");
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(rows["rows"], serde_json::json!([original]), "{rows}");
|
|
}
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"apply_transaction","ops":[
|
|
{"op":"delete","table":"items","ids":[1,1]},
|
|
{"op":"upsert","table":"items","rows":[{"id":1,"secret":"new"}]},
|
|
{"op":"replace","table":"other","rows":[]},
|
|
{"op":"upsert","table":"other","rows":[{"id":1}]}
|
|
]}),
|
|
)?;
|
|
drop(c);
|
|
drop(server);
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let rows: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"items","column":"id","key":1}"#,
|
|
))?;
|
|
assert_eq!(rows["rows"], serde_json::json!([{"id":1,"secret":"new"}]));
|
|
Ok(())
|
|
}
|
|
|
|
/// 预检保留旧重复键及每次删除首行的语义
|
|
#[test]
|
|
fn id_preflight_preserves_replacement_and_delete_order() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_server, _data) = start_server();
|
|
let mut c = connect()?;
|
|
let legacy = serde_json::json!([{"id":1,"secret":"first"},{"id":1,"secret":"second"}]);
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"replace_all","table":"items","rows":legacy}),
|
|
)?;
|
|
let rejected: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
&serde_json::json!({"method":"apply_transaction","ops":[
|
|
{"op":"delete","table":"items","ids":[1,1]},
|
|
{"op":"upsert","table":"items","rows":[{"id":1}]}
|
|
]})
|
|
.to_string(),
|
|
))?;
|
|
assert_eq!(rejected["status"], "error", "{rejected}");
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(rows["rows"], legacy, "{rows}");
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"apply_transaction","ops":[
|
|
{"op":"delete","table":"items","ids":[1]},
|
|
{"op":"delete","table":"items","ids":[1]},
|
|
{"op":"upsert","table":"items","rows":[{"id":1}]},
|
|
{"op":"replace","table":"items","rows":[{"secret":"no id"},{"id":null}]},
|
|
{"op":"upsert","table":"items","rows":[{"id":1}]}
|
|
]}),
|
|
)?;
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(
|
|
rows["rows"],
|
|
serde_json::json!([{"secret":"no id"},{"id":null},{"id":1}])
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 扩列版本耗尽在提交前拒绝且不封闭数据写入
|
|
#[test]
|
|
fn schema_expansion_is_validated_before_commit() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
let original = serde_json::json!({"id":1,"secret":"old"});
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":original}),
|
|
)?;
|
|
drop(c);
|
|
drop(server);
|
|
CURRENT.with(|slot| *slot.borrow_mut() = None);
|
|
let directory = data.path().join("databases/main");
|
|
let catalog_path = directory.join("catalog.json");
|
|
let mut catalog: serde_json::Value = serde_json::from_slice(&std::fs::read(&catalog_path)?)?;
|
|
catalog["tables"]["items"]["schema_version"] = serde_json::json!(u64::MAX - 1);
|
|
std::fs::write(&catalog_path, serde_json::to_vec(&catalog)?)?;
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let a = serde_json::json!({"id":2,"added_a":"a"});
|
|
let b = serde_json::json!({"id":3,"added_b":"b"});
|
|
let columns = catalog["tables"]["items"]["columns"].clone();
|
|
let mut files = std::fs::read_dir(&directory)?
|
|
.map(|entry| entry.map(|entry| entry.path()))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
files.sort();
|
|
let before = files
|
|
.iter()
|
|
.map(std::fs::read)
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
for request in [
|
|
serde_json::json!({"method":"insert_batch","table":"items","rows":[a,b]}),
|
|
serde_json::json!({"method":"replace_all","table":"items","rows":[a,b]}),
|
|
serde_json::json!({"method":"replace_schema","table":"items","columns":columns,"rows":[a,b]}),
|
|
serde_json::json!({"method":"apply_transaction","ops":[
|
|
{"op":"delete","table":"items","ids":[1]},
|
|
{"op":"upsert","table":"items","rows":[a]},
|
|
{"op":"upsert","table":"items","rows":[b]}
|
|
]}),
|
|
serde_json::json!({"method":"apply_transaction","ops":[
|
|
{"op":"replace","table":"items","rows":[a,b]}
|
|
]}),
|
|
] {
|
|
let rejected: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, &request.to_string()))?;
|
|
assert_eq!(rejected["status"], "error", "{rejected}");
|
|
assert!(
|
|
rejected["message"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("catalog schema version exhausted"),
|
|
"{rejected}"
|
|
);
|
|
let after = files
|
|
.iter()
|
|
.map(std::fs::read)
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
assert!(
|
|
after == before,
|
|
"rejected schema expansion changed persisted files"
|
|
);
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(rows["rows"], serde_json::json!([original]), "{rows}");
|
|
}
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":2,"added_a":"a","added_b":"b"}}),
|
|
)?;
|
|
let at_limit: serde_json::Value = serde_json::from_slice(&std::fs::read(&catalog_path)?)?;
|
|
assert_eq!(at_limit["tables"]["items"]["schema_version"], u64::MAX);
|
|
let before = files
|
|
.iter()
|
|
.map(std::fs::read)
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
let rejected: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"items","row":{"id":3,"added_c":"c"}}"#,
|
|
))?;
|
|
assert!(
|
|
rejected["message"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("catalog schema version exhausted"),
|
|
"{rejected}"
|
|
);
|
|
let after = files
|
|
.iter()
|
|
.map(std::fs::read)
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
assert!(
|
|
after == before,
|
|
"rejected expansion at the limit changed persisted files"
|
|
);
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":3,"secret":"valid","untyped":null}}),
|
|
)?;
|
|
let catalog: serde_json::Value = serde_json::from_slice(&std::fs::read(&catalog_path)?)?;
|
|
assert_eq!(catalog["tables"]["items"]["schema_version"], u64::MAX);
|
|
assert!(
|
|
!catalog["tables"]["items"]["columns"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|column| column["name"] == "untyped")
|
|
);
|
|
drop(c);
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(rows["rows"].as_array().unwrap().len(), 3);
|
|
Ok(())
|
|
}
|
|
|
|
/// 标记已落盘、数据未改的崩溃现场:重启重放并推进检查点
|
|
#[test]
|
|
fn committed_group_replays_after_restart() -> Result<(), Box<dyn std::error::Error>> {
|
|
use chusql_core_storage::wal::{Wal, WalOp};
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":1,"secret":"old"}}),
|
|
)?;
|
|
drop(c);
|
|
drop(server);
|
|
|
|
let mut added = std::collections::HashMap::new();
|
|
added.insert("id".to_string(), serde_json::json!(2));
|
|
added.insert("secret".to_string(), serde_json::json!("new"));
|
|
let group = vec![
|
|
WalOp::InsertBatch {
|
|
table: "items".into(),
|
|
rows: vec![added.clone()],
|
|
},
|
|
WalOp::DeleteKeys {
|
|
table: "items".into(),
|
|
keys: vec![1],
|
|
},
|
|
];
|
|
let wal = Wal::new(data.path().join("databases/main/wal.log"));
|
|
let commit_lsn = wal.append_group(&group)?;
|
|
drop(wal);
|
|
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(rows["rows"], serde_json::json!([{"id":2,"secret":"new"}]));
|
|
assert_eq!(
|
|
std::fs::metadata(data.path().join("databases/main/wal.log"))?.len(),
|
|
0
|
|
);
|
|
let checkpoint = std::fs::read(data.path().join("databases/main/wal.checkpoint"))?;
|
|
assert_eq!(checkpoint.len(), 8, "检查点要落盘");
|
|
assert_eq!(
|
|
u64::from_le_bytes(checkpoint[..8].try_into()?),
|
|
commit_lsn,
|
|
"检查点记到提交 LSN"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 没有提交标记的记录组在重启时整组丢掉
|
|
#[test]
|
|
fn uncommitted_group_is_discarded_on_restart() -> Result<(), Box<dyn std::error::Error>> {
|
|
use chusql_core_storage::wal::{Wal, WalOp};
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":1,"secret":"old"}}),
|
|
)?;
|
|
drop(c);
|
|
drop(server);
|
|
|
|
let mut lost = std::collections::HashMap::new();
|
|
lost.insert("id".to_string(), serde_json::json!(2));
|
|
lost.insert("secret".to_string(), serde_json::json!("lost"));
|
|
let wal = Wal::new(data.path().join("databases/main/wal.log"));
|
|
wal.append(&WalOp::InsertBatch {
|
|
table: "items".into(),
|
|
rows: vec![lost.clone()],
|
|
})?;
|
|
wal.append(&WalOp::InsertBatch {
|
|
table: "items".into(),
|
|
rows: vec![lost],
|
|
})?;
|
|
drop(wal);
|
|
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(rows["rows"], serde_json::json!([{"id":1,"secret":"old"}]));
|
|
assert_eq!(
|
|
std::fs::metadata(data.path().join("databases/main/wal.log"))?.len(),
|
|
0
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 直接删表后文件不留,重建同名表是空表
|
|
#[test]
|
|
fn drop_table_removes_files() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_srv, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"m4_gone","columns":[{"name":"id","ty":"int"},{"name":"note","ty":"text"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_index","table":"m4_gone","column":"note"}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"m4_gone","row":{"id":1,"note":"a"}}),
|
|
)?;
|
|
assert!(
|
|
data.path().join("databases/main/m4_gone.db").exists(),
|
|
"建表要落数据文件"
|
|
);
|
|
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"drop_table","table":"m4_gone"}),
|
|
)?;
|
|
assert!(
|
|
!data.path().join("databases/main/m4_gone.db").exists(),
|
|
"数据文件要删掉"
|
|
);
|
|
assert!(
|
|
!data.path().join("databases/main/m4_gone.idx").exists(),
|
|
"id 索引要删掉"
|
|
);
|
|
assert!(
|
|
!data.path().join("databases/main/m4_gone.note.idx").exists(),
|
|
"二级索引要删掉"
|
|
);
|
|
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"m4_gone","columns":[{"name":"id","ty":"int"},{"name":"note","ty":"text"}]}),
|
|
)?;
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"m4_gone"}"#))?;
|
|
assert_eq!(rows["rows"], serde_json::json!([]), "旧数据不能复活");
|
|
Ok(())
|
|
}
|
|
|
|
/// 建表操作进了 WAL 后,重启按提交组把表重建出来
|
|
#[test]
|
|
fn create_table_replays_after_restart() -> Result<(), Box<dyn std::error::Error>> {
|
|
use chusql_core_storage::protocol::SchemaColumn;
|
|
use chusql_core_storage::wal::{Wal, WalOp};
|
|
let (server, data) = start_server();
|
|
drop(server);
|
|
|
|
let group = vec![WalOp::CreateTable {
|
|
object_id: None,
|
|
table: "m4_create".into(),
|
|
columns: vec![
|
|
SchemaColumn {
|
|
name: "id".into(),
|
|
ty: "int".into(),
|
|
..Default::default()
|
|
},
|
|
SchemaColumn {
|
|
name: "note".into(),
|
|
ty: "text".into(),
|
|
..Default::default()
|
|
},
|
|
],
|
|
}];
|
|
let wal = Wal::new(data.path().join("databases/main/wal.log"));
|
|
let commit_lsn = wal.append_group(&group)?;
|
|
drop(wal);
|
|
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let schema: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"describe_table","table":"m4_create"}"#,
|
|
))?;
|
|
assert_eq!(schema["status"], "schema", "{schema}");
|
|
assert_eq!(schema["columns"][1]["name"], "note", "{schema}");
|
|
assert!(
|
|
data.path().join("databases/main/m4_create.db").exists(),
|
|
"建表要落数据文件"
|
|
);
|
|
let checkpoint = std::fs::read(data.path().join("databases/main/wal.checkpoint"))?;
|
|
assert_eq!(u64::from_le_bytes(checkpoint[..8].try_into()?), commit_lsn);
|
|
Ok(())
|
|
}
|
|
|
|
/// 删表操作进了 WAL 后,重启把表和数据文件一起清掉
|
|
#[test]
|
|
fn drop_table_replays_after_restart() -> Result<(), Box<dyn std::error::Error>> {
|
|
use chusql_core_storage::wal::{Wal, WalOp};
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"m4_drop","columns":[{"name":"id","ty":"int"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"m4_drop","row":{"id":1}}),
|
|
)?;
|
|
drop(c);
|
|
drop(server);
|
|
|
|
let wal = Wal::new(data.path().join("databases/main/wal.log"));
|
|
wal.append_group(&[WalOp::DropTable {
|
|
table: "m4_drop".into(),
|
|
}])?;
|
|
drop(wal);
|
|
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let schema: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"describe_table","table":"m4_drop"}"#,
|
|
))?;
|
|
assert_eq!(schema["status"], "error", "{schema}");
|
|
let left: Vec<String> = std::fs::read_dir(data.path().join("databases/main"))?
|
|
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
|
.collect();
|
|
assert!(
|
|
!data.path().join("databases/main/m4_drop.db").exists(),
|
|
"数据文件要删掉,现存 {left:?}"
|
|
);
|
|
assert!(
|
|
!data.path().join("databases/main/m4_drop.idx").exists(),
|
|
"id 索引要删掉,现存 {left:?}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 建索引操作进了 WAL 后,重启补建索引文件
|
|
#[test]
|
|
fn create_index_replays_after_restart() -> Result<(), Box<dyn std::error::Error>> {
|
|
use chusql_core_storage::wal::{Wal, WalOp};
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"m4_idx","columns":[{"name":"id","ty":"int"},{"name":"note","ty":"text"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"m4_idx","row":{"id":1,"note":"a"}}),
|
|
)?;
|
|
drop(c);
|
|
drop(server);
|
|
|
|
let wal = Wal::new(data.path().join("databases/main/wal.log"));
|
|
wal.append_group(&[WalOp::CreateIndex {
|
|
table: "m4_idx".into(),
|
|
column: "note".into(),
|
|
}])?;
|
|
drop(wal);
|
|
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let schema: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"describe_table","table":"m4_idx"}"#,
|
|
))?;
|
|
assert_eq!(schema["status"], "schema", "{schema}");
|
|
let indexed = |schema: &serde_json::Value, column: &str| {
|
|
schema["indexes"]
|
|
.as_array()
|
|
.map(|list| list.iter().any(|i| i["column"] == column))
|
|
.unwrap_or(false)
|
|
};
|
|
assert!(indexed(&schema, "note"), "{schema}");
|
|
assert!(
|
|
data.path().join("databases/main/m4_idx.note.idx").exists(),
|
|
"索引文件要建出来"
|
|
);
|
|
let found: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"m4_idx","column":"note","key":"a"}"#,
|
|
))?;
|
|
assert_eq!(
|
|
found["rows"],
|
|
serde_json::json!([{"id":1,"note":"a"}]),
|
|
"{found}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 删索引操作进了 WAL 后,重启清掉索引定义和文件
|
|
#[test]
|
|
fn drop_index_replays_after_restart() -> Result<(), Box<dyn std::error::Error>> {
|
|
use chusql_core_storage::wal::{Wal, WalOp};
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"m4_didx","columns":[{"name":"id","ty":"int"},{"name":"note","ty":"text"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_index","table":"m4_didx","column":"note"}),
|
|
)?;
|
|
drop(c);
|
|
drop(server);
|
|
|
|
let wal = Wal::new(data.path().join("databases/main/wal.log"));
|
|
wal.append_group(&[WalOp::DropIndex {
|
|
table: "m4_didx".into(),
|
|
column: "note".into(),
|
|
}])?;
|
|
drop(wal);
|
|
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let schema: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"describe_table","table":"m4_didx"}"#,
|
|
))?;
|
|
assert_eq!(schema["status"], "schema", "{schema}");
|
|
let columns: Vec<&str> = schema["indexes"]
|
|
.as_array()
|
|
.map(|list| list.iter().filter_map(|i| i["column"].as_str()).collect())
|
|
.unwrap_or_default();
|
|
assert_eq!(columns, vec!["id"], "{schema}");
|
|
assert!(
|
|
!data.path().join("databases/main/m4_didx.note.idx").exists(),
|
|
"索引文件要删掉"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 已生效的建表记录重启时被跳过,不报恢复失败
|
|
#[test]
|
|
fn already_applied_create_table_is_skipped_on_restart() -> Result<(), Box<dyn std::error::Error>> {
|
|
use chusql_core_storage::protocol::SchemaColumn;
|
|
use chusql_core_storage::wal::{Wal, WalOp};
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"m4_done","columns":[{"name":"id","ty":"int"}]}),
|
|
)?;
|
|
drop(c);
|
|
drop(server);
|
|
|
|
let wal = Wal::new(data.path().join("databases/main/wal.log"));
|
|
wal.append_group(&[WalOp::CreateTable {
|
|
object_id: None,
|
|
table: "m4_done".into(),
|
|
columns: vec![SchemaColumn {
|
|
name: "id".into(),
|
|
ty: "int".into(),
|
|
..Default::default()
|
|
}],
|
|
}])?;
|
|
drop(wal);
|
|
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let schema: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"describe_table","table":"m4_done"}"#,
|
|
))?;
|
|
assert_eq!(schema["status"], "schema", "{schema}");
|
|
assert_eq!(
|
|
std::fs::metadata(data.path().join("databases/main/wal.log"))?.len(),
|
|
0
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 检查点跟着每组提交走,LSN 跨重启不回头
|
|
#[test]
|
|
fn checkpoint_advances_and_lsn_survives_restart() -> Result<(), Box<dyn std::error::Error>> {
|
|
let checkpoint = |data: &tempfile::TempDir| -> Result<u64, Box<dyn std::error::Error>> {
|
|
let bytes = std::fs::read(data.path().join("databases/main/wal.checkpoint"))?;
|
|
Ok(u64::from_le_bytes(bytes[..8].try_into()?))
|
|
};
|
|
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"m6_seq","columns":[{"name":"id","ty":"int"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"m6_seq","row":{"id":1}}),
|
|
)?;
|
|
drop(c);
|
|
let first = checkpoint(&data)?;
|
|
assert!(first > 0, "提交后检查点要前进: {first}");
|
|
drop(server);
|
|
|
|
// 重启后接着写,新的 LSN 必须落在检查点之后
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"m6_seq","row":{"id":2}}),
|
|
)?;
|
|
drop(c);
|
|
let second = checkpoint(&data)?;
|
|
assert!(
|
|
second > first,
|
|
"重启后 LSN 要接着检查点走: {second} vs {first}"
|
|
);
|
|
|
|
// 数据都在,日志本身是空的:每组提交后就按边界截掉了
|
|
let mut c = connect()?;
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"m6_seq"}"#))?;
|
|
assert_eq!(rows["rows"].as_array().map(Vec::len), Some(2), "{rows}");
|
|
assert_eq!(
|
|
std::fs::metadata(data.path().join("databases/main/wal.log"))?.len(),
|
|
0
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 给第二列建索引后能按那一列查
|
|
#[test]
|
|
fn create_index_on_secondary_column() {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect().unwrap();
|
|
|
|
for (id, code) in [(1, 5001), (2, 5002), (3, 5003)] {
|
|
let req = format!(
|
|
r#"{{"method":"insert","table":"sec_t","row":{{"id":{},"code":{}}}}}"#,
|
|
id, code
|
|
);
|
|
send(&mut c, &req);
|
|
}
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"sec_t","column":"code","key":5002}"#,
|
|
);
|
|
assert!(r.contains(r#""status":"no_index""#), "got: {}", r);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"create_index","table":"sec_t","column":"code"}"#,
|
|
);
|
|
assert!(r.contains(r#""status":"ok""#), "create_index: {}", r);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"sec_t","column":"code","key":5002}"#,
|
|
);
|
|
assert!(r.contains(r#""id":2"#), "lookup by code: {}", r);
|
|
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"sec_t","row":{"id":4,"code":5004}}"#,
|
|
);
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"sec_t","column":"code","key":5004}"#,
|
|
);
|
|
assert!(r.contains(r#""id":4"#), "lookup new row: {}", r);
|
|
|
|
let r = send(&mut c, r#"{"method":"describe_table","table":"sec_t"}"#);
|
|
assert!(r.contains(r#""column":"code""#), "describe: {}", r);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"drop_index","table":"sec_t","column":"code"}"#,
|
|
);
|
|
assert!(r.contains(r#""status":"ok""#), "drop_index: {}", r);
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"sec_t","column":"code","key":5002}"#,
|
|
);
|
|
assert!(r.contains(r#""status":"no_index""#), "after drop: {}", r);
|
|
}
|
|
|
|
/// 删列同时摘掉索引与行内该格
|
|
#[test]
|
|
fn drop_column_removes_column_index_and_values() {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect().unwrap();
|
|
|
|
for (id, code, note) in [(1, 5001, "a"), (2, 5002, "b")] {
|
|
let req = format!(
|
|
r#"{{"method":"insert","table":"drop_t","row":{{"id":{},"code":{},"note":"{}"}}}}"#,
|
|
id, code, note
|
|
);
|
|
send(&mut c, &req);
|
|
}
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"create_index","table":"drop_t","column":"code"}"#,
|
|
);
|
|
assert!(r.contains(r#""status":"ok""#), "create_index: {}", r);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"drop_column","table":"drop_t","column":"code"}"#,
|
|
);
|
|
assert!(r.contains(r#""status":"ok""#), "drop_column: {}", r);
|
|
|
|
let r = send(&mut c, r#"{"method":"describe_table","table":"drop_t"}"#);
|
|
assert!(
|
|
!r.contains(r#""name":"code""#),
|
|
"code column should be gone: {}",
|
|
r
|
|
);
|
|
assert!(
|
|
r.contains(r#""name":"note""#),
|
|
"note column should still be there: {}",
|
|
r
|
|
);
|
|
assert!(
|
|
!r.contains(r#""column":"code""#),
|
|
"index on code should be gone: {}",
|
|
r
|
|
);
|
|
|
|
let r = send(&mut c, r#"{"method":"scan","table":"drop_t"}"#);
|
|
assert!(
|
|
r.contains(r#""note":"a""#),
|
|
"row should still be there: {}",
|
|
r
|
|
);
|
|
assert!(
|
|
!r.contains(r#""code""#),
|
|
"row should no longer carry code: {}",
|
|
r
|
|
);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"drop_t","column":"code","key":5001}"#,
|
|
);
|
|
assert!(r.contains(r#""status":"no_index""#), "{}", r);
|
|
|
|
let r = send(&mut c, r#"{"method":"describe_table","table":"drop_t"}"#);
|
|
assert!(
|
|
r.contains(r#""row_count":2"#),
|
|
"row count should still be 2: {}",
|
|
r
|
|
);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"drop_column","table":"drop_t","column":"id"}"#,
|
|
);
|
|
assert!(
|
|
r.contains(r#""status":"error""#),
|
|
"the built-in id column must not be dropped: {}",
|
|
r
|
|
);
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"drop_column","table":"drop_t","column":"nope"}"#,
|
|
);
|
|
assert!(
|
|
r.contains(r#""status":"error""#),
|
|
"a missing column should error: {}",
|
|
r
|
|
);
|
|
}
|
|
|
|
/// 重复值列能建索引,删一行不影响另一行;id 仍唯一
|
|
#[test]
|
|
fn create_index_accepts_duplicate_values() {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect().unwrap();
|
|
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"dup_t","row":{"id":1,"age":30}}"#,
|
|
);
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"dup_t","row":{"id":2,"age":30}}"#,
|
|
);
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"dup_t","row":{"id":3,"age":41}}"#,
|
|
);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"create_index","table":"dup_t","column":"age"}"#,
|
|
);
|
|
assert!(
|
|
r.contains(r#""status":"ok""#),
|
|
"duplicate values are allowed: {}",
|
|
r
|
|
);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"dup_t","column":"age","key":30}"#,
|
|
);
|
|
assert!(
|
|
r.contains(r#""id":1"#) && r.contains(r#""id":2"#),
|
|
"both rows come back: {}",
|
|
r
|
|
);
|
|
assert!(!r.contains(r#""id":3"#), "only matching rows: {}", r);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"delete_keys","table":"dup_t","keys":[1]}"#,
|
|
);
|
|
assert!(r.contains(r#""status":"ok""#), "delete: {}", r);
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"dup_t","column":"age","key":30}"#,
|
|
);
|
|
assert!(
|
|
!r.contains(r#""id":1"#) && r.contains(r#""id":2"#),
|
|
"only the deleted entry goes away: {}",
|
|
r
|
|
);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"dup_t","row":{"id":2,"age":99}}"#,
|
|
);
|
|
assert!(
|
|
r.contains(r#""status":"error""#),
|
|
"the id index stays unique: {}",
|
|
r
|
|
);
|
|
}
|
|
|
|
/// 重启修复统计并保持点查范围结果
|
|
#[test]
|
|
fn restart_rebuilds_stale_stats_without_changing_index_results()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
let rows: Vec<serde_json::Value> = (0..160)
|
|
.map(|id| serde_json::json!({"id":id,"v":id}))
|
|
.collect();
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert_batch","table":"rebuilt","rows":rows}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_index","table":"rebuilt","column":"v"}),
|
|
)?;
|
|
let point: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"rebuilt","column":"v","key":80}"#,
|
|
))?;
|
|
let range: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"range_by_index","table":"rebuilt","column":"v","lo":80,"hi":89}"#,
|
|
))?;
|
|
let scan: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"rebuilt"}"#))?;
|
|
drop(c);
|
|
drop(server);
|
|
let path = data.path().join("databases/main/catalog.json");
|
|
let mut catalog: serde_json::Value = serde_json::from_slice(&std::fs::read(&path)?)?;
|
|
catalog["tables"]["rebuilt"]["row_count"] = serde_json::json!(9999);
|
|
catalog["tables"]["rebuilt"]["stats"] = serde_json::json!({});
|
|
std::fs::write(&path, serde_json::to_vec(&catalog)?)?;
|
|
let _restarted = restart_at(data.path())?;
|
|
let mut c = connect()?;
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"rebuilt","column":"v","key":80}"#
|
|
))?,
|
|
point
|
|
);
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&send(
|
|
&mut c,
|
|
r#"{"method":"range_by_index","table":"rebuilt","column":"v","lo":80,"hi":89}"#
|
|
))?,
|
|
range
|
|
);
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&send(
|
|
&mut c,
|
|
r#"{"method":"scan","table":"rebuilt"}"#
|
|
))?,
|
|
scan
|
|
);
|
|
let schema: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"describe_table","table":"rebuilt"}"#,
|
|
))?;
|
|
assert_eq!(schema["row_count"], 160);
|
|
let stat = schema["stats"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|stat| stat["name"] == "v")
|
|
.unwrap();
|
|
assert_eq!(
|
|
stat["hist"],
|
|
serde_json::json!([
|
|
10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10
|
|
])
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 数据字典里的统计:不同值个数
|
|
#[test]
|
|
fn stats_report_distinct_values() {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect().unwrap();
|
|
|
|
for (id, age) in [(1, 30), (2, 30), (3, 41)] {
|
|
let req = format!(
|
|
r#"{{"method":"insert","table":"stat_t","row":{{"id":{},"age":{}}}}}"#,
|
|
id, age
|
|
);
|
|
send(&mut c, &req);
|
|
}
|
|
|
|
let r = send(&mut c, r#"{"method":"describe_table","table":"stat_t"}"#);
|
|
assert!(r.contains(r#""row_count":3"#), "row_count: {}", r);
|
|
assert!(
|
|
r.contains(r#""name":"age","distinct":2"#),
|
|
"age has 2 distinct values: {}",
|
|
r
|
|
);
|
|
assert!(
|
|
r.contains(r#""name":"id","distinct":3"#),
|
|
"id has 3 distinct values: {}",
|
|
r
|
|
);
|
|
assert!(
|
|
r.contains(r#""name":"age","distinct":2,"capped":false,"lo":30.0,"hi":41.0"#),
|
|
"age carries a histogram range: {}",
|
|
r
|
|
);
|
|
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"delete_keys","table":"stat_t","keys":[1]}"#,
|
|
);
|
|
let r = send(&mut c, r#"{"method":"describe_table","table":"stat_t"}"#);
|
|
assert!(r.contains(r#""row_count":2"#), "after delete: {}", r);
|
|
assert!(
|
|
r.contains(r#""name":"age","distinct":2"#),
|
|
"remaining ages are still 30 and 41: {}",
|
|
r
|
|
);
|
|
}
|
|
|
|
/// 重开服务后索引仍在
|
|
#[test]
|
|
fn lookup_survives_reopen() {
|
|
let data = tempfile::tempdir().unwrap();
|
|
|
|
{
|
|
let _srv = start_server_in(data.path());
|
|
let mut c = connect().unwrap();
|
|
for (id, name) in [(1, "Alice"), (2, "Bob"), (3, "Carol")] {
|
|
let req = format!(
|
|
r#"{{"method":"insert","table":"persist","row":{{"id":{},"name":"{}"}},"key":{}}}"#,
|
|
id, name, id
|
|
);
|
|
send(&mut c, &req);
|
|
}
|
|
}
|
|
|
|
let _srv = start_server_in(data.path());
|
|
|
|
let mut c = connect().unwrap();
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"persist","key":2}"#,
|
|
);
|
|
assert!(r.contains("Bob"), "persist lookup: {}", r);
|
|
}
|
|
|
|
/// 配置文件真的生效
|
|
#[test]
|
|
fn config_file_is_used() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let data_dir = dir.path().join("data");
|
|
let config_path = dir.path().join("chusql-core-storage.toml");
|
|
|
|
let text = format!(
|
|
"[storage]\ndata_dir = \"{}\"\n[log]\nlevel = \"debug\"\n",
|
|
data_dir.display().to_string().replace('\\', "/")
|
|
);
|
|
std::fs::write(&config_path, text).unwrap();
|
|
|
|
open_storage(&config_path);
|
|
bootstrap_system();
|
|
create_fixture_database();
|
|
|
|
let mut c = connect().unwrap();
|
|
assert_eq!(send(&mut c, r#"{"method":"ping"}"#), r#"{"status":"pong"}"#);
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"cfg","row":{"id":1}}"#,
|
|
);
|
|
assert!(r.contains(r#""status":"ok""#), "insert: {}", r);
|
|
|
|
assert!(
|
|
data_dir.join("databases/main/cfg.db").exists(),
|
|
"data dir from config file was not used"
|
|
);
|
|
}
|
|
|
|
/// 重复建表报错
|
|
#[test]
|
|
fn create_table_twice_errors() {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect().unwrap();
|
|
|
|
let req = r#"{"method":"create_table","table":"dup","columns":[{"name":"id","ty":"int"}]}"#;
|
|
let r1 = send(&mut c, req);
|
|
assert!(r1.contains(r#""status":"ok""#), "first: {}", r1);
|
|
|
|
let r2 = send(&mut c, req);
|
|
assert!(r2.contains(r#""status":"error""#), "second: {}", r2);
|
|
assert!(r2.contains("already exists"), "second: {}", r2);
|
|
}
|
|
|
|
/// 新建空表能扫描
|
|
#[test]
|
|
fn scan_empty_table_after_create() {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect().unwrap();
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"create_table","table":"empty_t","columns":[{"name":"id","ty":"int"}]}"#,
|
|
);
|
|
assert!(r.contains(r#""status":"ok""#), "create: {}", r);
|
|
|
|
let r = send(&mut c, r#"{"method":"scan","table":"empty_t"}"#);
|
|
assert!(r.contains(r#""status":"rows""#), "scan: {}", r);
|
|
assert!(r.contains(r#""rows":[]"#), "scan: {}", r);
|
|
}
|
|
|
|
/// 约束元数据落盘;replace_schema 换列与行
|
|
#[test]
|
|
fn replace_schema_rewrites_columns_and_rows() {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect().unwrap();
|
|
|
|
let created = send(
|
|
&mut c,
|
|
r#"{"method":"create_table","table":"alt_t","columns":[{"name":"id","ty":"int","nullable":false,"auto_increment":true,"primary_key":true},{"name":"name","ty":"varchar(8)","default":"x"}]}"#,
|
|
);
|
|
assert!(created.contains(r#""status":"ok""#), "create: {}", created);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"alt_t","row":{"id":1,"name":null}}"#,
|
|
);
|
|
assert!(r.contains(r#""status":"ok""#), "insert null: {}", r);
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"alt_t","row":{"id":2,"name":"b"}}"#,
|
|
);
|
|
assert!(r.contains(r#""status":"ok""#), "insert: {}", r);
|
|
|
|
let r = send(&mut c, r#"{"method":"describe_table","table":"alt_t"}"#);
|
|
assert!(
|
|
r.contains(r#""ty":"varchar(8)""#),
|
|
"type parameters survive: {}",
|
|
r
|
|
);
|
|
assert!(
|
|
r.contains(r#""nullable":false"#),
|
|
"nullability survives: {}",
|
|
r
|
|
);
|
|
assert!(
|
|
r.contains(r#""auto_increment":true"#),
|
|
"auto increment survives: {}",
|
|
r
|
|
);
|
|
assert!(
|
|
r.contains(r#""primary_key":true"#),
|
|
"primary key survives: {}",
|
|
r
|
|
);
|
|
assert!(r.contains(r#""default":"x""#), "default survives: {}", r);
|
|
let scanned = send(&mut c, r#"{"method":"scan","table":"alt_t"}"#);
|
|
assert!(
|
|
scanned.contains(r#""name":null"#),
|
|
"null value survives a scan: {}",
|
|
scanned
|
|
);
|
|
|
|
let replaced = send(
|
|
&mut c,
|
|
r#"{"method":"replace_schema","table":"alt_t","columns":[{"name":"id","ty":"int","nullable":false},{"name":"tag","ty":"str","nullable":false,"default":"n"}],"rows":[{"id":1,"tag":"a"},{"id":2,"tag":"b"}]}"#,
|
|
);
|
|
assert!(
|
|
replaced.contains(r#""status":"ok""#),
|
|
"replace_schema: {}",
|
|
replaced
|
|
);
|
|
|
|
let r = send(&mut c, r#"{"method":"describe_table","table":"alt_t"}"#);
|
|
assert!(r.contains(r#""name":"tag""#), "new column: {}", r);
|
|
assert!(!r.contains(r#""name":"name""#), "old column gone: {}", r);
|
|
assert!(r.contains(r#""row_count":2"#), "row count rebuilt: {}", r);
|
|
|
|
let r = send(&mut c, r#"{"method":"scan","table":"alt_t"}"#);
|
|
assert!(r.contains(r#""tag":"a""#), "rewritten rows: {}", r);
|
|
}
|
|
|
|
/// 启动时升级旧类型字典,缺的格补 null
|
|
#[test]
|
|
fn legacy_catalog_and_partial_rows_are_migrated() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","table":"legacy_t","columns":[{"name":"id","ty":"int"},{"name":"name","ty":"str"},{"name":"age","ty":"int"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"legacy_t","row":{"id":1,"name":"a"}}),
|
|
)?;
|
|
drop(c);
|
|
drop(server);
|
|
|
|
// 把字典改回老样子:只留 name / ty,类型名写成别名
|
|
let catalog_path = data.path().join("databases/main/catalog.json");
|
|
let mut catalog: serde_json::Value =
|
|
serde_json::from_str(&std::fs::read_to_string(&catalog_path)?)?;
|
|
let columns = catalog["tables"]["legacy_t"]["columns"]
|
|
.as_array_mut()
|
|
.ok_or("missing columns")?;
|
|
for column in columns.iter_mut() {
|
|
let name = column["name"].clone();
|
|
let ty = if column["ty"] == "int" {
|
|
"integer"
|
|
} else {
|
|
"text"
|
|
};
|
|
*column = serde_json::json!({"name": name, "ty": ty});
|
|
}
|
|
std::fs::write(&catalog_path, serde_json::to_string_pretty(&catalog)?)?;
|
|
|
|
let _restarted = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"legacy_t"}"#))?;
|
|
assert_eq!(
|
|
rows["rows"],
|
|
serde_json::json!([{"id":1,"name":"a","age":null}]),
|
|
"缺的格要补 null"
|
|
);
|
|
|
|
let described: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"describe_table","table":"legacy_t"}"#,
|
|
))?;
|
|
let types: Vec<&str> = described["columns"]
|
|
.as_array()
|
|
.ok_or("missing columns")?
|
|
.iter()
|
|
.map(|col| col["ty"].as_str().unwrap_or(""))
|
|
.collect();
|
|
assert_eq!(types, vec!["int", "str", "int"], "老类型名要并到新写法");
|
|
assert!(
|
|
described["columns"][0]["nullable"].is_boolean(),
|
|
"新字段要落盘"
|
|
);
|
|
|
|
let saved: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&catalog_path)?)?;
|
|
assert_eq!(saved["tables"]["legacy_t"]["columns"][0]["ty"], "int");
|
|
Ok(())
|
|
}
|
|
|
|
/// 老系统库的角色表启动时改到内部前缀
|
|
#[test]
|
|
fn legacy_privilege_tables_are_renamed_on_start() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","database":"system","table":"sys_roles","columns":[{"name":"id","ty":"int"},{"name":"name","ty":"str"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","database":"system","table":"sys_roles","row":{"id":1,"name":"admin"}}),
|
|
)?;
|
|
drop(c);
|
|
drop(server);
|
|
|
|
let system_dir = data.path().join("system");
|
|
assert!(
|
|
system_dir.join("sys_roles.db").exists(),
|
|
"老数据文件要先生成"
|
|
);
|
|
|
|
let _restarted = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
|
|
let described: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"describe_table","database":"system","table":"__system_roles"}"#,
|
|
))?;
|
|
assert_eq!(described["status"], "schema", "{described}");
|
|
let rows: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"scan","database":"system","table":"__system_roles"}"#,
|
|
))?;
|
|
assert_eq!(
|
|
rows["rows"],
|
|
serde_json::json!([{"id":1,"name":"admin"}]),
|
|
"行要跟着改名走"
|
|
);
|
|
let old: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"describe_table","database":"system","table":"sys_roles"}"#,
|
|
))?;
|
|
assert_eq!(old["status"], "error", "旧名不该还在:{old}");
|
|
assert!(
|
|
system_dir.join("__system_roles.db").exists(),
|
|
"数据文件要改名"
|
|
);
|
|
assert!(
|
|
!system_dir.join("sys_roles.db").exists(),
|
|
"老数据文件要改掉"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 角色与授权表能被请求通道读到,账号表不能
|
|
#[test]
|
|
fn privilege_tables_are_reachable_but_account_table_is_not()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
let (_server, _data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","database":"system","table":"__system_roles","columns":[{"name":"id","ty":"int"},{"name":"name","ty":"str"}]}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_table","database":"system","table":"__system_grant_options","columns":[{"name":"id","ty":"int"},{"name":"role","ty":"str"}]}),
|
|
)?;
|
|
let scanned: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"scan","database":"system","table":"__system_roles"}"#,
|
|
))?;
|
|
assert_eq!(scanned["status"], "rows", "{scanned}");
|
|
let options: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"scan","database":"system","table":"__system_grant_options"}"#,
|
|
))?;
|
|
assert_eq!(options["status"], "rows", "{options}");
|
|
let denied: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"scan","database":"system","table":"__system_users"}"#,
|
|
))?;
|
|
assert_eq!(denied["status"], "error", "{denied}");
|
|
assert!(
|
|
denied["message"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains("reserved system table"),
|
|
"{denied}"
|
|
);
|
|
let listed: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"list_tables","database":"system"}"#,
|
|
))?;
|
|
let tables: Vec<&str> = listed["tables"]
|
|
.as_array()
|
|
.ok_or("missing tables")?
|
|
.iter()
|
|
.map(|one| one.as_str().unwrap_or(""))
|
|
.collect();
|
|
assert!(
|
|
tables.contains(&"__system_roles"),
|
|
"授权表要在字典里: {listed}"
|
|
);
|
|
assert!(
|
|
tables.contains(&"__system_grant_options"),
|
|
"转授权表要在字典里: {listed}"
|
|
);
|
|
assert!(
|
|
!tables.contains(&"__system_users"),
|
|
"账号表不能露面: {listed}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 账号表落盘类型与索引,登录盖时间戳
|
|
#[test]
|
|
fn account_table_uses_typed_columns_and_stamps_login() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (_srv, data) = start_server();
|
|
let mut c = connect()?;
|
|
|
|
let created: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"account_create","user":"alice","password_hash":"hash"}"#,
|
|
))?;
|
|
assert_eq!(created["status"], "accounts", "{}", created);
|
|
let account = &created["accounts"][0];
|
|
assert_eq!(account["user"], "alice");
|
|
assert_eq!(account["id"], 1);
|
|
assert_eq!(
|
|
account["registered_at"].as_str().unwrap_or("").len(),
|
|
19,
|
|
"注册时间要写成 timestamp: {}",
|
|
account
|
|
);
|
|
assert!(
|
|
account["last_login_at"].is_null(),
|
|
"还没登录过: {}",
|
|
account
|
|
);
|
|
|
|
let catalog: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(
|
|
data.path().join("system/catalog.json"),
|
|
)?)?;
|
|
let credential_columns = &catalog["tables"]["__system_users"]["columns"];
|
|
assert_eq!(
|
|
credential_columns
|
|
.as_array()
|
|
.ok_or("missing credentials")?
|
|
.iter()
|
|
.map(|c| c["name"].as_str().unwrap_or(""))
|
|
.collect::<Vec<_>>(),
|
|
vec!["id", "password_hash"]
|
|
);
|
|
let entry = &catalog["tables"]["__system_identities"];
|
|
let columns = entry["columns"].as_array().ok_or("missing columns")?;
|
|
let names: Vec<&str> = columns
|
|
.iter()
|
|
.map(|col| col["name"].as_str().unwrap_or(""))
|
|
.collect();
|
|
assert_eq!(
|
|
names,
|
|
vec![
|
|
"id",
|
|
"user",
|
|
"can_login",
|
|
"is_superuser",
|
|
"system_catalog_manager",
|
|
"allow_sudo_auth",
|
|
"enabled",
|
|
"identity_version",
|
|
"registered_at",
|
|
"last_login_at",
|
|
"revision"
|
|
]
|
|
);
|
|
let types: Vec<&str> = columns
|
|
.iter()
|
|
.map(|col| col["ty"].as_str().unwrap_or(""))
|
|
.collect();
|
|
assert_eq!(
|
|
types,
|
|
vec![
|
|
"int",
|
|
"varchar(64)",
|
|
"bool",
|
|
"bool",
|
|
"bool",
|
|
"bool",
|
|
"bool",
|
|
"int",
|
|
"timestamp",
|
|
"timestamp",
|
|
"int"
|
|
]
|
|
);
|
|
assert_eq!(columns[0]["primary_key"], true, "id 是主键");
|
|
assert_eq!(columns[0]["nullable"], false);
|
|
assert_eq!(columns[1]["unique"], true, "用户名唯一");
|
|
let indexes = entry["indexes"].as_array().ok_or("missing indexes")?;
|
|
assert!(
|
|
indexes.iter().any(|i| i["column"] == "user"),
|
|
"用户名索引要写进字典: {}",
|
|
entry
|
|
);
|
|
|
|
let login: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"account_login","user":"alice"}"#,
|
|
))?;
|
|
assert_eq!(login["status"], "accounts", "{}", login);
|
|
let stamp = login["accounts"][0]["last_login_at"].as_str().unwrap_or("");
|
|
assert_eq!(stamp.len(), 19, "登录要盖时间戳: {}", login);
|
|
Ok(())
|
|
}
|
|
|
|
/// 字符串列也能建索引并按键点查
|
|
#[test]
|
|
fn string_index_lookup_in_process() {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect().unwrap();
|
|
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"sidx","row":{"id":1,"name":"alice"}}"#,
|
|
);
|
|
send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"sidx","row":{"id":2,"name":"bob"}}"#,
|
|
);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"create_index","table":"sidx","column":"name"}"#,
|
|
);
|
|
assert!(r.contains(r#""status":"ok""#), "string column index: {}", r);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"sidx","column":"name","key":"bob"}"#,
|
|
);
|
|
assert!(r.contains(r#""id":2"#), "string lookup: {}", r);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"sidx","column":"name","key":"dave"}"#,
|
|
);
|
|
assert!(r.contains(r#""rows":[]"#), "miss: {}", r);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"insert","table":"sidx","row":{"id":3,"name":"alice"}}"#,
|
|
);
|
|
assert!(
|
|
r.contains(r#""status":"ok""#),
|
|
"duplicate string value is allowed on a secondary index: {}",
|
|
r
|
|
);
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"sidx","column":"name","key":"alice"}"#,
|
|
);
|
|
assert!(
|
|
r.contains(r#""id":1"#) && r.contains(r#""id":3"#),
|
|
"both rows come back: {}",
|
|
r
|
|
);
|
|
}
|
|
|
|
/// 范围扫描走索引,两端可开可闭,没索引回 no_index
|
|
#[test]
|
|
fn range_scan_in_process() {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect().unwrap();
|
|
|
|
for (id, code) in [(1, 100), (2, 200), (3, 300), (4, 400)] {
|
|
let req = format!(
|
|
r#"{{"method":"insert","table":"rng","row":{{"id":{},"code":{}}}}}"#,
|
|
id, code
|
|
);
|
|
send(&mut c, &req);
|
|
}
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"create_index","table":"rng","column":"code"}"#,
|
|
);
|
|
assert!(r.contains(r#""status":"ok""#), "create_index: {}", r);
|
|
|
|
let codes = |line: &str| -> Vec<i64> {
|
|
let value: serde_json::Value = serde_json::from_str(line).unwrap();
|
|
let mut out: Vec<i64> = value["rows"]
|
|
.as_array()
|
|
.map(|rows| rows.iter().filter_map(|row| row["code"].as_i64()).collect())
|
|
.unwrap_or_default();
|
|
out.sort_unstable();
|
|
out
|
|
};
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"range_by_index","table":"rng","column":"code","lo":200,"hi":400}"#,
|
|
);
|
|
assert_eq!(codes(&r), vec![200, 300, 400], "closed bounds: {}", r);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"range_by_index","table":"rng","column":"code","lo":200,"lo_inclusive":false,"hi":400,"hi_inclusive":false}"#,
|
|
);
|
|
assert_eq!(codes(&r), vec![300], "open bounds: {}", r);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"range_by_index","table":"rng","column":"code","lo":300}"#,
|
|
);
|
|
assert_eq!(codes(&r), vec![300, 400], "lower bound only: {}", r);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"range_by_index","table":"rng","column":"code","hi":200}"#,
|
|
);
|
|
assert_eq!(codes(&r), vec![100, 200], "upper bound only: {}", r);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"range_by_index","table":"rng","column":"nope"}"#,
|
|
);
|
|
assert!(
|
|
r.contains(r#""status":"no_index""#),
|
|
"unindexed column: {}",
|
|
r
|
|
);
|
|
}
|
|
|
|
/// 字符串列按字节序范围扫描
|
|
#[test]
|
|
fn string_range_scan_in_process() {
|
|
let (_srv, _data) = start_server();
|
|
let mut c = connect().unwrap();
|
|
|
|
for (id, name) in [(1, "alice"), (2, "bob"), (3, "carol"), (4, "dave")] {
|
|
let req = format!(
|
|
r#"{{"method":"insert","table":"srng","row":{{"id":{},"name":"{}"}}}}"#,
|
|
id, name
|
|
);
|
|
send(&mut c, &req);
|
|
}
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"create_index","table":"srng","column":"name"}"#,
|
|
);
|
|
assert!(r.contains(r#""status":"ok""#), "create_index: {}", r);
|
|
|
|
let names = |line: &str| -> Vec<String> {
|
|
let value: serde_json::Value = serde_json::from_str(line).unwrap();
|
|
let mut out: Vec<String> = value["rows"]
|
|
.as_array()
|
|
.map(|rows| {
|
|
rows.iter()
|
|
.filter_map(|row| row["name"].as_str().map(str::to_string))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
out.sort();
|
|
out
|
|
};
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"range_by_index","table":"srng","column":"name","lo":"b","hi":"carol"}"#,
|
|
);
|
|
assert_eq!(
|
|
names(&r),
|
|
vec!["bob".to_string(), "carol".to_string()],
|
|
"{}",
|
|
r
|
|
);
|
|
|
|
let r = send(
|
|
&mut c,
|
|
r#"{"method":"range_by_index","table":"srng","column":"name","lo":"carol","lo_inclusive":false}"#,
|
|
);
|
|
assert_eq!(names(&r), vec!["dave".to_string()], "{}", r);
|
|
}
|
|
|
|
/// 快照令牌复用须复查库身份和最新提交点
|
|
#[test]
|
|
fn versioned_database_snapshots_change_only_after_publication()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
let snapshot: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"database_snapshot"}"#))?;
|
|
assert_eq!(snapshot["status"], "domain_snapshot");
|
|
let token = snapshot["checkpoint"].as_str().unwrap();
|
|
let same: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
&serde_json::json!({"method":"database_snapshot","checkpoint":token}).to_string(),
|
|
))?;
|
|
assert_eq!(same["status"], "snapshot_unchanged");
|
|
assert!(same.get("rows").is_none());
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":1,"value":"v1"}}),
|
|
)?;
|
|
let changed: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
&serde_json::json!({"method":"database_snapshot","checkpoint":token}).to_string(),
|
|
))?;
|
|
assert_eq!(changed["status"], "domain_snapshot");
|
|
assert_ne!(changed["checkpoint"], snapshot["checkpoint"]);
|
|
assert!(
|
|
changed["rows"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|table| table[0] == "items"
|
|
&& table[1] == serde_json::json!([{"id":1,"value":"v1"}]))
|
|
);
|
|
drop(c);
|
|
drop(server);
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let same: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
&serde_json::json!({"method":"database_snapshot","checkpoint":changed["checkpoint"]})
|
|
.to_string(),
|
|
))?;
|
|
assert_eq!(same["status"], "snapshot_unchanged");
|
|
let page_versions: serde_json::Value = serde_json::from_slice(&std::fs::read(
|
|
data.path().join("databases/main/items.db.pages"),
|
|
)?)?;
|
|
assert!(page_versions["pages"]["0"].as_u64().unwrap() > 0);
|
|
Ok(())
|
|
}
|
|
|
|
/// 跨页整理缩小堆文件并重建全部索引位置
|
|
#[test]
|
|
fn compact_packs_survivors_across_pages_and_reopens() -> Result<(), Box<dyn std::error::Error>> {
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
let rows: Vec<_> = (1..=40)
|
|
.map(|id| serde_json::json!({"id":id,"secret":format!("key{id:03}"),"pad":"x".repeat(300)}))
|
|
.collect();
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert_batch","table":"items","rows":rows}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"create_index","table":"items","column":"secret"}),
|
|
)?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"delete_keys","table":"items","keys":(1..=40).filter(|n| n % 5 != 0).collect::<Vec<_>>()}),
|
|
)?;
|
|
let path = data.path().join("databases/main/items.db");
|
|
let before = std::fs::metadata(&path)?.len();
|
|
let expected: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"compact","table":"items"}),
|
|
)?;
|
|
let migrated = active_heap(data.path(), "items")?;
|
|
assert_ne!(migrated, path);
|
|
assert!(!path.exists());
|
|
assert!(std::fs::metadata(&migrated)?.len() < before);
|
|
let after: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(after, expected);
|
|
drop(c);
|
|
drop(server);
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
for id in (5..=40).step_by(5) {
|
|
let found: serde_json::Value = serde_json::from_str(&send(&mut c, &serde_json::json!({"method":"lookup_by_index","table":"items","column":"secret","key":format!("key{id:03}")}).to_string()))?;
|
|
assert_eq!(found["rows"], serde_json::json!([rows[(id - 1) as usize]]));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 残缺索引不阻断已提交整表日志恢复
|
|
#[test]
|
|
fn committed_replace_recovers_with_incomplete_index() -> Result<(), Box<dyn std::error::Error>> {
|
|
use chusql_core_storage::wal::{Wal, WalOp};
|
|
let (server, data) = start_server();
|
|
let mut c = connect()?;
|
|
request_ok(
|
|
&mut c,
|
|
serde_json::json!({"method":"insert","table":"items","row":{"id":1,"secret":"old"}}),
|
|
)?;
|
|
drop(c);
|
|
drop(server);
|
|
let directory = data.path().join("databases/main");
|
|
let replacement: Vec<chusql_core_storage::protocol::Row> =
|
|
serde_json::from_value(serde_json::json!([{"id":2,"secret":"new"}]))?;
|
|
Wal::new(directory.join("wal.log")).append_group(&[WalOp::ReplaceAll {
|
|
table: "items".into(),
|
|
rows: replacement,
|
|
}])?;
|
|
let index = directory.join("items.idx");
|
|
let mut bytes = std::fs::read(&index)?;
|
|
bytes[10..18].copy_from_slice(&99_u64.to_le_bytes());
|
|
std::fs::write(&index, bytes)?;
|
|
let _server = start_server_in(data.path());
|
|
let mut c = connect()?;
|
|
let rows: serde_json::Value =
|
|
serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
|
assert_eq!(rows["rows"], serde_json::json!([{"id":2,"secret":"new"}]));
|
|
let found: serde_json::Value = serde_json::from_str(&send(
|
|
&mut c,
|
|
r#"{"method":"lookup_by_index","table":"items","column":"id","key":2}"#,
|
|
))?;
|
|
assert_eq!(found["rows"], rows["rows"]);
|
|
Ok(())
|
|
}
|
|
|
|
/// 从目录读取当前物理堆路径
|
|
fn active_heap(
|
|
data: &std::path::Path,
|
|
table: &str,
|
|
) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
|
|
let directory = data.join("databases/main");
|
|
let catalog = chusql_core_storage::catalog::Catalog::load(directory.join("catalog.json"))?;
|
|
let generation = &catalog
|
|
.describe(table)
|
|
.ok_or("test table missing")?
|
|
.file_generation;
|
|
Ok(directory.join(if generation.is_empty() {
|
|
format!("{table}.db")
|
|
} else {
|
|
format!("{table}.g{generation}.db")
|
|
}))
|
|
}
|