diff --git a/.github/workflows/drizzle.yml b/.github/workflows/drizzle.yml new file mode 100644 index 000000000..441e6de1d --- /dev/null +++ b/.github/workflows/drizzle.yml @@ -0,0 +1,82 @@ +name: 'drizzle' + +# Informational, non-blocking workflow for the experimental +# `@clickhouse/drizzle-orm` package. It runs the build, typecheck, lint and +# unit tests and reports which checks pass and which fail in the job summary, +# so we can see what works and what doesn't while the adapter is still an MVP. +# Individual checks use `continue-on-error` so the workflow does not gate PRs. + +permissions: {} +on: + workflow_dispatch: + push: + branches: + - main + paths: + - 'packages/drizzle-clickhouse/**' + - '.github/workflows/drizzle.yml' + pull_request: + paths: + - 'packages/drizzle-clickhouse/**' + - '.github/workflows/drizzle.yml' + +concurrency: + group: '${{ github.workflow }}-${{ github.ref }}' + cancel-in-progress: true + +jobs: + drizzle-checks: + timeout-minutes: 5 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [20, 22, 24] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup NodeJS ${{ matrix.node }} + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: ${{ matrix.node }} + + - name: Install dependencies + run: | + npm install + + - name: Build packages + id: build + continue-on-error: true + run: | + npm run build + + - name: Typecheck + id: typecheck + continue-on-error: true + run: | + npm run typecheck --workspace @clickhouse/drizzle-orm + + - name: Lint + id: lint + continue-on-error: true + run: | + npm run lint --workspace @clickhouse/drizzle-orm + + - name: Unit tests + id: unit + continue-on-error: true + run: | + npm run test:drizzle:unit + + - name: Summarize results + run: | + { + echo "## drizzle-clickhouse checks (Node ${{ matrix.node }})" + echo "" + echo "| Check | Result |" + echo "| --- | --- |" + echo "| Build | ${{ steps.build.outcome == 'success' && '✅ pass' || '❌ fail' }} |" + echo "| Typecheck | ${{ steps.typecheck.outcome == 'success' && '✅ pass' || '❌ fail' }} |" + echo "| Lint | ${{ steps.lint.outcome == 'success' && '✅ pass' || '❌ fail' }} |" + echo "| Unit tests | ${{ steps.unit.outcome == 'success' && '✅ pass' || '❌ fail' }} |" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/package-lock.json b/package-lock.json index b8006e1cd..10185976d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -343,6 +343,10 @@ "resolved": "packages/client-web", "link": true }, + "node_modules/@clickhouse/drizzle-orm": { + "resolved": "packages/drizzle-clickhouse", + "link": true + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -7862,6 +7866,29 @@ "@clickhouse/client-common": "1.20.0" } }, + "packages/drizzle-clickhouse": { + "name": "@clickhouse/drizzle-orm", + "version": "0.1.0-0", + "license": "Apache-2.0", + "dependencies": { + "@clickhouse/client-common": "1.20.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@clickhouse/client": "^1.20.0", + "@clickhouse/client-web": "^1.20.0" + }, + "peerDependenciesMeta": { + "@clickhouse/client": { + "optional": true + }, + "@clickhouse/client-web": { + "optional": true + } + } + }, "tests/clickhouse-test-runner": { "name": "@clickhouse/clickhouse-test-runner", "version": "1.20.0", diff --git a/package.json b/package.json index c17c63018..f50f6c5cd 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "test:common:integration:node": "TEST_MODE=common-integration vitest -c vitest.node.config.ts", "test:common:integration:web": "TEST_MODE=common-integration vitest -c vitest.web.config.ts", "test:node:unit": "CLICKHOUSE_TEST_SKIP_INIT=1 TEST_MODE=unit vitest -c vitest.node.config.ts", + "test:drizzle:unit": "CLICKHOUSE_TEST_SKIP_INIT=1 TEST_MODE=unit vitest run -c vitest.node.config.ts packages/drizzle-clickhouse", "test:node:integration:tls": "TEST_MODE=tls vitest -c vitest.node.config.ts", "test:node:integration": "TEST_MODE=integration vitest -c vitest.node.config.ts", "test:node:integration:local_cluster": "CLICKHOUSE_TEST_ENVIRONMENT=local_cluster TEST_MODE=integration vitest -c vitest.node.config.ts", diff --git a/packages/drizzle-clickhouse/__tests__/unit/database.test.ts b/packages/drizzle-clickhouse/__tests__/unit/database.test.ts new file mode 100644 index 000000000..eba8a34c3 --- /dev/null +++ b/packages/drizzle-clickhouse/__tests__/unit/database.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from 'vitest' +import { + ClickHouseDatabase, + clickhouseTable, + ident, + int32, + int64, + mergeTree, + sql, + string, + UnsupportedFeatureError, +} from '../../src/index.js' + +const users = clickhouseTable( + 'users', + { + id: int64(), + name: string(), + age: int32(), + }, + () => ({ engine: mergeTree(), orderBy: ['id'] }), +) + +function fakeClient() { + const jsonResult: unknown = [{ id: '1', name: 'Ada', age: 36 }] + return { + query: vi.fn(async () => ({ + json: vi.fn(async () => jsonResult), + })), + command: vi.fn(async () => undefined), + insert: vi.fn(async () => undefined), + } +} + +describe('SelectBuilder → SQL', () => { + it('emits SELECT *, FROM, WHERE, ORDER BY, LIMIT, OFFSET, SETTINGS', () => { + const db = new ClickHouseDatabase(fakeClient()) + const q = db + .select() + .from(users) + .where(sql`${ident('age')} > ${18}`) + .orderBy({ expr: 'id', direction: 'DESC' }) + .limit(10) + .offset(20) + .settings({ max_threads: 4 }) + const { sql: text } = q.toSQL() + expect(text).toContain('SELECT *') + expect(text).toContain('FROM `users`') + expect(text).toContain('WHERE `age` > 18') + expect(text).toContain('ORDER BY `id` DESC') + expect(text).toContain('LIMIT 10 OFFSET 20') + expect(text).toContain('SETTINGS max_threads = 4') + }) + + it('supports FINAL and WITH clauses', () => { + const db = new ClickHouseDatabase(fakeClient()) + const q = db + .select() + .with({ name: 'recent', query: sql`SELECT * FROM ${ident('users')} LIMIT 100` }) + .from(users) + .final() + const { sql: text } = q.toSQL() + expect(text).toContain('WITH `recent` AS (SELECT * FROM `users` LIMIT 100)') + expect(text).toContain('FROM `users` FINAL') + }) + + it('rejects negative limit/offset', () => { + const db = new ClickHouseDatabase(fakeClient()) + expect(() => db.select().limit(-1)).toThrow() + expect(() => db.select().offset(-1)).toThrow() + }) +}) + +describe('ClickHouseDatabase execution', () => { + it('runs a SELECT through client.query() with JSONEachRow and returns rows', async () => { + const client = fakeClient() + const db = new ClickHouseDatabase(client) + const rows = await db.run(db.select().from(users).limit(1)) + expect(rows).toEqual([{ id: '1', name: 'Ada', age: 36 }]) + expect(client.query).toHaveBeenCalledTimes(1) + const call = client.query.mock.calls[0]![0] + expect(call.format).toBe('JSONEachRow') + expect(call.query).toContain('FROM `users`') + }) + + it('runs an INSERT through native client.insert() with JSONEachRow', async () => { + const client = fakeClient() + const db = new ClickHouseDatabase(client) + await db.runInsert(db.insert(users).values([{ id: '1', name: 'Ada', age: 36 }])) + expect(client.insert).toHaveBeenCalledTimes(1) + const call = client.insert.mock.calls[0]![0] + expect(call.table).toBe('users') + expect(call.format).toBe('JSONEachRow') + expect(call.values).toEqual([{ id: '1', name: 'Ada', age: 36 }]) + }) + + it('skips empty INSERT', async () => { + const client = fakeClient() + const db = new ClickHouseDatabase(client) + await db.runInsert(db.insert(users).values([])) + expect(client.insert).not.toHaveBeenCalled() + }) + + it('routes createTable/dropTable/truncateTable through client.command()', async () => { + const client = fakeClient() + const db = new ClickHouseDatabase(client) + await db.createTable(users, { ifNotExists: true }) + await db.dropTable(users, { ifExists: true }) + await db.truncateTable(users, { ifExists: true }) + expect(client.command).toHaveBeenCalledTimes(3) + expect(client.command.mock.calls[0]![0].query).toContain( + 'CREATE TABLE IF NOT EXISTS `users`', + ) + expect(client.command.mock.calls[1]![0].query).toContain('DROP TABLE IF EXISTS `users`') + expect(client.command.mock.calls[2]![0].query).toContain( + 'TRUNCATE TABLE IF EXISTS `users`', + ) + }) + + it('uses the configured default database for qualified names', async () => { + const client = fakeClient() + const db = new ClickHouseDatabase(client, { database: 'analytics' }) + await db.createTable(users) + expect(client.command.mock.calls[0]![0].query).toContain( + 'CREATE TABLE `analytics`.`users`', + ) + }) + + it('throws UnsupportedFeatureError on transaction() unless allowNoTx', async () => { + const db = new ClickHouseDatabase(fakeClient()) + await expect(db.transaction(async () => 1)).rejects.toBeInstanceOf( + UnsupportedFeatureError, + ) + await expect(db.transaction(async () => 7, { allowNoTx: true })).resolves.toBe(7) + }) + + it('passes through defaultSettings on every command/query/insert', async () => { + const client = fakeClient() + const db = new ClickHouseDatabase(client, { + defaultSettings: { async_insert: 1 }, + }) + await db.run(db.select().from(users).limit(1)) + await db.runInsert(db.insert(users).values([{ id: '1', name: 'X', age: 1 }])) + await db.createTable(users) + expect(client.query.mock.calls[0]![0].clickhouse_settings).toEqual({ + async_insert: 1, + }) + expect(client.insert.mock.calls[0]![0].clickhouse_settings).toEqual({ + async_insert: 1, + }) + expect(client.command.mock.calls[0]![0].clickhouse_settings).toEqual({ + async_insert: 1, + }) + }) +}) diff --git a/packages/drizzle-clickhouse/__tests__/unit/ddl.test.ts b/packages/drizzle-clickhouse/__tests__/unit/ddl.test.ts new file mode 100644 index 000000000..ca994270b --- /dev/null +++ b/packages/drizzle-clickhouse/__tests__/unit/ddl.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest' +import { + array, + ClickHouseDialect, + clickhouseTable, + dateTime64, + decimal, + enum8, + fixedString, + ident, + int32, + int64, + json as jsonCol, + lowCardinality, + mergeTree, + replacingMergeTree, + sql, + string, + uint8, + uuid, +} from '../../src/index.js' + +describe('escape & quoting', () => { + const d = new ClickHouseDialect() + + it('quotes identifiers with backticks and escapes embedded backticks', () => { + const t = clickhouseTable( + 'we`ird', + { id: int32(), col: string() }, + () => ({ engine: mergeTree(), orderBy: ['id'] }), + ) + const ddl = d.createTable(t).sql + expect(ddl).toContain('`we\\`ird`') + expect(ddl).toContain('`id` Int32') + }) + + it('escapes string literals in DEFAULT clauses', () => { + const t = clickhouseTable( + 'defaults', + { greeting: string().default("hi 'world'\n") }, + () => ({ engine: mergeTree(), orderBy: [] }), + ) + expect(d.createTable(t).sql).toContain( + "`greeting` String DEFAULT 'hi \\'world\\'\\n'", + ) + }) +}) + +describe('column DDL', () => { + const d = new ClickHouseDialect() + + it('builds Nullable + LowCardinality wrappers in the right order', () => { + const t = clickhouseTable( + 't', + { c: lowCardinality(string().nullable()) }, + () => ({ engine: mergeTree(), orderBy: [] }), + ) + expect(d.createTable(t).sql).toContain( + '`c` LowCardinality(Nullable(String))', + ) + }) + + it('builds Decimal/FixedString/DateTime64/Enum/Array/UUID/JSON/UInt8/Int64', () => { + const t = clickhouseTable( + 't', + { + price: decimal(18, 4), + code: fixedString(8), + ts: dateTime64(6, 'UTC'), + kind: enum8({ a: 1, b: 2 }), + tags: array(string()), + id: uuid(), + payload: jsonCol(), + flag: uint8(), + big: int64(), + }, + () => ({ engine: mergeTree(), orderBy: ['ts'] }), + ) + const sqlText = d.createTable(t).sql + expect(sqlText).toContain('`price` Decimal(18, 4)') + expect(sqlText).toContain('`code` FixedString(8)') + expect(sqlText).toContain("`ts` DateTime64(6, 'UTC')") + expect(sqlText).toContain("`kind` Enum8('a' = 1, 'b' = 2)") + expect(sqlText).toContain('`tags` Array(String)') + expect(sqlText).toContain('`id` UUID') + expect(sqlText).toContain('`payload` JSON') + expect(sqlText).toContain('`flag` UInt8') + expect(sqlText).toContain('`big` Int64') + }) + + it('rejects invalid decimal arguments', () => { + expect(() => decimal(0, 0)).toThrow() + expect(() => decimal(5, 6)).toThrow() + expect(() => decimal(5, -1)).toThrow() + }) + + it('rejects invalid fixedString / dateTime64 arguments', () => { + expect(() => fixedString(0)).toThrow() + expect(() => dateTime64(-1)).toThrow() + expect(() => dateTime64(10)).toThrow() + }) + + it('rejects empty enums', () => { + expect(() => enum8({})).toThrow() + }) +}) + +describe('CREATE / DROP / TRUNCATE TABLE', () => { + const d = new ClickHouseDialect({ database: 'analytics' }) + + it('renders engine, orderBy, partitionBy, settings, ttl, comment, cluster', () => { + const t = clickhouseTable( + 'events', + { + ts: dateTime64(3), + user_id: int64(), + kind: string(), + }, + () => ({ + engine: replacingMergeTree('ts'), + orderBy: ['user_id', 'ts'], + partitionBy: sql`toYYYYMM(${ident('ts')})`, + primaryKey: ['user_id'], + settings: { index_granularity: 8192, allow_nullable_key: true }, + ttl: sql`${ident('ts')} + INTERVAL 30 DAY`, + comment: "it's events", + cluster: 'main', + }), + ) + const out = d.createTable(t, { ifNotExists: true }).sql + expect(out).toContain('CREATE TABLE IF NOT EXISTS `analytics`.`events`') + expect(out).toContain('ON CLUSTER `main`') + expect(out).toContain('ENGINE = ReplacingMergeTree(ts)') + expect(out).toContain('ORDER BY `user_id`, `ts`') + expect(out).toContain('PARTITION BY toYYYYMM(`ts`)') + expect(out).toContain('PRIMARY KEY (`user_id`)') + expect(out).toContain('TTL `ts` + INTERVAL 30 DAY') + expect(out).toContain( + 'SETTINGS index_granularity = 8192, allow_nullable_key = 1', + ) + expect(out).toContain("COMMENT 'it\\'s events'") + }) + + it('emits ORDER BY tuple() when orderBy is empty', () => { + const t = clickhouseTable('noop', { x: int32() }, () => ({ + engine: mergeTree(), + orderBy: [], + })) + expect(d.createTable(t).sql).toContain('ORDER BY tuple()') + }) + + it('drops/truncates with IF EXISTS / SYNC / ON CLUSTER', () => { + const t = clickhouseTable('x', { a: int32() }, () => ({ + engine: mergeTree(), + orderBy: [], + cluster: 'c1', + })) + expect(d.dropTable(t, { ifExists: true, sync: true }).sql).toBe( + 'DROP TABLE IF EXISTS `analytics`.`x` ON CLUSTER `c1` SYNC', + ) + expect(d.truncateTable(t).sql).toBe( + 'TRUNCATE TABLE `analytics`.`x` ON CLUSTER `c1`', + ) + }) +}) diff --git a/packages/drizzle-clickhouse/__tests__/unit/sql.test.ts b/packages/drizzle-clickhouse/__tests__/unit/sql.test.ts new file mode 100644 index 000000000..28ee3011a --- /dev/null +++ b/packages/drizzle-clickhouse/__tests__/unit/sql.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { compile, ident, param, sql } from '../../src/index.js' + +describe('sql template tag', () => { + it('inlines safe numeric / boolean / short ASCII string literals', () => { + const { sql: text, params } = compile( + sql`SELECT ${1}, ${true}, ${'hi'} WHERE x = ${42}`, + ) + expect(text).toBe("SELECT 1, true, 'hi' WHERE x = 42") + expect(params).toEqual({}) + }) + + it('parameterises strings with control characters or non-ASCII bytes', () => { + const { sql: text, params } = compile(sql`SELECT ${'héllo\nworld'}`) + expect(text).toBe('SELECT {p0:String}') + expect(params).toEqual({ p0: 'héllo\nworld' }) + }) + + it('parameterises Date as DateTime64(3) by default', () => { + const d = new Date(0) + const { sql: text, params } = compile(sql`SELECT ${d}`) + expect(text).toBe('SELECT {p0:DateTime64(3)}') + expect(params.p0).toBe(d) + }) + + it('honours explicit param() type tags', () => { + const { sql: text, params } = compile( + sql`SELECT ${param(42, 'UInt32')}, ${param(null, 'Nullable(String)')}`, + ) + expect(text).toBe('SELECT {p0:UInt32}, {p1:Nullable(String)}') + expect(params).toEqual({ p0: 42, p1: null }) + }) + + it('quotes identifiers via ident()', () => { + const { sql: text } = compile( + sql`SELECT ${ident('a.b')} FROM ${ident('db.users')}`, + ) + expect(text).toBe('SELECT `a`.`b` FROM `db`.`users`') + }) + + it('refuses to bind null/undefined without an explicit type', () => { + expect(() => compile(sql`SELECT ${null}`)).toThrow(/cannot infer type/i) + expect(() => compile(sql`SELECT ${undefined}`)).toThrow(/cannot infer type/i) + }) + + it('infers Array element type from the first item', () => { + const { sql: text, params } = compile(sql`SELECT ${[1, 2, 3]}`) + expect(text).toBe('SELECT {p0:Array(Int64)}') + expect(params).toEqual({ p0: [1, 2, 3] }) + }) + + it('nests SQL fragments without re-numbering placeholders incorrectly', () => { + const inner = sql`x = ${'a long-ish value that should be parameterised because it contains spaces and is intentionally over sixty-four bytes long.'}` + const { sql: text, params } = compile(sql`SELECT * WHERE ${inner} AND y = ${999}`) + expect(text).toMatch(/SELECT \* WHERE x = \{p0:String\} AND y = 999/) + expect(Object.keys(params)).toEqual(['p0']) + }) +}) diff --git a/packages/drizzle-clickhouse/eslint.config.mjs b/packages/drizzle-clickhouse/eslint.config.mjs new file mode 100644 index 000000000..b3c478749 --- /dev/null +++ b/packages/drizzle-clickhouse/eslint.config.mjs @@ -0,0 +1,27 @@ +import js from '@eslint/js' +import { defineConfig } from 'eslint/config' +import tseslint from 'typescript-eslint' +import { typescriptEslintConfig } from '../../eslint.config.base.mjs' + +export default defineConfig( + js.configs.recommended, + ...tseslint.configs.strict, + ...tseslint.configs.stylistic, + { + rules: { + '@typescript-eslint/no-unused-vars': 'warn', + '@typescript-eslint/no-unused-expressions': 'warn', + }, + }, + typescriptEslintConfig(import.meta.dirname), + { + ignores: [ + './__tests__/**/*.ts', + 'eslint.config.mjs', + 'coverage', + 'out', + 'dist', + 'node_modules', + ], + }, +) diff --git a/packages/drizzle-clickhouse/package.json b/packages/drizzle-clickhouse/package.json new file mode 100644 index 000000000..33ea9df00 --- /dev/null +++ b/packages/drizzle-clickhouse/package.json @@ -0,0 +1,62 @@ +{ + "name": "@clickhouse/drizzle-orm", + "description": "Drizzle ORM adapter for ClickHouse — uses @clickhouse/client / @clickhouse/client-web as the driver", + "homepage": "https://clickhouse.com", + "version": "0.1.0-0", + "license": "Apache-2.0", + "keywords": [ + "clickhouse", + "drizzle", + "orm", + "sql", + "olap" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/ClickHouse/clickhouse-js.git" + }, + "private": true, + "engines": { + "node": ">=20.19.0" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./node": { + "types": "./dist/node.d.ts", + "default": "./dist/node.js" + }, + "./web": { + "types": "./dist/web.d.ts", + "default": "./dist/web.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint --max-warnings=0 .", + "lint:fix": "eslint . --fix", + "build": "rm -rf dist; tsc" + }, + "peerDependencies": { + "@clickhouse/client": "^1.20.0", + "@clickhouse/client-web": "^1.20.0" + }, + "peerDependenciesMeta": { + "@clickhouse/client": { + "optional": true + }, + "@clickhouse/client-web": { + "optional": true + } + }, + "dependencies": { + "@clickhouse/client-common": "1.20.0" + } +} diff --git a/packages/drizzle-clickhouse/src/dialect.ts b/packages/drizzle-clickhouse/src/dialect.ts new file mode 100644 index 000000000..af01cb3bf --- /dev/null +++ b/packages/drizzle-clickhouse/src/dialect.ts @@ -0,0 +1,305 @@ +import { compile, SQL, sql as sqlTag, type CompiledSQL } from './sql.js' +import { + quoteIdentifier, + quoteQualifiedIdentifier, + quoteStringLiteral, +} from './escape.js' +import type { Table } from './schema/table.js' +import type { Column } from './schema/columns.js' + +/** + * The ClickHouse dialect. Pure SQL generation — no driver concerns. + * Holds a couple of cross-cutting defaults (default database, `mutations_sync`) + * that get woven into emitted DDL/DML. + */ +export class ClickHouseDialect { + constructor( + readonly options: { + database?: string + } = {}, + ) {} + + // ── DDL ──────────────────────────────────────────────────────────────── + + createTable(table: Table, opts: { ifNotExists?: boolean } = {}): CompiledSQL { + const { columns, config } = table + const cols = (Object.entries(columns) as [string, Column][]).map( + ([key, col]) => this.renderColumnDef(col.clickhouseName ?? key, col), + ) + const lines: string[] = [] + lines.push( + `CREATE TABLE${opts.ifNotExists ? ' IF NOT EXISTS' : ''} ${quoteQualifiedIdentifier( + table.qualifiedName(this.options.database), + )}${config.cluster ? ` ON CLUSTER ${quoteIdentifier(config.cluster)}` : ''}`, + ) + lines.push('(') + lines.push(' ' + cols.join(',\n ')) + lines.push(')') + lines.push(`ENGINE = ${config.engine.clause}`) + + if (config.orderBy !== undefined) { + lines.push(`ORDER BY ${this.renderOrderBy(config.orderBy)}`) + } + if (config.partitionBy !== undefined) { + lines.push(`PARTITION BY ${this.renderExprOrIdent(config.partitionBy)}`) + } + if (config.primaryKey !== undefined && config.primaryKey.length > 0) { + lines.push( + `PRIMARY KEY (${config.primaryKey.map(quoteIdentifier).join(', ')})`, + ) + } + if (config.sampleBy !== undefined) { + lines.push(`SAMPLE BY ${this.renderExprOrIdent(config.sampleBy)}`) + } + if (config.ttl !== undefined) { + lines.push(`TTL ${compile(config.ttl).sql}`) + } + if (config.settings && Object.keys(config.settings).length > 0) { + const kv = Object.entries(config.settings) + .map(([k, v]) => `${k} = ${this.renderSettingValue(v)}`) + .join(', ') + lines.push(`SETTINGS ${kv}`) + } + if (config.comment !== undefined) { + lines.push(`COMMENT ${quoteStringLiteral(config.comment)}`) + } + return { sql: lines.join('\n'), params: {} } + } + + dropTable( + table: Table, + opts: { ifExists?: boolean; sync?: boolean } = {}, + ): CompiledSQL { + const fq = quoteQualifiedIdentifier( + table.qualifiedName(this.options.database), + ) + const parts = ['DROP TABLE'] + if (opts.ifExists) parts.push('IF EXISTS') + parts.push(fq) + if (table.config.cluster) { + parts.push(`ON CLUSTER ${quoteIdentifier(table.config.cluster)}`) + } + if (opts.sync) parts.push('SYNC') + return { sql: parts.join(' '), params: {} } + } + + truncateTable(table: Table, opts: { ifExists?: boolean } = {}): CompiledSQL { + const fq = quoteQualifiedIdentifier( + table.qualifiedName(this.options.database), + ) + const parts = ['TRUNCATE TABLE'] + if (opts.ifExists) parts.push('IF EXISTS') + parts.push(fq) + if (table.config.cluster) { + parts.push(`ON CLUSTER ${quoteIdentifier(table.config.cluster)}`) + } + return { sql: parts.join(' '), params: {} } + } + + // ── DML ──────────────────────────────────────────────────────────────── + + /** + * Build a SELECT statement. The "where" / "having" / "limitBy" clauses are + * accepted as compiled {@link SQL} fragments so users can mix in placeholders + * via the `sql` tag. + */ + select(plan: SelectPlan): CompiledSQL { + const lines: string[] = [] + const accParams: Record = {} + + if (plan.with && plan.with.length > 0) { + const parts = plan.with.map((w) => { + const c = compile(w.query) + Object.assign(accParams, c.params) + return `${quoteIdentifier(w.name)} AS (${c.sql})` + }) + lines.push(`WITH ${parts.join(', ')}`) + } + + const projection = plan.columns ?? ['*'] + const projectionText = projection + .map((p) => { + if (typeof p === 'string') return p === '*' ? '*' : quoteIdentifier(p) + const c = compile(p.expr) + Object.assign(accParams, c.params) + return p.alias ? `${c.sql} AS ${quoteIdentifier(p.alias)}` : c.sql + }) + .join(', ') + lines.push(`SELECT ${projection.length === 0 ? '*' : projectionText}`) + + if (plan.from) { + lines.push( + `FROM ${this.renderFrom(plan.from)}${plan.final ? ' FINAL' : ''}`, + ) + } + + if (plan.where) { + const c = compile(plan.where) + Object.assign(accParams, c.params) + lines.push(`WHERE ${c.sql}`) + } + if (plan.groupBy && plan.groupBy.length > 0) { + lines.push( + `GROUP BY ${plan.groupBy + .map((g) => + typeof g === 'string' + ? quoteIdentifier(g) + : compileInto(g, accParams), + ) + .join(', ')}`, + ) + } + if (plan.having) { + const c = compile(plan.having) + Object.assign(accParams, c.params) + lines.push(`HAVING ${c.sql}`) + } + if (plan.orderBy && plan.orderBy.length > 0) { + lines.push( + `ORDER BY ${plan.orderBy + .map((o) => { + const expr = + typeof o.expr === 'string' + ? quoteIdentifier(o.expr) + : compileInto(o.expr, accParams) + return `${expr} ${o.direction ?? 'ASC'}` + }) + .join(', ')}`, + ) + } + if (plan.limit !== undefined) { + lines.push( + `LIMIT ${plan.limit}${plan.offset !== undefined ? ` OFFSET ${plan.offset}` : ''}`, + ) + } + if (plan.settings && Object.keys(plan.settings).length > 0) { + const kv = Object.entries(plan.settings) + .map(([k, v]) => `${k} = ${this.renderSettingValue(v)}`) + .join(', ') + lines.push(`SETTINGS ${kv}`) + } + + return { sql: lines.join('\n'), params: accParams } + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + private renderColumnDef(name: string, col: Column): string { + const parts: string[] = [quoteIdentifier(name), col.ddlType] + if (col._hasDefault) { + const keyword = + col._defaultKind === 'materialized' + ? 'MATERIALIZED' + : col._defaultKind === 'alias' + ? 'ALIAS' + : col._defaultKind === 'ephemeral' + ? 'EPHEMERAL' + : 'DEFAULT' + if (col._default === undefined && col._defaultKind === 'ephemeral') { + parts.push(keyword) + } else { + parts.push(`${keyword} ${this.renderDefaultLiteral(col._default)}`) + } + } + if (col._codec) parts.push(`CODEC(${col._codec})`) + if (col._ttl) parts.push(`TTL ${compile(col._ttl).sql}`) + if (col._comment !== undefined) { + parts.push(`COMMENT ${quoteStringLiteral(col._comment)}`) + } + return parts.join(' ') + } + + private renderDefaultLiteral(value: unknown): string { + if (value instanceof SQL) return compile(value).sql + if (value === null) return 'NULL' + if (typeof value === 'string') return quoteStringLiteral(value) + if (typeof value === 'number' || typeof value === 'bigint') + return String(value) + if (typeof value === 'boolean') return value ? 'true' : 'false' + if (value instanceof Date) return quoteStringLiteral(value.toISOString()) + return quoteStringLiteral(JSON.stringify(value)) + } + + private renderOrderBy(orderBy: ReadonlyArray): string { + if (orderBy.length === 0) return 'tuple()' + return orderBy + .map((o) => (typeof o === 'string' ? quoteIdentifier(o) : compile(o).sql)) + .join(', ') + } + + private renderExprOrIdent(value: SQL | string): string { + return typeof value === 'string' + ? quoteIdentifier(value) + : compile(value).sql + } + + private renderSettingValue(v: string | number | boolean): string { + if (typeof v === 'string') return quoteStringLiteral(v) + if (typeof v === 'boolean') return v ? '1' : '0' + return String(v) + } + + private renderFrom(from: FromClause): string { + if (typeof from === 'string') { + return quoteQualifiedIdentifier(from) + } + if ('table' in from) { + const qn = quoteQualifiedIdentifier( + from.table.qualifiedName(this.options.database), + ) + return from.alias ? `${qn} AS ${quoteIdentifier(from.alias)}` : qn + } + // Subquery + const c = compile(from.subquery) + return `(${c.sql})${from.alias ? ` AS ${quoteIdentifier(from.alias)}` : ''}` + } +} + +function compileInto(s: SQL, into: Record): string { + const c = compile(s) + Object.assign(into, c.params) + return c.sql +} + +// ── Plan types ─────────────────────────────────────────────────────────── + +export type FromClause = + | string + | { table: Table; alias?: string } + | { subquery: SQL; alias?: string } + +export interface Projection { + expr: SQL + alias?: string +} + +export interface WithClause { + name: string + query: SQL +} + +export interface OrderByClause { + expr: string | SQL + direction?: 'ASC' | 'DESC' +} + +export interface SelectPlan { + with?: ReadonlyArray + columns?: ReadonlyArray + from?: FromClause + final?: boolean + where?: SQL + groupBy?: ReadonlyArray + having?: SQL + orderBy?: ReadonlyArray + limit?: number + offset?: number + settings?: Record +} + +// Re-export for convenience. +export { sqlTag as sql } + +// Used by table.test.ts and others +export { compile } from './sql.js' +export type { Table, TableConfig } from './schema/table.js' diff --git a/packages/drizzle-clickhouse/src/escape.ts b/packages/drizzle-clickhouse/src/escape.ts new file mode 100644 index 000000000..d245c3e53 --- /dev/null +++ b/packages/drizzle-clickhouse/src/escape.ts @@ -0,0 +1,57 @@ +/** + * Quote a ClickHouse identifier (database, table, column, alias) with backticks + * and escape embedded backticks/backslashes as ClickHouse expects. + * + * See https://clickhouse.com/docs/en/sql-reference/syntax#identifiers + */ +export function quoteIdentifier(name: string): string { + if (name.length === 0) { + throw new Error('[drizzle-clickhouse] identifier must be non-empty') + } + return '`' + name.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`' +} + +/** + * Quote a possibly-qualified identifier of the form `db.table` or `table`, + * preserving dots as separators. Each segment is escaped independently. + */ +export function quoteQualifiedIdentifier(qualified: string): string { + return qualified.split('.').map(quoteIdentifier).join('.') +} + +/** + * Escape a single ClickHouse string literal value (without the surrounding quotes). + * Mirrors {@link packages/client-common/src/data_formatter/format_query_params.ts}'s + * escape table: `\n`, `\t`, `\r`, `\\`, `\'`. + */ +export function escapeStringLiteralBody(value: string): string { + let out = '' + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i) + switch (c) { + case 0x5c: // '\' + out += '\\\\' + break + case 0x27: // "'" + out += "\\'" + break + case 0x0a: + out += '\\n' + break + case 0x0d: + out += '\\r' + break + case 0x09: + out += '\\t' + break + default: + out += value[i] + } + } + return out +} + +/** Wrap with single quotes after escaping. */ +export function quoteStringLiteral(value: string): string { + return "'" + escapeStringLiteralBody(value) + "'" +} diff --git a/packages/drizzle-clickhouse/src/index.ts b/packages/drizzle-clickhouse/src/index.ts new file mode 100644 index 000000000..5adbcedad --- /dev/null +++ b/packages/drizzle-clickhouse/src/index.ts @@ -0,0 +1,98 @@ +/** + * Common, driver-agnostic surface of @clickhouse/drizzle-orm. + * + * For end users, prefer the driver-specific entry points: + * + * import { drizzle } from '@clickhouse/drizzle-orm/node' + * import { drizzle } from '@clickhouse/drizzle-orm/web' + */ + +// SQL building blocks +export { + sql, + ident, + param, + compile, + SQL, + SQLPlaceholder, + Identifier, +} from './sql.js' +export type { CompiledSQL, BoundParam, SQLChunk } from './sql.js' + +// Schema DSL +export { Column } from './schema/columns.js' +export { + string, + fixedString, + uuid, + ipv4, + ipv6, + bool, + int8, + int16, + int32, + int64, + int128, + int256, + uint8, + uint16, + uint32, + uint64, + uint128, + uint256, + float32, + float64, + decimal, + date, + date32, + dateTime, + dateTime64, + enum8, + enum16, + array, + lowCardinality, + nullable, + tuple, + map, + json, + raw, +} from './schema/columns.js' + +export { clickhouseTable, Table } from './schema/table.js' +export type { TableConfig, TableColumns } from './schema/table.js' + +export { + mergeTree, + replacingMergeTree, + summingMergeTree, + aggregatingMergeTree, + collapsingMergeTree, + versionedCollapsingMergeTree, + memory, + nullEngine, + log, + tinyLog, + stripeLog, + replicated, + distributed, +} from './schema/engines.js' +export type { Engine } from './schema/engines.js' + +// Dialect / query builders / session +export { ClickHouseDialect } from './dialect.js' +export type { + SelectPlan, + FromClause, + Projection, + WithClause, + OrderByClause, +} from './dialect.js' + +export { SelectBuilder } from './query-builders/select.js' +export { InsertBuilder } from './query-builders/insert.js' + +export { ClickHouseDatabase } from './session/base.js' +export type { ClickHouseClientLike, DrizzleOptions } from './session/base.js' + +export { NoopLogger, ConsoleLogger, UnsupportedFeatureError } from './types.js' +export type { DrizzleLogger, ScalarTypeTag } from './types.js' diff --git a/packages/drizzle-clickhouse/src/node.ts b/packages/drizzle-clickhouse/src/node.ts new file mode 100644 index 000000000..dbbb3c54b --- /dev/null +++ b/packages/drizzle-clickhouse/src/node.ts @@ -0,0 +1,25 @@ +/** + * Node entry point. Wraps a `@clickhouse/client` instance (or its config) in + * a {@link ClickHouseDatabase}. + */ +import { + ClickHouseDatabase, + type ClickHouseClientLike, + type DrizzleOptions, +} from './session/base.js' + +export * from './index.js' + +/** + * Construct a Drizzle-style database handle around an existing + * `@clickhouse/client` instance. Accepts anything that implements the + * minimal {@link ClickHouseClientLike} surface so the package can be tested + * (and tree-shaken in non-Node bundles) without a hard dependency on the + * Node client. + */ +export function drizzle( + client: ClickHouseClientLike, + options?: DrizzleOptions, +): ClickHouseDatabase { + return new ClickHouseDatabase(client, options) +} diff --git a/packages/drizzle-clickhouse/src/query-builders/insert.ts b/packages/drizzle-clickhouse/src/query-builders/insert.ts new file mode 100644 index 000000000..43dd4ad7d --- /dev/null +++ b/packages/drizzle-clickhouse/src/query-builders/insert.ts @@ -0,0 +1,48 @@ +import type { Table, TableColumns } from '../schema/table.js' + +/** + * INSERT builder. Per the plan, bulk inserts go through the native + * `client.insert({ format: 'JSONEachRow' })` path rather than building a giant + * VALUES string — orders of magnitude faster, lossless for big numbers, and + * matches how the existing examples in this repo write to ClickHouse. + * + * The builder is intentionally a thin descriptor; execution lives in the + * session adapters which speak to the underlying driver. + */ +export class InsertBuilder { + private _values: ReadonlyArray> = [] + private _columnSubset: ReadonlyArray | undefined + + constructor(readonly table: Table) {} + + /** Restrict the inserted column list. */ + columns(names: ReadonlyArray): this { + this._columnSubset = names + return this + } + + /** Single-row or multi-row insert. Values must match `$inferInsert`. */ + values( + rows: + | { [K in keyof T]: T[K]['_']['insert'] } + | ReadonlyArray<{ [K in keyof T]: T[K]['_']['insert'] }>, + ): this { + this._values = Array.isArray(rows) + ? rows + : [rows as Record] + return this + } + + /** Snapshot for the session adapter to consume. */ + toPlan(): { + table: Table + values: ReadonlyArray> + columns?: ReadonlyArray + } { + return { + table: this.table, + values: this._values, + columns: this._columnSubset, + } + } +} diff --git a/packages/drizzle-clickhouse/src/query-builders/select.ts b/packages/drizzle-clickhouse/src/query-builders/select.ts new file mode 100644 index 000000000..e0bec6dab --- /dev/null +++ b/packages/drizzle-clickhouse/src/query-builders/select.ts @@ -0,0 +1,119 @@ +import type { + ClickHouseDialect, + FromClause, + OrderByClause, + Projection, + SelectPlan, + WithClause, +} from '../dialect.js' +import type { CompiledSQL, SQL } from '../sql.js' +import type { Table, TableColumns } from '../schema/table.js' + +/** + * Lightweight SELECT builder. Mirrors enough of Drizzle's surface that + * casual users feel at home; defers to the dialect for SQL generation so + * we don't lock ourselves into Drizzle's internal AST shape. + */ +export class SelectBuilder { + private plan: SelectPlan = {} + + constructor(private readonly dialect: ClickHouseDialect) {} + + with(...ctes: WithClause[]): this { + this.plan = { ...this.plan, with: [...(this.plan.with ?? []), ...ctes] } + return this + } + + select(columns: ReadonlyArray): this { + this.plan = { ...this.plan, columns } + return this + } + + from( + source: + | Table + | { table: Table; alias?: string } + | { subquery: SQL; alias?: string } + | string, + ): SelectBuilder< + T extends TableColumns ? { [K in keyof T]: T[K]['_']['data'] } : TRow + > { + let from: FromClause + if (typeof source === 'string') { + from = source + } else if ('table' in source) { + from = { table: source.table as unknown as Table, alias: source.alias } + } else if ('subquery' in source) { + from = source + } else { + from = { table: source as unknown as Table } + } + this.plan = { ...this.plan, from } + return this as unknown as SelectBuilder< + T extends TableColumns ? { [K in keyof T]: T[K]['_']['data'] } : TRow + > + } + + final(): this { + this.plan = { ...this.plan, final: true } + return this + } + + where(condition: SQL): this { + this.plan = { ...this.plan, where: condition } + return this + } + + groupBy(...columns: ReadonlyArray): this { + this.plan = { ...this.plan, groupBy: columns } + return this + } + + having(condition: SQL): this { + this.plan = { ...this.plan, having: condition } + return this + } + + orderBy(...order: OrderByClause[]): this { + this.plan = { ...this.plan, orderBy: order } + return this + } + + limit(n: number): this { + if (!Number.isInteger(n) || n < 0) { + throw new Error( + '[drizzle-clickhouse] limit must be a non-negative integer', + ) + } + this.plan = { ...this.plan, limit: n } + return this + } + + offset(n: number): this { + if (!Number.isInteger(n) || n < 0) { + throw new Error( + '[drizzle-clickhouse] offset must be a non-negative integer', + ) + } + this.plan = { ...this.plan, offset: n } + return this + } + + settings(s: Record): this { + this.plan = { + ...this.plan, + settings: { ...(this.plan.settings ?? {}), ...s }, + } + return this + } + + /** Compile to text + params without executing. Useful for tests/debugging. */ + toSQL(): CompiledSQL { + return this.dialect.select(this.plan) + } + + /** Snapshot of the underlying plan (read-only). */ + getPlan(): Readonly { + return this.plan + } +} diff --git a/packages/drizzle-clickhouse/src/schema/columns.ts b/packages/drizzle-clickhouse/src/schema/columns.ts new file mode 100644 index 000000000..b4f99f3f5 --- /dev/null +++ b/packages/drizzle-clickhouse/src/schema/columns.ts @@ -0,0 +1,243 @@ +import { quoteStringLiteral } from '../escape.js' +import type { SQL } from '../sql.js' + +/** + * Erased column shape used by the dialect. The TS-level generic type lives + * separately on {@link Column} so that schema users still get inferred + * SELECT/INSERT row types. + */ +export interface ColumnRuntime { + /** Final ClickHouse DDL type string, e.g. `Nullable(LowCardinality(String))`. */ + readonly ddlType: string + /** ClickHouse name (after `.name(...)` rename, otherwise the JS key). */ + readonly clickhouseName: string | undefined + readonly _default?: unknown | SQL + readonly _defaultKind?: + | 'value' + | 'expression' + | 'materialized' + | 'alias' + | 'ephemeral' + readonly _codec?: string + readonly _ttl?: SQL + readonly _comment?: string + readonly _nullable: boolean + readonly _lowCardinality: boolean +} + +/** + * TypeScript-typed column descriptor. `TData` is the row-shape type that + * appears in `$inferSelect`; `TInsert` is the insert-shape type. They are + * separate to model fields that are optional on insert (e.g. defaults). + */ +export class Column implements ColumnRuntime { + declare readonly _: { data: TData; insert: TInsert } + + ddlType: string + clickhouseName: string | undefined + _default?: unknown + _defaultKind?: ColumnRuntime['_defaultKind'] + _codec?: string + _ttl?: SQL + _comment?: string + _nullable = false + _lowCardinality = false + _hasDefault = false + + constructor(ddlType: string) { + this.ddlType = ddlType + } + + /** Override the on-disk column name (defaults to the JS property key). */ + name(n: string): this { + this.clickhouseName = n + return this + } + + /** `Nullable(T)` wrapper. */ + nullable(): Column { + this._nullable = true + this.ddlType = `Nullable(${this.ddlType})` + return this as unknown as Column + } + + /** `LowCardinality(T)` wrapper — only valid on Strings / FixedString / numeric scalars. */ + lowCardinality(): this { + if (this._lowCardinality) return this + this._lowCardinality = true + // Apply outside any Nullable so the DDL reads `LowCardinality(Nullable(T))`. + this.ddlType = `LowCardinality(${this.ddlType})` + return this + } + + /** Eager DEFAULT value or SQL expression. */ + default(value: TInsert | SQL): Column { + this._default = value + this._defaultKind = isSQL(value) ? 'expression' : 'value' + this._hasDefault = true + return this as unknown as Column + } + + /** MATERIALIZED expression. The column is not insertable. */ + materialized(expr: SQL): Column { + this._default = expr + this._defaultKind = 'materialized' + this._hasDefault = true + return this as unknown as Column + } + + /** ALIAS expression — not stored, computed on read. */ + alias(expr: SQL): Column { + this._default = expr + this._defaultKind = 'alias' + this._hasDefault = true + return this as unknown as Column + } + + /** EPHEMERAL with optional default — not stored, only valid in INSERTs. */ + ephemeral(value?: TInsert | SQL): this { + this._default = value + this._defaultKind = 'ephemeral' + this._hasDefault = true + return this + } + + codec(codec: string): this { + this._codec = codec + return this + } + + ttl(expr: SQL): this { + this._ttl = expr + return this + } + + comment(text: string): this { + this._comment = text + return this + } +} + +function isSQL(v: unknown): v is SQL { + return typeof v === 'object' && v !== null && 'chunks' in v +} + +// ── Scalar constructors ──────────────────────────────────────────────────── + +export const string = () => new Column('String') +export const fixedString = (n: number) => { + if (!Number.isInteger(n) || n <= 0) { + throw new Error( + '[drizzle-clickhouse] fixedString length must be a positive integer', + ) + } + return new Column(`FixedString(${n})`) +} +export const uuid = () => new Column('UUID') +export const ipv4 = () => new Column('IPv4') +export const ipv6 = () => new Column('IPv6') + +export const bool = () => new Column('Bool') + +export const int8 = () => new Column('Int8') +export const int16 = () => new Column('Int16') +export const int32 = () => new Column('Int32') +export const int64 = () => new Column('Int64') // string by default to avoid lossy >2^53 reads +export const int128 = () => new Column('Int128') +export const int256 = () => new Column('Int256') + +export const uint8 = () => new Column('UInt8') +export const uint16 = () => new Column('UInt16') +export const uint32 = () => new Column('UInt32') +export const uint64 = () => new Column('UInt64') +export const uint128 = () => new Column('UInt128') +export const uint256 = () => new Column('UInt256') + +export const float32 = () => new Column('Float32') +export const float64 = () => new Column('Float64') + +export const decimal = (precision: number, scale: number) => { + if ( + !Number.isInteger(precision) || + !Number.isInteger(scale) || + precision < 1 || + scale < 0 || + scale > precision + ) { + throw new Error( + '[drizzle-clickhouse] decimal(precision, scale): precision >= 1, 0 <= scale <= precision', + ) + } + return new Column(`Decimal(${precision}, ${scale})`) +} + +export const date = () => new Column('Date') +export const date32 = () => new Column('Date32') +export const dateTime = (tz?: string) => + new Column(tz ? `DateTime(${quoteStringLiteral(tz)})` : 'DateTime') +export const dateTime64 = (precision: number, tz?: string) => { + if (!Number.isInteger(precision) || precision < 0 || precision > 9) { + throw new Error( + '[drizzle-clickhouse] dateTime64 precision must be an integer 0..9', + ) + } + return new Column( + tz + ? `DateTime64(${precision}, ${quoteStringLiteral(tz)})` + : `DateTime64(${precision})`, + ) +} + +export const enum8 = >(values: T) => + new Column(buildEnumDDL('Enum8', values)) +export const enum16 = >(values: T) => + new Column(buildEnumDDL('Enum16', values)) + +function buildEnumDDL( + kind: 'Enum8' | 'Enum16', + values: Record, +): string { + const entries = Object.entries(values) + if (entries.length === 0) { + throw new Error(`[drizzle-clickhouse] ${kind} requires at least one value`) + } + const body = entries + .map(([k, v]) => `${quoteStringLiteral(k)} = ${v}`) + .join(', ') + return `${kind}(${body})` +} + +// ── Composite constructors ───────────────────────────────────────────────── + +export const array = (inner: Column) => + new Column(`Array(${inner.ddlType})`) + +/** + * Wrap an inner column with `LowCardinality(...)`. Equivalent to + * `inner.lowCardinality()`, kept around because the plan and Drizzle's + * column-wrapper style both name it as a free function. + */ +export const lowCardinality = (inner: Column) => + inner.lowCardinality() + +/** + * Wrap an inner column with `Nullable(...)`. Same shape as + * {@link lowCardinality}. + */ +export const nullable = (inner: Column) => + inner.nullable() + +export const tuple = (...inner: T) => + new Column<{ [K in keyof T]: T[K] extends Column ? D : never }>( + `Tuple(${inner.map((c) => c.ddlType).join(', ')})`, + ) + +export const map = (key: Column, value: Column) => + new Column>(`Map(${key.ddlType}, ${value.ddlType})`) + +export const json = () => new Column('JSON') + +// ── Generic escape hatch ─────────────────────────────────────────────────── + +/** Declare a column with an arbitrary ClickHouse type string. */ +export const raw = (ddl: string) => new Column(ddl) diff --git a/packages/drizzle-clickhouse/src/schema/engines.ts b/packages/drizzle-clickhouse/src/schema/engines.ts new file mode 100644 index 000000000..6da3c87e1 --- /dev/null +++ b/packages/drizzle-clickhouse/src/schema/engines.ts @@ -0,0 +1,99 @@ +import { quoteStringLiteral } from '../escape.js' +import type { SQL } from '../sql.js' + +/** + * A serialized ClickHouse table engine clause. Construct via the helpers + * exported below (`mergeTree()`, `replacingMergeTree(...)`, etc.) rather than + * by hand. + */ +export interface Engine { + readonly clause: string +} + +const make = (clause: string): Engine => ({ clause }) + +export const mergeTree = () => make('MergeTree()') + +export const replacingMergeTree = ( + versionColumn?: string, + isDeletedColumn?: string, +) => { + const args: string[] = [] + if (versionColumn) args.push(versionColumn) + if (isDeletedColumn) { + if (!versionColumn) { + throw new Error( + '[drizzle-clickhouse] replacingMergeTree: isDeletedColumn requires versionColumn', + ) + } + args.push(isDeletedColumn) + } + return make(`ReplacingMergeTree(${args.join(', ')})`) +} + +export const summingMergeTree = (columns?: string[]) => + make( + columns && columns.length > 0 + ? `SummingMergeTree((${columns.join(', ')}))` + : 'SummingMergeTree()', + ) + +export const aggregatingMergeTree = () => make('AggregatingMergeTree()') + +export const collapsingMergeTree = (signColumn: string) => + make(`CollapsingMergeTree(${signColumn})`) + +export const versionedCollapsingMergeTree = ( + signColumn: string, + versionColumn: string, +) => make(`VersionedCollapsingMergeTree(${signColumn}, ${versionColumn})`) + +export const memory = () => make('Memory') +export const nullEngine = () => make('Null') +export const log = () => make('Log') +export const tinyLog = () => make('TinyLog') +export const stripeLog = () => make('StripeLog') + +export const replicated = ( + base: Engine, + zooPath: string, + replicaName: string, +): Engine => { + // Insert the replication args after the engine name (works for MergeTree + // family which is what supports Replicated*). + const m = /^([A-Za-z][A-Za-z0-9]*)\((.*)\)$/.exec(base.clause) + if (!m) { + throw new Error( + '[drizzle-clickhouse] replicated(): unrecognised base engine clause', + ) + } + const [, name, args] = m + const head = `Replicated${name}(${quoteStringLiteral(zooPath)}, ${quoteStringLiteral(replicaName)}` + return make(args.length > 0 ? `${head}, ${args})` : `${head})`) +} + +export const distributed = ( + cluster: string, + database: string, + table: string, + shardingKey?: SQL | string, +): Engine => { + const parts = [ + quoteStringLiteral(cluster), + quoteStringLiteral(database), + quoteStringLiteral(table), + ] + if (shardingKey) { + parts.push( + typeof shardingKey === 'string' ? shardingKey : '__sharding_key__', + ) + // For SQL fragments, we'd need to compile in caller context; keep the + // common-case string form for MVP and document this as a Phase 2 follow-up. + if (typeof shardingKey !== 'string') { + throw new Error( + '[drizzle-clickhouse] distributed(): SQL shardingKey not yet supported; pass a column name as string', + ) + } + } + return make(`Distributed(${parts.join(', ')})`) +} diff --git a/packages/drizzle-clickhouse/src/schema/table.ts b/packages/drizzle-clickhouse/src/schema/table.ts new file mode 100644 index 000000000..48acaafb0 --- /dev/null +++ b/packages/drizzle-clickhouse/src/schema/table.ts @@ -0,0 +1,78 @@ +import type { SQL } from '../sql.js' +import type { Engine } from './engines.js' +import type { Column } from './columns.js' + +/** + * Options for the `clickhouseTable` table-config callback. + * + * `orderBy` is required for any MergeTree-family engine (ClickHouse will + * reject CREATE TABLE otherwise); the dialect emits `ORDER BY tuple()` if you + * pass an empty array, which is the canonical "no ordering" form. + */ +export interface TableConfig { + engine: Engine + orderBy?: ReadonlyArray + partitionBy?: SQL | string + primaryKey?: ReadonlyArray + sampleBy?: SQL | string + ttl?: SQL + settings?: Record + cluster?: string + comment?: string +} + +export type TableColumns = Readonly>> + +/** + * A schema-level handle to a ClickHouse table. Returned from + * {@link clickhouseTable}; the `_` property carries the row-shape types used + * by `$inferSelect`/`$inferInsert`. + */ +export class Table { + declare readonly $inferSelect: { + [K in keyof TColumns]: TColumns[K]['_']['data'] + } + declare readonly $inferInsert: { + [K in keyof TColumns]: TColumns[K]['_']['insert'] + } + + constructor( + readonly name: string, + readonly columns: TColumns, + readonly config: TableConfig, + readonly database?: string, + ) { + // Populate clickhouseName from the JS key when the user didn't override it. + for (const [key, col] of Object.entries(columns) as [string, Column][]) { + if (col.clickhouseName === undefined) col.clickhouseName = key + } + } + + /** Fully-qualified `db.table` identifier (without backticks). */ + qualifiedName(defaultDatabase?: string): string { + const db = this.database ?? defaultDatabase + return db ? `${db}.${this.name}` : this.name + } +} + +/** + * Declare a ClickHouse table. + * + * const events = clickhouseTable( + * 'events', + * { + * ts: dateTime64(3), + * userId: uint64(), + * kind: lowCardinality(string()), + * }, + * () => ({ engine: mergeTree(), orderBy: ['ts', 'userId'] }), + * ) + */ +export function clickhouseTable( + name: string, + columns: TColumns, + configFn: (columns: TColumns) => TableConfig, + options?: { database?: string }, +): Table { + return new Table(name, columns, configFn(columns), options?.database) +} diff --git a/packages/drizzle-clickhouse/src/session/base.ts b/packages/drizzle-clickhouse/src/session/base.ts new file mode 100644 index 000000000..991cc8624 --- /dev/null +++ b/packages/drizzle-clickhouse/src/session/base.ts @@ -0,0 +1,219 @@ +import type { CompiledSQL } from '../sql.js' +import { ClickHouseDialect } from '../dialect.js' +import type { DrizzleLogger } from '../types.js' +import { NoopLogger, UnsupportedFeatureError } from '../types.js' +import { SelectBuilder } from '../query-builders/select.js' +import { InsertBuilder } from '../query-builders/insert.js' +import type { Table, TableColumns } from '../schema/table.js' +import { compile, type SQL } from '../sql.js' + +/** + * Driver-agnostic interface that any underlying ClickHouse client must + * implement for the adapter to use it. + * + * Both `@clickhouse/client` and `@clickhouse/client-web` already expose this + * surface — see {@link ./node} and {@link ./web} for the trivial adapters. + */ +export interface ClickHouseClientLike { + query(args: { + query: string + query_params?: Record + format?: string + clickhouse_settings?: Record + abort_signal?: AbortSignal + }): Promise<{ json(): Promise }> + + command(args: { + query: string + query_params?: Record + clickhouse_settings?: Record + abort_signal?: AbortSignal + }): Promise + + insert(args: { + table: string + values: + | ReadonlyArray> + | NodeJS.ReadableStream + | ReadableStream + format?: string + columns?: ReadonlyArray + clickhouse_settings?: Record + abort_signal?: AbortSignal + }): Promise + + close?(): Promise +} + +export interface DrizzleOptions { + /** Override the default `database` used to qualify table names. */ + database?: string + /** Default ClickHouse settings injected into every query/insert/command. */ + defaultSettings?: Record + /** Default `mutations_sync` for ALTER UPDATE/DELETE/OPTIMIZE (Phase 2). */ + mutationsSync?: 0 | 1 | 2 + /** Custom logger; defaults to {@link NoopLogger}. */ + logger?: DrizzleLogger | boolean +} + +/** + * The user-facing handle returned from `drizzle(client)`. Routes + * select/insert/raw queries to the underlying driver and the dialect. + */ +export class ClickHouseDatabase { + readonly dialect: ClickHouseDialect + + constructor( + readonly client: ClickHouseClientLike, + readonly options: DrizzleOptions = {}, + ) { + this.dialect = new ClickHouseDialect({ database: options.database }) + } + + private get logger(): DrizzleLogger { + if (this.options.logger === true) { + // Lazy-load to avoid no-console lint hits in non-debug paths. + return { + logQuery: (q, p) => { + // eslint-disable-next-line no-console + console.log('[drizzle-clickhouse]', q, p ?? {}) + }, + } + } + if (this.options.logger && typeof this.options.logger === 'object') { + return this.options.logger + } + return new NoopLogger() + } + + /** Start a SELECT. Chain `.from(table)` to type the row. */ + select(columns?: ReadonlyArray): SelectBuilder { + const sb = new SelectBuilder(this.dialect) + if (columns) sb.select(columns) + return sb + } + + /** Start an INSERT. */ + insert(table: Table): InsertBuilder { + return new InsertBuilder(table) + } + + // ── Execution ──────────────────────────────────────────────────────── + + /** Compile a select builder and execute it, returning typed rows. */ + async run(builder: SelectBuilder): Promise { + const compiled = builder.toSQL() + return this.executeSelect(compiled) + } + + /** Execute an INSERT builder against the driver via the native `insert` path. */ + async runInsert( + builder: InsertBuilder, + ): Promise { + const plan = builder.toPlan() + if (plan.values.length === 0) return + this.logger.logQuery( + `INSERT INTO ${plan.table.qualifiedName(this.options.database)}`, + ) + await this.client.insert({ + table: plan.table.qualifiedName(this.options.database), + values: plan.values, + format: 'JSONEachRow', + columns: plan.columns, + clickhouse_settings: this.options.defaultSettings, + }) + } + + /** Execute a raw `sql` fragment, returning rows. */ + async execute(query: SQL): Promise { + const compiled = compile(query) + return this.executeSelect(compiled) + } + + /** + * Execute a raw command (DDL / DROP / TRUNCATE / OPTIMIZE / SYSTEM …), + * returning nothing. Use this for any statement that doesn't produce a + * row stream. + */ + async command(query: SQL): Promise { + const compiled = compile(query) + this.logger.logQuery(compiled.sql, compiled.params) + await this.client.command({ + query: compiled.sql, + query_params: compiled.params, + clickhouse_settings: this.options.defaultSettings, + }) + } + + // ── DDL helpers ───────────────────────────────────────────────────── + + async createTable( + table: Table, + opts?: { ifNotExists?: boolean }, + ): Promise { + const c = this.dialect.createTable(table, opts) + this.logger.logQuery(c.sql, c.params) + await this.client.command({ + query: c.sql, + query_params: c.params, + clickhouse_settings: this.options.defaultSettings, + }) + } + + async dropTable( + table: Table, + opts?: { ifExists?: boolean; sync?: boolean }, + ): Promise { + const c = this.dialect.dropTable(table, opts) + this.logger.logQuery(c.sql, c.params) + await this.client.command({ + query: c.sql, + query_params: c.params, + clickhouse_settings: this.options.defaultSettings, + }) + } + + async truncateTable( + table: Table, + opts?: { ifExists?: boolean }, + ): Promise { + const c = this.dialect.truncateTable(table, opts) + this.logger.logQuery(c.sql, c.params) + await this.client.command({ + query: c.sql, + query_params: c.params, + clickhouse_settings: this.options.defaultSettings, + }) + } + + /** + * ClickHouse has no general transactions; we throw rather than silently + * losing atomicity guarantees. Users who explicitly want a "no-op block" + * can pass `{ allowNoTx: true }`, which simply invokes the callback with + * `this`. + */ + async transaction( + fn: (tx: ClickHouseDatabase) => Promise, + opts: { allowNoTx?: boolean } = {}, + ): Promise { + if (!opts.allowNoTx) { + throw new UnsupportedFeatureError( + 'transaction()', + 'Pass { allowNoTx: true } to acknowledge that statements will execute without atomicity.', + ) + } + return fn(this) + } + + private async executeSelect(compiled: CompiledSQL): Promise { + this.logger.logQuery(compiled.sql, compiled.params) + const rs = await this.client.query({ + query: compiled.sql, + query_params: compiled.params, + format: 'JSONEachRow', + clickhouse_settings: this.options.defaultSettings, + }) + const json = await rs.json() + return Array.isArray(json) ? json : [json] + } +} diff --git a/packages/drizzle-clickhouse/src/sql.ts b/packages/drizzle-clickhouse/src/sql.ts new file mode 100644 index 000000000..bd7b9b47a --- /dev/null +++ b/packages/drizzle-clickhouse/src/sql.ts @@ -0,0 +1,178 @@ +import { quoteStringLiteral, quoteQualifiedIdentifier } from './escape.js' + +/** + * A bound query parameter destined for ClickHouse's `{name:Type}` placeholder + * syntax. + * + * The dialect emits `{p0:T}` markers in the SQL, then hands the named values + * to the driver (`@clickhouse/client.query({ query, query_params })`), which + * forwards them as URL params (see `format_query_params.ts` in client-common). + */ +export interface BoundParam { + /** ClickHouse type tag, e.g. `String`, `UInt32`, `DateTime64(3)`, `Array(Int8)`. */ + type: string + /** Raw value forwarded to the driver — the driver does the final encoding. */ + value: unknown +} + +/** + * One node in a `sql\`...\`` template tree. Either a literal SQL chunk + * (already escaped by the caller) or a placeholder that holds a value to be + * bound at compile time. + */ +export type SQLChunk = string | SQLPlaceholder | SQL | Identifier + +export class SQLPlaceholder { + constructor( + readonly value: unknown, + readonly type?: string, + ) {} +} + +export class Identifier { + constructor(readonly name: string) {} +} + +/** + * A composable SQL fragment built from string literals and placeholders. + * + * Use the {@link sql} tagged template to construct instances: + * + * sql`SELECT * FROM ${ident('users')} WHERE id = ${42}` + */ +export class SQL { + constructor(readonly chunks: ReadonlyArray) {} + + /** Append another fragment. */ + append(other: SQL): SQL { + return new SQL([...this.chunks, ' ', ...other.chunks]) + } +} + +/** + * The compiled output of a {@link SQL} fragment: SQL text with positional + * `{p0:T}` markers, plus the matching named parameter object. + */ +export interface CompiledSQL { + sql: string + params: Record +} + +/** + * Tagged-template constructor that accepts both raw SQL chunks and inline + * values. Values are turned into placeholders bound at compile time. + */ +export function sql(strings: TemplateStringsArray, ...values: unknown[]): SQL { + const chunks: SQLChunk[] = [] + for (let i = 0; i < strings.length; i++) { + chunks.push(strings[i] ?? '') + if (i < values.length) { + const v = values[i] + if (v instanceof SQL || v instanceof Identifier) { + chunks.push(v) + } else if (v instanceof SQLPlaceholder) { + chunks.push(v) + } else { + chunks.push(new SQLPlaceholder(v)) + } + } + } + return new SQL(chunks) +} + +/** Helper to embed an identifier into a `sql` template. */ +export function ident(name: string): Identifier { + return new Identifier(name) +} + +/** Helper to embed a typed parameter into a `sql` template. */ +export function param(value: unknown, type?: string): SQLPlaceholder { + return new SQLPlaceholder(value, type) +} + +/** + * Infer the ClickHouse type tag of a JS value when the user didn't supply one. + * This is intentionally conservative; users with non-trivial types (Decimals, + * DateTime64 precisions, Arrays of specific element types, etc.) should use + * {@link param} with an explicit type to avoid surprises. + */ +export function inferType(value: unknown): string { + if (value === null || value === undefined) { + // ClickHouse rejects untyped NULL params; require explicit type. + throw new Error( + '[drizzle-clickhouse] cannot infer type of null/undefined; use param(null, "Nullable(...)") instead', + ) + } + if (typeof value === 'string') return 'String' + if (typeof value === 'boolean') return 'Bool' + if (typeof value === 'bigint') return 'Int64' + if (typeof value === 'number') { + return Number.isInteger(value) ? 'Int64' : 'Float64' + } + if (value instanceof Date) return 'DateTime64(3)' + if (Array.isArray(value)) { + if (value.length === 0) { + throw new Error( + '[drizzle-clickhouse] cannot infer element type of empty array; use param(value, "Array(...)")', + ) + } + return `Array(${inferType(value[0])})` + } + throw new Error( + `[drizzle-clickhouse] cannot infer ClickHouse type of value: ${String(value)}`, + ) +} + +/** + * Compile a {@link SQL} fragment to text + named params, suitable for passing + * to `client.query({ query, query_params })`. + */ +export function compile(node: SQL): CompiledSQL { + const params: Record = {} + let counter = 0 + const text = renderChunks(node.chunks, params, () => `p${counter++}`) + return { sql: text, params } +} + +function renderChunks( + chunks: ReadonlyArray, + params: Record, + nextName: () => string, +): string { + let out = '' + for (const chunk of chunks) { + if (typeof chunk === 'string') { + out += chunk + } else if (chunk instanceof Identifier) { + out += quoteQualifiedIdentifier(chunk.name) + } else if (chunk instanceof SQL) { + out += renderChunks(chunk.chunks, params, nextName) + } else { + out += renderPlaceholder(chunk, params, nextName) + } + } + return out +} + +function renderPlaceholder( + p: SQLPlaceholder, + params: Record, + nextName: () => string, +): string { + // Inline strategy: small literals (numbers, booleans, plain strings <=64 chars + // with no control characters) are inlined for readability; everything else + // becomes a typed placeholder so the driver handles encoding/escaping. + const v = p.value + if (p.type === undefined) { + if (typeof v === 'number' && Number.isFinite(v)) return String(v) + if (typeof v === 'boolean') return v ? 'true' : 'false' + if (typeof v === 'bigint') return v.toString() + if (typeof v === 'string' && v.length <= 64 && /^[\x20-\x7E]*$/.test(v)) { + return quoteStringLiteral(v) + } + } + const name = nextName() + const type = p.type ?? inferType(v) + params[name] = v + return `{${name}:${type}}` +} diff --git a/packages/drizzle-clickhouse/src/types.ts b/packages/drizzle-clickhouse/src/types.ts new file mode 100644 index 000000000..182ff7e51 --- /dev/null +++ b/packages/drizzle-clickhouse/src/types.ts @@ -0,0 +1,69 @@ +/** + * Public ClickHouse type tags used by the schema DSL and the dialect. + * + * These mirror the textual form that appears in CREATE TABLE statements, + * minus modifiers like Nullable/Array/LowCardinality which are layered on + * top by column-builder wrappers (see {@link ./schema/columns}). + */ +export type ScalarTypeTag = + | 'String' + | 'FixedString' + | 'UUID' + | 'IPv4' + | 'IPv6' + | 'Bool' + | 'Int8' + | 'Int16' + | 'Int32' + | 'Int64' + | 'Int128' + | 'Int256' + | 'UInt8' + | 'UInt16' + | 'UInt32' + | 'UInt64' + | 'UInt128' + | 'UInt256' + | 'Float32' + | 'Float64' + | 'Decimal' + | 'Date' + | 'Date32' + | 'DateTime' + | 'DateTime64' + | 'Enum8' + | 'Enum16' + | 'JSON' + +/** Logger surface compatible with `@clickhouse/client-common` Logger but kept tiny. */ +export interface DrizzleLogger { + logQuery(query: string, params?: Record): void +} + +export class NoopLogger implements DrizzleLogger { + logQuery(): void { + /* no-op */ + } +} + +export class ConsoleLogger implements DrizzleLogger { + logQuery(query: string, params?: Record): void { + // eslint-disable-next-line no-console + console.log('[drizzle-clickhouse]', query, params ?? {}) + } +} + +/** + * Thrown when the user invokes a Drizzle feature that doesn't map cleanly + * to ClickHouse semantics (transactions, per-row UPDATE/DELETE w/o ALTER, + * unique constraints, etc.). + */ +export class UnsupportedFeatureError extends Error { + constructor(feature: string, hint?: string) { + super( + `[drizzle-clickhouse] ${feature} is not supported by ClickHouse.` + + (hint ? ` ${hint}` : ''), + ) + this.name = 'UnsupportedFeatureError' + } +} diff --git a/packages/drizzle-clickhouse/src/web.ts b/packages/drizzle-clickhouse/src/web.ts new file mode 100644 index 000000000..0c2cd57bf --- /dev/null +++ b/packages/drizzle-clickhouse/src/web.ts @@ -0,0 +1,20 @@ +/** + * Web entry point. Wraps a `@clickhouse/client-web` instance in a + * {@link ClickHouseDatabase}. The shape is identical to the Node entry — the + * two files exist primarily for documentation/discoverability and to give + * bundlers an explicit per-environment import path. + */ +import { + ClickHouseDatabase, + type ClickHouseClientLike, + type DrizzleOptions, +} from './session/base.js' + +export * from './index.js' + +export function drizzle( + client: ClickHouseClientLike, + options?: DrizzleOptions, +): ClickHouseDatabase { + return new ClickHouseDatabase(client, options) +} diff --git a/packages/drizzle-clickhouse/tsconfig.json b/packages/drizzle-clickhouse/tsconfig.json new file mode 100644 index 000000000..40e5ca437 --- /dev/null +++ b/packages/drizzle-clickhouse/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["./src/**/*.ts"], + "compilerOptions": { + "types": ["node"], + "outDir": "./dist" + } +} diff --git a/vitest.node.config.ts b/vitest.node.config.ts index 33a09929b..6b63df917 100644 --- a/vitest.node.config.ts +++ b/vitest.node.config.ts @@ -18,6 +18,7 @@ const collections = { unit: [ 'packages/client-node/__tests__/unit/*.test.ts', 'packages/client-node/__tests__/utils/*.test.ts', + 'packages/drizzle-clickhouse/__tests__/unit/*.test.ts', ], integration: [ 'packages/client-node/__tests__/integration/*.test.ts', @@ -45,6 +46,7 @@ const collections = { 'packages/client-node/__tests__/unit/*.test.ts', 'packages/client-node/__tests__/utils/*.test.ts', 'packages/client-node/__tests__/integration/*.test.ts', + 'packages/drizzle-clickhouse/__tests__/unit/*.test.ts', ], }