Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/workflows/drizzle.yml
Original file line number Diff line number Diff line change
@@ -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"
27 changes: 27 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
155 changes: 155 additions & 0 deletions packages/drizzle-clickhouse/__tests__/unit/database.test.ts
Original file line number Diff line number Diff line change
@@ -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,
})
})
})
Loading