A small SQL parser, handwritten on top of FE.
It lexes and parses a substantial subset of SQL into an arena-allocated AST, and can print that AST
back out as SQL.
Diagnostics carry precise path:row:col locations, and the parser recovers rather than giving up on
the first error.
This is a compact, readable example of a handwritten recursive-descent frontend:
- a UTF-8-aware lexer with a keyword table and one character of lookahead,
- a precedence-climbing expression parser with two tokens of lookahead,
- an arena-allocated AST that owns its nodes and streams itself back to SQL,
- a black-box test suite that holds the parser and the printer to each other.
It is deliberately small enough to read in one sitting.
Statements
A program is a ;-separated list of statements - not of arbitrary expressions.
A stray ; is an empty statement and is skipped.
CREATE TABLE- column definitions, and column- and table-level constraints:NOT NULL,PRIMARY KEY,UNIQUE,CHECK,DEFAULT,REFERENCES,FOREIGN KEY, and namedCONSTRAINTs, withON DELETE/ON UPDATEreferential actions. Also[GLOBAL|LOCAL] TEMPORARY,IF NOT EXISTS, andCREATE TABLE ... AS <query>.CREATE [OR REPLACE] VIEWwith an optional column list andWITH [CASCADED|LOCAL] CHECK OPTION,CREATE [UNIQUE] INDEX, andCREATE SCHEMA.ALTER TABLE-ADD/DROPa column or a constraint,ALTER COLUMNto set or drop aDEFAULT, aNOT NULL, or the data type, andRENAMEthe table or a column.DROP TABLE|VIEW|INDEX|SCHEMA, withIF EXISTSandCASCADE/RESTRICT;TRUNCATE TABLE.SELECT-ALL/DISTINCT, aliases with and withoutAS,WHERE,GROUP BY,HAVING,WINDOW. TheFROMclause is optional, soSELECT 1parses.INSERT INTO- from aVALUEStable, from a query, orDEFAULT VALUES.UPDATE/DELETE- with an optional correlation name andWHEREclause.- Transaction control:
START TRANSACTION/BEGIN,COMMIT,ROLLBACK [TO SAVEPOINT ...],SAVEPOINT, andRELEASE SAVEPOINT. - Names are qualified wherever a table is named:
s.t,cat.sch.tab.
Query expressions
WITH [RECURSIVE]common table expressions, each with an optional column list.UNION,INTERSECT, andEXCEPT, each withALL/DISTINCT.INTERSECTbinds tighter, and both chains are left-associative.- A
VALUEStable and the explicitTABLE <name>stand on their own as queries. ORDER BYwithASC/DESCandNULLS FIRST/NULLS LAST, plusOFFSET,FETCH, andLIMITin any order and combination.GROUP BYelements beyond a plain expression:ROLLUP,CUBE,GROUPING SETS, and the empty grouping set().- Subqueries anywhere an expression is allowed, including derived tables in
FROM,LATERALones, andUNNEST(...) WITH ORDINALITY.
Joins
INNER,LEFT,RIGHT, andFULL(with optionalOUTER), plusCROSSandNATURAL.ON <condition>andUSING (<columns>), in arbitrarily long chains.
Value expressions
- The usual arithmetic, comparison, and boolean operators, correctly ranked and left-associative,
plus
||concatenation and%. IS [NOT],IS [NOT] DISTINCT FROM,[NOT] IN,[NOT] BETWEEN, andEXISTS.[NOT] LIKEand[NOT] SIMILAR TO, each with an optionalESCAPE.- Quantified comparisons:
a = ANY (...),a > ALL (...),a <> SOME (...). CASEin both the simple and the searched form,CAST(... AS <type>), and... COLLATE <name>.- Function and aggregate calls, including
COUNT(*)andCOUNT(DISTINCT x), with the trailingWITHIN GROUP (ORDER BY ...),FILTER (WHERE ...), andOVERclauses. - Window specifications:
PARTITION BY,ORDER BY, aROWS/RANGE/GROUPSframe withBETWEEN ... AND ...andEXCLUDE, and references to a window named in theWINDOWclause. - The functions the standard spells with keyword-separated arguments:
EXTRACT(f FROM x),SUBSTRING(x FROM a FOR b),TRIM([BOTH] c FROM x),POSITION(a IN b),OVERLAY(x PLACING y FROM a FOR b). - Qualified references such as
t.aandt.*, and qualified calls such ass.f(x).
Types
INTEGER,INT,SMALLINT,BIGINT,BOOLEAN,DATE,REAL,DOUBLE PRECISION,FLOAT,TIME,TIMESTAMP,INTERVAL,NUMERIC,DECIMAL,DEC,CHAR,CHARACTER [VARYING],VARCHAR,BINARY,VARBINARY,BLOB,CLOB- with length and precision arguments.[WITHOUT] TIME ZONE, and an interval qualifier such asINTERVAL DAY(3) TO SECOND(6).- Any identifier is accepted as a type name too, so vendor types like
textoruuidjust work.
Lexical
- Keywords are case insensitive and unquoted identifiers fold to lower case.
- Double-quoted delimited identifiers keep their case; a doubled
"escapes one. - Single-quoted string literals, where a doubled
'escapes one. - Integer literals, and real ones with a fraction and/or an exponent:
1.5,.5,2.5E-3. - Typed literals:
DATE '...',TIME '...',TIMESTAMP '...',INTERVAL '1-2' YEAR TO MONTH. - Dynamic parameter markers in all three spellings:
?,$1, and:name. --line comments and/* ... */block comments.
SQL as standardized has a great many idiosyncrasies, and real-world SQL cheerfully ignores a good number of them. Rather than encoding every restriction in the grammar, this parser accepts a deliberately wider language and leaves the rest to a later check over the AST:
- Reserved words are accepted as identifiers. The standard reserves several hundred words, far
more than any real dialect.
SELECT ... AS characterandFROM aka_title AS atboth parse, as does a reference qualified by a reserved word, likeat.movie_id. - Statements are expressions.
Create,Select,Insertand friends all derive fromExpr, so a subquery needs no separate node hierarchy. The grammar, though, keeps them apart: a statement is a schema, data, or transaction statement or a query expression, and a query expression starts withSELECT,VALUES,TABLE,WITH, or a parenthesis.1 + 2;is a fine expression but no statement, and nothing can hang anORDER BYoff aCREATE TABLE. - Grouping is not a node. Parentheses around a scalar expression are pure grouping and are dropped; around a query they are structural and are kept, because that is what makes it a subquery.
- Non-reserved words are recognized by Sym.
LIMIT,CASCADE,NULLS,VIEWand the like lex as plain identifiers and only mean something in the one place that looks for them, soSELECT limit FROM viewstill parses as a query over a table.
The upshot is that some things parse that a conforming implementation would reject. That is intentional: it keeps the grammar small, and a checking pass has the whole AST to work with.
The trailing ;, on the other hand, is not optional - <direct SQL statement> ends in one, and
saying so gives a better diagnostic than running off the end of the file.
If you have a GitHub account setup with SSH, just do this:
git clone --recurse-submodules git@github.com:leissa/sql.gitOtherwise, clone via HTTPS:
git clone --recurse-submodules https://github.com/leissa/sql.gitThen, build with:
cd sql
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j $(nproc)For a Release build simply use -DCMAKE_BUILD_TYPE=Release.
This needs a C++23 compiler. Abseil and FE come along as submodules; nothing else is required.
./build/bin/sql -d test/parse/select.sql # parse and dump the AST back as SQL
./build/bin/sql --help # list all options
echo 'SELECT * FROM t;' | ./build/bin/sql -d -Use - as the file to read from stdin.
Diagnostics go to stderr and the exit status is non-zero if anything was rejected:
$ ./build/bin/sql test/error/missing_from.sql
test/error/missing_from.sql:1:10: error: expected 'FROM', got 't' while parsing SELECT expression
1 error(s) encountered
The test suite is black box: every test runs the sql binary and inspects only its exit code, its
dump, and its diagnostics.
Nothing links against the parser.
ctest --test-dir build --output-on-failureThere are three kinds of test, one CTest entry per fixture:
| Test | Fixtures | Asserts |
|---|---|---|
parse/parse/<name> |
test/parse/ |
Parses cleanly, and the dump matches the neighboring .out golden. |
error/error/<name> |
test/error/ |
Is rejected, with the diagnostics matching the neighboring .out golden. |
idempotent/... |
test/parse/, test/job/ |
Dumping a dump reproduces it verbatim. |
That last one is the interesting one: it holds the printer and the parser to each other, since
whatever the printer emits, the parser has to read back into the very same AST.
It runs over the curated fixtures and over test/job/, the Join Order
Benchmark - 113 real-world queries plus their
schema, which get no goldens of their own.
To run a single test, or one group:
ctest --test-dir build -R '^parse/parse/expr$' --output-on-failure
ctest --test-dir build -R '^idempotent/job/' --output-on-failureAfter deliberately changing what the parser accepts or how it prints, regenerate the goldens and review the resulting diff:
cmake --build build --target blessUse the following coding conventions:
- class/type names in
CamelCase - constants as defined in an
enumor viastatic constinCamel_Snake_Case - macro names in
SNAKE_IN_ALL_CAPS - everything else like variables, functions, etc. in
snake_case - use a trailing underscore suffix for a
private_or_protected_member_variable_ - don't do that for a
public_member_variable - use
structfor plain old data - use
classfor everything else - visibility groups in this order:
publicprotectedprivate
- prefer
// C++-style commentsover/* C-style comments */ - use
/// three slashes for Doxygenand group your methods into logical units if possible - use Markdown-style Doxygen comments
- methods/functions that return a
boolshould be prefixed withis_ - methods/functions that return a
std::optionalor a pointer that may benullptrshould be prefixed withisa_
For all the other minute details like indentation width etc. use clang-format and the provided .clang-format file in the root of the repository.
The format workflow checks this on every push:
clang-format --dry-run --Werror $(git ls-files '*.cpp' '*.h')In order to run clang-format automatically on all changed files, switch to the provided pre-commit hook:
git config --local core.hooksPath .githooks/Note that you can disable clang-format for a piece of code. In addition, you might want to check out plugins like the Vim integration.
SQL is licensed under the MIT License.