diff --git a/docs/modules/clickhouse.md b/docs/modules/clickhouse.md new file mode 100644 index 000000000..f08738436 --- /dev/null +++ b/docs/modules/clickhouse.md @@ -0,0 +1,13 @@ +# Clickhouse Module + +[Clickhouse](https://clickhouse.com/) is an open source OLAP columnar database. You can checkout the official [Clickhouse JavaScript](https://clickhouse.com/docs/en/integrations/language-clients/javascript) driver here. + +## Install + +```bash +npm install @testcontainers/clickhouse --save-dev +``` + +## Example + +[Test suite](../../packages/modules/clickhouse/src/clickhouse-container.test.ts) diff --git a/mkdocs.yml b/mkdocs.yml index 312724c25..d3fc25526 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,6 +43,7 @@ nav: - Advanced: features/advanced.md - Modules: - ArangoDB: modules/arangodb.md + - Clickhouse: modules/clickhouse.md - Couchbase: modules/couchbase.md - Elasticsearch: modules/elasticsearch.md - HiveMQ: modules/hivemq.md diff --git a/package-lock.json b/package-lock.json index afd1165ec..7ec70e5d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1944,6 +1944,24 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@clickhouse/client": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@clickhouse/client/-/client-0.2.2.tgz", + "integrity": "sha512-2faBnDS4x7ZkcOZqi3f6H967kH+nOfJLhBTWWjz0wTSBnEJBXRtePhN/ZY0NJIKc9Ga5w41Pf67mQgm6Dm/1/w==", + "dev": true, + "dependencies": { + "@clickhouse/client-common": "0.2.2" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@clickhouse/client-common": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@clickhouse/client-common/-/client-common-0.2.2.tgz", + "integrity": "sha512-jlom9zLfcDzX9E3off93ZD3CPOkClyM213Y7TN1datkuRGKMvVyj1k0KXaMekhbRev+FTe85CqfoD5eq6qOnPg==", + "dev": true + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -4284,6 +4302,10 @@ "resolved": "packages/modules/arangodb", "link": true }, + "node_modules/@testcontainers/clickhouse": { + "resolved": "packages/modules/clickhouse", + "link": true + }, "node_modules/@testcontainers/couchbase": { "resolved": "packages/modules/couchbase", "link": true @@ -17006,6 +17028,16 @@ "arangojs": "^8.6.0" } }, + "packages/modules/clickhouse": { + "version": "10.2.1", + "license": "MIT", + "dependencies": { + "testcontainers": "^10.2.1" + }, + "devDependencies": { + "@clickhouse/client": "^0.2.2" + } + }, "packages/modules/couchbase": { "version": "10.1.0", "license": "MIT", diff --git a/packages/modules/clickhouse/jest.config.ts b/packages/modules/clickhouse/jest.config.ts new file mode 100644 index 000000000..1f677baaf --- /dev/null +++ b/packages/modules/clickhouse/jest.config.ts @@ -0,0 +1,11 @@ +import type { Config } from "jest"; +import * as path from "path"; + +const config: Config = { + preset: "ts-jest", + moduleNameMapper: { + "^testcontainers$": path.resolve(__dirname, "../../testcontainers/src"), + }, +}; + +export default config; diff --git a/packages/modules/clickhouse/package.json b/packages/modules/clickhouse/package.json new file mode 100644 index 000000000..c517b0f89 --- /dev/null +++ b/packages/modules/clickhouse/package.json @@ -0,0 +1,37 @@ +{ + "name": "@testcontainers/clickhouse", + "version": "10.2.1", + "license": "MIT", + "keywords": [ + "clickhouse", + "testing", + "docker", + "testcontainers" + ], + "description": "Clickhouse module for Testcontainers", + "homepage": "https://github.com/testcontainers/testcontainers-node#readme", + "repository": { + "type": "git", + "url": "https://github.com/testcontainers/testcontainers-node" + }, + "bugs": { + "url": "https://github.com/testcontainers/testcontainers-node/issues" + }, + "main": "build/index.js", + "files": [ + "build" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "prepack": "shx cp ../../../README.md . && shx cp ../../../LICENSE .", + "build": "tsc --project tsconfig.build.json" + }, + "devDependencies": { + "@clickhouse/client": "^0.2.2" + }, + "dependencies": { + "testcontainers": "^10.2.1" + } +} diff --git a/packages/modules/clickhouse/src/clickhouse-container.test.ts b/packages/modules/clickhouse/src/clickhouse-container.test.ts new file mode 100644 index 000000000..e37b4692f --- /dev/null +++ b/packages/modules/clickhouse/src/clickhouse-container.test.ts @@ -0,0 +1,99 @@ +import { ClickhouseContainer, StartedClickhouseContainer } from "./clickhouse-container"; +import { ClickHouseClient, createClient } from "@clickhouse/client"; +import path from "path"; +import { RandomUuid } from "testcontainers"; + +const CONFIG_FILE_MODE = parseInt("0644", 8); + +describe("ClickhouseContainer", () => { + jest.setTimeout(240_000); + + it("should work with defaults", async () => { + const container = await new ClickhouseContainer().start(); + const client = createClickhouseContainerHttpClient(container); + await testExample(client, container.getDatabase()); + await client.close(); + await container.stop(); + }); + + it("should work with custom credentials", async () => { + const uuid = new RandomUuid(); + const container = await new ClickhouseContainer() + .withUsername(`un-${uuid.nextUuid()}`) + .withPassword(`pass-${uuid.nextUuid()}`) + .start(); + const client = createClickhouseContainerHttpClient(container); + await testExample(client, container.getDatabase()); + await client.close(); + await container.stop(); + }); + + it("should work with custom database and custom yaml config", async () => { + const uuid = new RandomUuid(); + const CONFIG_PATH_YAML = "/etc/clickhouse-server/config.d/config.yaml"; + const container = await new ClickhouseContainer() + .withDatabase(`db-${uuid.nextUuid()}`) + .withPassword("") + .withCopyFilesToContainer([ + { source: path.join("testdata", "config.yaml"), target: CONFIG_PATH_YAML, mode: CONFIG_FILE_MODE }, + ]) + .start(); + const client = createClickhouseContainerHttpClient(container); + await testExample(client, container.getDatabase()); + await client.close(); + await container.stop(); + }); + + it("should work with custom image and custom xml config example", async () => { + const CONFIG_PATH_XML = "/etc/clickhouse-server/config.d/config.xml"; + const container = await new ClickhouseContainer("23.8-alpine") + .withPassword("") + .withCopyFilesToContainer([ + { source: path.join("testdata", "config.xml"), target: CONFIG_PATH_XML, mode: CONFIG_FILE_MODE }, + ]) + .start(); + const client = createClickhouseContainerHttpClient(container); + await testExample(client, container.getDatabase()); + await client.close(); + await container.stop(); + }); + + function createClickhouseContainerHttpClient(container: StartedClickhouseContainer) { + return createClient({ + host: container.getHttpUrl(), + username: container.getUsername(), + password: container.getPassword(), + }); + } + + async function testExample(client: ClickHouseClient, db: string) { + const tableName = "array_json_each_row"; + await client.command({ + query: `DROP TABLE IF EXISTS ${db}.${tableName}`, + }); + await client.command({ + query: ` + CREATE TABLE ${db}.${tableName} + (id UInt64, name String) + ENGINE MergeTree() + ORDER BY (id) + `, + }); + await client.insert({ + table: `${db}.${tableName}`, + values: [ + { id: 42, name: "foo" }, + { id: 42, name: "bar" }, + ], + format: "JSONEachRow", + }); + const rows = await client.query({ + query: `SELECT * FROM ${db}.${tableName}`, + format: "JSONEachRow", + }); + expect(await rows.json()).toEqual([ + { id: "42", name: "foo" }, + { id: "42", name: "bar" }, + ]); + } +}); diff --git a/packages/modules/clickhouse/src/clickhouse-container.ts b/packages/modules/clickhouse/src/clickhouse-container.ts new file mode 100644 index 000000000..c3be860a7 --- /dev/null +++ b/packages/modules/clickhouse/src/clickhouse-container.ts @@ -0,0 +1,94 @@ +import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers"; + +const IMAGE_NAME = "clickhouse/clickhouse-server"; +const DEFAULT_IMAGE_VER = "23.3.8.21-alpine"; + +// The official clickhouse-js client doesn't support the native protocol for now. See https://github.com/ClickHouse/clickhouse-js +const HTTP_PORT = 8123; +const HTTPS_PORT = 8443; + +export class ClickhouseContainer extends GenericContainer { + private database = "test"; + private username = "test"; + private password = "test"; + + constructor(imageVer = DEFAULT_IMAGE_VER) { + super(`${IMAGE_NAME}:${imageVer}`); + } + + public withDatabase(database: string): this { + this.database = database; + return this; + } + + public withUsername(username: string): this { + this.username = username; + return this; + } + + public withPassword(password: string): this { + this.password = password; + return this; + } + + public override async start(): Promise { + this.withExposedPorts(...(this.hasExposedPorts ? this.exposedPorts : [HTTP_PORT, HTTPS_PORT])) + .withEnvironment({ + CLICKHOUSE_DB: this.database, + CLICKHOUSE_USER: this.username, + CLICKHOUSE_PASSWORD: this.password, + }) + .withWaitStrategy(Wait.forHttp("/ping", HTTP_PORT).forStatusCode(200)) + .withStartupTimeout(120_000); + return new StartedClickhouseContainer( + await super.start(), + HTTP_PORT, + HTTPS_PORT, + this.database, + this.username, + this.password + ); + } +} + +export class StartedClickhouseContainer extends AbstractStartedContainer { + private readonly hostHttpPort: number; + private readonly hostHttpsPort: number; + + constructor( + startedTestContainer: StartedTestContainer, + httpPort: number, + httpsPort: number, + private readonly database: string, + private readonly username: string, + private readonly password: string + ) { + super(startedTestContainer); + this.hostHttpPort = startedTestContainer.getMappedPort(httpPort); + this.hostHttpsPort = startedTestContainer.getMappedPort(httpsPort); + } + + public getHttpUrl(): string { + return this.toUrl("http", this.hostHttpPort); + } + + public getHttpsUrl(): string { + return this.toUrl("https", this.hostHttpsPort); + } + + private toUrl(schema: string, port: number): string { + return `${schema}://${this.startedTestContainer.getHost()}:${port}`; + } + + public getDatabase(): string { + return this.database; + } + + public getUsername(): string { + return this.username; + } + + public getPassword(): string { + return this.password; + } +} diff --git a/packages/modules/clickhouse/src/index.ts b/packages/modules/clickhouse/src/index.ts new file mode 100644 index 000000000..0059c19a7 --- /dev/null +++ b/packages/modules/clickhouse/src/index.ts @@ -0,0 +1 @@ +export { ClickhouseContainer, StartedClickhouseContainer } from "./clickhouse-container"; diff --git a/packages/modules/clickhouse/testdata/config.xml b/packages/modules/clickhouse/testdata/config.xml new file mode 100644 index 000000000..027055d38 --- /dev/null +++ b/packages/modules/clickhouse/testdata/config.xml @@ -0,0 +1,3 @@ + + 1 + diff --git a/packages/modules/clickhouse/testdata/config.yaml b/packages/modules/clickhouse/testdata/config.yaml new file mode 100644 index 000000000..eec29f799 --- /dev/null +++ b/packages/modules/clickhouse/testdata/config.yaml @@ -0,0 +1 @@ +allow_no_password: true diff --git a/packages/modules/clickhouse/tsconfig.build.json b/packages/modules/clickhouse/tsconfig.build.json new file mode 100644 index 000000000..0222f6ff1 --- /dev/null +++ b/packages/modules/clickhouse/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "build", + "jest.config.ts", + "src/**/*.test.ts" + ], + "references": [ + { + "path": "../../testcontainers" + } + ] +} \ No newline at end of file diff --git a/packages/modules/clickhouse/tsconfig.json b/packages/modules/clickhouse/tsconfig.json new file mode 100644 index 000000000..39b165817 --- /dev/null +++ b/packages/modules/clickhouse/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "build", + "paths": { + "testcontainers": [ + "../../testcontainers/src" + ] + } + }, + "exclude": [ + "build", + "jest.config.ts" + ], + "references": [ + { + "path": "../../testcontainers" + } + ] +} \ No newline at end of file