准备可扩展类型系统工作,更合理的JOIN查询优化算法,并行查询

This commit is contained in:
LIm1tZz7 committed 2026-10-03 11:56:13 +08:00
1 parent 9758439b82
commit 274b7fcdcc
75 files changed
+3828 -1789

No files matched your search

+47 -6
View File
@@ -73,14 +73,25 @@ fn unpack_position(v: u64) -> (PageId, u16) {
(v >> 16, (v & 0xFFFF) as u16)
}
/// 取行里某一列的整数值(不是整数就没有)
fn row_int(row: &Row, column: &str) -> Option<i64> {
match row.get(column) {
Some(serde_json::Value::Number(n)) => n.as_i64(),
/// 数值里取整数,整值浮点折成整数
pub(crate) fn scalar_int(v: &serde_json::Value) -> Option<i64> {
match v {
serde_json::Value::Number(n) => n.as_i64().or_else(|| {
let f = n.as_f64()?;
if f.fract() != 0.0 || f.abs() > 9_007_199_254_740_992.0 {
return None;
}
Some(f as i64)
}),
_ => None,
}
}
/// 取行里某一列的整数值(不是整数就没有)
fn row_int(row: &Row, column: &str) -> Option<i64> {
row.get(column).and_then(scalar_int)
}
/// i64 键 → 8 字节大端(保序,负数也在前)
fn key_of(value: i64) -> [u8; 8] {
(value as u64 ^ (1u64 << 63)).to_be_bytes()
@@ -89,7 +100,7 @@ fn key_of(value: i64) -> [u8; 8] {
/// JSON 值 → 索引键字节(与写索引时同一套编码)
fn value_key(v: &serde_json::Value) -> Option<Vec<u8>> {
match v {
serde_json::Value::Number(n) => n.as_i64().map(|k| key_of(k).to_vec()),
serde_json::Value::Number(_) => scalar_int(v).map(|k| key_of(k).to_vec()),
serde_json::Value::String(s) => Some(s.as_bytes().to_vec()),
serde_json::Value::Bool(b) => Some(vec![u8::from(*b)]),
_ => None,
@@ -613,11 +624,41 @@ impl HeapTable {
self.scan_projected_with_positions(None)
}
/// 分片扫描,只取 [from, to) 页区间内的行
pub fn scan_columns_pages(
&mut self,
columns: Option<&[String]>,
from: PageId,
to: PageId,
) -> io::Result<Vec<Row>> {
Ok(self
.scan_pages_with_positions(columns, from, to)?
.into_iter()
.map(|(_, _, r)| r)
.collect())
}
/// 当前页数
pub fn num_pages(&mut self) -> io::Result<u64> {
self.file.num_pages()
}
/// 全表扫描,带位置与列投影
fn scan_projected_with_positions(&mut self, columns: Option<&[String]>) -> io::Result<Vec<(PageId, u16, Row)>> {
let n = self.file.num_pages()?;
self.scan_pages_with_positions(columns, 0, n)
}
/// 扫描 [from, to) 页区间,带位置与列投影
fn scan_pages_with_positions(
&mut self,
columns: Option<&[String]>,
from: PageId,
to: PageId,
) -> io::Result<Vec<(PageId, u16, Row)>> {
let n = self.file.num_pages()?;
let mut out = Vec::new();
for i in 0..n {
for i in from..to.min(n) {
let tuples = self.file.with_page(i, |p| {
let mut v = Vec::new();
for slot in HeapPage::iter_slots(p) {
-206
View File
@@ -1,206 +0,0 @@
use crate::protocol::SchemaColumn;
// 读取 scripts/init.sql,只认 CREATE TABLE 与 CREATE INDEX。
// 系统库的 schema 由这份脚本声明,进程按它建系统表。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InitColumn {
pub name: String,
pub ty: String,
pub nullable: bool,
pub auto_increment: bool,
pub primary_key: bool,
pub unique: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InitTable {
pub name: String,
pub columns: Vec<InitColumn>,
pub indexes: Vec<String>,
}
impl InitColumn {
/// 转成数据字典里的列
pub fn to_schema(&self) -> SchemaColumn {
SchemaColumn {
name: self.name.clone(),
ty: self.ty.clone(),
nullable: self.nullable,
default: None,
auto_increment: self.auto_increment,
primary_key: self.primary_key,
unique: self.unique,
check: None,
}
}
}
/// 解析整份脚本;不认识的语句直接报错,避免悄悄漏建表
pub fn parse(text: &str) -> Result<Vec<InitTable>, String> {
let mut tables: Vec<InitTable> = Vec::new();
for statement in statements(text) {
let words = statement.split_whitespace().collect::<Vec<_>>();
let head = |index: usize| words.get(index).map(|word| word.to_ascii_uppercase());
match (head(0).as_deref(), head(1).as_deref()) {
(Some("CREATE"), Some("TABLE")) => {
let name = clean_name(words.get(2).ok_or("CREATE TABLE needs a name")?);
let body = statement.split_once('(').ok_or("CREATE TABLE needs a column list")?.1;
let inner = body.rsplit_once(')').map(|(inner, _)| inner).unwrap_or(body);
tables.push(InitTable { name, columns: columns(inner)?, indexes: Vec::new() });
}
(Some("CREATE"), Some("INDEX")) => {
let table = clean_name(words.get(2).ok_or("CREATE INDEX needs a table")?);
let column = statement
.split_once('(')
.and_then(|(_, rest)| rest.split_once(')'))
.map(|(column, _)| clean_name(column))
.ok_or("CREATE INDEX needs a column")?;
let entry = tables
.iter_mut()
.find(|entry| entry.name.eq_ignore_ascii_case(&table))
.ok_or_else(|| format!("CREATE INDEX on unknown table: {table}"))?;
entry.indexes.push(column);
}
_ => return Err(format!("unsupported statement: {}", statement.trim())),
}
}
Ok(tables)
}
/// 解析表定义里的列清单
fn columns(body: &str) -> Result<Vec<InitColumn>, String> {
let mut out = Vec::new();
for part in split_top_level(body) {
let words = part.split_whitespace().collect::<Vec<_>>();
if words.is_empty() {
continue;
}
let keyword = words[0].to_ascii_uppercase();
if matches!(keyword.as_str(), "PRIMARY" | "UNIQUE" | "KEY" | "CONSTRAINT" | "INDEX" | "CHECK" | "FOREIGN") {
return Err(format!("table-level constraint is not supported: {}", part.trim()));
}
let name = clean_name(words[0]);
let ty = words
.get(1)
.ok_or_else(|| format!("column {name} needs a type"))?
.to_string();
let upper = part.to_ascii_uppercase();
out.push(InitColumn {
name,
ty,
nullable: !upper.contains("NOT NULL"),
auto_increment: upper.contains("AUTO_INCREMENT"),
primary_key: upper.contains("PRIMARY KEY"),
unique: upper.contains("UNIQUE"),
});
}
Ok(out)
}
/// 按顶层的逗号切开(类型里的括号不算)
fn split_top_level(body: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut depth = 0;
for ch in body.chars() {
match ch {
'(' => {
depth += 1;
current.push(ch);
}
')' => {
depth -= 1;
current.push(ch);
}
',' if depth == 0 => {
parts.push(std::mem::take(&mut current));
}
_ => current.push(ch),
}
}
if !current.trim().is_empty() {
parts.push(current);
}
parts
}
/// 去掉反引号与空白
fn clean_name(raw: &str) -> String {
raw.trim().trim_matches('`').trim_matches('"').trim().to_string()
}
/// 去掉注释后按分号切开
fn statements(text: &str) -> Vec<String> {
let mut cleaned = String::new();
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '-' && chars.peek() == Some(&'-') {
for next in chars.by_ref() {
if next == '\n' {
cleaned.push('\n');
break;
}
}
} else if ch == '/' && chars.peek() == Some(&'*') {
chars.next();
while let Some(next) = chars.next() {
if next == '*' && chars.peek() == Some(&'/') {
chars.next();
break;
}
}
} else {
cleaned.push(ch);
}
}
cleaned
.split(';')
.map(|statement| statement.trim().to_string())
.filter(|statement| !statement.is_empty())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// 仓库里的系统库脚本必须始终能解析
const SYSTEM_SCHEMA: &str = include_str!("../../../scripts/init.sql");
/// 仓库自带脚本必须能解析出账号表
#[test]
fn parses_the_repository_init_script() {
let tables = parse(SYSTEM_SCHEMA).expect("scripts/init.sql must parse");
let users = tables
.iter()
.find(|table| table.name == "__chusql_users")
.expect("init.sql must declare the account table");
let names: Vec<&str> = users.columns.iter().map(|column| column.name.as_str()).collect();
assert_eq!(names, ["id", "user", "password_hash", "registered_at", "last_login_at", "revision"]);
assert_eq!(users.columns[0].ty, "int");
assert!(users.columns[0].primary_key && users.columns[0].auto_increment && !users.columns[0].nullable);
assert_eq!(users.columns[1].ty, "varchar(64)");
assert!(users.columns[1].unique);
assert_eq!(users.columns[4].ty, "timestamp");
assert!(users.columns[4].nullable);
assert_eq!(users.indexes, ["user"]);
}
/// 未知语句与表级约束要报错
#[test]
fn rejects_unknown_statements_and_table_level_constraints() {
assert!(parse("DROP TABLE users;").is_err());
assert!(parse("CREATE TABLE t (id int, PRIMARY KEY (id));").is_err());
assert!(parse("CREATE INDEX missing (user);").is_err());
}
/// 注释与空语句要被跳过
#[test]
fn ignores_comments_and_blank_statements() {
let tables = parse("-- note\nCREATE TABLE t (a int NOT NULL);\n\n/* done */\n").unwrap();
assert_eq!(tables.len(), 1);
assert_eq!(tables[0].columns[0].name, "a");
assert!(!tables[0].columns[0].nullable);
}
}
-1
View File
@@ -5,7 +5,6 @@ pub mod catalog;
pub mod config;
pub mod ffi;
pub mod heap;
pub mod initsql;
pub mod log;
pub mod page;
pub mod protocol;
+23 -4
View File
@@ -5,7 +5,7 @@ use std::collections::HashMap;
pub type Row = HashMap<String, serde_json::Value>;
pub const USERS_TABLE: &str = "__chusql_users";
pub const USERS_TABLE: &str = "__system_users";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Account {
@@ -83,12 +83,28 @@ pub enum Request {
at: Option<String>,
},
AccountDrop { user: String },
/// 引导系统目录:建系统表,给了 user 就同时补首个管理员
BootstrapSystem {
#[serde(default)]
user: Option<String>,
#[serde(default)]
password_hash: Option<String>,
},
SystemStatus,
Ping,
Scan {
table: String,
#[serde(default)]
columns: Option<Vec<String>>,
},
/// 分片扫描:第 shard 片只读总页数按 shards 均分后的那一段
ScanShard {
table: String,
#[serde(default)]
columns: Option<Vec<String>>,
shard: u32,
shards: u32,
},
Insert {
table: String,
row: Row,
@@ -174,7 +190,8 @@ impl Request {
/// 普通表操作的统一访问边界
pub fn table(&self) -> Option<&str> {
match self {
Self::Scan { table, .. } | Self::Insert { table, .. }
Self::Scan { table, .. } | Self::ScanShard { table, .. }
| Self::Insert { table, .. }
| Self::InsertBatch { table, .. } | Self::DeleteKeys { table, .. }
| Self::LookupByIndex { table, .. } | Self::ReplaceAll { table, .. }
| Self::RangeByIndex { table, .. }
@@ -185,14 +202,14 @@ impl Request {
| Self::ReplaceSchema { table, .. } => Some(table),
Self::Ping | Self::ListTables | Self::ListCatalog | Self::AccountsList
| Self::AccountCreate { .. } | Self::AccountReset { .. } | Self::AccountLogin { .. }
| Self::AccountDrop { .. } => None,
| Self::AccountDrop { .. } | Self::BootstrapSystem { .. } | Self::SystemStatus => None,
}
}
}
/// 是不是保留系统表名
pub fn reserved_table(table: &str) -> bool {
table.to_ascii_lowercase().starts_with("__chusql_")
table.to_ascii_lowercase().starts_with("__system_")
}
/// 没写 column 时按 `id` 算
@@ -219,6 +236,8 @@ pub enum Response {
},
NoIndex,
Ok,
/// 系统目录状态
System { initialized: bool },
Error { message: String },
Catalog { schemas: Vec<TableSchemaWire> },
}
+232 -62
View File
@@ -1,12 +1,11 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, RwLock};
use crate::catalog::Catalog;
use crate::config::{self, Config, Loaded};
use crate::heap::HeapTable;
use crate::initsql::{self, InitColumn, InitTable};
use crate::heap::{scalar_int, HeapTable};
use crate::log;
use crate::protocol::{
reserved_table, Account, ColumnStatWire, IndexWire, Request, Response, Row, SchemaColumn, TableSchemaWire, USERS_TABLE,
@@ -21,6 +20,7 @@ struct Server {
cfg: Config,
tables: Mutex<HashMap<String, Arc<Mutex<HeapTable>>>>,
write_lock: Mutex<()>,
gate: RwLock<()>,
catalog: Mutex<Catalog>,
wal: Wal,
recovery_required: AtomicBool,
@@ -38,6 +38,7 @@ impl Server {
cfg,
tables: Mutex::new(HashMap::new()),
write_lock: Mutex::new(()),
gate: RwLock::new(()),
catalog: Mutex::new(catalog),
wal,
recovery_required: AtomicBool::new(false),
@@ -127,6 +128,56 @@ impl Server {
f(&mut guard)
}
/// 请求带列名时逐个查字典
fn check_columns(&self, table: &str, columns: Option<&[String]>) -> std::io::Result<()> {
let Some(cols) = columns else {
return Ok(());
};
let c = self.catalog.lock().map_err(|_| std::io::Error::other("catalog lock poisoned"))?;
let schema = c
.describe(table)
.ok_or_else(|| std::io::Error::other(format!("unknown table: {table}")))?;
for col in cols {
if !schema.columns.iter().any(|c| &c.name == col) {
return Err(std::io::Error::other(format!("unknown column: {col}")));
}
}
Ok(())
}
/// 分片只读扫描:独立句柄读一段连续页,不经表锁
fn scan_shard(
&self,
table: &str,
columns: Option<&[String]>,
shard: u32,
shards: u32,
) -> std::io::Result<Vec<Row>> {
if shards == 0 || shard >= shards {
return Err(std::io::Error::other("invalid shard range"));
}
if !self.table_exists(table)? {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("unknown table: {}", table),
));
}
let hidden: HashSet<String> = self
.catalog
.lock()
.map_err(|_| std::io::Error::other("catalog lock poisoned"))?
.describe(table)
.map(|s| s.dropped_columns.iter().cloned().collect())
.unwrap_or_default();
let mut t = HeapTable::open(self.table_path(table), self.cfg.page_size, self.cfg.pool_size)?;
t.set_hidden_columns(hidden);
let n = t.num_pages()?;
let chunk = n.div_ceil(u64::from(shards));
let from = u64::from(shard).saturating_mul(chunk).min(n);
let to = u64::from(shard + 1).saturating_mul(chunk).min(n);
t.scan_columns_pages(columns, from, to)
}
/// 表不存在就报错
fn with_existing_table<R>(
&self,
@@ -528,6 +579,88 @@ impl Server {
Ok(())
}
/// 账号表是否已登记为系统表
fn account_table_ready(&self) -> std::io::Result<bool> {
use std::io::{Error, ErrorKind};
let registered = {
let c = self.catalog.lock().map_err(|_| Error::other("catalog lock poisoned"))?;
c.describe(USERS_TABLE).map(|schema| schema.system)
};
match registered {
Some(true) => Ok(true),
Some(false) => Err(Error::new(ErrorKind::AlreadyExists, "reserved account table collision")),
None => {
if self.table_path(USERS_TABLE).exists() || self.index_path(USERS_TABLE, "id").exists() {
Err(Error::new(ErrorKind::AlreadyExists, "reserved account file collision"))
} else {
Ok(false)
}
}
}
}
/// 系统目录里账号表已登记
fn system_initialized(&self) -> bool {
self.catalog
.lock()
.map(|c| c.describe(USERS_TABLE).is_some_and(|schema| schema.system))
.unwrap_or(false)
}
/// 读账号表;表没登记或数据文件缺失都算错
fn load_accounts(&self) -> std::io::Result<Vec<Account>> {
use std::io::Error;
if !self.table_path(USERS_TABLE).exists() {
return Err(Error::other("missing account data"));
}
let mut accounts: Vec<Account> = self
.with_existing_table(USERS_TABLE, |t| t.scan())?
.iter()
.map(decode_account_row)
.collect::<std::io::Result<_>>()?;
for account in accounts.iter_mut() {
normalize_account(account);
}
Ok(accounts)
}
/// 引导系统目录:建账号表,空表才补管理员
fn bootstrap_system(&self, user: Option<&str>, password_hash: Option<&str>) -> std::io::Result<Vec<Account>> {
use std::io::Error;
let _guard = self.write_lock.lock().map_err(|_| Error::other("write lock poisoned"))?;
if self.recovery_required.load(Ordering::Acquire) {
return Err(Error::other("storage recovery required"));
}
let initialized = self.account_table_ready()?;
let mut accounts = if initialized { self.load_accounts()? } else { Vec::new() };
let mut changed = !initialized;
if let Some(user) = user {
let name = account_name(user)?;
// 表里已经有行就不再补人:引导程序只在空表上播种,
// 免得有人拿 storage 透传绕过账号接口往里塞一个已知口令的账号
if accounts.is_empty() && !accounts.iter().any(|a| a.user == name) {
let id = accounts.iter().map(|a| a.id).max().unwrap_or(0)
.checked_add(1).ok_or_else(|| Error::other("account id exhausted"))?;
accounts.push(new_account(id, &name, password_hash.unwrap_or_default().to_string())?);
changed = true;
}
}
if !changed {
return Ok(accounts);
}
for row in account_rows(&accounts)? {
HeapTable::encode_row(&row, self.cfg.page_size)?;
}
let op = WalOp::Accounts { accounts: accounts.clone() };
self.recovery_required.store(true, Ordering::Release);
self.wal.truncate()?;
self.wal.append(&op)?;
self.apply_op(&op)?;
self.wal.clear()?;
self.recovery_required.store(false, Ordering::Release);
Ok(accounts)
}
/// 账号读写共享串行持久化边界
fn accounts_request(&self, request: Request) -> std::io::Result<Vec<Account>> {
use std::io::{Error, ErrorKind};
@@ -535,31 +668,10 @@ impl Server {
if self.recovery_required.load(Ordering::Acquire) {
return Err(Error::other("storage recovery required"));
}
let initialized = {
let c = self.catalog.lock().map_err(|_| Error::other("catalog lock poisoned"))?;
match c.describe(USERS_TABLE) {
Some(schema) if schema.system => true,
Some(_) => return Err(Error::new(ErrorKind::AlreadyExists, "reserved account table collision")),
None => false,
}
};
if !initialized && (self.table_path(USERS_TABLE).exists() || self.index_path(USERS_TABLE, "id").exists()) {
return Err(Error::new(ErrorKind::AlreadyExists, "reserved account file collision"));
if !self.account_table_ready()? {
return Err(Error::other("system catalog is not initialized; run csql-bootstrap"));
}
let mut accounts: Vec<Account> = if initialized {
if !self.table_path(USERS_TABLE).exists() {
return Err(Error::other("missing account data"));
}
let mut loaded: Vec<Account> = self
.with_existing_table(USERS_TABLE, |t| t.scan())?
.iter()
.map(decode_account_row)
.collect::<std::io::Result<_>>()?;
for account in loaded.iter_mut() {
normalize_account(account);
}
loaded
} else { Vec::new() };
let mut accounts = self.load_accounts()?;
match request {
Request::AccountsList => return Ok(accounts),
Request::AccountCreate { user, password_hash } => {
@@ -609,8 +721,27 @@ impl Server {
Ok(accounts)
}
/// 一条请求翻成一条响应
/// 一条请求翻成一条响应:先过读/写门再分发
fn handle_request(&self, req: Request) -> Response {
if concurrent_read(&req) {
match self.gate.read() {
Ok(_read) => self.dispatch(req),
Err(_) => Response::Error {
message: "storage read gate poisoned".into(),
},
}
} else {
match self.gate.write() {
Ok(_write) => self.dispatch(req),
Err(_) => Response::Error {
message: "storage write gate poisoned".into(),
},
}
}
}
/// 持门后分发一条请求
fn dispatch(&self, req: Request) -> Response {
let _ddl_guard = if matches!(req, Request::CreateTable { .. } | Request::DropTable { .. }
| Request::CreateIndex { .. } | Request::DropIndex { .. }) {
match self.write_lock.lock() {
@@ -641,6 +772,13 @@ impl Server {
Err(e) => Response::Error { message: e.to_string() },
}
}
Request::BootstrapSystem { user, password_hash } => {
match self.bootstrap_system(user.as_deref(), password_hash.as_deref()) {
Ok(_) => Response::System { initialized: true },
Err(e) => Response::Error { message: e.to_string() },
}
}
Request::SystemStatus => Response::System { initialized: self.system_initialized() },
Request::Ping => {
log_debug!(request, "ping");
Response::Pong
@@ -650,15 +788,7 @@ impl Server {
log_debug!(request, "scan table={}", table);
let result = (|| {
if let Some(cols) = &columns {
let c = self.catalog.lock().map_err(|_| std::io::Error::other("catalog lock poisoned"))?;
let schema = c.describe(&table).ok_or_else(|| std::io::Error::other(format!("unknown table: {table}")))?;
for col in cols {
if !schema.columns.iter().any(|c| &c.name == col) {
return Err(std::io::Error::other(format!("unknown column: {col}")));
}
}
}
self.check_columns(&table, columns.as_deref())?;
self.with_existing_table(&table, |t| t.scan_columns(columns.as_deref()))
})();
@@ -673,6 +803,30 @@ impl Server {
}
}
Request::ScanShard {
table,
columns,
shard,
shards,
} => {
log_debug!(request, "scan_shard table={} shard={}/{}", table, shard, shards);
let result = (|| {
self.check_columns(&table, columns.as_deref())?;
self.scan_shard(&table, columns.as_deref(), shard, shards)
})();
match result {
Ok(rows) => Response::Rows { rows },
Err(e) => {
log_warn!(request, "scan {} shard {}/{}: {}", table, shard, shards, e);
Response::Error {
message: format!("scan {}: {}", table, e),
}
}
}
}
Request::Insert { table, row } => {
log_debug!(request, "insert table={}", table);
let tname = table.clone();
@@ -733,7 +887,7 @@ impl Server {
if !indexed {
return Response::NoIndex;
}
let found = match key.as_i64() {
let found = match scalar_int(&key) {
Some(k) => self.with_table(&table, |t| t.get_all_by_column_key(&column, k)),
None => match key.as_str() {
Some(s) => self.with_table(&table, |t| t.get_all_by_string_key(&column, s)),
@@ -1025,18 +1179,31 @@ fn account_rows(accounts: &[Account]) -> std::io::Result<Vec<Row>> {
]))).collect()
}
/// 按 init.sql 把系统表写进字典
/// 系统库的账号表结构:引导模块按这份定义建表
fn users_columns() -> Vec<SchemaColumn> {
vec![
SchemaColumn { name: "id".into(), ty: "int".into(), nullable: false,
auto_increment: true, primary_key: true, ..Default::default() },
SchemaColumn { name: "user".into(), ty: "varchar(64)".into(), nullable: false,
unique: true, ..Default::default() },
SchemaColumn { name: "password_hash".into(), ty: "varchar(256)".into(),
nullable: false, ..Default::default() },
SchemaColumn { name: "registered_at".into(), ty: "timestamp".into(),
nullable: false, ..Default::default() },
SchemaColumn { name: "last_login_at".into(), ty: "timestamp".into(),
..Default::default() },
SchemaColumn { name: "revision".into(), ty: "int".into(), nullable: false,
..Default::default() },
]
}
/// 把系统表写进字典
fn declare_system_schema(catalog: &mut Catalog, rows: &[Row]) -> std::io::Result<()> {
for table in system_schema() {
let stats: &[Row] = if table.name == USERS_TABLE { rows } else { &[] };
catalog.rebuild_stats(&table.name, stats);
catalog.set_columns(&table.name, table.columns.iter().map(InitColumn::to_schema).collect())?;
catalog.rebuild_stats(&table.name, stats);
catalog.mark_system(&table.name)?;
for index in &table.indexes {
catalog.add_index(&table.name, index)?;
}
}
catalog.rebuild_stats(USERS_TABLE, rows);
catalog.set_columns(USERS_TABLE, users_columns())?;
catalog.rebuild_stats(USERS_TABLE, rows);
catalog.mark_system(USERS_TABLE)?;
catalog.add_index(USERS_TABLE, "user")?;
Ok(())
}
@@ -1110,9 +1277,9 @@ fn account_name(user: &str) -> std::io::Result<String> {
Ok(user.to_ascii_lowercase())
}
/// 校验口令哈希长度
/// 校验口令哈希长度,空串表示免密
fn validate_account_hash(hash: &str) -> std::io::Result<()> {
if hash.is_empty() || hash.len() > 256 {
if hash.len() > 256 {
return Err(std::io::Error::other("invalid account credential"));
}
Ok(())
@@ -1172,15 +1339,6 @@ impl Drop for Server {
/// 别的库一律要显式建(没有默认工作库)。
const SYSTEM_DATABASE: &str = "system";
/// 系统库的表结构来自这份脚本(编译期嵌进来)
const SYSTEM_SCHEMA_SQL: &str = include_str!("../../../scripts/init.sql");
/// 系统库 schema:脚本缺表或多表都按它来
fn system_schema() -> &'static [InitTable] {
static TABLES: std::sync::OnceLock<Vec<InitTable>> = std::sync::OnceLock::new();
TABLES.get_or_init(|| initsql::parse(SYSTEM_SCHEMA_SQL).unwrap_or_else(|e| panic!("scripts/init.sql: {e}")))
}
/// 保留库:不能建、不能删。
fn reserved_database(name: &str) -> bool {
name == SYSTEM_DATABASE
@@ -1322,7 +1480,8 @@ impl Databases {
let request: Request = serde_json::from_value(value).map_err(Error::other)?;
let global = matches!(request, Request::AccountsList | Request::AccountCreate { .. }
| Request::AccountReset { .. } | Request::AccountLogin { .. }
| Request::AccountDrop { .. } | Request::Ping);
| Request::AccountDrop { .. } | Request::BootstrapSystem { .. }
| Request::SystemStatus | Request::Ping);
let server = if global { &self.system }
else if selected.is_empty() { return Err(Error::other("no database selected")); }
else if selected == SYSTEM_DATABASE { &self.system }
@@ -1331,6 +1490,17 @@ impl Databases {
}
}
/// 只读表页的请求可与其它读并发,其余独占
fn concurrent_read(req: &Request) -> bool {
matches!(
req,
Request::Scan { .. }
| Request::ScanShard { .. }
| Request::LookupByIndex { .. }
| Request::RangeByIndex { .. }
)
}
/// 校验库名是否合法
fn valid_database(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
+322 -16
View File
@@ -32,6 +32,190 @@ fn scan_projects_columns_and_preserves_empty_rows() -> Result<(), Box<dyn std::e
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 drop_column_keeps_heap_bytes_and_hides_values_after_restart() -> Result<(), Box<dyn std::error::Error>> {
@@ -240,7 +424,7 @@ fn startup_compaction_reclaims_dropped_columns() -> Result<(), Box<dyn std::erro
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", "__chusql_users"] {
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}");
}
@@ -270,7 +454,7 @@ fn system_database_owns_accounts_and_nothing_is_default() -> Result<(), Box<dyn
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/__chusql_users.db").is_file());
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");
@@ -323,9 +507,10 @@ fn start_server() -> (ServerProc, tempfile::TempDir) {
(server, data)
}
/// 在指定数据目录起存储并建测试库
/// 在指定数据目录起存储、引导系统目录并建测试库
fn start_server_in(data: &std::path::Path) -> ServerProc {
open_storage(&write_config(data));
bootstrap_system();
create_fixture_database();
ServerProc
}
@@ -371,7 +556,7 @@ fn named_database_survives_restart_and_accounts_stay_global() -> Result<(), Box<
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.__chusql_users"}"#))?;
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(())
}
@@ -404,6 +589,15 @@ fn create_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))
@@ -437,7 +631,7 @@ fn reserved_tables_reject_every_generic_operation() -> std::io::Result<()> {
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": "__chusql_users",
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}");
@@ -450,11 +644,11 @@ fn reserved_tables_reject_every_generic_operation() -> std::io::Result<()> {
fn generic_ipc_rejects_path_aliases() -> std::io::Result<()> {
let (_srv, _data) = start_server();
let mut c = connect()?;
for table in ["../__chusql_users", "__CHUSQL_USERS", "__chusql_users."] {
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":"../__chusql_users"}"#);
let result = send(&mut c, r#"{"method":"create_index","table":"business","column":"../__system_users"}"#);
assert!(result.contains("invalid column name"), "{result}");
Ok(())
}
@@ -484,7 +678,7 @@ fn account_create_reset_drop_and_hidden_from_ordinary_api() -> Result<(), Box<dy
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("__chusql_users"), "{result}");
assert!(!result.contains("__system_users"), "{result}");
}
Ok(())
}
@@ -492,6 +686,7 @@ fn account_create_reset_drop_and_hidden_from_ordinary_api() -> Result<(), Box<dy
/// 用同一份数据目录重开一次存储
fn restart_at(data: &std::path::Path) -> std::io::Result<ServerProc> {
open_storage(&write_config(data));
bootstrap_system();
create_fixture_database();
Ok(ServerProc)
}
@@ -514,8 +709,8 @@ fn account_snapshot_replays_twice_without_duplicating_accounts() -> Result<(), B
wal.truncate()?;
wal.append(&WalOp::Accounts { accounts: accounts.clone() })?;
drop(wal);
std::fs::write(data.path().join("system/__chusql_users.idx"), b"torn index")?;
std::fs::write(data.path().join("system/__chusql_users.db"), b"torn heap")?;
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"}"#))?;
@@ -555,12 +750,13 @@ fn account_failure_preserves_wal_and_blocks_writes() -> Result<(), Box<dyn std::
/// 账号文件已存在时拒绝建账号
#[test]
fn account_create_rejects_reserved_file_collision() -> std::io::Result<()> {
let (_server, data) = start_server();
std::fs::write(data.path().join("system/__chusql_users.db"), b"existing")?;
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/__chusql_users.db"))?, b"existing");
assert_eq!(std::fs::read(data.path().join("system/__system_users.db"))?, b"existing");
Ok(())
}
@@ -578,21 +774,98 @@ fn oversized_account_hash_is_rejected_before_wal() -> Result<(), Box<dyn std::er
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("__chusql_users", Vec::new())?;
catalog.create_table("__system_users", Vec::new())?;
std::fs::create_dir(data.path().join("system"))?;
catalog.save(data.path().join("system/catalog.json"))?;
let _server = restart_at(data.path())?;
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() {
@@ -663,6 +936,38 @@ fn duplicate_id_is_rejected() {
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() {
@@ -878,6 +1183,7 @@ fn config_file_is_used() {
std::fs::write(&config_path, text).unwrap();
open_storage(&config_path);
bootstrap_system();
create_fixture_database();
let mut c = connect().unwrap();
@@ -1016,7 +1322,7 @@ fn account_table_uses_typed_columns_and_stamps_login() -> Result<(), Box<dyn std
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 entry = &catalog["tables"]["__chusql_users"];
let entry = &catalog["tables"]["__system_users"];
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", "password_hash", "registered_at", "last_login_at", "revision"]);