使用React js完成web前端

This commit is contained in:
LIm1tZz7 committed 2026-09-27 22:08:23 +08:00
1 parent 9e8c5f1ef0
commit 7600f0f9ed
130 files changed
+12708 -788

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ order = 4
[storage]
# 数据目录:一张表一个 <表名>.db,一个索引一个 <表名>.idx。
# 环境变量:CHUSQL_DATA_DIR
data_dir = "data"
data_dir = "../localdata"
[server]
# 命名管道名(Windows 上实际是 \\.\pipe\<这个名字>)。
@@ -1,4 +1,3 @@
use std::collections::{BTreeSet, HashMap};
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
@@ -10,7 +9,7 @@ use chusql_storage::config::{self, Config, Loaded};
use chusql_storage::heap::HeapTable;
use chusql_storage::log;
use chusql_storage::protocol::{
ColumnStatWire, IndexWire, Request, Response, SchemaColumn, TableSchemaWire,
ColumnStatWire, IndexWire, Request, Response, Row, SchemaColumn, TableSchemaWire,
};
use chusql_storage::wal::{Wal, WalOp};
use chusql_storage::{log_debug, log_error, log_info, log_warn};
@@ -20,8 +19,6 @@ use interprocess::TryClone;
// 命名管道服务:按行读写 JSON,Windows 上映射成 \\.\pipe\<name>。
// 启动时读配置并把每项来源打进日志;表句柄按名字缓存复用。
// 服务器
/// 服务器状态:配置 + 表句柄缓存 + 写锁 + 数据字典
struct Server {
cfg: Config,
tables: Mutex<HashMap<String, Arc<Mutex<HeapTable>>>>,
@@ -45,16 +42,12 @@ impl Server {
})
}
// 路径
/// 表名 -> 数据文件
fn table_path(&self, table: &str) -> PathBuf {
self.cfg.data_dir.join(format!("{}.db", table))
}
/// (表名, 列名) -> 索引文件。
///
/// `id` 是内建索引,沿用老文件名 `表名.idx`(老数据目录不用迁移);
/// 其余列是 `表名.列名.idx`。
/// (表名, 列名) 对应哪个索引文件
fn index_path(&self, table: &str, column: &str) -> PathBuf {
if column == "id" {
self.cfg.data_dir.join(format!("{}.idx", table))
@@ -70,7 +63,6 @@ impl Server {
self.cfg.data_dir.join("catalog.json")
}
// 表句柄
/// 表文件存在且非空
fn table_exists(&self, table: &str) -> bool {
if self.tables.lock().unwrap().contains_key(table) {
@@ -86,7 +78,7 @@ impl Server {
.unwrap_or(false)
}
/// 一张表该开哪些索引:内建的 `id`,加上数据字典里记的那些
/// 一张表该开哪些索引
fn index_columns(&self, table: &str) -> Vec<String> {
let mut cols = vec!["id".to_string()];
let c = self.catalog.lock().unwrap();
@@ -105,7 +97,6 @@ impl Server {
if let Some(h) = self.tables.lock().unwrap().get(table) {
return Ok(h.clone());
}
// 先把索引清单取出来,再进 tables 锁(别套着锁去要 catalog)
let columns = self.index_columns(table);
let paths: Vec<(String, PathBuf)> = columns
.iter()
@@ -146,7 +137,6 @@ impl Server {
table: &str,
f: impl FnOnce(&mut HeapTable) -> std::io::Result<R>,
) -> std::io::Result<R> {
// 已经在缓存里的表不用再查磁盘(省一次 metadata 系统调用)
let cached = self.tables.lock().unwrap().contains_key(table);
if !cached && !self.table_exists(table) {
return Err(std::io::Error::new(
@@ -157,7 +147,6 @@ impl Server {
self.with_table(table, f)
}
// 写入路径
/// 把一条 WAL 操作落到磁盘
fn apply_op(&self, op: &WalOp) -> std::io::Result<()> {
match op {
@@ -191,11 +180,23 @@ impl Server {
}
WalOp::ReplaceAll { table, rows } => {
self.with_table(table, |t| t.replace_all(rows))?;
// 整表重写 = 统计与行数从头再数一遍
let mut c = self.catalog.lock().unwrap();
c.rebuild_stats(table, rows);
c.save(self.catalog_path())
}
WalOp::DropColumn { table, column, rows } => {
self.with_table(table, |t| {
t.detach_index(column);
t.replace_all(rows)
})?;
let mut c = self.catalog.lock().unwrap();
c.remove_column(table, column)?;
c.rebuild_stats(table, rows);
c.save(self.catalog_path())?;
drop(c);
let _ = std::fs::remove_file(self.index_path(table, column));
Ok(())
}
}
}
@@ -205,8 +206,6 @@ impl Server {
std::fs::create_dir_all(&self.cfg.data_dir)?;
let wal = &self.wal;
// 只截断不落盘:上一条写操作最后已经把 WAL 刷成空的了,
// 这里的截断只是保证下面追加的是唯一一条,跟着的 append 会顺手把它落下去。
wal.truncate()?;
wal.append(op)?;
@@ -219,7 +218,6 @@ impl Server {
}
}
// 这一步必须落盘:WAL 空 = 这条操作已经提交
wal.clear()?;
result
}
@@ -230,7 +228,8 @@ impl Server {
WalOp::Insert { table, .. }
| WalOp::InsertBatch { table, .. }
| WalOp::DeleteKeys { table, .. }
| WalOp::ReplaceAll { table, .. } => table,
| WalOp::ReplaceAll { table, .. }
| WalOp::DropColumn { table, .. } => table,
};
self.with_table(table, |t| t.flush())
}
@@ -251,10 +250,7 @@ impl Server {
}
}
/// 启动时把列统计重数一遍。
///
/// 统计是"每列见过哪些值"精确数出来的,只在内存里维护(随 catalog 落盘),
/// 所以重启之后要扫一遍表把它重建出来;否则统计会随重启慢慢失真。
/// 启动时重建列统计
fn rebuild_stats(&self) -> std::io::Result<()> {
let names: Vec<String> = self.catalog.lock().unwrap().table_names();
let mut touched = false;
@@ -274,13 +270,12 @@ impl Server {
Ok(())
}
// 索引维护
/// 给一列建索引
fn create_index(&self, table: &str, column: &str) -> std::io::Result<()> {
if column == "id" {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"id 是内建索引,不需要再建",
"id is the built-in index and cannot be created twice",
));
}
{
@@ -321,7 +316,7 @@ impl Server {
if column == "id" {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"id 是内建索引,不能删",
"id is the built-in index and cannot be dropped",
));
}
{
@@ -347,7 +342,48 @@ impl Server {
Ok(())
}
// 请求分发
/// 删一列:定义、索引、行里的字段
fn drop_column(&self, table: &str, column: &str) -> std::io::Result<()> {
if column == "id" {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"the built-in id column cannot be dropped",
));
}
{
let c = self.catalog.lock().unwrap();
let Some(schema) = c.describe(table) else {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("unknown table: {}", table),
));
};
if !schema.columns.iter().any(|c| c.name == column) {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("unknown column: {}", column),
));
}
}
let rows: Vec<Row> = self
.with_existing_table(table, |t| t.scan())?
.into_iter()
.map(|row| {
row.into_iter()
.filter(|(k, _)| k != column)
.collect::<Row>()
})
.collect();
let op = WalOp::DropColumn {
table: table.to_string(),
column: column.to_string(),
rows,
};
self.write_via_wal(&op)
}
/// 一条请求翻成一条响应
fn handle_request(&self, req: Request) -> Response {
match req {
@@ -430,8 +466,6 @@ impl Server {
}
};
if !indexed {
// 没有这个索引:告诉查询层"这条我帮不上忙",让它退回全表扫描。
// 回 NoIndex 而不是空结果,是因为"没有索引"和"没查到"必须分开。
return Response::NoIndex;
}
match self.with_table(&table, |t| t.get_by_column_key(&column, key)) {
@@ -599,10 +633,22 @@ impl Server {
}
}
}
Request::DropColumn { table, column } => {
log_debug!(pipe, "drop_column table={} column={}", table, column);
match self.drop_column(&table, &column) {
Ok(()) => Response::Ok,
Err(e) => {
log_warn!(pipe, "drop_column {}({}): {}", table, column, e);
Response::Error {
message: format!("drop_column {}({}): {}", table, column, e),
}
}
}
}
}
}
// 信息查询
/// 表名:catalog 与目录取并集
fn list_tables(&self) -> std::io::Result<Vec<String>> {
let mut names = BTreeSet::new();
@@ -625,7 +671,7 @@ impl Server {
Ok(names.into_iter().collect())
}
/// 查一张表的 schema(列 + 行数 + 索引 + 统计)
/// 查一张表的 schema
fn describe_table(
&self,
table: &str,
@@ -650,8 +696,7 @@ impl Server {
}
}
/// 一张表的索引定义 -> 线上格式。
/// `id` 是内建索引(表一开出来就有),数据字典里不记它,但对外要报出来。
/// 一张表的索引定义转线上格式
fn index_wires(schema: &chusql_storage::catalog::TableSchema) -> Vec<IndexWire> {
let mut out = vec![IndexWire {
column: "id".to_string(),
@@ -687,7 +732,6 @@ impl Drop for Server {
}
}
// 连接
/// 处理一条连接
fn handle(server: &Server, conn: LocalSocketStream) -> std::io::Result<()> {
let mut writer = conn.try_clone()?;
@@ -716,7 +760,6 @@ fn handle(server: &Server, conn: LocalSocketStream) -> std::io::Result<()> {
Ok(())
}
// 入口
/// 把配置来源打进日志
fn log_config(loaded: &Loaded) {
match &loaded.config_path {
@@ -751,7 +794,6 @@ fn main() -> std::io::Result<()> {
}
if let Err(e) = server.rebuild_stats() {
// 统计重建失败不影响服务:报一声,继续跑(统计会停留在落盘时的值)
log_warn!(core, "stats rebuild skipped: {}", e);
}
+1 -19
View File
@@ -5,26 +5,19 @@ use crate::page::{Page, PageFile, PageId};
// 磁盘 B+ 树:节点占一页,文件头记页大小与 order。
// 布局常量
/// 文件头放在第 0 页
const META_PAGE: PageId = 0;
/// 魔数,用来认出索引文件
const META_MAGIC: &[u8; 4] = b"CBTR";
/// 文件头各字段的偏移
const OFF_META_PAGE_SIZE: usize = 4;
const OFF_META_ORDER: usize = 8;
const OFF_META_ROOT: usize = 10;
/// 节点类型标记
const NODE_LEAF: u8 = 0;
const NODE_INTERNAL: u8 = 1;
/// 节点内布局的偏移
const OFF_TYPE: usize = 0;
const OFF_COUNT: usize = 1;
const OFF_NEXT: usize = 3;
const OFF_KEYS: usize = 12;
// 容量计算
/// 一个节点占多少字节
fn node_bytes(order: usize) -> usize {
OFF_KEYS + max_keys(order) * 8 + order * 8
@@ -50,7 +43,6 @@ pub fn max_order(page_size: usize) -> usize {
page_size.saturating_sub(4) / 16
}
// 小端读写
/// 读一个 u8
fn read_u8(p: &Page, off: usize) -> u8 {
p.data[off]
@@ -93,7 +85,6 @@ fn write_u64(p: &mut Page, off: usize, v: u64) {
p.data[off..off + 8].copy_from_slice(&v.to_le_bytes());
}
// 文件头
/// 写文件头
fn write_meta(page: &mut Page, page_size: usize, order: usize, root: PageId) {
page.zero();
@@ -125,8 +116,6 @@ fn check_meta(page: &Page, page_size: usize, order: usize) -> io::Result<()> {
Ok(())
}
// 节点编解码
/// 节点在内存里的样子
enum NodeData {
Leaf {
keys: Vec<i64>,
@@ -210,8 +199,6 @@ fn child_index(keys: &[i64], key: i64) -> usize {
lo
}
// B+ 树
/// 磁盘 B+ 树
pub struct DiskBTree {
file: PageFile,
order: usize,
@@ -430,12 +417,7 @@ impl DiskBTree {
}
}
/// 删掉一个键;键不存在返回 false。
///
/// 这是**惰性删除**:只把叶子里那一对 (key, value) 拿掉,不做合并、也不向兄弟借位。
/// 于是树可能变稀、甚至留下空叶子,但查找依然正确——内部节点的分隔键仍然满足
/// "小于等于它的往左、大于它的往右",空叶子二分找不到东西自然返回 None。
/// 代价是空间不回收(节点删空了也不还页),整表重写(`clear`)时会一起重建。
/// 删掉一个键,键不存在返回 false
pub fn delete(&mut self, key: i64) -> io::Result<bool> {
match self.root()? {
None => Ok(false),
+23 -26
View File
@@ -1,4 +1,3 @@
use std::collections::hash_map::DefaultHasher;
use std::collections::{BTreeMap, HashSet};
use std::hash::{Hash, Hasher};
@@ -10,32 +9,22 @@ use serde::{Deserialize, Serialize};
use crate::protocol::{ColumnType, Row, SchemaColumn};
// 数据字典:记每张表有哪些列、什么类型、多少行、有哪些索引,以及列级统计。
//
// 统计只在内存里精确维护(每列见过哪些值的哈希,有上限),随 catalog.json 一起落盘;
// 服务启动时会扫一遍表把统计重建出来,所以重启之后数字仍然是对的。
/// 一列的不同值个数最多精确统计到这里;超过就只报"下界"
const STATS_DISTINCT_CAP: u64 = 4096;
// 表结构
#[derive(Debug, Clone, Serialize, Deserialize)]
/// 一条索引定义:只记"盯的是哪一列"(索引按列命名,一列最多一个)
pub struct IndexSchema {
pub column: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
/// 一列的统计
pub struct ColumnStat {
/// 不同值个数(`capped` 为真时是下界)
pub distinct: u64,
/// 不同值太多,已经不再精确统计
#[serde(default)]
pub capped: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
/// 一张表的 schema + 统计
pub struct TableSchema {
pub columns: Vec<SchemaColumn>,
#[serde(default)]
@@ -47,16 +36,13 @@ pub struct TableSchema {
}
#[derive(Debug, Default, Serialize, Deserialize)]
/// 数据字典:表名到 schema
pub struct Catalog {
tables: BTreeMap<String, TableSchema>,
/// 每列"见过的值"的哈希集合(只在内存里,不落盘;有上限)
#[serde(skip)]
seen: BTreeMap<String, HashSet<u64>>,
}
impl Catalog {
// 读取与保存
/// 从文件读;不存在给空字典
pub fn load<P: AsRef<Path>>(path: P) -> io::Result<Self> {
if !path.as_ref().exists() {
@@ -80,7 +66,6 @@ impl Catalog {
std::fs::write(path, bytes)
}
// 查询
/// 一张表的 schema
pub fn describe(&self, table: &str) -> Option<&TableSchema> {
self.tables.get(table)
@@ -91,7 +76,6 @@ impl Catalog {
self.tables.keys().cloned().collect()
}
// 更新
/// 按一行数据补列
pub fn ensure_columns(&mut self, table: &str, row: &Row) {
let entry = self.tables.entry(table.to_string()).or_default();
@@ -118,7 +102,7 @@ impl Catalog {
Ok(())
}
/// 记一次插入:补列 + 行数 +1 + 更新列统计;返回"列有没有变化"(决定要不要立刻写盘)
/// 记一次插入,返回列有没有变化
pub fn record_insert(&mut self, table: &str, row: &Row) -> bool {
let changed = {
let entry = self.tables.entry(table.to_string()).or_default();
@@ -141,7 +125,6 @@ impl Catalog {
if stat.capped {
continue;
}
// 这个值彻底没了才减:集合里删得掉说明没有别的行还用它
if seen.remove(&fold(name, value_hash(v))) {
stat.distinct = stat.distinct.saturating_sub(1);
}
@@ -149,7 +132,7 @@ impl Catalog {
}
}
/// 重数一遍统计与行数(启动时重建、整表改写完都走它);返回"列有没有变化"
/// 重数一遍统计与行数
pub fn rebuild_stats(&mut self, table: &str, rows: &[Row]) -> bool {
self.seen.remove(table);
{
@@ -157,12 +140,11 @@ impl Catalog {
entry.stats.clear();
entry.row_count = 0;
}
// 逐行"插入"地数:行数、列、统计一次到位
rows.iter()
.fold(false, |changed, r| self.record_insert(table, r) || changed)
}
/// 列统计:这一列第一次见到这个值就 distinct +1(到上限之后只报下界)
/// 这一列第一次见到这个值就加一
fn count_values(&mut self, table: &str, row: &Row) {
let entry = self.tables.entry(table.to_string()).or_default();
let seen = self.seen.entry(table.to_string()).or_default();
@@ -175,7 +157,7 @@ impl Catalog {
stat.distinct += 1;
if stat.distinct >= STATS_DISTINCT_CAP {
stat.capped = true;
seen.clear(); // 到上限就不再精确统计,把内存还回去
seen.clear();
}
}
}
@@ -192,7 +174,23 @@ impl Catalog {
Ok(())
}
// 索引
/// 删一列;之前没有也返回成功(重放幂等)
pub fn remove_column(&mut self, table: &str, column: &str) -> io::Result<bool> {
let entry = self
.tables
.get_mut(table)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("unknown table: {}", table)))?;
let had_column = {
let before = entry.columns.len();
entry.columns.retain(|c| c.name != column);
entry.columns.len() != before
};
entry.stats.remove(column);
entry.indexes.retain(|i| i.column != column);
self.seen.remove(table);
Ok(had_column)
}
/// 记下一条索引定义
pub fn add_index(&mut self, table: &str, column: &str) -> io::Result<()> {
let entry = self
@@ -230,7 +228,6 @@ impl Catalog {
}
}
// 推断
/// 把行里"字典还不知道的列"补进来;返回有没有变化
fn add_missing_columns(entry: &mut TableSchema, row: &Row) -> bool {
let mut changed = false;
@@ -259,14 +256,14 @@ fn infer_type(v: &serde_json::Value) -> Option<ColumnType> {
}
}
/// 一个值的哈希(按它的 JSON 文本算;统计"不同值个数"用)
/// 一个值的哈希,统计不同值个数用
fn value_hash(v: &serde_json::Value) -> u64 {
let mut h = DefaultHasher::new();
v.to_string().hash(&mut h);
h.finish()
}
/// 把"列名"和"值哈希"合成一个哈希:避免两列的同值互相干扰
/// 列名与值哈希合成一个哈希
fn fold(column: &str, value_hash: u64) -> u64 {
let mut h = DefaultHasher::new();
column.hash(&mut h);
+2 -27
View File
@@ -6,32 +6,20 @@ use crate::log::Level;
// 配置:环境变量优先于 TOML,最后回退内置默认值。
// 默认值
/// 默认配置文件路径
pub const DEFAULT_CONFIG_PATH: &str = "chusql-storage.toml";
/// 默认池容量(页数)
pub const DEFAULT_POOL_SIZE: usize = 1024;
/// 默认页大小
pub const DEFAULT_PAGE_SIZE: usize = 4096;
/// 默认 B+ 树 order
pub const DEFAULT_BTREE_ORDER: usize = 4;
/// 默认数据目录
pub const DEFAULT_DATA_DIR: &str = "data";
/// 默认管道名
pub const DEFAULT_DATA_DIR: &str = "../localdata";
pub const DEFAULT_PIPE_NAME: &str = "chusql-storage";
/// 默认日志等级
pub const DEFAULT_LOG_LEVEL: &str = "info";
/// 页大小允许范围
const MIN_PAGE_SIZE: usize = 512;
const MAX_PAGE_SIZE: usize = 65536;
/// order 最小值
const MIN_BTREE_ORDER: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
// 配置
/// 生效的配置
pub struct Config {
pub page_size: usize,
pub btree_order: usize,
@@ -41,7 +29,6 @@ pub struct Config {
pub log_level: Level,
}
/// 全默认值
impl Default for Config {
fn default() -> Self {
Config {
@@ -56,7 +43,6 @@ impl Default for Config {
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// 一个选项的来源
pub enum Origin {
Default,
File(PathBuf),
@@ -75,7 +61,6 @@ impl Origin {
}
#[derive(Debug, Clone)]
/// 配置 + 每项来源
pub struct Loaded {
pub config: Config,
pub config_path: Option<PathBuf>,
@@ -84,8 +69,6 @@ pub struct Loaded {
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
// 文件形状
/// TOML 的形状(字段都可选)
struct FileConfig {
page: Option<FilePage>,
btree: Option<FileBtree>,
@@ -97,47 +80,40 @@ struct FileConfig {
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
/// [buffer] 段
struct FileBuffer {
pool_size: Option<usize>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
/// [page] 段
struct FilePage {
size: Option<usize>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
/// [btree] 段
struct FileBtree {
order: Option<usize>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
/// [storage] 段
struct FileStorage {
data_dir: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
/// [server] 段
struct FileServer {
pipe_name: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
/// [log] 段
struct FileLog {
level: Option<String>,
}
// 加载
/// 按真实环境加载
pub fn load() -> Result<Loaded, String> {
let path = match std::env::var("CHUSQL_CONFIG") {
@@ -252,7 +228,7 @@ pub fn resolve(
let origins = vec![
("page.size", page_size.to_string(), page_origin),
("btree.order", btree_order.to_string(), order_origin),
("buffer.pool_size", pool_size.to_string(), pool_origin), // 新增
("buffer.pool_size", pool_size.to_string(), pool_origin),
("storage.data_dir", data_dir.clone(), dir_origin),
("server.pipe_name", pipe_name.clone(), pipe_origin),
("log.level", log_level.name().to_string(), log_origin),
@@ -291,7 +267,6 @@ fn pick<T: Clone>(
Ok((default, Origin::Default))
}
// 校验
/// 检查选项是否自洽
fn validate_layout(page_size: usize, btree_order: usize) -> Result<(), String> {
if !(MIN_PAGE_SIZE..=MAX_PAGE_SIZE).contains(&page_size) || !page_size.is_power_of_two() {
+13 -39
View File
@@ -8,16 +8,9 @@ use crate::page::{Page, PageFile};
use crate::protocol::Row;
// 堆表:行按插入顺序进页,支持全表扫描、按索引取行、行级删除。
//
// 索引由**表自己维护**:有哪些索引记在数据字典里,插入时按索引的列从行里取值写进去,
// 调用方不需要知道有哪些索引,也不需要自己指定 key。
// 页内布局
/// 页头:slot_count 占 2 字节
const HEADER_SIZE: usize = 2;
/// 一个槽位占 8 字节
const SLOT_SIZE: usize = 8;
/// 槽位目录起点
const SLOT_DIR_START: usize = HEADER_SIZE;
/// 读一个小端 u16
@@ -40,7 +33,6 @@ fn write_u32(buf: &mut [u8], off: usize, v: u32) {
buf[off..off + 4].copy_from_slice(&v.to_le_bytes());
}
// 索引位置
/// (页号, 槽位号) 打包成 u64
fn pack_position(page_id: PageId, slot: u16) -> u64 {
(page_id << 16) | (slot as u64)
@@ -59,8 +51,6 @@ fn row_int(row: &Row, column: &str) -> Option<i64> {
}
}
// 页内操作
/// 只管一页里的字节
pub struct HeapPage;
impl HeapPage {
@@ -115,7 +105,7 @@ impl HeapPage {
Some(n)
}
/// 删掉一个槽位:只把长度置 0,行字节原地留着(读的时候按"长度 0 = 没有这一行"处理)
/// 删掉一个槽位,只把长度置 0
pub fn delete_tuple(page: &mut Page, slot: u16) -> bool {
let n = Self::slot_count(page);
if slot >= n {
@@ -150,15 +140,11 @@ impl HeapPage {
}
}
// 索引
/// 表上的一个索引:盯着一列,键是这一列的整数值
struct NamedIndex {
column: String,
tree: DiskBTree,
}
// 堆表
/// 一个数据文件 = 若干页;外加若干索引文件
pub struct HeapTable {
file: PageFile,
indexes: Vec<NamedIndex>,
@@ -173,7 +159,7 @@ impl HeapTable {
})
}
/// 打开表 + `id` 列索引(`id` 沿用老文件名 `表名.idx`)
/// 打开表并挂上 id 列索引
pub fn open_indexed<P: AsRef<Path>, Q: AsRef<Path>>(
path: P,
index_path: Q,
@@ -189,7 +175,7 @@ impl HeapTable {
Ok(t)
}
/// 打开表 + 任意组索引:(列名, 索引文件路径)
/// 打开表并挂上多组索引
pub fn open_with_indexes<P: AsRef<Path>>(
path: P,
indexes: &[(String, std::path::PathBuf)],
@@ -207,7 +193,7 @@ impl HeapTable {
Ok(t)
}
/// 挂上一棵索引树(只打开/创建文件,不填数据)
/// 挂上一棵索引树,不填数据
fn attach_index<P: AsRef<Path>>(
&mut self,
column: &str,
@@ -229,11 +215,7 @@ impl HeapTable {
self.indexes.iter().any(|i| i.column == column)
}
/// 建索引:先扫一遍查重,再建树填数据。
///
/// **要求这一列的整数取值唯一**,不唯一就报错、一个索引文件都不留。
/// 这样"走索引取一行"和"全表扫一遍再筛"必然得到同一个答案;
/// 非唯一索引要等 B+ 树支持重复键(记在项目计划的 P2-② 里)。
/// 建索引:要求这一列取值唯一
pub fn build_index(
&mut self,
column: &str,
@@ -249,18 +231,17 @@ impl HeapTable {
));
}
// 第 1 步:扫全表,查重并攒下 (键, 位置)
let mut seen: HashSet<i64> = HashSet::new();
let mut entries: Vec<(i64, u64)> = Vec::new();
for (page_id, slot, r) in self.scan_with_positions()? {
let Some(k) = row_int(&r, column) else {
continue; // 这一列不是整数的行不进索引
continue;
};
if !seen.insert(k) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"column \"{}\" has duplicate value {} — 暂不支持非唯一索引",
"column \"{}\" has duplicate value {} -- non-unique indexes are not supported yet",
column, k
),
));
@@ -268,10 +249,9 @@ impl HeapTable {
entries.push((k, pack_position(page_id, slot)));
}
// 第 2 步:建树
self.attach_index(column, path, page_size, btree_order, pool_size)?;
let i = self.indexes.len() - 1;
self.indexes[i].tree.clear()?; // 清掉可能残留的旧文件内容
self.indexes[i].tree.clear()?;
for (k, pos) in entries {
self.indexes[i].tree.insert(k, pos)?;
}
@@ -289,19 +269,17 @@ impl HeapTable {
}
}
/// 插入一行:进堆表,并按每个索引的列顺手写索引
/// 插入一行,并顺手写各索引
pub fn insert_row(&mut self, row: &Row) -> io::Result<()> {
self.insert_row_returning_position(row).map(|_| ())
}
/// 插入一行并返回它的位置
pub fn insert_row_returning_position(&mut self, row: &Row) -> io::Result<(PageId, u16)> {
// 先把键算好并查重:**查重必须在落堆之前**,
// 否则某个索引写不进去时行已经进堆了,表和索引就不一致了。
let mut pending: Vec<(usize, i64)> = Vec::new();
for (i, idx) in self.indexes.iter_mut().enumerate() {
let Some(k) = row_int(row, &idx.column) else {
continue; // 这一列不是整数(或没这一列):这行不进这个索引
continue;
};
if idx.tree.get(k)?.is_some() {
return Err(io::Error::new(
@@ -320,7 +298,7 @@ impl HeapTable {
Ok((page_id, slot))
}
/// 批量插入(只写缓冲池,落盘由调用方 `flush` 一次完成)
/// 批量插入,落盘由调用方负责
pub fn insert_rows(&mut self, rows: &[Row]) -> io::Result<()> {
for r in rows {
self.insert_row(r)?;
@@ -387,9 +365,8 @@ impl HeapTable {
}
}
/// 按位置删一行:槽位长度置 0,从所有索引里摘掉,并把这一行交出来
/// 按位置删一行并返回它
pub fn delete_at(&mut self, page_id: PageId, slot: u16) -> io::Result<Option<Row>> {
// 先读出来:删完就不知道这行各列是什么,索引也就无从摘起
let row = self.read_at(page_id, slot)?;
let existed = self.file.update_page(page_id, |p| HeapPage::delete_tuple(p, slot))?;
if !existed {
@@ -405,9 +382,7 @@ impl HeapTable {
Ok(row)
}
/// 按 `id` 批量删行,返回被删掉的那些行(数据字典要用它更新统计)。
///
/// 定位靠 `id` 索引;没有这个索引就退化成一趟全表扫描(仍然只删目标行)。
/// 按 id 批量删行,返回删掉的行
pub fn delete_by_keys(&mut self, keys: &[i64]) -> io::Result<Vec<Row>> {
let mut positions: Vec<(PageId, u16)> = Vec::new();
@@ -500,7 +475,6 @@ impl HeapTable {
self.file.flush()
}
// 缓冲池统计
/// 当前缓存页数
pub fn cached_pages(&self) -> usize { self.file.cached_pages() }
/// 命中次数
-19
View File
@@ -5,19 +5,12 @@ use std::sync::OnceLock;
// 极简日志:等级用原子变量,关掉的等级只花一次原子读。
// 颜色
/// 转义序列收尾
const RESET: &str = "\u{1b}[0m";
/// 加粗红(ERROR)
const BOLD_RED: &str = "\u{1b}[1m\u{1b}[31m";
/// 加粗橙(WARN)
const BOLD_ORANGE: &str = "\u{1b}[1m\u{1b}[38;5;208m";
/// 只加粗(DEBUG)
const BOLD: &str = "\u{1b}[1m";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
// 等级与来源
/// 日志等级
pub enum Level {
Off,
Error,
@@ -27,7 +20,6 @@ pub enum Level {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// 来源:服务自身或管道请求
pub enum Channel {
Core,
Pipe,
@@ -92,7 +84,6 @@ static LEVEL: AtomicU8 = AtomicU8::new(Level::Info as u8);
static COLOR: OnceLock<bool> = OnceLock::new();
// 写入
/// 设置全局等级
pub fn set_level(level: Level) {
LEVEL.store(level as u8, Ordering::Relaxed);
@@ -140,8 +131,6 @@ fn render(level: Level, channel: Channel, color: bool, stamp: &Stamp, msg: &str)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
// 时间
/// 本地日期时间
pub struct Stamp {
pub year: u16,
pub month: u16,
@@ -152,7 +141,6 @@ pub struct Stamp {
pub milli: u16,
}
/// 格式:年-月-日 时:分:秒.毫秒
impl fmt::Display for Stamp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
@@ -238,7 +226,6 @@ pub fn civil_from_days(days: i64) -> (i64, u16, u16) {
(y + i64::from(month <= 2), month, day)
}
// 终端
/// 打开 Windows 的 VT 处理
#[cfg(windows)]
fn enable_vt() {
@@ -265,8 +252,6 @@ fn enable_vt() {
#[cfg(not(windows))]
fn enable_vt() {}
// 宏
/// debug 日志
#[macro_export]
macro_rules! log_debug {
(core, $($arg:tt)*) => {
@@ -277,7 +262,6 @@ macro_rules! log_debug {
};
}
/// info 日志
#[macro_export]
macro_rules! log_info {
(core, $($arg:tt)*) => {
@@ -288,7 +272,6 @@ macro_rules! log_info {
};
}
/// warn 日志
#[macro_export]
macro_rules! log_warn {
(core, $($arg:tt)*) => {
@@ -299,7 +282,6 @@ macro_rules! log_warn {
};
}
/// error 日志
#[macro_export]
macro_rules! log_error {
(core, $($arg:tt)*) => {
@@ -310,7 +292,6 @@ macro_rules! log_error {
};
}
/// 四个宏的公共部分
#[doc(hidden)]
#[macro_export]
macro_rules! log_line {
+1 -8
View File
@@ -5,15 +5,11 @@ use std::path::Path;
// 页管理:定长页的读写,外加一层页缓冲池。
// 页
/// 页号:从 0 开始
pub type PageId = u64;
/// 缓冲池默认容量(页数)
pub const DEFAULT_POOL_SIZE: usize = 64;
#[derive(Debug, Clone)]
/// 一页:定长字节 + 页号
pub struct Page {
pub id: PageId,
pub data: Vec<u8>,
@@ -35,7 +31,6 @@ impl Page {
}
#[derive(Debug)]
/// 页文件:定长页读写 + 页缓存
pub struct PageFile {
file: File,
page_size: usize,
@@ -84,7 +79,6 @@ impl PageFile {
self.page_size
}
// 缓冲池
/// 池容量(页数)
pub fn capacity(&self) -> usize {
self.capacity
@@ -188,7 +182,7 @@ impl PageFile {
Ok(r)
}
/// 记一次访问:命中加 hits,未命中读进来并记 misses
/// 记一次访问:命中 / 未命中都记数
fn note_access(&mut self, id: PageId) -> io::Result<()> {
if self.cache.contains_key(&id) {
self.hits += 1;
@@ -287,7 +281,6 @@ impl PageFile {
}
}
/// 退出前把脏页写回
impl Drop for PageFile {
fn drop(&mut self) {
let _ = self.flush();
+4 -15
View File
@@ -3,12 +3,10 @@ use std::collections::HashMap;
// 协议:Haskell 与 Rust 之间的请求 / 响应类型。
/// 一行:列名到 JSON 值
pub type Row = HashMap<String, serde_json::Value>;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
/// 列类型(对应 Haskell 的 TInt / TStr / TBool)
pub enum ColumnType {
Int,
Str,
@@ -16,52 +14,43 @@ pub enum ColumnType {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
/// 一列的 schema
pub struct SchemaColumn {
pub name: String,
pub ty: ColumnType,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
/// 一列的统计(随数据字典一起走)
pub struct ColumnStatWire {
pub name: String,
/// 不同值个数;`capped` 为真时它只是下界
pub distinct: u64,
#[serde(default)]
pub capped: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
/// 一条索引定义
pub struct IndexWire {
pub column: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "method", rename_all = "snake_case")]
/// 请求:method 字段决定类型
pub enum Request {
Ping,
Scan {
table: String,
},
/// 插一行;要不要写索引由表上的索引决定(调用方不用管)
Insert {
table: String,
row: Row,
},
/// 一批行一起插:一次 WAL、一次 fsync
InsertBatch {
table: String,
rows: Vec<Row>,
},
/// 按 id 批量删行
DeleteKeys {
table: String,
keys: Vec<i64>,
},
/// 按某一列的索引取一行(列名默认 "id",兼容老客户端)
LookupByIndex {
table: String,
#[serde(default = "default_id_column")]
@@ -84,16 +73,18 @@ pub enum Request {
table: String,
},
ListCatalog,
/// 给某一列建索引(要求这一列整数取值唯一)
CreateIndex {
table: String,
column: String,
},
/// 去掉某一列的索引
DropIndex {
table: String,
column: String,
},
DropColumn {
table: String,
column: String,
},
}
/// 没写 column 时按 `id` 算
@@ -103,7 +94,6 @@ fn default_id_column() -> String {
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
/// 响应:status 字段决定类型
pub enum Response {
Pong,
Rows { rows: Vec<Row> },
@@ -118,7 +108,6 @@ pub enum Response {
#[serde(default)]
stats: Vec<ColumnStatWire>,
},
/// 这一列没有索引 —— 调用方应该退回全表扫描
NoIndex,
Ok,
Error { message: String },
+31 -14
View File
@@ -1,4 +1,3 @@
use std::fs::{File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
@@ -14,20 +13,18 @@ const OP_INSERT: u8 = 1;
const OP_REPLACE_ALL: u8 = 2;
const OP_INSERT_BATCH: u8 = 3;
const OP_DELETE_KEYS: u8 = 4;
const OP_DROP_COLUMN: u8 = 5;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
/// 一条待写入的操作
pub enum WalOp {
Insert {
table: String,
row: Row,
},
/// 一批行:一次日志、一次 fsync 就能全部落盘
InsertBatch {
table: String,
rows: Vec<Row>,
},
/// 按 id 批量删行(重放是幂等的:再删一次等于什么都没删)
DeleteKeys {
table: String,
keys: Vec<i64>,
@@ -36,20 +33,25 @@ pub enum WalOp {
table: String,
rows: Vec<Row>,
},
DropColumn {
table: String,
column: String,
rows: Vec<Row>,
},
}
#[derive(Debug, Serialize, Deserialize)]
struct DropColumnPayload {
column: String,
rows: Vec<Row>,
}
/// WAL 文件
///
/// 句柄只开一次并一直留着:写入路径是每条操作一次
/// `truncate -> append -> truncate`,每次都重新 open/close 文件要多花
/// 一倍的时间(见 benchmark/chusql-storage)。
pub struct Wal {
path: PathBuf,
handle: Mutex<Option<File>>,
}
impl Wal {
// 读写
/// 绑定 WAL 文件路径
pub fn new<P: AsRef<Path>>(path: P) -> Self {
Wal {
@@ -87,8 +89,7 @@ impl Wal {
})
}
/// 只截断、不落盘:紧跟其后的 `append` 会 fsync,
/// 那一次会把截断一起落下去,所以这里不必单独再刷一遍。
/// 只截断,落盘交给紧随的 append
pub fn truncate(&self) -> io::Result<()> {
self.with_handle(truncate)
}
@@ -123,14 +124,13 @@ impl Wal {
}
}
/// 清空文件并回到起点(句柄不是 append 模式,位置要自己摆正)
/// 清空文件并把读写位置归零
fn truncate(f: &mut File) -> io::Result<()> {
f.set_len(0)?;
f.seek(SeekFrom::Start(0))?;
Ok(())
}
// 编解码
/// 把操作编成字节
fn encode(op: &WalOp) -> io::Result<Vec<u8>> {
let (op_type, table, payload) = match op {
@@ -150,6 +150,14 @@ fn encode(op: &WalOp) -> io::Result<Vec<u8>> {
let p = serde_json::to_vec(rows).map_err(io::Error::other)?;
(OP_REPLACE_ALL, table.clone(), p)
}
WalOp::DropColumn { table, column, rows } => {
let p = serde_json::to_vec(&DropColumnPayload {
column: column.clone(),
rows: rows.clone(),
})
.map_err(io::Error::other)?;
(OP_DROP_COLUMN, table.clone(), p)
}
};
let table_bytes = table.as_bytes();
@@ -214,6 +222,15 @@ fn decode(buf: &[u8]) -> Result<Option<WalOp>, String> {
.map_err(|e| format!("bad replace_all payload: {}", e))?;
WalOp::ReplaceAll { table, rows }
}
OP_DROP_COLUMN => {
let p: DropColumnPayload = serde_json::from_slice(payload)
.map_err(|e| format!("bad drop_column payload: {}", e))?;
WalOp::DropColumn {
table,
column: p.column,
rows: p.rows,
}
}
other => return Err(format!("unknown op type: {}", other)),
};
Ok(Some(op))
+2 -6
View File
@@ -169,21 +169,19 @@ fn delete_removes_only_that_key() {
t.insert(i, i as u64 + 1).unwrap();
}
assert!(t.delete(50).unwrap(), "删一个存在的键应该返回 true");
assert!(t.delete(50).unwrap(), "deleting an existing key should return true");
assert_eq!(t.get(50).unwrap(), None);
assert_eq!(t.get(49).unwrap(), Some(50));
assert_eq!(t.get(51).unwrap(), Some(52));
// 再删一次:它已经不在了
assert!(!t.delete(50).unwrap());
// 遍历里也不能再有它
let all = t.iter_all().unwrap();
assert_eq!(all.len(), 199);
assert!(all.iter().all(|(k, _)| *k != 50));
}
/// 删过之后树还能继续插(惰性删除会留下稀疏节点,但不能影响正确性)
/// 删过之后树还能继续插
#[test]
fn insert_after_delete_still_works() {
let dir = tempfile::tempdir().unwrap();
@@ -197,11 +195,9 @@ fn insert_after_delete_still_works() {
for i in 0..300 {
t.insert(i, i as u64).unwrap();
}
// 把偶数键全删掉
for i in (0..300).step_by(2) {
assert!(t.delete(i).unwrap());
}
// 再插一批新的
for i in 1000..1100 {
t.insert(i, i as u64).unwrap();
}
+25
View File
@@ -65,3 +65,28 @@ fn describe_unknown_returns_none() {
let c = Catalog::load(&path).unwrap();
assert!(c.describe("nope").is_none());
}
/// 删列同时清掉统计与索引
#[test]
fn remove_column_drops_definition_stats_and_index() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("catalog.json");
let mut c = Catalog::load(&path).unwrap();
c.ensure_columns("t", &row(&[("id", json!(1)), ("code", json!(5))]));
c.add_index("t", "code").unwrap();
c.rebuild_stats("t", &[row(&[("id", json!(1)), ("code", json!(5))])]);
let removed = c.remove_column("t", "code").unwrap();
assert!(removed, "first remove should hit the column");
let s = c.describe("t").unwrap();
assert_eq!(s.columns.len(), 1);
assert_eq!(s.columns[0].name, "id");
assert!(s.indexes.is_empty(), "index on code should be gone");
assert!(s.stats.get("code").is_none(), "stats for code should be gone");
let again = c.remove_column("t", "code").unwrap();
assert!(!again);
assert!(c.remove_column("nope", "code").is_err());
}
+8 -13
View File
@@ -135,9 +135,9 @@ fn replace_all_rebuilds_index() {
])
.unwrap();
assert!(t.get_by_key(1).unwrap().is_none(), "旧键必须作废");
assert!(t.get_by_key(1).unwrap().is_none(), "old key must be invalidated");
assert!(t.get_by_key(2).unwrap().is_none());
let got = t.get_by_key(7).unwrap().expect("新行应该按 id 建好索引");
let got = t.get_by_key(7).unwrap().expect("new row should be indexed by id");
assert_eq!(got["name"], json!("Zoe"));
assert_eq!(t.scan().unwrap().len(), 2);
}
@@ -161,7 +161,7 @@ fn delete_by_keys_removes_rows() {
}
let deleted = t.delete_by_keys(&[2, 4, 99]).unwrap();
assert_eq!(deleted.len(), 2, "只有 2、4 真的删掉了");
assert_eq!(deleted.len(), 2, "only 2 and 4 were actually deleted");
let ids: Vec<i64> = t
.scan()
@@ -169,13 +169,12 @@ fn delete_by_keys_removes_rows() {
.iter()
.map(|r| r["id"].as_i64().unwrap())
.collect();
assert_eq!(ids, vec![1, 3, 5], "剩下的行要保持原顺序");
assert_eq!(ids, vec![1, 3, 5], "remaining rows must keep their order");
assert!(t.get_by_key(2).unwrap().is_none(), "索引条目要跟着删");
assert!(t.get_by_key(2).unwrap().is_none(), "index entry should be deleted too");
assert!(t.get_by_key(4).unwrap().is_none());
assert!(t.get_by_key(3).unwrap().is_some());
// 删掉的位置可以再插回来
t.insert_row(&row(&[("id", json!(2)), ("name", json!("u2b"))]))
.unwrap();
assert_eq!(t.get_by_key(2).unwrap().unwrap()["name"], json!("u2b"));
@@ -202,7 +201,6 @@ fn insert_rows_batch() {
assert_eq!(t.scan().unwrap().len(), 20);
assert_eq!(t.get_by_key(20).unwrap().unwrap()["name"], json!("u20"));
// 批里出现重复键要报错(整批之前就查出来,不会插一半)
let dup = vec![
row(&[("id", json!(100))]),
row(&[("id", json!(100))]),
@@ -241,16 +239,13 @@ fn secondary_index_is_maintained() {
assert!(t.has_index("code"));
assert_eq!(t.get_by_column_key("code", 7002).unwrap().unwrap()["id"], json!(2));
// 建索引之后新插入的行也进索引
t.insert_row(&row(&[("id", json!(3)), ("code", json!(7003))]))
.unwrap();
assert_eq!(t.get_by_column_key("code", 7003).unwrap().unwrap()["id"], json!(3));
// 删掉一行,索引条目跟着走
t.delete_by_keys(&[2]).unwrap();
assert!(t.get_by_column_key("code", 7002).unwrap().is_none());
// code 已经有索引,重复的 code 插不进去(索引列是唯一的)
assert!(t.insert_row(&row(&[("id", json!(4)), ("code", json!(7001))])).is_err());
let mut t2 = HeapTable::open(&dir.path().join("d.db"), DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).unwrap();
t2.insert_rows(&[
@@ -265,8 +260,8 @@ fn secondary_index_is_maintained() {
chusql_storage::config::DEFAULT_BTREE_ORDER,
DEFAULT_POOL_SIZE,
);
assert!(err.is_err(), "重复值不给建索引");
assert!(!t2.has_index("age"), "失败之后不留半成品");
assert!(err.is_err(), "duplicate values must not be indexed");
assert!(!t2.has_index("age"), "no half-built index after failure");
}
/// 同一个 id 插两次要报错
@@ -285,6 +280,6 @@ fn duplicate_key_is_rejected() {
t.insert_row(&row(&[("id", json!(1))])).unwrap();
let r = t.insert_row(&row(&[("id", json!(1))]));
assert!(r.is_err());
assert_eq!(t.scan().unwrap().len(), 1, "失败的那一行不应该进堆表");
assert_eq!(t.scan().unwrap().len(), 1, "the failed row should not reach the heap");
}
+52 -15
View File
@@ -21,7 +21,6 @@ fn unique_pipe_name() -> String {
format!("chusql-test-{}-{}", id, ns)
}
/// 持有 server 子进程,退出时杀掉
struct ServerProc(Child);
impl Drop for ServerProc {
@@ -36,7 +35,7 @@ impl Drop for ServerProc {
fn start_server(pipe: &str) -> (ServerProc, tempfile::TempDir) {
let data = tempfile::tempdir().unwrap();
let child = Command::new(env!("CARGO_BIN_EXE_server"))
let child = Command::new(env!("CARGO_BIN_EXE_chusql-storage"))
.env("CHUSQL_PIPE", pipe)
.env("CHUSQL_DATA_DIR", data.path())
.env("CHUSQL_PAGE_SIZE", "4096")
@@ -251,7 +250,7 @@ fn delete_keys_removes_row_and_index_entry() {
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 应该能再插进来: {}", r);
assert!(r.contains(r#""status":"ok""#), "id 2 should be insertable again: {}", r);
}
/// 给第二列建索引后能按那一列查
@@ -269,7 +268,6 @@ fn create_index_on_secondary_column() {
send(&mut c, &req);
}
// 没建之前:明确回 no_index,而不是回空结果
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);
@@ -279,23 +277,64 @@ fn create_index_on_secondary_column() {
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);
// schema 里能看到这条索引
let r = send(&mut c, r#"{"method":"describe_table","table":"sec_t"}"#);
assert!(r.contains(r#""column":"code""#), "describe: {}", r);
// 删掉索引后回到 no_index
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 pipe = unique_pipe_name();
let (_srv, _data) = start_server(&pipe);
let mut c = connect(&pipe).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);
}
/// 重复值列不给建索引
#[test]
fn create_index_rejects_duplicate_values() {
let pipe = unique_pipe_name();
@@ -309,7 +348,6 @@ fn create_index_rejects_duplicate_values() {
assert!(r.contains(r#""status":"error""#), "got: {}", r);
assert!(r.contains("duplicate value"), "got: {}", r);
// 失败之后不留半成品:查这一列仍然是 no_index
let r = send(&mut c, r#"{"method":"lookup_by_index","table":"dup_t","column":"age","key":30}"#);
assert!(r.contains(r#""status":"no_index""#), "got: {}", r);
}
@@ -331,10 +369,9 @@ fn stats_report_distinct_values() {
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 有 2 个不同值: {}", r);
assert!(r.contains(r#""name":"id","distinct":3"#), "id 有 3 个不同值: {}", 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);
// 删掉一行,统计跟着掉
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);
@@ -348,7 +385,7 @@ fn lookup_survives_reopen() {
let data = tempfile::tempdir().unwrap();
{
let exe = env!("CARGO_BIN_EXE_server");
let exe = env!("CARGO_BIN_EXE_chusql-storage");
let child = Command::new(exe)
.env("CHUSQL_PIPE", &pipe)
.env("CHUSQL_DATA_DIR", data.path())
@@ -375,7 +412,7 @@ fn lookup_survives_reopen() {
thread::sleep(Duration::from_millis(200));
let (_srv, _data) = {
let child = Command::new(env!("CARGO_BIN_EXE_server"))
let child = Command::new(env!("CARGO_BIN_EXE_chusql-storage"))
.env("CHUSQL_PIPE", &pipe)
.env("CHUSQL_DATA_DIR", data.path())
.stdout(Stdio::null())
@@ -410,7 +447,7 @@ fn config_file_is_used() {
);
std::fs::write(&config_path, text).unwrap();
let child = Command::new(env!("CARGO_BIN_EXE_server"))
let child = Command::new(env!("CARGO_BIN_EXE_chusql-storage"))
.env("CHUSQL_CONFIG", &config_path)
.stdout(Stdio::null())
.stderr(Stdio::null())
+20
View File
@@ -48,6 +48,26 @@ fn append_and_read_replace_all() {
assert_eq!(back, op);
}
/// 写读 DropColumn 操作,行随日志落盘
#[test]
fn append_and_read_drop_column() {
let dir = tempfile::tempdir().unwrap();
let wal = Wal::new(dir.path().join("wal.log"));
let op = WalOp::DropColumn {
table: "users".into(),
column: "age".into(),
rows: vec![
row(&[("id", json!(1)), ("name", json!("Alice"))]),
row(&[("id", json!(2)), ("name", json!("Bob"))]),
],
};
wal.append(&op).unwrap();
let back = wal.read().unwrap().unwrap();
assert_eq!(back, op);
}
/// 文件不存在返回 None
#[test]
fn missing_file_returns_none() {