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/cli.py
ADDED
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
"""Command-line interface for ch-migrate-cli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
from dotenv import load_dotenv
|
|
17
|
+
|
|
18
|
+
from ch_migrate import ui
|
|
19
|
+
from ch_migrate.authoring import NewOptions
|
|
20
|
+
from ch_migrate.config import get_env_config
|
|
21
|
+
from ch_migrate.runner import alembic_failure, run_alembic, run_migrations
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from ch_migrate.rebase import RevisionGraph
|
|
25
|
+
|
|
26
|
+
# Load .env.local if it exists in the current directory
|
|
27
|
+
_env_local = Path.cwd() / ".env.local"
|
|
28
|
+
if _env_local.exists():
|
|
29
|
+
load_dotenv(_env_local)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_template_path(name: str) -> Path:
|
|
33
|
+
"""Get path to a template file."""
|
|
34
|
+
return Path(__file__).parent / "templates" / "project" / f"{name}.template"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def render_template(template_path: Path, **kwargs: str) -> str:
|
|
38
|
+
"""Render a template with substitutions."""
|
|
39
|
+
content = template_path.read_text()
|
|
40
|
+
for key, value in kwargs.items():
|
|
41
|
+
content = content.replace(f"{{{key}}}", value)
|
|
42
|
+
return content
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _refuse_irreversible_downgrade(environment: str, target: str) -> None:
|
|
46
|
+
from ch_migrate.connection import get_current_heads
|
|
47
|
+
from ch_migrate.downgrade import irreversible_reason, revisions_to_revert
|
|
48
|
+
from ch_migrate.rebase import build_revision_graph
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
env_config = get_env_config(environment, Path.cwd() / "config.yaml")
|
|
52
|
+
heads = get_current_heads(env_config)
|
|
53
|
+
except Exception:
|
|
54
|
+
return # `up`/`down` report configuration and connection errors themselves.
|
|
55
|
+
graph = build_revision_graph(Path.cwd() / "migrations" / "versions")
|
|
56
|
+
revisions = revisions_to_revert(graph, heads, target)
|
|
57
|
+
if revisions is None:
|
|
58
|
+
ui.warn("The downgrade range is unknown; relying on each migration's own refusal.")
|
|
59
|
+
return
|
|
60
|
+
reasons = {rev: irreversible_reason(graph, rev) for rev in revisions}
|
|
61
|
+
if any(reason is not None for reason in reasons.values()):
|
|
62
|
+
_report_irreversible_range(environment, graph, reasons)
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _report_irreversible_range(
|
|
67
|
+
environment: str, graph: RevisionGraph, reasons: dict[str, str | None]
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Show every migration the downgrade would revert, newest first, marking which can't be.
|
|
70
|
+
|
|
71
|
+
Listing the whole range keeps it clear that only the marked migrations are
|
|
72
|
+
irreversible, not everything the downgrade touches.
|
|
73
|
+
"""
|
|
74
|
+
blocked = [rev for rev, reason in reasons.items() if reason is not None]
|
|
75
|
+
if len(reasons) == 1:
|
|
76
|
+
summary = "The migration it would revert is irreversible:"
|
|
77
|
+
else:
|
|
78
|
+
verb = "is" if len(blocked) == 1 else "are"
|
|
79
|
+
summary = f"Of the {len(reasons)} migrations it would revert, {len(blocked)} {verb} irreversible:"
|
|
80
|
+
ui.error(f"Downgrade refused; nothing was run. {summary}")
|
|
81
|
+
for rev, reason in reasons.items():
|
|
82
|
+
name = graph.migrations[rev].description or ""
|
|
83
|
+
marker, status = ("✗", f"irreversible: {reason}") if reason is not None else (" ", "reversible")
|
|
84
|
+
ui.detail(f"{marker} {rev[:8]} {name} ({status})", stderr=True)
|
|
85
|
+
newest_blocked = blocked[0]
|
|
86
|
+
above = list(reasons).index(newest_blocked)
|
|
87
|
+
if above:
|
|
88
|
+
ui.hint(
|
|
89
|
+
f"Run `ch-migrate down {environment} -r {newest_blocked[:8]}` to revert only the "
|
|
90
|
+
f"{_plural(above, 'migration')} above {newest_blocked[:8]}.",
|
|
91
|
+
stderr=True,
|
|
92
|
+
)
|
|
93
|
+
if len(blocked) == 1:
|
|
94
|
+
fix = f"To revert {newest_blocked[:8]}, write its downgrade and remove its irreversible marker"
|
|
95
|
+
else:
|
|
96
|
+
fix = "To revert them, write their downgrades and remove their irreversible markers"
|
|
97
|
+
ui.hint(f"{fix} in a reviewed change.", stderr=True)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _plural(count: int, noun: str) -> str:
|
|
101
|
+
return f"{count} {noun}" if count == 1 else f"{count} {noun}s"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@click.group()
|
|
105
|
+
@click.version_option()
|
|
106
|
+
def main() -> None:
|
|
107
|
+
"""ClickHouse migration tool built on Alembic.
|
|
108
|
+
|
|
109
|
+
ch-migrate provides a unified CLI for managing ClickHouse database migrations.
|
|
110
|
+
It handles project initialization, database bootstrapping, and running migrations.
|
|
111
|
+
|
|
112
|
+
\b
|
|
113
|
+
Quick start:
|
|
114
|
+
ch-migrate init # Initialize a new project
|
|
115
|
+
ch-migrate bootstrap dev # Set up database and users
|
|
116
|
+
ch-migrate up dev # Apply pending migrations
|
|
117
|
+
ch-migrate status dev # Check migration status
|
|
118
|
+
"""
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@main.command()
|
|
123
|
+
@click.argument("path", default=".", type=click.Path())
|
|
124
|
+
@click.option("--name", "-n", default=None, help="Project name (defaults to directory name)")
|
|
125
|
+
def init(path: str, name: str | None) -> None:
|
|
126
|
+
"""Initialize a new ClickHouse migration project.
|
|
127
|
+
|
|
128
|
+
Creates the project structure with config.yaml and migrations directory.
|
|
129
|
+
"""
|
|
130
|
+
project_path = Path(path).resolve()
|
|
131
|
+
|
|
132
|
+
if name is None:
|
|
133
|
+
name = project_path.name
|
|
134
|
+
|
|
135
|
+
# Normalize project name (replace spaces/hyphens with underscores for database names)
|
|
136
|
+
safe_name = name.replace("-", "_").replace(" ", "_").lower()
|
|
137
|
+
|
|
138
|
+
ui.step(f"Creating ClickHouse migration project {name} in {project_path}")
|
|
139
|
+
|
|
140
|
+
# Create directories
|
|
141
|
+
project_path.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
(project_path / "migrations" / "sql" / "bootstrap").mkdir(parents=True, exist_ok=True)
|
|
143
|
+
(project_path / "migrations" / "sql" / "history" / "tables").mkdir(parents=True, exist_ok=True)
|
|
144
|
+
(project_path / "migrations" / "sql" / "history" / "views").mkdir(parents=True, exist_ok=True)
|
|
145
|
+
(project_path / "migrations" / "sql" / "history" / "dictionaries").mkdir(
|
|
146
|
+
parents=True, exist_ok=True
|
|
147
|
+
)
|
|
148
|
+
(project_path / "migrations" / "versions").mkdir(parents=True, exist_ok=True)
|
|
149
|
+
|
|
150
|
+
# Copy/render templates
|
|
151
|
+
templates = [
|
|
152
|
+
("alembic.ini", "alembic.ini"),
|
|
153
|
+
("config.yaml", "config.yaml"),
|
|
154
|
+
("env.local.example", ".env.local.example"),
|
|
155
|
+
("script.py.mako", "migrations/script.py.mako"),
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
for template_name, output_name in templates:
|
|
159
|
+
template_path = get_template_path(template_name)
|
|
160
|
+
output_path = project_path / output_name
|
|
161
|
+
|
|
162
|
+
if output_path.exists():
|
|
163
|
+
ui.detail(f"Skipped {output_name} (already exists)")
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
content = render_template(template_path, project_name=safe_name)
|
|
167
|
+
output_path.write_text(content)
|
|
168
|
+
ui.detail(f"Created {output_name}")
|
|
169
|
+
|
|
170
|
+
# Copy env.py from package
|
|
171
|
+
env_py_src = Path(__file__).parent / "env.py"
|
|
172
|
+
env_py_dst = project_path / "migrations" / "env.py"
|
|
173
|
+
if not env_py_dst.exists():
|
|
174
|
+
shutil.copy(env_py_src, env_py_dst)
|
|
175
|
+
ui.detail("Created migrations/env.py")
|
|
176
|
+
|
|
177
|
+
# Create .gitignore
|
|
178
|
+
gitignore_path = project_path / ".gitignore"
|
|
179
|
+
if not gitignore_path.exists():
|
|
180
|
+
gitignore_path.write_text(".env.local\n__pycache__/\n*.pyc\n")
|
|
181
|
+
ui.detail("Created .gitignore")
|
|
182
|
+
|
|
183
|
+
ui.success("Project created. Next steps:")
|
|
184
|
+
ui.hint(" 1. Point config.yaml at your ClickHouse servers.")
|
|
185
|
+
ui.hint(" 2. Copy .env.local.example to .env.local and add the passwords.")
|
|
186
|
+
ui.hint(" 3. Run `ch-migrate bootstrap dev`.")
|
|
187
|
+
ui.hint(" 4. Run `ch-migrate new dev create_users --table users`, then write the SQL")
|
|
188
|
+
ui.hint(" in the .up.sql and .down.sql files it creates.")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@main.command()
|
|
192
|
+
@click.argument("environment")
|
|
193
|
+
@click.option("--dry-run", is_flag=True, help="Show SQL without executing")
|
|
194
|
+
@click.option("--verbose", "-v", is_flag=True, help="Show SQL statements as they execute")
|
|
195
|
+
def bootstrap(environment: str, dry_run: bool, verbose: bool) -> None:
|
|
196
|
+
"""Initialize database and users for an environment.
|
|
197
|
+
|
|
198
|
+
Creates the database, roles, and users (migration user, optional MCP user,
|
|
199
|
+
optional dict_reader user). Safe to run multiple times (idempotent).
|
|
200
|
+
|
|
201
|
+
Requires admin credentials in .env.local or SSM.
|
|
202
|
+
"""
|
|
203
|
+
from ch_migrate.bootstrap import run_bootstrap
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
run_bootstrap(environment, dry_run=dry_run, verbose=verbose)
|
|
207
|
+
except Exception as e:
|
|
208
|
+
ui.fail(f"Bootstrap failed: {e}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@main.command()
|
|
212
|
+
@click.argument("environment")
|
|
213
|
+
@click.option("--revision", "-r", default="head", help="Revision to upgrade to (default: head)")
|
|
214
|
+
@click.option(
|
|
215
|
+
"--skip-mv-check",
|
|
216
|
+
is_flag=True,
|
|
217
|
+
help="Skip materialized view declaration validation",
|
|
218
|
+
)
|
|
219
|
+
@click.option("--verbose", is_flag=True, help="Show the full traceback if a migration fails")
|
|
220
|
+
def up(environment: str, revision: str, skip_mv_check: bool, verbose: bool) -> None:
|
|
221
|
+
"""Apply pending migrations.
|
|
222
|
+
|
|
223
|
+
Runs all unapplied migrations to bring the database to the latest version.
|
|
224
|
+
Use --revision to upgrade to a specific revision instead of head.
|
|
225
|
+
|
|
226
|
+
Validates that migrations creating MATERIALIZED VIEWs include proper
|
|
227
|
+
MV_DECLARATIONS and companion grants. Use --skip-mv-check to bypass.
|
|
228
|
+
"""
|
|
229
|
+
if not skip_mv_check:
|
|
230
|
+
_check_mv_declarations()
|
|
231
|
+
sys.exit(run_migrations(environment, ["upgrade", revision], verbose=verbose))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@main.command()
|
|
235
|
+
@click.argument("environment")
|
|
236
|
+
@click.option("--revision", "-r", default="-1", help="Revision to downgrade to (default: -1)")
|
|
237
|
+
@click.option("--verbose", is_flag=True, help="Show the full traceback if a migration fails")
|
|
238
|
+
def down(environment: str, revision: str, verbose: bool) -> None:
|
|
239
|
+
"""Rollback migrations.
|
|
240
|
+
|
|
241
|
+
By default, rolls back the last migration. Use --revision to specify a target.
|
|
242
|
+
"""
|
|
243
|
+
_refuse_irreversible_downgrade(environment, revision)
|
|
244
|
+
sys.exit(run_migrations(environment, ["downgrade", revision], verbose=verbose))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@main.command()
|
|
248
|
+
@click.argument("environment")
|
|
249
|
+
def status(environment: str) -> None:
|
|
250
|
+
"""Show migration status.
|
|
251
|
+
|
|
252
|
+
Displays environment info, applied/pending counts, and head status.
|
|
253
|
+
Exits 1 if the database cannot be reached.
|
|
254
|
+
"""
|
|
255
|
+
from ch_migrate.display import render_status
|
|
256
|
+
|
|
257
|
+
state = _load_migration_state(environment)
|
|
258
|
+
render_status(environment, state.env_config, state.graph, state.applied, db_error=state.db_error)
|
|
259
|
+
if state.db_error:
|
|
260
|
+
ui.fail(f"Could not reach the database: {state.db_error.strip().splitlines()[0]}")
|
|
261
|
+
pending = len(set(state.graph.migrations) - (state.applied or set()))
|
|
262
|
+
if pending:
|
|
263
|
+
noun = "migration" if pending == 1 else "migrations"
|
|
264
|
+
ui.hint(f"Run `ch-migrate up {environment}` to apply {pending} pending {noun}.")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@main.command()
|
|
268
|
+
@click.argument("environment")
|
|
269
|
+
def history(environment: str) -> None:
|
|
270
|
+
"""Show migration history.
|
|
271
|
+
|
|
272
|
+
Displays a tree of all migrations, color-coded by applied status.
|
|
273
|
+
"""
|
|
274
|
+
from ch_migrate.display import render_history
|
|
275
|
+
|
|
276
|
+
state = _load_migration_state(environment)
|
|
277
|
+
render_history(state.graph, state.applied, db_error=state.db_error)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass(frozen=True)
|
|
281
|
+
class _MigrationState:
|
|
282
|
+
env_config: dict[str, Any]
|
|
283
|
+
graph: RevisionGraph
|
|
284
|
+
applied: set[str] | None # None when the database could not be read
|
|
285
|
+
db_error: str | None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _load_migration_state(environment: str) -> _MigrationState:
|
|
289
|
+
"""Local revision graph plus what the database says is applied."""
|
|
290
|
+
from ch_migrate.connection import get_current_heads
|
|
291
|
+
from ch_migrate.rebase import build_revision_graph
|
|
292
|
+
|
|
293
|
+
env_config = _env_config_or_fail(environment)
|
|
294
|
+
graph = build_revision_graph(_versions_dir_or_fail())
|
|
295
|
+
try:
|
|
296
|
+
heads = get_current_heads(env_config)
|
|
297
|
+
except Exception as e:
|
|
298
|
+
return _MigrationState(env_config, graph, None, str(e))
|
|
299
|
+
applied: set[str] = set()
|
|
300
|
+
unknown = [head for head in heads if head not in graph.migrations]
|
|
301
|
+
for head in heads:
|
|
302
|
+
if head in graph.migrations:
|
|
303
|
+
applied.update(graph.walk_to_root(head))
|
|
304
|
+
for head in unknown:
|
|
305
|
+
ui.warn(f"The database is at {head[:12]}, which is not in your local migration files.")
|
|
306
|
+
if unknown:
|
|
307
|
+
ui.warn("Applied status may be incomplete; pull the missing revisions.")
|
|
308
|
+
return _MigrationState(env_config, graph, applied, None)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _env_config_or_fail(environment: str) -> dict[str, Any]:
|
|
312
|
+
try:
|
|
313
|
+
return get_env_config(environment, Path.cwd() / "config.yaml")
|
|
314
|
+
except Exception as e:
|
|
315
|
+
ui.fail(f"Could not load config: {e}")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _versions_dir_or_fail() -> Path:
|
|
319
|
+
versions_dir = Path.cwd() / "migrations" / "versions"
|
|
320
|
+
if not versions_dir.exists():
|
|
321
|
+
ui.fail("migrations/versions/ not found.", "Run `ch-migrate init` to create a project.")
|
|
322
|
+
return versions_dir
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _check_mv_declarations() -> None:
|
|
326
|
+
"""Refuse `up` when a materialized-view migration lacks its declarations."""
|
|
327
|
+
from ch_migrate.config import load_config
|
|
328
|
+
from ch_migrate.lint import LintConfig
|
|
329
|
+
from ch_migrate.mv_validate import validate_mv_migrations
|
|
330
|
+
|
|
331
|
+
versions_dir = Path.cwd() / "migrations" / "versions"
|
|
332
|
+
if not versions_dir.exists():
|
|
333
|
+
return
|
|
334
|
+
cutoff = None
|
|
335
|
+
config_path = Path.cwd() / "config.yaml"
|
|
336
|
+
if config_path.exists():
|
|
337
|
+
try:
|
|
338
|
+
cutoff = LintConfig.from_config(load_config(config_path)).mv_validation_cutoff
|
|
339
|
+
except Exception as e:
|
|
340
|
+
ui.warn(f"Could not load lint config: {e}")
|
|
341
|
+
mv_errors = validate_mv_migrations(versions_dir, cutoff_date=cutoff)
|
|
342
|
+
if not mv_errors:
|
|
343
|
+
return
|
|
344
|
+
ui.error("Materialized view declarations are incomplete; nothing was run.")
|
|
345
|
+
for error in mv_errors:
|
|
346
|
+
where = f"{error.file} ({error.mv_name})" if error.mv_name else error.file
|
|
347
|
+
ui.detail(f"{where}: {error.message}", stderr=True)
|
|
348
|
+
ui.hint("Fix these, or pass `--skip-mv-check` to run anyway.", stderr=True)
|
|
349
|
+
sys.exit(1)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@main.command()
|
|
353
|
+
@click.argument("environment")
|
|
354
|
+
@click.argument("name")
|
|
355
|
+
@click.option("--table", "-t", "table_name", help="Create SQL file for table (e.g., --table users)")
|
|
356
|
+
@click.option(
|
|
357
|
+
"--view", "-v", "view_name", help="Create SQL file for view (e.g., --view active_users)"
|
|
358
|
+
)
|
|
359
|
+
@click.option(
|
|
360
|
+
"--dict", "-d", "dict_name", help="Create SQL file for dictionary (e.g., --dict regions)"
|
|
361
|
+
)
|
|
362
|
+
@click.option(
|
|
363
|
+
"--exchange", is_flag=True, help="Generate EXCHANGE TABLES scaffold (requires --table)"
|
|
364
|
+
)
|
|
365
|
+
@click.option(
|
|
366
|
+
"--python", "python_migration", is_flag=True, help="Keep the Python migration template"
|
|
367
|
+
)
|
|
368
|
+
@click.option(
|
|
369
|
+
"--irreversible",
|
|
370
|
+
"irreversible_reason",
|
|
371
|
+
metavar="REASON",
|
|
372
|
+
help="Write only upgrade SQL and refuse downgrades, with this reason",
|
|
373
|
+
)
|
|
374
|
+
def new(
|
|
375
|
+
environment: str,
|
|
376
|
+
name: str,
|
|
377
|
+
table_name: str | None,
|
|
378
|
+
view_name: str | None,
|
|
379
|
+
dict_name: str | None,
|
|
380
|
+
exchange: bool,
|
|
381
|
+
python_migration: bool,
|
|
382
|
+
irreversible_reason: str | None,
|
|
383
|
+
) -> None:
|
|
384
|
+
"""Create upgrade and downgrade SQL files, plus the revision that runs them.
|
|
385
|
+
|
|
386
|
+
Name an object with --table, --view or --dict to group its SQL history.
|
|
387
|
+
Use --irreversible REASON when a change cannot restore dropped data.
|
|
388
|
+
--python keeps the Python template; --exchange still requires --table.
|
|
389
|
+
"""
|
|
390
|
+
options = NewOptions(
|
|
391
|
+
table_name, view_name, dict_name, exchange, python_migration, irreversible_reason
|
|
392
|
+
)
|
|
393
|
+
_check_new_options(options)
|
|
394
|
+
result = run_alembic(environment, ["revision", "-m", name])
|
|
395
|
+
if result.returncode != 0:
|
|
396
|
+
ui.fail(f"Could not create the revision: {alembic_failure(result)}")
|
|
397
|
+
migration_path = _find_migration_file(result.stdout)
|
|
398
|
+
if migration_path is None:
|
|
399
|
+
ui.fail("Could not find the revision file Alembic generated.")
|
|
400
|
+
if exchange:
|
|
401
|
+
revision = _extract_revision_from_output(result.stdout) or ""
|
|
402
|
+
_create_exchange_scaffold(environment, table_name or "", revision, result.stdout)
|
|
403
|
+
elif python_migration:
|
|
404
|
+
_create_python_migration(migration_path, options)
|
|
405
|
+
else:
|
|
406
|
+
_create_sql_first_migration(migration_path, options)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _check_new_options(options: NewOptions) -> None:
|
|
410
|
+
problems = []
|
|
411
|
+
if len(options.named_objects()) > 1:
|
|
412
|
+
problems.append("use only one of --table, --view and --dict")
|
|
413
|
+
if options.exchange and not options.table_name:
|
|
414
|
+
problems.append("--exchange requires --table")
|
|
415
|
+
if options.exchange and options.python_migration:
|
|
416
|
+
problems.append("--exchange cannot be combined with --python")
|
|
417
|
+
if options.irreversible_reason is not None:
|
|
418
|
+
if options.exchange or options.python_migration:
|
|
419
|
+
problems.append("--irreversible cannot be combined with --python or --exchange")
|
|
420
|
+
if not options.irreversible_reason.strip():
|
|
421
|
+
problems.append("--irreversible needs a non-empty reason")
|
|
422
|
+
for problem in problems:
|
|
423
|
+
ui.error(problem)
|
|
424
|
+
if problems:
|
|
425
|
+
sys.exit(1)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _create_sql_first_migration(migration_path: Path, options: NewOptions) -> None:
|
|
429
|
+
from ch_migrate.authoring import read_revision_header, render_revision, write_sql_files
|
|
430
|
+
|
|
431
|
+
header = read_revision_header(migration_path)
|
|
432
|
+
files = write_sql_files(Path.cwd() / "migrations" / "sql", header, options)
|
|
433
|
+
migration_path.write_text(render_revision(header, files, options.irreversible_reason))
|
|
434
|
+
ui.success(f"Created migration {header.revision[:8]} {header.message}")
|
|
435
|
+
ui.detail(f"migrations/sql/{files.upgrade}")
|
|
436
|
+
if files.downgrade:
|
|
437
|
+
ui.detail(f"migrations/sql/{files.downgrade}")
|
|
438
|
+
ui.hint("Write the SQL in these files; the revision needs no edits.")
|
|
439
|
+
else:
|
|
440
|
+
ui.hint("Write the SQL in this file; the revision needs no edits.")
|
|
441
|
+
ui.hint("It is marked irreversible, so `ch-migrate down` will refuse to revert it.")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _create_python_migration(migration_path: Path, options: NewOptions) -> None:
|
|
445
|
+
from ch_migrate.authoring import read_revision_header
|
|
446
|
+
|
|
447
|
+
header = read_revision_header(migration_path)
|
|
448
|
+
ui.success(f"Created migration {header.revision[:8]} {header.message}")
|
|
449
|
+
ui.detail(str(migration_path.relative_to(Path.cwd())))
|
|
450
|
+
named = options.named_objects()
|
|
451
|
+
if named:
|
|
452
|
+
object_type, object_name = named[0]
|
|
453
|
+
sql_path = _create_sql_file(object_name, object_type, header.revision)
|
|
454
|
+
if sql_path:
|
|
455
|
+
ui.detail(str(sql_path.relative_to(Path.cwd())))
|
|
456
|
+
ui.hint("Write upgrade() and downgrade() in the revision file.")
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _extract_revision_from_output(stdout: str) -> str | None:
|
|
460
|
+
"""Extract revision ID from alembic output by reading the generated file."""
|
|
461
|
+
# Find the generated file path from output
|
|
462
|
+
# Format: "Generating /path/to/migrations/versions/<filename>.py ... done"
|
|
463
|
+
# Note: Terminal wrapping may insert newlines/spaces in path
|
|
464
|
+
match = re.search(r"Generating (.+?\.py)", stdout, re.DOTALL)
|
|
465
|
+
if not match:
|
|
466
|
+
return None
|
|
467
|
+
|
|
468
|
+
# Clean up newline+spaces inserted by terminal wrapping (preserves intentional spaces)
|
|
469
|
+
file_path = re.sub(r"\n\s*", "", match.group(1))
|
|
470
|
+
migration_file = Path(file_path)
|
|
471
|
+
if not migration_file.exists():
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
# Parse revision from file content
|
|
475
|
+
content = migration_file.read_text()
|
|
476
|
+
rev_match = re.search(r'revision = ["\'](\w+)["\']', content)
|
|
477
|
+
return rev_match.group(1) if rev_match else None
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _create_sql_file(name: str, object_type: str, revision: str) -> Path | None:
|
|
481
|
+
"""Create SQL history file for a migration.
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
name: Object name (e.g., "users")
|
|
485
|
+
object_type: One of "table", "view", "dictionary"
|
|
486
|
+
revision: Alembic revision ID
|
|
487
|
+
|
|
488
|
+
Returns:
|
|
489
|
+
Path to created file, or None if failed
|
|
490
|
+
"""
|
|
491
|
+
# Determine directory (tables, views, dictionaries)
|
|
492
|
+
type_dir = f"{object_type}s" if object_type != "dictionary" else "dictionaries"
|
|
493
|
+
sql_dir = Path.cwd() / "migrations" / "sql" / "history" / type_dir / name
|
|
494
|
+
sql_dir.mkdir(parents=True, exist_ok=True)
|
|
495
|
+
|
|
496
|
+
# Use datetime prefix for ordering (matches alembic's file_template format)
|
|
497
|
+
now = datetime.now()
|
|
498
|
+
date_prefix = now.strftime("%Y_%m_%d_%H%M")
|
|
499
|
+
|
|
500
|
+
# Create SQL file with minimal header
|
|
501
|
+
sql_file = sql_dir / f"{date_prefix}_{revision}.sql"
|
|
502
|
+
template = f"""-- {name} {object_type}
|
|
503
|
+
-- Migration: {revision}
|
|
504
|
+
-- Created: {now.strftime("%Y-%m-%d %H:%M")}
|
|
505
|
+
|
|
506
|
+
"""
|
|
507
|
+
sql_file.write_text(template)
|
|
508
|
+
return sql_file
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _create_exchange_scaffold(
|
|
512
|
+
environment: str, table_name: str, revision: str, alembic_stdout: str
|
|
513
|
+
) -> None:
|
|
514
|
+
"""Create EXCHANGE TABLES migration scaffold.
|
|
515
|
+
|
|
516
|
+
Rewrites the alembic-generated migration with the EXCHANGE pattern
|
|
517
|
+
and creates a SQL history file for the shadow table.
|
|
518
|
+
"""
|
|
519
|
+
from ch_migrate.scaffold import (
|
|
520
|
+
fetch_current_ddl,
|
|
521
|
+
find_dependent_dictionaries,
|
|
522
|
+
generate_exchange_sql,
|
|
523
|
+
rewrite_migration_file,
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
config_path = Path.cwd() / "config.yaml"
|
|
527
|
+
|
|
528
|
+
# Try to connect to live DB for current DDL and dict detection
|
|
529
|
+
current_ddl: str | None = None
|
|
530
|
+
dict_names: list[str] = []
|
|
531
|
+
try:
|
|
532
|
+
env_config = get_env_config(environment, config_path)
|
|
533
|
+
current_ddl = fetch_current_ddl(env_config, table_name)
|
|
534
|
+
if current_ddl:
|
|
535
|
+
ui.detail(f"Fetched the current DDL for {table_name}")
|
|
536
|
+
dict_names = find_dependent_dictionaries(env_config, table_name)
|
|
537
|
+
if dict_names:
|
|
538
|
+
ui.detail(f"Found dependent dictionaries: {', '.join(dict_names)}")
|
|
539
|
+
except Exception:
|
|
540
|
+
ui.warn("Could not connect to the database; the scaffold uses placeholder DDL.")
|
|
541
|
+
|
|
542
|
+
# Create SQL history file with shadow table DDL
|
|
543
|
+
sql_content = generate_exchange_sql(table_name, current_ddl)
|
|
544
|
+
sql_path = _create_sql_file(table_name, "table", revision)
|
|
545
|
+
if sql_path:
|
|
546
|
+
sql_path.write_text(sql_content)
|
|
547
|
+
|
|
548
|
+
# Rewrite the migration .py with EXCHANGE pattern
|
|
549
|
+
migration_path = _find_migration_file(alembic_stdout)
|
|
550
|
+
if migration_path:
|
|
551
|
+
rel_sql = str(sql_path.relative_to(Path.cwd() / "migrations" / "sql"))
|
|
552
|
+
rewrite_migration_file(migration_path, table_name, rel_sql, dict_names or None)
|
|
553
|
+
ui.success(f"Created EXCHANGE TABLES migration {revision[:8]} for {table_name}")
|
|
554
|
+
ui.detail(str(migration_path.relative_to(Path.cwd())))
|
|
555
|
+
ui.detail(str(sql_path.relative_to(Path.cwd())))
|
|
556
|
+
ui.hint("Edit the shadow table's CREATE statement in the SQL file.")
|
|
557
|
+
else:
|
|
558
|
+
ui.warn("Could not locate the migration file to rewrite.")
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _find_migration_file(alembic_stdout: str) -> Path | None:
|
|
562
|
+
"""Find the migration .py file path from alembic output."""
|
|
563
|
+
match = re.search(r"Generating (.+?\.py)", alembic_stdout, re.DOTALL)
|
|
564
|
+
if not match:
|
|
565
|
+
return None
|
|
566
|
+
file_path = re.sub(r"\n\s*", "", match.group(1))
|
|
567
|
+
path = Path(file_path)
|
|
568
|
+
return path if path.exists() else None
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
@main.command()
|
|
572
|
+
@click.argument("environment")
|
|
573
|
+
@click.option("--onto", default=None, help="Target revision to rebase onto (skips auto-detection)")
|
|
574
|
+
@click.option("--dry-run", is_flag=True, help="Show planned changes without applying")
|
|
575
|
+
def rebase(environment: str, onto: str | None, dry_run: bool) -> None:
|
|
576
|
+
"""Rebase dangling migration branches onto the deployed head.
|
|
577
|
+
|
|
578
|
+
Finds migrations that branch off an older revision and rewrites them
|
|
579
|
+
to branch off the current deployed head instead.
|
|
580
|
+
|
|
581
|
+
\b
|
|
582
|
+
Guided mode (detects deployed head automatically):
|
|
583
|
+
ch-migrate rebase dev
|
|
584
|
+
|
|
585
|
+
\b
|
|
586
|
+
Explicit mode (specify target revision):
|
|
587
|
+
ch-migrate rebase dev --onto abc123
|
|
588
|
+
"""
|
|
589
|
+
from ch_migrate.rebase import apply_rebase, plan_rebase
|
|
590
|
+
|
|
591
|
+
versions_dir = _versions_dir_or_fail()
|
|
592
|
+
|
|
593
|
+
# Check for uncommitted changes to migration files
|
|
594
|
+
result = subprocess.run(
|
|
595
|
+
["git", "diff", "--name-only", "migrations/versions/"],
|
|
596
|
+
capture_output=True,
|
|
597
|
+
text=True,
|
|
598
|
+
cwd=Path.cwd(),
|
|
599
|
+
)
|
|
600
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
601
|
+
ui.fail("Migration files have uncommitted changes.", "Commit or stash them first.")
|
|
602
|
+
|
|
603
|
+
if onto is None:
|
|
604
|
+
onto = _deployed_head(environment)
|
|
605
|
+
|
|
606
|
+
try:
|
|
607
|
+
changes = plan_rebase(versions_dir, onto)
|
|
608
|
+
except ValueError as e:
|
|
609
|
+
ui.hint(str(e))
|
|
610
|
+
sys.exit(0)
|
|
611
|
+
|
|
612
|
+
if not changes:
|
|
613
|
+
ui.success("All branches already point to the target revision.")
|
|
614
|
+
sys.exit(0)
|
|
615
|
+
|
|
616
|
+
ui.step(f"Rebasing onto {onto}:")
|
|
617
|
+
for change in changes:
|
|
618
|
+
ui.detail(change.migration.path.name)
|
|
619
|
+
ui.detail(f" down_revision: {change.old_down_revision} -> {change.new_down_revision}")
|
|
620
|
+
|
|
621
|
+
if dry_run:
|
|
622
|
+
ui.hint("Dry run; no changes made.")
|
|
623
|
+
sys.exit(0)
|
|
624
|
+
|
|
625
|
+
if not click.confirm("Apply these changes?"):
|
|
626
|
+
ui.hint("Aborted.")
|
|
627
|
+
sys.exit(0)
|
|
628
|
+
|
|
629
|
+
apply_rebase(changes)
|
|
630
|
+
ui.success("Rebase complete.")
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _deployed_head(environment: str) -> str:
|
|
634
|
+
"""The revision `alembic current` reports for this environment."""
|
|
635
|
+
explicit = "Use `--onto REVISION` to name the target explicitly."
|
|
636
|
+
result = run_alembic(environment, ["current"])
|
|
637
|
+
if result.returncode != 0:
|
|
638
|
+
ui.fail(f"Could not read the current revision: {alembic_failure(result)}", explicit)
|
|
639
|
+
# Format: "abc123 (head)" or "abc123"
|
|
640
|
+
current = re.search(r"(\w{4,})", result.stdout)
|
|
641
|
+
if current is None:
|
|
642
|
+
ui.fail("Could not parse the current revision from Alembic's output.", explicit)
|
|
643
|
+
return current.group(1)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@main.command()
|
|
647
|
+
@click.option(
|
|
648
|
+
"--user",
|
|
649
|
+
"target",
|
|
650
|
+
flag_value="user",
|
|
651
|
+
default=True,
|
|
652
|
+
help="Install to ~/.claude/skills/ (default)",
|
|
653
|
+
)
|
|
654
|
+
@click.option("--project", "target", flag_value="project", help="Install to ./.claude/skills/")
|
|
655
|
+
def skill(target: str) -> None:
|
|
656
|
+
"""Install the ch-migrate Claude skill.
|
|
657
|
+
|
|
658
|
+
Copies the skill file to help Claude assist with ch-migrate integration.
|
|
659
|
+
|
|
660
|
+
\b
|
|
661
|
+
Locations:
|
|
662
|
+
--user ~/.claude/skills/ch-migrate/ (default, for all projects)
|
|
663
|
+
--project ./.claude/skills/ch-migrate/ (current project only)
|
|
664
|
+
"""
|
|
665
|
+
# Find the skill bundled with this package
|
|
666
|
+
skill_src = Path(__file__).parent / "skills" / "ch-migrate" / "SKILL.md"
|
|
667
|
+
|
|
668
|
+
if not skill_src.exists():
|
|
669
|
+
ui.fail(f"Skill file not found at {skill_src}")
|
|
670
|
+
|
|
671
|
+
# Determine destination
|
|
672
|
+
if target == "user":
|
|
673
|
+
skill_dir = Path.home() / ".claude" / "skills" / "ch-migrate"
|
|
674
|
+
else:
|
|
675
|
+
skill_dir = Path.cwd() / ".claude" / "skills" / "ch-migrate"
|
|
676
|
+
|
|
677
|
+
skill_dst = skill_dir / "SKILL.md"
|
|
678
|
+
|
|
679
|
+
# Create directory and copy
|
|
680
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
681
|
+
|
|
682
|
+
if skill_dst.exists():
|
|
683
|
+
ui.warn(f"A skill already exists at {skill_dst}")
|
|
684
|
+
if not click.confirm("Overwrite?"):
|
|
685
|
+
ui.hint("Aborted.")
|
|
686
|
+
return
|
|
687
|
+
|
|
688
|
+
shutil.copy(skill_src, skill_dst)
|
|
689
|
+
ui.success(f"Installed the skill to {skill_dst}")
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
@main.command()
|
|
693
|
+
@click.argument("environment", required=False, default=None)
|
|
694
|
+
def lint(environment: str | None) -> None:
|
|
695
|
+
"""Lint upgrade statements with their source file and line.
|
|
696
|
+
|
|
697
|
+
Without an environment, checks every local revision statically without
|
|
698
|
+
credentials or a database connection.
|
|
699
|
+
|
|
700
|
+
With an environment, checks only pending revisions and adds live size and
|
|
701
|
+
dependency checks. Fails if the pending scope cannot be determined.
|
|
702
|
+
|
|
703
|
+
\b
|
|
704
|
+
Examples:
|
|
705
|
+
ch-migrate lint # Static only (CI-friendly)
|
|
706
|
+
ch-migrate lint dev # Static + runtime (needs DB)
|
|
707
|
+
"""
|
|
708
|
+
from ch_migrate.config import load_config
|
|
709
|
+
from ch_migrate.display import render_lint_report
|
|
710
|
+
from ch_migrate.lint import LintConfig, lint_migrations
|
|
711
|
+
from ch_migrate.rebase import build_revision_graph
|
|
712
|
+
from ch_migrate.statements import pending_revisions
|
|
713
|
+
|
|
714
|
+
versions_dir = _versions_dir_or_fail()
|
|
715
|
+
|
|
716
|
+
config_path = Path.cwd() / "config.yaml"
|
|
717
|
+
lint_config = LintConfig()
|
|
718
|
+
if config_path.exists():
|
|
719
|
+
try:
|
|
720
|
+
raw_config = load_config(config_path)
|
|
721
|
+
lint_config = LintConfig.from_config(raw_config)
|
|
722
|
+
except Exception:
|
|
723
|
+
pass
|
|
724
|
+
|
|
725
|
+
client = None
|
|
726
|
+
database = None
|
|
727
|
+
revisions = None
|
|
728
|
+
|
|
729
|
+
if environment:
|
|
730
|
+
try:
|
|
731
|
+
env_config = get_env_config(environment, config_path)
|
|
732
|
+
database = env_config["database"]
|
|
733
|
+
|
|
734
|
+
from ch_migrate.connection import get_client, get_current_heads
|
|
735
|
+
|
|
736
|
+
revisions = pending_revisions(
|
|
737
|
+
build_revision_graph(versions_dir), get_current_heads(env_config)
|
|
738
|
+
)
|
|
739
|
+
client = get_client(env_config)
|
|
740
|
+
except Exception as e:
|
|
741
|
+
ui.fail(f"Could not work out the pending revisions for {environment}: {e}")
|
|
742
|
+
|
|
743
|
+
try:
|
|
744
|
+
report = lint_migrations(
|
|
745
|
+
versions_dir,
|
|
746
|
+
config=lint_config,
|
|
747
|
+
client=client,
|
|
748
|
+
database=database,
|
|
749
|
+
revisions=revisions,
|
|
750
|
+
)
|
|
751
|
+
except (OSError, SyntaxError, ValueError) as e:
|
|
752
|
+
ui.fail(str(e))
|
|
753
|
+
finally:
|
|
754
|
+
if client is not None:
|
|
755
|
+
client.close()
|
|
756
|
+
|
|
757
|
+
render_lint_report(report, runtime=environment is not None)
|
|
758
|
+
|
|
759
|
+
sys.exit(1 if report.has_errors else 0)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
@main.command()
|
|
763
|
+
@click.argument("environment")
|
|
764
|
+
@click.option("--validate", "-v", "validate_sql", type=click.Path(exists=True),
|
|
765
|
+
help="Validate a SQL file against the dependency graph")
|
|
766
|
+
def deps(environment: str, validate_sql: str | None) -> None:
|
|
767
|
+
"""Show materialized view and dictionary dependency graph.
|
|
768
|
+
|
|
769
|
+
Queries the live database to build a dependency graph of all tables,
|
|
770
|
+
views, materialized views, and dictionaries, then renders it as a tree.
|
|
771
|
+
|
|
772
|
+
Use --validate to check if a SQL file would break any dependencies.
|
|
773
|
+
|
|
774
|
+
\b
|
|
775
|
+
Examples:
|
|
776
|
+
ch-migrate deps dev
|
|
777
|
+
ch-migrate deps dev --validate migrations/sql/history/tables/users/drop.sql
|
|
778
|
+
"""
|
|
779
|
+
from ch_migrate.connection import get_client
|
|
780
|
+
from ch_migrate.deps import build_dependency_graph, validate_migration
|
|
781
|
+
from ch_migrate.display import render_dependency_tree
|
|
782
|
+
|
|
783
|
+
env_config = _env_config_or_fail(environment)
|
|
784
|
+
database = env_config["database"]
|
|
785
|
+
|
|
786
|
+
try:
|
|
787
|
+
client = get_client(env_config)
|
|
788
|
+
except Exception as e:
|
|
789
|
+
ui.fail(f"Could not connect to {environment}: {e}")
|
|
790
|
+
|
|
791
|
+
ui.step(f"Reading dependencies in {environment} ({database})")
|
|
792
|
+
|
|
793
|
+
try:
|
|
794
|
+
graph = build_dependency_graph(client, database)
|
|
795
|
+
except Exception as e:
|
|
796
|
+
ui.fail(f"Could not build the dependency graph: {e}")
|
|
797
|
+
|
|
798
|
+
render_dependency_tree(graph)
|
|
799
|
+
|
|
800
|
+
if validate_sql:
|
|
801
|
+
sql_content = Path(validate_sql).read_text()
|
|
802
|
+
warnings = validate_migration(sql_content, graph)
|
|
803
|
+
for w in warnings:
|
|
804
|
+
(ui.error if w.severity == "error" else ui.warn)(w.message)
|
|
805
|
+
if warnings:
|
|
806
|
+
sys.exit(1)
|
|
807
|
+
ui.success(f"Validation passed: {validate_sql} keeps every dependency intact.")
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
@main.command(name="diff")
|
|
811
|
+
@click.argument("environment")
|
|
812
|
+
@click.option(
|
|
813
|
+
"--snapshot-dir",
|
|
814
|
+
"-s",
|
|
815
|
+
type=click.Path(exists=True),
|
|
816
|
+
help="Path to a snapshot directory to compare against. Defaults to latest snapshot.",
|
|
817
|
+
)
|
|
818
|
+
def diff_cmd(environment: str, snapshot_dir: str | None) -> None:
|
|
819
|
+
"""Detect schema drift between local snapshot and live database.
|
|
820
|
+
|
|
821
|
+
Compares the most recent snapshot (or a specified one) against the live
|
|
822
|
+
database schema. Exit code 0 if in sync, 1 if drift detected.
|
|
823
|
+
|
|
824
|
+
\b
|
|
825
|
+
Examples:
|
|
826
|
+
ch-migrate diff dev
|
|
827
|
+
ch-migrate diff dev --snapshot-dir migrations/sql/snapshots/20260305_120000
|
|
828
|
+
"""
|
|
829
|
+
from ch_migrate.connection import get_client
|
|
830
|
+
from ch_migrate.diff import DiffStatus, compare_schemas
|
|
831
|
+
from ch_migrate.display import render_diff_report
|
|
832
|
+
from ch_migrate.introspect import (
|
|
833
|
+
VERSION_TABLE,
|
|
834
|
+
Schema,
|
|
835
|
+
get_live_schema,
|
|
836
|
+
parse_create_statement,
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
env_config = _env_config_or_fail(environment)
|
|
840
|
+
database = env_config["database"]
|
|
841
|
+
|
|
842
|
+
# Resolve snapshot directory
|
|
843
|
+
if snapshot_dir:
|
|
844
|
+
snap_path = Path(snapshot_dir)
|
|
845
|
+
else:
|
|
846
|
+
snapshots_base = Path.cwd() / "migrations" / "sql" / "snapshots"
|
|
847
|
+
dirs = sorted(snapshots_base.iterdir()) if snapshots_base.exists() else []
|
|
848
|
+
if not dirs:
|
|
849
|
+
ui.fail("No snapshots found.", f"Run `ch-migrate snapshot {environment}` first.")
|
|
850
|
+
snap_path = dirs[-1]
|
|
851
|
+
|
|
852
|
+
ui.step(f"Comparing snapshot {snap_path.name} with {environment} ({database})")
|
|
853
|
+
|
|
854
|
+
# Load local schema from snapshot files
|
|
855
|
+
local_schema = Schema(database=database)
|
|
856
|
+
type_dirs = {
|
|
857
|
+
"tables": "table",
|
|
858
|
+
"views": "view",
|
|
859
|
+
"materialized_views": "materialized_view",
|
|
860
|
+
"dictionaries": "dictionary",
|
|
861
|
+
}
|
|
862
|
+
schema_attrs = {
|
|
863
|
+
"table": local_schema.tables,
|
|
864
|
+
"view": local_schema.views,
|
|
865
|
+
"materialized_view": local_schema.materialized_views,
|
|
866
|
+
"dictionary": local_schema.dictionaries,
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
for dir_name, obj_type in type_dirs.items():
|
|
870
|
+
type_path = snap_path / dir_name
|
|
871
|
+
if not type_path.exists():
|
|
872
|
+
continue
|
|
873
|
+
for sql_file in sorted(type_path.glob("*.sql")):
|
|
874
|
+
name = sql_file.stem
|
|
875
|
+
if name == VERSION_TABLE:
|
|
876
|
+
continue # older snapshots captured Alembic's own table
|
|
877
|
+
ddl = sql_file.read_text()
|
|
878
|
+
parsed = parse_create_statement(ddl)
|
|
879
|
+
if parsed:
|
|
880
|
+
schema_attrs[obj_type][name] = parsed
|
|
881
|
+
else:
|
|
882
|
+
# Store minimal object with raw DDL
|
|
883
|
+
from ch_migrate.introspect import (
|
|
884
|
+
DictDefinition,
|
|
885
|
+
MVDefinition,
|
|
886
|
+
TableDefinition,
|
|
887
|
+
ViewDefinition,
|
|
888
|
+
)
|
|
889
|
+
fallback_types = {
|
|
890
|
+
"table": lambda: TableDefinition(name=name, engine="", raw_ddl=ddl),
|
|
891
|
+
"view": lambda: ViewDefinition(name=name, select_query="", raw_ddl=ddl),
|
|
892
|
+
"materialized_view": lambda: MVDefinition(name=name, raw_ddl=ddl),
|
|
893
|
+
"dictionary": lambda: DictDefinition(name=name, raw_ddl=ddl),
|
|
894
|
+
}
|
|
895
|
+
schema_attrs[obj_type][name] = fallback_types[obj_type]()
|
|
896
|
+
|
|
897
|
+
# Get live schema
|
|
898
|
+
try:
|
|
899
|
+
client = get_client(env_config)
|
|
900
|
+
live_schema = get_live_schema(client, database)
|
|
901
|
+
except Exception as e:
|
|
902
|
+
ui.fail(f"Could not read the live schema from {environment}: {e}")
|
|
903
|
+
|
|
904
|
+
# Compare
|
|
905
|
+
diffs = compare_schemas(local_schema, live_schema)
|
|
906
|
+
render_diff_report(diffs)
|
|
907
|
+
|
|
908
|
+
if any(d.status != DiffStatus.IN_SYNC for d in diffs):
|
|
909
|
+
ui.hint(
|
|
910
|
+
f"Write a migration for it, or run `ch-migrate snapshot {environment}` "
|
|
911
|
+
"to accept the live schema."
|
|
912
|
+
)
|
|
913
|
+
sys.exit(1)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
@main.command(name="upgrade-env")
|
|
917
|
+
def upgrade_env() -> None:
|
|
918
|
+
"""Regenerate migrations/env.py from the latest ch-migrate version.
|
|
919
|
+
|
|
920
|
+
Updates the Alembic environment file to the latest version shipped with
|
|
921
|
+
ch-migrate. This is needed when upgrading ch-migrate to pick up new
|
|
922
|
+
features like execution hooks.
|
|
923
|
+
|
|
924
|
+
The previous env.py is backed up as env.py.bak.
|
|
925
|
+
"""
|
|
926
|
+
env_py_src = Path(__file__).parent / "env.py"
|
|
927
|
+
env_py_dst = Path.cwd() / "migrations" / "env.py"
|
|
928
|
+
|
|
929
|
+
if not env_py_dst.parent.exists():
|
|
930
|
+
ui.fail("migrations/ not found.", "Run `ch-migrate init` first.")
|
|
931
|
+
|
|
932
|
+
if not env_py_src.exists():
|
|
933
|
+
ui.fail("The package's env.py is missing; reinstall ch-migrate-cli.")
|
|
934
|
+
|
|
935
|
+
# Back up existing env.py if present
|
|
936
|
+
if env_py_dst.exists():
|
|
937
|
+
backup = env_py_dst.with_suffix(".py.bak")
|
|
938
|
+
shutil.copy(env_py_dst, backup)
|
|
939
|
+
ui.detail(f"Backed up the existing env.py to migrations/{backup.name}")
|
|
940
|
+
|
|
941
|
+
shutil.copy(env_py_src, env_py_dst)
|
|
942
|
+
ui.success("Updated migrations/env.py")
|
|
943
|
+
ui.hint("If you had custom changes, compare with env.py.bak and reapply them.")
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
@main.command()
|
|
947
|
+
@click.argument("environment")
|
|
948
|
+
@click.option(
|
|
949
|
+
"--exclude",
|
|
950
|
+
"-e",
|
|
951
|
+
multiple=True,
|
|
952
|
+
help="Glob patterns to exclude (e.g., --exclude 'system_*' --exclude 'peerdb_*')",
|
|
953
|
+
)
|
|
954
|
+
@click.option(
|
|
955
|
+
"--filter",
|
|
956
|
+
"-f",
|
|
957
|
+
"include_filter",
|
|
958
|
+
multiple=True,
|
|
959
|
+
help="Glob patterns to include (only matching objects are captured)",
|
|
960
|
+
)
|
|
961
|
+
def snapshot(environment: str, exclude: tuple[str, ...], include_filter: tuple[str, ...]) -> None:
|
|
962
|
+
"""Capture a schema snapshot from a live database.
|
|
963
|
+
|
|
964
|
+
Connects to the environment and writes CREATE statements for all tables,
|
|
965
|
+
views, materialized views, and dictionaries to a timestamped directory.
|
|
966
|
+
|
|
967
|
+
\b
|
|
968
|
+
Examples:
|
|
969
|
+
ch-migrate snapshot dev
|
|
970
|
+
ch-migrate snapshot dev --exclude 'system_*' --exclude 'peerdb_*'
|
|
971
|
+
ch-migrate snapshot dev --filter 'geo_*'
|
|
972
|
+
"""
|
|
973
|
+
import fnmatch
|
|
974
|
+
|
|
975
|
+
from ch_migrate.connection import get_client
|
|
976
|
+
from ch_migrate.display import render_snapshot_progress
|
|
977
|
+
from ch_migrate.introspect import Schema, get_live_schema
|
|
978
|
+
|
|
979
|
+
env_config = _env_config_or_fail(environment)
|
|
980
|
+
database = env_config["database"]
|
|
981
|
+
|
|
982
|
+
try:
|
|
983
|
+
client = get_client(env_config)
|
|
984
|
+
except Exception as e:
|
|
985
|
+
ui.fail(f"Could not connect to {environment}: {e}")
|
|
986
|
+
|
|
987
|
+
ui.step(f"Capturing the schema of {environment} ({database})")
|
|
988
|
+
|
|
989
|
+
try:
|
|
990
|
+
schema = get_live_schema(client, database)
|
|
991
|
+
except Exception as e:
|
|
992
|
+
ui.fail(f"Could not read the schema: {e}")
|
|
993
|
+
|
|
994
|
+
# Build output directory
|
|
995
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
996
|
+
snapshot_dir = Path.cwd() / "migrations" / "sql" / "snapshots" / timestamp
|
|
997
|
+
|
|
998
|
+
# Flatten all exclude patterns (support comma-separated within a single --exclude)
|
|
999
|
+
exclude_patterns = []
|
|
1000
|
+
for pat in exclude:
|
|
1001
|
+
exclude_patterns.extend(p.strip() for p in pat.split(",") if p.strip())
|
|
1002
|
+
|
|
1003
|
+
include_patterns = []
|
|
1004
|
+
for pat in include_filter:
|
|
1005
|
+
include_patterns.extend(p.strip() for p in pat.split(",") if p.strip())
|
|
1006
|
+
|
|
1007
|
+
def should_include(name: str) -> bool:
|
|
1008
|
+
if include_patterns and not any(fnmatch.fnmatch(name, p) for p in include_patterns):
|
|
1009
|
+
return False
|
|
1010
|
+
if any(fnmatch.fnmatch(name, p) for p in exclude_patterns):
|
|
1011
|
+
return False
|
|
1012
|
+
return True
|
|
1013
|
+
|
|
1014
|
+
# Write DDL files organized by type
|
|
1015
|
+
type_map = {
|
|
1016
|
+
"tables": schema.tables,
|
|
1017
|
+
"views": schema.views,
|
|
1018
|
+
"materialized_views": schema.materialized_views,
|
|
1019
|
+
"dictionaries": schema.dictionaries,
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
counts: dict[str, int] = {}
|
|
1023
|
+
excluded_count = 0
|
|
1024
|
+
|
|
1025
|
+
for type_name, objects in type_map.items():
|
|
1026
|
+
count = 0
|
|
1027
|
+
for name, obj in objects.items():
|
|
1028
|
+
if not should_include(name):
|
|
1029
|
+
excluded_count += 1
|
|
1030
|
+
continue
|
|
1031
|
+
type_dir = snapshot_dir / type_name
|
|
1032
|
+
type_dir.mkdir(parents=True, exist_ok=True)
|
|
1033
|
+
ddl = obj.raw_ddl if obj.raw_ddl else f"-- No DDL captured for {name}\n"
|
|
1034
|
+
(type_dir / f"{name}.sql").write_text(ddl)
|
|
1035
|
+
count += 1
|
|
1036
|
+
counts[type_name] = count
|
|
1037
|
+
|
|
1038
|
+
if sum(counts.values()) == 0:
|
|
1039
|
+
ui.fail("No objects matched the filters.")
|
|
1040
|
+
|
|
1041
|
+
render_snapshot_progress(
|
|
1042
|
+
str(snapshot_dir.relative_to(Path.cwd())),
|
|
1043
|
+
counts,
|
|
1044
|
+
excluded=excluded_count,
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
if __name__ == "__main__":
|
|
1049
|
+
main()
|