ch-migrate-cli 0.5.0__py3-none-any.whl
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.
- ch_migrate/__init__.py +68 -0
- ch_migrate/authoring.py +163 -0
- ch_migrate/bootstrap.py +379 -0
- ch_migrate/cli.py +1049 -0
- ch_migrate/config.py +116 -0
- ch_migrate/connection.py +83 -0
- ch_migrate/deps.py +123 -0
- ch_migrate/diff.py +232 -0
- ch_migrate/display.py +450 -0
- ch_migrate/downgrade.py +103 -0
- ch_migrate/env.py +226 -0
- ch_migrate/helpers.py +162 -0
- ch_migrate/hooks.py +74 -0
- ch_migrate/introspect.py +732 -0
- ch_migrate/lint.py +601 -0
- ch_migrate/mv_validate.py +554 -0
- ch_migrate/py.typed +0 -0
- ch_migrate/rebase.py +308 -0
- ch_migrate/runner.py +188 -0
- ch_migrate/scaffold.py +253 -0
- ch_migrate/secrets.py +162 -0
- ch_migrate/skills/ch-migrate/SKILL.md +250 -0
- ch_migrate/sql.py +216 -0
- ch_migrate/statements.py +144 -0
- ch_migrate/templates/bootstrap/init_users.sql +56 -0
- ch_migrate/templates/project/alembic.ini.template +43 -0
- ch_migrate/templates/project/config.yaml.template +55 -0
- ch_migrate/templates/project/env.local.example.template +25 -0
- ch_migrate/templates/project/script.py.mako.template +30 -0
- ch_migrate/ui.py +79 -0
- ch_migrate_cli-0.5.0.dist-info/METADATA +448 -0
- ch_migrate_cli-0.5.0.dist-info/RECORD +36 -0
- ch_migrate_cli-0.5.0.dist-info/WHEEL +4 -0
- ch_migrate_cli-0.5.0.dist-info/entry_points.txt +2 -0
- ch_migrate_cli-0.5.0.dist-info/licenses/LICENSE +21 -0
- clickhouse_alembic/__init__.py +59 -0
ch_migrate/scaffold.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""EXCHANGE TABLES migration scaffold generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def fetch_current_ddl(env_config: dict[str, Any], table_name: str) -> str | None:
|
|
12
|
+
"""Fetch current CREATE TABLE statement from the live database.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
env_config: Environment config dict from get_env_config().
|
|
16
|
+
table_name: Table name to inspect.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
DDL string or None if connection fails or table doesn't exist.
|
|
20
|
+
"""
|
|
21
|
+
from ch_migrate.connection import get_client
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
client = get_client(env_config)
|
|
25
|
+
db = env_config["database"]
|
|
26
|
+
result = client.query(f"SHOW CREATE TABLE {db}.{table_name}")
|
|
27
|
+
if result.result_rows:
|
|
28
|
+
return result.result_rows[0][0]
|
|
29
|
+
except Exception:
|
|
30
|
+
return None
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def find_dependent_dictionaries(
|
|
35
|
+
env_config: dict[str, Any], table_name: str
|
|
36
|
+
) -> list[str]:
|
|
37
|
+
"""Find dictionaries that use this table as a source.
|
|
38
|
+
|
|
39
|
+
Queries system.dictionaries to find any dictionary whose source
|
|
40
|
+
references the given table.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
env_config: Environment config dict from get_env_config().
|
|
44
|
+
table_name: Table name to check.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
List of dictionary names that depend on this table.
|
|
48
|
+
"""
|
|
49
|
+
from ch_migrate.connection import get_client
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
client = get_client(env_config)
|
|
53
|
+
db = env_config["database"]
|
|
54
|
+
result = client.query(
|
|
55
|
+
"SELECT name FROM system.dictionaries "
|
|
56
|
+
"WHERE database = {db:String} "
|
|
57
|
+
"AND (source LIKE {exact:String} OR source LIKE {dotted:String})",
|
|
58
|
+
parameters={
|
|
59
|
+
"db": db,
|
|
60
|
+
"exact": f"%'{table_name}'%",
|
|
61
|
+
"dotted": f"%.{table_name}%",
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
return [row[0] for row in result.result_rows]
|
|
65
|
+
except Exception:
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _make_shadow_ddl(ddl: str, table_name: str) -> str:
|
|
70
|
+
"""Transform a CREATE TABLE statement into a shadow table version.
|
|
71
|
+
|
|
72
|
+
Replaces the table name with <table>_shadow and adds IF NOT EXISTS.
|
|
73
|
+
"""
|
|
74
|
+
# Replace table name (handles db.table and just table patterns)
|
|
75
|
+
shadow = re.sub(
|
|
76
|
+
rf"(CREATE\s+TABLE\s+)(\S+\.)?{re.escape(table_name)}\b",
|
|
77
|
+
rf"\g<1>\g<2>{table_name}_shadow",
|
|
78
|
+
ddl,
|
|
79
|
+
count=1,
|
|
80
|
+
flags=re.IGNORECASE,
|
|
81
|
+
)
|
|
82
|
+
# Add IF NOT EXISTS if not present
|
|
83
|
+
if "IF NOT EXISTS" not in shadow.upper():
|
|
84
|
+
shadow = re.sub(
|
|
85
|
+
r"(CREATE\s+TABLE\s+)",
|
|
86
|
+
r"\1IF NOT EXISTS ",
|
|
87
|
+
shadow,
|
|
88
|
+
count=1,
|
|
89
|
+
flags=re.IGNORECASE,
|
|
90
|
+
)
|
|
91
|
+
return shadow
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def generate_exchange_sql(
|
|
95
|
+
table_name: str, current_ddl: str | None = None
|
|
96
|
+
) -> str:
|
|
97
|
+
"""Generate the SQL file content for an EXCHANGE TABLES migration.
|
|
98
|
+
|
|
99
|
+
This creates the shadow table DDL that the user should modify with
|
|
100
|
+
their desired schema changes before running the migration.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
table_name: Name of the table being altered.
|
|
104
|
+
current_ddl: Current CREATE TABLE DDL from the database, if available.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
SQL file content for the shadow table creation.
|
|
108
|
+
"""
|
|
109
|
+
if current_ddl:
|
|
110
|
+
# read_sql() runs str.format on this file; keep engine macros such as {uuid} and
|
|
111
|
+
# {replica} (Replicated/Shared engines, ClickHouse Cloud) literal.
|
|
112
|
+
shadow_ddl = _make_shadow_ddl(current_ddl, table_name).replace("{", "{{").replace("}", "}}")
|
|
113
|
+
return (
|
|
114
|
+
f"-- Shadow table for EXCHANGE TABLES migration\n"
|
|
115
|
+
f"-- Modify this schema with your desired changes.\n"
|
|
116
|
+
f"--\n"
|
|
117
|
+
f"-- Original DDL fetched from live database.\n"
|
|
118
|
+
f"-- The migration will:\n"
|
|
119
|
+
f"-- 1. Create this shadow table\n"
|
|
120
|
+
f"-- 2. Copy data from {table_name} into it\n"
|
|
121
|
+
f"-- 3. Atomically swap via EXCHANGE TABLES\n"
|
|
122
|
+
f"-- 4. Drop the old table\n\n"
|
|
123
|
+
f"{shadow_ddl}\n"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Placeholder when no live DDL is available
|
|
127
|
+
return (
|
|
128
|
+
f"-- Shadow table for EXCHANGE TABLES migration\n"
|
|
129
|
+
f"-- Replace this placeholder with your desired schema.\n"
|
|
130
|
+
f"--\n"
|
|
131
|
+
f"-- TIP: Run `clickhouse-client --query 'SHOW CREATE TABLE {{db}}.{table_name}'`\n"
|
|
132
|
+
f"-- to get the current schema, then modify it here.\n\n"
|
|
133
|
+
f"CREATE TABLE IF NOT EXISTS {{db}}.{table_name}_shadow\n"
|
|
134
|
+
f"(\n"
|
|
135
|
+
f" -- TODO: Define columns here\n"
|
|
136
|
+
f")\n"
|
|
137
|
+
f"ENGINE = MergeTree\n"
|
|
138
|
+
f"ORDER BY tuple()\n"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def generate_exchange_migration(
|
|
143
|
+
revision: str,
|
|
144
|
+
down_revision: str | None,
|
|
145
|
+
message: str,
|
|
146
|
+
table_name: str,
|
|
147
|
+
sql_path: str,
|
|
148
|
+
dict_names: list[str] | None = None,
|
|
149
|
+
) -> str:
|
|
150
|
+
"""Generate migration .py content with the EXCHANGE TABLES pattern.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
revision: Alembic revision ID.
|
|
154
|
+
down_revision: Previous revision ID.
|
|
155
|
+
message: Migration description.
|
|
156
|
+
table_name: Table being exchanged.
|
|
157
|
+
sql_path: Relative path to the SQL history file (from migrations/sql/).
|
|
158
|
+
dict_names: Dictionaries to reload after exchange, if any.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Complete migration .py file content.
|
|
162
|
+
"""
|
|
163
|
+
down_repr = repr(down_revision)
|
|
164
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
165
|
+
|
|
166
|
+
dict_lines = ""
|
|
167
|
+
if dict_names:
|
|
168
|
+
dict_lines = "\n # Reload dependent dictionaries\n"
|
|
169
|
+
for d in dict_names:
|
|
170
|
+
dict_lines += f' op.execute("SYSTEM RELOAD DICTIONARY {{db}}.{d}")\n'
|
|
171
|
+
|
|
172
|
+
return f'''"""{message}
|
|
173
|
+
|
|
174
|
+
Revision ID: {revision}
|
|
175
|
+
Revises: {down_revision or "None"}
|
|
176
|
+
Create Date: {now}
|
|
177
|
+
|
|
178
|
+
EXCHANGE TABLES migration for: {table_name}
|
|
179
|
+
Steps: CREATE shadow -> INSERT SELECT -> EXCHANGE -> DROP
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
from alembic import op
|
|
183
|
+
|
|
184
|
+
from ch_migrate import IrreversibleMigration, get_db, read_sql
|
|
185
|
+
|
|
186
|
+
# revision identifiers
|
|
187
|
+
revision = {repr(revision)}
|
|
188
|
+
down_revision = {down_repr}
|
|
189
|
+
branch_labels = None
|
|
190
|
+
depends_on = None
|
|
191
|
+
irreversible = (
|
|
192
|
+
"EXCHANGE TABLES drops the old table; its data cannot be restored automatically."
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def upgrade() -> None:
|
|
197
|
+
db = get_db()
|
|
198
|
+
|
|
199
|
+
# 1. Create shadow table with new schema
|
|
200
|
+
op.execute(read_sql("{sql_path}", db=db))
|
|
201
|
+
|
|
202
|
+
# 2. Copy data from original table into shadow
|
|
203
|
+
# NOTE: Modify the SELECT if columns changed (added/removed/renamed)
|
|
204
|
+
op.execute(f"INSERT INTO {{db}}.{table_name}_shadow SELECT * FROM {{db}}.{table_name}")
|
|
205
|
+
|
|
206
|
+
# 3. Atomically swap tables
|
|
207
|
+
op.execute(f"EXCHANGE TABLES {{db}}.{table_name} AND {{db}}.{table_name}_shadow")
|
|
208
|
+
|
|
209
|
+
# 4. Drop old table (now named {table_name}_shadow)
|
|
210
|
+
op.execute(f"DROP TABLE IF EXISTS {{db}}.{table_name}_shadow")
|
|
211
|
+
{dict_lines}
|
|
212
|
+
|
|
213
|
+
def downgrade() -> None:
|
|
214
|
+
raise IrreversibleMigration(revision, irreversible)
|
|
215
|
+
'''
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def rewrite_migration_file(
|
|
219
|
+
migration_path: Path,
|
|
220
|
+
table_name: str,
|
|
221
|
+
sql_path: str,
|
|
222
|
+
dict_names: list[str] | None = None,
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Rewrite an alembic-generated migration file with EXCHANGE pattern.
|
|
225
|
+
|
|
226
|
+
Reads the revision and down_revision from the existing file, then
|
|
227
|
+
overwrites it with the EXCHANGE TABLES template.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
migration_path: Path to the generated migration .py file.
|
|
231
|
+
table_name: Table being exchanged.
|
|
232
|
+
sql_path: Relative path to the SQL history file.
|
|
233
|
+
dict_names: Dictionaries to reload after exchange, if any.
|
|
234
|
+
"""
|
|
235
|
+
content = migration_path.read_text()
|
|
236
|
+
|
|
237
|
+
rev_match = re.search(r'revision\s*=\s*["\'](\w+)["\']', content)
|
|
238
|
+
down_match = re.search(r'down_revision\s*=\s*["\'](\w+)["\']', content)
|
|
239
|
+
msg_match = re.search(r'^"""(.+?)$', content, re.MULTILINE)
|
|
240
|
+
|
|
241
|
+
revision = rev_match.group(1) if rev_match else "UNKNOWN"
|
|
242
|
+
down_revision = down_match.group(1) if down_match else None
|
|
243
|
+
message = msg_match.group(1) if msg_match else table_name
|
|
244
|
+
|
|
245
|
+
new_content = generate_exchange_migration(
|
|
246
|
+
revision=revision,
|
|
247
|
+
down_revision=down_revision,
|
|
248
|
+
message=message,
|
|
249
|
+
table_name=table_name,
|
|
250
|
+
sql_path=sql_path,
|
|
251
|
+
dict_names=dict_names,
|
|
252
|
+
)
|
|
253
|
+
migration_path.write_text(new_content)
|
ch_migrate/secrets.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Secrets management with environment variable and SSM support."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional, Union
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SSMSecretNotFoundError(Exception):
|
|
9
|
+
"""Raised when an SSM parameter cannot be found."""
|
|
10
|
+
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SSMJsonKeyError(Exception):
|
|
15
|
+
"""Raised when a JSON key cannot be found in an SSM parameter value."""
|
|
16
|
+
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_ssm_client(region: Optional[str] = None): # type: ignore[no-untyped-def]
|
|
21
|
+
"""Get boto3 SSM client, raising helpful error if boto3 not installed.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
region: Optional AWS region name (e.g., 'us-east-1'). If not provided,
|
|
25
|
+
uses AWS_REGION environment variable or default from AWS config.
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
import boto3 # type: ignore[import-not-found,import-untyped]
|
|
29
|
+
except ImportError:
|
|
30
|
+
raise ImportError(
|
|
31
|
+
"boto3 is required for SSM support. "
|
|
32
|
+
"Install with: pip install ch-migrate-cli[ssm]"
|
|
33
|
+
)
|
|
34
|
+
if region:
|
|
35
|
+
return boto3.client("ssm", region_name=region)
|
|
36
|
+
return boto3.client("ssm")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _get_ssm_exceptions(): # type: ignore[no-untyped-def]
|
|
40
|
+
"""Get boto3 SSM exception classes."""
|
|
41
|
+
try:
|
|
42
|
+
from botocore.exceptions import ClientError # type: ignore[import-not-found,import-untyped]
|
|
43
|
+
|
|
44
|
+
return ClientError
|
|
45
|
+
except ImportError:
|
|
46
|
+
# Fallback if botocore not available (shouldn't happen if boto3 is installed)
|
|
47
|
+
return Exception
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_ssm_path(ssm_path: Union[str, dict]) -> tuple[str, Optional[str]]:
|
|
51
|
+
"""
|
|
52
|
+
Parse SSM path into (path, json_key).
|
|
53
|
+
|
|
54
|
+
Supports two formats:
|
|
55
|
+
- String with hash suffix: "/path/to/param#json_key"
|
|
56
|
+
- Dict with explicit fields: {"path": "/path/to/param", "json_key": "password"}
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
ssm_path: SSM path as string or dict
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Tuple of (ssm_parameter_path, json_key_or_none)
|
|
63
|
+
"""
|
|
64
|
+
if isinstance(ssm_path, dict):
|
|
65
|
+
return ssm_path["path"], ssm_path.get("json_key")
|
|
66
|
+
elif "#" in ssm_path:
|
|
67
|
+
path, json_key = ssm_path.rsplit("#", 1)
|
|
68
|
+
return path, json_key
|
|
69
|
+
else:
|
|
70
|
+
return ssm_path, None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_secret(
|
|
74
|
+
env_name: str,
|
|
75
|
+
key: str,
|
|
76
|
+
*,
|
|
77
|
+
ssm_path: Optional[Union[str, dict]] = None,
|
|
78
|
+
aws_region: Optional[str] = None,
|
|
79
|
+
required: bool = True,
|
|
80
|
+
) -> Optional[str]:
|
|
81
|
+
"""
|
|
82
|
+
Get a secret value from SSM or environment variable.
|
|
83
|
+
|
|
84
|
+
Precedence:
|
|
85
|
+
1. SSM parameter at ssm_path (if provided) - use SSM directly
|
|
86
|
+
2. Environment variable CH_{ENV}_{KEY} (e.g., CH_DEV_MIGRATION_PASSWORD)
|
|
87
|
+
3. Legacy env var for migration_password: CH_{ENV}_PASSWORD
|
|
88
|
+
4. None (if not required) or raise ValueError
|
|
89
|
+
|
|
90
|
+
SSM path formats:
|
|
91
|
+
- Simple string: "/myproject/dev/password"
|
|
92
|
+
- With JSON key (hash suffix): "/myproject/credentials#password"
|
|
93
|
+
- With JSON key (object): {"path": "/myproject/credentials", "json_key": "password"}
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
env_name: Environment name (dev, staging, production)
|
|
97
|
+
key: Secret key (migration_password, admin_password, dict_reader_password)
|
|
98
|
+
ssm_path: Optional SSM parameter path (string or dict with path/json_key)
|
|
99
|
+
aws_region: Optional AWS region for SSM lookups (e.g., 'us-east-1')
|
|
100
|
+
required: Whether to raise if secret not found
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Secret value or None if not required and not found
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
ValueError: If required and not found in env or SSM
|
|
107
|
+
SSMSecretNotFoundError: If SSM path provided but parameter not found
|
|
108
|
+
SSMJsonKeyError: If JSON key specified but not found in parameter value
|
|
109
|
+
ImportError: If SSM path provided but boto3 not installed
|
|
110
|
+
"""
|
|
111
|
+
# If SSM path provided, use SSM directly (don't check env vars)
|
|
112
|
+
if ssm_path:
|
|
113
|
+
path, json_key = _parse_ssm_path(ssm_path)
|
|
114
|
+
client = _get_ssm_client(aws_region)
|
|
115
|
+
ClientError = _get_ssm_exceptions()
|
|
116
|
+
try:
|
|
117
|
+
response = client.get_parameter(Name=path, WithDecryption=True)
|
|
118
|
+
value = response["Parameter"]["Value"]
|
|
119
|
+
except ClientError as e:
|
|
120
|
+
error_code = e.response.get("Error", {}).get("Code", "")
|
|
121
|
+
if error_code == "ParameterNotFound":
|
|
122
|
+
if required:
|
|
123
|
+
raise SSMSecretNotFoundError(f"SSM parameter not found: {path}") from e
|
|
124
|
+
return None
|
|
125
|
+
# Re-raise other AWS errors (permission denied, etc.)
|
|
126
|
+
raise
|
|
127
|
+
|
|
128
|
+
# Extract JSON key if specified
|
|
129
|
+
if json_key:
|
|
130
|
+
try:
|
|
131
|
+
data = json.loads(value)
|
|
132
|
+
except json.JSONDecodeError as e:
|
|
133
|
+
raise SSMJsonKeyError(
|
|
134
|
+
f"SSM parameter '{path}' is not valid JSON (needed for key '{json_key}')"
|
|
135
|
+
) from e
|
|
136
|
+
if json_key not in data:
|
|
137
|
+
raise SSMJsonKeyError(f"JSON key '{json_key}' not found in SSM parameter '{path}'")
|
|
138
|
+
return str(data[json_key])
|
|
139
|
+
|
|
140
|
+
return value # type: ignore[no-any-return]
|
|
141
|
+
|
|
142
|
+
# No SSM path - use environment variables
|
|
143
|
+
env_var = f"CH_{env_name.upper()}_{key.upper()}"
|
|
144
|
+
value = os.environ.get(env_var)
|
|
145
|
+
if value:
|
|
146
|
+
return value
|
|
147
|
+
|
|
148
|
+
# Legacy support: CH_{ENV}_PASSWORD for migration_password
|
|
149
|
+
if key == "migration_password":
|
|
150
|
+
legacy_var = f"CH_{env_name.upper()}_PASSWORD"
|
|
151
|
+
value = os.environ.get(legacy_var)
|
|
152
|
+
if value:
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
# Not found anywhere
|
|
156
|
+
if required:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"{env_var} is required. "
|
|
159
|
+
f"Set it in .env.local or provide an SSM path in config.yaml."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return None
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ch-migrate
|
|
3
|
+
description: Use when integrating ClickHouse migrations into a project, setting up ch-migrate, creating migration files, bootstrapping ClickHouse databases, or troubleshooting ClickHouse Alembic issues. Triggers on "ClickHouse migration", "ch-migrate", "Alembic ClickHouse", "bootstrap ClickHouse", "migration user", "EXCHANGE TABLES".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ch-migrate: ClickHouse Migration Tool
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
`ch-migrate` adds SQL-first authoring, environment configuration, bootstrap,
|
|
11
|
+
inspection, and drift checks above Alembic. It complements ClickHouse's official
|
|
12
|
+
Alembic integration; this development line still uses `clickhouse-sqlalchemy`
|
|
13
|
+
for migration connections. Do not imply an endorsement or promise transactional
|
|
14
|
+
DDL.
|
|
15
|
+
|
|
16
|
+
**Install:** `uv tool install ch-migrate-cli` or `pip install ch-migrate-cli`.
|
|
17
|
+
The command is `ch-migrate`; the import package is `ch_migrate`.
|
|
18
|
+
|
|
19
|
+
## CLI Quick Reference
|
|
20
|
+
|
|
21
|
+
| Command | Description |
|
|
22
|
+
|---------|-------------|
|
|
23
|
+
| `ch-migrate init [PATH] [--name NAME]` | Initialize project structure |
|
|
24
|
+
| `ch-migrate bootstrap ENV [--dry-run]` | Create database, roles, users |
|
|
25
|
+
| `ch-migrate new ENV NAME [--table X] [--irreversible REASON]` | Create upgrade/downgrade SQL and their revision |
|
|
26
|
+
| `ch-migrate up ENV [-r REV]` | Apply migrations (default: head, or to REV) |
|
|
27
|
+
| `ch-migrate down ENV [-r REV]` | Rollback (default: last, or to REV) |
|
|
28
|
+
| `ch-migrate status ENV` | Show current migration state |
|
|
29
|
+
| `ch-migrate history ENV` | Show migration history |
|
|
30
|
+
| `ch-migrate lint [ENV]` | Check upgrade statements; ENV restricts to pending revisions and adds live checks |
|
|
31
|
+
| `ch-migrate deps ENV [--validate PATH]` | Inspect live dependencies |
|
|
32
|
+
| `ch-migrate snapshot ENV [--exclude GLOB] [--filter GLOB]` | Capture schema |
|
|
33
|
+
| `ch-migrate diff ENV [--snapshot-dir PATH]` | Compare snapshot and live schema |
|
|
34
|
+
| `ch-migrate rebase ENV [--onto REV] [--dry-run]` | Preview/rewrite dangling revision branches |
|
|
35
|
+
| `ch-migrate upgrade-env` | Refresh env.py, keeping env.py.bak |
|
|
36
|
+
| `ch-migrate skill [--user\|--project]` | Install Claude skill for ch-migrate |
|
|
37
|
+
|
|
38
|
+
**Options for `new`:** choose one of `--table NAME`, `--view NAME`, and
|
|
39
|
+
`--dict NAME`. SQL lives under `migrations/sql/history/{tables|views|dictionaries}/NAME/`;
|
|
40
|
+
without an object it goes under `history/other/`. Filenames are
|
|
41
|
+
`<YYYY_MM_DD_HHMM>_<revision>_<slug>.up.sql` and `.down.sql`.
|
|
42
|
+
`--irreversible REASON` omits the down file and installs a static marker plus
|
|
43
|
+
`IrreversibleMigration` backstop. `--python` retains the Python template and
|
|
44
|
+
optional single SQL file. `--exchange --table NAME` retains the exchange
|
|
45
|
+
scaffold. These three modes are mutually exclusive; invalid choices fail
|
|
46
|
+
before Alembic writes a revision.
|
|
47
|
+
|
|
48
|
+
## Project Structure
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
project/
|
|
52
|
+
├── config.yaml # ClickHouse hosts and settings
|
|
53
|
+
├── .env.local # Secrets (gitignored)
|
|
54
|
+
├── alembic.ini # Alembic configuration
|
|
55
|
+
└── migrations/
|
|
56
|
+
├── env.py # Alembic environment
|
|
57
|
+
├── versions/ # Generated revision adapters; no Python edits needed
|
|
58
|
+
└── sql/
|
|
59
|
+
├── bootstrap/ # Custom bootstrap SQL (optional)
|
|
60
|
+
└── history/ # Object-centric SQL versions
|
|
61
|
+
├── tables/
|
|
62
|
+
├── views/
|
|
63
|
+
├── dictionaries/
|
|
64
|
+
└── other/
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
### config.yaml
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
project:
|
|
73
|
+
name: my_project
|
|
74
|
+
|
|
75
|
+
defaults:
|
|
76
|
+
port: 8443 # 8123 for local HTTP
|
|
77
|
+
secure: true # false for local Docker
|
|
78
|
+
admin_user: default
|
|
79
|
+
# Optional users (uncomment to enable):
|
|
80
|
+
# mcp_user_name: mcp_reader # Read-only for AI tools
|
|
81
|
+
# dict_reader_name: dict_reader
|
|
82
|
+
|
|
83
|
+
environments:
|
|
84
|
+
dev:
|
|
85
|
+
host: dev.clickhouse.cloud # or localhost for Docker
|
|
86
|
+
database: my_project_dev
|
|
87
|
+
migration_user: migration_dev
|
|
88
|
+
# aws_region: us-east-1 # Optional: for region-scoped SSM lookups
|
|
89
|
+
# Optional SSM paths (if set, fetches from SSM directly):
|
|
90
|
+
# Supports JSON key extraction: /path/to/param#json_key
|
|
91
|
+
# ssm:
|
|
92
|
+
# admin_password: /my_project/dev/admin_password
|
|
93
|
+
# migration_password: /my_project/credentials#password
|
|
94
|
+
|
|
95
|
+
staging:
|
|
96
|
+
host: staging.clickhouse.cloud
|
|
97
|
+
database: my_project_staging
|
|
98
|
+
migration_user: migration_staging
|
|
99
|
+
|
|
100
|
+
production:
|
|
101
|
+
host: prod.clickhouse.cloud
|
|
102
|
+
database: my_project
|
|
103
|
+
migration_user: migration_prod
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### .env.local
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Required
|
|
110
|
+
CH_DEV_MIGRATION_PASSWORD=your-migration-password
|
|
111
|
+
CH_DEV_ADMIN_PASSWORD=your-admin-password # For bootstrap only
|
|
112
|
+
|
|
113
|
+
# Optional (if mcp_user_name configured)
|
|
114
|
+
CH_DEV_MCP_PASSWORD=your-mcp-password
|
|
115
|
+
|
|
116
|
+
# Repeat for staging/production with appropriate env name
|
|
117
|
+
CH_PRODUCTION_MIGRATION_PASSWORD=prod-password
|
|
118
|
+
CH_PRODUCTION_ADMIN_PASSWORD=prod-admin-password
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Integration Workflow
|
|
122
|
+
|
|
123
|
+
### SQL-first workflow
|
|
124
|
+
|
|
125
|
+
Use a dedicated, authorized server. Local HTTP normally uses port `8123` and
|
|
126
|
+
`secure: false`; Cloud normally uses HTTPS `8443`. Do not start or modify shared
|
|
127
|
+
infrastructure as part of trying the tool.
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
ch-migrate init ./schema --name my_project
|
|
131
|
+
cd schema
|
|
132
|
+
# Edit config.yaml and create .env.local here.
|
|
133
|
+
ch-migrate bootstrap dev --dry-run
|
|
134
|
+
ch-migrate bootstrap dev
|
|
135
|
+
ch-migrate new dev add_status --table logs
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Fill the generated `.up.sql` with:
|
|
139
|
+
|
|
140
|
+
```sql
|
|
141
|
+
CREATE TABLE IF NOT EXISTS {db}.logs (id UInt64)
|
|
142
|
+
ENGINE = MergeTree ORDER BY id;
|
|
143
|
+
ALTER TABLE {db}.logs ADD COLUMN IF NOT EXISTS status String;
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
For an empty test project only, fill `.down.sql` with:
|
|
147
|
+
|
|
148
|
+
```sql
|
|
149
|
+
DROP TABLE IF EXISTS {db}.logs;
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Then run:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
ch-migrate lint
|
|
156
|
+
ch-migrate up dev
|
|
157
|
+
ch-migrate status dev
|
|
158
|
+
ch-migrate history dev
|
|
159
|
+
ch-migrate down dev
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Do not edit the generated Python adapter. Empty/comment-only SQL files fail.
|
|
163
|
+
The example downgrade drops the table and its data; do not apply it to a live
|
|
164
|
+
table that needs preserving.
|
|
165
|
+
|
|
166
|
+
### SQL execution
|
|
167
|
+
|
|
168
|
+
`run_sql` splits on semicolons outside strings, identifiers, comments, and
|
|
169
|
+
heredocs, then sends one statement per request. It substitutes `{db}`,
|
|
170
|
+
`{cluster}`, and `{on_cluster}`, plus explicit keyword overrides. Other braces
|
|
171
|
+
remain literal, including JSON and `{id:UInt64}` parameters. Doubled braces
|
|
172
|
+
are not escapes. A failure stops later statements but cannot undo earlier DDL.
|
|
173
|
+
|
|
174
|
+
For migrations requiring logic:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
ch-migrate new dev backfill --python
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`read_sql` and `get_db` remain available to Python migrations. `read_sql` still
|
|
181
|
+
uses `str.format`, so its literal-brace rules differ from `run_sql`.
|
|
182
|
+
|
|
183
|
+
### Irreversible changes
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
ch-migrate new dev drop_legacy --table logs --irreversible "Drops legacy data"
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`down` statically checks the whole known range before running Alembic and refuses
|
|
190
|
+
if any revision carries an irreversible marker. It understands `-N` on a linear
|
|
191
|
+
chain, full or unique-prefix IDs, and `base`. Unknown ranges fall back to each
|
|
192
|
+
migration's exception; direct Alembic calls also rely on that backstop.
|
|
193
|
+
|
|
194
|
+
There is no override flag. Implement a real downgrade and remove the marker
|
|
195
|
+
through review to revert past it. Hand-written Python revisions must provide
|
|
196
|
+
both `irreversible = "reason"` and a downgrade that raises
|
|
197
|
+
`IrreversibleMigration(revision, irreversible)`.
|
|
198
|
+
|
|
199
|
+
### Exchange and dictionary operations
|
|
200
|
+
|
|
201
|
+
`new --exchange --table NAME` creates the existing copy-and-swap scaffold.
|
|
202
|
+
Pause or coordinate writers: copying and exchanging alone does not preserve
|
|
203
|
+
inserts arriving during the copy. Review the schema and column mapping.
|
|
204
|
+
The generated revision is irreversible because it drops the old table.
|
|
205
|
+
|
|
206
|
+
For dictionaries, `create_dictionary("history/dictionaries/NAME/file.sql")`
|
|
207
|
+
retains the configured dictionary reader's automatic SELECT grant.
|
|
208
|
+
|
|
209
|
+
For offline SQL, set `CH_ENVIRONMENT` and run `alembic upgrade head --sql`.
|
|
210
|
+
Existing projects need `ch-migrate upgrade-env` for the offline version-table
|
|
211
|
+
and literal-rendering fixes. Keep credentials out of SQL files and logs.
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
## Roles Created by Bootstrap
|
|
215
|
+
|
|
216
|
+
| Role | Purpose |
|
|
217
|
+
|------|---------|
|
|
218
|
+
| `{project}_migration_role` | Schema changes (CREATE/DROP/ALTER TABLE/VIEW/DICTIONARY), data ops (SELECT/INSERT/DELETE/TRUNCATE), and introspection including explicit `system.grants` access |
|
|
219
|
+
| `{project}_readonly_role` | SELECT + SHOW (if mcp_user configured) |
|
|
220
|
+
| `{project}_dict_role` | Dictionary source access (if dict_reader configured) |
|
|
221
|
+
|
|
222
|
+
Note: Bootstrap uses explicit grants (not `GRANT ALL`) for ClickHouse Cloud compatibility.
|
|
223
|
+
|
|
224
|
+
## Troubleshooting
|
|
225
|
+
|
|
226
|
+
| Issue | Solution |
|
|
227
|
+
|-------|----------|
|
|
228
|
+
| "migration_user required" | Add `migration_user: username` to environment in config.yaml |
|
|
229
|
+
| "Connection refused" | Check host/port. Local Docker: port 8123, secure: false |
|
|
230
|
+
| Passwords not loading | Ensure .env.local exists in project root (not migrations/) |
|
|
231
|
+
| Bootstrap hangs | Verify admin_password is correct for admin_user |
|
|
232
|
+
| "Database does not exist" | Run `ch-migrate bootstrap ENV` first |
|
|
233
|
+
|
|
234
|
+
### Verify Configuration
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
# Check what SQL would run
|
|
238
|
+
ch-migrate bootstrap dev --dry-run
|
|
239
|
+
|
|
240
|
+
# Should show masked passwords like:
|
|
241
|
+
# CREATE USER IF NOT EXISTS migration_dev
|
|
242
|
+
# IDENTIFIED BY '********';
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## ClickHouse Cloud Notes
|
|
246
|
+
|
|
247
|
+
- Uses standard engines (MergeTree, ReplacingMergeTree) - auto-upgraded to Shared* on Cloud
|
|
248
|
+
- Default port 8443 (HTTPS), use 8123 for local HTTP
|
|
249
|
+
- DDL is non-transactional - migrations can't be atomically rolled back
|
|
250
|
+
- Each `op.execute()` runs one statement
|