From 5553828000b75779d6f74e1dac9b2f67a8e4609f Mon Sep 17 00:00:00 2001 From: Alvaro Date: Fri, 17 Jul 2026 12:40:56 +0200 Subject: [PATCH 1/2] fix(pull): capture index-only projections and render them correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) Claude-Session: https://claude.ai/code/session_01Biy2vG7iixRiBsBBsFg5Nu --- .changeset/index-only-projections.md | 10 ++ .../src/content/docs/schema/dsl-reference.md | 15 ++- packages/cli/src/commands/drift/compare.ts | 8 ++ packages/cli/src/test/drift.test.ts | 95 ++++++++++++++ .../clickhouse/src/create-table-parser.ts | 29 +++-- packages/clickhouse/src/index.test.ts | 45 +++++++ packages/core/src/canonical.ts | 9 +- packages/core/src/index.test.ts | 121 ++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/model-types.ts | 17 ++- packages/core/src/projection.ts | 62 +++++++++ packages/core/src/sql.ts | 11 +- packages/plugin-pull/src/index.test.ts | 28 ++++ packages/plugin-pull/src/pull.e2e.test.ts | 114 ++++++++++++++++- packages/plugin-pull/src/render-schema.ts | 6 +- 15 files changed, 547 insertions(+), 24 deletions(-) create mode 100644 .changeset/index-only-projections.md create mode 100644 packages/core/src/projection.ts diff --git a/.changeset/index-only-projections.md b/.changeset/index-only-projections.md new file mode 100644 index 00000000..cbd7d66e --- /dev/null +++ b/.changeset/index-only-projections.md @@ -0,0 +1,10 @@ +--- +"@chkit/clickhouse": patch +"@chkit/plugin-pull": patch +"@chkit/core": patch +"chkit": patch +--- + +Support index-only projections (`PROJECTION p INDEX (a, b) TYPE basic`) end to end. `ProjectionDefinition` is now a union of the existing `{ name, query }` SELECT form and a new `{ name, index, type }` index-only form, which renders without the wrapping parens that made the SELECT form invalid for it. `chkit pull` previously parsed only the SELECT form and dropped index-only projections on the floor, so a pulled schema silently recreated the table without them; they now round-trip through pull, generate, migrate, and drift. + +Index expressions are normalized the way ClickHouse normalizes them — a single expression renders bare (`INDEX a`), several render as a tuple (`INDEX (a, b)`) — so `'(a)'` and `'a'` describe the same table and no longer read as drift. diff --git a/apps/docs/src/content/docs/schema/dsl-reference.md b/apps/docs/src/content/docs/schema/dsl-reference.md index 753cbc1d..9bef64a2 100644 --- a/apps/docs/src/content/docs/schema/dsl-reference.md +++ b/apps/docs/src/content/docs/schema/dsl-reference.md @@ -273,19 +273,32 @@ indexes: [ ## Projections -Each entry in the `projections` array is a `ProjectionDefinition`. +Each entry in the `projections` array is a `ProjectionDefinition`, which takes one of two forms. + +A **SELECT projection** stores a rewritten copy of the data. | Field | Type | Description | |-------|------|-------------| | `name` | `string` | Projection name | | `query` | `string` | Projection SELECT query | +An **index-only projection** stores no SELECT body. It reorders parts by a secondary key so lookups on that key prune instead of scanning. + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `string` | Projection name | +| `index` | `string` | Expression list to order by, e.g. `receiver, sender` | +| `type` | `string` | Projection index type. ClickHouse currently accepts `basic` | + ```ts projections: [ { name: 'p_recent', query: 'SELECT id ORDER BY received_at DESC LIMIT 10' }, + { name: 'by_receiver', index: 'receiver, sender', type: 'basic' }, ] ``` +The `index` expression is rendered the way ClickHouse itself normalizes it: a single expression is emitted bare (`INDEX receiver`) and several are emitted as a tuple (`INDEX (receiver, sender)`). Writing `'(receiver)'` and `'receiver'` therefore produces the same table, and neither reads as drift. + ## `view()` Creates a view definition. diff --git a/packages/cli/src/commands/drift/compare.ts b/packages/cli/src/commands/drift/compare.ts index 64c70dd6..20d60004 100644 --- a/packages/cli/src/commands/drift/compare.ts +++ b/packages/cli/src/commands/drift/compare.ts @@ -1,5 +1,7 @@ import { normalizeEngine as coreNormalizeEngine, + isIndexProjection, + normalizeProjectionIndex, normalizeSQLFragment, type ColumnDefinition, type ProjectionDefinition, @@ -227,6 +229,12 @@ function normalizeIndexShape(index: SkipIndexDefinition): string { } function normalizeProjectionShape(projection: ProjectionDefinition): string { + if (isIndexProjection(projection)) { + return [ + `index=${normalizeProjectionIndex(projection.index)}`, + `type=${projection.type.trim()}`, + ].join('|') + } return `query=${normalizeSQLFragment(projection.query)}` } diff --git a/packages/cli/src/test/drift.test.ts b/packages/cli/src/test/drift.test.ts index 8cce1df2..d218ed17 100644 --- a/packages/cli/src/test/drift.test.ts +++ b/packages/cli/src/test/drift.test.ts @@ -123,6 +123,101 @@ describe('@chkit/cli drift comparer', () => { expect(result).toBeNull() }) + // ClickHouse rewrites a single-column `INDEX (id)` to `INDEX id`, so a schema + // written with parens must not read as drift against the live table. + test('treats an index-only projection as clean regardless of index parens', () => { + const expected = table({ + database: 'app', + name: 'events', + engine: 'MergeTree()', + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'receiver', type: 'String' }, + ], + primaryKey: ['id'], + orderBy: ['id'], + projections: [{ name: 'by_receiver', index: '(receiver)', type: 'basic' }], + }) + + const result = compareTableShape(expected, { + engine: 'MergeTree()', + primaryKey: '(id)', + orderBy: '(id)', + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'receiver', type: 'String' }, + ], + settings: {}, + indexes: [], + projections: [{ name: 'by_receiver', index: 'receiver', type: 'basic' }], + }) + + expect(result).toBeNull() + }) + + test('reports projection_mismatch when an index projection changes type', () => { + const expected = table({ + database: 'app', + name: 'events', + engine: 'MergeTree()', + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'receiver', type: 'String' }, + ], + primaryKey: ['id'], + orderBy: ['id'], + projections: [{ name: 'by_receiver', index: 'receiver, id', type: 'basic' }], + }) + + const result = compareTableShape(expected, { + engine: 'MergeTree()', + primaryKey: '(id)', + orderBy: '(id)', + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'receiver', type: 'String' }, + ], + settings: {}, + indexes: [], + projections: [{ name: 'by_receiver', index: 'receiver', type: 'basic' }], + }) + + expect(result?.reasonCodes).toContain('projection_mismatch') + expect(result?.projectionDiffs).toEqual(['by_receiver']) + }) + + // A SELECT projection and an index-only projection sharing a name are + // different objects; the fingerprint must not collapse them. + test('reports projection_mismatch when a select projection becomes index-only', () => { + const expected = table({ + database: 'app', + name: 'events', + engine: 'MergeTree()', + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'receiver', type: 'String' }, + ], + primaryKey: ['id'], + orderBy: ['id'], + projections: [{ name: 'p', index: 'receiver', type: 'basic' }], + }) + + const result = compareTableShape(expected, { + engine: 'MergeTree()', + primaryKey: '(id)', + orderBy: '(id)', + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'receiver', type: 'String' }, + ], + settings: {}, + indexes: [], + projections: [{ name: 'p', query: 'SELECT receiver' }], + }) + + expect(result?.reasonCodes).toContain('projection_mismatch') + }) + test('treats quoted string defaults and implicit engine settings as equivalent', () => { const expected = table({ database: 'app', diff --git a/packages/clickhouse/src/create-table-parser.ts b/packages/clickhouse/src/create-table-parser.ts index 2e39f0a1..16996782 100644 --- a/packages/clickhouse/src/create-table-parser.ts +++ b/packages/clickhouse/src/create-table-parser.ts @@ -1,9 +1,8 @@ -import { normalizeSQLFragment, splitTopLevelComma } from '@chkit/core' +import { normalizeProjectionIndex, normalizeSQLFragment, splitTopLevelComma } from '@chkit/core' -interface ProjectionDefinitionShape { - name: string - query: string -} +type ProjectionDefinitionShape = + | { name: string; query: string } + | { name: string; index: string; type: string } function parseClauseFromCreateTableQuery( createTableQuery: string | undefined, @@ -142,12 +141,26 @@ export function parseProjectionsFromCreateTableQuery( const parts = splitTopLevelComma(body) const projections: ProjectionDefinitionShape[] = [] for (const part of parts) { + // Index-only projections have no SELECT body, so they must be matched + // before the parenthesized SELECT form. + const indexMatch = part.match( + /^\s*PROJECTION\s+(?:`([^`]+)`|([A-Za-z_][A-Za-z0-9_]*))\s+INDEX\s+([\s\S]+?)\s+TYPE\s+([A-Za-z_][A-Za-z0-9_]*)\s*$/i + ) + if (indexMatch) { + const name = (indexMatch[1] ?? indexMatch[2] ?? '').trim() + const index = normalizeProjectionIndex(indexMatch[3] ?? '') + const type = (indexMatch[4] ?? '').trim() + if (!name || !index || !type) continue + projections.push({ name, index, type }) + continue + } + const match = part.match( - /^\s*PROJECTION\s+(`([^`]+)`|([A-Za-z_][A-Za-z0-9_]*))\s*\(([\s\S]*)\)\s*$/i + /^\s*PROJECTION\s+(?:`([^`]+)`|([A-Za-z_][A-Za-z0-9_]*))\s*\(([\s\S]*)\)\s*$/i ) if (!match) continue - const name = (match[2] ?? match[3] ?? '').trim() - const query = normalizeSQLFragment((match[4] ?? '').trim()) + const name = (match[1] ?? match[2] ?? '').trim() + const query = normalizeSQLFragment((match[3] ?? '').trim()) if (!name || !query) continue projections.push({ name, query }) } diff --git a/packages/clickhouse/src/index.test.ts b/packages/clickhouse/src/index.test.ts index b5e3cffb..22cabaed 100644 --- a/packages/clickhouse/src/index.test.ts +++ b/packages/clickhouse/src/index.test.ts @@ -426,6 +426,51 @@ ORDER BY id;` }, ]) }) + + // Verbatim SHOW CREATE TABLE output from ClickHouse 26.3, including its own + // pretty-printing of the SELECT body. Index-only projections used to be + // dropped here, which is what made `chkit pull` lose them entirely. + test('parses index-only projections alongside select projections', () => { + const query = `CREATE TABLE default.address_counterparts +( + \`sender\` String, + \`receiver\` String, + \`cnt\` UInt64, + PROJECTION by_receiver INDEX (receiver, sender) TYPE basic, + PROJECTION agg_by_sender + ( + SELECT + sender, + sum(cnt) + GROUP BY sender + ) +) +ENGINE = SharedMergeTree +ORDER BY (sender, receiver) +SETTINGS storage_policy = 's3', index_granularity = 8192` + + expect(parseProjectionsFromCreateTableQuery(query)).toEqual([ + { name: 'by_receiver', index: '(receiver, sender)', type: 'basic' }, + { name: 'agg_by_sender', query: 'SELECT sender, sum(cnt) GROUP BY sender' }, + ]) + }) + + // ClickHouse drops the parens around a single-column index expression, so the + // parser sees the bare form even when the table was created with `INDEX (b)`. + test('parses a single-column index projection with a backticked name', () => { + const query = `CREATE TABLE app.events +( + \`a\` String, + \`b\` String, + PROJECTION \`p_one\` INDEX b TYPE basic +) +ENGINE = MergeTree +ORDER BY a` + + expect(parseProjectionsFromCreateTableQuery(query)).toEqual([ + { name: 'p_one', index: 'b', type: 'basic' }, + ]) + }) }) describe('formatConnectionError', () => { diff --git a/packages/core/src/canonical.ts b/packages/core/src/canonical.ts index fa809d6d..8b0c61df 100644 --- a/packages/core/src/canonical.ts +++ b/packages/core/src/canonical.ts @@ -2,7 +2,6 @@ import type { ColumnDefinition, MaterializedViewDefinition, MaterializedViewRefresh, - ProjectionDefinition, SchemaDefinition, SkipIndexDefinition, TableDefinition, @@ -11,6 +10,7 @@ import type { import { normalizeKeyColumns } from './key-clause.js' import { isSchemaDefinition } from './model.js' import { canonicalizeCodec } from './codec.js' +import { canonicalizeProjection } from './projection.js' import { normalizeEngine, normalizeSQLFragment } from './sql-normalizer.js' function sortByName(items: T[]): T[] { @@ -41,13 +41,6 @@ function canonicalizeIndex(index: SkipIndexDefinition): SkipIndexDefinition { } } -function canonicalizeProjection(projection: ProjectionDefinition): ProjectionDefinition { - return { - ...projection, - query: normalizeSQLFragment(projection.query), - } -} - function canonicalizeTable(def: TableDefinition): TableDefinition { const settings = def.settings ? Object.fromEntries( diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index 9a782ebe..640abf84 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -56,6 +56,127 @@ describe('@chkit/core smoke', () => { expect(sql).toContain('PROJECTION `p_recent` (SELECT id ORDER BY id DESC LIMIT 10)') }) + test('renders an index-only projection without wrapping parens', () => { + const counterparts = table({ + database: 'solana', + name: 'address_counterparts', + columns: [ + { name: 'sender', type: 'String' }, + { name: 'receiver', type: 'String' }, + ], + engine: 'AggregatingMergeTree()', + primaryKey: ['sender'], + orderBy: ['sender', 'receiver'], + projections: [{ name: 'by_receiver', index: 'receiver, sender', type: 'basic' }], + }) + + const sql = toCreateSQL(counterparts) + expect(sql).toContain('PROJECTION `by_receiver` INDEX (receiver, sender) TYPE basic') + expect(sql).not.toContain('PROJECTION `by_receiver` (') + }) + + test('renders both projection kinds on the same table', () => { + const events = table({ + database: 'app', + name: 'events', + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'source', type: 'String' }, + ], + engine: 'MergeTree()', + primaryKey: ['id'], + orderBy: ['id'], + projections: [ + { name: 'p_by_source', index: 'source', type: 'basic' }, + { name: 'p_recent', query: 'SELECT id ORDER BY id DESC LIMIT 10' }, + ], + }) + + const sql = toCreateSQL(events) + expect(sql).toContain('PROJECTION `p_by_source` INDEX source TYPE basic') + expect(sql).toContain('PROJECTION `p_recent` (SELECT id ORDER BY id DESC LIMIT 10)') + }) + + // ClickHouse rewrites `INDEX (b)` to `INDEX b` and rejects `INDEX a, b`, so + // the renderer has to emit exactly the form ClickHouse echoes back or drift + // never reads clean. Each expectation here mirrors a form verified against a + // live 26.3 instance. + test('renders index expressions the way ClickHouse normalizes them', () => { + const renderIndex = (index: string): string => { + const sql = toCreateSQL( + table({ + database: 'app', + name: 'events', + columns: [ + { name: 'a', type: 'String' }, + { name: 'b', type: 'String' }, + { name: 'ts', type: 'DateTime' }, + ], + engine: 'MergeTree()', + primaryKey: ['a'], + orderBy: ['a'], + projections: [{ name: 'p', index, type: 'basic' }], + }) + ) + return sql.split('\n').find((line) => line.includes('PROJECTION'))?.trim() ?? '' + } + + expect(renderIndex('b')).toBe('PROJECTION `p` INDEX b TYPE basic') + expect(renderIndex('(b)')).toBe('PROJECTION `p` INDEX b TYPE basic') + expect(renderIndex('(a, b)')).toBe('PROJECTION `p` INDEX (a, b) TYPE basic') + expect(renderIndex('a, b')).toBe('PROJECTION `p` INDEX (a, b) TYPE basic') + expect(renderIndex('(toYYYYMM(ts))')).toBe('PROJECTION `p` INDEX toYYYYMM(ts) TYPE basic') + expect(renderIndex('(toYYYYMM(ts), a)')).toBe( + 'PROJECTION `p` INDEX (toYYYYMM(ts), a) TYPE basic' + ) + }) + + test('treats parens-only differences in an index projection as no change', () => { + const defs = (index: string) => [ + table({ + database: 'app', + name: 'events', + columns: [{ name: 'id', type: 'UInt64' }], + engine: 'MergeTree()', + primaryKey: ['id'], + orderBy: ['id'], + projections: [{ name: 'p_by_id', index, type: 'basic' }], + }), + ] + + expect(planDiff(defs('(id)'), defs('id')).operations).toEqual([]) + }) + + test('plans add and drop for index-only projections', () => { + const base = { + database: 'app', + name: 'events', + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'source', type: 'String' }, + ], + engine: 'MergeTree()', + primaryKey: ['id'], + orderBy: ['id'], + } as const + + const oldDefs = [ + table({ ...base, projections: [{ name: 'p_drop', index: 'source', type: 'basic' }] }), + ] + const newDefs = [ + table({ ...base, projections: [{ name: 'p_add', index: 'source, id', type: 'basic' }] }), + ] + + const plan = planDiff(oldDefs, newDefs) + expect(plan.operations.map((op) => op.type)).toEqual([ + 'alter_table_add_projection', + 'alter_table_drop_projection', + ]) + expect(plan.operations[0]?.sql).toContain( + 'ADD PROJECTION IF NOT EXISTS `p_add` INDEX (source, id) TYPE basic' + ) + }) + test('normalizes comma-delimited key clauses in create table sql', () => { const events = table({ database: 'app', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ce75f806..228c789d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,6 +9,7 @@ export { export { planDiff } from './planner.js' export { createSnapshot } from './snapshot.js' export { splitTopLevelComma } from './key-clause.js' +export { isIndexProjection, normalizeProjectionIndex } from './projection.js' export { normalizeEngine, normalizeSQLFragment } from './sql-normalizer.js' export { toCreateSQL } from './sql.js' export { applyOnClusterToPlan, onClusterClause } from './on-cluster.js' diff --git a/packages/core/src/model-types.ts b/packages/core/src/model-types.ts index 2b3bbf09..e5167808 100644 --- a/packages/core/src/model-types.ts +++ b/packages/core/src/model-types.ts @@ -100,11 +100,26 @@ export type SkipIndexDefinition = SkipIndexBase & } ) -export interface ProjectionDefinition { +export interface SelectProjectionDefinition { name: string query: string } +/** + * An index-only projection (`PROJECTION p INDEX (a, b) TYPE basic`) has no + * SELECT body: it only reorders parts to prune on a secondary key. ClickHouse + * currently accepts `basic` as the only index type, but `type` stays a string + * so new types work without a DSL change. + */ +export interface IndexProjectionDefinition { + name: string + /** Expression list, e.g. `receiver, sender`. Parenthesized on render. */ + index: string + type: string +} + +export type ProjectionDefinition = SelectProjectionDefinition | IndexProjectionDefinition + // biome-ignore lint/suspicious/noEmptyInterface: must be an interface for declaration merging by plugins export interface TablePlugins {} diff --git a/packages/core/src/projection.ts b/packages/core/src/projection.ts new file mode 100644 index 00000000..69064b2a --- /dev/null +++ b/packages/core/src/projection.ts @@ -0,0 +1,62 @@ +import type { IndexProjectionDefinition, ProjectionDefinition } from './model-types.js' +import { splitTopLevelComma } from './key-clause.js' +import { normalizeSQLFragment } from './sql-normalizer.js' + +export function isIndexProjection( + projection: ProjectionDefinition +): projection is IndexProjectionDefinition { + return 'index' in projection +} + +function stripWrappingParens(input: string): string { + if (!input.startsWith('(') || !input.endsWith(')')) return input + + // Only strip when the leading paren closes at the very end, so `(a), (b)` + // keeps both groups. + let depth = 0 + for (let i = 0; i < input.length; i += 1) { + const char = input[i] + if (char === '(') depth += 1 + else if (char === ')') { + depth -= 1 + if (depth === 0) return i === input.length - 1 ? input.slice(1, -1).trim() : input + } + } + return input +} + +/** + * Render an index expression list the way ClickHouse itself echoes it back: + * a single element is bare (`INDEX b`), several are a tuple (`INDEX (a, b)`). + * + * Both halves matter. ClickHouse rewrites `INDEX (b)` to `INDEX b`, so without + * this a pulled schema would drift against the live table forever; and it + * rejects `INDEX a, b` outright, so multiple elements must be parenthesized. + */ +export function normalizeProjectionIndex(index: string): string { + const parts = splitTopLevelComma(stripWrappingParens(normalizeSQLFragment(index))) + if (parts.length === 0) return '' + return parts.length === 1 ? (parts[0] ?? '') : `(${parts.join(', ')})` +} + +export function canonicalizeProjection(projection: ProjectionDefinition): ProjectionDefinition { + // Rebuilt field-by-field rather than spread: the planner compares projections + // with JSON.stringify, which is key-order sensitive. + if (isIndexProjection(projection)) { + return { + name: projection.name, + index: normalizeProjectionIndex(projection.index), + type: projection.type.trim(), + } + } + return { + name: projection.name, + query: normalizeSQLFragment(projection.query), + } +} + +export function renderProjectionBody(projection: ProjectionDefinition): string { + return isIndexProjection(projection) + ? `INDEX ${normalizeProjectionIndex(projection.index)} TYPE ${projection.type.trim()}` + : `(${projection.query})` +} diff --git a/packages/core/src/sql.ts b/packages/core/src/sql.ts index 48104996..91b51d24 100644 --- a/packages/core/src/sql.ts +++ b/packages/core/src/sql.ts @@ -2,6 +2,7 @@ import type { ColumnDefinition, MaterializedViewDefinition, MaterializedViewRefresh, + ProjectionDefinition, SchemaDefinition, SkipIndexDefinition, TableDefinition, @@ -9,6 +10,7 @@ import type { } from './model.js' import { renderCodec } from './codec.js' import { isPlainColumnReference, normalizeKeyColumns } from './key-clause.js' +import { renderProjectionBody } from './projection.js' import { assertValidDefinitions } from './validate.js' function renderDefault(value: string | number | boolean): string { @@ -62,7 +64,7 @@ function renderTableSQL(def: TableDefinition): string { `INDEX \`${idx.name}\` (${idx.expression}) TYPE ${renderIndexType(idx)} GRANULARITY ${idx.granularity}` ) const projections = (def.projections ?? []).map( - (projection) => `PROJECTION \`${projection.name}\` (${projection.query})` + (projection) => `PROJECTION \`${projection.name}\` ${renderProjectionBody(projection)}` ) const body = [...columns, ...indexes, ...projections].join(',\n ') @@ -176,8 +178,11 @@ export function renderAlterDropIndex(def: TableDefinition, indexName: string): s return `ALTER TABLE ${def.database}.${def.name} DROP INDEX IF EXISTS \`${indexName}\`;` } -export function renderAlterAddProjection(def: TableDefinition, projection: { name: string; query: string }): string { - return `ALTER TABLE ${def.database}.${def.name} ADD PROJECTION IF NOT EXISTS \`${projection.name}\` (${projection.query});` +export function renderAlterAddProjection( + def: TableDefinition, + projection: ProjectionDefinition +): string { + return `ALTER TABLE ${def.database}.${def.name} ADD PROJECTION IF NOT EXISTS \`${projection.name}\` ${renderProjectionBody(projection)};` } export function renderAlterDropProjection(def: TableDefinition, projectionName: string): string { diff --git a/packages/plugin-pull/src/index.test.ts b/packages/plugin-pull/src/index.test.ts index c8f99770..91ec3a84 100644 --- a/packages/plugin-pull/src/index.test.ts +++ b/packages/plugin-pull/src/index.test.ts @@ -170,6 +170,34 @@ export default schema(app_events, app_events_view, analytics_daily_mv) expect(content).toContain("export default schema(app_events)") }) + test('renders an index-only projection pulled from a live table', () => { + const content = renderSchemaFile([ + { + kind: 'table', + database: 'solana', + name: 'address_counterparts', + engine: 'AggregatingMergeTree()', + columns: [ + { name: 'sender', type: 'String' }, + { name: 'receiver', type: 'String' }, + ], + primaryKey: ['sender'], + orderBy: ['sender', 'receiver'], + projections: [ + { name: 'by_receiver', index: '(receiver, sender)', type: 'basic' }, + { name: 'agg_by_sender', query: 'SELECT sender, sum(cnt) GROUP BY sender' }, + ], + }, + ]) + + expect(content).toContain( + ' { name: "by_receiver", index: "(receiver, sender)", type: "basic" },' + ) + expect(content).toContain( + ' { name: "agg_by_sender", query: "SELECT sender, sum(cnt) GROUP BY sender" },' + ) + }) + test('renders structured index args in pulled schema', () => { const content = renderSchemaFile([ { diff --git a/packages/plugin-pull/src/pull.e2e.test.ts b/packages/plugin-pull/src/pull.e2e.test.ts index 80c2022c..01c8f2d1 100644 --- a/packages/plugin-pull/src/pull.e2e.test.ts +++ b/packages/plugin-pull/src/pull.e2e.test.ts @@ -1,9 +1,10 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test' -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import type { ResolvedChxConfig } from '@chkit/core' +import type { ResolvedChxConfig, TableDefinition } from '@chkit/core' +import { collectDefinitionsFromModule, toCreateSQL } from '@chkit/core' import { createClickHouseExecutor, type ClickHouseExecutor } from '@chkit/clickhouse' import { getRequiredEnv, @@ -14,6 +15,11 @@ import { import { createPullPlugin } from './index.js' +// A pulled schema imports from the '@chkit/core' package specifier, which does +// not resolve from a temp dir. Point it at the workspace source instead so the +// generated file can be imported back and rendered. +const CORE_ENTRY = join(import.meta.dir, '../../core/src/index.ts') + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -40,6 +46,8 @@ function createResolvedConfig(clickhouse: NonNullable { const liveEnv = getRequiredEnv() @@ -55,6 +63,7 @@ describe('@chkit/plugin-pull live env e2e', () => { await executor.command(`CREATE DATABASE IF NOT EXISTS ${quoteIdent(targetDatabase)}`) await executor.command(`CREATE DATABASE IF NOT EXISTS ${quoteIdent(noiseDatabase)}`) + await executor.command(`CREATE DATABASE IF NOT EXISTS ${quoteIdent(projectionDatabase)}`) }) afterAll(async () => { @@ -70,6 +79,12 @@ describe('@chkit/plugin-pull live env e2e', () => { // Best-effort cleanup for shared e2e environment. } + try { + await executor.command(`DROP DATABASE IF EXISTS ${quoteIdent(projectionDatabase)}`) + } catch { + // Best-effort cleanup for shared e2e environment. + } + await executor.close() }) @@ -77,6 +92,101 @@ describe('@chkit/plugin-pull live env e2e', () => { expect(() => getRequiredEnv()).not.toThrow() }) + // Regression for #183: an index-only projection used to be dropped on pull, + // so recreating from the pulled schema silently lost it. Recreating the table + // from the pulled definition must reproduce the projection byte-for-byte, + // which is also what makes drift read clean. + test( + 'round-trips an index-only projection from live table through pull and back', + async () => { + const prefix = createPrefix('pull_proj') + const sourceTable = `${prefix}counterparts` + const recreatedTable = `${prefix}counterparts_copy` + + const dir = await mkdtemp(join(tmpdir(), 'chkit-plugin-pull-proj-e2e-')) + const pulledSchemaPath = join(dir, 'pulled.ts') + + const plugin = createPullPlugin({ outFile: pulledSchemaPath, overwrite: true }) + const command = plugin.commands[0] + if (!command) { + throw new Error('Pull plugin command not registered') + } + + try { + await executor.command( + `CREATE TABLE ${quoteIdent(projectionDatabase)}.${quoteIdent(sourceTable)} (sender String, receiver String, cnt UInt64, PROJECTION by_receiver INDEX (receiver, sender) TYPE basic, PROJECTION agg_by_sender (SELECT sender, sum(cnt) GROUP BY sender)) ENGINE = MergeTree() PRIMARY KEY (sender) ORDER BY (sender, receiver)` + ) + await waitForTable(executor, projectionDatabase, sourceTable) + + const exitCode = await command.run({ + args: [], + flags: { '--database': [projectionDatabase] }, + jsonMode: true, + options: {}, + configPath: join(dir, 'clickhouse.config.ts'), + config: createResolvedConfig({ + url: liveEnv.clickhouseUrl, + username: liveEnv.clickhouseUser, + password: liveEnv.clickhousePassword, + database: 'default', + secure: liveEnv.clickhouseUrl.startsWith('https://'), + }), + print() {}, + }) + expect(exitCode).toBe(0) + + const pulledSchema = await readFile(pulledSchemaPath, 'utf8') + expect(pulledSchema).toContain( + '{ name: "by_receiver", index: "(receiver, sender)", type: "basic" },' + ) + expect(pulledSchema).toContain('{ name: "agg_by_sender", query:') + + // Load the pulled schema back and recreate the table from it — the + // generate + migrate half of the round trip. + const modulePath = join(dir, 'importable.ts') + await writeFile( + modulePath, + pulledSchema.replace("'@chkit/core'", JSON.stringify(CORE_ENTRY)), + 'utf8' + ) + const pulledModule = await import(modulePath) + const pulledDefinition = collectDefinitionsFromModule(pulledModule).find( + (definition): definition is TableDefinition => + definition.kind === 'table' && definition.name === sourceTable + ) + expect(pulledDefinition).toBeDefined() + if (!pulledDefinition) throw new Error('pulled table definition missing') + + const createSQL = toCreateSQL({ ...pulledDefinition, name: recreatedTable }) + expect(createSQL).toContain('PROJECTION `by_receiver` INDEX (receiver, sender) TYPE basic') + + await executor.command(createSQL) + await waitForTable(executor, projectionDatabase, recreatedTable) + + // The recreated table must be indistinguishable from the source as far + // as projections go — that equality is exactly what drift compares. + // Compare keyed by name, not by position: a pulled schema is + // canonicalized into name order, while the live source keeps creation + // order, and drift keys by name too. + const details = await executor.listTableDetails([projectionDatabase]) + const byName = (table: { name: string } | undefined) => + [...(details.find((entry) => entry.name === table?.name)?.projections ?? [])].sort( + (left, right) => left.name.localeCompare(right.name) + ) + + const sourceProjections = byName({ name: sourceTable }) + expect(sourceProjections).toEqual([ + { name: 'agg_by_sender', query: 'SELECT sender, sum(cnt) GROUP BY sender' }, + { name: 'by_receiver', index: '(receiver, sender)', type: 'basic' }, + ]) + expect(byName({ name: recreatedTable })).toEqual(sourceProjections) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }, + 240_000 + ) + test( 'pulls table fixtures from dedicated database and excludes out-of-scope objects', async () => { diff --git a/packages/plugin-pull/src/render-schema.ts b/packages/plugin-pull/src/render-schema.ts index 14ec4a71..e9f5f3e8 100644 --- a/packages/plugin-pull/src/render-schema.ts +++ b/packages/plugin-pull/src/render-schema.ts @@ -1,5 +1,6 @@ import { canonicalizeDefinitions, + isIndexProjection, isRawCodec, type ColumnCodec, type ColumnCodecSpec, @@ -191,7 +192,10 @@ function renderIndex(index: SkipIndexDefinition): string { function renderProjectionLines(projections: ProjectionDefinition[]): string[] { const lines: string[] = [' projections: ['] for (const projection of projections) { - lines.push(` { name: ${renderString(projection.name)}, query: ${renderString(projection.query)} },`) + const fields = isIndexProjection(projection) + ? `index: ${renderString(projection.index)}, type: ${renderString(projection.type)}` + : `query: ${renderString(projection.query)}` + lines.push(` { name: ${renderString(projection.name)}, ${fields} },`) } lines.push(' ],') return lines From 9fe24fe72d342484043f9f01f3366ec5232ab2aa Mon Sep 17 00:00:00 2001 From: Alvaro Date: Fri, 17 Jul 2026 13:24:59 +0200 Subject: [PATCH 2/2] fix(core): make projection index normalization faithful and guarded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Biy2vG7iixRiBsBBsFg5Nu --- .changeset/index-only-projections.md | 4 +- .../src/content/docs/schema/dsl-reference.md | 6 +- packages/core/src/index.test.ts | 77 +++++++++++++++++++ packages/core/src/model-types.ts | 2 + packages/core/src/projection.ts | 61 ++++++++++++++- packages/core/src/validate.ts | 22 ++++++ packages/plugin-pull/src/pull.e2e.test.ts | 8 +- 7 files changed, 171 insertions(+), 9 deletions(-) diff --git a/.changeset/index-only-projections.md b/.changeset/index-only-projections.md index cbd7d66e..b210e07c 100644 --- a/.changeset/index-only-projections.md +++ b/.changeset/index-only-projections.md @@ -7,4 +7,6 @@ Support index-only projections (`PROJECTION p INDEX (a, b) TYPE basic`) end to end. `ProjectionDefinition` is now a union of the existing `{ name, query }` SELECT form and a new `{ name, index, type }` index-only form, which renders without the wrapping parens that made the SELECT form invalid for it. `chkit pull` previously parsed only the SELECT form and dropped index-only projections on the floor, so a pulled schema silently recreated the table without them; they now round-trip through pull, generate, migrate, and drift. -Index expressions are normalized the way ClickHouse normalizes them — a single expression renders bare (`INDEX a`), several render as a tuple (`INDEX (a, b)`) — so `'(a)'` and `'a'` describe the same table and no longer read as drift. +Index expressions are normalized to the exact form ClickHouse stores — a single expression bare (`INDEX a`), several as a tuple (`INDEX (a, b)`), redundant parens peeled at every level, and a space after each argument separator — so `'(a)'` and `'a'`, or `'concat(x,y)'` and `'concat(x, y)'`, describe the same table and no longer read as drift. + +Two new validation errors guard the new form: `projection_ambiguous_kind` when an entry sets both `query` and `index` (which would otherwise silently discard the SELECT body), and `projection_empty_index` when the index expression is empty (which would otherwise emit invalid DDL). diff --git a/apps/docs/src/content/docs/schema/dsl-reference.md b/apps/docs/src/content/docs/schema/dsl-reference.md index 9bef64a2..13321f16 100644 --- a/apps/docs/src/content/docs/schema/dsl-reference.md +++ b/apps/docs/src/content/docs/schema/dsl-reference.md @@ -297,7 +297,9 @@ projections: [ ] ``` -The `index` expression is rendered the way ClickHouse itself normalizes it: a single expression is emitted bare (`INDEX receiver`) and several are emitted as a tuple (`INDEX (receiver, sender)`). Writing `'(receiver)'` and `'receiver'` therefore produces the same table, and neither reads as drift. +The `index` expression is rendered the way ClickHouse normalizes it: a single expression is emitted bare (`INDEX receiver`), several are emitted as a tuple (`INDEX (receiver, sender)`), redundant parentheses are dropped, and a space follows every argument separator. Writing `'(receiver)'` and `'receiver'` therefore produce the same table, and neither reads as drift. + +A projection must be exactly one of the two kinds. Setting both `query` and `index` on the same entry is a `projection_ambiguous_kind` validation error, and an empty `index` is a `projection_empty_index` error. ## `view()` @@ -456,6 +458,8 @@ chkit validates schema definitions and throws a `ChxValidationError` if any issu - **Duplicate column names** -- repeated column name within a table - **Duplicate index names** -- repeated index name within a table - **Duplicate projection names** -- repeated projection name within a table +- **Ambiguous projection kind** (`projection_ambiguous_kind`) -- a projection sets both `query` and `index`; use one or the other (see [Projections](#projections)) +- **Empty projection index** (`projection_empty_index`) -- an index-only projection whose `index` expression is empty - **Primary key references missing column** -- `primaryKey` includes a bare column name not in `columns` (function expressions like `toDate(ts)` are passed through to ClickHouse unchecked) - **Order by references missing column** -- `orderBy` includes a bare column name not in `columns` (function expressions like `toStartOfHour(ts)` are passed through to ClickHouse unchecked) - **Empty codec chain** (`codec_chain_empty`) -- a `codec` array with no steps; provide at least one codec or omit the field diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index 640abf84..7a6e1d45 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -6,6 +6,7 @@ import { codec, collectDefinitionsFromModule, materializedView, + normalizeProjectionIndex, planDiff, schema, table, @@ -129,6 +130,43 @@ describe('@chkit/core smoke', () => { expect(renderIndex('(toYYYYMM(ts), a)')).toBe( 'PROJECTION `p` INDEX (toYYYYMM(ts), a) TYPE basic' ) + // Redundant parens are peeled at every level, including inside a tuple... + expect(renderIndex('((a, b))')).toBe('PROJECTION `p` INDEX (a, b) TYPE basic') + expect(renderIndex('(((a,b)))')).toBe('PROJECTION `p` INDEX (a, b) TYPE basic') + expect(renderIndex('(a, (b))')).toBe('PROJECTION `p` INDEX (a, b) TYPE basic') + expect(renderIndex('(a), (b)')).toBe('PROJECTION `p` INDEX (a, b) TYPE basic') + // ...but a genuine nested tuple is not redundant and survives. + expect(renderIndex('(a, (b, c))')).toBe('PROJECTION `p` INDEX (a, (b, c)) TYPE basic') + // ClickHouse prints a space after every argument separator. + expect(renderIndex('(concat(a,b), ts)')).toBe( + 'PROJECTION `p` INDEX (concat(a, b), ts) TYPE basic' + ) + expect(renderIndex('cityHash64(a,b)')).toBe('PROJECTION `p` INDEX cityHash64(a, b) TYPE basic') + // A paren inside a quoted identifier is text, not nesting. + expect(renderIndex('(`weird)name`)')).toBe('PROJECTION `p` INDEX `weird)name` TYPE basic') + }) + + // The index is normalized at canonicalize time and again at render time, so a + // form that keeps changing would make every generate re-emit a drop + rebuild. + test('normalizes index expressions idempotently', () => { + const inputs = [ + 'b', + '(b)', + '(a,b)', + '((a,b))', + '(((a,b)))', + '(a), (b)', + '(a, (b))', + '(a, (b, c))', + 'concat(a,b)', + '(concat(a,b), ts)', + '(toYYYYMM(ts))', + '(`weird)name`)', + ] + for (const input of inputs) { + const once = normalizeProjectionIndex(input) + expect(normalizeProjectionIndex(once)).toBe(once) + } }) test('treats parens-only differences in an index projection as no change', () => { @@ -147,6 +185,45 @@ describe('@chkit/core smoke', () => { expect(planDiff(defs('(id)'), defs('id')).operations).toEqual([]) }) + // Both keys present satisfies the union, so TypeScript admits it — e.g. when + // converting a SELECT projection to index-only and leaving `query` behind. + // Without this, the SELECT body is silently discarded. + test('rejects a projection that sets both query and index', () => { + const events = table({ + database: 'app', + name: 'events', + columns: [{ name: 'id', type: 'UInt64' }], + engine: 'MergeTree()', + primaryKey: ['id'], + orderBy: ['id'], + projections: [{ name: 'p', query: 'SELECT id', index: 'id', type: 'basic' }], + }) + + expect(validateDefinitions([events]).map((issue) => issue.code)).toContain( + 'projection_ambiguous_kind' + ) + }) + + test('rejects an index-only projection with an empty index expression', () => { + const build = (index: string) => + table({ + database: 'app', + name: 'events', + columns: [{ name: 'id', type: 'UInt64' }], + engine: 'MergeTree()', + primaryKey: ['id'], + orderBy: ['id'], + projections: [{ name: 'p', index, type: 'basic' }], + }) + + for (const empty of ['', ' ', '()']) { + expect(validateDefinitions([build(empty)]).map((issue) => issue.code)).toContain( + 'projection_empty_index' + ) + } + expect(validateDefinitions([build('id')])).toEqual([]) + }) + test('plans add and drop for index-only projections', () => { const base = { database: 'app', diff --git a/packages/core/src/model-types.ts b/packages/core/src/model-types.ts index e5167808..78af0482 100644 --- a/packages/core/src/model-types.ts +++ b/packages/core/src/model-types.ts @@ -325,6 +325,8 @@ export type ValidationIssueCode = | 'duplicate_column_name' | 'duplicate_index_name' | 'duplicate_projection_name' + | 'projection_ambiguous_kind' + | 'projection_empty_index' | 'primary_key_missing_column' | 'order_by_missing_column' | 'refresh_requires_every_or_after' diff --git a/packages/core/src/projection.ts b/packages/core/src/projection.ts index 69064b2a..a0dd9151 100644 --- a/packages/core/src/projection.ts +++ b/packages/core/src/projection.ts @@ -12,10 +12,21 @@ function stripWrappingParens(input: string): string { if (!input.startsWith('(') || !input.endsWith(')')) return input // Only strip when the leading paren closes at the very end, so `(a), (b)` - // keeps both groups. + // keeps both groups. Parens inside quoted identifiers and string literals + // are text, not nesting — `` (`weird)name`) `` is still a single wrapped + // expression. let depth = 0 + let quote: "'" | '"' | '`' | null = null for (let i = 0; i < input.length; i += 1) { const char = input[i] + if (quote) { + if (char === quote && input[i - 1] !== '\\') quote = null + continue + } + if (char === "'" || char === '"' || char === '`') { + quote = char + continue + } if (char === '(') depth += 1 else if (char === ')') { depth -= 1 @@ -25,6 +36,34 @@ function stripWrappingParens(input: string): string { return input } +/** ClickHouse prints one space after every argument separator. */ +function spaceAfterCommas(input: string): string { + let out = '' + let quote: "'" | '"' | '`' | null = null + for (let i = 0; i < input.length; i += 1) { + const char = input[i] ?? '' + if (quote) { + out += char + if (char === quote && input[i - 1] !== '\\') quote = null + continue + } + if (char === "'" || char === '"' || char === '`') { + quote = char + out += char + continue + } + // Whitespace is already collapsed to single spaces by normalizeSQLFragment, + // so a comma is followed by at most one space. + if (char === ',') { + out += ', ' + if (input[i + 1] === ' ') i += 1 + continue + } + out += char + } + return out +} + /** * Render an index expression list the way ClickHouse itself echoes it back: * a single element is bare (`INDEX b`), several are a tuple (`INDEX (a, b)`). @@ -32,11 +71,27 @@ function stripWrappingParens(input: string): string { * Both halves matter. ClickHouse rewrites `INDEX (b)` to `INDEX b`, so without * this a pulled schema would drift against the live table forever; and it * rejects `INDEX a, b` outright, so multiple elements must be parenthesized. + * + * Must be idempotent: it runs at canonicalize time and again at render time, + * and a canonical form that keeps changing makes every `generate` re-emit a + * drop + rebuild of the projection. */ export function normalizeProjectionIndex(index: string): string { - const parts = splitTopLevelComma(stripWrappingParens(normalizeSQLFragment(index))) + // Peel every redundant layer, not just one: ClickHouse reports `((a, b))` + // back as `(a, b)`. + let expression = normalizeSQLFragment(index) + for (let stripped = stripWrappingParens(expression); stripped !== expression; ) { + expression = stripped + stripped = stripWrappingParens(expression) + } + + const parts = splitTopLevelComma(expression) if (parts.length === 0) return '' - return parts.length === 1 ? (parts[0] ?? '') : `(${parts.join(', ')})` + // A lone element is the base case — recursing here would not terminate. + if (parts.length === 1) return spaceAfterCommas(expression) + // Each element is peeled too, since ClickHouse reports `(a, (b))` back as + // `(a, b)` while keeping a genuine nested tuple like `(a, (b, c))`. + return `(${parts.map(normalizeProjectionIndex).join(', ')})` } export function canonicalizeProjection(projection: ProjectionDefinition): ProjectionDefinition { diff --git a/packages/core/src/validate.ts b/packages/core/src/validate.ts index 89d8a21c..6003b42b 100644 --- a/packages/core/src/validate.ts +++ b/packages/core/src/validate.ts @@ -1,6 +1,7 @@ import { definitionKey } from './canonical.js' import { canonicalizeCodec, isGeneralCodec, isRawCodec } from './codec.js' import { isPlainColumnReference, normalizeKeyColumns } from './key-clause.js' +import { isIndexProjection, normalizeProjectionIndex } from './projection.js' import type { ColumnDefinition, MaterializedViewDefinition, @@ -117,6 +118,27 @@ function validateTableDefinition(def: TableDefinition, issues: ValidationIssue[] continue } projectionSeen.add(projection.name) + + // A projection carrying both keys satisfies the union, so TypeScript admits + // it. Renders as index-only and drops the SELECT body on the floor. + if ('index' in projection && 'query' in projection) { + pushValidationIssue( + issues, + def, + 'projection_ambiguous_kind', + `Table ${def.database}.${def.name} projection "${projection.name}" sets both "query" and "index"; use "query" for a SELECT projection or "index"/"type" for an index-only projection` + ) + continue + } + + if (isIndexProjection(projection) && normalizeProjectionIndex(projection.index) === '') { + pushValidationIssue( + issues, + def, + 'projection_empty_index', + `Table ${def.database}.${def.name} projection "${projection.name}" has an empty index expression` + ) + } } for (const column of normalizeKeyColumns(def.primaryKey)) { diff --git a/packages/plugin-pull/src/pull.e2e.test.ts b/packages/plugin-pull/src/pull.e2e.test.ts index 01c8f2d1..ddba7c69 100644 --- a/packages/plugin-pull/src/pull.e2e.test.ts +++ b/packages/plugin-pull/src/pull.e2e.test.ts @@ -169,17 +169,17 @@ describe('@chkit/plugin-pull live env e2e', () => { // canonicalized into name order, while the live source keeps creation // order, and drift keys by name too. const details = await executor.listTableDetails([projectionDatabase]) - const byName = (table: { name: string } | undefined) => - [...(details.find((entry) => entry.name === table?.name)?.projections ?? [])].sort( + const projectionsOf = (tableName: string) => + [...(details.find((entry) => entry.name === tableName)?.projections ?? [])].sort( (left, right) => left.name.localeCompare(right.name) ) - const sourceProjections = byName({ name: sourceTable }) + const sourceProjections = projectionsOf(sourceTable) expect(sourceProjections).toEqual([ { name: 'agg_by_sender', query: 'SELECT sender, sum(cnt) GROUP BY sender' }, { name: 'by_receiver', index: '(receiver, sender)', type: 'basic' }, ]) - expect(byName({ name: recreatedTable })).toEqual(sourceProjections) + expect(projectionsOf(recreatedTable)).toEqual(sourceProjections) } finally { await rm(dir, { recursive: true, force: true }) }