fix(pull): capture index-only projections and render them correctly#193
Conversation
`chkit pull` parsed only the SELECT form of a PROJECTION, so an index-only
projection (`PROJECTION p INDEX (a, b) TYPE basic`) never matched and was
skipped. A pulled schema then recreated the table without it, silently
dropping the pruning it provided, and drift never read clean.
The DSL could not express one either: `ProjectionDefinition` was
`{ name, query }` and the renderer always wrapped the body in parens, which
is invalid for the index-only form.
- core: `ProjectionDefinition` becomes a union of the existing
`{ name, query }` and a new `{ name, index, type }`; the renderer branches
on it and emits the index-only form without wrapping parens.
- core: normalize index expressions the way ClickHouse does — a single
expression bare, several as a tuple. ClickHouse rewrites `INDEX (b)` to
`INDEX b` and rejects `INDEX a, b`, so without this a pulled schema drifts
against its own source table forever.
- clickhouse: parse the index-only form out of create_table_query.
- pull/drift: render and fingerprint both kinds.
Verified end to end against live ClickHouse 26.3: pull captures the
projection, generate emits it, migrate reproduces it byte-for-byte, and
drift reports no projection_mismatch.
Closes #183
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Biy2vG7iixRiBsBBsFg5Nu
Follow-up from review of the index-only projection support. Three real defects, each verified against live ClickHouse 26.3. Normalization did not match what ClickHouse actually stores: - It stripped only one layer of parens, so `((a,b))` normalized to `(a,b)` on the first pass and `(a, b)` on the second. Since the index is normalized at canonicalize time and again at render time, that non-idempotence made every `generate` re-emit a drop + rebuild of the projection. Peel every layer instead. - It only peeled at the top level, but ClickHouse reports `(a, (b))` back as `(a, b)` while keeping a genuine nested tuple `(a, (b, c))`. Peel each element recursively. - It left nested commas alone, so `concat(a,b)` stayed put while ClickHouse stores `concat(a, b)` — permanent drift no migration could fix. Space after every argument separator. All 18 forms are now checked against a live instance: what chkit emits is byte-for-byte what ClickHouse stores back. Two silent failures are now validation errors: - `projection_ambiguous_kind` — an entry setting both `query` and `index` satisfies the union, so TypeScript admits it; it rendered as index-only and discarded the SELECT body with no warning. - `projection_empty_index` — an empty index rendered `INDEX TYPE basic`, failing at migrate time with an opaque ClickHouse syntax error. Also make stripWrappingParens quote-aware, matching splitTopLevelComma: a paren inside a quoted identifier is text, not nesting. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Biy2vG7iixRiBsBBsFg5Nu
|
Pushed 9fe24fe after an adversarial review pass. It found three real defects in the first commit that my own testing missed — all now fixed and re-verified against live ClickHouse 26.3. The normalization did not match what ClickHouse actually stores:
The first was the worst: the index is normalized at canonicalize time and at render time, so a non-idempotent form made every Normalization now peels every paren layer, recurses into each tuple element (while preserving a genuine nested tuple like Two silent failures are now validation errors:
Deliberately not fixed (out of scope, both pre-existing and reproducible on
|
|
Filed the deferred findings as issues so they don't get lost with this branch:
Each has a minimal repro verified against live ClickHouse 26.3.9.1. #197 notes that its fix is best sequenced with #194/#190, since it touches the key-clause parsing path. |
Summary
Fixes #183.
chkit pullparsed only the SELECT form of aPROJECTION, so an index-only projection (PROJECTION p INDEX (a, b) TYPE basic) never matched the parser regex and was skipped. A pulled schema then recreated the table without it — silently dropping the reverse-lookup pruning — and drift never read clean. The DSL couldn't express one either:ProjectionDefinitionwas{ name, query }and the renderer always wrapped the body in parens, which is invalid for the index-only form.One scope correction to the issue: SELECT-style projections were already pulled correctly. Verified against live ClickHouse — only index-only projections were dropped, which is why the reporter's table (whose sole projection is index-only) appeared to lose projections entirely.
ProjectionDefinitionbecomes a union of the existing{ name, query }and a new{ name, index, type }. Existing schemas are unaffected; the renderer branches and emits the index-only form without wrapping parens.INDEX b), several as a tuple (INDEX (a, b)). This matters beyond cosmetics — ClickHouse rewritesINDEX (b)toINDEX band rejectsINDEX a, bwith a syntax error, so without normalization a pulled schema would drift against its own source table forever.create_table_query(system.projectionsis not readable by the default ObsessionDB user, so parsing stays the only viable source).Test plan
typecheck+lintclean across all packagesSHOW CREATE TABLEoutput from a live 26.3 instance), pull rendering, and drift equivalence/mismatchplugin-backfillhas 14 pre-existing failures (missing chunking fixture table) that reproduce identically on cleanmainpullcaptures the projection,generateemits it,migrate --applyreproduces it byte-for-byte, anddriftreports noprojection_mismatchNote for reviewers
While verifying, I found a separate pre-existing bug, left unfixed to keep this PR scoped: a pulled table whose
PRIMARY KEYis derived fromORDER BYalways reportsprimary_key_mismatchon drift. It reproduces on a plainMergeTreetable with no projections at all, so it is independent of this change — likely the same area as #190.🤖 Generated with Claude Code
https://claude.ai/code/session_01Biy2vG7iixRiBsBBsFg5Nu