mirror of
https://github.com/simonw/sqlite-utils.git
synced 2026-07-23 17:34:32 +02:00
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
167 lines
8 KiB
Markdown
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.
|