sqlite-utils/create-table-parser-feedback.md
Claude f3c012774a
Add review feedback for the create-table-parser branch
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
2026-07-04 17:39:12 +00:00

167 lines
8 KiB
Markdown

# 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_master` DDL 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.json`
parse without crashing; the 230 that are independently executable were
cross-checked against SQLite's own `PRAGMA 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 ... STORED` and bare `AS`), FK action clauses
(`ON DELETE SET NULL ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED`),
`WITHOUT ROWID, STRICT` trailers, keyword-named columns (`"unique"`),
multi-word types with parenthesized arguments, and `CREATE 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 CONFLICT` clauses on PRIMARY KEY / UNIQUE / NOT NULL (parsed by
`_conflict_clause`, thrown away)
- FK `MATCH` and `DEFERRABLE INITIALLY DEFERRED` (parsed, thrown away —
deferred FKs are real in the wild)
- `ASC` / `DESC` in table-level `PRIMARY KEY (a DESC)` / `UNIQUE (...)`
column lists (`_column_list_group` keeps only the leading identifier;
inline single-column PK order *is* captured)
- Unknown column constraints (`_column_constraint` consumes one token and
returns `None`)
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.py` lives at the repo root; it needs to move inside
the `sqlite_utils/` package.
- `parse_checks` is 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.