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/env.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ClickHouse Alembic Environment.
|
|
3
|
+
|
|
4
|
+
This module is copied to user projects and configures Alembic for ClickHouse.
|
|
5
|
+
It loads configuration from config.yaml and secrets from environment variables.
|
|
6
|
+
|
|
7
|
+
Why SQLAlchemy?
|
|
8
|
+
--------------
|
|
9
|
+
Alembic is built on SQLAlchemy as its core database abstraction layer. While we use
|
|
10
|
+
clickhouse-connect for direct ClickHouse operations (bootstrap, helpers), Alembic
|
|
11
|
+
requires SQLAlchemy for:
|
|
12
|
+
- Connection management and pooling
|
|
13
|
+
- Transaction handling (even though ClickHouse DDL isn't transactional)
|
|
14
|
+
- Database dialect registration (clickhouse-sqlalchemy provides the ClickHouse dialect)
|
|
15
|
+
- The `op.execute()` interface in migrations
|
|
16
|
+
|
|
17
|
+
The clickhouse-sqlalchemy package provides the `clickhouse+http://` URL dialect that
|
|
18
|
+
Alembic uses to connect to ClickHouse.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
from logging.config import fileConfig
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from urllib.parse import quote_plus
|
|
26
|
+
|
|
27
|
+
from alembic import context
|
|
28
|
+
from alembic.ddl import impl
|
|
29
|
+
from clickhouse_sqlalchemy import Table, engines
|
|
30
|
+
from clickhouse_sqlalchemy.drivers.compilers.sqlcompiler import ClickHouseSQLCompiler
|
|
31
|
+
from dotenv import load_dotenv
|
|
32
|
+
from sqlalchemy import Column, Connection, DateTime, MetaData, String, create_engine, pool, text
|
|
33
|
+
|
|
34
|
+
from ch_migrate.config import get_env_config
|
|
35
|
+
from ch_migrate.hooks import HookRegistry, run_hooks
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# Alembic Config object
|
|
40
|
+
config = context.config
|
|
41
|
+
|
|
42
|
+
# Get environment name from CH_ENVIRONMENT (set by ch-migrate CLI)
|
|
43
|
+
env_name = os.environ.get("CH_ENVIRONMENT", "dev")
|
|
44
|
+
|
|
45
|
+
# Load .env.local for secrets (if it exists)
|
|
46
|
+
env_local = Path.cwd() / ".env.local"
|
|
47
|
+
if env_local.exists():
|
|
48
|
+
load_dotenv(env_local)
|
|
49
|
+
|
|
50
|
+
# Load configuration
|
|
51
|
+
config_path = Path.cwd() / "config.yaml"
|
|
52
|
+
try:
|
|
53
|
+
env_config = get_env_config(env_name, config_path)
|
|
54
|
+
except FileNotFoundError:
|
|
55
|
+
raise FileNotFoundError(
|
|
56
|
+
f"config.yaml not found in {Path.cwd()}\n"
|
|
57
|
+
f"Run 'ch-migrate init' to create project structure."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Setup logging
|
|
61
|
+
if config.config_file_name is not None:
|
|
62
|
+
fileConfig(config.config_file_name)
|
|
63
|
+
|
|
64
|
+
# Load hook registry from config
|
|
65
|
+
hook_registry = HookRegistry.from_config(env_config.get("hooks"))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ClickhouseImpl(impl.DefaultImpl):
|
|
69
|
+
"""Alembic implementation for ClickHouse dialect."""
|
|
70
|
+
|
|
71
|
+
__dialect__ = "clickhouse"
|
|
72
|
+
transactional_ddl = False
|
|
73
|
+
|
|
74
|
+
def version_table_impl(self, *, version_table, version_table_schema, **kw):
|
|
75
|
+
"""Match bootstrap's version table when Alembic renders offline DDL."""
|
|
76
|
+
return Table(
|
|
77
|
+
version_table,
|
|
78
|
+
MetaData(),
|
|
79
|
+
Column("updated", DateTime, server_default=text("now()")),
|
|
80
|
+
Column("version_num", String, nullable=False),
|
|
81
|
+
engines.ReplacingMergeTree(order_by="updated"),
|
|
82
|
+
schema=version_table_schema,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_sqlalchemy_url() -> str:
|
|
87
|
+
"""Build SQLAlchemy-compatible ClickHouse URL from config."""
|
|
88
|
+
host = env_config["host"]
|
|
89
|
+
# Support both new 'migration_user' and legacy 'user' field
|
|
90
|
+
user = env_config.get("migration_user") or env_config.get("user")
|
|
91
|
+
password = env_config["password"]
|
|
92
|
+
database = env_config["database"]
|
|
93
|
+
port = env_config.get("port", 8443)
|
|
94
|
+
secure = env_config.get("secure", True)
|
|
95
|
+
|
|
96
|
+
# URL-encode password to handle special characters
|
|
97
|
+
encoded_password = quote_plus(password)
|
|
98
|
+
|
|
99
|
+
# Build SQLAlchemy URL
|
|
100
|
+
protocol_param = "?protocol=https" if secure else ""
|
|
101
|
+
return f"clickhouse+http://{user}:{encoded_password}@{host}:{port}/{database}{protocol_param}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Export database name for use in migrations
|
|
105
|
+
DATABASE_NAME = env_config["database"]
|
|
106
|
+
os.environ["CH_DATABASE"] = DATABASE_NAME
|
|
107
|
+
|
|
108
|
+
# Export cluster name if configured (for ON CLUSTER support)
|
|
109
|
+
if env_config.get("cluster"):
|
|
110
|
+
os.environ["CH_CLUSTER"] = env_config["cluster"]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def bootstrap_version_table(connection: Connection) -> None:
|
|
114
|
+
"""
|
|
115
|
+
Create alembic_version table with ClickHouse-compatible engine.
|
|
116
|
+
|
|
117
|
+
Must be called before Alembic tries to use its version table.
|
|
118
|
+
The database must already exist (created by 'ch-migrate bootstrap').
|
|
119
|
+
"""
|
|
120
|
+
db = DATABASE_NAME
|
|
121
|
+
|
|
122
|
+
# Check if version table exists
|
|
123
|
+
result = connection.execute(
|
|
124
|
+
text(
|
|
125
|
+
f"""
|
|
126
|
+
SELECT count() FROM system.tables
|
|
127
|
+
WHERE database = '{db}' AND name = 'alembic_version'
|
|
128
|
+
"""
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
count = result.scalar()
|
|
132
|
+
exists = count is not None and count > 0
|
|
133
|
+
|
|
134
|
+
if not exists:
|
|
135
|
+
# Create with ClickHouse Cloud compatible engine
|
|
136
|
+
connection.execute(
|
|
137
|
+
text(
|
|
138
|
+
f"""
|
|
139
|
+
CREATE TABLE {db}.alembic_version
|
|
140
|
+
(
|
|
141
|
+
`updated` DateTime DEFAULT now(),
|
|
142
|
+
`version_num` String
|
|
143
|
+
)
|
|
144
|
+
ENGINE = ReplacingMergeTree()
|
|
145
|
+
ORDER BY updated
|
|
146
|
+
SETTINGS index_granularity = 8192
|
|
147
|
+
"""
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
connection.commit()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def run_migrations_offline() -> None:
|
|
154
|
+
"""Run migrations in 'offline' mode - generates SQL without executing."""
|
|
155
|
+
url = get_sqlalchemy_url()
|
|
156
|
+
context.configure(
|
|
157
|
+
url=url,
|
|
158
|
+
target_metadata=None,
|
|
159
|
+
literal_binds=True,
|
|
160
|
+
dialect_opts={"paramstyle": "named"},
|
|
161
|
+
version_table="alembic_version",
|
|
162
|
+
version_table_schema=DATABASE_NAME,
|
|
163
|
+
)
|
|
164
|
+
# No DBAPI interpolates offline output; its percent escaping would corrupt literals.
|
|
165
|
+
context.get_context().dialect.statement_compiler = _OfflineSQLCompiler
|
|
166
|
+
|
|
167
|
+
with context.begin_transaction():
|
|
168
|
+
context.run_migrations()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def run_migrations_online() -> None:
|
|
172
|
+
"""Run migrations in 'online' mode - executes against the database."""
|
|
173
|
+
connectable = create_engine(get_sqlalchemy_url(), poolclass=pool.NullPool)
|
|
174
|
+
db = DATABASE_NAME
|
|
175
|
+
|
|
176
|
+
def _on_version_apply(ctx, step, heads, run_args):
|
|
177
|
+
"""Fire post-migrate hooks after each migration step.
|
|
178
|
+
|
|
179
|
+
on_version_apply fires AFTER each migration step completes,
|
|
180
|
+
so we use it for post-migrate hooks only.
|
|
181
|
+
"""
|
|
182
|
+
revision = step.up_revision if step.is_upgrade else (
|
|
183
|
+
step.down_revisions[0] if step.down_revisions else "unknown"
|
|
184
|
+
)
|
|
185
|
+
if hook_registry.post_migrate:
|
|
186
|
+
run_hooks(
|
|
187
|
+
ctx.connection, hook_registry.post_migrate,
|
|
188
|
+
db=db, phase="post_migrate", revision=revision,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
with connectable.connect() as connection:
|
|
192
|
+
bootstrap_version_table(connection)
|
|
193
|
+
|
|
194
|
+
configure_kwargs = dict(
|
|
195
|
+
connection=connection,
|
|
196
|
+
target_metadata=None,
|
|
197
|
+
version_table="alembic_version",
|
|
198
|
+
version_table_schema=DATABASE_NAME,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if hook_registry.has_hooks:
|
|
202
|
+
configure_kwargs["on_version_apply"] = _on_version_apply
|
|
203
|
+
|
|
204
|
+
context.configure(**configure_kwargs)
|
|
205
|
+
|
|
206
|
+
with context.begin_transaction():
|
|
207
|
+
# Fire pre-migrate hooks before the migration run
|
|
208
|
+
if hook_registry.pre_migrate:
|
|
209
|
+
run_hooks(
|
|
210
|
+
connection, hook_registry.pre_migrate,
|
|
211
|
+
db=db, phase="pre_migrate", revision="all",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
context.run_migrations()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class _OfflineSQLCompiler(ClickHouseSQLCompiler):
|
|
218
|
+
def post_process_text(self, text):
|
|
219
|
+
"""Do not double percent signs in SQL intended for direct execution."""
|
|
220
|
+
return text
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if context.is_offline_mode():
|
|
224
|
+
run_migrations_offline()
|
|
225
|
+
else:
|
|
226
|
+
run_migrations_online()
|
ch_migrate/helpers.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Helper functions for ClickHouse migrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_sql_dir() -> Path:
|
|
12
|
+
"""Get the SQL directory relative to current working directory."""
|
|
13
|
+
return Path.cwd() / "migrations" / "sql"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def read_sql(path: str, **kwargs: Any) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Read a SQL file and substitute placeholders.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
path: Relative path from migrations/sql/ (e.g., "history/tables/users/001_abc.sql")
|
|
22
|
+
**kwargs: Values to substitute (e.g., db="mydb", password="secret")
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
SQL string with placeholders replaced
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
FileNotFoundError: If the SQL file doesn't exist
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
>>> read_sql("history/tables/users/001_abc.sql", db="mydb")
|
|
32
|
+
'CREATE TABLE mydb.users ...'
|
|
33
|
+
"""
|
|
34
|
+
sql_path = _get_sql_dir() / path
|
|
35
|
+
if not sql_path.exists():
|
|
36
|
+
raise FileNotFoundError(f"SQL file not found: {sql_path}")
|
|
37
|
+
|
|
38
|
+
sql = sql_path.read_text()
|
|
39
|
+
return sql.format(**kwargs)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_db() -> str:
|
|
43
|
+
"""
|
|
44
|
+
Get database name from environment.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Database name from CH_DATABASE env var, or "default" if not set
|
|
48
|
+
"""
|
|
49
|
+
return os.environ.get("CH_DATABASE", "default")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_cluster() -> str | None:
|
|
53
|
+
"""
|
|
54
|
+
Get the cluster name from environment.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Cluster name from CH_CLUSTER env var, or None if not set
|
|
58
|
+
"""
|
|
59
|
+
return os.environ.get("CH_CLUSTER") or None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def on_cluster() -> str:
|
|
63
|
+
"""
|
|
64
|
+
Get the ON CLUSTER clause for use in DDL statements.
|
|
65
|
+
|
|
66
|
+
This is an opt-in template variable. Not all DDL supports ON CLUSTER
|
|
67
|
+
equally — dictionary creation, some ALTER operations, and system queries
|
|
68
|
+
have version-dependent ON CLUSTER support. Use this explicitly in
|
|
69
|
+
statements where ON CLUSTER is appropriate.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
"ON CLUSTER cluster_name" if cluster is configured, empty string otherwise
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
>>> read_sql("tables/users.sql", db=get_db(), on_cluster=on_cluster())
|
|
76
|
+
# In SQL: CREATE TABLE {db}.users {on_cluster} (...)
|
|
77
|
+
"""
|
|
78
|
+
cluster = get_cluster()
|
|
79
|
+
if cluster:
|
|
80
|
+
return f"ON CLUSTER {cluster}"
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_config_value(key: str) -> str | None:
|
|
85
|
+
"""
|
|
86
|
+
Get a configuration value from environment.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
key: Environment variable name
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Value or None if not set
|
|
93
|
+
"""
|
|
94
|
+
return os.environ.get(key)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def create_dictionary(path: str, *, db: str | None = None, password: str | None = None) -> None:
|
|
98
|
+
"""
|
|
99
|
+
Create a dictionary with automatic SELECT grant for dict_reader.
|
|
100
|
+
|
|
101
|
+
This helper:
|
|
102
|
+
1. Reads the dictionary SQL file
|
|
103
|
+
2. Parses the source table from the SQL
|
|
104
|
+
3. Grants SELECT on that table to dict_reader
|
|
105
|
+
4. Creates the dictionary
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
path: Relative path to dictionary SQL file
|
|
109
|
+
db: Database name (defaults to get_db())
|
|
110
|
+
password: dict_reader password (defaults to DICT_READER_PASSWORD env var)
|
|
111
|
+
|
|
112
|
+
Example:
|
|
113
|
+
create_dictionary("history/dictionaries/dict_users/001_abc.sql")
|
|
114
|
+
"""
|
|
115
|
+
# Import here to avoid circular imports and allow usage without alembic context
|
|
116
|
+
from alembic import op
|
|
117
|
+
|
|
118
|
+
if db is None:
|
|
119
|
+
db = get_db()
|
|
120
|
+
if password is None:
|
|
121
|
+
password = os.environ.get("DICT_READER_PASSWORD")
|
|
122
|
+
if not password:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
"DICT_READER_PASSWORD environment variable is required for dictionaries."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Read and format the dictionary SQL
|
|
128
|
+
dict_sql = read_sql(path, db=db, password=password)
|
|
129
|
+
|
|
130
|
+
# Parse the source table from the SQL
|
|
131
|
+
source_table = _parse_source_table(dict_sql)
|
|
132
|
+
|
|
133
|
+
if source_table:
|
|
134
|
+
# Grant SELECT to dict_reader before creating dictionary
|
|
135
|
+
op.execute(f"GRANT SELECT ON {db}.{source_table} TO dict_reader")
|
|
136
|
+
|
|
137
|
+
# Create the dictionary
|
|
138
|
+
op.execute(dict_sql)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _parse_source_table(dict_sql: str) -> str | None:
|
|
142
|
+
"""
|
|
143
|
+
Parse the source table name from dictionary SQL.
|
|
144
|
+
|
|
145
|
+
Supports two patterns:
|
|
146
|
+
1. TABLE 'table_name' (simple table source)
|
|
147
|
+
2. QUERY '...FROM db.table_name...' (query source)
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Table name or None if not found
|
|
151
|
+
"""
|
|
152
|
+
# Try TABLE 'table_name' pattern first
|
|
153
|
+
table_match = re.search(r"TABLE\s+'(\w+)'", dict_sql, re.IGNORECASE)
|
|
154
|
+
if table_match:
|
|
155
|
+
return table_match.group(1)
|
|
156
|
+
|
|
157
|
+
# Try QUERY pattern: FROM db.table_name
|
|
158
|
+
query_match = re.search(r"FROM\s+\w+\.(\w+)", dict_sql, re.IGNORECASE)
|
|
159
|
+
if query_match:
|
|
160
|
+
return query_match.group(1)
|
|
161
|
+
|
|
162
|
+
return None
|
ch_migrate/hooks.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Pre/post migration hook support for ch-migrate-cli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import Connection, text
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class HookRegistry:
|
|
16
|
+
"""Registry of pre/post migration hooks loaded from config.yaml.
|
|
17
|
+
|
|
18
|
+
Config format:
|
|
19
|
+
hooks:
|
|
20
|
+
pre_migrate:
|
|
21
|
+
- "SELECT 1"
|
|
22
|
+
post_migrate:
|
|
23
|
+
- "SYSTEM RELOAD DICTIONARY {db}.dict_regions ON CLUSTER default"
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
pre_migrate: list[str] = field(default_factory=list)
|
|
27
|
+
post_migrate: list[str] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_config(cls, hooks_config: dict[str, Any] | None) -> HookRegistry:
|
|
31
|
+
"""Build a HookRegistry from the hooks section of config.yaml."""
|
|
32
|
+
if not hooks_config:
|
|
33
|
+
return cls()
|
|
34
|
+
|
|
35
|
+
pre = hooks_config.get("pre_migrate", [])
|
|
36
|
+
post = hooks_config.get("post_migrate", [])
|
|
37
|
+
|
|
38
|
+
if not isinstance(pre, list):
|
|
39
|
+
pre = [pre] if pre else []
|
|
40
|
+
if not isinstance(post, list):
|
|
41
|
+
post = [post] if post else []
|
|
42
|
+
|
|
43
|
+
return cls(pre_migrate=pre, post_migrate=post)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def has_hooks(self) -> bool:
|
|
47
|
+
return bool(self.pre_migrate or self.post_migrate)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_hooks(
|
|
51
|
+
connection: Connection,
|
|
52
|
+
hooks: list[str],
|
|
53
|
+
*,
|
|
54
|
+
db: str,
|
|
55
|
+
phase: str,
|
|
56
|
+
revision: str,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Execute a list of hook SQL statements.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
connection: SQLAlchemy connection to ClickHouse
|
|
62
|
+
hooks: List of SQL strings (may contain {db} placeholder)
|
|
63
|
+
db: Database name for placeholder resolution
|
|
64
|
+
phase: "pre_migrate" or "post_migrate" (for logging)
|
|
65
|
+
revision: Migration revision being processed (for logging)
|
|
66
|
+
"""
|
|
67
|
+
for i, hook_sql in enumerate(hooks, 1):
|
|
68
|
+
# Use explicit replace instead of str.format() to avoid KeyError on
|
|
69
|
+
# unknown placeholders (e.g. {cluster}) and ClickHouse parameterized
|
|
70
|
+
# query syntax like {param:String}.
|
|
71
|
+
resolved = hook_sql.replace("{db}", db)
|
|
72
|
+
logger.info("[%s] hook %d/%d for %s: %s", phase, i, len(hooks), revision, resolved)
|
|
73
|
+
connection.execute(text(resolved))
|
|
74
|
+
connection.commit()
|