Omni is the SQL toolchain Bytebase runs on: one hand-written recursive descent parser per database engine, and the catalog, completion, analysis, and review packages built on its AST. Bytebase imports these packages. Omni owns every decision about what an engine accepts and how its DDL behaves; Bytebase owns everything that needs its metadata store or a live connection.
- Before adding an engine or a capability layer (catalog, completion, review), read docs/engine-capability-guide.md and docs/PARSER-DEFENSE-MATRIX.md. They say how to build a layer; this file says how to change one.
- Before changing
pg/parserorredshift/parser, read pg/parser/AGENTS.md for the FIRST-set and backtracking rules. - Before reading anything under
docs/plans,docs/specs, ordocs/scenarios, read docs/AGENTS.md: those are historical records. AGENTS.mdfiles are the instruction source of truth;CLAUDE.mdfiles import them.
- Every engine is an independent Go tree under its own top-level directory. Engines share only
metadata(the Bytebase metadata proto) andreview(the SQL Review V2 contract). An engine never imports another engine. - Fork engines began as mechanical copies and diverge as explicit dialect deltas:
redshiftfrompg;mariadbandtidbfrommysql;starrocksfromdoris. A parser fix in a parent usually applies to its forks. Check, port it in the same PR, and name both in the subject (fix(pg,redshift): ...). Forks carry no copies of the parent's planning docs or guides; a fork's file points at the parent's. harness/holds nested Go modules that./...does not reach: external harnesses (conformance,googlesql-spanner,mssql-scriptdom) that tests exec rather than import.<engine>/ast/walk.gois generated bygo run ./cmd/genwalkerin pg, redshift, mysql, mariadb, tidb, mssql, oracle, snowflake, and googlesql. After changing AST node types, rungo generate ./<engine>/ast/...and commit the result. CI verifies only pg, mysql, and mssql stay in sync; for the others, verify it yourself.- Non-test code has zero dependencies. pgx, go-sql-driver, go-mssqldb, go-ora, testcontainers, testify, go-cmp, and yaml in
go.modexist for tests and harnesses only.
These rules were paid for by rework in the PostgreSQL engine (docs/engine-capability-guide.md section 4) and bind every engine:
- Every parse function returns
(node, error). Never return nil in place of an error, and never discard one with_. - Every AST node carries
Loc{Start, End}as byte offsets into the text given to the parser. Start is inclusive; End is exclusive and ends at the node's last token, not at the start of the next one. Unknown is-1, never0. - No input panics the parser: empty, binary, or truncated at any byte. A parse failure is a
ParseErrorcarrying a byte offset. Parseis strict. It rejects what the engine rejects and stops at the first error. Recovery, partial trees, and diagnostic lists belong inParseBestEffortwhere an engine offers one. Do not add tolerance toParse.- A grammar decision cites the upstream grammar or reference page (
gram.yline, documentation URL). When the upstream text does not settle what the engine accepts, ask the engine: that is what the oracle tests are for.
Bytebase's adapters under backend/plugin/parser/<engine> and omni's own packages (catalog, completion, analysis, review) call the parser the same way. Deviate only for a reason written beside the call.
| Need | Call |
|---|---|
| Statement boundaries of a script, valid SQL or not | <engine>.Split or <engine>/parser.Split. Lexical, never fails. A segment holding no statement (a lone ;, a comment) answers Empty(); pg, redshift, partiql, and cassandra return such segments, the other splitters drop them (see Known deviations) |
| Every statement of a script with text, AST, and positions | <engine>.Parse(script), which returns []Statement (pg, redshift, mssql, oracle, cassandra, cosmosdb, mongo) |
| One statement the caller already isolated | <engine>.Parse or parser.Parse, then assert exactly one non-empty statement |
| An incomplete fragment, as in completion | Complete it first, by wrapping it in a full statement or patching a placeholder at the caret, then parse. A failure there is expected and falls back to lexer-based extraction |
| Tokens without a tree, as in limit rewriting or caret lookup | parser.Tokenize or parser.NewLexer |
- Split first, then parse each segment. Every
ParseErrorand everyLocis then relative to text the caller holds. Whole-scriptparser.Parseis for single statements and for internal helpers that report no positions. - Parse once and pass the AST. A package that receives an AST (analysis, review, a catalog after
Exec) does not parse the text again. - Positions leave the parser as byte offsets. They become line and column only at the boundary that reports to a user, through
review.Indexorreview.Position: lines 1-based, columns 1-based and counted in code points, which is what Bytebase'sPositionholds. Do not write the byte-to-rune loop again. - A segment parsed on its own has positions relative to the segment. Parse it in place instead, through a ranged entry such as
oracle/parser.ParseRange(script, seg.ByteStart, seg.ByteEnd)or a lexer base offset as in doris, so positions come out absolute. Never pad the input with spaces (quadratic on large scripts), and never rewriteLocfields through reflection. - A parse error is returned, or reported as a finding with its position. The one accepted downgrade is feature extraction from a definition the engine already accepted (a synced function signature, an index definition), and the comment on the call says so.
ParseErrorhas the same shape in every engine:Message stringandPosition int, the byte offset into the parsed text, implementingerrorand reachable witherrors.As. An engine may add fields such asEnd,Line,Column,RelatedText, orCode, but does not rename those two.Parsereturnserror, not a slice. An engine whose strict parse reports every failure of a multi-statement script (trino, googlesql, doris, starrocks) returns aParseErrorslist that unwraps to each*ParseError;parser.AllErrors(err)andparser.FirstError(err)read it. The elasticsearch REST console parser is the one non-SQL grammar and keeps itsSyntaxError{ByteOffset, Message}.Splitmarks an empty segment withEmpty()and the target is to return it, so a consumer that accounts for every;(Bytebase's execution log) can; theStatementlist fromParseholds only statements with an AST.- DDL enters a catalog only through
catalog.Exec(sql, opts). It splits and parses itself and returns oneExecResultper statement with its line. A synced schema enters throughLoadMetadata. Callers do not pre-split forExec. <engine>/review.Review(ctx, sql, opts, targets)splits and parses once inside, treats a syntax error as a finding at its position, and reports every position throughreview.Index. The contract is in review/review.go.
The rules above are the target. These places do not meet them yet; do not copy them into new code.
Splitin mysql, mariadb, tidb, oracle, snowflake, trino, googlesql, doris, and starrocks drops empty segments instead of returning them markedEmpty(). Bytebase's adapters for those engines cannot show a lone;in the execution log until they keep them.- On the Bytebase side,
ByteOffsetToRunePositionexists five times and adapters disagree on keeping empty segments. Not omni's to fix, but the reasonreview.Indexstays the single implementation.
- Engine dialect and DDL fidelity tests live here, beside the package they cover. Bytebase tests its API and workflow against PostgreSQL only and trusts omni's result, so a behavior Bytebase depends on is pinned by an omni test.
- Test against the engine, not against memory. Parser acceptance, catalog results, and generated DDL are verified against a real engine wherever one can run: testcontainers for pg, mysql, mariadb, tidb, mssql, doris, and starrocks; Trino and the Spanner emulator as CI services; Oracle and Redshift through
ORACLE_PARSER_REF_DSNandREDSHIFT_COMPAT_DSN. Gate a test on an environment variable only when it needs a server CI cannot start. - Container tests fail when Docker is missing; they do not skip. CI runs every test on every PR with no
-shortand no nightly. Never checktesting.Short(), and never park a known gap behind a skip. - Pinned images are part of the recorded expectations:
trinodb/trino:482and the Spanner emulator1.5.54in.github/workflows/ci.yml,postgres:17-alpineinpg/parser. Upgrading one means rerunning its oracle tests and reading the drift, not editing expectations to match. - CI runs the sweeps at
PAREN_FUZZ_SIZE=10000,SPLITTEST_N=1000000, andS3DIFF_N=2000; local defaults are smaller. Trino packages run one at a time because they share the service and run stateful DDL against the same names. - A PR runs the packages
scripts/affected-packages.shderives from the diff. A change togo.mod,proto/,scripts/,harness/,docs/, or any root file runs everything.
During iteration, run the focused test with the regex quoted:
go test ./pg/parser/ -run '^TestName$' -count=1Before handoff, in order:
gofmt -won the Go files you modified. Parts of the tree are not gofmt-clean; format only what you touched.go build ./...go veton the changed packages, and fix every finding your change introduced. Some packages carry older findings (unreachable code inoracle/parser); leave those to their own PR.go generatefor any AST package you changed, then confirmgit status --porcelainis empty apart from your edits.make test-<engine>for every engine you touched. To see what CI will run,scripts/affected-packages.sh origin/main.- For
proto/:make proto, thenmake proto-breaking. - For
harness/:cdinto the module andgo test ./...;./...at the root does not reach it.
Correct failures before rerunning. Report an unavailable prerequisite (no Docker, no DSN) explicitly rather than as a pass.
- Scenario checklists go in
docs/scenarios/<engine>/, dated plans indocs/plans/, dated designs indocs/specs/, per-engine migration notes indocs/migration/<engine>/, and prompt templates inscripts/prompts/. Nothing of that kind goes in the repo root. SKILL.md,PROGRESS*.json, andSCENARIOS-*.mdinside engine directories are artifacts of the batch pipelines that built the engine. They are not guidance for changing it.- Write
<repo root>, never an absolute path, in prompts and guides.
- Subject:
<type>(<scope>): <what changed>, with type one offix,feat,test,perf,ci,docs,choreand scope the engine directory, listing every engine the change touches (fix(mysql,mariadb,tidb): ...), orreview,catalog,cifor cross-engine work. A Linear key goes in parentheses at the end:fix(oracle): support constraint_state clause on constraints (BYT-10010). - One PR per change; a parent and its forks are one change.
- Bytebase pins omni by commit in its
go.mod. A change Bytebase needs is not done until that pin is bumped there and its tests pass.