前言
阅读本篇文章要求了解编译原理的基础知识,如:文法、词法分析、语法分析、语义分析等概念,需要对这些概念有基本的认识。
SQL语句解析的大致流程
像是C语言的编译,其大致流程为:
- 通过词法分析、语法分析和语义分析,得出四元式列表。
- 分析四元式列表,进行代码优化。
- 将四元式列表变为具体的汇编语言,使用汇编器将汇编文件转化为二进制文件。
我们可以将这个流程抽象成:
- 通过词法分析、语法分析和语义分析,得出可以存在于解析器程序中的数据结构,比如:四元式列表可能就是一个(
list<Item>
)。 - 分析数据结构,进行某种优化。
- 执行数据结构。如:将数据结构转化成二进制文件(让CPU读取指令并执行);将数据结构的数据提取出来,调用程序的方法来执行。
我们再将其套用到SQL语句的解析流程上:
- 通过词法分析、语法分析和语义分析,得出一个代码级别的数据结构。
- 分析语数据结构中关系代数相关的部分,进行关系代数优化。
- 优化后的语法分析树中有数据,那么这些数据就可以指导代码去执行查询、插入、建表等操作。
本篇文章,我们要了解的重点是步骤1:通过词法分析、语法分析和语义分析,得出一个代码级别的数据结构。
语法分析树,它确实是一棵树,但它不是代码数据结构层面的树,是语法分析过程的形象表述。
代码级别的数据结构
如果我们想要自己实现一个SQL语句的解析器,那么这个代码级别的数据结构应该是什么怎么样的呢?
假设这个SQL语句的解析器只需要解析CREATE TABLE
语句,那么这个代码级别的数据结构可能是这样的:
cpp
enum class SqlCommandType {
CREATE_TABLE
};
struct SqlCommand {
CommandType command_type;
string table_name;
vector<AttributeInfo> attributes;
};
struct AttributeInfo {
string attr_name;
string attr_type;
// ...
};
解析的时候将Command
中的command_type
、table_name
和attributes
设置上即可。
由于建表语句不涉及查询操作,因此不需要进行优化,直接进入执行步骤,数据库会提供一个create_table
方法给你调用:
cpp
void create_table(string table_name, vector<AttributeInfo> attributes);
SQL解析器需要解析出什么样的数据结构,是这样被决定的:当前正在解析的命令的类型,决定需要调用哪些数据库的接口,数据库提供的接口需要的参数决定了SQL解析器必须解析出什么数据结构。
在解析出来了必要的数据结构之后,这些数据结构需要以什么样的方式存在是没有做规定的。我们可以根据需求进行设计。
比如,SQL解析器不只是需要解析一个命令,那么我们可以将存储解析结果的数据结构设计为:
cpp
enum class SqlCommandType {
CREATE_TABLE,
INSERT,
SELECT,
UPDATE
};
struct SqlCommand {
SqlCommandType command_type;
}
struct CreateTableCommand : SqlCommand {
string table_name;
vector<AttributeInfo> attributes;
};
struct InsertCommand : SqlCommand {
// ... 暂时不知道要什么数据结构
};
struct SelectCommand : SqlCommand {
// ... 暂时不知道要什么数据结构
};
struct UpdateCommand : SqlCommand {
// ... 暂时不知道要什么数据结构
};
通过继承来组织这些必要的数据结构,这样方便后续的代码编写。比如:我们使用SqlCommand*
指向任意类型的SQL命令,通过判断类型知道它具体是什么类型后,再将其转换回相应类型后使用。也可以在不转换类型的前提下,判断SQL命令是否应该执行优化操作。
MiniOB存储SQL语句解析结果的数据结构
ParsedSqlNode
是MiniOB存放SQL语句解析结果的数据结构,我们可以直接查看其源码,看看它由什么构成:
cpp
/**
* @brief 表示一个SQL语句
* @ingroup SQLParser
*/
class ParsedSqlNode
{
public:
enum SqlCommandFlag flag;
ErrorSqlNode error; // 代表SQL语句解析错误,存放错误的位置已经错误的信息
CalcSqlNode calc; //
SelectSqlNode selection;
InsertSqlNode insertion;
DeleteSqlNode deletion;
UpdateSqlNode update;
CreateTableSqlNode create_table;
DropTableSqlNode drop_table;
AnalyzeTableSqlNode analyze_table;
CreateIndexSqlNode create_index;
DropIndexSqlNode drop_index;
DescTableSqlNode desc_table;
LoadDataSqlNode load_data;
ExplainSqlNode explain;
SetVariableSqlNode set_variable;
public:
ParsedSqlNode();
explicit ParsedSqlNode(SqlCommandFlag flag);
};
存放Create Table语句解析结果的数据结构
可以看到,MiniOB中并非采用继承来表示SQL语句的解析结果。
而是将所有代表语句解析结果的类作为成员变量放在了ParsedSqlNode
中,SqlCommandFlag
存放着语句类型,是哪个语句类型,对应的代表SQL语句解析结果的成员变量就会有效。
我猜你现在一定很好奇这些SqlNode里面到底存了什么。就看看CreateTableSqlNode
吧:
cpp
/**
* @brief 描述一个create table语句
* @ingroup SQLParser
* @details 这里也做了很多简化。
*/
struct CreateTableSqlNode
{
string relation_name; // Relation name
vector<AttrInfoSqlNode> attr_infos; // attributes
vector<string> primary_keys; // primary keys
// TODO: integrate to CreateTableOptions
string storage_format; // storage format
string storage_engine; // storage engine
};
我们看到这里面的信息更我们前面所描述的差不多,主键方面有所偏差(我前面的例子有点欠考虑了)。
storage_format
和storage_engine
应该是用来决定什么样的存储格式或者存储引擎的。
这些值将会怎么样被设置呢?这就需要我们查看bison文件:
cpp
create_table_stmt: /*create table 语句的语法解析树*/
CREATE TABLE ID LBRACE attr_def_list primary_key RBRACE storage_format
{
$$ = new ParsedSqlNode(SCF_CREATE_TABLE);
CreateTableSqlNode &create_table = $$->create_table;
create_table.relation_name = $3;
//free($3);
create_table.attr_infos.swap(*$5);
delete $5;
if ($6 != nullptr) {
create_table.primary_keys.swap(*$6);
delete $6;
}
if ($8 != nullptr) {
create_table.storage_format = $8;
}
}
;
解析Create Table语句的过程
在bison中,产生式可以用左部: 右部1 {语义计算1}| 右部2 {语义计算2}| 右部3 {语义计算3};
来进行表示,这里的create_table_stmt
是一个左部(即非终结符)。
CREATE TABLE ID LBRACE attr_def_list primary_key RBRACE storaged_format
是一个右部,它的组成为:
CREATE
终结符,是大小写不敏感的create
TABLE
终结符,是大小写不敏感的table
ID
终结符,标识符,其语义值是一个字符串,$3
可以引用对应右部从左往右数第三个符号的语义值,在这里$3
代表的就是ID
终结符的语义值。LBRACE
终结符,即左括号(
。attr_def_list
非终结符,解析出属性信息,其语义值大概是一个属性信息的列表。primary_key
非终结符,解析出主键信息,其语义值大概是一个主键名列表。RBRACE
终结符,即右括号)
。storaged_format
非终结符,语义值是一个字符串,代表存储格式。
这里我们发现没有解析
storaged engine
的语义计算部分,CreateTableSqlNode
描述的一样,这部分的内容是TODO有待实现。
其语义计算部分干了的事情:
- 申请一个
ParsedSqlNode
并将其flag设置为SCF_CREATE_TABLE
,代表这是一个CREATE TABLE命令。$$ =
这个赋值操作的含义是:设置产生式右部的语义值为指向一个ParsedSqlNode
的指针。 - 然后为这个刚申请的
ParsedSqlNode
里的CreateTableSqlNode
设置变量,relation_name
是表名,3号位是表名的位置,而$3
引用了它。 - 以此类推,将
attr_infos
,primary_keys
和storage_format
均设置上了。
如果要进一步深究,那就可以去研究attr_def_list
产生式的语义计算如何得出attr_infos
这个语义值的以及primary_key
产生式是如何得出primary_keys
这个语义值的。
我们先来看AttrInfoSqlNode
是什么,才能知道attr_def_list
要解析什么出来:
cpp
/**
* @brief 描述一个属性
* @ingroup SQLParser
* @details 属性,或者说字段(column, field)
*/
struct AttrInfoSqlNode
{
AttrType type; ///< Type of attribute
string name; ///< Attribute name
size_t length; ///< Length of attribute
};
接下来我们就看看attr_def_list
产生式中,单个AttrInfoSqlNode
是如何被解析出来的,然后看看多个又是怎么处理的:
cpp
attr_def:
ID type LBRACE number RBRACE
{
$$ = new AttrInfoSqlNode;
$$->type = (AttrType)$2;
$$->name = $1;
$$->length = $4;
}
| ID type
{
$$ = new AttrInfoSqlNode;
$$->type = (AttrType)$2;
$$->name = $1;
$$->length = 4;
}
;
number:
NUMBER {$$ = $1;}
;
type:
INT_T { $$ = static_cast<int>(AttrType::INTS); }
| STRING_T { $$ = static_cast<int>(AttrType::CHARS); }
| FLOAT_T { $$ = static_cast<int>(AttrType::FLOATS); }
| VECTOR_T { $$ = static_cast<int>(AttrType::VECTORS); }
;
type
其实就是(大小写不敏感):
- 单词是
int
就将其语义值设置成AttrType::INTS
; - 单词是
char
就将其语义值设置成AttrType::CHARS
; - 单词是
float
就将其语义值设置成AttrType::FLOATS
; - 单词是
vector
就将其语义值设置成AttrType::VECTORS
;
number
很好理解,语义值就是一个数字。
attr_def
有两种产生式:
ID type LBRACE number RBRACE
,比如name char(10)
,这种的话AttrInfoSqlNode
就将length
设置为括号里面的数字。ID type
,比如age int
或者salary float
,length
就默认为4。
从这我们就可以发现attr_def
的语义值是一个指向AttrInfoSqlNode
的指针,里面已经存放好了解析完成的属性信息。
attr_def_list
则将这些指针集中成一个数组:
cpp
attr_def_list:
attr_def
{
$$ = new vector<AttrInfoSqlNode>;
$$->emplace_back(*$1);
delete $1;
}
| attr_def_list COMMA attr_def
{
$$ = $1;
$$->emplace_back(*$3);
delete $3;
}
;
产生式推导到最后一定是一个attr_def_list: attr_def
结尾,此时是构造数组的时机,因此这里构造一个数组,并将attr_def
的语义值插入,最后释放指针(因为已经值已经存到数组中了)。
attr_def_list: attr_def_list COMMA attr_def
,则是通过不断规约,然后将attr_def
指向的AttrInfoSqlNode
添加到数组中。
最后的结果就是attr_def_list
的语义值就是括号内声明的所有属性的信息。
通过类似的方式去研究,primary_key
:
cpp
primary_key:
/* empty */
{
$$ = nullptr;
}
| COMMA PRIMARY KEY LBRACE attr_list RBRACE
{
$$ = $5;
}
;
attr_list:
ID {
$$ = new vector<string>();
$$->push_back($1);
}
| ID COMMA attr_list {
if ($3 != nullptr) {
$$ = $3;
} else {
$$ = new vector<string>;
}
$$->insert($$->begin(), $1);
}
;
attr_list
分析的大致思路和attr_def_list
的思路差不多,只不过从attr_def
变成了string
,最终attr_list
的语义值为一个string的列表,每个string都是一个属性名。
primary_key
则是在attr_list
的外面套了一层产生式,应该是为了添加语法规则,明确主键的声明方式。
storaged_format
产生式比较简单,和primary_key
一样是为了添加语法规则而存在的(不然的话直接放个ID
就行了):
cpp
storage_format:
/* empty */
{
$$ = nullptr;
}
| STORAGE FORMAT EQ ID
{
$$ = $4;
}
;
在这个过程中,由flex生成的代码提供词法分析的能力,由bison生成的代码提供语法分析(从定义的产生式可以得到)和语义计算的能力(语义计算的代码由我们自己写)。
可以发现,我们只需要真正需要我们花时间的是语义计算部分该怎么写,我们只需要定义好产生式和语义计算的代码,而无需关心语法分析怎么搞(该采用LR分析法还是LL分析法,使用LR分析法怎么把状态机搞出来),也需要关心词法分析怎么搞(只需要定义匹配到某些字符串作出什么动作)。
如果你曾经编写过编译器(比如学校课程设计要求你写编译器)并且没有使用到flex和bison,你应该能够理解flex和bison所带来的便利。
下一节将尝试使用flex和bison实现自己的SQL解析器解析CREATE TABLE
语句。