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/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