大更新:
1、完善SQL语法 2、移除node前端,只保留静态资源 3、写好安装脚本 4、做好linux适配
This commit is contained in:
1 parent
687c012841
commit
df6d348995
166 files changed
+16317
-10763
No files matched your search
@@ -1,40 +1,42 @@
|
||||
# ChuSQL 存储服务配置示例
|
||||
#
|
||||
# 用法:把本文件复制成 chusql-storage.toml(放在启动 server 时的当前目录下),
|
||||
# 或者让环境变量 CHUSQL_CONFIG 指向任意路径的这个文件。
|
||||
# 用法:安装时把本文件放到固定的用户配置目录(Windows 是 %APPDATA%\ChuSQL\chusql.toml,
|
||||
# 其它平台是 ${XDG_CONFIG_HOME:-$HOME/.config}/ChuSQL/chusql.toml);
|
||||
# 临时换一份就用命令行参数 --config <path>(没有环境变量通道了)。
|
||||
#
|
||||
# 每一项都可以删掉:没写的项回退到内置默认值(下面注释里的值就是默认值)。
|
||||
# 优先级:环境变量 > 本文件 > 内置默认值。
|
||||
# 优先级:本文件 > 内置默认值(没有配置键走环境变量了)。
|
||||
# 启动时 server 会把"读了哪个文件、每个选项从哪来"打进日志([INFO] 开头那几行)。
|
||||
|
||||
[page]
|
||||
# 页大小(字节):512 ~ 65536 之间的 2 的幂。
|
||||
# 注意:改它会让已有数据文件(.db)读不出来(文件长度对不上页大小会直接报错)。
|
||||
# 环境变量:CHUSQL_PAGE_SIZE
|
||||
size = 4096
|
||||
|
||||
[btree]
|
||||
# B+ 树一个节点最多几个孩子(>= 3,且必须能放进一页:12 + (order-1)*8 + order*8 <= size)。
|
||||
# 注意:改它会让已有索引文件(.idx)对不上文件头里记的值,打开时报错。
|
||||
# 环境变量:CHUSQL_BTREE_ORDER
|
||||
order = 4
|
||||
|
||||
[buffer]
|
||||
# 缓冲池里最多放几页(>= 1)。
|
||||
pool_size = 1024
|
||||
|
||||
[storage]
|
||||
# 数据目录:一张表一个 <表名>.db,一个索引一个 <表名>.idx。
|
||||
# 环境变量:CHUSQL_DATA_DIR
|
||||
data_dir = "../localdata"
|
||||
# 不写就跟随平台惯例(Windows %LOCALAPPDATA%\ChuSQL\data,
|
||||
# Unix ${XDG_DATA_HOME:-$HOME/.local/share}/chusql/data,跟安装脚本装的位置一致);
|
||||
# 要固定就写绝对路径,例如:
|
||||
# data_dir = "/srv/chusql/data"
|
||||
|
||||
[server]
|
||||
# 命名管道名(Windows 上实际是 \\.\pipe\<这个名字>)。
|
||||
# 环境变量:CHUSQL_PIPE
|
||||
pipe_name = "chusql-storage"
|
||||
pipe_name = "chusql-joint"
|
||||
|
||||
[log]
|
||||
# 日志等级:off / error / warn / info / debug。debug 会多打每个请求和连接。
|
||||
# 环境变量:CHUSQL_LOG
|
||||
level = "info"
|
||||
|
||||
# 颜色不用写在文件里,用环境变量控制:CHUSQL_LOG_COLOR = auto(默认,stderr 是终端才着色)
|
||||
# / always / never
|
||||
# 颜色没有配置项也没有环境变量:要写的那条流是终端就着色,重定向到文件就不着色。
|
||||
# 行格式:[等级 本地日期时间] [来源] 消息;来源 core=服务自身、pipe=由管道请求触发
|
||||
# ERROR 加粗红、WARN 加粗橙、DEBUG 加粗不变色、INFO 不加样式
|
||||
File diff suppressed because it is too large.
Load diff
+314
-303
@@ -3,84 +3,77 @@ use std::path::Path;
|
||||
|
||||
use crate::page::{Page, PageFile, PageId};
|
||||
|
||||
// 磁盘 B+ 树:节点占一页,文件头记页大小与 order。
|
||||
// 磁盘 B+ 树:键是变长字节串,允许重复键,叶子链支持范围扫描。
|
||||
|
||||
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 META_MAGIC: &[u8; 4] = b"CBK2";
|
||||
const OFF_PAGE_SIZE: usize = 4;
|
||||
const OFF_MAX_KEY: usize = 8;
|
||||
const OFF_ROOT: usize = 10;
|
||||
const OFF_ORDER: usize = 18;
|
||||
|
||||
const NODE_LEAF: u8 = 0;
|
||||
const NODE_INTERNAL: u8 = 1;
|
||||
const OFF_TYPE: usize = 0;
|
||||
const OFF_KIND: usize = 0;
|
||||
const OFF_COUNT: usize = 1;
|
||||
const OFF_NEXT: usize = 3;
|
||||
const OFF_KEYS: usize = 12;
|
||||
const HEADER: usize = 11;
|
||||
const SLOT: usize = 2;
|
||||
const CELL_FIXED: usize = 2 + 8;
|
||||
|
||||
/// 一个节点占多少字节
|
||||
fn node_bytes(order: usize) -> usize {
|
||||
OFF_KEYS + max_keys(order) * 8 + order * 8
|
||||
}
|
||||
/// 键长上限:节点里的格子必须放得进一页
|
||||
pub const MAX_KEY_BYTES: usize = 64;
|
||||
|
||||
/// 最多几个键
|
||||
/// 一个节点最多几个键
|
||||
fn max_keys(order: usize) -> usize {
|
||||
order - 1
|
||||
}
|
||||
|
||||
/// 值区域的起点
|
||||
fn off_payload(order: usize) -> usize {
|
||||
OFF_KEYS + max_keys(order) * 8
|
||||
/// 一个格子最多占多少字节
|
||||
fn max_cell_bytes() -> usize {
|
||||
CELL_FIXED + MAX_KEY_BYTES
|
||||
}
|
||||
|
||||
/// 这个 order 放得进一页吗
|
||||
pub fn fits_in_page(page_size: usize, order: usize) -> bool {
|
||||
order >= 2 && node_bytes(order) <= page_size
|
||||
order >= 2 && HEADER + max_keys(order) * (SLOT + max_cell_bytes()) <= page_size
|
||||
}
|
||||
|
||||
/// 一页最多几个孩子
|
||||
pub fn max_order(page_size: usize) -> usize {
|
||||
page_size.saturating_sub(4) / 16
|
||||
page_size.saturating_sub(HEADER) / (SLOT + max_cell_bytes()) + 1
|
||||
}
|
||||
|
||||
/// 读一个 u8
|
||||
fn read_u8(p: &Page, off: usize) -> u8 {
|
||||
p.data[off]
|
||||
}
|
||||
|
||||
/// 写一个 u8
|
||||
fn write_u8(p: &mut Page, off: usize, v: u8) {
|
||||
p.data[off] = v;
|
||||
}
|
||||
|
||||
/// 读一个小端 u16
|
||||
fn read_u16(p: &Page, off: usize) -> u16 {
|
||||
u16::from_le_bytes([p.data[off], p.data[off + 1]])
|
||||
}
|
||||
|
||||
/// 写一个小端 u16
|
||||
fn write_u16(p: &mut Page, off: usize, v: u16) {
|
||||
p.data[off..off + 2].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
|
||||
/// 读一个小端 u32
|
||||
fn read_u32(p: &Page, off: usize) -> u32 {
|
||||
u32::from_le_bytes([p.data[off], p.data[off + 1], p.data[off + 2], p.data[off + 3]])
|
||||
}
|
||||
|
||||
/// 写一个小端 u32
|
||||
fn write_u32(p: &mut Page, off: usize, v: u32) {
|
||||
p.data[off..off + 4].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
|
||||
/// 读一个小端 u64
|
||||
fn read_u64(p: &Page, off: usize) -> u64 {
|
||||
let mut b = [0u8; 8];
|
||||
b.copy_from_slice(&p.data[off..off + 8]);
|
||||
u64::from_le_bytes(b)
|
||||
}
|
||||
|
||||
/// 写一个小端 u64
|
||||
fn write_u64(p: &mut Page, off: usize, v: u64) {
|
||||
p.data[off..off + 8].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
@@ -89,9 +82,10 @@ fn write_u64(p: &mut Page, off: usize, v: u64) {
|
||||
fn write_meta(page: &mut Page, page_size: usize, order: usize, root: PageId) {
|
||||
page.zero();
|
||||
page.data[0..4].copy_from_slice(META_MAGIC);
|
||||
write_u32(page, OFF_META_PAGE_SIZE, page_size as u32);
|
||||
write_u16(page, OFF_META_ORDER, order as u16);
|
||||
write_u64(page, OFF_META_ROOT, root);
|
||||
write_u32(page, OFF_PAGE_SIZE, page_size as u32);
|
||||
write_u16(page, OFF_MAX_KEY, MAX_KEY_BYTES as u16);
|
||||
write_u64(page, OFF_ROOT, root);
|
||||
write_u16(page, OFF_ORDER, order as u16);
|
||||
}
|
||||
|
||||
/// 校验文件头与配置
|
||||
@@ -102,8 +96,8 @@ fn check_meta(page: &Page, page_size: usize, order: usize) -> io::Result<()> {
|
||||
"not a chusql index file (bad magic)",
|
||||
));
|
||||
}
|
||||
let stored_page = read_u32(page, OFF_META_PAGE_SIZE) as usize;
|
||||
let stored_order = read_u16(page, OFF_META_ORDER) as usize;
|
||||
let stored_page = read_u32(page, OFF_PAGE_SIZE) as usize;
|
||||
let stored_order = read_u16(page, OFF_ORDER) as usize;
|
||||
if stored_page != page_size || stored_order != order {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
@@ -116,89 +110,114 @@ fn check_meta(page: &Page, page_size: usize, order: usize) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
enum NodeData {
|
||||
Leaf {
|
||||
keys: Vec<i64>,
|
||||
values: Vec<u64>,
|
||||
next: PageId,
|
||||
},
|
||||
Internal {
|
||||
keys: Vec<i64>,
|
||||
children: Vec<PageId>,
|
||||
},
|
||||
/// 一个节点:叶子存行位置,内部节点存孩子页号
|
||||
struct Node {
|
||||
kind: u8,
|
||||
next: PageId,
|
||||
keys: Vec<Vec<u8>>,
|
||||
values: Vec<u64>,
|
||||
}
|
||||
|
||||
/// 一个格子占多少字节
|
||||
fn cell_bytes(key: &[u8]) -> usize {
|
||||
CELL_FIXED + key.len()
|
||||
}
|
||||
|
||||
/// 节点占多少字节
|
||||
fn node_bytes(node: &Node) -> usize {
|
||||
HEADER + node.keys.len() * SLOT + node.keys.iter().map(|k| cell_bytes(k)).sum::<usize>()
|
||||
}
|
||||
|
||||
/// 把节点写回一页(格子从页尾往前摆,槽位从头部往后长)
|
||||
fn put_node(p: &mut Page, node: &Node) -> io::Result<()> {
|
||||
if node.keys.len() > u16::MAX as usize || node_bytes(node) > p.data.len() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"index node does not fit in a page",
|
||||
));
|
||||
}
|
||||
p.zero();
|
||||
write_u8(p, OFF_KIND, node.kind);
|
||||
write_u16(p, OFF_COUNT, node.keys.len() as u16);
|
||||
// 内部节点比键多一个孩子,最后一个放在 next 槽位里
|
||||
let trailer = if node.kind == NODE_INTERNAL {
|
||||
node.values.last().copied().unwrap_or(0)
|
||||
} else {
|
||||
node.next
|
||||
};
|
||||
write_u64(p, OFF_NEXT, trailer);
|
||||
let mut cursor = p.data.len();
|
||||
for (i, key) in node.keys.iter().enumerate() {
|
||||
let size = cell_bytes(key);
|
||||
cursor -= size;
|
||||
write_u16(p, cursor, key.len() as u16);
|
||||
p.data[cursor + 2..cursor + 2 + key.len()].copy_from_slice(key);
|
||||
write_u64(p, cursor + 2 + key.len(), node.values[i]);
|
||||
write_u16(p, HEADER + i * SLOT, cursor as u16);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从一页读出节点
|
||||
fn read_node(p: &Page, order: usize) -> NodeData {
|
||||
let ty = read_u8(p, OFF_TYPE);
|
||||
let n = read_u16(p, OFF_COUNT) as usize;
|
||||
let payload = off_payload(order);
|
||||
if ty == NODE_LEAF {
|
||||
let next = read_u64(p, OFF_NEXT);
|
||||
let mut keys = Vec::with_capacity(n);
|
||||
let mut values = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
keys.push(read_u64(p, OFF_KEYS + i * 8) as i64);
|
||||
values.push(read_u64(p, payload + i * 8));
|
||||
}
|
||||
NodeData::Leaf { keys, values, next }
|
||||
fn get_node(p: &Page) -> Node {
|
||||
let kind = read_u8(p, OFF_KIND);
|
||||
let count = read_u16(p, OFF_COUNT) as usize;
|
||||
let trailer = read_u64(p, OFF_NEXT);
|
||||
let mut keys = Vec::with_capacity(count);
|
||||
let mut values = Vec::with_capacity(count + 1);
|
||||
for i in 0..count {
|
||||
let off = read_u16(p, HEADER + i * SLOT) as usize;
|
||||
let klen = read_u16(p, off) as usize;
|
||||
keys.push(p.data[off + 2..off + 2 + klen].to_vec());
|
||||
values.push(read_u64(p, off + 2 + klen));
|
||||
}
|
||||
if kind == NODE_INTERNAL {
|
||||
values.push(trailer);
|
||||
Node { kind, next: 0, keys, values }
|
||||
} else {
|
||||
let mut keys = Vec::with_capacity(n);
|
||||
let mut children = Vec::with_capacity(n + 1);
|
||||
for i in 0..n {
|
||||
keys.push(read_u64(p, OFF_KEYS + i * 8) as i64);
|
||||
}
|
||||
for i in 0..(n + 1) {
|
||||
children.push(read_u64(p, payload + i * 8));
|
||||
}
|
||||
NodeData::Internal { keys, children }
|
||||
Node { kind, next: trailer, keys, values }
|
||||
}
|
||||
}
|
||||
|
||||
/// 把节点写回一页
|
||||
fn write_node(p: &mut Page, node: &NodeData, order: usize) {
|
||||
p.zero();
|
||||
let payload = off_payload(order);
|
||||
match node {
|
||||
NodeData::Leaf { keys, values, next } => {
|
||||
write_u8(p, OFF_TYPE, NODE_LEAF);
|
||||
write_u16(p, OFF_COUNT, keys.len() as u16);
|
||||
write_u64(p, OFF_NEXT, *next);
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
write_u64(p, OFF_KEYS + i * 8, *k as u64);
|
||||
}
|
||||
for (i, v) in values.iter().enumerate() {
|
||||
write_u64(p, payload + i * 8, *v);
|
||||
}
|
||||
}
|
||||
NodeData::Internal { keys, children } => {
|
||||
write_u8(p, OFF_TYPE, NODE_INTERNAL);
|
||||
write_u16(p, OFF_COUNT, keys.len() as u16);
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
write_u64(p, OFF_KEYS + i * 8, *k as u64);
|
||||
}
|
||||
for (i, c) in children.iter().enumerate() {
|
||||
write_u64(p, payload + i * 8, *c);
|
||||
}
|
||||
}
|
||||
/// 叶子里的插入位置:同键按 value 排,保证顺序稳定
|
||||
fn leaf_slot(keys: &[Vec<u8>], values: &[u64], key: &[u8], value: u64) -> usize {
|
||||
let mut i = keys.partition_point(|k| k.as_slice() < key);
|
||||
while i < keys.len() && keys[i].as_slice() == key && values[i] < value {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// 内部节点里选哪个孩子
|
||||
fn child_slot(keys: &[Vec<u8>], key: &[u8]) -> usize {
|
||||
keys.partition_point(|k| k.as_slice() <= key)
|
||||
}
|
||||
|
||||
/// 切开一个节点:返回上提的键与右半节点
|
||||
fn split_node(node: &mut Node) -> (Vec<u8>, Node) {
|
||||
let mid = node.keys.len() / 2;
|
||||
if node.kind == NODE_LEAF {
|
||||
let sep = node.keys[mid].clone();
|
||||
let right = Node {
|
||||
kind: NODE_LEAF,
|
||||
next: node.next,
|
||||
keys: node.keys.split_off(mid),
|
||||
values: node.values.split_off(mid),
|
||||
};
|
||||
(sep, right)
|
||||
} else {
|
||||
let sep = node.keys[mid].clone();
|
||||
let right_keys = node.keys.split_off(mid + 1);
|
||||
let right_values = node.values.split_off(mid + 1);
|
||||
node.keys.pop();
|
||||
(
|
||||
sep,
|
||||
Node { kind: NODE_INTERNAL, next: 0, keys: right_keys, values: right_values },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 二分选孩子
|
||||
fn child_index(keys: &[i64], key: i64) -> usize {
|
||||
let mut lo = 0;
|
||||
let mut hi = keys.len();
|
||||
while lo < hi {
|
||||
let mid = (lo + hi) / 2;
|
||||
if keys[mid] <= key {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
lo
|
||||
}
|
||||
|
||||
/// 磁盘 B+ 树
|
||||
pub struct DiskBTree {
|
||||
file: PageFile,
|
||||
order: usize,
|
||||
@@ -207,11 +226,11 @@ pub struct DiskBTree {
|
||||
impl DiskBTree {
|
||||
/// 打开或新建索引文件
|
||||
pub fn open<P: AsRef<Path>>(
|
||||
path: P,
|
||||
page_size: usize,
|
||||
order: usize,
|
||||
pool_size: usize,
|
||||
) -> io::Result<Self> {
|
||||
path: P,
|
||||
page_size: usize,
|
||||
order: usize,
|
||||
pool_size: usize,
|
||||
) -> io::Result<Self> {
|
||||
if !fits_in_page(page_size, order) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
@@ -235,21 +254,44 @@ impl DiskBTree {
|
||||
Ok(DiskBTree { file, order })
|
||||
}
|
||||
|
||||
/// 读根页号
|
||||
/// 键太长直接报错,避免写坏节点
|
||||
fn check_key(&self, key: &[u8]) -> io::Result<()> {
|
||||
if key.len() > MAX_KEY_BYTES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"index key is {} bytes, at most {} are supported",
|
||||
key.len(),
|
||||
MAX_KEY_BYTES
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn root(&mut self) -> io::Result<Option<PageId>> {
|
||||
let r = self.file.with_page(META_PAGE, |p| read_u64(p, OFF_META_ROOT))?;
|
||||
let r = self.file.with_page(META_PAGE, |p| read_u64(p, OFF_ROOT))?;
|
||||
Ok(if r == 0 { None } else { Some(r) })
|
||||
}
|
||||
|
||||
/// 写根页号
|
||||
fn set_root(&mut self, r: Option<PageId>) -> io::Result<()> {
|
||||
self.file
|
||||
.update_page(META_PAGE, |p| write_u64(p, OFF_META_ROOT, r.unwrap_or(0)))
|
||||
.update_page(META_PAGE, |p| write_u64(p, OFF_ROOT, r.unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// 分配新页
|
||||
fn alloc(&mut self) -> io::Result<PageId> {
|
||||
Ok(self.file.append_page()?.id)
|
||||
fn alloc(&mut self, node: &Node) -> io::Result<PageId> {
|
||||
let page = self.file.append_page()?;
|
||||
let id = page.id;
|
||||
self.file.update_page(id, |p| put_node(p, node))??;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn load(&mut self, id: PageId) -> io::Result<Node> {
|
||||
self.file.with_page(id, get_node)
|
||||
}
|
||||
|
||||
fn store(&mut self, id: PageId, node: &Node) -> io::Result<()> {
|
||||
self.file.update_page(id, |p| put_node(p, node))?
|
||||
}
|
||||
|
||||
/// 清空整棵树
|
||||
@@ -262,221 +304,190 @@ impl DiskBTree {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 空页,用来拼节点
|
||||
fn new_page(&self, id: PageId) -> Page {
|
||||
Page::new(id, self.file.page_size())
|
||||
}
|
||||
|
||||
/// 按 key 找 value
|
||||
pub fn get(&mut self, key: i64) -> io::Result<Option<u64>> {
|
||||
match self.root()? {
|
||||
None => Ok(None),
|
||||
Some(r) => self.get_rec(r, key),
|
||||
/// 树是空的吗(只看最左叶子,不用整树遍历)
|
||||
pub fn is_empty(&mut self) -> io::Result<bool> {
|
||||
let Some(mut id) = self.root()? else {
|
||||
return Ok(true);
|
||||
};
|
||||
loop {
|
||||
let node = self.load(id)?;
|
||||
if node.kind == NODE_LEAF {
|
||||
return Ok(node.keys.is_empty());
|
||||
}
|
||||
id = node.values[0];
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归查找
|
||||
fn get_rec(&mut self, page_id: PageId, key: i64) -> io::Result<Option<u64>> {
|
||||
let order = self.order;
|
||||
match self.file.with_page(page_id, |p| read_node(p, order))? {
|
||||
NodeData::Leaf { keys, values, .. } => {
|
||||
Ok(keys.binary_search(&key).ok().map(|i| values[i]))
|
||||
}
|
||||
NodeData::Internal { keys, children } => {
|
||||
let i = child_index(&keys, key);
|
||||
self.get_rec(children[i], key)
|
||||
/// 按 key 取第一个位置
|
||||
pub fn get(&mut self, key: &[u8]) -> io::Result<Option<u64>> {
|
||||
Ok(self.get_all(key)?.into_iter().next())
|
||||
}
|
||||
|
||||
/// 按 key 取全部位置(重复键按值升序)
|
||||
pub fn get_all(&mut self, key: &[u8]) -> io::Result<Vec<u64>> {
|
||||
let Some(mut id) = self.root()? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
loop {
|
||||
let node = self.load(id)?;
|
||||
if node.kind == NODE_LEAF {
|
||||
let start = node.keys.partition_point(|k| k.as_slice() < key);
|
||||
let mut out = Vec::new();
|
||||
for i in start..node.keys.len() {
|
||||
if node.keys[i].as_slice() != key {
|
||||
break;
|
||||
}
|
||||
out.push(node.values[i]);
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
id = node.values[child_slot(&node.keys, key)];
|
||||
}
|
||||
}
|
||||
|
||||
/// 插入或覆盖
|
||||
pub fn insert(&mut self, key: i64, value: u64) -> io::Result<()> {
|
||||
/// 插入一个键值对(键可重复)
|
||||
pub fn insert(&mut self, key: &[u8], value: u64) -> io::Result<()> {
|
||||
self.check_key(key)?;
|
||||
match self.root()? {
|
||||
None => {
|
||||
let id = self.alloc()?;
|
||||
let node = NodeData::Leaf {
|
||||
keys: vec![key],
|
||||
values: vec![value],
|
||||
let node = Node {
|
||||
kind: NODE_LEAF,
|
||||
next: 0,
|
||||
keys: vec![key.to_vec()],
|
||||
values: vec![value],
|
||||
};
|
||||
let mut p = self.new_page(id);
|
||||
write_node(&mut p, &node, self.order);
|
||||
self.file.write_page(&p)?;
|
||||
let id = self.alloc(&node)?;
|
||||
self.set_root(Some(id))
|
||||
}
|
||||
Some(r) => {
|
||||
let (_, split) = self.insert_rec(r, key, value)?;
|
||||
if let Some((sep, right_id)) = split {
|
||||
let root_id = self.alloc()?;
|
||||
let node = NodeData::Internal {
|
||||
Some(root) => match self.insert_rec(root, key, value)? {
|
||||
None => Ok(()),
|
||||
Some((sep, right_id)) => {
|
||||
let node = Node {
|
||||
kind: NODE_INTERNAL,
|
||||
next: 0,
|
||||
keys: vec![sep],
|
||||
children: vec![r, right_id],
|
||||
values: vec![root, right_id],
|
||||
};
|
||||
let mut p = self.new_page(root_id);
|
||||
write_node(&mut p, &node, self.order);
|
||||
self.file.write_page(&p)?;
|
||||
self.set_root(Some(root_id))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归插入,可能上提
|
||||
fn insert_rec(
|
||||
&mut self,
|
||||
page_id: PageId,
|
||||
key: i64,
|
||||
value: u64,
|
||||
) -> io::Result<(PageId, Option<(i64, PageId)>)> {
|
||||
let order = self.order;
|
||||
let node = self.file.with_page(page_id, |p| read_node(p, order))?;
|
||||
let limit = max_keys(order);
|
||||
match node {
|
||||
NodeData::Leaf { mut keys, mut values, next } => {
|
||||
match keys.binary_search(&key) {
|
||||
Ok(i) => values[i] = value,
|
||||
Err(i) => {
|
||||
keys.insert(i, key);
|
||||
values.insert(i, value);
|
||||
}
|
||||
}
|
||||
if keys.len() <= limit {
|
||||
self.file.update_page(page_id, |p| {
|
||||
write_node(p, &NodeData::Leaf { keys, values, next }, order)
|
||||
})?;
|
||||
Ok((page_id, None))
|
||||
} else {
|
||||
let mid = keys.len() / 2;
|
||||
let rkeys = keys.split_off(mid);
|
||||
let rvalues = values.split_off(mid);
|
||||
let sep = rkeys[0];
|
||||
|
||||
let right_id = self.alloc()?;
|
||||
let mut rp = self.new_page(right_id);
|
||||
write_node(
|
||||
&mut rp,
|
||||
&NodeData::Leaf { keys: rkeys, values: rvalues, next },
|
||||
order,
|
||||
);
|
||||
self.file.write_page(&rp)?;
|
||||
|
||||
self.file.update_page(page_id, |p| {
|
||||
write_node(p, &NodeData::Leaf { keys, values, next: right_id }, order)
|
||||
})?;
|
||||
|
||||
Ok((page_id, Some((sep, right_id))))
|
||||
}
|
||||
}
|
||||
|
||||
NodeData::Internal { mut keys, mut children } => {
|
||||
let i = child_index(&keys, key);
|
||||
let (new_child, split) = self.insert_rec(children[i], key, value)?;
|
||||
children[i] = new_child;
|
||||
match split {
|
||||
None => {
|
||||
self.file.update_page(page_id, |p| {
|
||||
write_node(p, &NodeData::Internal { keys, children }, order)
|
||||
})?;
|
||||
Ok((page_id, None))
|
||||
}
|
||||
Some((sep, right_id)) => {
|
||||
keys.insert(i, sep);
|
||||
children.insert(i + 1, right_id);
|
||||
if keys.len() <= limit {
|
||||
self.file.update_page(page_id, |p| {
|
||||
write_node(p, &NodeData::Internal { keys, children }, order)
|
||||
})?;
|
||||
Ok((page_id, None))
|
||||
} else {
|
||||
let mid = keys.len() / 2;
|
||||
let up = keys[mid];
|
||||
let rkeys = keys.split_off(mid + 1);
|
||||
keys.truncate(mid);
|
||||
let rchildren = children.split_off(mid + 1);
|
||||
|
||||
let right_id = self.alloc()?;
|
||||
let mut rp = self.new_page(right_id);
|
||||
write_node(
|
||||
&mut rp,
|
||||
&NodeData::Internal { keys: rkeys, children: rchildren },
|
||||
order,
|
||||
);
|
||||
self.file.write_page(&rp)?;
|
||||
|
||||
self.file.update_page(page_id, |p| {
|
||||
write_node(p, &NodeData::Internal { keys, children }, order)
|
||||
})?;
|
||||
|
||||
Ok((page_id, Some((up, right_id))))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 删掉一个键,键不存在返回 false
|
||||
pub fn delete(&mut self, key: i64) -> io::Result<bool> {
|
||||
match self.root()? {
|
||||
None => Ok(false),
|
||||
Some(r) => self.delete_rec(r, key),
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归删除:一路走到叶子,删掉那一对
|
||||
fn delete_rec(&mut self, page_id: PageId, key: i64) -> io::Result<bool> {
|
||||
let order = self.order;
|
||||
match self.file.with_page(page_id, |p| read_node(p, order))? {
|
||||
NodeData::Leaf { mut keys, mut values, next } => match keys.binary_search(&key) {
|
||||
Err(_) => Ok(false),
|
||||
Ok(i) => {
|
||||
keys.remove(i);
|
||||
values.remove(i);
|
||||
self.file.update_page(page_id, |p| {
|
||||
write_node(p, &NodeData::Leaf { keys, values, next }, order)
|
||||
})?;
|
||||
Ok(true)
|
||||
let id = self.alloc(&node)?;
|
||||
self.set_root(Some(id))
|
||||
}
|
||||
},
|
||||
NodeData::Internal { keys, children } => {
|
||||
let i = child_index(&keys, key);
|
||||
self.delete_rec(children[i], key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 按键升序返回全部
|
||||
pub fn iter_all(&mut self) -> io::Result<Vec<(i64, u64)>> {
|
||||
/// 递归插入,溢出就把右半节点与上提键交给上层
|
||||
fn insert_rec(
|
||||
&mut self,
|
||||
id: PageId,
|
||||
key: &[u8],
|
||||
value: u64,
|
||||
) -> io::Result<Option<(Vec<u8>, PageId)>> {
|
||||
let mut node = self.load(id)?;
|
||||
if node.kind == NODE_LEAF {
|
||||
let at = leaf_slot(&node.keys, &node.values, key, value);
|
||||
node.keys.insert(at, key.to_vec());
|
||||
node.values.insert(at, value);
|
||||
} else {
|
||||
let child = node.values[child_slot(&node.keys, key)];
|
||||
if let Some((sep, right_id)) = self.insert_rec(child, key, value)? {
|
||||
let at = node.keys.partition_point(|k| k.as_slice() <= sep.as_slice());
|
||||
node.keys.insert(at, sep);
|
||||
node.values.insert(at + 1, right_id);
|
||||
}
|
||||
}
|
||||
if node.keys.len() <= max_keys(self.order) {
|
||||
self.store(id, &node)?;
|
||||
return Ok(None);
|
||||
}
|
||||
let (sep, mut right) = split_node(&mut node);
|
||||
let right_id = self.alloc(&right)?;
|
||||
if node.kind == NODE_LEAF {
|
||||
right.next = node.next;
|
||||
node.next = right_id;
|
||||
}
|
||||
self.store(id, &node)?;
|
||||
Ok(Some((sep, right_id)))
|
||||
}
|
||||
|
||||
/// 删掉一个 (key, value)
|
||||
pub fn delete(&mut self, key: &[u8], value: u64) -> io::Result<bool> {
|
||||
let Some(root) = self.root()? else {
|
||||
return Ok(false);
|
||||
};
|
||||
self.delete_rec(root, key, value)
|
||||
}
|
||||
|
||||
fn delete_rec(&mut self, id: PageId, key: &[u8], value: u64) -> io::Result<bool> {
|
||||
let mut node = self.load(id)?;
|
||||
if node.kind == NODE_LEAF {
|
||||
let at = leaf_slot(&node.keys, &node.values, key, value);
|
||||
if at < node.keys.len() && node.keys[at].as_slice() == key && node.values[at] == value {
|
||||
node.keys.remove(at);
|
||||
node.values.remove(at);
|
||||
self.store(id, &node)?;
|
||||
return Ok(true);
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
let child = node.values[child_slot(&node.keys, key)];
|
||||
self.delete_rec(child, key, value)
|
||||
}
|
||||
|
||||
/// 叶子链上按顺序取全部 (键, 值)
|
||||
pub fn iter_all(&mut self) -> io::Result<Vec<(Vec<u8>, u64)>> {
|
||||
self.scan(None, None)
|
||||
}
|
||||
|
||||
/// 范围扫描:lo / hi 都是闭区间,None 表示这一侧不限
|
||||
pub fn scan(
|
||||
&mut self,
|
||||
lo: Option<&[u8]>,
|
||||
hi: Option<&[u8]>,
|
||||
) -> io::Result<Vec<(Vec<u8>, u64)>> {
|
||||
let Some(mut id) = self.root()? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
loop {
|
||||
let node = self.load(id)?;
|
||||
if node.kind == NODE_LEAF {
|
||||
id = match lo {
|
||||
None => id,
|
||||
Some(start) => {
|
||||
let at = node.keys.partition_point(|k| k.as_slice() < start);
|
||||
if at < node.keys.len() { id } else { node.next }
|
||||
}
|
||||
};
|
||||
break;
|
||||
}
|
||||
id = match lo {
|
||||
None => node.values[0],
|
||||
Some(start) => node.values[child_slot(&node.keys, start)],
|
||||
};
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if let Some(r) = self.root()? {
|
||||
self.walk(r, &mut out)?;
|
||||
while id != 0 {
|
||||
let node = self.load(id)?;
|
||||
for i in 0..node.keys.len() {
|
||||
if lo.is_some_and(|start| node.keys[i].as_slice() < start) {
|
||||
continue;
|
||||
}
|
||||
if hi.is_some_and(|end| node.keys[i].as_slice() > end) {
|
||||
return Ok(out);
|
||||
}
|
||||
out.push((node.keys[i].clone(), node.values[i]));
|
||||
}
|
||||
id = node.next;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 中序遍历
|
||||
fn walk(&mut self, page_id: PageId, out: &mut Vec<(i64, u64)>) -> io::Result<()> {
|
||||
let order = self.order;
|
||||
match self.file.with_page(page_id, |p| read_node(p, order))? {
|
||||
NodeData::Leaf { keys, values, .. } => {
|
||||
for i in 0..keys.len() {
|
||||
out.push((keys[i], values[i]));
|
||||
}
|
||||
}
|
||||
NodeData::Internal { children, .. } => {
|
||||
for c in children {
|
||||
self.walk(c, out)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 把脏页写回
|
||||
/// 脏页写回
|
||||
pub fn flush(&mut self) -> io::Result<()> {
|
||||
self.file.flush()
|
||||
}
|
||||
|
||||
/// 当前缓存页数
|
||||
pub fn cached_pages(&self) -> usize {
|
||||
self.file.cached_pages()
|
||||
}
|
||||
}
|
||||
+121
-11
@@ -1,12 +1,13 @@
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::protocol::{ColumnType, Row, SchemaColumn};
|
||||
use crate::protocol::{Row, SchemaColumn};
|
||||
|
||||
// 数据字典:记每张表有哪些列、什么类型、多少行、有哪些索引,以及列级统计。
|
||||
|
||||
@@ -26,8 +27,14 @@ pub struct ColumnStat {
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TableSchema {
|
||||
#[serde(default)]
|
||||
pub system: bool,
|
||||
pub columns: Vec<SchemaColumn>,
|
||||
#[serde(default)]
|
||||
pub dropped_columns: BTreeSet<String>,
|
||||
#[serde(default)]
|
||||
pub compacted: bool,
|
||||
#[serde(default)]
|
||||
pub row_count: u64,
|
||||
#[serde(default)]
|
||||
pub indexes: Vec<IndexSchema>,
|
||||
@@ -63,7 +70,13 @@ impl Catalog {
|
||||
/// 写回文件
|
||||
pub fn save<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
|
||||
let bytes = serde_json::to_vec_pretty(self).map_err(io::Error::other)?;
|
||||
std::fs::write(path, bytes)
|
||||
let pending = path.as_ref().with_extension("json.pending");
|
||||
let mut file = std::fs::File::create(&pending)?;
|
||||
file.write_all(&bytes)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
std::fs::rename(pending, &path)?;
|
||||
std::fs::OpenOptions::new().read(true).write(true).open(path)?.sync_all()
|
||||
}
|
||||
|
||||
/// 一张表的 schema
|
||||
@@ -93,7 +106,10 @@ impl Catalog {
|
||||
self.tables.insert(
|
||||
table.to_string(),
|
||||
TableSchema {
|
||||
system: false,
|
||||
columns,
|
||||
dropped_columns: BTreeSet::new(),
|
||||
compacted: false,
|
||||
row_count: 0,
|
||||
indexes: Vec::new(),
|
||||
stats: BTreeMap::new(),
|
||||
@@ -102,6 +118,14 @@ impl Catalog {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 标记内部账号表
|
||||
pub fn mark_system(&mut self, table: &str) -> io::Result<()> {
|
||||
let entry = self.tables.get_mut(table)
|
||||
.ok_or_else(|| io::Error::other("missing system table"))?;
|
||||
entry.system = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 记一次插入,返回列有没有变化
|
||||
pub fn record_insert(&mut self, table: &str, row: &Row) -> bool {
|
||||
let changed = {
|
||||
@@ -140,8 +164,11 @@ impl Catalog {
|
||||
entry.stats.clear();
|
||||
entry.row_count = 0;
|
||||
}
|
||||
rows.iter()
|
||||
.fold(false, |changed, r| self.record_insert(table, r) || changed)
|
||||
let mut changed = false;
|
||||
for row in rows {
|
||||
changed = self.record_insert(table, row) || changed;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// 这一列第一次见到这个值就加一
|
||||
@@ -164,7 +191,8 @@ impl Catalog {
|
||||
}
|
||||
|
||||
/// 删表
|
||||
pub fn drop_table(&mut self, table: &str) -> io::Result<()> { if self.tables.remove(table).is_none() {
|
||||
pub fn drop_table(&mut self, table: &str) -> io::Result<()> {
|
||||
if self.tables.remove(table).is_none() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("unknown table: {}", table),
|
||||
@@ -174,6 +202,35 @@ impl Catalog {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// ALTER:整列定义换成新的,并清掉二级索引与统计
|
||||
pub fn set_columns(&mut self, table: &str, columns: Vec<SchemaColumn>) -> io::Result<()> {
|
||||
let entry = self
|
||||
.tables
|
||||
.get_mut(table)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("unknown table: {}", table)))?;
|
||||
entry.columns = columns;
|
||||
entry.dropped_columns.clear();
|
||||
entry.indexes.clear();
|
||||
entry.stats.clear();
|
||||
self.seen.remove(table);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 老数据的列类型名规范化;返回有没有改动
|
||||
pub fn normalize_types(&mut self) -> bool {
|
||||
let mut changed = false;
|
||||
for entry in self.tables.values_mut() {
|
||||
for column in entry.columns.iter_mut() {
|
||||
let fixed = normalize_type(&column.ty);
|
||||
if fixed != column.ty {
|
||||
column.ty = fixed;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// 删一列;之前没有也返回成功(重放幂等)
|
||||
pub fn remove_column(&mut self, table: &str, column: &str) -> io::Result<bool> {
|
||||
let entry = self
|
||||
@@ -186,11 +243,33 @@ impl Catalog {
|
||||
entry.columns.len() != before
|
||||
};
|
||||
entry.stats.remove(column);
|
||||
entry.dropped_columns.insert(column.to_string());
|
||||
entry.compacted = false;
|
||||
entry.indexes.retain(|i| i.column != column);
|
||||
self.seen.remove(table);
|
||||
Ok(had_column)
|
||||
}
|
||||
|
||||
/// 整理完成:记住这一版已经重写过(隐藏列名要留住,防止被重新写进来)
|
||||
pub fn mark_compacted(&mut self, table: &str) -> io::Result<()> {
|
||||
let entry = self
|
||||
.tables
|
||||
.get_mut(table)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("unknown table: {}", table)))?;
|
||||
entry.compacted = true;
|
||||
self.seen.remove(table);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 还有隐藏列字节待整理的表
|
||||
pub fn dropped_tables(&self) -> Vec<String> {
|
||||
self.tables
|
||||
.iter()
|
||||
.filter(|(_, e)| !e.dropped_columns.is_empty() && !e.compacted)
|
||||
.map(|(t, _)| t.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 记下一条索引定义
|
||||
pub fn add_index(&mut self, table: &str, column: &str) -> io::Result<()> {
|
||||
let entry = self
|
||||
@@ -232,13 +311,14 @@ impl Catalog {
|
||||
fn add_missing_columns(entry: &mut TableSchema, row: &Row) -> bool {
|
||||
let mut changed = false;
|
||||
for (k, v) in row {
|
||||
if entry.columns.iter().any(|c| &c.name == k) {
|
||||
if entry.dropped_columns.contains(k) || entry.columns.iter().any(|c| &c.name == k) {
|
||||
continue;
|
||||
}
|
||||
if let Some(ty) = infer_type(v) {
|
||||
entry.columns.push(SchemaColumn {
|
||||
name: k.clone(),
|
||||
ty,
|
||||
..Default::default()
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
@@ -247,15 +327,45 @@ fn add_missing_columns(entry: &mut TableSchema, row: &Row) -> bool {
|
||||
}
|
||||
|
||||
/// 由 JSON 值猜列类型
|
||||
fn infer_type(v: &serde_json::Value) -> Option<ColumnType> {
|
||||
fn infer_type(v: &serde_json::Value) -> Option<String> {
|
||||
match v {
|
||||
serde_json::Value::Number(_) => Some(ColumnType::Int),
|
||||
serde_json::Value::String(_) => Some(ColumnType::Str),
|
||||
serde_json::Value::Bool(_) => Some(ColumnType::Bool),
|
||||
serde_json::Value::Number(_) => Some("int".to_string()),
|
||||
serde_json::Value::String(_) => Some("str".to_string()),
|
||||
serde_json::Value::Bool(_) => Some("bool".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 旧写法与新别名都并到规范名字,参数(varchar(20))原样保留;不认识的原样返回
|
||||
pub fn normalize_type(ty: &str) -> String {
|
||||
let trimmed = ty.trim();
|
||||
let (base, args) = match trimmed.find('(') {
|
||||
Some(i) => (&trimmed[..i], &trimmed[i..]),
|
||||
None => (trimmed, ""),
|
||||
};
|
||||
match base.trim().to_ascii_lowercase().as_str() {
|
||||
"integer" => format!("int{args}"),
|
||||
"int" => format!("int{args}"),
|
||||
"bigint" => format!("bigint{args}"),
|
||||
"smallint" => format!("smallint{args}"),
|
||||
"str" => format!("str{args}"),
|
||||
"text" => format!("str{args}"),
|
||||
"varchar" => format!("varchar{args}"),
|
||||
"char" => format!("char{args}"),
|
||||
"bool" => format!("bool{args}"),
|
||||
"boolean" => format!("bool{args}"),
|
||||
"float" => format!("float{args}"),
|
||||
"real" => format!("float{args}"),
|
||||
"double" => format!("double{args}"),
|
||||
"decimal" => format!("decimal{args}"),
|
||||
"numeric" => format!("decimal{args}"),
|
||||
"date" => format!("date{args}"),
|
||||
"timestamp" => format!("timestamp{args}"),
|
||||
"blob" => format!("blob{args}"),
|
||||
_ => trimmed.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个值的哈希,统计不同值个数用
|
||||
fn value_hash(v: &serde_json::Value) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
|
||||
+151
-75
@@ -4,21 +4,103 @@ use serde::Deserialize;
|
||||
|
||||
use crate::log::Level;
|
||||
|
||||
// 配置:环境变量优先于 TOML,最后回退内置默认值。
|
||||
// 全局配置:只从 TOML 文件读,没写的项回退内置默认值。
|
||||
//
|
||||
// 这个文件是整条链路的唯一配置文件,按分区各管各的:
|
||||
// 存储只看 [page]/[btree]/[buffer]/[storage]/[server]/[log],
|
||||
// 其他分区(例如 web 的 [web])由对应的层自己读,这里既不校验也不关心。
|
||||
// 位置是安装时定下的固定路径,只有命令行 --config 能覆盖;没有任何环境变量通道。
|
||||
// (唯一跟环境变量有关的是路径的默认值:配置文件和默认数据目录都按平台惯例算,
|
||||
// 见 default_config_path / default_data_dir,它们只是算默认位置,不是配置通道。)
|
||||
// 读一次就关,不长期占着文件句柄。
|
||||
|
||||
pub const DEFAULT_CONFIG_PATH: &str = "chusql-storage.toml";
|
||||
/// 配置文件名
|
||||
pub const CONFIG_FILE_NAME: &str = "chusql.toml";
|
||||
/// 配置目录名(随系统惯例放在用户配置目录下)
|
||||
pub const APP_DIR_NAME: &str = "ChuSQL";
|
||||
/// Unix 下的数据目录名(XDG 惯例小写,跟安装脚本的默认安装目录一致)
|
||||
pub const UNIX_APP_DIR_NAME: &str = "chusql";
|
||||
/// 数据子目录名
|
||||
pub const DATA_DIR_NAME: &str = "data";
|
||||
|
||||
pub const DEFAULT_POOL_SIZE: usize = 1024;
|
||||
pub const DEFAULT_PAGE_SIZE: usize = 4096;
|
||||
pub const DEFAULT_BTREE_ORDER: usize = 4;
|
||||
pub const DEFAULT_DATA_DIR: &str = "../localdata";
|
||||
pub const DEFAULT_PIPE_NAME: &str = "chusql-storage";
|
||||
pub const DEFAULT_PIPE_NAME: &str = "chusql-joint";
|
||||
pub const DEFAULT_LOG_LEVEL: &str = "info";
|
||||
|
||||
const MIN_PAGE_SIZE: usize = 512;
|
||||
const MAX_PAGE_SIZE: usize = 65536;
|
||||
const MIN_BTREE_ORDER: usize = 3;
|
||||
|
||||
/// 安装后的固定配置路径:Windows 用 %APPDATA%,其它平台用 XDG 惯例。
|
||||
/// 环境变量缺失时退回相对路径 ChuSQL/chusql.toml。
|
||||
pub fn default_config_path() -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let appdata = std::env::var_os("APPDATA").filter(|v| !v.is_empty());
|
||||
if let Some(appdata) = appdata {
|
||||
return PathBuf::from(appdata).join(APP_DIR_NAME).join(CONFIG_FILE_NAME);
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let xdg = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty());
|
||||
if let Some(xdg) = xdg {
|
||||
return PathBuf::from(xdg).join(APP_DIR_NAME).join(CONFIG_FILE_NAME);
|
||||
}
|
||||
let home = std::env::var_os("HOME").filter(|v| !v.is_empty());
|
||||
if let Some(home) = home {
|
||||
return PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join(APP_DIR_NAME)
|
||||
.join(CONFIG_FILE_NAME);
|
||||
}
|
||||
}
|
||||
PathBuf::from(APP_DIR_NAME).join(CONFIG_FILE_NAME)
|
||||
}
|
||||
|
||||
/// [storage] data_dir 没写时的默认数据目录,按各平台惯例自动切换:
|
||||
///
|
||||
/// Windows %LOCALAPPDATA%\ChuSQL\data(没设就退 %APPDATA%)
|
||||
/// Unix $XDG_DATA_HOME/chusql/data(没设就是 ~/.local/share/chusql/data)
|
||||
///
|
||||
/// 跟安装脚本的默认数据目录是同一处:install.ps1 默认装到 %LOCALAPPDATA%\ChuSQL、
|
||||
/// 数据放它下面的 data\;install.sh 默认装到 ~/.local/share/chusql、数据放它下面的 data/。
|
||||
/// 环境变量都缺时才退回相对路径(跟 default_config_path 的兜底风格一致)。
|
||||
#[cfg(windows)]
|
||||
pub fn default_data_dir() -> PathBuf {
|
||||
let local = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty());
|
||||
if let Some(local) = local {
|
||||
return PathBuf::from(local).join(APP_DIR_NAME).join(DATA_DIR_NAME);
|
||||
}
|
||||
let roaming = std::env::var_os("APPDATA").filter(|v| !v.is_empty());
|
||||
if let Some(roaming) = roaming {
|
||||
return PathBuf::from(roaming).join(APP_DIR_NAME).join(DATA_DIR_NAME);
|
||||
}
|
||||
PathBuf::from(APP_DIR_NAME).join(DATA_DIR_NAME)
|
||||
}
|
||||
|
||||
/// 见 Windows 版本的注释:这里走 XDG 惯例。
|
||||
#[cfg(not(windows))]
|
||||
pub fn default_data_dir() -> PathBuf {
|
||||
let xdg = std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty());
|
||||
if let Some(xdg) = xdg {
|
||||
return PathBuf::from(xdg)
|
||||
.join(UNIX_APP_DIR_NAME)
|
||||
.join(DATA_DIR_NAME);
|
||||
}
|
||||
let home = std::env::var_os("HOME").filter(|v| !v.is_empty());
|
||||
if let Some(home) = home {
|
||||
return PathBuf::from(home)
|
||||
.join(".local")
|
||||
.join("share")
|
||||
.join(UNIX_APP_DIR_NAME)
|
||||
.join(DATA_DIR_NAME);
|
||||
}
|
||||
PathBuf::from(UNIX_APP_DIR_NAME).join(DATA_DIR_NAME)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Config {
|
||||
pub page_size: usize,
|
||||
@@ -35,7 +117,7 @@ impl Default for Config {
|
||||
page_size: DEFAULT_PAGE_SIZE,
|
||||
btree_order: DEFAULT_BTREE_ORDER,
|
||||
pool_size: DEFAULT_POOL_SIZE,
|
||||
data_dir: PathBuf::from(DEFAULT_DATA_DIR),
|
||||
data_dir: default_data_dir(),
|
||||
pipe_name: DEFAULT_PIPE_NAME.to_string(),
|
||||
log_level: Level::Info,
|
||||
}
|
||||
@@ -46,7 +128,6 @@ impl Default for Config {
|
||||
pub enum Origin {
|
||||
Default,
|
||||
File(PathBuf),
|
||||
Env(&'static str),
|
||||
}
|
||||
|
||||
impl Origin {
|
||||
@@ -55,7 +136,6 @@ impl Origin {
|
||||
match self {
|
||||
Origin::Default => "default".to_string(),
|
||||
Origin::File(p) => format!("file {}", p.display()),
|
||||
Origin::Env(k) => format!("env {}", k),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,7 +148,6 @@ pub struct Loaded {
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct FileConfig {
|
||||
page: Option<FilePage>,
|
||||
btree: Option<FileBtree>,
|
||||
@@ -114,12 +193,19 @@ struct FileLog {
|
||||
level: Option<String>,
|
||||
}
|
||||
|
||||
/// 按真实环境加载
|
||||
pub fn load() -> Result<Loaded, String> {
|
||||
let path = match std::env::var("CHUSQL_CONFIG") {
|
||||
Ok(p) if !p.trim().is_empty() => Some(PathBuf::from(p)),
|
||||
_ => {
|
||||
let p = PathBuf::from(DEFAULT_CONFIG_PATH);
|
||||
/// 加载配置:explicit 有值就用它(读不到要报错),否则用固定安装路径(存在才读)
|
||||
pub fn load(explicit: Option<&str>) -> Result<Loaded, String> {
|
||||
let explicit = explicit.map(str::trim).filter(|p| !p.is_empty());
|
||||
let path = match explicit {
|
||||
Some(p) => {
|
||||
let p = PathBuf::from(p);
|
||||
if !p.is_file() {
|
||||
return Err(format!("config file not found: {}", p.display()));
|
||||
}
|
||||
Some(p)
|
||||
}
|
||||
None => {
|
||||
let p = default_config_path();
|
||||
if p.is_file() { Some(p) } else { None }
|
||||
}
|
||||
};
|
||||
@@ -130,15 +216,11 @@ pub fn load() -> Result<Loaded, String> {
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
resolve(text.as_deref(), path, &|k| std::env::var(k).ok())
|
||||
resolve(text.as_deref(), path)
|
||||
}
|
||||
|
||||
/// 解析文本并套环境变量
|
||||
pub fn resolve(
|
||||
text: Option<&str>,
|
||||
path: Option<PathBuf>,
|
||||
env: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<Loaded, String> {
|
||||
/// 解析文本:文件 -> 默认
|
||||
pub fn resolve(text: Option<&str>, path: Option<PathBuf>) -> Result<Loaded, String> {
|
||||
let file: FileConfig = match text {
|
||||
None => FileConfig::default(),
|
||||
Some(t) => toml::from_str(t).map_err(|e| match &path {
|
||||
@@ -148,70 +230,44 @@ pub fn resolve(
|
||||
};
|
||||
|
||||
let file_origin = Origin::File(
|
||||
path.clone()
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH)),
|
||||
path.clone().unwrap_or_else(default_config_path),
|
||||
);
|
||||
|
||||
let (page_size, page_origin) = pick(
|
||||
"CHUSQL_PAGE_SIZE",
|
||||
file.page.and_then(|p| p.size),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
&file_origin,
|
||||
env,
|
||||
|s| {
|
||||
s.trim()
|
||||
.parse::<usize>()
|
||||
.map_err(|_| format!("not a number: {}", s))
|
||||
},
|
||||
)?;
|
||||
);
|
||||
let (btree_order, order_origin) = pick(
|
||||
"CHUSQL_BTREE_ORDER",
|
||||
file.btree.and_then(|b| b.order),
|
||||
DEFAULT_BTREE_ORDER,
|
||||
&file_origin,
|
||||
env,
|
||||
|s| {
|
||||
s.trim()
|
||||
.parse::<usize>()
|
||||
.map_err(|_| format!("not a number: {}", s))
|
||||
},
|
||||
)?;
|
||||
);
|
||||
let (pool_size, pool_origin) = pick(
|
||||
"CHUSQL_POOL_SIZE",
|
||||
file.buffer.and_then(|b| b.pool_size),
|
||||
DEFAULT_POOL_SIZE,
|
||||
&file_origin,
|
||||
env,
|
||||
|s| {
|
||||
s.trim()
|
||||
.parse::<usize>()
|
||||
.map_err(|_| format!("not a number: {}", s))
|
||||
},
|
||||
)?;
|
||||
);
|
||||
let (data_dir, dir_origin) = pick(
|
||||
"CHUSQL_DATA_DIR",
|
||||
file.storage.and_then(|s| s.data_dir),
|
||||
DEFAULT_DATA_DIR.to_string(),
|
||||
default_data_dir().to_string_lossy().into_owned(),
|
||||
&file_origin,
|
||||
env,
|
||||
|s| Ok(s.trim().to_string()),
|
||||
)?;
|
||||
);
|
||||
let (pipe_name, pipe_origin) = pick(
|
||||
"CHUSQL_PIPE",
|
||||
file.server.and_then(|s| s.pipe_name),
|
||||
DEFAULT_PIPE_NAME.to_string(),
|
||||
&file_origin,
|
||||
env,
|
||||
|s| Ok(s.trim().to_string()),
|
||||
)?;
|
||||
);
|
||||
let (log_level, log_origin) = pick(
|
||||
"CHUSQL_LOG",
|
||||
file.log.and_then(|l| l.level),
|
||||
DEFAULT_LOG_LEVEL.to_string(),
|
||||
&file_origin,
|
||||
env,
|
||||
|s| Ok(s.trim().to_string()),
|
||||
)?;
|
||||
);
|
||||
|
||||
let data_dir = data_dir.trim().to_string();
|
||||
let pipe_name = pipe_name.trim().to_string();
|
||||
let log_level = log_level.trim().to_string();
|
||||
|
||||
let log_level = Level::parse(&log_level)
|
||||
.ok_or_else(|| format!("log.level is not one of off/error/warn/info/debug: {}", log_level))?;
|
||||
|
||||
@@ -248,23 +304,12 @@ pub fn resolve(
|
||||
})
|
||||
}
|
||||
|
||||
/// 取一项:env -> 文件 -> 默认
|
||||
fn pick<T: Clone>(
|
||||
env_key: &'static str,
|
||||
file_value: Option<T>,
|
||||
default: T,
|
||||
file_origin: &Origin,
|
||||
env: &dyn Fn(&str) -> Option<String>,
|
||||
parse: impl Fn(&str) -> Result<T, String>,
|
||||
) -> Result<(T, Origin), String> {
|
||||
if let Some(raw) = env(env_key) {
|
||||
let value = parse(&raw).map_err(|e| format!("{}: {}", env_key, e))?;
|
||||
return Ok((value, Origin::Env(env_key)));
|
||||
}
|
||||
/// 取一项:文件 -> 默认
|
||||
fn pick<T>(file_value: Option<T>, default: T, file_origin: &Origin) -> (T, Origin) {
|
||||
if let Some(value) = file_value {
|
||||
return Ok((value, file_origin.clone()));
|
||||
return (value, file_origin.clone());
|
||||
}
|
||||
Ok((default, Origin::Default))
|
||||
(default, Origin::Default)
|
||||
}
|
||||
|
||||
/// 检查选项是否自洽
|
||||
@@ -291,3 +336,34 @@ fn validate_layout(page_size: usize, btree_order: usize) -> Result<(), String> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 全局配置里别的层(例如 web)的分区不能被存储层当成错误
|
||||
#[test]
|
||||
fn ignores_sections_owned_by_other_layers() {
|
||||
let text = r#"
|
||||
[page]
|
||||
size = 8192
|
||||
|
||||
[storage]
|
||||
data_dir = "../data"
|
||||
|
||||
[web]
|
||||
port = 7777
|
||||
user = "root"
|
||||
"#;
|
||||
let loaded = resolve(Some(text), Some(PathBuf::from("chusql.toml"))).unwrap();
|
||||
assert_eq!(loaded.config.page_size, 8192);
|
||||
assert_eq!(loaded.config.data_dir, PathBuf::from("../data"));
|
||||
}
|
||||
|
||||
/// 自己分区里的拼写错误仍然要报错
|
||||
#[test]
|
||||
fn still_rejects_typos_inside_its_own_sections() {
|
||||
let text = "[page]\npage_size = 8192\n";
|
||||
assert!(resolve(Some(text), None).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// 本地端点:Windows 上是具名管道 `\\.\pipe\<name>`,Unix/macOS 上是文件系统套接字
|
||||
// `<socket_dir>/<name>.sock`。存储进程、Haskell 前端(见 chusql-engine 的 Storage.IPC)
|
||||
// 和集成测试都用同一条规则,保证对同一个 `[server] pipe_name` 算出同一个端点。
|
||||
//
|
||||
// 用文件系统套接字而不是 Linux 抽象命名空间:抽象命名空间没有文件、跨平台不存在,
|
||||
// 而带路径的套接字两个方向都能连,Haskell 那边也能用常规 socket 连上。
|
||||
|
||||
use interprocess::local_socket::{prelude::*, ListenerOptions};
|
||||
#[cfg(unix)]
|
||||
use interprocess::local_socket::GenericFilePath;
|
||||
#[cfg(windows)]
|
||||
use interprocess::local_socket::GenericNamespaced;
|
||||
#[cfg(unix)]
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// 端点的可读形式:Windows 是 `\\.\pipe\<name>`,Unix 是套接字文件路径。
|
||||
pub fn display(pipe_name: &str) -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
format!("\\\\.\\pipe\\{}", pipe_name)
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
socket_path(pipe_name).display().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Unix 端点路径:`$XDG_RUNTIME_DIR` → `$TMPDIR` → `/tmp`,文件名固定 `<pipe_name>.sock`。
|
||||
#[cfg(unix)]
|
||||
pub fn socket_path(pipe_name: &str) -> PathBuf {
|
||||
let dir = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.filter(|v| !v.is_empty())
|
||||
.or_else(|| std::env::var_os("TMPDIR").filter(|v| !v.is_empty()))
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"));
|
||||
dir.join(format!("{}.sock", pipe_name))
|
||||
}
|
||||
|
||||
/// 连到正在监听的存储进程。
|
||||
pub fn connect(pipe_name: &str) -> std::io::Result<LocalSocketStream> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let name = pipe_name
|
||||
.to_ns_name::<GenericNamespaced>()
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
LocalSocketStream::connect(name)
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let path = socket_path(pipe_name);
|
||||
let name = path
|
||||
.to_fs_name::<GenericFilePath>()
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
LocalSocketStream::connect(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// 监听端点:Windows 直接建具名管道;Unix 先探活,再清掉上次崩溃留下的残留套接字文件。
|
||||
pub fn listen(pipe_name: &str) -> std::io::Result<LocalSocketListener> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let name = pipe_name
|
||||
.to_ns_name::<GenericNamespaced>()
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
ListenerOptions::new().name(name).create_sync()
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let path = socket_path(pipe_name);
|
||||
prepare_socket_path(&path)?;
|
||||
let name = path
|
||||
.to_fs_name::<GenericFilePath>()
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
ListenerOptions::new().name(name).create_sync()
|
||||
}
|
||||
}
|
||||
|
||||
/// Unix:先探活的监听者,再清掉残留的套接字文件。
|
||||
#[cfg(unix)]
|
||||
fn prepare_socket_path(path: &Path) -> std::io::Result<()> {
|
||||
match std::os::unix::net::UnixStream::connect(path) {
|
||||
Ok(_) => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::AddrInUse,
|
||||
format!(
|
||||
"another storage server already listens on {}",
|
||||
path.display()
|
||||
),
|
||||
)),
|
||||
Err(_) => {
|
||||
if path.exists() {
|
||||
std::fs::remove_file(path)?;
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
+222
-42
@@ -1,6 +1,8 @@
|
||||
use std::collections::HashSet;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use serde::de::{IgnoredAny, MapAccess, Visitor};
|
||||
use serde::Deserializer;
|
||||
|
||||
use crate::btree::DiskBTree;
|
||||
use crate::page::PageId;
|
||||
@@ -13,6 +15,32 @@ const HEADER_SIZE: usize = 2;
|
||||
const SLOT_SIZE: usize = 8;
|
||||
const SLOT_DIR_START: usize = HEADER_SIZE;
|
||||
|
||||
/// 未选字段只校验并跳过,不分配值。
|
||||
fn decode_row(bytes: &[u8], columns: Option<&[String]>, hidden: &HashSet<String>) -> io::Result<Row> {
|
||||
struct RowVisitor<'a>(Option<&'a [String]>, &'a HashSet<String>);
|
||||
impl<'de> Visitor<'de> for RowVisitor<'_> {
|
||||
type Value = Row;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a row object")
|
||||
}
|
||||
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Row, M::Error> {
|
||||
let mut row = Row::new();
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
if !self.1.contains(&key) && self.0.is_none_or(|cols| cols.contains(&key)) {
|
||||
row.insert(key, map.next_value()?);
|
||||
} else {
|
||||
map.next_value::<IgnoredAny>()?;
|
||||
}
|
||||
}
|
||||
Ok(row)
|
||||
}
|
||||
}
|
||||
let mut decoder = serde_json::Deserializer::from_slice(bytes);
|
||||
let row = decoder.deserialize_map(RowVisitor(columns, hidden)).map_err(io::Error::other)?;
|
||||
decoder.end().map_err(io::Error::other)?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// 读一个小端 u16
|
||||
fn read_u16(buf: &[u8], off: usize) -> u16 {
|
||||
u16::from_le_bytes([buf[off], buf[off + 1]])
|
||||
@@ -51,6 +79,26 @@ fn row_int(row: &Row, column: &str) -> Option<i64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// i64 键 → 8 字节大端(保序,负数也在前)
|
||||
fn key_of(value: i64) -> [u8; 8] {
|
||||
(value as u64 ^ (1u64 << 63)).to_be_bytes()
|
||||
}
|
||||
|
||||
/// 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::String(s) => Some(s.as_bytes().to_vec()),
|
||||
serde_json::Value::Bool(b) => Some(vec![u8::from(*b)]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 取行里某一列的索引键;NULL 与缺列不入索引
|
||||
fn row_key(row: &Row, column: &str) -> Option<Vec<u8>> {
|
||||
row.get(column).and_then(value_key)
|
||||
}
|
||||
|
||||
pub struct HeapPage;
|
||||
|
||||
impl HeapPage {
|
||||
@@ -146,6 +194,7 @@ struct NamedIndex {
|
||||
}
|
||||
|
||||
pub struct HeapTable {
|
||||
hidden_columns: HashSet<String>,
|
||||
file: PageFile,
|
||||
indexes: Vec<NamedIndex>,
|
||||
}
|
||||
@@ -154,6 +203,7 @@ impl HeapTable {
|
||||
/// 打开表(不带索引)
|
||||
pub fn open<P: AsRef<Path>>(path: P, page_size: usize, pool_size: usize) -> io::Result<Self> {
|
||||
Ok(HeapTable {
|
||||
hidden_columns: HashSet::new(),
|
||||
file: PageFile::with_options(path, page_size, pool_size)?,
|
||||
indexes: Vec::new(),
|
||||
})
|
||||
@@ -167,12 +217,13 @@ impl HeapTable {
|
||||
btree_order: usize,
|
||||
pool_size: usize,
|
||||
) -> io::Result<Self> {
|
||||
let mut t = HeapTable {
|
||||
file: PageFile::with_options(path, page_size, pool_size)?,
|
||||
indexes: Vec::new(),
|
||||
};
|
||||
t.attach_index("id", index_path, page_size, btree_order, pool_size)?;
|
||||
Ok(t)
|
||||
Self::open_with_indexes(
|
||||
path,
|
||||
&[("id".to_string(), index_path.as_ref().to_path_buf())],
|
||||
page_size,
|
||||
btree_order,
|
||||
pool_size,
|
||||
)
|
||||
}
|
||||
|
||||
/// 打开表并挂上多组索引
|
||||
@@ -184,11 +235,29 @@ impl HeapTable {
|
||||
pool_size: usize,
|
||||
) -> io::Result<Self> {
|
||||
let mut t = HeapTable {
|
||||
hidden_columns: HashSet::new(),
|
||||
file: PageFile::with_options(path, page_size, pool_size)?,
|
||||
indexes: Vec::new(),
|
||||
};
|
||||
let mut filled = false;
|
||||
for (col, p) in indexes {
|
||||
t.attach_index(col, p, page_size, btree_order, pool_size)?;
|
||||
match t.attach_index(col, p, page_size, btree_order, pool_size) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == io::ErrorKind::InvalidData => {
|
||||
// 格式不兼容(例如上一版落的索引文件):删掉重开
|
||||
let _ = std::fs::remove_file(p);
|
||||
t.attach_index(col, p, page_size, btree_order, pool_size)?;
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
// 空索引(新文件、被截断、强杀留下)都按堆数据补齐
|
||||
if t.index_empty(col)? {
|
||||
t.fill_index(col)?;
|
||||
filled = true;
|
||||
}
|
||||
}
|
||||
if filled {
|
||||
t.flush()?;
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
@@ -231,19 +300,17 @@ impl HeapTable {
|
||||
));
|
||||
}
|
||||
|
||||
let mut seen: HashSet<i64> = HashSet::new();
|
||||
let mut entries: Vec<(i64, u64)> = Vec::new();
|
||||
let mut seen: HashSet<Vec<u8>> = HashSet::new();
|
||||
let mut entries: Vec<(Vec<u8>, u64)> = Vec::new();
|
||||
for (page_id, slot, r) in self.scan_with_positions()? {
|
||||
let Some(k) = row_int(&r, column) else {
|
||||
let Some(k) = row_key(&r, column) else {
|
||||
continue;
|
||||
};
|
||||
if !seen.insert(k) {
|
||||
// 行身份索引仍然唯一;其余列允许重复值
|
||||
if column == "id" && !seen.insert(k.clone()) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"column \"{}\" has duplicate value {} -- non-unique indexes are not supported yet",
|
||||
column, k
|
||||
),
|
||||
format!("column \"{}\" has duplicate values", column),
|
||||
));
|
||||
}
|
||||
entries.push((k, pack_position(page_id, slot)));
|
||||
@@ -253,7 +320,31 @@ impl HeapTable {
|
||||
let i = self.indexes.len() - 1;
|
||||
self.indexes[i].tree.clear()?;
|
||||
for (k, pos) in entries {
|
||||
self.indexes[i].tree.insert(k, pos)?;
|
||||
self.indexes[i].tree.insert(&k, pos)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 这一列的索引是空的吗
|
||||
fn index_empty(&mut self, column: &str) -> io::Result<bool> {
|
||||
match self.indexes.iter_mut().find(|i| i.column == column) {
|
||||
Some(idx) => idx.tree.is_empty(),
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// 按堆里的行把某一列索引补齐
|
||||
fn fill_index(&mut self, column: &str) -> io::Result<()> {
|
||||
let rows = self.scan_with_positions()?;
|
||||
let Some(i) = self.indexes.iter().position(|idx| idx.column == column) else {
|
||||
return Ok(());
|
||||
};
|
||||
for (page_id, slot, row) in rows {
|
||||
if let Some(k) = row_key(&row, column) {
|
||||
self.indexes[i]
|
||||
.tree
|
||||
.insert(&k, pack_position(page_id, slot))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -269,6 +360,15 @@ impl HeapTable {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_hidden_columns(&mut self, columns: HashSet<String>) {
|
||||
self.hidden_columns = columns;
|
||||
}
|
||||
|
||||
pub fn hide_column(&mut self, column: &str) {
|
||||
self.detach_index(column);
|
||||
self.hidden_columns.insert(column.to_string());
|
||||
}
|
||||
|
||||
/// 插入一行,并顺手写各索引
|
||||
pub fn insert_row(&mut self, row: &Row) -> io::Result<()> {
|
||||
self.insert_row_returning_position(row).map(|_| ())
|
||||
@@ -276,15 +376,15 @@ impl HeapTable {
|
||||
|
||||
/// 插入一行并返回它的位置
|
||||
pub fn insert_row_returning_position(&mut self, row: &Row) -> io::Result<(PageId, u16)> {
|
||||
let mut pending: Vec<(usize, i64)> = Vec::new();
|
||||
let mut pending: Vec<(usize, Vec<u8>)> = Vec::new();
|
||||
for (i, idx) in self.indexes.iter_mut().enumerate() {
|
||||
let Some(k) = row_int(row, &idx.column) else {
|
||||
let Some(k) = row_key(row, &idx.column) else {
|
||||
continue;
|
||||
};
|
||||
if idx.tree.get(k)?.is_some() {
|
||||
if idx.column == "id" && idx.tree.get(&k)?.is_some() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("duplicate key {} on indexed column \"{}\"", k, idx.column),
|
||||
"duplicate value on the id index".to_string(),
|
||||
));
|
||||
}
|
||||
pending.push((i, k));
|
||||
@@ -293,7 +393,7 @@ impl HeapTable {
|
||||
let (page_id, slot) = self.insert_returning_position(row)?;
|
||||
let pos = pack_position(page_id, slot);
|
||||
for (i, k) in pending {
|
||||
self.indexes[i].tree.insert(k, pos)?;
|
||||
self.indexes[i].tree.insert(&k, pos)?;
|
||||
}
|
||||
Ok((page_id, slot))
|
||||
}
|
||||
@@ -306,17 +406,22 @@ impl HeapTable {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入一行,只进堆表、不碰索引
|
||||
fn insert_returning_position(&mut self, row: &Row) -> io::Result<(PageId, u16)> {
|
||||
/// 编码并验证一行能放进页
|
||||
pub fn encode_row(row: &Row, page_size: usize) -> io::Result<Vec<u8>> {
|
||||
let tuple = serde_json::to_vec(row).map_err(io::Error::other)?;
|
||||
|
||||
if HEADER_SIZE + SLOT_SIZE + tuple.len() > self.file.page_size() {
|
||||
if HEADER_SIZE + SLOT_SIZE + tuple.len() > page_size {
|
||||
return Err(io::Error::other(format!(
|
||||
"tuple too large: {} bytes",
|
||||
tuple.len()
|
||||
)));
|
||||
}
|
||||
Ok(tuple)
|
||||
}
|
||||
|
||||
/// 插入一行,只进堆表、不碰索引
|
||||
fn insert_returning_position(&mut self, row: &Row) -> io::Result<(PageId, u16)> {
|
||||
let tuple = Self::encode_row(row, self.file.page_size())?;
|
||||
let n = self.file.num_pages()?;
|
||||
if n > 0 {
|
||||
let last_id = n - 1;
|
||||
@@ -341,16 +446,84 @@ impl HeapTable {
|
||||
self.get_by_column_key("id", key)
|
||||
}
|
||||
|
||||
/// 按某一列的索引取一行
|
||||
/// 按某一列的整数索引取一行
|
||||
pub fn get_by_column_key(&mut self, column: &str, key: i64) -> io::Result<Option<Row>> {
|
||||
self.get_by_bytes(column, &key_of(key))
|
||||
}
|
||||
|
||||
/// 按某一列的字符串索引取一行
|
||||
pub fn get_by_string_key(&mut self, column: &str, key: &str) -> io::Result<Option<Row>> {
|
||||
self.get_by_bytes(column, key.as_bytes())
|
||||
}
|
||||
|
||||
/// 按某一列的整数索引取全部行
|
||||
pub fn get_all_by_column_key(&mut self, column: &str, key: i64) -> io::Result<Vec<Row>> {
|
||||
self.get_all_by_bytes(column, &key_of(key))
|
||||
}
|
||||
|
||||
/// 按某一列的字符串索引取全部行
|
||||
pub fn get_all_by_string_key(&mut self, column: &str, key: &str) -> io::Result<Vec<Row>> {
|
||||
self.get_all_by_bytes(column, key.as_bytes())
|
||||
}
|
||||
|
||||
/// 按索引键的字节取第一行
|
||||
fn get_by_bytes(&mut self, column: &str, key: &[u8]) -> io::Result<Option<Row>> {
|
||||
Ok(self.get_all_by_bytes(column, key)?.into_iter().next())
|
||||
}
|
||||
|
||||
/// 按索引键的字节取全部行(重复值都给出来)
|
||||
fn get_all_by_bytes(&mut self, column: &str, key: &[u8]) -> io::Result<Vec<Row>> {
|
||||
let Some(idx) = self.indexes.iter_mut().find(|i| i.column == column) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let positions = idx.tree.get_all(key)?;
|
||||
let mut rows = Vec::new();
|
||||
for pos in positions {
|
||||
let (page_id, slot) = unpack_position(pos);
|
||||
if let Some(row) = self.read_at(page_id, slot)? {
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// 范围扫描:某一列有索引就走叶子链,没有索引返回 None(上层退回全表扫描)
|
||||
pub fn scan_range(
|
||||
&mut self,
|
||||
column: &str,
|
||||
lo: Option<(&serde_json::Value, bool)>,
|
||||
hi: Option<(&serde_json::Value, bool)>,
|
||||
) -> io::Result<Option<Vec<Row>>> {
|
||||
let Some(i) = self.indexes.iter().position(|idx| idx.column == column) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(pos) = idx.tree.get(key)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (page_id, slot) = unpack_position(pos);
|
||||
self.read_at(page_id, slot)
|
||||
let lo_bound = lo.and_then(|(v, inclusive)| value_key(v).map(|k| (k, inclusive)));
|
||||
let hi_bound = hi.and_then(|(v, inclusive)| value_key(v).map(|k| (k, inclusive)));
|
||||
// 树按闭区间取,排他端在取回来之后再筛
|
||||
let pairs = self.indexes[i].tree.scan(
|
||||
lo_bound.as_ref().map(|(k, _)| k.as_slice()),
|
||||
hi_bound.as_ref().map(|(k, _)| k.as_slice()),
|
||||
)?;
|
||||
let mut rows = Vec::new();
|
||||
for (key, pos) in pairs {
|
||||
if let Some((bound, inclusive)) = &lo_bound {
|
||||
let outside = if *inclusive { &key < bound } else { &key <= bound };
|
||||
if outside {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some((bound, inclusive)) = &hi_bound {
|
||||
let outside = if *inclusive { &key > bound } else { &key >= bound };
|
||||
if outside {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let (page_id, slot) = unpack_position(pos);
|
||||
if let Some(row) = self.read_at(page_id, slot)? {
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
Ok(Some(rows))
|
||||
}
|
||||
|
||||
/// 按位置读一行
|
||||
@@ -359,7 +532,7 @@ impl HeapTable {
|
||||
match bytes {
|
||||
None => Ok(None),
|
||||
Some(bytes) => {
|
||||
let row: Row = serde_json::from_slice(&bytes).map_err(io::Error::other)?;
|
||||
let row = decode_row(&bytes, None, &self.hidden_columns)?;
|
||||
Ok(Some(row))
|
||||
}
|
||||
}
|
||||
@@ -373,9 +546,10 @@ impl HeapTable {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(r) = &row {
|
||||
let pos = pack_position(page_id, slot);
|
||||
for idx in &mut self.indexes {
|
||||
if let Some(k) = row_int(r, &idx.column) {
|
||||
idx.tree.delete(k)?;
|
||||
if let Some(k) = row_key(r, &idx.column) {
|
||||
idx.tree.delete(&k, pos)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -392,7 +566,7 @@ impl HeapTable {
|
||||
.indexes
|
||||
.iter_mut()
|
||||
.find(|i| i.column == "id")
|
||||
.and_then(|i| i.tree.get(k).ok().flatten());
|
||||
.and_then(|i| i.tree.get(&key_of(k)).ok().flatten());
|
||||
if let Some(p) = pos {
|
||||
positions.push(unpack_position(p));
|
||||
}
|
||||
@@ -401,10 +575,8 @@ impl HeapTable {
|
||||
let mut wanted: Vec<i64> = keys.to_vec();
|
||||
wanted.sort_unstable();
|
||||
for (page_id, slot, r) in self.scan_with_positions()? {
|
||||
if let Some(k) = row_int(&r, "id") {
|
||||
if wanted.binary_search(&k).is_ok() {
|
||||
positions.push((page_id, slot));
|
||||
}
|
||||
if row_int(&r, "id").is_some_and(|k| wanted.binary_search(&k).is_ok()) {
|
||||
positions.push((page_id, slot));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -420,8 +592,12 @@ impl HeapTable {
|
||||
|
||||
/// 全表扫描
|
||||
pub fn scan(&mut self) -> io::Result<Vec<Row>> {
|
||||
self.scan_columns(None)
|
||||
}
|
||||
|
||||
pub fn scan_columns(&mut self, columns: Option<&[String]>) -> io::Result<Vec<Row>> {
|
||||
Ok(self
|
||||
.scan_with_positions()?
|
||||
.scan_projected_with_positions(columns)?
|
||||
.into_iter()
|
||||
.map(|(_, _, r)| r)
|
||||
.collect())
|
||||
@@ -429,6 +605,10 @@ impl HeapTable {
|
||||
|
||||
/// 全表扫描,同时给出每行的位置(建索引用)
|
||||
pub fn scan_with_positions(&mut self) -> io::Result<Vec<(PageId, u16, Row)>> {
|
||||
self.scan_projected_with_positions(None)
|
||||
}
|
||||
|
||||
fn scan_projected_with_positions(&mut self, columns: Option<&[String]>) -> io::Result<Vec<(PageId, u16, Row)>> {
|
||||
let n = self.file.num_pages()?;
|
||||
let mut out = Vec::new();
|
||||
for i in 0..n {
|
||||
@@ -442,7 +622,7 @@ impl HeapTable {
|
||||
v
|
||||
})?;
|
||||
for (slot, bytes) in tuples {
|
||||
let row: Row = serde_json::from_slice(&bytes).map_err(io::Error::other)?;
|
||||
let row = decode_row(&bytes, columns, &self.hidden_columns)?;
|
||||
out.push((i, slot, row));
|
||||
}
|
||||
}
|
||||
@@ -459,8 +639,8 @@ impl HeapTable {
|
||||
let (page_id, slot) = self.insert_returning_position(r)?;
|
||||
let pos = pack_position(page_id, slot);
|
||||
for idx in &mut self.indexes {
|
||||
if let Some(k) = row_int(r, &idx.column) {
|
||||
idx.tree.insert(k, pos)?;
|
||||
if let Some(k) = row_key(r, &idx.column) {
|
||||
idx.tree.insert(&k, pos)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! `scripts/init.sql` 的最小读取:只认 CREATE TABLE 与 CREATE INDEX。
|
||||
//! 系统库的 schema 由这份脚本声明,存储进程按它建系统表。
|
||||
|
||||
use crate::protocol::SchemaColumn;
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
/// 一行一列:`name type [NOT NULL] [AUTO_INCREMENT] [PRIMARY KEY] [UNIQUE]`
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
pub mod btree;
|
||||
pub mod config;
|
||||
pub mod endpoint;
|
||||
pub mod heap;
|
||||
pub mod initsql;
|
||||
pub mod log;
|
||||
pub mod page;
|
||||
pub mod protocol;
|
||||
|
||||
+29
-29
@@ -82,7 +82,9 @@ impl Level {
|
||||
|
||||
static LEVEL: AtomicU8 = AtomicU8::new(Level::Info as u8);
|
||||
|
||||
static COLOR: OnceLock<bool> = OnceLock::new();
|
||||
static COLOR_STDERR: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
static COLOR_STDOUT: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
/// 设置全局等级
|
||||
pub fn set_level(level: Level) {
|
||||
@@ -100,22 +102,29 @@ pub fn init() {
|
||||
enable_vt();
|
||||
}
|
||||
|
||||
/// 写出一行
|
||||
/// 写出一行:warn / error 走 stderr,info / debug 走 stdout。
|
||||
pub fn write(channel: Channel, level: Level, args: fmt::Arguments) {
|
||||
let msg = args.to_string();
|
||||
let line = render(level, channel, color_enabled(), &now(), &msg);
|
||||
let stderr = std::io::stderr();
|
||||
let mut out = stderr.lock();
|
||||
let _ = writeln!(out, "{}", line);
|
||||
let to_stderr = streams_to_stderr(level);
|
||||
let line = render(level, channel, color_enabled(to_stderr), &now(), &msg);
|
||||
if to_stderr {
|
||||
let mut out = std::io::stderr().lock();
|
||||
let _ = writeln!(out, "{}", line);
|
||||
} else {
|
||||
let mut out = std::io::stdout().lock();
|
||||
let _ = writeln!(out, "{}", line);
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否着色(终端或强制)
|
||||
fn color_enabled() -> bool {
|
||||
*COLOR.get_or_init(|| match std::env::var("CHUSQL_LOG_COLOR").as_deref().map(str::trim) {
|
||||
Ok("always") | Ok("yes") | Ok("1") => true,
|
||||
Ok("never") | Ok("no") | Ok("0") => false,
|
||||
_ => std::io::stderr().is_terminal(),
|
||||
})
|
||||
/// 这个等级写 stderr 吗:warn / error 是,info / debug 写 stdout
|
||||
fn streams_to_stderr(level: Level) -> bool {
|
||||
matches!(level, Level::Error | Level::Warn)
|
||||
}
|
||||
|
||||
/// 是否着色:只按要写的那条流是不是终端判断,没有环境变量开关
|
||||
fn color_enabled(to_stderr: bool) -> bool {
|
||||
let cached = if to_stderr { &COLOR_STDERR } else { &COLOR_STDOUT };
|
||||
*cached.get_or_init(|| if to_stderr { std::io::stderr().is_terminal() } else { std::io::stdout().is_terminal() })
|
||||
}
|
||||
|
||||
/// 拼一行(纯函数,可测)
|
||||
@@ -203,8 +212,8 @@ fn now() -> Stamp {
|
||||
let (year, month, day) = civil_from_days((secs / 86_400) as i64);
|
||||
Stamp {
|
||||
year: year as u16,
|
||||
month: month as u16,
|
||||
day: day as u16,
|
||||
month,
|
||||
day,
|
||||
hour: ((secs % 86_400) / 3600) as u16,
|
||||
minute: ((secs % 3_600) / 60) as u16,
|
||||
second: (secs % 60) as u16,
|
||||
@@ -346,19 +355,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_without_color_is_plain() {
|
||||
let stamp = Stamp {
|
||||
year: 2026,
|
||||
month: 9,
|
||||
day: 24,
|
||||
hour: 1,
|
||||
minute: 2,
|
||||
second: 3,
|
||||
milli: 4,
|
||||
};
|
||||
assert_eq!(
|
||||
render(Level::Error, Channel::Core, false, &stamp, "boom"),
|
||||
"[ERROR 2026-09-24 01:02:03.004] [core] boom"
|
||||
);
|
||||
fn warnings_and_errors_go_to_stderr() {
|
||||
assert!(streams_to_stderr(Level::Error));
|
||||
assert!(streams_to_stderr(Level::Warn));
|
||||
assert!(!streams_to_stderr(Level::Info));
|
||||
assert!(!streams_to_stderr(Level::Debug));
|
||||
}
|
||||
}
|
||||
@@ -5,18 +5,55 @@ use std::collections::HashMap;
|
||||
|
||||
pub type Row = HashMap<String, serde_json::Value>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ColumnType {
|
||||
Int,
|
||||
Str,
|
||||
Bool,
|
||||
pub const USERS_TABLE: &str = "__chusql_users";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Account {
|
||||
pub id: i64,
|
||||
pub user: String,
|
||||
pub password_hash: String,
|
||||
pub revision: u64,
|
||||
#[serde(default)]
|
||||
pub registered_at: String,
|
||||
#[serde(default)]
|
||||
pub last_login_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SchemaColumn {
|
||||
pub name: String,
|
||||
pub ty: ColumnType,
|
||||
pub ty: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub nullable: bool,
|
||||
#[serde(default)]
|
||||
pub default: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub auto_increment: bool,
|
||||
#[serde(default)]
|
||||
pub primary_key: bool,
|
||||
#[serde(default)]
|
||||
pub unique: bool,
|
||||
#[serde(default)]
|
||||
pub check: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for SchemaColumn {
|
||||
fn default() -> Self {
|
||||
SchemaColumn {
|
||||
name: String::new(),
|
||||
ty: String::new(),
|
||||
nullable: true,
|
||||
default: None,
|
||||
auto_increment: false,
|
||||
primary_key: false,
|
||||
unique: false,
|
||||
check: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -35,9 +72,20 @@ pub struct IndexWire {
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "method", rename_all = "snake_case")]
|
||||
pub enum Request {
|
||||
AccountsList,
|
||||
AccountCreate { user: String, password_hash: String },
|
||||
AccountReset { user: String, password_hash: String },
|
||||
AccountLogin {
|
||||
user: String,
|
||||
#[serde(default)]
|
||||
at: Option<String>,
|
||||
},
|
||||
AccountDrop { user: String },
|
||||
Ping,
|
||||
Scan {
|
||||
table: String,
|
||||
#[serde(default)]
|
||||
columns: Option<Vec<String>>,
|
||||
},
|
||||
Insert {
|
||||
table: String,
|
||||
@@ -55,7 +103,19 @@ pub enum Request {
|
||||
table: String,
|
||||
#[serde(default = "default_id_column")]
|
||||
column: String,
|
||||
key: i64,
|
||||
key: serde_json::Value,
|
||||
},
|
||||
RangeByIndex {
|
||||
table: String,
|
||||
column: String,
|
||||
#[serde(default)]
|
||||
lo: Option<serde_json::Value>,
|
||||
#[serde(default = "default_true")]
|
||||
lo_inclusive: bool,
|
||||
#[serde(default)]
|
||||
hi: Option<serde_json::Value>,
|
||||
#[serde(default = "default_true")]
|
||||
hi_inclusive: bool,
|
||||
},
|
||||
ReplaceAll {
|
||||
table: String,
|
||||
@@ -85,6 +145,51 @@ pub enum Request {
|
||||
table: String,
|
||||
column: String,
|
||||
},
|
||||
Compact {
|
||||
table: String,
|
||||
},
|
||||
ReplaceSchema {
|
||||
table: String,
|
||||
columns: Vec<SchemaColumn>,
|
||||
rows: Vec<Row>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// 拒绝可能影响文件路径的列名
|
||||
pub fn valid_columns(&self) -> bool {
|
||||
let valid = |name: &str| !name.is_empty()
|
||||
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
|
||||
match self {
|
||||
Self::CreateIndex { column, .. } | Self::DropIndex { column, .. }
|
||||
| Self::DropColumn { column, .. } | Self::LookupByIndex { column, .. }
|
||||
| Self::RangeByIndex { column, .. } => valid(column),
|
||||
Self::CreateTable { columns, .. } => columns.iter().all(|c| valid(&c.name)),
|
||||
Self::ReplaceSchema { columns, .. } => columns.iter().all(|c| valid(&c.name)),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
/// 普通表操作的统一访问边界
|
||||
pub fn table(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Scan { table, .. } | Self::Insert { table, .. }
|
||||
| Self::InsertBatch { table, .. } | Self::DeleteKeys { table, .. }
|
||||
| Self::LookupByIndex { table, .. } | Self::ReplaceAll { table, .. }
|
||||
| Self::RangeByIndex { table, .. }
|
||||
| Self::DescribeTable { table } | Self::CreateTable { table, .. }
|
||||
| Self::DropTable { table } | Self::CreateIndex { table, .. }
|
||||
| Self::DropIndex { table, .. } | Self::DropColumn { table, .. }
|
||||
| Self::Compact { table }
|
||||
| Self::ReplaceSchema { table, .. } => Some(table),
|
||||
Self::Ping | Self::ListTables | Self::ListCatalog | Self::AccountsList
|
||||
| Self::AccountCreate { .. } | Self::AccountReset { .. } | Self::AccountLogin { .. }
|
||||
| Self::AccountDrop { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reserved_table(table: &str) -> bool {
|
||||
table.to_ascii_lowercase().starts_with("__chusql_")
|
||||
}
|
||||
|
||||
/// 没写 column 时按 `id` 算
|
||||
@@ -95,6 +200,7 @@ fn default_id_column() -> String {
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum Response {
|
||||
Accounts { accounts: Vec<Account> },
|
||||
Pong,
|
||||
Rows { rows: Vec<Row> },
|
||||
Tables { tables: Vec<String> },
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::Mutex;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::protocol::Row;
|
||||
use crate::protocol::{Account, Row, SchemaColumn, USERS_TABLE};
|
||||
|
||||
// 预写日志:先记操作并 fsync,再改数据;重启重放残留。
|
||||
|
||||
@@ -14,9 +14,16 @@ const OP_REPLACE_ALL: u8 = 2;
|
||||
const OP_INSERT_BATCH: u8 = 3;
|
||||
const OP_DELETE_KEYS: u8 = 4;
|
||||
const OP_DROP_COLUMN: u8 = 5;
|
||||
const OP_ACCOUNTS: u8 = 6;
|
||||
const OP_REPLACE_SCHEMA: u8 = 7;
|
||||
const OP_HIDE_COLUMN: u8 = 8;
|
||||
const OP_COMPACT: u8 = 9;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum WalOp {
|
||||
HideColumn { table: String, column: String },
|
||||
Compact { table: String, rows: Vec<Row> },
|
||||
Accounts { accounts: Vec<Account> },
|
||||
Insert {
|
||||
table: String,
|
||||
row: Row,
|
||||
@@ -38,6 +45,11 @@ pub enum WalOp {
|
||||
column: String,
|
||||
rows: Vec<Row>,
|
||||
},
|
||||
ReplaceSchema {
|
||||
table: String,
|
||||
columns: Vec<SchemaColumn>,
|
||||
rows: Vec<Row>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -46,6 +58,12 @@ struct DropColumnPayload {
|
||||
rows: Vec<Row>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ReplaceSchemaPayload {
|
||||
columns: Vec<SchemaColumn>,
|
||||
rows: Vec<Row>,
|
||||
}
|
||||
|
||||
pub struct Wal {
|
||||
path: PathBuf,
|
||||
handle: Mutex<Option<File>>,
|
||||
@@ -134,6 +152,14 @@ fn truncate(f: &mut File) -> io::Result<()> {
|
||||
/// 把操作编成字节
|
||||
fn encode(op: &WalOp) -> io::Result<Vec<u8>> {
|
||||
let (op_type, table, payload) = match op {
|
||||
WalOp::HideColumn { table, column } => (OP_HIDE_COLUMN, table.clone(),
|
||||
serde_json::to_vec(column).map_err(io::Error::other)?),
|
||||
WalOp::Compact { table, rows } => {
|
||||
let p = serde_json::to_vec(rows).map_err(io::Error::other)?;
|
||||
(OP_COMPACT, table.clone(), p)
|
||||
}
|
||||
WalOp::Accounts { accounts } => (OP_ACCOUNTS, USERS_TABLE.to_string(),
|
||||
serde_json::to_vec(accounts).map_err(io::Error::other)?),
|
||||
WalOp::Insert { table, row } => {
|
||||
let p = serde_json::to_vec(row).map_err(io::Error::other)?;
|
||||
(OP_INSERT, table.clone(), p)
|
||||
@@ -158,6 +184,14 @@ fn encode(op: &WalOp) -> io::Result<Vec<u8>> {
|
||||
.map_err(io::Error::other)?;
|
||||
(OP_DROP_COLUMN, table.clone(), p)
|
||||
}
|
||||
WalOp::ReplaceSchema { table, columns, rows } => {
|
||||
let p = serde_json::to_vec(&ReplaceSchemaPayload {
|
||||
columns: columns.clone(),
|
||||
rows: rows.clone(),
|
||||
})
|
||||
.map_err(io::Error::other)?;
|
||||
(OP_REPLACE_SCHEMA, table.clone(), p)
|
||||
}
|
||||
};
|
||||
|
||||
let table_bytes = table.as_bytes();
|
||||
@@ -202,6 +236,22 @@ fn decode(buf: &[u8]) -> Result<Option<WalOp>, String> {
|
||||
let payload = &body[11 + table_len..];
|
||||
|
||||
let op = match op_type {
|
||||
OP_HIDE_COLUMN => {
|
||||
let column = serde_json::from_slice(payload)
|
||||
.map_err(|e| format!("bad hide_column payload: {e}"))?;
|
||||
WalOp::HideColumn { table, column }
|
||||
}
|
||||
OP_COMPACT => {
|
||||
let rows: Vec<Row> = serde_json::from_slice(payload)
|
||||
.map_err(|e| format!("bad compact payload: {e}"))?;
|
||||
WalOp::Compact { table, rows }
|
||||
}
|
||||
OP_ACCOUNTS => {
|
||||
if table != USERS_TABLE { return Err("invalid account WAL table".into()); }
|
||||
let accounts = serde_json::from_slice(payload)
|
||||
.map_err(|e| format!("bad account payload: {e}"))?;
|
||||
WalOp::Accounts { accounts }
|
||||
}
|
||||
OP_INSERT => {
|
||||
let row: Row =
|
||||
serde_json::from_slice(payload).map_err(|e| format!("bad insert payload: {}", e))?;
|
||||
@@ -231,6 +281,15 @@ fn decode(buf: &[u8]) -> Result<Option<WalOp>, String> {
|
||||
rows: p.rows,
|
||||
}
|
||||
}
|
||||
OP_REPLACE_SCHEMA => {
|
||||
let p: ReplaceSchemaPayload = serde_json::from_slice(payload)
|
||||
.map_err(|e| format!("bad replace_schema payload: {}", e))?;
|
||||
WalOp::ReplaceSchema {
|
||||
table,
|
||||
columns: p.columns,
|
||||
rows: p.rows,
|
||||
}
|
||||
}
|
||||
other => return Err(format!("unknown op type: {}", other)),
|
||||
};
|
||||
Ok(Some(op))
|
||||
|
||||
+147
-102
@@ -1,21 +1,24 @@
|
||||
use chusql_storage::btree::DiskBTree;
|
||||
use chusql_storage::btree::{fits_in_page, DiskBTree, MAX_KEY_BYTES};
|
||||
use chusql_storage::config::{DEFAULT_BTREE_ORDER, DEFAULT_PAGE_SIZE};
|
||||
use chusql_storage::page::DEFAULT_POOL_SIZE;
|
||||
|
||||
// B+ 树测试:插查、重开、分裂、页大小与 order 校验。
|
||||
// B+ 树测试:字节键、重复键、范围扫描、重开与配置校验。
|
||||
|
||||
fn open(dir: &std::path::Path, name: &str) -> DiskBTree {
|
||||
DiskBTree::open(dir.join(name), DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).unwrap()
|
||||
}
|
||||
|
||||
/// 整数键编码成保序的 8 字节(与堆表一致)
|
||||
fn key(n: i64) -> [u8; 8] {
|
||||
(n as u64 ^ (1u64 << 63)).to_be_bytes()
|
||||
}
|
||||
|
||||
/// 空树查不到,遍历为空
|
||||
#[test]
|
||||
fn empty_tree_get() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = DiskBTree::open(
|
||||
dir.path().join("t.idx"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
DEFAULT_BTREE_ORDER,
|
||||
DEFAULT_POOL_SIZE,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(t.get(1).unwrap(), None);
|
||||
let mut t = open(dir.path(), "t.idx");
|
||||
assert_eq!(t.get(&key(1)).unwrap(), None);
|
||||
assert_eq!(t.iter_all().unwrap(), vec![]);
|
||||
}
|
||||
|
||||
@@ -23,18 +26,12 @@ fn empty_tree_get() {
|
||||
#[test]
|
||||
fn insert_then_get() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = DiskBTree::open(
|
||||
dir.path().join("t.idx"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
DEFAULT_BTREE_ORDER,
|
||||
DEFAULT_POOL_SIZE,
|
||||
)
|
||||
.unwrap();
|
||||
let mut t = open(dir.path(), "t.idx");
|
||||
for i in 0..100 {
|
||||
t.insert(i, i as u64 * 10).unwrap();
|
||||
t.insert(&key(i), i as u64 * 10).unwrap();
|
||||
}
|
||||
for i in 0..100 {
|
||||
assert_eq!(t.get(i).unwrap(), Some(i as u64 * 10));
|
||||
assert_eq!(t.get(&key(i)).unwrap(), Some(i as u64 * 10));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,81 +43,42 @@ fn reopen_persists() {
|
||||
{
|
||||
let mut t = DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).unwrap();
|
||||
for i in 0..50 {
|
||||
t.insert(i, i as u64).unwrap();
|
||||
t.insert(&key(i), i as u64).unwrap();
|
||||
}
|
||||
}
|
||||
let mut t = DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).unwrap();
|
||||
for i in 0..50 {
|
||||
assert_eq!(t.get(i).unwrap(), Some(i as u64));
|
||||
assert_eq!(t.get(&key(i)).unwrap(), Some(i as u64));
|
||||
}
|
||||
}
|
||||
|
||||
/// 同一个键后写的值生效
|
||||
/// 同一个键可以挂多个位置,按值升序返回
|
||||
#[test]
|
||||
fn update_existing() {
|
||||
fn duplicate_keys_keep_every_value() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = DiskBTree::open(
|
||||
dir.path().join("t.idx"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
DEFAULT_BTREE_ORDER,
|
||||
DEFAULT_POOL_SIZE,
|
||||
)
|
||||
.unwrap();
|
||||
t.insert(5, 100).unwrap();
|
||||
t.insert(5, 200).unwrap();
|
||||
assert_eq!(t.get(5).unwrap(), Some(200));
|
||||
let mut t = open(dir.path(), "dup.idx");
|
||||
t.insert(b"same", 30).unwrap();
|
||||
t.insert(b"same", 10).unwrap();
|
||||
t.insert(b"same", 20).unwrap();
|
||||
assert_eq!(t.get_all(b"same").unwrap(), vec![10, 20, 30]);
|
||||
assert_eq!(t.get(b"same").unwrap(), Some(10));
|
||||
assert!(t.delete(b"same", 10).unwrap());
|
||||
assert_eq!(t.get_all(b"same").unwrap(), vec![20, 30]);
|
||||
assert!(!t.delete(b"same", 10).unwrap());
|
||||
}
|
||||
|
||||
/// 倒序插入后遍历仍升序
|
||||
#[test]
|
||||
fn iter_all_sorted() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = DiskBTree::open(
|
||||
dir.path().join("t.idx"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
DEFAULT_BTREE_ORDER,
|
||||
DEFAULT_POOL_SIZE,
|
||||
)
|
||||
.unwrap();
|
||||
let mut t = open(dir.path(), "t.idx");
|
||||
for i in (0..50).rev() {
|
||||
t.insert(i, i as u64).unwrap();
|
||||
t.insert(&key(i), i as u64).unwrap();
|
||||
}
|
||||
let all = t.iter_all().unwrap();
|
||||
assert_eq!(all.len(), 50);
|
||||
for (i, (k, _)) in all.iter().enumerate() {
|
||||
assert_eq!(*k, i as i64);
|
||||
}
|
||||
}
|
||||
|
||||
/// 倒序插 500 个键都能查到
|
||||
#[test]
|
||||
fn reverse_order_insert() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = DiskBTree::open(
|
||||
dir.path().join("t.idx"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
DEFAULT_BTREE_ORDER,
|
||||
DEFAULT_POOL_SIZE,
|
||||
)
|
||||
.unwrap();
|
||||
for i in (0..500).rev() {
|
||||
t.insert(i, i as u64).unwrap();
|
||||
}
|
||||
for i in 0..500 {
|
||||
assert_eq!(t.get(i).unwrap(), Some(i as u64));
|
||||
}
|
||||
}
|
||||
|
||||
/// 512 字节页 + 8 个孩子可用
|
||||
#[test]
|
||||
fn custom_page_size_and_order() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = DiskBTree::open(dir.path().join("t.idx"), 512, 8, DEFAULT_POOL_SIZE).unwrap();
|
||||
for i in 0..200 {
|
||||
t.insert(i, i as u64 + 1).unwrap();
|
||||
}
|
||||
for i in 0..200 {
|
||||
assert_eq!(t.get(i).unwrap(), Some(i as u64 + 1));
|
||||
assert_eq!(k.as_slice(), key(i as i64).as_slice());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +89,7 @@ fn config_mismatch_errors() {
|
||||
let path = dir.path().join("t.idx");
|
||||
{
|
||||
let mut t = DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).unwrap();
|
||||
t.insert(1, 1).unwrap();
|
||||
t.insert(&key(1), 1).unwrap();
|
||||
}
|
||||
assert!(DiskBTree::open(&path, DEFAULT_PAGE_SIZE, 8, DEFAULT_POOL_SIZE).is_err());
|
||||
assert!(DiskBTree::open(&path, 8192, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).is_err());
|
||||
@@ -152,60 +110,147 @@ fn not_an_index_file_errors() {
|
||||
fn order_too_large_for_page_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(DiskBTree::open(dir.path().join("t.idx"), 512, 300, DEFAULT_POOL_SIZE).is_err());
|
||||
assert!(!fits_in_page(64, DEFAULT_BTREE_ORDER));
|
||||
}
|
||||
|
||||
/// 删掉键之后查不到,别人不受影响
|
||||
/// 删掉一个键值之后查不到,别人不受影响
|
||||
#[test]
|
||||
fn delete_removes_only_that_key() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = DiskBTree::open(
|
||||
dir.path().join("t.idx"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
DEFAULT_BTREE_ORDER,
|
||||
DEFAULT_POOL_SIZE,
|
||||
)
|
||||
.unwrap();
|
||||
let mut t = open(dir.path(), "t.idx");
|
||||
for i in 0..200 {
|
||||
t.insert(i, i as u64 + 1).unwrap();
|
||||
t.insert(&key(i), i as u64 + 1).unwrap();
|
||||
}
|
||||
|
||||
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(&key(50), 51).unwrap(), "deleting an existing entry should return true");
|
||||
assert_eq!(t.get(&key(50)).unwrap(), None);
|
||||
assert_eq!(t.get(&key(49)).unwrap(), Some(50));
|
||||
assert_eq!(t.get(&key(51)).unwrap(), Some(52));
|
||||
|
||||
assert!(!t.delete(50).unwrap());
|
||||
assert!(!t.delete(&key(50), 51).unwrap());
|
||||
|
||||
let all = t.iter_all().unwrap();
|
||||
assert_eq!(all.len(), 199);
|
||||
assert!(all.iter().all(|(k, _)| *k != 50));
|
||||
assert!(all.iter().all(|(k, _)| k.as_slice() != key(50).as_slice()));
|
||||
}
|
||||
|
||||
/// 删过之后树还能继续插
|
||||
#[test]
|
||||
fn insert_after_delete_still_works() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = DiskBTree::open(
|
||||
dir.path().join("t.idx"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
DEFAULT_BTREE_ORDER,
|
||||
DEFAULT_POOL_SIZE,
|
||||
)
|
||||
.unwrap();
|
||||
let mut t = open(dir.path(), "t.idx");
|
||||
for i in 0..300 {
|
||||
t.insert(i, i as u64).unwrap();
|
||||
t.insert(&key(i), i as u64).unwrap();
|
||||
}
|
||||
for i in (0..300).step_by(2) {
|
||||
assert!(t.delete(i).unwrap());
|
||||
assert!(t.delete(&key(i), i as u64).unwrap());
|
||||
}
|
||||
for i in 1000..1100 {
|
||||
t.insert(i, i as u64).unwrap();
|
||||
t.insert(&key(i), i as u64).unwrap();
|
||||
}
|
||||
for i in 0..300 {
|
||||
let want = if i % 2 == 0 { None } else { Some(i as u64) };
|
||||
assert_eq!(t.get(i).unwrap(), want, "key {}", i);
|
||||
assert_eq!(t.get(&key(i)).unwrap(), want, "key {}", i);
|
||||
}
|
||||
for i in 1000..1100 {
|
||||
assert_eq!(t.get(i).unwrap(), Some(i as u64));
|
||||
assert_eq!(t.get(&key(i)).unwrap(), Some(i as u64));
|
||||
}
|
||||
}
|
||||
|
||||
/// 变长键能插能查,最长键也支持
|
||||
#[test]
|
||||
fn variable_length_keys_round_trip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = open(dir.path(), "var.idx");
|
||||
t.insert(b"a", 1).unwrap();
|
||||
t.insert(b"alice", 2).unwrap();
|
||||
t.insert(&[b'z'; MAX_KEY_BYTES], 3).unwrap();
|
||||
t.flush().unwrap();
|
||||
|
||||
assert_eq!(t.get(b"a").unwrap(), Some(1));
|
||||
assert_eq!(t.get(b"alice").unwrap(), Some(2));
|
||||
assert_eq!(t.get(b"bob").unwrap(), None);
|
||||
assert_eq!(t.get(&[b'z'; MAX_KEY_BYTES]).unwrap(), Some(3));
|
||||
assert_eq!(t.get(&[b'z'; MAX_KEY_BYTES - 1]).unwrap(), None);
|
||||
}
|
||||
|
||||
/// 超出键长上限直接报错,不写坏节点
|
||||
#[test]
|
||||
fn oversized_key_is_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = open(dir.path(), "long.idx");
|
||||
let too_long = vec![b'k'; MAX_KEY_BYTES + 1];
|
||||
assert!(t.insert(&too_long, 1).is_err());
|
||||
assert_eq!(t.get(&too_long).unwrap(), None);
|
||||
}
|
||||
|
||||
/// 插满一页后会分裂,顺序仍然正确
|
||||
#[test]
|
||||
fn splits_keep_keys_sorted() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = open(dir.path(), "split.idx");
|
||||
|
||||
let mut expected: Vec<Vec<u8>> = Vec::new();
|
||||
for i in 0..600 {
|
||||
let k = format!("key-{:04}", (i * 7919) % 600).into_bytes();
|
||||
t.insert(&k, i as u64).unwrap();
|
||||
expected.push(k);
|
||||
}
|
||||
expected.sort();
|
||||
expected.dedup();
|
||||
|
||||
let all: Vec<Vec<u8>> = t.iter_all().unwrap().into_iter().map(|(k, _)| k).collect();
|
||||
assert_eq!(all, expected);
|
||||
for k in &expected {
|
||||
assert!(t.get(k).unwrap().is_some(), "missing {:?}", String::from_utf8_lossy(k));
|
||||
}
|
||||
}
|
||||
|
||||
/// 范围扫描走叶子链,两端都是闭区间
|
||||
#[test]
|
||||
fn range_scan_walks_the_leaf_chain() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = open(dir.path(), "range.idx");
|
||||
for word in ["apple", "banana", "cherry", "date", "elderberry"] {
|
||||
t.insert(word.as_bytes(), word.len() as u64).unwrap();
|
||||
}
|
||||
|
||||
let mid: Vec<String> = t
|
||||
.scan(Some(b"banana"), Some(b"date"))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(k, _)| String::from_utf8(k).unwrap())
|
||||
.collect();
|
||||
assert_eq!(mid, vec!["banana", "cherry", "date"]);
|
||||
|
||||
let from: Vec<String> = t
|
||||
.scan(Some(b"cherry"), None)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(k, _)| String::from_utf8(k).unwrap())
|
||||
.collect();
|
||||
assert_eq!(from, vec!["cherry", "date", "elderberry"]);
|
||||
|
||||
let until: Vec<String> = t
|
||||
.scan(None, Some(b"banana"))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(k, _)| String::from_utf8(k).unwrap())
|
||||
.collect();
|
||||
assert_eq!(until, vec!["apple", "banana"]);
|
||||
|
||||
assert!(t.scan(Some(b"z"), None).unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// 清空之后还能接着用
|
||||
#[test]
|
||||
fn clear_resets_the_tree() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = open(dir.path(), "clear.idx");
|
||||
t.insert(b"a", 1).unwrap();
|
||||
t.insert(b"b", 2).unwrap();
|
||||
t.clear().unwrap();
|
||||
assert!(t.iter_all().unwrap().is_empty());
|
||||
t.insert(b"c", 3).unwrap();
|
||||
assert_eq!(t.get(b"c").unwrap(), Some(3));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use chusql_storage::catalog::Catalog;
|
||||
use chusql_storage::protocol::{ColumnType, Row};
|
||||
use chusql_storage::protocol::Row;
|
||||
use serde_json::json;
|
||||
|
||||
// 数据字典测试:补列、去重、未知表。
|
||||
@@ -14,13 +14,13 @@ fn row(pairs: &[(&str, serde_json::Value)]) -> Row {
|
||||
m
|
||||
}
|
||||
|
||||
/// 空文件得到空字典
|
||||
/// 空文件得到空字典,未知表返回 None
|
||||
#[test]
|
||||
fn empty_file_gives_empty_catalog() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fn empty_file_gives_empty_catalog_and_unknown_table_returns_none() { let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("catalog.json");
|
||||
let c = Catalog::load(&path).unwrap();
|
||||
assert_eq!(c.table_names().len(), 0);
|
||||
assert!(c.describe("nope").is_none());
|
||||
}
|
||||
|
||||
/// 补列之后能查到 schema
|
||||
@@ -40,9 +40,9 @@ fn ensure_then_describe() {
|
||||
assert!(names.contains(&"id"));
|
||||
assert!(names.contains(&"name"));
|
||||
let id_col = s.columns.iter().find(|c| c.name == "id").unwrap();
|
||||
assert_eq!(id_col.ty, ColumnType::Int);
|
||||
assert_eq!(id_col.ty, "int");
|
||||
let name_col = s.columns.iter().find(|c| c.name == "name").unwrap();
|
||||
assert_eq!(name_col.ty, ColumnType::Str);
|
||||
assert_eq!(name_col.ty, "str");
|
||||
}
|
||||
|
||||
/// 再插入不会重复列
|
||||
@@ -57,15 +57,6 @@ fn second_insert_does_not_duplicate_columns() {
|
||||
assert_eq!(c.describe("users").unwrap().columns.len(), 1);
|
||||
}
|
||||
|
||||
/// 未知表返回 None
|
||||
#[test]
|
||||
fn describe_unknown_returns_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("catalog.json");
|
||||
let c = Catalog::load(&path).unwrap();
|
||||
assert!(c.describe("nope").is_none());
|
||||
}
|
||||
|
||||
/// 删列同时清掉统计与索引
|
||||
#[test]
|
||||
fn remove_column_drops_definition_stats_and_index() {
|
||||
@@ -83,10 +74,44 @@ fn remove_column_drops_definition_stats_and_index() {
|
||||
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");
|
||||
assert!(!s.stats.contains_key("code"), "stats for code should be gone");
|
||||
|
||||
let again = c.remove_column("t", "code").unwrap();
|
||||
assert!(!again);
|
||||
|
||||
assert!(c.remove_column("nope", "code").is_err());
|
||||
}
|
||||
|
||||
/// 老类型名并到新写法,参数保留,未知类型原样
|
||||
#[test]
|
||||
fn normalize_type_maps_legacy_names() {
|
||||
use chusql_storage::catalog::normalize_type;
|
||||
assert_eq!(normalize_type("integer"), "int");
|
||||
assert_eq!(normalize_type("TEXT"), "str");
|
||||
assert_eq!(normalize_type("BOOLEAN"), "bool");
|
||||
assert_eq!(normalize_type("varchar(20)"), "varchar(20)");
|
||||
assert_eq!(normalize_type("numeric(8,2)"), "decimal(8,2)");
|
||||
assert_eq!(normalize_type("real"), "float");
|
||||
assert_eq!(normalize_type("weird"), "weird");
|
||||
}
|
||||
|
||||
/// 老格式字典(列里没有新字段)能读进来,规范化后写回新写法
|
||||
#[test]
|
||||
fn legacy_catalog_loads_and_normalizes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("catalog.json");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{"tables":{"legacy":{"columns":[{"name":"id","ty":"integer"},{"name":"name","ty":"text"}]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut c = Catalog::load(&path).unwrap();
|
||||
let s = c.describe("legacy").unwrap();
|
||||
assert_eq!(s.columns.len(), 2);
|
||||
assert!(s.columns[0].nullable, "老列默认可空");
|
||||
assert!(c.normalize_types());
|
||||
let types: Vec<&str> = c.describe("legacy").unwrap().columns.iter().map(|col| col.ty.as_str()).collect();
|
||||
assert_eq!(types, vec!["int", "str"]);
|
||||
assert!(!c.normalize_types(), "第二次没有可改的");
|
||||
}
|
||||
@@ -1,23 +1,32 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use chusql_storage::config::{
|
||||
self, Config, DEFAULT_BTREE_ORDER, DEFAULT_DATA_DIR, DEFAULT_PAGE_SIZE, DEFAULT_PIPE_NAME,
|
||||
Origin,
|
||||
self, Config, DEFAULT_BTREE_ORDER, DEFAULT_PAGE_SIZE, DEFAULT_PIPE_NAME, Origin,
|
||||
};
|
||||
use chusql_storage::log::Level;
|
||||
|
||||
// 配置测试:默认值、文件覆盖、环境变量优先、非法值报错。
|
||||
// 配置测试:默认值、文件覆盖、环境变量彻底无效、非法值报错。
|
||||
|
||||
/// set_var 改的是进程环境,用例之间要串行,免得互相污染
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// 拿锁(前面的用例 panic 过也照样拿)
|
||||
fn env_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// 无文件时全默认
|
||||
#[test]
|
||||
fn defaults_without_file() {
|
||||
let loaded = config::resolve(None, None, &|_| None).unwrap();
|
||||
let _guard = env_guard();
|
||||
let loaded = config::resolve(None, None).unwrap();
|
||||
assert_eq!(loaded.config, Config::default());
|
||||
assert!(loaded.config_path.is_none());
|
||||
assert!(loaded.origins.iter().all(|(_, _, o)| *o == Origin::Default));
|
||||
assert_eq!(loaded.config.page_size, DEFAULT_PAGE_SIZE);
|
||||
assert_eq!(loaded.config.btree_order, DEFAULT_BTREE_ORDER);
|
||||
assert_eq!(loaded.config.data_dir, PathBuf::from(DEFAULT_DATA_DIR));
|
||||
assert_eq!(loaded.config.data_dir, config::default_data_dir());
|
||||
assert_eq!(loaded.config.pipe_name, DEFAULT_PIPE_NAME);
|
||||
assert_eq!(loaded.config.log_level, Level::Info);
|
||||
}
|
||||
@@ -25,13 +34,14 @@ fn defaults_without_file() {
|
||||
/// 只覆盖文件里写了的项
|
||||
#[test]
|
||||
fn file_overrides_only_written_keys() {
|
||||
let _guard = env_guard();
|
||||
let path = PathBuf::from("t.toml");
|
||||
let loaded = config::resolve(Some("[page]\nsize = 8192\n"), Some(path.clone()), &|_| None)
|
||||
let loaded = config::resolve(Some("[page]\nsize = 8192\n"), Some(path.clone()))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(loaded.config.page_size, 8192);
|
||||
assert_eq!(loaded.config.btree_order, DEFAULT_BTREE_ORDER);
|
||||
assert_eq!(loaded.config.data_dir, PathBuf::from(DEFAULT_DATA_DIR));
|
||||
assert_eq!(loaded.config.data_dir, config::default_data_dir());
|
||||
assert_eq!(loaded.config_path, Some(path.clone()));
|
||||
|
||||
let page = loaded.origins.iter().find(|(n, _, _)| *n == "page.size").unwrap();
|
||||
@@ -43,9 +53,10 @@ fn file_overrides_only_written_keys() {
|
||||
/// 六项全写都生效
|
||||
#[test]
|
||||
fn file_all_keys() {
|
||||
let _guard = env_guard();
|
||||
let text = "[page]\nsize = 8192\n[btree]\norder = 32\n[buffer]\npool_size = 128\n[storage]\ndata_dir = \"mydata\"\n[server]\npipe_name = \"mypipe\"\n[log]\nlevel = \"debug\"\n";
|
||||
let path = PathBuf::from("t.toml");
|
||||
let loaded = config::resolve(Some(text), Some(path.clone()), &|_| None).unwrap();
|
||||
let loaded = config::resolve(Some(text), Some(path.clone())).unwrap();
|
||||
|
||||
assert_eq!(loaded.config.page_size, 8192);
|
||||
assert_eq!(loaded.config.btree_order, 32);
|
||||
@@ -61,71 +72,161 @@ fn file_all_keys() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 环境变量优先于文件
|
||||
/// 环境变量完全无效:这条通道已经删掉,设了 CHUSQL_* 也只认文件/内置默认
|
||||
#[test]
|
||||
fn env_beats_file() {
|
||||
let text = "[storage]\ndata_dir = \"from-file\"\n";
|
||||
let env = |k: &str| (k == "CHUSQL_DATA_DIR").then(|| "from-env".to_string());
|
||||
let loaded = config::resolve(Some(text), Some(PathBuf::from("t.toml")), &env).unwrap();
|
||||
fn env_vars_are_ignored_entirely() {
|
||||
let _guard = env_guard();
|
||||
unsafe {
|
||||
std::env::set_var("CHUSQL_DATA_DIR", "from-env");
|
||||
std::env::set_var("CHUSQL_PAGE_SIZE", "8192");
|
||||
}
|
||||
|
||||
assert_eq!(loaded.config.data_dir, PathBuf::from("from-env"));
|
||||
let text = "[storage]\ndata_dir = \"from-file\"\n";
|
||||
let loaded = config::resolve(Some(text), Some(PathBuf::from("t.toml"))).unwrap();
|
||||
assert_eq!(loaded.config.data_dir, PathBuf::from("from-file"));
|
||||
assert_eq!(loaded.config.page_size, DEFAULT_PAGE_SIZE);
|
||||
let dir = loaded
|
||||
.origins
|
||||
.iter()
|
||||
.find(|(n, _, _)| *n == "storage.data_dir")
|
||||
.unwrap();
|
||||
assert_eq!(dir.2, Origin::Env("CHUSQL_DATA_DIR"));
|
||||
assert_eq!(dir.2, Origin::File(PathBuf::from("t.toml")));
|
||||
|
||||
let builtin = config::resolve(None, None).unwrap();
|
||||
assert_eq!(builtin.config.data_dir, config::default_data_dir());
|
||||
assert_eq!(builtin.config.page_size, DEFAULT_PAGE_SIZE);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("CHUSQL_DATA_DIR");
|
||||
std::env::remove_var("CHUSQL_PAGE_SIZE");
|
||||
}
|
||||
}
|
||||
|
||||
/// 固定路径按系统惯例:<配置目录>/ChuSQL/chusql.toml
|
||||
#[test]
|
||||
fn default_config_path_follows_system_convention() {
|
||||
let _guard = env_guard();
|
||||
let path = config::default_config_path();
|
||||
assert_eq!(path.file_name().unwrap(), "chusql.toml");
|
||||
assert_eq!(path.parent().unwrap().file_name().unwrap(), "ChuSQL");
|
||||
assert!(path.ends_with(format!("ChuSQL{}chusql.toml", std::path::MAIN_SEPARATOR)), "got {}", path.display());
|
||||
|
||||
// 本机 APPDATA 存在时必须是 %APPDATA%\ChuSQL\chusql.toml
|
||||
#[cfg(windows)]
|
||||
if let Some(appdata) = std::env::var_os("APPDATA") {
|
||||
assert_eq!(
|
||||
path,
|
||||
PathBuf::from(appdata).join("ChuSQL").join("chusql.toml")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认数据目录也按系统惯例:Windows 落 %LOCALAPPDATA%\ChuSQL\data,Unix 落 XDG 数据目录
|
||||
#[test]
|
||||
fn default_data_dir_follows_system_convention() {
|
||||
let _guard = env_guard();
|
||||
let dir = config::default_data_dir();
|
||||
let expected_parent = if cfg!(windows) { "ChuSQL" } else { "chusql" };
|
||||
assert_eq!(dir.file_name().unwrap(), "data");
|
||||
assert_eq!(dir.parent().unwrap().file_name().unwrap(), expected_parent);
|
||||
// 老的内置默认是仓库里的相对路径 ../localdata,现在不能再指回仓库
|
||||
assert_ne!(dir, PathBuf::from("../localdata"));
|
||||
|
||||
#[cfg(windows)]
|
||||
if let Some(local) = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()) {
|
||||
assert_eq!(dir, PathBuf::from(local).join("ChuSQL").join("data"));
|
||||
} else if let Some(roaming) = std::env::var_os("APPDATA").filter(|v| !v.is_empty()) {
|
||||
assert_eq!(dir, PathBuf::from(roaming).join("ChuSQL").join("data"));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
if let Some(xdg) = std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) {
|
||||
assert_eq!(dir, PathBuf::from(xdg).join("chusql").join("data"));
|
||||
} else if let Some(home) = std::env::var_os("HOME").filter(|v| !v.is_empty()) {
|
||||
assert_eq!(
|
||||
dir,
|
||||
PathBuf::from(home)
|
||||
.join(".local")
|
||||
.join("share")
|
||||
.join("chusql")
|
||||
.join("data")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 显式给了一个不存在的路径:必须报错,不能静默回退默认
|
||||
#[test]
|
||||
fn explicit_missing_path_is_an_error() {
|
||||
let _guard = env_guard();
|
||||
let missing = std::env::temp_dir().join("chusql-no-such-config-9f21.toml");
|
||||
assert!(!missing.is_file());
|
||||
|
||||
let err = config::load(Some(missing.to_str().unwrap())).unwrap_err();
|
||||
assert!(
|
||||
err.contains("config file not found") || err.contains("cannot read config file"),
|
||||
"got {}",
|
||||
err
|
||||
);
|
||||
assert!(err.contains("chusql-no-such-config-9f21.toml"), "got {}", err);
|
||||
}
|
||||
|
||||
/// 不给 --config:固定路径没有文件时全用内置默认,且不报错
|
||||
#[test]
|
||||
fn load_without_explicit_path_uses_builtin_defaults() {
|
||||
let _guard = env_guard();
|
||||
let default = config::default_config_path();
|
||||
let loaded = config::load(None).unwrap();
|
||||
if default.is_file() {
|
||||
assert_eq!(loaded.config_path, Some(default));
|
||||
} else {
|
||||
assert_eq!(loaded.config, Config::default());
|
||||
assert_eq!(loaded.config.data_dir, config::default_data_dir());
|
||||
assert_eq!(loaded.config.pipe_name, DEFAULT_PIPE_NAME);
|
||||
assert!(loaded.origins.iter().all(|(_, _, o)| *o == Origin::Default));
|
||||
assert!(loaded.config_path.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志等级非法要报错
|
||||
#[test]
|
||||
fn rejects_bad_log_level() {
|
||||
let err = config::resolve(Some("[log]\nlevel = \"loud\"\n"), None, &|_| None).unwrap_err();
|
||||
let _guard = env_guard();
|
||||
let err = config::resolve(Some("[log]\nlevel = \"loud\"\n"), None).unwrap_err();
|
||||
assert!(err.contains("log.level"), "got {}", err);
|
||||
}
|
||||
|
||||
/// 页大小非法要报错
|
||||
/// 页大小或 order 非法都要报错
|
||||
#[test]
|
||||
fn rejects_bad_page_size() {
|
||||
let err = config::resolve(Some("[page]\nsize = 3000\n"), None, &|_| None).unwrap_err();
|
||||
assert!(err.contains("page.size"), "got {}", err);
|
||||
fn rejects_bad_numeric_limits() {
|
||||
let _guard = env_guard();
|
||||
for text in ["[page]\nsize = 3000\n", "[page]\nsize = 128\n"] {
|
||||
let err = config::resolve(Some(text), None).unwrap_err();
|
||||
assert!(err.contains("page.size"), "{}: {}", text, err);
|
||||
}
|
||||
|
||||
let err = config::resolve(Some("[page]\nsize = 128\n"), None, &|_| None).unwrap_err();
|
||||
assert!(err.contains("page.size"), "got {}", err);
|
||||
}
|
||||
|
||||
/// order 非法要报错
|
||||
#[test]
|
||||
fn rejects_bad_btree_order() {
|
||||
let err = config::resolve(Some("[btree]\norder = 2\n"), None, &|_| None).unwrap_err();
|
||||
let err = config::resolve(Some("[btree]\norder = 2\n"), None).unwrap_err();
|
||||
assert!(err.contains("btree.order"), "got {}", err);
|
||||
|
||||
let err = config::resolve(Some("[btree]\norder = 300\n"), None, &|_| None).unwrap_err();
|
||||
let err = config::resolve(Some("[btree]\norder = 300\n"), None).unwrap_err();
|
||||
assert!(err.contains("does not fit"), "got {}", err);
|
||||
}
|
||||
|
||||
/// 未知配置键要报错
|
||||
#[test]
|
||||
fn rejects_unknown_key() {
|
||||
let err = config::resolve(Some("[page]\nsizes = 4096\n"), None, &|_| None).unwrap_err();
|
||||
let _guard = env_guard();
|
||||
let err = config::resolve(Some("[page]\nsizes = 4096\n"), None).unwrap_err();
|
||||
assert!(err.contains("bad config"), "got {}", err);
|
||||
}
|
||||
|
||||
/// 环境变量非法要报错
|
||||
/// 坏的页面尺寸只能来自文件
|
||||
#[test]
|
||||
fn rejects_bad_env_value() {
|
||||
let env = |k: &str| (k == "CHUSQL_PAGE_SIZE").then(|| "big".to_string());
|
||||
let err = config::resolve(None, None, &env).unwrap_err();
|
||||
assert!(err.contains("CHUSQL_PAGE_SIZE"), "got {}", err);
|
||||
}
|
||||
fn rejects_bad_page_size_from_file() {
|
||||
let _guard = env_guard();
|
||||
unsafe { std::env::set_var("CHUSQL_PAGE_SIZE", "big") };
|
||||
|
||||
/// 来源文案正确
|
||||
#[test]
|
||||
fn origin_description() {
|
||||
assert_eq!(Origin::Default.describe(), "default");
|
||||
assert_eq!(Origin::Env("CHUSQL_PIPE").describe(), "env CHUSQL_PIPE");
|
||||
assert_eq!(
|
||||
Origin::File(PathBuf::from("a.toml")).describe(),
|
||||
format!("file {}", PathBuf::from("a.toml").display())
|
||||
);
|
||||
let err = config::resolve(Some("[page]\nsize = 3000\n"), None).unwrap_err();
|
||||
assert!(err.contains("page.size"), "got {}", err);
|
||||
|
||||
unsafe { std::env::remove_var("CHUSQL_PAGE_SIZE") };
|
||||
}
|
||||
@@ -14,20 +14,62 @@ fn row(pairs: &[(&str, serde_json::Value)]) -> Row {
|
||||
m
|
||||
}
|
||||
|
||||
/// 插一行再扫描读回
|
||||
/// 索引文件被清空(强杀或截断)后打开,会按堆数据补齐
|
||||
#[test]
|
||||
fn insert_and_scan_one_row() {
|
||||
fn empty_index_file_is_rebuilt_from_rows() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("t.db");
|
||||
let mut t = HeapTable::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).unwrap();
|
||||
let data = dir.path().join("t.db");
|
||||
let index = dir.path().join("t.idx");
|
||||
{
|
||||
let mut t = HeapTable::open_indexed(&data, &index, DEFAULT_PAGE_SIZE, 4, DEFAULT_POOL_SIZE).unwrap();
|
||||
for i in 1..=5 {
|
||||
t.insert_row(&row(&[("id", json!(i))])).unwrap();
|
||||
}
|
||||
t.flush().unwrap();
|
||||
}
|
||||
std::fs::write(&index, b"").unwrap();
|
||||
let mut t = HeapTable::open_indexed(&data, &index, DEFAULT_PAGE_SIZE, 4, DEFAULT_POOL_SIZE).unwrap();
|
||||
assert!(t.get_by_column_key("id", 3).unwrap().is_some(), "空索引文件要从堆数据重建");
|
||||
assert!(t.get_by_column_key("id", 9).unwrap().is_none());
|
||||
}
|
||||
|
||||
t.insert_row(&row(&[("id", json!(1)), ("name", json!("Alice"))]))
|
||||
.unwrap();
|
||||
/// 字符串列能真正建索引:按值点查,NULL 不入索引
|
||||
#[test]
|
||||
fn string_index_looks_up_by_value() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let data = dir.path().join("s.db");
|
||||
let index = dir.path().join("s.idx");
|
||||
let mut t = HeapTable::open(&data, DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).unwrap();
|
||||
t.insert_row(&row(&[("id", json!(1)), ("name", json!("alice"))])).unwrap();
|
||||
t.insert_row(&row(&[("id", json!(2)), ("name", json!("bob"))])).unwrap();
|
||||
t.insert_row(&row(&[("id", json!(3)), ("name", json!(null))])).unwrap();
|
||||
t.build_index("name", &index, DEFAULT_PAGE_SIZE, 4, DEFAULT_POOL_SIZE).unwrap();
|
||||
|
||||
let rows = t.scan().unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0]["id"], json!(1));
|
||||
assert_eq!(rows[0]["name"], json!("Alice"));
|
||||
assert_eq!(t.get_by_string_key("name", "bob").unwrap().unwrap()["id"], json!(2));
|
||||
assert!(t.get_by_string_key("name", "dave").unwrap().is_none());
|
||||
assert!(t.get_by_string_key("name", "").unwrap().is_none(), "NULL 不入索引");
|
||||
}
|
||||
|
||||
/// 允许重复值建索引:点查把重复的行都取回来
|
||||
#[test]
|
||||
fn duplicate_values_are_indexed_and_all_returned() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let data = dir.path().join("d.db");
|
||||
let index = dir.path().join("d.idx");
|
||||
let mut t = HeapTable::open(&data, DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).unwrap();
|
||||
t.insert_row(&row(&[("id", json!(1)), ("name", json!("alice"))])).unwrap();
|
||||
t.insert_row(&row(&[("id", json!(2)), ("name", json!("alice"))])).unwrap();
|
||||
t.build_index("name", &index, DEFAULT_PAGE_SIZE, 4, DEFAULT_POOL_SIZE).unwrap();
|
||||
|
||||
let mut ids: Vec<i64> = t
|
||||
.get_all_by_string_key("name", "alice")
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|r| r["id"].as_i64())
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
assert_eq!(ids, vec![1, 2]);
|
||||
assert!(t.get_all_by_string_key("name", "bob").unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// 500 行跨页顺序不变
|
||||
@@ -94,22 +136,6 @@ fn reopen_and_scan() {
|
||||
assert_eq!(rows[1]["id"], json!(2));
|
||||
}
|
||||
|
||||
/// 512 字节页可用
|
||||
#[test]
|
||||
fn custom_page_size() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("small.db");
|
||||
let mut t = HeapTable::open(&path, 512, DEFAULT_POOL_SIZE).unwrap();
|
||||
|
||||
for i in 0..50 {
|
||||
t.insert_row(&row(&[("id", json!(i))])).unwrap();
|
||||
}
|
||||
assert_eq!(t.scan().unwrap().len(), 50);
|
||||
|
||||
let big = "x".repeat(600);
|
||||
assert!(t.insert_row(&row(&[("data", json!(big))])).is_err());
|
||||
}
|
||||
|
||||
/// 整表改写后索引重建
|
||||
#[test]
|
||||
fn replace_all_rebuilds_index() {
|
||||
@@ -246,40 +272,34 @@ fn secondary_index_is_maintained() {
|
||||
t.delete_by_keys(&[2]).unwrap();
|
||||
assert!(t.get_by_column_key("code", 7002).unwrap().is_none());
|
||||
|
||||
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();
|
||||
t.insert_row(&row(&[("id", json!(4)), ("code", json!(7001))])).unwrap();
|
||||
let mut dup_ids: Vec<i64> = t
|
||||
.get_all_by_column_key("code", 7001)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|r| r["id"].as_i64())
|
||||
.collect();
|
||||
dup_ids.sort_unstable();
|
||||
assert_eq!(dup_ids, vec![1, 4], "duplicate index values keep both rows");
|
||||
|
||||
let mut t2 = HeapTable::open(dir.path().join("d.db"), DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).unwrap();
|
||||
t2.insert_rows(&[
|
||||
row(&[("id", json!(1)), ("age", json!(30))]),
|
||||
row(&[("id", json!(2)), ("age", json!(30))]),
|
||||
])
|
||||
.unwrap();
|
||||
let err = t2.build_index(
|
||||
t2.build_index(
|
||||
"age",
|
||||
&dir.path().join("d.age.idx"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
chusql_storage::config::DEFAULT_BTREE_ORDER,
|
||||
DEFAULT_POOL_SIZE,
|
||||
);
|
||||
assert!(err.is_err(), "duplicate values must not be indexed");
|
||||
assert!(!t2.has_index("age"), "no half-built index after failure");
|
||||
}
|
||||
|
||||
/// 同一个 id 插两次要报错
|
||||
#[test]
|
||||
fn duplicate_key_is_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut t = HeapTable::open_indexed(
|
||||
dir.path().join("t.db"),
|
||||
dir.path().join("t.idx"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
chusql_storage::config::DEFAULT_BTREE_ORDER,
|
||||
DEFAULT_POOL_SIZE,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
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, "the failed row should not reach the heap");
|
||||
assert_eq!(
|
||||
t2.get_all_by_column_key("age", 30).unwrap().len(),
|
||||
2,
|
||||
"duplicate values are all indexed"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,25 +49,6 @@ fn append_grows_file() {
|
||||
assert_eq!(pf.num_pages().unwrap(), 3);
|
||||
}
|
||||
|
||||
/// 两页内容互不影响
|
||||
#[test]
|
||||
fn two_pages_independent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("test.db");
|
||||
|
||||
let mut pf = PageFile::open(&path, DEFAULT_PAGE_SIZE).unwrap();
|
||||
|
||||
let mut a = Page::new(0, DEFAULT_PAGE_SIZE);
|
||||
a.data[0] = 1;
|
||||
let mut b = Page::new(1, DEFAULT_PAGE_SIZE);
|
||||
b.data[0] = 2;
|
||||
pf.write_page(&a).unwrap();
|
||||
pf.write_page(&b).unwrap();
|
||||
|
||||
assert_eq!(pf.read_page(0).unwrap().data[0], 1);
|
||||
assert_eq!(pf.read_page(1).unwrap().data[0], 2);
|
||||
}
|
||||
|
||||
/// 覆写同页读到新内容
|
||||
#[test]
|
||||
fn overwrite_page() {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -13,77 +13,51 @@ fn row(pairs: &[(&str, serde_json::Value)]) -> Row {
|
||||
m
|
||||
}
|
||||
|
||||
/// 写读 Insert 操作
|
||||
/// 三种操作写完都能原样读回
|
||||
#[test]
|
||||
fn append_and_read_insert() {
|
||||
fn append_and_read_round_trips() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let wal = Wal::new(dir.path().join("wal.log"));
|
||||
|
||||
let op = WalOp::Insert {
|
||||
table: "users".into(),
|
||||
row: row(&[("id", json!(1)), ("name", json!("Alice"))]),
|
||||
};
|
||||
wal.append(&op).unwrap();
|
||||
|
||||
let back = wal.read().unwrap().unwrap();
|
||||
assert_eq!(back, op);
|
||||
let ops = [
|
||||
WalOp::Insert {
|
||||
table: "users".into(),
|
||||
row: row(&[("id", json!(1)), ("name", json!("Alice"))]),
|
||||
},
|
||||
WalOp::ReplaceAll {
|
||||
table: "users".into(),
|
||||
rows: vec![
|
||||
row(&[("id", json!(1)), ("name", json!("Alice"))]),
|
||||
row(&[("id", json!(2)), ("name", json!("Bob"))]),
|
||||
],
|
||||
},
|
||||
WalOp::DropColumn {
|
||||
table: "users".into(),
|
||||
column: "age".into(),
|
||||
rows: vec![
|
||||
row(&[("id", json!(1)), ("name", json!("Alice"))]),
|
||||
row(&[("id", json!(2)), ("name", json!("Bob"))]),
|
||||
],
|
||||
},
|
||||
];
|
||||
for op in ops {
|
||||
wal.clear().unwrap();
|
||||
wal.append(&op).unwrap();
|
||||
assert_eq!(wal.read().unwrap().unwrap(), op);
|
||||
}
|
||||
}
|
||||
|
||||
/// 写读 ReplaceAll 操作
|
||||
/// 文件不存在或为空都返回 None
|
||||
#[test]
|
||||
fn append_and_read_replace_all() {
|
||||
fn read_without_content_returns_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let wal = Wal::new(dir.path().join("wal.log"));
|
||||
let missing = Wal::new(dir.path().join("missing.log"));
|
||||
assert!(missing.read().unwrap().is_none());
|
||||
|
||||
let op = WalOp::ReplaceAll {
|
||||
table: "users".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);
|
||||
}
|
||||
|
||||
/// 写读 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() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let wal = Wal::new(dir.path().join("wal.log"));
|
||||
assert!(wal.read().unwrap().is_none());
|
||||
}
|
||||
|
||||
/// 空文件返回 None
|
||||
#[test]
|
||||
fn empty_file_returns_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("wal.log");
|
||||
std::fs::write(&path, b"").unwrap();
|
||||
let wal = Wal::new(&path);
|
||||
assert!(wal.read().unwrap().is_none());
|
||||
let empty = Wal::new(&path);
|
||||
assert!(empty.read().unwrap().is_none());
|
||||
}
|
||||
|
||||
/// 清空后读不到
|
||||
@@ -124,18 +98,3 @@ fn truncated_tail_is_ignored() {
|
||||
assert!(wal.read().unwrap().is_none());
|
||||
}
|
||||
|
||||
/// 第二次追加会覆盖
|
||||
#[test]
|
||||
fn overwrite_on_second_append_is_visible() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let wal = Wal::new(dir.path().join("wal.log"));
|
||||
|
||||
wal.append(&WalOp::Insert {
|
||||
table: "t".into(),
|
||||
row: row(&[("id", json!(1))]),
|
||||
})
|
||||
.unwrap();
|
||||
let first = wal.read().unwrap().unwrap();
|
||||
assert!(matches!(first, WalOp::Insert { .. }));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user