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