Assessment of the CREATE TABLE parser as the foundation for 4.1 transform() improvements: fit for 4.1, two tokenizer bugs to fix first (comments, numeric literal defaults), test wiring, and design decisions around round-trip fidelity and API visibility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
8 KiB
Review: create-table-parser branch
Feedback on the create-table-parser branch (commits ed1de15, 95d6ff4,
407db59 — 819-line create_table_parser.py plus JSON test corpora),
reviewed as the proposed foundation for 4.1 transform() improvements that
make UNIQUE and CHECK constraints supported instead of silently dropped.
Verdict: good fit for 4.1 — right design, right release — but it has two
silent-corruption bugs that must be fixed before transform() can trust it,
and a few design decisions to settle.
Why 4.1 is the right home
- The parser is purely additive: a new module and a new capability. Nothing about it needs to land in 4.0, so it does not constrain or delay the stable release.
- The
transform()improvement it enables — preserving CHECK and UNIQUE constraints instead of silently dropping them — is a behavior change in the bug-fix direction, appropriate for a minor release with a prominent changelog note. - SQLite exposes no introspection for CHECK constraints (and only partial
detail elsewhere), so parsing the
sqlite_masterDDL is the correct — and only — approach. The module docstring says exactly this.
What holds up well (verified empirically)
- Corpus results: all 272 statements in
tests/valid_create_table.jsonparse without crashing; the 230 that are independently executable were cross-checked against SQLite's ownPRAGMA table_xinfo— 0 column-name or primary-key mismatches. The 29 invalid statements also do not crash the parser. - Survived adversarial probing: identifier quoting in all four styles
including embedded escaped quotes (
"col ""x""",[square], backticks), strings containing,)and keywords inside CHECK expressions,col IN (...)option extraction with escaped quotes, generated columns (GENERATED ALWAYS AS ... STOREDand bareAS), FK action clauses (ON DELETE SET NULL ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED),WITHOUT ROWID, STRICTtrailers, keyword-named columns ("unique"), multi-word types with parenthesized arguments, andCREATE TABLE AS SELECT. - The design is genuinely good: recursive-descent functions mirroring the
SQLite grammar are readable; the constraint list as source of truth with
derived accessor properties (
table.checks,col.not_null,table.primary_key) is the right model — adding a constraint kind means adding a dataclass and a property. The group-capturing tokenizer (parenthesized groups taken whole so nested commas never reach the parser) is a clean way to sidestep expression parsing.
Must-fix before transform() builds on it
1. Comments produce phantom columns (silent corruption)
The tokenizer does not handle -- line comments or /* */ block comments,
and sqlite_master stores DDL verbatim. Reproduced:
CREATE TABLE t (
a TEXT, -- user's name, (important)
b INTEGER
)
→ columns parsed as ['a', '-'] (real: ['a', 'b'])
CREATE TABLE t (a TEXT /* legacy, do not use */, b INTEGER)
→ columns parsed as ['a', 'do', 'b'] (real: ['a', 'b'])
Hand-written schemas — exactly the ones that have CHECK constraints — are
exactly the ones with comments. If transform() rebuilds a table from a
model with wrong columns, that is data loss. Comment handling belongs in
_tokenize / _locate_body / _split_top_level (all three scan raw text).
2. Numeric literal defaults are truncated (silent corruption)
_tokenize splits 1.5 into 1 / . / 5 and _default_value consumes
one token. Reproduced:
| DDL | parsed default | correct |
|---|---|---|
DEFAULT 1.5 |
'1' |
1.5 |
DEFAULT -1.5 |
'-1' |
-1.5 |
DEFAULT 1e-3 |
'1e' |
1e-3 |
DEFAULT x'0102' |
'x' |
x'0102' |
Re-emitting these in a transform rewrites DEFAULT 1.5 as DEFAULT 1 —
silent schema corruption. Notably the corpus already contains
DEFAULT -45.8e22, but nothing catches this because the fixtures are
input-only (see next point). Fix by lexing numeric literals (including
sign, decimal point, exponent) and blob literals (x'...') as single
tokens, or by making _default_value consume the full literal.
3. No tests are actually executed
The JSON corpora are not wired into pytest — there is no test_*.py on the
branch, so the parser currently has zero executed tests. Beyond wiring the
corpus in, add expected-output snapshots (parse each valid statement,
assert the full structured result), not just "doesn't crash": the
DEFAULT -45.8e22 bug sat undetected in the corpus precisely because only
inputs are recorded. A cheap high-value addition: property test that for
every executable statement, parsed column names / pk / not-null match
PRAGMA table_xinfo.
Design decisions to settle
Discard-vs-error policy for grammar the model does not capture
The plan is presumably parse → modify model → serialize, which matches how
transform() already rebuilds tables. That makes "what the model discards"
the critical list, because anything discarded is silently stripped from the
user's schema on transform:
ON CONFLICTclauses on PRIMARY KEY / UNIQUE / NOT NULL (parsed by_conflict_clause, thrown away)- FK
MATCHandDEFERRABLE INITIALLY DEFERRED(parsed, thrown away — deferred FKs are real in the wild) ASC/DESCin table-levelPRIMARY KEY (a DESC)/UNIQUE (...)column lists (_column_list_groupkeeps only the leading identifier; inline single-column PK order is captured)- Unknown column constraints (
_column_constraintconsumes one token and returnsNone)
Either capture these in the model, or have transform() raise
TransformError when the source DDL contains grammar it cannot round-trip —
the honest-failure pattern 3.38 established for un-recreatable indexes.
Silence is the one wrong answer.
Same question for column renames: a renamed column referenced inside a CHECK
expression (stored as a raw string) cannot be mechanically rewritten, so
transform(rename=...) intersecting a check should raise TransformError
rather than emit a stale constraint. Likewise dropping a column referenced
by a table-level CHECK or multi-column UNIQUE.
Ship it private in 4.1
The module defines Table, Column, and ForeignKey — the package already
has db.Table and a db.ForeignKey namedtuple with different, incompatible
fields ((table, column, other_table, other_column) vs
(columns, table, references, ...)). Two public ForeignKey types in one
package is a confusion trap, and 4.0 is a fresh reminder that public surface
is forever. Recommendation: land it as a private module
(sqlite_utils._parser or similar) powering transform() in 4.1, promote
to documented public API in a later minor once battle-tested. Public-later
is additive; public-now-retract-later is breaking.
Related naming nit: the parser's Table.schema is the attached-database
name (main/temp), while db.Table.schema is the DDL string. Rename one
(e.g. database or schema_name) before anything goes public.
Housekeeping
create_table_parser.pylives at the repo root; it needs to move inside thesqlite_utils/package.parse_checksis documented as a "backwards-compatible helper" but nothing in sqlite-utils exists for it to be compatible with — stale comment from an earlier draft.- The valid corpus is strong on grammar coverage but light on real-world mess: add fixtures with comments, tabs/newlines in odd places, numeric and blob defaults, and a couple of schemas generated by sqlite-utils itself (double-quoted everything) and by common tools.
Bottom line
Hold it for 4.1 exactly as planned — it has no 4.0 dependency and should not
delay the stable release. Before transform() builds on it: fix comment
handling and numeric/blob literal lexing, wire the fixtures into pytest with
expected-output snapshots, decide the discard-vs-error policy, and keep the
module private for one release. The bones are solid; both bugs are
tokenizer-level and small.