Normative notes for the code in src/OneLite/Sql/ that later units (name resolution, planner, VM) build on. The target is SQLite's documented dialect (lang_*, lang_keywords, lang_expr, datatype3, limits) as the pinned sqlite3 3.53.4 accepts it. Anything the documents leave open was settled by running statements through the pinned binary.
Entry points
SqlParser.ParseOne(string sql, int start, out int end, ParserOptions? options = null) // Statement? (null at end of text) SqlParser.ParseAll(string sql, ParserOptions? options = null) // ImmutableArray<Statement> SqlWriter.Write(Statement | Expr, bool parenthesize = false) // canonical text
ParseOneparses the statement starting atstart, returns it and setsendto the index after its;(or the end of text). It skips leading empty statements (; ; select 1). A NUL character ends the text, as in SQLite. This is the shapeprepareneeds: the unused tail is the rest ofsqlfromend.ParserOptions.BeginConcurrentacceptsBEGIN CONCURRENT. Off by default because the pinned SQLite rejects it.- Everything is internal for now; consumers inside the assembly use it directly. The tree is not public API.
Errors
A rejected statement throws SqlParseException:
| Member | Meaning |
|---|---|
Message | SQLite's text, byte for byte (near "x": syntax error, unrecognized token: "…", incomplete input, unterminated …, limit messages such as Recursion limit and too many terms in compound SELECT) |
ResultCodeError | the primary code of every parse failure: SQLITE_ERROR (1) |
ErrorOffset | what sqlite3_error_offset returns: the UTF-8 byte offset of the offending token, or -1 when SQLite reports none (incomplete input, limits) |
Position | the same place as a UTF-16 index into the string, or -1 |
Parsing stops at the first error. The parser never returns a partial tree.
The tree
- Every node is an immutable
recordderiving fromSqlNode. Collections areImmutableArray<T>. Value equality comes with records, so tests compare trees structurally (spans excluded from construction but included in equality only if set; compare afterSqlWriter.Writewhen spans are irrelevant). SqlNode.Spanis a half-open[Start, End)range of UTF-16 indexes into the text passed to the parser, set once by the parser while it builds the node. A child's span lies inside its parent's. Spans are the only link back to source text: result-column names, error carets andsqlite_schema.sqltext come from them.- Hierarchy:
Statement(select, insert, update, delete, create table/index/view/trigger/virtual table, drop, alter table forms, attach, detach, begin, commit, rollback, savepoint, release, pragma, vacuum, reindex, analyze, explain),Expr,SelectCore(SimpleSelect,ValuesCore),TableSource(NamedTable,TableFunction,SubquerySource,ParenFrom),ResultColumn, and clause nodes (WithClause,LimitClause,OrderingTerm,FromClause,FromItem,JoinOperator,ReturningClause,UpsertClause, window and frame nodes, column and table constraints). Nameholds an identifier with quoting removed ("a""b"isa"b). Keyword-as-identifier fallbacks (lang_keywords, includingIF) arrive as ordinary names.LiteralExprkeeps the literal's text as written (numbers, strings with quotes removed, blobs); typing and overflow-to-REAL are for the evaluator, not the parser.
Normalizations
The parser removes spelling differences that mean the same thing. Everything else is kept as written.
==isBinaryOp.Eq,<>isNe;IS NOT DISTINCT FROMisIs,IS DISTINCT FROMisIsNot(lang_expr).LIMIT a, bis stored asLimit = b, Offset = a.x ISNULL,x NOTNULLandx NOT NULLareNullTestExpr;x IS NULLandx IS NOT NULLareBinaryExpr(Is/IsNot, …, NULL literal).- Parentheses are kept as
ParenExpr(column naming and carets need the source form). Row values(a, b)with at least two items areRowValueExpr. - A
CONSTRAINT namewith nothing after it is kept as a marker node (DanglingTableConstraintName), because SQLite accepts it in places. PRAGMAvalues keep their kind:Number,Word,String, orKeyword(keywords keep their written case).- A table-options list ignores a leading comma (
create table x(a) , strictparses). GENERATED ALWAYS AS (…)is a column constraint after any other constraint; it is stripped from the declared type only when the type has no size list and the words were bare keywords.
SqlWriter
SqlWriter.Write prints a tree as canonical SQL that parses back to a tree equal to the original after the normalizations above. It is the round-trip oracle, and the base for sqlite_schema.sql text normalisation later. With parenthesize: true every compound expression is wrapped, which removes precedence from the text; the comparer evaluates literal expressions in both forms to check the parser's precedence against the oracle. Quoting uses SqlWriter.QuoteName (double quotes only when needed). The writer checks stack depth and throws InsufficientExecutionStackException rather than overflowing.
Limits
Matching the pinned build's compile-time limits (PRAGMA compile_options): the parser enforces MAX_EXPR_DEPTH 1000, MAX_COMPOUND_SELECT 500 and MAX_VARIABLE_NUMBER 32766. MAX_SQL_LENGTH, MAX_COLUMN and the other limits are checked by later layers, not here.
SQLite fails deeply nested statements with Recursion limit (parser stack, near-less) or Expression tree is too large (maximum depth 1000). Two models reproduce this:
- Parser stack. Each construct costs a weight (paren 1, function call 3 plus 2 per extra argument, binary operand 2, CASE 1/3/5/4, WITH item 5, SELECT 2, FROM 2, parenthesised FROM 2, window spec 6, FILTER 5); the statement fails once the sum passes
StackLimit= 2495. Calibrated per construct family against the oracle, not derived from its internals. - Expression height. Operator and call nesting count 1 per level; a subquery or
EXISTSadds 23, anINlist 2,COLLATE/ISNULL/NOTNULLadd 0.
Measured with tools/OneLite.SqlCompare (run.sh depth 6000, 31 families): all families have the same first failing depth as the oracle except those below.
Known residual differences (limit boundaries only; accepted statements and all other errors are identical):
- Window-call nesting: oracle fails at 277, we fail at 278.
- A subquery nested n deep around an inner chain of m
+terms: the oracle's height grows faster than our flat 23 per level. Minimum failing nesting n for(selectrepeated around1+1…: m=0: 44, m=1: 43, m=2: 42, m=5: 39, m=10: 35, m=20: 28, m=50: 16, m=100: 9, m=200: 4, m=400: 2, m=800: 1. We fail later than the oracle for m>0 (for m=0 we agree). The relation is not linear, and no formula fitting the data was found by probing. Familiessubq+chainN,subq-where-in-col,subq-limit,subq-join-onare affected; forsubq-join-onthe oracle reportsRecursion limitat 249 where we report the height error at 44.
Stack requirement. The parser is recursive. Reaching 2,493 nested parentheses needs about 2 MB of real stack; with less, parsing ends earlier with the same Recursion limit message (it checks RuntimeHelpers.TryEnsureSufficientExecutionStack). The default 1 MB thread stack of Windows threads therefore rejects some statements the oracle accepts at extreme depth. Run the parser on a thread with 4 MB or more if that matters.
Rule for tree consumers. Name resolution, the planner and the evaluator walk the tree recursively; each must call RuntimeHelpers.EnsureSufficientExecutionStack per level (or use an explicit stack) so a tree near the limit cannot overflow the process.
Tokenizer
Lexer is a struct over the string with zero allocation per token (Token carries kind, start and length; text is sliced on demand). Notable rules, all checked against the oracle: /* as the last two characters is a slash and a star, while a longer unterminated comment runs to the end; vertical tab is blank only inside a run that began with another blank; #name is a variable and #NNN… raises a deferred near "#NNN": syntax error; keywords are case-insensitive via a generated table (KeywordTable.g.cs) with the fallback set from lang_keywords.
Verification
tools/OneLite.SqlCompareruns our parser and the pinnedsqlite3(throughsqlite3_prepare_v2on the pinned library, error offset included) on the same text and compares accept/reject, message and offset. Modes:slt(all sqllogictest statements),schema(statement text from real-world and generated databases),gen(grammar-driven generation plus token and character mutation, seeded),check(probe files),depth,alloc. Every accepted statement is round-tripped throughSqlWriter, and every fourth generated statement is a literal-onlySELECTevaluated as written and inparenthesizeform.- A native grammar-action error that comes before our syntax error token is masked, not counted, because SQLite runs actions while parsing (
Checker.EndedBeforeOurError); two errors on a semantic evaluation count as equal. fuzz/OneLite.Fuzz sqlfuzzes the tokenizer and parser for crashes, hangs and writer-reproduction failures.- Unit tests:
tests/OneLite.Tests/Sql/.