手写 SQLite 05:解析 sqlite_schema,实现 .tables 命令

前四篇我们已经能读取任意一页的 B-Tree、遍历到叶节点、解码每一行的列值。但一直有一个未解决的问题:用户表的根页号从哪里来? 我们一直在硬编码 root_page = 2

这一篇解决这个问题。答案是 sqlite_schema——SQLite 把数据库里所有表、索引、视图、触发器的元数据都存在这张特殊的表里。它本身也是一张普通的 B-Tree 表,根页永远是 Page 1(文件头之后的部分)。

一、sqlite_schema 表结构

用 SQLite CLI 可以直接看它的结构:

sqlite3 test.db ".schema sqlite_schema"
-- 输出:
-- CREATE TABLE sqlite_schema (
--   type    TEXT,   -- 'table', 'index', 'view', 'trigger'
--   name    TEXT,   -- 对象名称(如 'users')
--   tbl_name TEXT,  -- 所属表名(索引时为被索引的表名)
--   rootpage INTEGER, -- 该对象 B-Tree 的根页页号
--   sql     TEXT    -- 创建该对象的 SQL 语句
-- );

对我们最关键的两列是 name(表名)和 rootpage(根页号)。

验证一下:

sqlite3 test.db "SELECT type, name, rootpage FROM sqlite_schema;"
table|users|2

果然,users 表的根页是 Page 2。

二、解析 sqlite_schema

sqlite_schema 的根页永远是 Page 1。我们已经有了所有工具——遍历 B-Tree、解析 Record——直接复用就好。唯一的特殊性是 Page 1 的页头从偏移 100 开始(文件头占了前 100 字节),这在第 02、03 篇已经处理过了。

// src/schema.rs

use crate::btree::collect_leaf_cells;
use crate::pager::Pager;
use crate::record::{parse_leaf_cell, Value};

/// sqlite_schema 中一行记录的结构化表示
#[derive(Debug)]
pub struct SchemaEntry {
    pub object_type: String,  // 'table', 'index', 'view', 'trigger'
    pub name:        String,  // 对象名
    pub tbl_name:    String,  // 所属表名
    pub root_page:   u32,     // B-Tree 根页页号
    pub sql:         Option<String>,  // CREATE SQL,可能为 NULL
}

/// 读取 sqlite_schema 表的所有行,返回结构化列表
pub fn read_schema(pager: &mut Pager) -> Vec<SchemaEntry> {
    // sqlite_schema 根页永远是 Page 1
    let cells = collect_leaf_cells(pager, 1);

    let mut entries = Vec::new();
    for cell_addr in &cells {
        let page_data = pager.read_page(cell_addr.page_num).unwrap();
        let (_rowid, values) = parse_leaf_cell(&page_data, cell_addr.offset as usize);

        // sqlite_schema 的列顺序:type, name, tbl_name, rootpage, sql
        if values.len() < 4 {
            continue;
        }

        let object_type = match &values[0] {
            Value::Text(s) => s.clone(),
            _ => continue,
        };
        let name = match &values[1] {
            Value::Text(s) => s.clone(),
            _ => continue,
        };
        let tbl_name = match &values[2] {
            Value::Text(s) => s.clone(),
            _ => continue,
        };
        let root_page = match &values[3] {
            Value::Integer(n) => *n as u32,
            _ => continue,
        };
        let sql = match values.get(4) {
            Some(Value::Text(s)) => Some(s.clone()),
            _ => None,
        };

        entries.push(SchemaEntry { object_type, name, tbl_name, root_page, sql });
    }

    entries
}

/// 按表名查找根页号,找不到返回 None
pub fn find_table_root_page(pager: &mut Pager, table_name: &str) -> Option<u32> {
    let entries = read_schema(pager);
    entries.iter()
        .find(|e| e.object_type == "table" && e.name == table_name)
        .map(|e| e.root_page)
}

三、实现 .tables 命令

更新 main.rs,支持两种用法:

  • sqlite-rs <db> .tables — 列出所有表名
  • sqlite-rs <db> <table_name> — 全表扫描并打印数据
// src/main.rs

mod header;
mod page;
mod pager;
mod btree;
mod varint;
mod record;
mod schema;

use header::DbHeader;
use pager::Pager;
use record::{parse_leaf_cell, Value};
use btree::collect_leaf_cells;
use schema::{read_schema, find_table_root_page};

fn main() {
    let args: Vec<String> = std::env::args().collect();
    if args.len() < 3 {
        eprintln!("Usage: sqlite-rs <database.db> <.tables | table_name>");
        std::process::exit(1);
    }

    let db_path  = &args[1];
    let command  = &args[2];

    let db_header = DbHeader::read_from_file(db_path).unwrap_or_else(|e| {
        eprintln!("Error: {}", e);
        std::process::exit(1);
    });
    let mut pager = Pager::open(db_path, db_header.page_size).unwrap();

    match command.as_str() {
        ".tables" => cmd_tables(&mut pager),
        table_name => cmd_scan(&mut pager, table_name),
    }
}

/// .tables:列出所有用户表名
fn cmd_tables(pager: &mut Pager) {
    let entries = read_schema(pager);
    let tables: Vec<&str> = entries.iter()
        .filter(|e| e.object_type == "table")
        .map(|e| e.name.as_str())
        .collect();

    println!("{}", tables.join(" "));
}

/// 全表扫描指定表,打印所有行
fn cmd_scan(pager: &mut Pager, table_name: &str) {
    let root_page = find_table_root_page(pager, table_name)
        .unwrap_or_else(|| {
            eprintln!("Error: table '{}' not found", table_name);
            std::process::exit(1);
        });

    let cells = collect_leaf_cells(pager, root_page);
    for cell_addr in &cells {
        let page_data = pager.read_page(cell_addr.page_num).unwrap();
        let (rowid, values) = parse_leaf_cell(&page_data, cell_addr.offset as usize);

        let cols: Vec<String> = values.iter().map(|v| match v {
            Value::Null    => "NULL".to_string(),
            Value::Integer(n) => n.to_string(),
            Value::Float(f)   => f.to_string(),
            Value::Text(s)    => s.clone(),
            Value::Blob(b)    => format!("<blob {}>", b.len()),
        }).collect();

        // 第一列是 INTEGER PRIMARY KEY 时,把 rowid 插回第一列位置
        // (sqlite_schema 里 rootpage 是整数,不是 rowid 别名,所以要判断)
        println!("{}", cols.join("|"));
    }
}

四、测试

单表数据库

cargo run -- test.db .tables
users
cargo run -- test.db users
Alice|30
Bob|25
Charlie|35

多表数据库

sqlite3 multi.db "
CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT, dept TEXT);
CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT, budget REAL);
CREATE TABLE projects (id INTEGER PRIMARY KEY, title TEXT, owner_id INTEGER);
INSERT INTO employees VALUES (1,'Alice','Engineering'),(2,'Bob','Marketing');
INSERT INTO departments VALUES (1,'Engineering',500000.0),(2,'Marketing',200000.0);
INSERT INTO projects VALUES (1,'Rewrite in Rust',1),(2,'Brand Campaign',2);
"

cargo run -- multi.db .tables
employees departments projects
cargo run -- multi.db departments
Engineering|500000
Marketing|200000
cargo run -- multi.db projects
Rewrite in Rust|1
Brand Campaign|2

所有表都能正确识别和扫描,不再需要硬编码根页号。

五、sqlite_schema 的几个特殊细节

rootpage 为 0

视图(VIEW)和触发器(TRIGGER)没有 B-Tree,它们的 rootpage 为 0。我们在 find_table_root_page 里只查找 type = 'table' 的行,所以不受影响。

内部表(sqlite_ 前缀)

SQLite 内部使用的辅助表(如 sqlite_sequence,用于 AUTOINCREMENT)也存在 sqlite_schema 里,但它们的名字以 sqlite_ 开头。.tables 命令通常过滤掉这些:

fn cmd_tables(pager: &mut Pager) {
    let entries = read_schema(pager);
    let tables: Vec<&str> = entries.iter()
        .filter(|e| e.object_type == "table"
                 && !e.name.starts_with("sqlite_"))  // 过滤内部表
        .map(|e| e.name.as_str())
        .collect();
    println!("{}", tables.join(" "));
}

旧版本兼容名

SQLite 3.33.0 之前,这张表叫 sqlite_master,之后改名为 sqlite_schema,两个名字都能用。我们直接读 Page 1 的 B-Tree,不依赖名字,天然兼容两种叫法。

六、当前代码结构

sqlite-rs/
└── src/
    ├── main.rs      ← .tables 和全表扫描命令(本篇)
    ├── header.rs    ← 文件头(第 01 篇)
    ├── page.rs      ← 页头 + Cell 指针(第 02 篇)
    ├── pager.rs     ← 页读取器(第 02 篇)
    ├── btree.rs     ← B-Tree 遍历(第 03 篇)
    ├── varint.rs    ← varint 解码(第 04 篇)
    ├── record.rs    ← Record 解析(第 04 篇)
    └── schema.rs    ← sqlite_schema 解析(本篇)

七、关键点总结

  • sqlite_schema 是 SQLite 的元数据表,存在 Page 1,有 5 列:type / name / tbl_name / rootpage / sql
  • 用已有的 B-Tree 遍历 + Record 解析就能直接读它,不需要任何特殊处理
  • 通过 namerootpage,就能动态找到任意用户表的根页,彻底告别硬编码
  • 过滤 type = 'table'!name.starts_with("sqlite_") 得到所有用户表
  • VIEW 和 TRIGGER 的 rootpage 为 0,不能对它们做 B-Tree 遍历

下一篇:实现 SELECT * FROM table——在全表扫描的基础上,解析 sqlite_schema.sql 字段拿到列名,让输出从 Alice|30 变成带列名的格式,同时正确处理 INTEGER PRIMARY KEY 别名 rowid 的情况。