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