在MiniOB源码中学习使用Flex与Bison解析SQL语句-第一节

前言

阅读本篇文章要求了解编译原理的基础知识,如:文法、词法分析、语法分析、语义分析等概念,需要对这些概念有基本的认识。

SQL语句解析的大致流程

像是C语言的编译,其大致流程为:

  1. 通过词法分析、语法分析和语义分析,得出四元式列表。
  2. 分析四元式列表,进行代码优化。
  3. 将四元式列表变为具体的汇编语言,使用汇编器将汇编文件转化为二进制文件。

我们可以将这个流程抽象成:

  1. 通过词法分析、语法分析和语义分析,得出可以存在于解析器程序中的数据结构,比如:四元式列表可能就是一个(list<Item>)。
  2. 分析数据结构,进行某种优化。
  3. 执行数据结构。如:将数据结构转化成二进制文件(让CPU读取指令并执行);将数据结构的数据提取出来,调用程序的方法来执行。

我们再将其套用到SQL语句的解析流程上:

  1. 通过词法分析、语法分析和语义分析,得出一个代码级别的数据结构。
  2. 分析语数据结构中关系代数相关的部分,进行关系代数优化。
  3. 优化后的语法分析树中有数据,那么这些数据就可以指导代码去执行查询、插入、建表等操作。

本篇文章,我们要了解的重点是步骤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_typetable_nameattributes设置上即可。

由于建表语句不涉及查询操作,因此不需要进行优化,直接进入执行步骤,数据库会提供一个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_formatstorage_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是一个右部,它的组成为:

  1. CREATE终结符,是大小写不敏感的create
  2. TABLE终结符,是大小写不敏感的table
  3. ID终结符,标识符,其语义值是一个字符串,$3可以引用对应右部从左往右数第三个符号的语义值,在这里$3代表的就是ID终结符的语义值。
  4. LBRACE终结符,即左括号(
  5. attr_def_list非终结符,解析出属性信息,其语义值大概是一个属性信息的列表。
  6. primary_key非终结符,解析出主键信息,其语义值大概是一个主键名列表。
  7. RBRACE终结符,即右括号)
  8. storaged_format非终结符,语义值是一个字符串,代表存储格式。

这里我们发现没有解析storaged engine的语义计算部分,CreateTableSqlNode描述的一样,这部分的内容是TODO有待实现。

其语义计算部分干了的事情:

  1. 申请一个ParsedSqlNode并将其flag设置为SCF_CREATE_TABLE,代表这是一个CREATE TABLE命令。$$ = 这个赋值操作的含义是:设置产生式右部的语义值为指向一个ParsedSqlNode的指针。
  2. 然后为这个刚申请的ParsedSqlNode里的CreateTableSqlNode设置变量,relation_name是表名,3号位是表名的位置,而$3引用了它。
  3. 以此类推,将attr_infosprimary_keysstorage_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 floatlength就默认为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语句。