创建表(CREATE TABLE
)
在实现了 Catalog 之后,我们就可以使用 CREATE TABLE
语句创建数据表:
CREATE TABLE student (
id INTEGER PRIMARY KEY,
name VARCHAR NOT NULL,
age INTEGER
);
这一语句除了解析和执行两个步骤以外,还需要做名称的检查,并将它们与数据库内部对象绑定起来。 这些工作一般是由一个叫做 Binder 的模块完成。
在此任务中,我们将实现一个基础的 Binder,同时拓展 Executor 实现相应的执行逻辑,最终支持 CREATE TABLE
语句。
背景知识
Binder
Binder 是整个数据库系统中一个不太起眼但又十分重要的模块。
它的作用是将解析后生成的 AST 和 Schema 信息绑定起来,具体包括:
- 检查输入的名称是否合法、是否有重复、有歧义
- 推断表达式的返回值类型,并检查是否合法
- 将输入的名称转换成内部 ID
比如,对于一个简单的创建表的命令:
CREATE TABLE student (
id INTEGER PRIMARY KEY,
name VARCHAR NOT NULL,
age INTEGER
);
Binder 会依次完成以下工作:
- 由于 SQL 对大小写不敏感,Binder 会首先将所有名称统一成小写。
- 对于表名
student
,自动补全省略的 schema 名。假如当前 schema 是school
,那么会将其补全成school.student
。 - 检查 schema 是否存在,并获取它的 Schema ID。
- 检查 schema 中是否已经存在名为
student
的表。 - 检查列名
id
name
age
是否合法、是否有重复。 - 检查列的属性是否合法,例如不能出现两个
PRIMARY KEY
。 - 向 AST 中填入绑定后的信息:Schema ID。
对于插入数据的 INSERT
语句,例如:
INSERT INTO student VALUES (1, 'Alice', 18)
Binder 需要查询表中每一列的信息,推断表达式的类型,并检查它们是否相符。 换言之,Binder 应该能够识别出以下不合法的插入语句:
INSERT INTO student VALUES (1) -- 存在未指定的 NOT NULL 值
INSERT INTO student VALUES (1, 'Alice', 'old') -- 类型不匹配
INSERT INTO student VALUES (1, 'Alice', 18+'g') -- 表达式类型不匹配
对于更复杂的嵌套查询语句,Binder 还需要根据当前语境,推断出每个名称具体指代哪个对象:
SELECT name FROM student WHERE sid IN (
-- ^-----------^ student.sid
SELECT sid FROM enrolled WHERE class = 'database'
-- ^----------^ enrolled.sid
)
可以看出,Binder 干的都是一些比较繁琐的脏活累活。因此后面我们写的 Binder 代码也会比较冗长并且细节琐碎。
任务目标
能够创建数据表,支持以下 SQL 语句:
CREATE TABLE student (
id INTEGER PRIMARY KEY,
name VARCHAR NOT NULL,
age INTEGER
);
【练习】支持 DROP TABLE
语句,删除数据表:
DROP TABLE student;
【练习】支持 CREATE SCHEMA
语句,创建 schema:
CREATE SCHEMA school;
整体设计
在加入 Binder 之后,RisingLight 的整个数据处理流程扩展成了这个样子:
其中 Binder 插在了 Parser 和 Executor 之间。 它会将 Parser 生成的 AST 进行处理后,生成一个新的 AST 交给 Executor,在此过程中需要从 Catalog 读取 Schema 信息。 Executor 拿到绑定后的 AST 去执行,在此过程中可能也会再次修改 Catalog(比如创建一个表)。
在代码结构上,我们可能会新增以下文件:
src
├── binder
│ ├── mod.rs
│ └── statement
│ ├── mod.rs
│ ├── create.rs
│ └── select.rs
├── executor
│ ├── mod.rs
│ ├── create.rs
│ └── select.rs
...
此外还需要对数据库顶层结构进行修改。
Bound AST
Binder 模块的主要任务是给 Parser 生成的 AST 绑定必要的信息。
由于我们的 Parser 使用了第三方库,不能在它的 AST 结构上扩展新的属性,所以只能定义新的结构来存放这些信息。
例如对于 CREATE TABLE
语句来说,绑定后的 AST 应该具有以下信息:
#![allow(unused)] fn main() { // binder/statement/create.rs /// A bound `CREATE TABLE` statement. #[derive(Debug, PartialEq, Clone)] pub struct BoundCreateTable { pub schema_id: SchemaId, // schema name 经过向 catalog 查询转换成了 ID pub table_name: String, pub columns: Vec<(String, ColumnDesc)>, } }
类似地,对于 1.1 中的 SELECT 1
语句而言,我们可以只提取出必要的值来保存:
#![allow(unused)] fn main() { // binder/statement/select.rs use crate::parser::Value; /// A bound `SELECT` statement. #[derive(Debug, PartialEq, Clone)] pub struct BoundSelect { pub values: Vec<Value>, } }
最后,我们需要定义一个 enum 将各种不同类型的语句聚合起来:
#![allow(unused)] fn main() { // binder/mod.rs /// A bound SQL statement. #[derive(Debug, PartialEq, Clone)] pub enum BoundStatement { CreateTable(BoundCreateTable), Select(BoundSelect), } }
这样,一个 BoundStatement
变量就可以表示 Binder 生成的整个 AST 了。
Binder
接下来,我们实现真正的 Binder
对象。它会将 Parser 生成的 AST 转换成一个新的 AST。
由于在绑定过程中会访问 Catalog 的数据,Binder
中需要存放一个 Catalog 对象的指针:
#![allow(unused)] fn main() { pub struct Binder { catalog: Arc<Catalog>, } }
我们在 Binder
对象上实现各种 bind
方法来完成对不同 AST 节点的处理:
#![allow(unused)] fn main() { use crate::parser::{Query, Statement}; impl Binder { pub fn bind(&mut self, stmt: &Statement) -> Result<BoundStatement, BindError> { use Statement::*; match stmt { CreateTable { .. } => Ok(BoundStatement::CreateTable(self.bind_create_table(stmt)?)), Query(query) => Ok(BoundStatement::Select(self.bind_select(query)?)), _ => todo!("bind statement: {:#?}", stmt), } } fn bind_create_table(&mut self, stmt: &Statement) -> Result<BoundCreateTable, BindError> { // YOUR CODE HERE } fn bind_select(&mut self, query: &Query) -> Result<BoundSelect, BindError> { // YOUR CODE HERE } } }
注意到这些方法都使用了 &mut self
签名,这是因为 Binder
未来会有内部状态,并且在 bind 过程中还会修改这些状态。
另外在 bind 过程中还可能产生各种各样的错误,比如名称不存在或者重复等等。
我们将所有可能发生的错误定义在一个 BindError
错误类型中(参考 1.1 错误处理):
#![allow(unused)] fn main() { /// The error type of bind operations. #[derive(thiserror::Error, Debug, PartialEq)] pub enum BindError { #[error("schema not found: {0}")] SchemaNotFound(String), // ... } }
至于具体的 bind 逻辑,大家可以参考背景知识中描述的过程尝试自己实现。
Executor
在 1.1 中我们实现过一个最简单的执行器,它只是一个函数,拿到 AST 后做具体的执行。 现在我们有了更多类型的语句,并且在执行它们的过程中还需要访问 Catalog。 因此和 Binder 类似,我们现在需要将 Executor 也扩展为一个对象:
#![allow(unused)] fn main() { pub struct Executor { catalog: Arc<Catalog>, } }
然后在 Executor
上实现各种 execute
方法来对不同类型的 AST 节点做执行:
#![allow(unused)] fn main() { /// The error type of execution. #[derive(thiserror::Error, Debug)] pub enum ExecuteError {...} impl Executor { pub fn execute(&self, stmt: BoundStatement) -> Result<String, ExecuteError> { match stmt { BoundStatement::CreateTable(stmt) => self.execute_create_table(stmt), BoundStatement::Select(stmt) => self.execute_select(stmt), } } fn execute_create_table(&self, stmt: BoundCreateTable) -> Result<String, ExecuteError> { // YOUR CODE HERE } fn execute_select(&self, query: BoundSelect) -> Result<String, BindError> { // YOUR CODE HERE } } }
我们暂时将 Executor 的返回值设定为 String
类型,表示语句的执行结果。
在下一个任务中,我们会实现更具体的内存数据类型 Array
和 DataChunk
。
到那时,Executor 的输出就是一段真正的数据了。