clickhouse-migrations 1.4.1 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,9 @@ Create a directory, where migrations will be stored. It will be used as the valu
14
14
 
15
15
  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.
16
16
 
17
- 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 `--`, `# `, `#!`.
17
+ Migration files contain ClickHouse SQL statements separated by semicolons (`;`). Semicolons and comment markers inside single-quoted strings, quoted identifiers (`"..."`, `` `...` ``), and dollar-quoted strings (`$tag$...$tag$` or `$$...$$`) are preserved. Whitespace inside quoted text is also preserved. In SQL, comments (`--`, `//`, `# `, `#!`, and nested `/* ... */`) are removed, and whitespace outside quoted text is collapsed. Inline formatted data is preserved separately as described below.
18
+
19
+ See [SQL execution and settings](#sql-execution-and-settings) below for details on settings, supported migration content, and error handling.
18
20
 
19
21
  If the database provided in the `--db` option (or in `CH_MIGRATIONS_DB`) doesn't exist, it will be created automatically. To disable this behavior (for example, when the user has no privileges to create databases), use the `--skip-db-creation` option (or set `CH_MIGRATIONS_SKIP_DB_CREATION` to `'true'`); in that case the database must already exist.
20
22
 
@@ -150,3 +152,28 @@ LAYOUT(COMPLEX_KEY_HASHED());
150
152
  ```
151
153
 
152
154
  _Please note:_ When substitution is enabled, the checksum is taken from the **raw file**, before substitution. This keeps a migration stable across environments and keeps secrets out of `_migrations`. Two consequences follow: an already-applied migration is not re-run when a variable changes (ship a new migration instead), and the substituted SQL still reaches ClickHouse, so it may appear in `system.query_log` or server logs.
155
+
156
+ ## SQL execution and settings
157
+
158
+ Queries execute in file order. Assignment-style `SET name = value` statements are collected before executing the file: the last assignment to each setting applies to **every query in that file**, including queries written before the `SET`. This preserves the package's existing behavior. Settings do not carry over to the next migration file.
159
+
160
+ Settings are sent with each HTTP request, overriding defaults supplied in the host URL. This works without shared session state when a load balancer routes requests to different servers, and also supports HTTP parameters such as `wait_end_of_query`.
161
+
162
+ Use assignment-style `SET` commands for settings and query parameters. Quoted string values, multiline assignments and compound parameter values are supported; the available setting names and values depend on your ClickHouse version:
163
+
164
+ ```sql
165
+ SET max_threads = 2,
166
+ log_comment = 'migration; keep this text';
167
+ SET wait_end_of_query = 1;
168
+ SET session_timezone = 'UTC';
169
+ SET param_d = {'10': [11, 12], '13': [14, 15]};
170
+ SELECT {d:Map(String, Array(UInt8))};
171
+ ```
172
+
173
+ For a setting specific to one query, use that query's `SETTINGS` clause where ClickHouse supports it. Commands requiring shared session state, such as `SET ROLE` followed by another query, are not supported. Use `SET session_timezone = 'UTC'` to configure the timezone for the file.
174
+
175
+ Inline `INSERT ... FORMAT` data is accepted, including `INSERT ... SELECT ... FROM input(...) FORMAT ...`. `FORMAT Values` uses the same quote handling as `INSERT ... VALUES`. For other formats, everything after the format name is preserved verbatim up to the next semicolon or the end of the file: tabs, newlines, quotes and comment markers remain data.
176
+
177
+ Raw formats retain the legacy semicolon delimiter, even inside quoted data. Payloads containing semicolons should use `INSERT ... VALUES` or be loaded separately. ClickHouse validates the data format; this package does not parse CSV, JSON or other raw formats.
178
+
179
+ Unterminated SQL quotes/comments and malformed setting assignments cause an error identifying the migration file before any query in that file executes. ClickHouse validates SQL syntax, setting names, setting values and inline data when each query runs. Database setup and earlier files may already have run, and server-side errors can still leave a file partially applied. Where appropriate, use idempotent statements such as `CREATE TABLE IF NOT EXISTS ...`.
package/lib/migrate.js CHANGED
@@ -201,24 +201,27 @@ const apply_migrations = async (client, migrations, migrations_home) => {
201
201
  // Skip if a migration is already applied.
202
202
  continue;
203
203
  }
204
- // Substitute ${VAR} from the environment (opt-in) at apply time only. The
205
- // checksum above is over the raw file, so it stays stable across environments.
206
- let sql;
204
+ // Parse the entire file before executing its statements. Substitution is
205
+ // opt-in; the checksum above always uses the original file content.
206
+ let queries;
207
+ let settings;
207
208
  try {
208
- sql = (0, sql_parse_1.substitute_env)(content);
209
+ ({ queries, settings } = (0, sql_parse_1.parse_migration_sql)((0, sql_parse_1.substitute_env)(content)));
209
210
  }
210
211
  catch (e) {
211
212
  log('error', `the migration ${migration.file} has an error.`, e instanceof Error ? e.message : String(e));
212
213
  process.exit(1);
213
214
  }
214
- // Extract sql from the migration.
215
- const queries = (0, sql_parse_1.sql_queries)(sql);
216
- const sets = (0, sql_parse_1.sql_sets)(sql);
215
+ // Keep the established file-wide SET behavior: the last assignment wins,
216
+ // including for queries earlier in the file. Send settings on every request
217
+ // instead of using sessions, whose state is lost if a load balancer routes
218
+ // the next query to another server. Per-query settings also override host
219
+ // URL defaults and support HTTP parameters such as wait_end_of_query.
217
220
  for (const query of queries) {
218
221
  try {
219
222
  await client.command({
220
223
  query: query,
221
- clickhouse_settings: sets,
224
+ clickhouse_settings: settings,
222
225
  });
223
226
  }
224
227
  catch (e) {
@@ -1,6 +1,8 @@
1
1
  declare const substitute_env: (content: string) => string;
2
- declare const sql_queries: (content: string) => string[];
3
- declare const sql_sets: (content: string) => {
4
- [key: string]: string;
2
+ declare const parse_migration_sql: (content: string) => {
3
+ queries: string[];
4
+ settings: Record<string, string>;
5
5
  };
6
- export { sql_queries, sql_sets, substitute_env };
6
+ declare const sql_queries: (content: string) => string[];
7
+ declare const sql_sets: (content: string) => Record<string, string>;
8
+ export { parse_migration_sql, sql_queries, sql_sets, substitute_env };
package/lib/sql-parse.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.substitute_env = exports.sql_sets = exports.sql_queries = void 0;
3
+ exports.substitute_env = exports.sql_sets = exports.sql_queries = exports.parse_migration_sql = void 0;
4
+ const sql_split_1 = require("./sql-split");
4
5
  // Replace ${NAME} in migration content with env vars. Opt-in via
5
6
  // CH_MIGRATIONS_SUBSTITUTE_ENV=true; otherwise returns content unchanged.
6
7
  // Use $${NAME} to keep a literal ${NAME}. Throws on an unset variable or a
@@ -51,35 +52,86 @@ const substitute_env = (content) => {
51
52
  return result;
52
53
  };
53
54
  exports.substitute_env = substitute_env;
54
- // Extract sql queries from migrations.
55
- const sql_queries = (content) => {
56
- const queries = content
57
- .replace(/(--|#!|#\s).*(\n|\r\n|\r|$)/gm, '\n')
58
- .replace(/^\s*(SET\s).*(\n|\r\n|\r|$)/gm, '')
59
- .replace(/(\n|\r\n|\r)/gm, ' ')
60
- .replace(/\s+/g, ' ')
61
- .split(';')
62
- .map((el) => el.trim())
63
- .filter((el) => el.length != 0);
64
- return queries;
55
+ // HTTP settings take values, not SQL string-literal syntax. Decode quoted
56
+ // strings once; leave numbers and compound parameter values as text so large
57
+ // integers never lose precision through JavaScript Number conversion.
58
+ const setting_value = (raw) => {
59
+ const tag = /^\$[A-Za-z0-9_]*\$/.exec(raw)?.[0];
60
+ if (tag && raw.endsWith(tag))
61
+ return raw.slice(tag.length, -tag.length);
62
+ const quote = raw[0];
63
+ if (!["'", '"', '`'].includes(quote) || !raw.endsWith(quote))
64
+ return raw;
65
+ // Hex escapes represent bytes; decode UTF-8 after unescaping so \xC3\xA9 is é.
66
+ const input = Buffer.from(raw.slice(1, -1));
67
+ const bytes = [];
68
+ const escapes = {
69
+ '0': 0,
70
+ a: 7,
71
+ b: 8,
72
+ e: 27,
73
+ f: 12,
74
+ n: 10,
75
+ r: 13,
76
+ t: 9,
77
+ v: 11,
78
+ "'": 39,
79
+ '"': 34,
80
+ '`': 96,
81
+ '\\': 92,
82
+ '/': 47,
83
+ '=': 61,
84
+ };
85
+ for (let i = 0; i < input.length; i += 1) {
86
+ const byte = input[i];
87
+ if (byte === quote.charCodeAt(0) && input[i + 1] === byte) {
88
+ i += 1;
89
+ }
90
+ else if (byte === 92 && i + 1 < input.length) {
91
+ const next = String.fromCharCode(input[i + 1]);
92
+ const hex = input.subarray(i + 2, i + 4).toString();
93
+ if (next === 'x' && /^[0-9a-f]{2}$/i.test(hex)) {
94
+ bytes.push(parseInt(hex, 16));
95
+ i += 3;
96
+ continue;
97
+ }
98
+ if (Object.hasOwn(escapes, next)) {
99
+ bytes.push(escapes[next]);
100
+ i += 1;
101
+ continue;
102
+ }
103
+ if (next === 'N') {
104
+ i += 1; // ClickHouse's \N escape is an empty string inside a literal.
105
+ continue;
106
+ }
107
+ }
108
+ bytes.push(byte);
109
+ }
110
+ return Buffer.from(bytes).toString('utf8');
65
111
  };
66
- exports.sql_queries = sql_queries;
67
- // Extract query settings from migrations.
68
- const sql_sets = (content) => {
69
- const sets = {};
70
- const sets_arr = content
71
- .replace(/(--|#!|#\s).*(\n|\r\n|\r|$)/gm, '\n')
72
- .replace(/^\s*(?!SET\s).*(\n|\r\n|\r|$)/gm, '')
73
- .replace(/^\s*(SET\s)/gm, '')
74
- .replace(/(\n|\r\n|\r)/gm, ' ')
75
- .replace(/\s+/g, '')
76
- .split(';');
77
- sets_arr.forEach((set_full) => {
78
- const set = set_full.split('=');
79
- if (set[0]) {
80
- sets[set[0]] = set[1];
112
+ const parse_migration_sql = (content) => {
113
+ const queries = [];
114
+ const settings = {};
115
+ for (const statement of (0, sql_split_1.split_statements)(content)) {
116
+ // Only assignment-style SET belongs to file-wide settings. Administrative
117
+ // statements such as SET DEFAULT ROLE remain SQL for ClickHouse to execute.
118
+ const setting = /^SET\s+([A-Za-z_][A-Za-z0-9_]*\s*=[\s\S]*)$/i.exec(statement);
119
+ if (!setting) {
120
+ queries.push(statement);
121
+ continue;
122
+ }
123
+ for (const assignment of (0, sql_split_1.split_assignments)(setting[1])) {
124
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([\s\S]+)$/.exec(assignment);
125
+ if (!match)
126
+ throw new Error(`invalid SET assignment: ${assignment}`);
127
+ settings[match[1]] = setting_value(match[2].trim());
81
128
  }
82
- });
83
- return sets;
129
+ }
130
+ return { queries, settings };
84
131
  };
132
+ exports.parse_migration_sql = parse_migration_sql;
133
+ // Preserve the existing helper exports for callers importing lib/sql-parse.
134
+ const sql_queries = (content) => parse_migration_sql(content).queries;
135
+ exports.sql_queries = sql_queries;
136
+ const sql_sets = (content) => parse_migration_sql(content).settings;
85
137
  exports.sql_sets = sql_sets;
@@ -0,0 +1,3 @@
1
+ declare const split_statements: (content: string) => string[];
2
+ declare const split_assignments: (content: string) => string[];
3
+ export { split_statements, split_assignments };
@@ -0,0 +1,213 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.split_assignments = exports.split_statements = void 0;
4
+ // Return a location for error messages, for example: "line 12, column 7".
5
+ const describe_position = (content, index) => {
6
+ const before = content.slice(0, index);
7
+ const line = before.split(/\r\n|\r|\n/).length;
8
+ const lineStart = Math.max(before.lastIndexOf('\n'), before.lastIndexOf('\r')) + 1;
9
+ // Count code points, not UTF-16 units, so supplementary characters do not shift the column.
10
+ const column = Array.from(before.slice(lineStart)).length + 1;
11
+ return `line ${line}, column ${column}`;
12
+ };
13
+ const isSpace = (ch) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f' || ch === '\v';
14
+ // Both statements and SET assignments use the same quoting/comment rules.
15
+ const split_sql = (content, separator) => {
16
+ const statements = [];
17
+ let current = '';
18
+ let pendingSpace = false; // whitespace seen in plain SQL, emitted lazily as one space
19
+ let bracketDepth = 0;
20
+ let insertStatement = false;
21
+ let insertHeader = false;
22
+ let inputSource = false;
23
+ let inputFunctionExpected = false;
24
+ let formatExpected = false;
25
+ let previousToken = '';
26
+ const rememberToken = (token, quoted = false) => {
27
+ inputFunctionExpected = token === 'INPUT' && ['FROM', 'JOIN'].includes(previousToken);
28
+ previousToken = quoted ? 'quoted' : token;
29
+ };
30
+ const emitPlain = (text) => {
31
+ if (pendingSpace && current.length > 0) {
32
+ current += ' ';
33
+ }
34
+ pendingSpace = false;
35
+ current += text;
36
+ };
37
+ const flush = (preserveData = false) => {
38
+ const statement = preserveData ? current : current.trim();
39
+ if (statement || separator === ',')
40
+ statements.push(statement);
41
+ current = '';
42
+ pendingSpace = false;
43
+ bracketDepth = 0;
44
+ insertStatement = false;
45
+ insertHeader = false;
46
+ inputSource = false;
47
+ inputFunctionExpected = false;
48
+ formatExpected = false;
49
+ previousToken = '';
50
+ };
51
+ const finishFormat = (name, start) => {
52
+ formatExpected = false;
53
+ insertHeader = false;
54
+ if (name === 'VALUES')
55
+ return start; // VALUES uses SQL literals, including quoted semicolons.
56
+ // Other formats contain data, not SQL: preserve tabs, newlines, quotes and
57
+ // comment markers verbatim. Keep the legacy boundary: the next ';' or EOF.
58
+ // Semicolons inside raw data require a format-aware loader, outside this runner.
59
+ const semicolon = content.indexOf(';', start);
60
+ const end = semicolon === -1 ? content.length : semicolon;
61
+ current += content.slice(start, end);
62
+ flush(true);
63
+ return semicolon === -1 ? end : end + 1;
64
+ };
65
+ const n = content.length;
66
+ let i = 0;
67
+ while (i < n) {
68
+ const ch = content[i];
69
+ const next = i + 1 < n ? content[i + 1] : '';
70
+ // Line comments end at the next newline; markers inside quotes are copied below.
71
+ if ((ch === '-' && next === '-') ||
72
+ (ch === '/' && next === '/') ||
73
+ (ch === '#' && (next === '!' || next === '' || isSpace(next)))) {
74
+ while (i < n && content[i] !== '\n' && content[i] !== '\r') {
75
+ i += 1;
76
+ }
77
+ pendingSpace = true; // the comment stood between tokens; keep them apart
78
+ continue;
79
+ }
80
+ // block comments, nested
81
+ if (ch === '/' && next === '*') {
82
+ const start = i;
83
+ let depth = 1;
84
+ i += 2;
85
+ while (i < n && depth > 0) {
86
+ if (content[i] === '/' && content[i + 1] === '*') {
87
+ depth += 1;
88
+ i += 2;
89
+ }
90
+ else if (content[i] === '*' && content[i + 1] === '/') {
91
+ depth -= 1;
92
+ i += 2;
93
+ }
94
+ else {
95
+ i += 1;
96
+ }
97
+ }
98
+ if (depth > 0) {
99
+ throw new Error(`unterminated block comment starting at ${describe_position(content, start)}`);
100
+ }
101
+ pendingSpace = true;
102
+ continue;
103
+ }
104
+ // quoted strings and identifiers: copied verbatim, including the quotes
105
+ if (ch === "'" || ch === '"' || ch === '`') {
106
+ const quote = ch;
107
+ let j = i + 1;
108
+ while (j < n) {
109
+ const c = content[j];
110
+ if (c === '\\' && j + 1 < n) {
111
+ j += 2; // backslash escape: skip the escaped character whatever it is
112
+ continue;
113
+ }
114
+ if (c === quote) {
115
+ if (content[j + 1] === quote) {
116
+ j += 2; // doubled quote inside the literal
117
+ continue;
118
+ }
119
+ break; // closing quote
120
+ }
121
+ j += 1;
122
+ }
123
+ if (j >= n) {
124
+ const what = quote === "'" ? 'string literal' : 'quoted identifier';
125
+ throw new Error(`unterminated ${what} starting at ${describe_position(content, i)}`);
126
+ }
127
+ const end = j + 1; // include the closing quote
128
+ emitPlain(content.slice(i, end));
129
+ if (formatExpected && quote !== "'") {
130
+ i = finishFormat(content.slice(i + 1, j).toUpperCase(), end);
131
+ continue;
132
+ }
133
+ rememberToken(quote === "'" ? 'quoted' : content.slice(i + 1, j).toUpperCase(), true);
134
+ formatExpected = false;
135
+ i = end;
136
+ continue;
137
+ }
138
+ // dollar-quoted strings: $tag$ ... $tag$ (tag may be empty)
139
+ if (ch === '$' && (i === 0 || !/[A-Za-z0-9_$]/.test(content[i - 1]))) {
140
+ const tagMatch = /^\$[A-Za-z0-9_]*\$/.exec(content.slice(i));
141
+ if (tagMatch) {
142
+ const opener = tagMatch[0];
143
+ const close = content.indexOf(opener, i + opener.length);
144
+ if (close === -1) {
145
+ throw new Error(`unterminated dollar-quoted string ${opener} starting at ${describe_position(content, i)}`);
146
+ }
147
+ const end = close + opener.length;
148
+ emitPlain(content.slice(i, end));
149
+ rememberToken('quoted');
150
+ formatExpected = false;
151
+ i = end;
152
+ continue;
153
+ }
154
+ }
155
+ // Commas inside arrays, maps and tuples belong to the setting value.
156
+ if (ch === separator && (separator === ';' || bracketDepth === 0)) {
157
+ flush();
158
+ i += 1;
159
+ continue;
160
+ }
161
+ // plain SQL
162
+ if (isSpace(ch)) {
163
+ pendingSpace = true;
164
+ i += 1;
165
+ continue;
166
+ }
167
+ // Recognize data only in an INSERT header or after input(). WITH/SELECT
168
+ // expressions and identifiers such as "AS format" must stay ordinary SQL.
169
+ const word = /^[A-Za-z_][A-Za-z0-9_$]*/.exec(content.slice(i));
170
+ if (word) {
171
+ const keyword = word[0].toUpperCase();
172
+ if (formatExpected) {
173
+ emitPlain(word[0]);
174
+ i = finishFormat(keyword, i + word[0].length);
175
+ continue;
176
+ }
177
+ if (separator === ';' && bracketDepth === 0) {
178
+ const identifier = ['INTO', 'TABLE', 'FUNCTION', '.', 'AS'].includes(previousToken);
179
+ if (!identifier) {
180
+ if (keyword === 'INSERT' && (!current || /^WITH\b/i.test(current))) {
181
+ insertStatement = true;
182
+ insertHeader = true;
183
+ }
184
+ if (['WITH', 'SELECT', 'VALUES'].includes(keyword))
185
+ insertHeader = false;
186
+ formatExpected = insertStatement && (insertHeader || inputSource) && keyword === 'FORMAT';
187
+ }
188
+ }
189
+ rememberToken(keyword);
190
+ emitPlain(word[0]);
191
+ i += word[0].length;
192
+ continue;
193
+ }
194
+ // input() also accepts raw FORMAT data after an INSERT SELECT, even in a subquery.
195
+ if (ch === '(' && inputFunctionExpected)
196
+ inputSource = true;
197
+ inputFunctionExpected = false;
198
+ if ('([{'.includes(ch))
199
+ bracketDepth += 1;
200
+ if (')]}'.includes(ch))
201
+ bracketDepth -= 1;
202
+ previousToken = ch;
203
+ formatExpected = false;
204
+ emitPlain(ch);
205
+ i += 1;
206
+ }
207
+ flush();
208
+ return statements;
209
+ };
210
+ const split_statements = (content) => split_sql(content, ';');
211
+ exports.split_statements = split_statements;
212
+ const split_assignments = (content) => split_sql(content, ',');
213
+ exports.split_assignments = split_assignments;
package/lib/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "1.4.1";
1
+ export declare const VERSION = "1.5.1";
package/lib/version.js CHANGED
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
- exports.VERSION = '1.4.1'; // x-release-please-version
4
+ exports.VERSION = '1.5.1'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clickhouse-migrations",
3
- "version": "1.4.1",
3
+ "version": "1.5.1",
4
4
  "description": "ClickHouse Migrations",
5
5
  "bin": {
6
6
  "clickhouse-migrations": "lib/cli.js"
@@ -28,7 +28,7 @@
28
28
  "postversion": "git push && git push --tags"
29
29
  },
30
30
  "dependencies": {
31
- "@clickhouse/client": "^1.23.1",
31
+ "@clickhouse/client": "^1.24.1",
32
32
  "commander": "^14.0.3"
33
33
  },
34
34
  "devDependencies": {