在MiniOB源码中学习使用Flex与Bison解析SQL语句-第一节
前言
阅读本篇文章要求了解编译原理的基础知识,如:文法、词法分析、语法分析、语义分析等概念,需要对这些概念有基本的认识。
SQL语句解析的大致流程
像是C语言的编译,其大致流程为:
- 通过词法分析、语法分析和语义分析,得出四元式列表。
- 分析四元式列表,进行代码优化。
- 将四元式列表变为具体的汇编语言,使用汇编器将汇编文件转化为二进制文件。
我们可以将这个流程抽象成:
- 通过词法分析、语法分析和语义分析,得出可以存在于解析器程序中的数据结构,比如:四元式列表可能就是一个(
list<Item>
)。 - 分析数据结构,进行某种优化。
- 执行数据结构。如:将数据结构转化成二进制文件(让CPU读取指令并执行);将数据结构的数据提取出来,调用程序的方法来执行。
我们再将其套用到SQL语句的解析流程上:
- 通过词法分析、语法分析和语义分析,得出一个代码级别的数据结构。
- 分析语数据结构中关系代数相关的部分,进行关系代数优化。
- 优化后的语法分析树中有数据,那么这些数据就可以指导代码去执行查询、插入、建表等操作。
本篇文章,我们要了解的重点是步骤1:通过词法分析、语法分析和语义分析,得出一个代码级别的数据结构。
语法分析树,它确实是一棵树,但它不是代码数据结构层面的树,是语法分析过程的形象表述。
代码级别的数据结构
如果我们想要自己实现一个SQL语句的解析器,那么这个代码级别的数据结构应该是什么怎么样的呢?
假设这个SQL语句的解析器只需要解析CREATE TABLE
语句,那么这个代码级别的数据结构可能是这样的:
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
方法给你调用:
void create_table(string table_name, vector<AttributeInfo> attributes);
SQL解析器需要解析出什么样的数据结构,是这样被决定的:当前正在解析的命令的类型,决定需要调用哪些数据库的接口,数据库提供的接口需要的参数决定了SQL解析器必须解析出什么数据结构。
在解析出来了必要的数据结构之后,这些数据结构需要以什么样的方式存在是没有做规定的。我们可以根据需求进行设计。
比如,SQL解析器不只是需要解析一个命令,那么我们可以将存储解析结果的数据结构设计为:
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语句解析结果的数据结构,我们可以直接查看其源码,看看它由什么构成:
/*** @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
吧:
/*** @brief 描述一个create table语句* @ingroup SQLParser* @details 这里也做了很多简化。*/
struct CreateTableSqlNode
{string relation_name; // Relation namevector<AttrInfoSqlNode> attr_infos; // attributesvector<string> primary_keys; // primary keys// TODO: integrate to CreateTableOptionsstring storage_format; // storage formatstring storage_engine; // storage engine
};
我们看到这里面的信息更我们前面所描述的差不多,主键方面有所偏差(我前面的例子有点欠考虑了)。
storage_format
和storage_engine
应该是用来决定什么样的存储格式或者存储引擎的。
这些值将会怎么样被设置呢?这就需要我们查看bison文件:
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
要解析什么出来:
/*** @brief 描述一个属性* @ingroup SQLParser* @details 属性,或者说字段(column, field)*/
struct AttrInfoSqlNode
{AttrType type; ///< Type of attributestring name; ///< Attribute namesize_t length; ///< Length of attribute
};
接下来我们就看看attr_def_list
产生式中,单个AttrInfoSqlNode
是如何被解析出来的,然后看看多个又是怎么处理的:
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
则将这些指针集中成一个数组:
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
:
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
就行了):
storage_format:/* empty */{$$ = nullptr;}| STORAGE FORMAT EQ ID{$$ = $4;};
在这个过程中,由flex生成的代码提供词法分析的能力,由bison生成的代码提供语法分析(从定义的产生式可以得到)和语义计算的能力(语义计算的代码由我们自己写)。
可以发现,我们只需要真正需要我们花时间的是语义计算部分该怎么写,我们只需要定义好产生式和语义计算的代码,而无需关心语法分析怎么搞(该采用LR分析法还是LL分析法,使用LR分析法怎么把状态机搞出来),也需要关心词法分析怎么搞(只需要定义匹配到某些字符串作出什么动作)。
如果你曾经编写过编译器(比如学校课程设计要求你写编译器)并且没有使用到flex和bison,你应该能够理解flex和bison所带来的便利。
下一节将尝试使用flex和bison实现自己的SQL解析器解析CREATE TABLE
语句。