diff --git a/bun.lock b/bun.lock index 330da88..bb1d68f 100644 --- a/bun.lock +++ b/bun.lock @@ -59,6 +59,9 @@ "dependencies": { "fast-glob": "^3.3.2", }, + "devDependencies": { + "@clickhouse/client": "^1.11.0", + }, }, "packages/plugin-backfill": { "name": "@chkit/plugin-backfill", diff --git a/packages/core/package.json b/packages/core/package.json index f40867f..5d40e4f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -43,5 +43,8 @@ }, "dependencies": { "fast-glob": "^3.3.2" + }, + "devDependencies": { + "@clickhouse/client": "^1.11.0" } } diff --git a/packages/core/src/sql-validation.e2e.test.ts b/packages/core/src/sql-validation.e2e.test.ts new file mode 100644 index 0000000..7163731 --- /dev/null +++ b/packages/core/src/sql-validation.e2e.test.ts @@ -0,0 +1,1021 @@ +/** + * EXPLAIN AST SQL Validation Test Suite + * + * Validates that all SQL generated by chkit is syntactically valid ClickHouse SQL + * by running EXPLAIN AST on each statement against a live ClickHouse instance. + * No DDL is executed — only parsing via EXPLAIN AST. + */ + +import { describe, expect, test } from 'bun:test' +import { createClient } from '@clickhouse/client' +import type { + ColumnDefinition, + SkipIndexDefinition, + TableDefinition, +} from './model-types.js' +import { table, view, materializedView } from './model.js' +import { toCreateSQL } from './sql.js' +import { + renderAlterAddColumn, + renderAlterModifyColumn, + renderAlterDropColumn, + renderAlterAddIndex, + renderAlterDropIndex, + renderAlterAddProjection, + renderAlterDropProjection, + renderAlterModifySetting, + renderAlterResetSetting, + renderAlterModifyTTL, +} from './sql.js' +import { planDiff } from './planner.js' + +// --------------------------------------------------------------------------- +// Environment — hard-fail on missing env (never skip) +// --------------------------------------------------------------------------- + +function getRequiredEnv() { + const host = process.env.CLICKHOUSE_HOST?.trim() + const url = process.env.CLICKHOUSE_URL?.trim() || (host ? `https://${host}` : '') + const username = process.env.CLICKHOUSE_USER?.trim() || 'default' + const password = process.env.CLICKHOUSE_PASSWORD?.trim() || '' + const database = process.env.CLICKHOUSE_DB?.trim() || 'default' + + if (!url) throw new Error('Missing CLICKHOUSE_URL or CLICKHOUSE_HOST') + if (!password) throw new Error('Missing CLICKHOUSE_PASSWORD') + + return { url, username, password, database } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface QueryClient { + query(sql: string): Promise + close(): Promise +} + +function createQueryClient(): QueryClient { + const env = getRequiredEnv() + const client = createClient({ + url: env.url, + username: env.username, + password: env.password, + database: env.database, + clickhouse_settings: { + wait_end_of_query: 1, + }, + }) + + return { + async query(sql: string) { + const rs = await client.query({ query: sql }) + await rs.close() + }, + async close() { + await client.close() + }, + } +} + +/** + * Strips trailing `;` and `SYNC` keyword before sending to EXPLAIN AST. + * On failure, throws with the SQL and the ClickHouse error message. + */ +async function assertValidSQL(client: QueryClient, sql: string): Promise { + const cleaned = sql + .replace(/;\s*$/, '') + .replace(/\bSYNC\s*$/i, '') + .trim() + + try { + await client.query(`EXPLAIN AST ${cleaned}`) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Invalid SQL:\n${cleaned}\n\nClickHouse error:\n${message}`) + } +} + +/** Minimal table definition used as context for ALTER statements. */ +function baseTable(overrides?: Partial>): TableDefinition { + return table({ + database: 'default', + name: 'test_table', + columns: [{ name: 'id', type: 'UInt64' }], + engine: 'MergeTree()', + primaryKey: ['id'], + orderBy: ['id'], + ...overrides, + }) +} + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe('SQL validation via EXPLAIN AST', () => { + let client: QueryClient + + test('setup', () => { + client = createQueryClient() + }) + + // ========================================================================= + // CREATE TABLE — Primitive column types + // ========================================================================= + + describe('CREATE TABLE — primitive column types', () => { + const primitiveTypes = [ + 'String', + 'UInt8', + 'UInt16', + 'UInt32', + 'UInt64', + 'UInt128', + 'UInt256', + 'Int8', + 'Int16', + 'Int32', + 'Int64', + 'Int128', + 'Int256', + 'Float32', + 'Float64', + 'Bool', + 'Date', + 'DateTime', + 'DateTime64', + 'Date32', + ] + + for (const type of primitiveTypes) { + test(`type: ${type}`, async () => { + const def = baseTable({ + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'value', type }, + ], + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + } + }) + + // ========================================================================= + // CREATE TABLE — Parameterized types + // ========================================================================= + + describe('CREATE TABLE — parameterized types', () => { + const parameterizedTypes = [ + 'DateTime64(3)', + "DateTime64(3, 'UTC')", + 'FixedString(10)', + 'Decimal(18, 4)', + 'Decimal32(2)', + 'Decimal64(4)', + 'Decimal128(6)', + ] + + for (const type of parameterizedTypes) { + test(`type: ${type}`, async () => { + const def = baseTable({ + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'value', type }, + ], + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + } + }) + + // ========================================================================= + // CREATE TABLE — Complex/nested types + // ========================================================================= + + describe('CREATE TABLE — complex/nested types', () => { + const complexTypes = [ + 'Nullable(String)', + 'LowCardinality(String)', + 'LowCardinality(Nullable(String))', + 'Array(String)', + 'Array(UInt32)', + 'Array(Array(String))', + 'Map(String, UInt64)', + 'Tuple(String, UInt32)', + 'Tuple(String, Array(UInt32))', + 'Array(Tuple(String, Array(UInt32)))', + "Enum8('a' = 1, 'b' = 2)", + "Enum16('active' = 1, 'inactive' = 2, 'deleted' = 3)", + 'SimpleAggregateFunction(sum, UInt64)', + 'SimpleAggregateFunction(max, DateTime)', + ] + + for (const type of complexTypes) { + test(`type: ${type}`, async () => { + const def = baseTable({ + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'value', type }, + ], + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + } + }) + + // ========================================================================= + // CREATE TABLE — Column defaults + // ========================================================================= + + describe('CREATE TABLE — column defaults', () => { + const defaults: Array<{ label: string; col: ColumnDefinition }> = [ + { label: 'string literal', col: { name: 'status', type: 'String', default: 'active' } }, + { label: 'numeric', col: { name: 'count', type: 'UInt32', default: 0 } }, + { label: 'boolean', col: { name: 'flag', type: 'Bool', default: false } }, + { label: 'fn: now()', col: { name: 'created_at', type: 'DateTime', default: 'fn:now()' } }, + { + label: 'fn: toDate(now())', + col: { name: 'created_date', type: 'Date', default: 'fn:toDate(now())' }, + }, + ] + + for (const { label, col } of defaults) { + test(`default: ${label}`, async () => { + const def = baseTable({ + columns: [{ name: 'id', type: 'UInt64' }, col], + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + } + }) + + // ========================================================================= + // CREATE TABLE — Column comments and nullable + // ========================================================================= + + describe('CREATE TABLE — column comments & nullable', () => { + test('column with comment', async () => { + const def = baseTable({ + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'name', type: 'String', comment: 'User name' }, + ], + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + + test('column with escaped quote in comment', async () => { + const def = baseTable({ + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'name', type: 'String', comment: "User's full name" }, + ], + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + + test('nullable column', async () => { + const def = baseTable({ + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'email', type: 'String', nullable: true }, + ], + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + + test('nullable column with default and comment', async () => { + const def = baseTable({ + columns: [ + { name: 'id', type: 'UInt64' }, + { + name: 'nickname', + type: 'String', + nullable: true, + default: 'anon', + comment: 'Display name', + }, + ], + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + }) + + // ========================================================================= + // CREATE TABLE — Engine family + // ========================================================================= + + describe('CREATE TABLE — engine family', () => { + const engines = [ + { engine: 'MergeTree()', extra: {} }, + { + engine: 'ReplacingMergeTree(version)', + extra: { + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'version', type: 'UInt64' }, + ], + }, + }, + { + engine: 'SummingMergeTree(amount)', + extra: { + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'amount', type: 'Float64' }, + ], + }, + }, + { engine: 'AggregatingMergeTree()', extra: {} }, + { + engine: 'CollapsingMergeTree(sign)', + extra: { + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'sign', type: 'Int8' }, + ], + }, + }, + { + engine: 'VersionedCollapsingMergeTree(sign, version)', + extra: { + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'sign', type: 'Int8' }, + { name: 'version', type: 'UInt64' }, + ], + }, + }, + ] + + for (const { engine, extra } of engines) { + test(`engine: ${engine}`, async () => { + const def = baseTable({ engine, ...extra }) + await assertValidSQL(client, toCreateSQL(def)) + }) + } + }) + + // ========================================================================= + // CREATE TABLE — PARTITION BY + // ========================================================================= + + describe('CREATE TABLE — PARTITION BY', () => { + const partitions = [ + { label: 'toYYYYMM', expr: 'toYYYYMM(created_at)' }, + { label: 'toDate', expr: 'toDate(created_at)' }, + { label: 'tuple', expr: 'tuple(region, toYYYYMM(created_at))' }, + ] + + for (const { label, expr } of partitions) { + test(`partitionBy: ${label}`, async () => { + const def = baseTable({ + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'created_at', type: 'DateTime' }, + { name: 'region', type: 'String' }, + ], + partitionBy: expr, + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + } + }) + + // ========================================================================= + // CREATE TABLE — ORDER BY / PRIMARY KEY variations + // ========================================================================= + + describe('CREATE TABLE — ORDER BY / PRIMARY KEY', () => { + test('multi-column order by', async () => { + const def = baseTable({ + columns: [ + { name: 'tenant_id', type: 'UInt64' }, + { name: 'id', type: 'UInt64' }, + { name: 'created_at', type: 'DateTime' }, + ], + primaryKey: ['tenant_id', 'id'], + orderBy: ['tenant_id', 'id', 'created_at'], + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + + test('expression in order by', async () => { + // Bypass toCreateSQL validation since it rejects expressions in orderBy. + // We still want to verify ClickHouse can parse expression-based ORDER BY. + const sql = `CREATE TABLE IF NOT EXISTS default.test_expr_order +( + \`id\` UInt64, + \`created_at\` DateTime +) ENGINE = MergeTree() +PRIMARY KEY (\`id\`) +ORDER BY (\`id\`, toDate(\`created_at\`))` + await assertValidSQL(client, sql) + }) + }) + + // ========================================================================= + // CREATE TABLE — TTL + // ========================================================================= + + describe('CREATE TABLE — TTL', () => { + test('simple TTL', async () => { + const def = baseTable({ + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'created_at', type: 'DateTime' }, + ], + ttl: 'created_at + INTERVAL 30 DAY', + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + + test('TTL with DELETE', async () => { + const def = baseTable({ + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'created_at', type: 'DateTime' }, + ], + ttl: 'created_at + INTERVAL 90 DAY DELETE', + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + }) + + // ========================================================================= + // CREATE TABLE — SETTINGS + // ========================================================================= + + describe('CREATE TABLE — SETTINGS', () => { + test('numeric setting', async () => { + const def = baseTable({ + settings: { index_granularity: 8192 }, + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + + test('multiple settings', async () => { + const def = baseTable({ + settings: { + index_granularity: 8192, + min_bytes_for_wide_part: 0, + }, + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + }) + + // ========================================================================= + // CREATE TABLE — Skip indexes + // ========================================================================= + + describe('CREATE TABLE — skip indexes', () => { + const indexes: Array<{ label: string; idx: SkipIndexDefinition }> = [ + { + label: 'minmax', + idx: { name: 'idx_ts', expression: 'created_at', type: 'minmax', granularity: 3 }, + }, + { + label: 'set', + idx: { name: 'idx_status', expression: 'status', type: 'set', typeArgs: '100', granularity: 2 }, + }, + { + label: 'bloom_filter', + idx: { name: 'idx_email', expression: 'email', type: 'bloom_filter', granularity: 1 }, + }, + { + label: 'bloom_filter with args', + idx: { name: 'idx_email2', expression: 'email', type: 'bloom_filter', typeArgs: '0.01', granularity: 1 }, + }, + { + label: 'tokenbf_v1', + idx: { name: 'idx_body', expression: 'body', type: 'tokenbf_v1', typeArgs: '10240, 3, 0', granularity: 1 }, + }, + { + label: 'ngrambf_v1', + idx: { name: 'idx_name', expression: 'name', type: 'ngrambf_v1', typeArgs: '3, 256, 2, 0', granularity: 1 }, + }, + { + label: 'expression index', + idx: { name: 'idx_lower', expression: 'lower(name)', type: 'bloom_filter', granularity: 1 }, + }, + ] + + for (const { label, idx } of indexes) { + test(`index: ${label}`, async () => { + const def = baseTable({ + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'created_at', type: 'DateTime' }, + { name: 'status', type: 'String' }, + { name: 'email', type: 'String' }, + { name: 'body', type: 'String' }, + { name: 'name', type: 'String' }, + ], + indexes: [idx], + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + } + }) + + // ========================================================================= + // CREATE TABLE — Projections + // ========================================================================= + + describe('CREATE TABLE — projections', () => { + test('simple projection', async () => { + const def = baseTable({ + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'status', type: 'String' }, + ], + projections: [ + { name: 'proj_status', query: 'SELECT status, count() GROUP BY status' }, + ], + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + + test('projection with ORDER BY', async () => { + const def = baseTable({ + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'created_at', type: 'DateTime' }, + ], + projections: [ + { name: 'proj_ts', query: 'SELECT * ORDER BY created_at' }, + ], + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + }) + + // ========================================================================= + // CREATE TABLE — Table comment + // ========================================================================= + + test('CREATE TABLE — table comment', async () => { + const def = baseTable({ comment: 'Main events table' }) + await assertValidSQL(client, toCreateSQL(def)) + }) + + test('CREATE TABLE — table comment with escaped quote', async () => { + const def = baseTable({ comment: "User's activity log" }) + await assertValidSQL(client, toCreateSQL(def)) + }) + + // ========================================================================= + // CREATE TABLE — Kitchen sink + // ========================================================================= + + test('CREATE TABLE — kitchen sink', async () => { + const def = table({ + database: 'default', + name: 'kitchen_sink', + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'tenant_id', type: 'UInt32' }, + { name: 'name', type: 'String', comment: 'Full name' }, + { name: 'email', type: 'String', nullable: true }, + { name: 'status', type: "Enum8('active' = 1, 'inactive' = 2)" }, + { name: 'score', type: 'Float64', default: 0 }, + { name: 'tags', type: 'Array(String)' }, + { name: 'metadata', type: 'Map(String, String)' }, + { name: 'created_at', type: 'DateTime', default: 'fn:now()' }, + { name: 'updated_at', type: 'Nullable(DateTime)' }, + { name: 'amount', type: 'Decimal(18, 4)' }, + { name: 'flags', type: 'UInt8', default: 0, comment: 'Bitmask flags' }, + ], + engine: 'MergeTree()', + primaryKey: ['tenant_id', 'id'], + orderBy: ['tenant_id', 'id', 'created_at'], + partitionBy: 'toYYYYMM(created_at)', + ttl: 'created_at + INTERVAL 365 DAY', + settings: { index_granularity: 8192 }, + indexes: [ + { name: 'idx_email', expression: 'email', type: 'bloom_filter', granularity: 1 }, + { name: 'idx_ts', expression: 'created_at', type: 'minmax', granularity: 3 }, + ], + projections: [ + { name: 'proj_status', query: 'SELECT status, count() GROUP BY status' }, + ], + comment: 'All-in-one test table', + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + + // ========================================================================= + // CREATE TABLE — 20+ columns + // ========================================================================= + + test('CREATE TABLE — many columns', async () => { + const columns: ColumnDefinition[] = [{ name: 'id', type: 'UInt64' }] + for (let i = 0; i < 25; i++) { + columns.push({ name: `col_${i}`, type: i % 2 === 0 ? 'String' : 'UInt32' }) + } + const def = baseTable({ columns }) + await assertValidSQL(client, toCreateSQL(def)) + }) + + // ========================================================================= + // CREATE TABLE — Reserved words as column names + // ========================================================================= + + test('CREATE TABLE — reserved word column names', async () => { + const def = baseTable({ + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'select', type: 'String' }, + { name: 'from', type: 'String' }, + { name: 'table', type: 'UInt32' }, + { name: 'index', type: 'UInt32' }, + ], + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + + // ========================================================================= + // CREATE TABLE — Deeply nested types + // ========================================================================= + + test('CREATE TABLE — deeply nested type', async () => { + const def = baseTable({ + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'nested', type: 'Array(Tuple(String, Array(UInt32)))' }, + ], + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + + // ========================================================================= + // CREATE VIEW + // ========================================================================= + + describe('CREATE VIEW', () => { + test('simple view', async () => { + const def = view({ + database: 'default', + name: 'test_view', + as: 'SELECT 1 AS x', + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + + test('view with comment (not rendered, but SQL still valid)', async () => { + const def = view({ + database: 'default', + name: 'test_view_comment', + as: 'SELECT 1 AS x', + comment: 'A test view', + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + }) + + // ========================================================================= + // CREATE MATERIALIZED VIEW + // ========================================================================= + + describe('CREATE MATERIALIZED VIEW', () => { + test('MV with target table', async () => { + const def = materializedView({ + database: 'default', + name: 'test_mv', + to: { database: 'default', name: 'target_table' }, + as: 'SELECT id, count() AS cnt FROM default.source GROUP BY id', + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + + test('MV with aggregation SELECT', async () => { + const def = materializedView({ + database: 'default', + name: 'test_mv_agg', + to: { database: 'default', name: 'agg_target' }, + as: 'SELECT toDate(created_at) AS day, sum(amount) AS total FROM default.events GROUP BY day', + }) + await assertValidSQL(client, toCreateSQL(def)) + }) + }) + + // ========================================================================= + // ALTER TABLE — ADD COLUMN + // ========================================================================= + + describe('ALTER TABLE — ADD COLUMN', () => { + const addColumnCases: Array<{ label: string; col: ColumnDefinition }> = [ + { label: 'simple string', col: { name: 'name', type: 'String' } }, + { label: 'nullable', col: { name: 'email', type: 'String', nullable: true } }, + { label: 'with default', col: { name: 'score', type: 'Float64', default: 0 } }, + { label: 'with fn default', col: { name: 'ts', type: 'DateTime', default: 'fn:now()' } }, + { label: 'with comment', col: { name: 'notes', type: 'String', comment: 'User notes' } }, + { label: 'complex type', col: { name: 'tags', type: 'Array(String)' } }, + ] + + for (const { label, col } of addColumnCases) { + test(`ADD COLUMN: ${label}`, async () => { + const def = baseTable() + await assertValidSQL(client, renderAlterAddColumn(def, col)) + }) + } + }) + + // ========================================================================= + // ALTER TABLE — MODIFY COLUMN + // ========================================================================= + + describe('ALTER TABLE — MODIFY COLUMN', () => { + test('type change', async () => { + const def = baseTable() + await assertValidSQL(client, renderAlterModifyColumn(def, { name: 'id', type: 'UInt128' })) + }) + + test('nullable change', async () => { + const def = baseTable() + await assertValidSQL( + client, + renderAlterModifyColumn(def, { name: 'value', type: 'String', nullable: true }) + ) + }) + + test('default change', async () => { + const def = baseTable() + await assertValidSQL( + client, + renderAlterModifyColumn(def, { name: 'value', type: 'String', default: 'unknown' }) + ) + }) + }) + + // ========================================================================= + // ALTER TABLE — DROP COLUMN + // ========================================================================= + + test('ALTER TABLE — DROP COLUMN', async () => { + const def = baseTable() + await assertValidSQL(client, renderAlterDropColumn(def, 'old_column')) + }) + + // ========================================================================= + // ALTER TABLE — ADD INDEX + // ========================================================================= + + describe('ALTER TABLE — ADD INDEX', () => { + const indexCases: Array<{ label: string; idx: SkipIndexDefinition }> = [ + { + label: 'minmax', + idx: { name: 'idx_ts', expression: 'created_at', type: 'minmax', granularity: 3 }, + }, + { + label: 'set', + idx: { name: 'idx_status', expression: 'status', type: 'set', typeArgs: '100', granularity: 2 }, + }, + { + label: 'bloom_filter', + idx: { name: 'idx_email', expression: 'email', type: 'bloom_filter', granularity: 1 }, + }, + { + label: 'tokenbf_v1', + idx: { name: 'idx_body', expression: 'body', type: 'tokenbf_v1', typeArgs: '10240, 3, 0', granularity: 1 }, + }, + { + label: 'ngrambf_v1', + idx: { name: 'idx_name', expression: 'name', type: 'ngrambf_v1', typeArgs: '3, 256, 2, 0', granularity: 1 }, + }, + ] + + for (const { label, idx } of indexCases) { + test(`ADD INDEX: ${label}`, async () => { + const def = baseTable() + await assertValidSQL(client, renderAlterAddIndex(def, idx)) + }) + } + }) + + // ========================================================================= + // ALTER TABLE — DROP INDEX + // ========================================================================= + + test('ALTER TABLE — DROP INDEX', async () => { + const def = baseTable() + await assertValidSQL(client, renderAlterDropIndex(def, 'idx_old')) + }) + + // ========================================================================= + // ALTER TABLE — ADD/DROP PROJECTION + // ========================================================================= + + test('ALTER TABLE — ADD PROJECTION', async () => { + const def = baseTable() + await assertValidSQL( + client, + renderAlterAddProjection(def, { + name: 'proj_status', + query: 'SELECT status, count() GROUP BY status', + }) + ) + }) + + test('ALTER TABLE — DROP PROJECTION', async () => { + const def = baseTable() + await assertValidSQL(client, renderAlterDropProjection(def, 'proj_old')) + }) + + // ========================================================================= + // ALTER TABLE — MODIFY SETTING / RESET SETTING + // ========================================================================= + + test('ALTER TABLE — MODIFY SETTING', async () => { + const def = baseTable() + await assertValidSQL(client, renderAlterModifySetting(def, 'index_granularity', 4096)) + }) + + test('ALTER TABLE — RESET SETTING', async () => { + const def = baseTable() + await assertValidSQL(client, renderAlterResetSetting(def, 'index_granularity')) + }) + + // ========================================================================= + // ALTER TABLE — MODIFY TTL / REMOVE TTL + // ========================================================================= + + test('ALTER TABLE — MODIFY TTL', async () => { + const def = baseTable() + await assertValidSQL(client, renderAlterModifyTTL(def, 'created_at + INTERVAL 30 DAY')) + }) + + test('ALTER TABLE — REMOVE TTL', async () => { + const def = baseTable() + await assertValidSQL(client, renderAlterModifyTTL(def, undefined)) + }) + + // ========================================================================= + // planDiff — migration plans + // ========================================================================= + + describe('planDiff — migration plans', () => { + test('new table creation', async () => { + const newDef = baseTable({ name: 'new_events' }) + const plan = planDiff([], [newDef]) + expect(plan.operations.length).toBeGreaterThan(0) + for (const op of plan.operations) { + await assertValidSQL(client, op.sql) + } + }) + + test('table drop', async () => { + const oldDef = baseTable({ name: 'old_events' }) + const plan = planDiff([oldDef], []) + expect(plan.operations.length).toBeGreaterThan(0) + for (const op of plan.operations) { + await assertValidSQL(client, op.sql) + } + }) + + test('additive changes — add columns + indexes + settings', async () => { + const oldDef = baseTable({ name: 'events' }) + const newDef = baseTable({ + name: 'events', + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'name', type: 'String' }, + { name: 'created_at', type: 'DateTime' }, + ], + indexes: [ + { name: 'idx_ts', expression: 'created_at', type: 'minmax', granularity: 3 }, + ], + settings: { index_granularity: 4096 }, + }) + const plan = planDiff([oldDef], [newDef]) + expect(plan.operations.length).toBeGreaterThan(0) + for (const op of plan.operations) { + await assertValidSQL(client, op.sql) + } + }) + + test('destructive changes — drop columns + indexes', async () => { + const oldDef = baseTable({ + name: 'events', + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'obsolete', type: 'String' }, + { name: 'created_at', type: 'DateTime' }, + ], + indexes: [ + { name: 'idx_ts', expression: 'created_at', type: 'minmax', granularity: 3 }, + ], + }) + const newDef = baseTable({ + name: 'events', + columns: [{ name: 'id', type: 'UInt64' }], + }) + const plan = planDiff([oldDef], [newDef]) + expect(plan.operations.length).toBeGreaterThan(0) + for (const op of plan.operations) { + await assertValidSQL(client, op.sql) + } + }) + + test('structural recreate — engine change triggers drop + create', async () => { + const oldDef = baseTable({ name: 'events', engine: 'MergeTree()' }) + const newDef = baseTable({ name: 'events', engine: 'ReplacingMergeTree()' }) + const plan = planDiff([oldDef], [newDef]) + expect(plan.operations.length).toBe(2) + for (const op of plan.operations) { + await assertValidSQL(client, op.sql) + } + }) + + test('view modification', async () => { + const oldView = view({ + database: 'default', + name: 'events_view', + as: 'SELECT 1 AS x', + }) + const newView = view({ + database: 'default', + name: 'events_view', + as: 'SELECT 1 AS x, 2 AS y', + }) + const plan = planDiff([oldView], [newView]) + expect(plan.operations.length).toBe(2) + for (const op of plan.operations) { + await assertValidSQL(client, op.sql) + } + }) + + test('materialized view modification', async () => { + const oldMV = materializedView({ + database: 'default', + name: 'events_mv', + to: { database: 'default', name: 'events_target' }, + as: 'SELECT id FROM default.source', + }) + const newMV = materializedView({ + database: 'default', + name: 'events_mv', + to: { database: 'default', name: 'events_target' }, + as: 'SELECT id, name FROM default.source', + }) + const plan = planDiff([oldMV], [newMV]) + expect(plan.operations.length).toBe(2) + for (const op of plan.operations) { + await assertValidSQL(client, op.sql) + } + }) + + test('CREATE DATABASE', async () => { + const newDef = table({ + database: 'analytics', + name: 'events', + columns: [{ name: 'id', type: 'UInt64' }], + engine: 'MergeTree()', + primaryKey: ['id'], + orderBy: ['id'], + }) + const plan = planDiff([], [newDef]) + const dbOps = plan.operations.filter((op) => op.type === 'create_database') + expect(dbOps.length).toBe(1) + for (const op of plan.operations) { + await assertValidSQL(client, op.sql) + } + }) + + test('multiple operations in one plan', async () => { + const oldDefs = [baseTable({ name: 'users' }), baseTable({ name: 'events' })] + const newDefs = [ + baseTable({ + name: 'users', + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'email', type: 'String' }, + ], + }), + baseTable({ + name: 'events', + columns: [ + { name: 'id', type: 'UInt64' }, + { name: 'created_at', type: 'DateTime' }, + ], + }), + baseTable({ name: 'sessions' }), + ] + const plan = planDiff(oldDefs, newDefs) + expect(plan.operations.length).toBeGreaterThan(0) + for (const op of plan.operations) { + await assertValidSQL(client, op.sql) + } + }) + }) + + // ========================================================================= + // Cleanup + // ========================================================================= + + test('teardown', async () => { + await client.close() + }) +}, { timeout: 60_000 })