OLAP – Phase 8 SQL Parser
The execution engine from Phases 5-7 works, but it requires manually constructing operator trees and expression objects in Go code. A real database accepts SQL text from users. This phase builds a SQL parser that converts a SQL string into an Abstract Syntax Tree (AST) — a structured representation that the planner (Phase 9) can transform into a physical execution plan.
The parser has two stages: a lexer that splits the SQL string into tokens, and a recursive descent parser that builds the AST from the token stream.
In DuckDB, the parser is based on PostgreSQL’s parser (src/parser/), which uses a grammar file and bison-generated parser. Our implementation uses hand-written recursive descent, which is simpler and easier to extend.
Here is the roadmap for the phases to come:
- Phase 8: SQL parser
- Phase 9: Query planner and optimizer
- Phase 10: Sorting, parallel execution, REPL, and server
Full Source Code
The code referenced in this post can be found in https://gitlab.com/kimserey.lam/olap-learn.
Tokens
The lexer produces a stream of tokens — the smallest meaningful units of SQL:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
type TokenType uint8
const (
TokenEOF TokenType = iota
TokenIdent
TokenInt
TokenFloat
TokenString
TokenStar
TokenComma
TokenDot
TokenSemicolon
TokenLParen
TokenRParen
TokenEquals
TokenNotEquals
TokenLessThan
TokenLessThanOrEqual
TokenGreaterThan
TokenGreaterThanOrEqual
TokenPlus
TokenMinus
TokenSlash
// keywords
TokenSelect
TokenFrom
TokenWhere
TokenAnd
TokenOr
TokenNot
TokenJoin
TokenInner
TokenLeft
TokenRight
TokenOn
TokenGroup
TokenBy
TokenHaving
TokenOrder
TokenAsc
TokenDesc
TokenLimit
TokenOffset
TokenInsert
TokenInto
TokenValues
TokenCreate
TokenTable
TokenCopy
TokenDistinct
TokenAs
TokenNull
TokenNulls
TokenFirst
TokenLast
TokenTrue
TokenFalse
// type keywords
TokenInt32Type
TokenInt64Type
TokenFloat64Type
TokenVarcharType
TokenBooleanType
)
Each token carries its type, literal value, and source position.
Lexer
The lexer scans the SQL string character by character, producing tokens:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type Lexer struct {
input string
pos int
}
func (l *Lexer) NextToken() Token {
l.skipWhitespace()
if l.pos >= len(l.input) {
return Token{Type: TokenEOF}
}
ch := l.input[l.pos]
switch {
case ch == '*':
l.pos++
return Token{Type: TokenStar}
case ch == '\'':
return l.readString()
case isDigit(ch):
return l.readNumber()
case isLetter(ch) || ch == '_':
return l.readIdentOrKeyword()
// ... operators, punctuation
}
}
Keywords are recognized by checking the identifier against a lookup map — "SELECT" → TokenSelect, "FROM" → TokenFrom, etc. Identifiers that aren’t keywords remain as TokenIdent.
AST Nodes
The AST is a tree of nodes representing the structure of the SQL statement:
Statements
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
type SelectStmt struct {
Distinct bool
SelectList []SelectItem
From *TableRef
Joins []JoinClause
Where Expr
GroupBy []Expr
Having Expr
OrderBy []OrderByItem
Limit Expr
Offset Expr
}
type CreateTableStmt struct {
Name string
Columns []ColumnDef
}
type InsertStmt struct {
Table string
Values [][]Expr
}
type CopyStmt struct {
Table string
FilePath string
}
Expressions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type ColumnRef struct {
Table string // optional qualifier: "o" in "o.region"
Name string // "region"
}
type BinaryExpr struct {
Left Expr
Op string // "+", "-", "=", ">", "AND", etc.
Right Expr
}
type FunctionCall struct {
Name string // "SUM", "COUNT", etc.
Args []Expr
Distinct bool // COUNT(DISTINCT x)
Star bool // COUNT(*)
}
type IntLit struct{ Value int64 }
type FloatLit struct{ Value float64 }
type StringLit struct{ Value string }
type BoolLit struct{ Value bool }
type NullLit struct{}
type StarExpr struct{}
Recursive Descent Parser
The parser consumes tokens and builds the AST using a top-down approach. Each grammar rule becomes a method:
Operator Precedence
The key challenge is parsing expressions with correct precedence. We handle this by nesting parse functions — each level handles operators of one precedence:
1
2
3
4
5
6
7
8
parseExpr() → OR
parseAndExpr() → AND
parseNotExpr() → NOT
parseCompExpr() → =, !=, <, >, <=, >=
parseAddExpr() → +, -
parseMulExpr() → *, /
parseUnary() → unary -
parsePrimary() → literal, column, function, (expr)
For example, a + b * c > 10 AND x = 1 parses as:
1
2
3
4
5
6
7
8
9
10
11
AND
├── >
│ ├── +
│ │ ├── a
│ │ └── *
│ │ ├── b
│ │ └── c
│ └── 10
└── =
├── x
└── 1
Parsing SELECT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
func (p *Parser) parseSelect() (*SelectStmt, error) {
p.expect(TokenSelect)
stmt := &SelectStmt{}
// DISTINCT?
if p.match(TokenDistinct) {
stmt.Distinct = true
}
// select list
stmt.SelectList = p.parseSelectList()
// FROM
p.expect(TokenFrom)
stmt.From = p.parseTableRef()
// JOINs
for p.isJoinKeyword() {
stmt.Joins = append(stmt.Joins, p.parseJoinClause())
}
// WHERE
if p.match(TokenWhere) {
stmt.Where = p.parseExpr()
}
// GROUP BY
if p.matchSequence(TokenGroup, TokenBy) {
stmt.GroupBy = p.parseExprList()
}
// HAVING
if p.match(TokenHaving) {
stmt.Having = p.parseExpr()
}
// ORDER BY
if p.matchSequence(TokenOrder, TokenBy) {
stmt.OrderBy = p.parseOrderByList()
}
// LIMIT / OFFSET
if p.match(TokenLimit) {
stmt.Limit = p.parseExpr()
if p.match(TokenOffset) {
stmt.Offset = p.parseExpr()
}
}
return stmt, nil
}
Parsing Function Calls
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (p *Parser) parseFunctionCall(name string) *FunctionCall {
p.expect(TokenLParen)
fn := &FunctionCall{Name: name}
if p.match(TokenStar) {
fn.Star = true
} else if p.match(TokenDistinct) {
fn.Distinct = true
fn.Args = p.parseExprList()
} else if !p.check(TokenRParen) {
fn.Args = p.parseExprList()
}
p.expect(TokenRParen)
return fn
}
Example Parse
1
2
3
4
SELECT o.region, SUM(o.amount)
FROM orders o
WHERE o.year > 2023
GROUP BY o.region;
Produces:
1
2
3
4
5
6
7
8
9
10
11
12
13
SelectStmt {
SelectList: [
ColumnRef{Table: "o", Name: "region"},
FunctionCall{Name: "SUM", Args: [ColumnRef{Table: "o", Name: "amount"}]}
],
From: TableRef{Name: "orders", Alias: "o"},
Where: BinaryExpr{
Left: ColumnRef{Table: "o", Name: "year"},
Op: ">",
Right: IntLit{Value: 2023}
},
GroupBy: [ColumnRef{Table: "o", Name: "region"}]
}
This AST is pure syntax — it doesn’t know whether orders is a real table, whether region is a valid column, or what type amount is. That’s the binder’s job in Phase 9.
Summary
The parser converts SQL text into a structured AST through two stages: lexing (character stream → tokens) and recursive descent parsing (token stream → AST). Operator precedence is handled by nesting parse functions. The AST captures the full structure of SELECT, CREATE TABLE, INSERT, and COPY statements, ready for semantic analysis and planning in the next phase.