├── .docker
└── clickhouse
│ ├── config.xml
│ └── users.xml
├── .github
└── workflows
│ ├── release.yml
│ └── tests.yml
├── .gitignore
├── .prettierrc
├── CHANGELOG.md
├── LICENSE
├── README.md
├── docker-compose.yml
├── eslint.config.mjs
├── jestconfig.json
├── package-lock.json
├── package.json
├── src
├── cli.ts
├── migrate.ts
├── sql-parse.ts
└── types
│ └── cli.d.ts
├── tests
├── cli.test.ts
├── db.test.ts
├── migrations
│ ├── bad
│ │ └── bad_1.sql
│ ├── few
│ │ ├── 1_init.sql
│ │ └── 2_more.sql
│ └── one
│ │ └── 1_init.sql
└── unit.test.ts
└── tsconfig.json
/.docker/clickhouse/config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | 8123
5 | 9000
6 |
7 | users.xml
8 | default
9 | default
10 |
11 | 5368709120
12 |
13 | /var/lib/clickhouse/
14 | /var/lib/clickhouse/tmp/
15 | /var/lib/clickhouse/user_files/
16 | /var/lib/clickhouse/access/
17 | 3
18 |
19 |
20 | debug
21 | /var/log/clickhouse-server/clickhouse-server.log
22 | /var/log/clickhouse-server/clickhouse-server.err.log
23 | 1000M
24 | 10
25 | 1
26 |
27 |
28 |
29 | system
30 |
31 | toYYYYMM(event_date)
32 | 1000
33 |
34 |
35 |
36 |
37 | Access-Control-Allow-Origin
38 | *
39 |
40 |
41 | Access-Control-Allow-Headers
42 | accept, origin, x-requested-with, content-type, authorization
43 |
44 |
45 | Access-Control-Allow-Methods
46 | POST, GET, OPTIONS
47 |
48 |
49 | Access-Control-Max-Age
50 | 86400
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/.docker/clickhouse/users.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | random
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | ::/0
15 |
16 | default
17 | default
18 | 1
19 |
20 |
21 |
22 |
23 |
24 |
25 | 3600
26 | 0
27 | 0
28 | 0
29 | 0
30 | 0
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release-please
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | jobs:
8 | release-please:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: googleapis/release-please-action@v4
12 | id: release
13 | with:
14 | release-type: node
15 |
16 | # publish npm:
17 | - uses: actions/checkout@v4
18 | # a publication only occurs when a new release is created:
19 | if: ${{ steps.release.outputs.release_created }}
20 | - uses: actions/setup-node@v4
21 | with:
22 | node-version: 20
23 | registry-url: 'https://registry.npmjs.org'
24 | if: ${{ steps.release.outputs.release_created }}
25 | - run: npm ci
26 | if: ${{ steps.release.outputs.release_created }}
27 | - run: npm publish
28 | env:
29 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
30 | if: ${{ steps.release.outputs.release_created }}
31 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: 'tests'
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | branches:
7 | - main
8 | paths-ignore:
9 | - '**/*.md'
10 |
11 | schedule:
12 | - cron: '0 9 * * *'
13 |
14 | jobs:
15 | unit-tests:
16 | runs-on: ubuntu-latest
17 | strategy:
18 | fail-fast: true
19 | matrix:
20 | node: [18, 20, 22]
21 | steps:
22 | - uses: actions/checkout@main
23 |
24 | - name: Setup NodeJS ${{ matrix.node }}
25 | uses: actions/setup-node@v4
26 | with:
27 | node-version: ${{ matrix.node }}
28 |
29 | - name: Install dependencies
30 | run: |
31 | npm ci
32 |
33 | - name: Linting
34 | run: |
35 | npm run lint
36 |
37 | - name: Unit testing
38 | run: |
39 | npm run test
40 |
41 | node-integration-tests-local-single-node:
42 | needs: unit-tests
43 | runs-on: ubuntu-latest
44 | strategy:
45 | fail-fast: true
46 | matrix:
47 | node: [18, 20, 22]
48 | clickhouse: [head, latest]
49 |
50 | steps:
51 | - uses: actions/checkout@main
52 |
53 | - name: Start ClickHouse (version - ${{ matrix.clickhouse }}) using docker-compose
54 | uses: isbang/compose-action@v1.5.1
55 | env:
56 | CLICKHOUSE_VERSION: ${{ matrix.clickhouse }}
57 | with:
58 | compose-file: 'docker-compose.yml'
59 | down-flags: '--volumes'
60 |
61 | - name: Setup NodeJS ${{ matrix.node }}
62 | uses: actions/setup-node@v4
63 | with:
64 | node-version: ${{ matrix.node }}
65 |
66 | - name: Install dependencies
67 | run: |
68 | npm ci
69 |
70 | - name: Build
71 | run: |
72 | npm run build
73 |
74 | - name: Test - apply migrations ./tests/migrations/few
75 | run: |
76 | node ./lib/cli.js migrate --host=http://localhost:8123 --user=default --password='' --db=analytics --migrations-home=./tests/migrations/few
77 |
78 | - name: Verify Migration
79 | run: |
80 | echo "Checking if tables exist..."
81 | response=$(curl -s "http://localhost:8123/?query=SELECT%20groupArray(name)%20FROM%20system.tables%20WHERE%20name%20IN%20('_migrations','events','sessions')%20AND%20database%20=%20'analytics'")
82 | echo "Response: $response"
83 |
84 | expected_tables="['_migrations','events','sessions']"
85 |
86 | if [ "$response" = "$expected_tables" ]; then
87 | echo "The tables were created successfully."
88 | else
89 | echo "Tables were not found!"
90 | echo "Expected: $expected_tables"
91 | echo "Got: $response"
92 | exit 1
93 | fi
94 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | lib
3 | .DS_Store
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "trailingComma": "all",
4 | "singleQuote": true
5 | }
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## [1.0.4](https://github.com/VVVi/clickhouse-migrations/compare/v1.0.3...v1.0.4) (2025-01-21)
4 |
5 |
6 | ### Features
7 |
8 | * update to eslint 9, update dependencies ([f91cf7d](https://github.com/VVVi/clickhouse-migrations/commit/f91cf7d83bbccde937de00206092e0474a885fac))
9 |
10 |
11 | ### Miscellaneous Chores
12 |
13 | * release 1.0.4 ([2b653e3](https://github.com/VVVi/clickhouse-migrations/commit/2b653e3cdcb0b5d7db0bcb47ab99dd6ae4dd389b))
14 |
15 | ## [1.0.3](https://github.com/VVVi/clickhouse-migrations/compare/v1.0.2...v1.0.3) (2025-01-20)
16 |
17 |
18 | ### Features
19 |
20 | * update packages to latest version ([4125b31](https://github.com/VVVi/clickhouse-migrations/commit/4125b3128d4fbd4ffe31c0170801b24ec2e0bc42))
21 |
22 |
23 | ### Miscellaneous Chores
24 |
25 | * release 1.0.3 ([3f50c00](https://github.com/VVVi/clickhouse-migrations/commit/3f50c00910d6c76fe2db1e8f3d963ee3451f5864))
26 |
27 | ## [1.0.2](https://github.com/VVVi/clickhouse-migrations/compare/v1.0.1...v1.0.2) (2024-09-14)
28 |
29 |
30 | ### Miscellaneous Chores
31 |
32 | * release 1.0.2 ([f761a87](https://github.com/VVVi/clickhouse-migrations/commit/f761a875da5a6a8038467a4322ba5d0f3df87e9d))
33 | * release 1.0.2 ([a194e0c](https://github.com/VVVi/clickhouse-migrations/commit/a194e0ccd316a0f4370995f9da19de02798c5777))
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 VVVi
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | # clickhouse-migrations
3 |
4 | > ClickHouse Migrations CLI
5 |
6 | ## Install
7 |
8 | ```sh
9 | npm install clickhouse-migrations
10 | ```
11 |
12 | ## Usage
13 |
14 | Create a directory, where migrations will be stored. It will be used as the value for the `--migrations-home` option (or for environment variable `CH_MIGRATIONS_HOME`).
15 |
16 | In the directory, create migration files, which should be named like this: `1_some_text.sql`, `2_other_text.sql`, `10_more_test.sql`. What's important here is that the migration version number should come first, followed by an underscore (`_`), and then any text can follow. The version number should increase for every next migration. Please note that once a migration file has been applied to the database, it cannot be modified or removed.
17 |
18 | For migrations' content should be used correct SQL ClickHouse queries. Multiple queries can be used in a single migration file, and each query should be terminated with a semicolon (;). The queries could be idempotent - for example: `CREATE TABLE IF NOT EXISTS table ...;` Clickhouse settings, that can be included at the query level, can be added like `SET allow_experimental_object_type = 1;`. For adding comments should be used `--`, `# `, `#!`.
19 |
20 | If the database provided in the `--db` option (or in `CH_MIGRATIONS_DB`) doesn't exist, it will be created automatically.
21 |
22 | ```
23 | Usage
24 | $ clickhouse-migrations migrate
25 |
26 | Required options
27 | --host= Clickhouse hostname
28 | (ex. https://clickhouse:8123)
29 | --user= Username
30 | --password= Password
31 | --db= Database name
32 | --migrations-home= Migrations' directory
33 |
34 | Optional options
35 | --db-engine= ON CLUSTER and/or ENGINE for DB
36 | (default: 'ENGINE=Atomic')
37 | --timeout= Client request timeout
38 | (milliseconds, default: 30000)
39 |
40 | Environment variables
41 | Instead of options can be used environment variables.
42 | CH_MIGRATIONS_HOST Clickhouse hostname (--host)
43 | CH_MIGRATIONS_USER Username (--user)
44 | CH_MIGRATIONS_PASSWORD Password (--password)
45 | CH_MIGRATIONS_DB Database name (--db)
46 | CH_MIGRATIONS_HOME Migrations' directory (--migrations-home)
47 |
48 | CH_MIGRATIONS_DB_ENGINE (optional) DB engine (--db-engine)
49 | CH_MIGRATIONS_TIMEOUT (optional) Client request timeout
50 | (--timeout)
51 |
52 | CLI executions examples
53 | settings are passed as command-line options
54 | clickhouse-migrations migrate --host=http://localhost:8123
55 | --user=default --password='' --db=analytics
56 | --migrations-home=/app/clickhouse/migrations
57 |
58 | settings provided as options, including timeout and db-engine
59 | clickhouse-migrations migrate --host=http://localhost:8123
60 | --user=default --password='' --db=analytics
61 | --migrations-home=/app/clickhouse/migrations --timeout=60000
62 | --db-engine="ON CLUSTER default ENGINE=Replicated('{replica}')"
63 |
64 | settings provided as environment variables
65 | clickhouse-migrations migrate
66 |
67 | settings provided partially through options and environment variables
68 | clickhouse-migrations migrate --timeout=60000
69 | ```
70 |
71 | Migration file example:
72 | (e.g., located at /app/clickhouse/migrations/1_init.sql)
73 | ```
74 | -- an example of migration file 1_init.sql
75 |
76 | SET allow_experimental_json_type = 1;
77 |
78 | CREATE TABLE IF NOT EXISTS events (
79 | timestamp DateTime('UTC'),
80 | session_id UInt64,
81 | event JSON
82 | )
83 | ENGINE=AggregatingMergeTree
84 | PARTITION BY toYYYYMM(timestamp)
85 | SAMPLE BY session_id
86 | ORDER BY (session_id)
87 | SETTINGS index_granularity = 8192;
88 | ```
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | #version: '3.8'
2 |
3 | services:
4 | clickhouse:
5 | container_name: 'clickhouse-server'
6 | image: 'clickhouse/clickhouse-server:${CLICKHOUSE_VERSION-24.8-alpine}'
7 | ports:
8 | - '8123:8123'
9 | - '9000:9000'
10 | ulimits:
11 | nofile:
12 | soft: 262144
13 | hard: 262144
14 | volumes:
15 | - './.docker/clickhouse/config.xml:/etc/clickhouse-server/config.xml'
16 | - './.docker/clickhouse/users.xml:/etc/clickhouse-server/users.xml'
17 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import eslint from '@eslint/js';
2 | import tseslint from 'typescript-eslint';
3 | import eslintConfigPrettier from 'eslint-config-prettier';
4 |
5 | const eslintConfig = [
6 | eslint.configs.recommended,
7 | ...tseslint.configs.recommended,
8 |
9 | {
10 | rules: {
11 | '@typescript-eslint/no-unused-vars': 'off',
12 | },
13 | },
14 | {
15 | ignores: ['node_modules', 'lib'],
16 | },
17 |
18 | eslintConfigPrettier,
19 | ];
20 |
21 | export default eslintConfig;
22 |
--------------------------------------------------------------------------------
/jestconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "transform": {
3 | "^.+\\.(t|j)sx?$": "ts-jest"
4 | },
5 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
6 | "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"]
7 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "clickhouse-migrations",
3 | "version": "1.0.4",
4 | "description": "ClickHouse Migrations",
5 | "bin": {
6 | "clickhouse-migrations": "lib/cli.js"
7 | },
8 | "main": "lib/migrate.js",
9 | "types": "lib/migrate.d.ts",
10 | "engines": {
11 | "node": ">=20"
12 | },
13 | "private": false,
14 | "license": "MIT",
15 | "author": "VVVi",
16 | "repository": "VVVi/clickhouse-migrations",
17 | "scripts": {
18 | "build": "rm lib/*; tsc",
19 | "test": "jest --config jestconfig.json",
20 | "format": "prettier --write \"src/**/*.ts\" \"src/**/*.js\"",
21 | "lint": "eslint ./src",
22 | "prepare": "npm run build",
23 | "prepublishOnly": "npm test && npm run lint",
24 | "preversion": "npm run lint",
25 | "version": "npm run format && git add -A src",
26 | "postversion": "git push && git push --tags"
27 | },
28 | "dependencies": {
29 | "@clickhouse/client": "^1.10.1",
30 | "commander": "^13.1.0"
31 | },
32 | "devDependencies": {
33 | "@eslint/js": "^9.18.0",
34 | "@types/jest": "^29.5.14",
35 | "@types/node": "^22.10.7",
36 | "@typescript-eslint/eslint-plugin": "^8.21.0",
37 | "@typescript-eslint/parser": "^8.21.0",
38 | "eslint": "^9.18.0",
39 | "eslint-config-prettier": "^10.0.1",
40 | "eslint-plugin-prettier": "^5.2.3",
41 | "jest": "^29.7.0",
42 | "prettier": "^3.4.2",
43 | "ts-jest": "^29.2.5",
44 | "typescript": "^5.7.3",
45 | "typescript-eslint": "^8.21.0"
46 | },
47 | "files": [
48 | "lib/**/*"
49 | ],
50 | "keywords": [
51 | "clickhouse-migrations",
52 | "clickhouse-migration",
53 | "clickhouse-migrate",
54 | "clickhouse",
55 | "migrations",
56 | "migration",
57 | "migrate",
58 | "cli"
59 | ]
60 | }
61 |
--------------------------------------------------------------------------------
/src/cli.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import { migrate } from './migrate';
4 |
5 | migrate();
6 |
7 | export {};
8 |
--------------------------------------------------------------------------------
/src/migrate.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from '@clickhouse/client';
2 | import type { ClickHouseClient, ClickHouseClientConfigOptions } from '@clickhouse/client';
3 | import { Command } from 'commander';
4 | import fs from 'fs';
5 | import crypto from 'crypto';
6 |
7 | import { sql_queries, sql_sets } from './sql-parse';
8 |
9 | const log = (type: 'info' | 'error' = 'info', message: string, error?: string) => {
10 | if (type === 'info') {
11 | console.log('\x1b[36m', `clickhouse-migrations :`, '\x1b[0m', message);
12 | } else {
13 | console.error('\x1b[36m', `clickhouse-migrations :`, '\x1b[31m', `Error: ${message}`, error ? `\n\n ${error}` : '');
14 | }
15 | };
16 |
17 | const connect = (
18 | url: string,
19 | username: string,
20 | password: string,
21 | db_name?: string,
22 | timeout?: string,
23 | ): ClickHouseClient => {
24 | const db_params: ClickHouseClientConfigOptions = {
25 | url,
26 | username,
27 | password,
28 | application: 'clickhouse-migrations',
29 | };
30 |
31 | if (db_name) {
32 | db_params.database = db_name;
33 | }
34 |
35 | if (timeout) {
36 | db_params.request_timeout = Number(timeout);
37 | }
38 |
39 | return createClient(db_params);
40 | };
41 |
42 | const create_db = async (
43 | host: string,
44 | username: string,
45 | password: string,
46 | db_name: string,
47 | db_engine: string = 'ENGINE=Atomic',
48 | ): Promise => {
49 | const client = connect(host, username, password);
50 |
51 | const q = `CREATE DATABASE IF NOT EXISTS "${db_name}" ${db_engine}`;
52 |
53 | try {
54 | await client.exec({
55 | query: q,
56 | clickhouse_settings: {
57 | wait_end_of_query: 1,
58 | },
59 | });
60 | } catch (e: unknown) {
61 | log('error', `can't create the database ${db_name}.`, (e as QueryError).message);
62 | process.exit(1);
63 | }
64 |
65 | await client.close();
66 | };
67 |
68 | const init_migration_table = async (client: ClickHouseClient): Promise => {
69 | const q = `CREATE TABLE IF NOT EXISTS _migrations (
70 | uid UUID DEFAULT generateUUIDv4(),
71 | version UInt32,
72 | checksum String,
73 | migration_name String,
74 | applied_at DateTime DEFAULT now()
75 | )
76 | ENGINE = MergeTree
77 | ORDER BY tuple(applied_at)`;
78 |
79 | try {
80 | await client.exec({
81 | query: q,
82 | clickhouse_settings: {
83 | wait_end_of_query: 1,
84 | },
85 | });
86 | } catch (e: unknown) {
87 | log('error', `can't create the _migrations table.`, (e as QueryError).message);
88 | process.exit(1);
89 | }
90 | };
91 |
92 | const get_migrations = (migrations_home: string): { version: number; file: string }[] => {
93 | let files;
94 | try {
95 | files = fs.readdirSync(migrations_home);
96 | } catch (e: unknown) {
97 | log('error', `no migration directory ${migrations_home}. Please create it.`);
98 | process.exit(1);
99 | }
100 |
101 | const migrations: MigrationBase[] = [];
102 | files.forEach((file: string) => {
103 | const version = Number(file.split('_')[0]);
104 |
105 | if (!version) {
106 | log(
107 | 'error',
108 | `a migration name should start from number, example: 1_init.sql. Please check, if the migration ${file} is named correctly`,
109 | );
110 | process.exit(1);
111 | }
112 |
113 | // Manage only .sql files.
114 | if (!file.endsWith('.sql')) return;
115 |
116 | migrations.push({
117 | version,
118 | file,
119 | });
120 | });
121 |
122 | if (!migrations) {
123 | log('error', `no migrations in the ${migrations_home} migrations directory`);
124 | }
125 |
126 | // Order by version.
127 | migrations.sort((m1, m2) => m1.version - m2.version);
128 |
129 | return migrations;
130 | };
131 |
132 | const apply_migrations = async (
133 | client: ClickHouseClient,
134 | migrations: MigrationBase[],
135 | migrations_home: string,
136 | ): Promise => {
137 | let migration_query_result: MigrationsRowData[] = [];
138 | try {
139 | const resultSet = await client.query({
140 | query: `SELECT version, checksum, migration_name FROM _migrations ORDER BY version`,
141 | format: 'JSONEachRow',
142 | });
143 | migration_query_result = await resultSet.json();
144 | } catch (e: unknown) {
145 | log('error', `can't select data from the _migrations table.`, (e as QueryError).message);
146 | process.exit(1);
147 | }
148 |
149 | const migrations_applied: MigrationsRowData[] = [];
150 | migration_query_result.forEach((row: MigrationsRowData) => {
151 | migrations_applied[row.version] = {
152 | version: row.version,
153 | checksum: row.checksum,
154 | migration_name: row.migration_name,
155 | };
156 |
157 | // Check if migration file was not removed after apply.
158 | const migration_exist = migrations.find(({ version }) => version === row.version);
159 | if (!migration_exist) {
160 | log(
161 | 'error',
162 | `a migration file shouldn't be removed after apply. Please, restore the migration ${row.migration_name}.`,
163 | );
164 | process.exit(1);
165 | }
166 | });
167 |
168 | let applied_migrations = '';
169 |
170 | for (const migration of migrations) {
171 | const content = fs.readFileSync(migrations_home + '/' + migration.file).toString();
172 | const checksum = crypto.createHash('md5').update(content).digest('hex');
173 |
174 | if (migrations_applied[migration.version]) {
175 | // Check if migration file was not changed after apply.
176 | if (migrations_applied[migration.version].checksum !== checksum) {
177 | log(
178 | 'error',
179 | `a migration file should't be changed after apply. Please, restore content of the ${
180 | migrations_applied[migration.version].migration_name
181 | } migrations.`,
182 | );
183 | process.exit(1);
184 | }
185 |
186 | // Skip if a migration is already applied.
187 | continue;
188 | }
189 |
190 | // Extract sql from the migration.
191 | const queries = sql_queries(content);
192 | const sets = sql_sets(content);
193 |
194 | for (const query of queries) {
195 | try {
196 | await client.exec({
197 | query: query,
198 | clickhouse_settings: sets,
199 | });
200 | } catch (e: unknown) {
201 | if (applied_migrations) {
202 | log('info', `The migration(s) ${applied_migrations} was successfully applied!`);
203 | }
204 |
205 | log(
206 | 'error',
207 | `the migrations ${migration.file} has an error. Please, fix it (be sure that already executed parts of the migration would not be run second time) and re-run migration script.`,
208 | (e as QueryError).message,
209 | );
210 | process.exit(1);
211 | }
212 | }
213 |
214 | try {
215 | await client.insert({
216 | table: '_migrations',
217 | values: [{ version: migration.version, checksum: checksum, migration_name: migration.file }],
218 | format: 'JSONEachRow',
219 | });
220 | } catch (e: unknown) {
221 | log('error', `can't insert a data into the table _migrations.`, (e as QueryError).message);
222 | process.exit(1);
223 | }
224 |
225 | applied_migrations = applied_migrations ? applied_migrations + ', ' + migration.file : migration.file;
226 | }
227 |
228 | if (applied_migrations) {
229 | log('info', `The migration(s) ${applied_migrations} was successfully applied!`);
230 | } else {
231 | log('info', `No migrations to apply.`);
232 | }
233 | };
234 |
235 | const migration = async (
236 | migrations_home: string,
237 | host: string,
238 | username: string,
239 | password: string,
240 | db_name: string,
241 | db_engine?: string,
242 | timeout?: string,
243 | ): Promise => {
244 | const migrations = get_migrations(migrations_home);
245 |
246 | await create_db(host, username, password, db_name, db_engine);
247 |
248 | const client = connect(host, username, password, db_name, timeout);
249 |
250 | await init_migration_table(client);
251 |
252 | await apply_migrations(client, migrations, migrations_home);
253 |
254 | await client.close();
255 | };
256 |
257 | const migrate = () => {
258 | const program = new Command();
259 |
260 | program.name('clickhouse-migrations').description('ClickHouse migrations.').version('1.0.4');
261 |
262 | program
263 | .command('migrate')
264 | .description('Apply migrations.')
265 | .requiredOption('--host ', 'Clickhouse hostname (ex: http://clickhouse:8123)', process.env.CH_MIGRATIONS_HOST)
266 | .requiredOption('--user ', 'Username', process.env.CH_MIGRATIONS_USER)
267 | .requiredOption('--password ', 'Password', process.env.CH_MIGRATIONS_PASSWORD)
268 | .requiredOption('--db ', 'Database name', process.env.CH_MIGRATIONS_DB)
269 | .requiredOption('--migrations-home ', "Migrations' directory", process.env.CH_MIGRATIONS_HOME)
270 | .option(
271 | '--db-engine ',
272 | 'ON CLUSTER and/or ENGINE clauses for database (default: "ENGINE=Atomic")',
273 | process.env.CH_MIGRATIONS_DB_ENGINE,
274 | )
275 | .option(
276 | '--timeout ',
277 | 'Client request timeout (milliseconds, default value 30000)',
278 | process.env.CH_MIGRATIONS_TIMEOUT,
279 | )
280 | .action(async (options: CliParameters) => {
281 | await migration(
282 | options.migrationsHome,
283 | options.host,
284 | options.user,
285 | options.password,
286 | options.db,
287 | options.dbEngine,
288 | options.timeout,
289 | );
290 | });
291 |
292 | program.parse();
293 | };
294 |
295 | export { migrate, migration };
296 |
--------------------------------------------------------------------------------
/src/sql-parse.ts:
--------------------------------------------------------------------------------
1 | // Extract sql queries from migrations.
2 | const sql_queries = (content: string): string[] => {
3 | const queries = content
4 | .replace(/(--|#!|#\s).*(\n|\r\n|\r|$)/gm, '\n')
5 | .replace(/^\s*(SET\s).*(\n|\r\n|\r|$)/gm, '')
6 | .replace(/(\n|\r\n|\r)/gm, ' ')
7 | .replace(/\s+/g, ' ')
8 | .split(';')
9 | .map((el: string) => el.trim())
10 | .filter((el: string) => el.length != 0);
11 |
12 | return queries;
13 | };
14 |
15 | // Extract query settings from migrations.
16 | const sql_sets = (content: string) => {
17 | const sets: { [key: string]: string } = {};
18 |
19 | const sets_arr = content
20 | .replace(/(--|#!|#\s).*(\n|\r\n|\r|$)/gm, '\n')
21 | .replace(/^\s*(?!SET\s).*(\n|\r\n|\r|$)/gm, '')
22 | .replace(/^\s*(SET\s)/gm, '')
23 | .replace(/(\n|\r\n|\r)/gm, ' ')
24 | .replace(/\s+/g, '')
25 | .split(';');
26 |
27 | sets_arr.forEach((set_full) => {
28 | const set = set_full.split('=');
29 | if (set[0]) {
30 | sets[set[0]] = set[1];
31 | }
32 | });
33 |
34 | return sets;
35 | };
36 |
37 | export { sql_queries, sql_sets };
38 |
--------------------------------------------------------------------------------
/src/types/cli.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | type MigrationBase = {
4 | version: number;
5 | file: string;
6 | };
7 |
8 | type MigrationsRowData = {
9 | version: number;
10 | checksum: string;
11 | migration_name: string;
12 | };
13 |
14 | type CliParameters = {
15 | migrationsHome: string;
16 | host: string;
17 | user: string;
18 | password: string;
19 | db: string;
20 | dbEngine?: string;
21 | timeout?: string;
22 | };
23 |
24 | type QueryError = {
25 | message: string;
26 | };
27 |
--------------------------------------------------------------------------------
/tests/cli.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from '@jest/globals';
2 | import exec from 'child_process';
3 |
4 | const execute = async (script: string, execOptions: any) => {
5 | const result: { error: exec.ExecException | null; stdout: string; stderr: string } = await new Promise((resolve) => {
6 | exec.exec(script, execOptions, (error: exec.ExecException | null, stdout: string, stderr: string) => {
7 | resolve({
8 | error,
9 | stdout,
10 | stderr,
11 | });
12 | });
13 | });
14 |
15 | return result;
16 | };
17 |
18 | const envVars = {
19 | CH_MIGRATIONS_HOST: 'http://sometesthost:8123',
20 | CH_MIGRATIONS_USER: 'default',
21 | CH_MIGRATIONS_PASSWORD: '',
22 | CH_MIGRATIONS_DB: 'analytics',
23 | CH_MIGRATIONS_HOME: '/app/clickhouse/migrations',
24 | };
25 |
26 | describe('Execution tests', () => {
27 | beforeEach(() => {
28 | jest.resetModules();
29 | });
30 |
31 | it('No parameters provided', async () => {
32 | const result = await execute('node lib/cli.js migrate', '.');
33 |
34 | expect(result.stderr).toBe("error: required option '--host ' not specified\n");
35 | });
36 |
37 | it('No migration directory', async () => {
38 | const command =
39 | "node ./lib/cli.js migrate --host=http://sometesthost:8123 --user=default --password='' --db=analytics --migrations-home=/app/clickhouse/migrations";
40 |
41 | const result = await execute(command, '.');
42 |
43 | expect(result.stderr).toBe(
44 | '\x1B[36m clickhouse-migrations : \x1B[31m Error: no migration directory /app/clickhouse/migrations. Please create it. \n',
45 | );
46 | });
47 |
48 | it('Environment variables are provided, but no migration directory', async () => {
49 | const result = await execute('node lib/cli.js migrate', { env: envVars });
50 |
51 | expect(result.stderr).toBe(
52 | '\x1B[36m clickhouse-migrations : \x1B[31m Error: no migration directory /app/clickhouse/migrations. Please create it. \n',
53 | );
54 | });
55 |
56 | it('Incorrectly named migration', async () => {
57 | const command =
58 | "node ./lib/cli.js migrate --host=http://sometesthost:8123 --user=default --password='' --db=analytics --migrations-home=tests/migrations/bad";
59 |
60 | const result = await execute(command, '.');
61 |
62 | expect(result.stderr).toBe(
63 | '\x1B[36m clickhouse-migrations : \x1B[31m Error: a migration name should start from number, example: 1_init.sql. Please check, if the migration bad_1.sql is named correctly \n',
64 | );
65 | });
66 | });
67 |
--------------------------------------------------------------------------------
/tests/db.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, jest } from '@jest/globals';
2 |
3 | import { migration } from '../src/migrate';
4 |
5 | jest.mock('@clickhouse/client', () => ({ createClient: () => createClient1 }));
6 |
7 | const createClient1 = {
8 | query: jest.fn().mockImplementationOnce(() => Promise.resolve({ json: () => [] })), // return 0 rows
9 | exec: jest.fn().mockImplementationOnce(() => Promise.resolve({})),
10 | insert: jest.fn(),
11 | close: jest.fn(),
12 | };
13 |
14 | describe('Migration tests', () => {
15 | // beforeEach(() => {
16 | // jest.clearAllMocks();
17 | // jest.resetAllMocks();
18 | // jest.resetModules();
19 | // });
20 |
21 | // todo: remove only
22 | it.only('First migration', async () => {
23 | const querySpy = jest.spyOn(createClient1, 'query');
24 | const execSpy = jest.spyOn(createClient1, 'exec');
25 | const insertSpy = jest.spyOn(createClient1, 'insert');
26 |
27 | await migration('tests/migrations/one', 'http://sometesthost:8123', 'default', '', 'analytics');
28 |
29 | expect(execSpy).toHaveBeenCalledTimes(3);
30 | expect(querySpy).toHaveBeenCalledTimes(1);
31 | expect(insertSpy).toHaveBeenCalledTimes(1);
32 |
33 | expect(execSpy).toHaveBeenNthCalledWith(1, {
34 | query: 'CREATE DATABASE IF NOT EXISTS "analytics" ENGINE=Atomic',
35 | clickhouse_settings: {
36 | wait_end_of_query: 1,
37 | },
38 | });
39 | expect(execSpy).toHaveBeenNthCalledWith(2, {
40 | query: `CREATE TABLE IF NOT EXISTS _migrations (
41 | uid UUID DEFAULT generateUUIDv4(),
42 | version UInt32,
43 | checksum String,
44 | migration_name String,
45 | applied_at DateTime DEFAULT now()
46 | )
47 | ENGINE = MergeTree
48 | ORDER BY tuple(applied_at)`,
49 | clickhouse_settings: {
50 | wait_end_of_query: 1,
51 | },
52 | });
53 | expect(execSpy).toHaveBeenNthCalledWith(3, {
54 | clickhouse_settings: { allow_experimental_json_type: '1' },
55 | query:
56 | 'CREATE TABLE IF NOT EXISTS `events` ( `event_id` UInt64, `event_data` JSON ) ENGINE=MergeTree() ORDER BY (`event_id`) SETTINGS index_granularity = 8192',
57 | });
58 |
59 | expect(querySpy).toHaveBeenNthCalledWith(1, {
60 | format: 'JSONEachRow',
61 | query: 'SELECT version, checksum, migration_name FROM _migrations ORDER BY version',
62 | });
63 |
64 | expect(insertSpy).toHaveBeenNthCalledWith(1, {
65 | format: 'JSONEachRow',
66 | table: '_migrations',
67 | values: [{ checksum: '2f66edf1a8c3fa2e29835ad9ac8140a7', migration_name: '1_init.sql', version: 1 }],
68 | });
69 | });
70 | });
71 |
--------------------------------------------------------------------------------
/tests/migrations/bad/bad_1.sql:
--------------------------------------------------------------------------------
1 | -- bad query without ; and bad migration file name
2 |
3 | SET allow_experimental_json_type = 1;
4 |
5 | CREATE TABLE IF NOT EXISTS `events` (
6 | `event_id` UInt64,
7 | `event_data` JSON
8 | )
9 | ENGINE=MergeTree()
10 | ORDER BY (`event_id`)
11 |
--------------------------------------------------------------------------------
/tests/migrations/few/1_init.sql:
--------------------------------------------------------------------------------
1 | -- create table events
2 |
3 | SET allow_experimental_json_type = 1;
4 |
5 | CREATE TABLE IF NOT EXISTS `events` (
6 | `event_id` UInt64,
7 | `event_data` JSON
8 | )
9 | ENGINE=MergeTree()
10 | ORDER BY (`event_id`);
11 |
--------------------------------------------------------------------------------
/tests/migrations/few/2_more.sql:
--------------------------------------------------------------------------------
1 | -- create table sessions
2 |
3 | CREATE TABLE IF NOT EXISTS `sessions` (
4 | `session_id` UInt64
5 | )
6 | ENGINE=MergeTree()
7 | ORDER BY (`session_id`);
8 |
--------------------------------------------------------------------------------
/tests/migrations/one/1_init.sql:
--------------------------------------------------------------------------------
1 | -- create table events
2 |
3 | SET allow_experimental_json_type = 1;
4 |
5 | CREATE TABLE IF NOT EXISTS `events` (
6 | `event_id` UInt64,
7 | `event_data` JSON
8 | )
9 | ENGINE=MergeTree()
10 | ORDER BY (`event_id`)
11 | SETTINGS index_granularity = 8192;
12 |
--------------------------------------------------------------------------------
/tests/unit.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from '@jest/globals';
2 |
3 | import { sql_queries, sql_sets } from '../src/sql-parse';
4 |
5 | describe('Sql query parse', () => {
6 | beforeEach(() => {
7 | jest.resetModules();
8 | });
9 |
10 | it('1 query test', async () => {
11 | const input = '-- any\n\n# other comment\n\n#! also comment\n SELECT * \nFROM events;\n';
12 |
13 | const output = ['SELECT * FROM events'];
14 |
15 | expect(sql_queries(input)).toEqual(output);
16 | });
17 | });
18 |
19 | describe('Sql settings parse', () => {
20 | beforeEach(() => {
21 | jest.resetModules();
22 | });
23 |
24 | it('one set and comments with no end of lines', async () => {
25 | const input = '-- any\nSET allow_experimental_json_type = 1;\n\n --set option\nSELECT * FROM events';
26 |
27 | const output = { allow_experimental_json_type: '1' };
28 |
29 | expect(sql_sets(input)).toEqual(output);
30 | });
31 |
32 | it('two sets and comments', async () => {
33 | const input =
34 | '-- any\nSET allow_experimental_json_type = 1; --set option\nSET allow_experimental_object_new = 1;\nSELECT * \n --comment\n FROM events\n';
35 |
36 | const output = { allow_experimental_json_type: '1', allow_experimental_object_new: '1' };
37 |
38 | expect(sql_sets(input)).toEqual(output);
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "module": "commonjs",
5 |
6 | "strict": true,
7 | "esModuleInterop": true,
8 | "skipLibCheck": true,
9 | "forceConsistentCasingInFileNames": true,
10 |
11 | "declaration": true,
12 | "outDir": "./lib"
13 | },
14 | "include": ["src", "types"],
15 | "exclude": ["node_modules", "tests"]
16 | }
--------------------------------------------------------------------------------