fraiseql-confiture 0.3.4__cp311-cp311-win_amd64.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.
Files changed (119) hide show
  1. confiture/__init__.py +48 -0
  2. confiture/_core.cp311-win_amd64.pyd +0 -0
  3. confiture/cli/__init__.py +0 -0
  4. confiture/cli/dry_run.py +116 -0
  5. confiture/cli/lint_formatter.py +193 -0
  6. confiture/cli/main.py +1656 -0
  7. confiture/config/__init__.py +0 -0
  8. confiture/config/environment.py +263 -0
  9. confiture/core/__init__.py +51 -0
  10. confiture/core/anonymization/__init__.py +0 -0
  11. confiture/core/anonymization/audit.py +485 -0
  12. confiture/core/anonymization/benchmarking.py +372 -0
  13. confiture/core/anonymization/breach_notification.py +652 -0
  14. confiture/core/anonymization/compliance.py +617 -0
  15. confiture/core/anonymization/composer.py +298 -0
  16. confiture/core/anonymization/data_subject_rights.py +669 -0
  17. confiture/core/anonymization/factory.py +319 -0
  18. confiture/core/anonymization/governance.py +737 -0
  19. confiture/core/anonymization/performance.py +1092 -0
  20. confiture/core/anonymization/profile.py +284 -0
  21. confiture/core/anonymization/registry.py +195 -0
  22. confiture/core/anonymization/security/kms_manager.py +547 -0
  23. confiture/core/anonymization/security/lineage.py +888 -0
  24. confiture/core/anonymization/security/token_store.py +686 -0
  25. confiture/core/anonymization/strategies/__init__.py +41 -0
  26. confiture/core/anonymization/strategies/address.py +359 -0
  27. confiture/core/anonymization/strategies/credit_card.py +374 -0
  28. confiture/core/anonymization/strategies/custom.py +161 -0
  29. confiture/core/anonymization/strategies/date.py +218 -0
  30. confiture/core/anonymization/strategies/differential_privacy.py +398 -0
  31. confiture/core/anonymization/strategies/email.py +141 -0
  32. confiture/core/anonymization/strategies/format_preserving_encryption.py +310 -0
  33. confiture/core/anonymization/strategies/hash.py +150 -0
  34. confiture/core/anonymization/strategies/ip_address.py +235 -0
  35. confiture/core/anonymization/strategies/masking_retention.py +252 -0
  36. confiture/core/anonymization/strategies/name.py +298 -0
  37. confiture/core/anonymization/strategies/phone.py +119 -0
  38. confiture/core/anonymization/strategies/preserve.py +85 -0
  39. confiture/core/anonymization/strategies/redact.py +101 -0
  40. confiture/core/anonymization/strategies/salted_hashing.py +322 -0
  41. confiture/core/anonymization/strategies/text_redaction.py +183 -0
  42. confiture/core/anonymization/strategies/tokenization.py +334 -0
  43. confiture/core/anonymization/strategy.py +241 -0
  44. confiture/core/anonymization/syncer_audit.py +357 -0
  45. confiture/core/blue_green.py +683 -0
  46. confiture/core/builder.py +500 -0
  47. confiture/core/checksum.py +358 -0
  48. confiture/core/connection.py +132 -0
  49. confiture/core/differ.py +522 -0
  50. confiture/core/drift.py +564 -0
  51. confiture/core/dry_run.py +182 -0
  52. confiture/core/health.py +313 -0
  53. confiture/core/hooks/__init__.py +87 -0
  54. confiture/core/hooks/base.py +232 -0
  55. confiture/core/hooks/context.py +146 -0
  56. confiture/core/hooks/execution_strategies.py +57 -0
  57. confiture/core/hooks/observability.py +220 -0
  58. confiture/core/hooks/phases.py +53 -0
  59. confiture/core/hooks/registry.py +295 -0
  60. confiture/core/large_tables.py +775 -0
  61. confiture/core/linting/__init__.py +70 -0
  62. confiture/core/linting/composer.py +192 -0
  63. confiture/core/linting/libraries/__init__.py +17 -0
  64. confiture/core/linting/libraries/gdpr.py +168 -0
  65. confiture/core/linting/libraries/general.py +184 -0
  66. confiture/core/linting/libraries/hipaa.py +144 -0
  67. confiture/core/linting/libraries/pci_dss.py +104 -0
  68. confiture/core/linting/libraries/sox.py +120 -0
  69. confiture/core/linting/schema_linter.py +491 -0
  70. confiture/core/linting/versioning.py +151 -0
  71. confiture/core/locking.py +389 -0
  72. confiture/core/migration_generator.py +298 -0
  73. confiture/core/migrator.py +793 -0
  74. confiture/core/observability/__init__.py +44 -0
  75. confiture/core/observability/audit.py +323 -0
  76. confiture/core/observability/logging.py +187 -0
  77. confiture/core/observability/metrics.py +174 -0
  78. confiture/core/observability/tracing.py +192 -0
  79. confiture/core/pg_version.py +418 -0
  80. confiture/core/pool.py +406 -0
  81. confiture/core/risk/__init__.py +39 -0
  82. confiture/core/risk/predictor.py +188 -0
  83. confiture/core/risk/scoring.py +248 -0
  84. confiture/core/rollback_generator.py +388 -0
  85. confiture/core/schema_analyzer.py +769 -0
  86. confiture/core/schema_to_schema.py +590 -0
  87. confiture/core/security/__init__.py +32 -0
  88. confiture/core/security/logging.py +201 -0
  89. confiture/core/security/validation.py +416 -0
  90. confiture/core/signals.py +371 -0
  91. confiture/core/syncer.py +540 -0
  92. confiture/exceptions.py +192 -0
  93. confiture/integrations/__init__.py +0 -0
  94. confiture/models/__init__.py +0 -0
  95. confiture/models/lint.py +193 -0
  96. confiture/models/migration.py +180 -0
  97. confiture/models/schema.py +203 -0
  98. confiture/scenarios/__init__.py +36 -0
  99. confiture/scenarios/compliance.py +586 -0
  100. confiture/scenarios/ecommerce.py +199 -0
  101. confiture/scenarios/financial.py +253 -0
  102. confiture/scenarios/healthcare.py +315 -0
  103. confiture/scenarios/multi_tenant.py +340 -0
  104. confiture/scenarios/saas.py +295 -0
  105. confiture/testing/FRAMEWORK_API.md +722 -0
  106. confiture/testing/__init__.py +38 -0
  107. confiture/testing/fixtures/__init__.py +11 -0
  108. confiture/testing/fixtures/data_validator.py +229 -0
  109. confiture/testing/fixtures/migration_runner.py +167 -0
  110. confiture/testing/fixtures/schema_snapshotter.py +352 -0
  111. confiture/testing/frameworks/__init__.py +10 -0
  112. confiture/testing/frameworks/mutation.py +587 -0
  113. confiture/testing/frameworks/performance.py +479 -0
  114. confiture/testing/utils/__init__.py +0 -0
  115. fraiseql_confiture-0.3.4.dist-info/METADATA +438 -0
  116. fraiseql_confiture-0.3.4.dist-info/RECORD +119 -0
  117. fraiseql_confiture-0.3.4.dist-info/WHEEL +4 -0
  118. fraiseql_confiture-0.3.4.dist-info/entry_points.txt +2 -0
  119. fraiseql_confiture-0.3.4.dist-info/licenses/LICENSE +21 -0
confiture/cli/main.py ADDED
@@ -0,0 +1,1656 @@
1
+ """Main CLI entry point for Confiture.
2
+
3
+ This module defines the main Typer application and all CLI commands.
4
+ """
5
+
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from confiture.cli.lint_formatter import format_lint_report, save_report
14
+ from confiture.core.builder import SchemaBuilder
15
+ from confiture.core.differ import SchemaDiffer
16
+ from confiture.core.linting import SchemaLinter
17
+ from confiture.core.linting.schema_linter import (
18
+ LintConfig as LinterConfig,
19
+ )
20
+ from confiture.core.linting.schema_linter import (
21
+ LintReport as LinterReport,
22
+ )
23
+ from confiture.core.linting.schema_linter import (
24
+ RuleSeverity,
25
+ )
26
+ from confiture.core.migration_generator import MigrationGenerator
27
+ from confiture.models.lint import LintReport, LintSeverity, Violation
28
+
29
+ # Valid output formats for linting
30
+ LINT_FORMATS = ("table", "json", "csv")
31
+
32
+
33
+ def _convert_linter_report(linter_report: LinterReport, schema_name: str = "schema") -> LintReport:
34
+ """Convert a schema_linter.LintReport to models.lint.LintReport.
35
+
36
+ Args:
37
+ linter_report: Report from SchemaLinter
38
+ schema_name: Name of schema being linted
39
+
40
+ Returns:
41
+ LintReport compatible with format_lint_report
42
+ """
43
+ violations = []
44
+
45
+ # Map RuleSeverity to LintSeverity
46
+ severity_map = {
47
+ RuleSeverity.ERROR: LintSeverity.ERROR,
48
+ RuleSeverity.WARNING: LintSeverity.WARNING,
49
+ RuleSeverity.INFO: LintSeverity.INFO,
50
+ }
51
+
52
+ # Convert all violations
53
+ for violation in linter_report.errors:
54
+ violations.append(
55
+ Violation(
56
+ rule_name=violation.rule_name,
57
+ severity=severity_map[violation.severity],
58
+ message=violation.message,
59
+ location=violation.object_name,
60
+ )
61
+ )
62
+
63
+ for violation in linter_report.warnings:
64
+ violations.append(
65
+ Violation(
66
+ rule_name=violation.rule_name,
67
+ severity=severity_map[violation.severity],
68
+ message=violation.message,
69
+ location=violation.object_name,
70
+ )
71
+ )
72
+
73
+ for violation in linter_report.info:
74
+ violations.append(
75
+ Violation(
76
+ rule_name=violation.rule_name,
77
+ severity=severity_map[violation.severity],
78
+ message=violation.message,
79
+ location=violation.object_name,
80
+ )
81
+ )
82
+
83
+ return LintReport(
84
+ violations=violations,
85
+ schema_name=schema_name,
86
+ tables_checked=0, # Not tracked in linter
87
+ columns_checked=0, # Not tracked in linter
88
+ errors_count=len(linter_report.errors),
89
+ warnings_count=len(linter_report.warnings),
90
+ info_count=len(linter_report.info),
91
+ execution_time_ms=0, # Not tracked in linter
92
+ )
93
+
94
+
95
+ # Create Typer app
96
+ app = typer.Typer(
97
+ name="confiture",
98
+ help="PostgreSQL migrations, sweetly done šŸ“",
99
+ add_completion=False,
100
+ )
101
+
102
+ # Create Rich console for pretty output
103
+ console = Console()
104
+
105
+ # Version
106
+ __version__ = "0.3.0"
107
+
108
+
109
+ def version_callback(value: bool) -> None:
110
+ """Print version and exit."""
111
+ if value:
112
+ console.print(f"confiture version {__version__}")
113
+ raise typer.Exit()
114
+
115
+
116
+ @app.callback()
117
+ def main(
118
+ version: bool = typer.Option(
119
+ False,
120
+ "--version",
121
+ callback=version_callback,
122
+ is_eager=True,
123
+ help="Show version and exit",
124
+ ),
125
+ ) -> None:
126
+ """Confiture - PostgreSQL migrations, sweetly done šŸ“."""
127
+ pass
128
+
129
+
130
+ @app.command()
131
+ def init(
132
+ path: Path = typer.Argument(
133
+ Path("."),
134
+ help="Project directory to initialize",
135
+ ),
136
+ ) -> None:
137
+ """Initialize a new Confiture project.
138
+
139
+ Creates necessary directory structure and configuration files.
140
+ """
141
+ try:
142
+ # Create directory structure
143
+ db_dir = path / "db"
144
+ schema_dir = db_dir / "schema"
145
+ seeds_dir = db_dir / "seeds"
146
+ migrations_dir = db_dir / "migrations"
147
+ environments_dir = db_dir / "environments"
148
+
149
+ # Check if already initialized
150
+ if db_dir.exists():
151
+ console.print(
152
+ "[yellow]āš ļø Project already exists. Some files may be overwritten.[/yellow]"
153
+ )
154
+ if not typer.confirm("Continue?"):
155
+ raise typer.Exit()
156
+
157
+ # Create directories
158
+ schema_dir.mkdir(parents=True, exist_ok=True)
159
+ (seeds_dir / "common").mkdir(parents=True, exist_ok=True)
160
+ (seeds_dir / "development").mkdir(parents=True, exist_ok=True)
161
+ (seeds_dir / "test").mkdir(parents=True, exist_ok=True)
162
+ migrations_dir.mkdir(parents=True, exist_ok=True)
163
+ environments_dir.mkdir(parents=True, exist_ok=True)
164
+
165
+ # Create example schema directory structure
166
+ (schema_dir / "00_common").mkdir(exist_ok=True)
167
+ (schema_dir / "10_tables").mkdir(exist_ok=True)
168
+
169
+ # Create example schema file
170
+ example_schema = schema_dir / "00_common" / "extensions.sql"
171
+ example_schema.write_text(
172
+ """-- PostgreSQL extensions
173
+ -- Add commonly used extensions here
174
+
175
+ -- Example:
176
+ -- CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
177
+ -- CREATE EXTENSION IF NOT EXISTS "pg_trgm";
178
+ """
179
+ )
180
+
181
+ # Create example table
182
+ example_table = schema_dir / "10_tables" / "example.sql"
183
+ example_table.write_text(
184
+ """-- Example table
185
+ -- Replace with your actual schema
186
+
187
+ CREATE TABLE IF NOT EXISTS users (
188
+ id SERIAL PRIMARY KEY,
189
+ username TEXT NOT NULL UNIQUE,
190
+ email TEXT NOT NULL UNIQUE,
191
+ created_at TIMESTAMP DEFAULT NOW()
192
+ );
193
+ """
194
+ )
195
+
196
+ # Create example seed file
197
+ example_seed = seeds_dir / "common" / "00_example.sql"
198
+ example_seed.write_text(
199
+ """-- Common seed data
200
+ -- These records are included in all non-production environments
201
+
202
+ -- Example: Test users
203
+ -- INSERT INTO users (username, email) VALUES
204
+ -- ('admin', 'admin@example.com'),
205
+ -- ('editor', 'editor@example.com'),
206
+ -- ('reader', 'reader@example.com');
207
+ """
208
+ )
209
+
210
+ # Create local environment config
211
+ local_config = environments_dir / "local.yaml"
212
+ local_config.write_text(
213
+ """# Local development environment configuration
214
+
215
+ name: local
216
+ include_dirs:
217
+ - db/schema/00_common
218
+ - db/schema/10_tables
219
+ exclude_dirs: []
220
+
221
+ database:
222
+ host: localhost
223
+ port: 5432
224
+ database: myapp_local
225
+ user: postgres
226
+ password: postgres
227
+ """
228
+ )
229
+
230
+ # Create README
231
+ readme = db_dir / "README.md"
232
+ readme.write_text(
233
+ """# Database Schema
234
+
235
+ This directory contains your database schema and migrations.
236
+
237
+ ## Directory Structure
238
+
239
+ - `schema/` - DDL files organized by category
240
+ - `00_common/` - Extensions, types, functions
241
+ - `10_tables/` - Table definitions
242
+ - `migrations/` - Python migration files
243
+ - `environments/` - Environment-specific configurations
244
+
245
+ ## Quick Start
246
+
247
+ 1. Edit schema files in `schema/`
248
+ 2. Generate migrations: `confiture migrate diff old.sql new.sql --generate`
249
+ 3. Apply migrations: `confiture migrate up`
250
+
251
+ ## Learn More
252
+
253
+ Documentation: https://github.com/evoludigit/confiture
254
+ """
255
+ )
256
+
257
+ console.print("[green]āœ… Confiture project initialized successfully![/green]")
258
+ console.print(f"\nšŸ“ Created structure in: {path.absolute()}")
259
+ console.print("\nšŸ“ Next steps:")
260
+ console.print(" 1. Edit your schema files in db/schema/")
261
+ console.print(" 2. Configure environments in db/environments/")
262
+ console.print(" 3. Run 'confiture migrate diff' to detect changes")
263
+
264
+ except Exception as e:
265
+ console.print(f"[red]āŒ Error initializing project: {e}[/red]")
266
+ raise typer.Exit(1) from e
267
+
268
+
269
+ @app.command()
270
+ def build(
271
+ env: str = typer.Option(
272
+ "local",
273
+ "--env",
274
+ "-e",
275
+ help="Environment to build (references db/environments/{env}.yaml)",
276
+ ),
277
+ output: Path = typer.Option(
278
+ None,
279
+ "--output",
280
+ "-o",
281
+ help="Output file path (default: db/generated/schema_{env}.sql)",
282
+ ),
283
+ project_dir: Path = typer.Option(
284
+ Path("."),
285
+ "--project-dir",
286
+ help="Project directory (default: current directory)",
287
+ ),
288
+ show_hash: bool = typer.Option(
289
+ False,
290
+ "--show-hash",
291
+ help="Display schema hash after build",
292
+ ),
293
+ schema_only: bool = typer.Option(
294
+ False,
295
+ "--schema-only",
296
+ help="Build schema only, exclude seed data",
297
+ ),
298
+ ) -> None:
299
+ """Build complete schema from DDL files.
300
+
301
+ This command builds a complete schema by concatenating all SQL files
302
+ from the db/schema/ directory in deterministic order. This is the
303
+ fastest way to create or recreate a database from scratch.
304
+
305
+ The build process:
306
+ 1. Reads environment configuration (db/environments/{env}.yaml)
307
+ 2. Discovers all .sql files in configured include_dirs
308
+ 3. Concatenates files in alphabetical order
309
+ 4. Adds metadata headers (environment, file count, timestamp)
310
+ 5. Writes to output file (default: db/generated/schema_{env}.sql)
311
+
312
+ Examples:
313
+ # Build local environment schema
314
+ confiture build
315
+
316
+ # Build for specific environment
317
+ confiture build --env production
318
+
319
+ # Custom output location
320
+ confiture build --output /tmp/schema.sql
321
+
322
+ # Show hash for change detection
323
+ confiture build --show-hash
324
+ """
325
+ try:
326
+ # Create schema builder
327
+ builder = SchemaBuilder(env=env, project_dir=project_dir)
328
+
329
+ # Override to exclude seeds if --schema-only is specified
330
+ if schema_only:
331
+ builder.include_dirs = [d for d in builder.include_dirs if "seed" not in str(d).lower()]
332
+ # Recalculate base_dir after filtering
333
+ if builder.include_dirs:
334
+ builder.base_dir = builder._find_common_parent(builder.include_dirs)
335
+
336
+ # Set default output path if not specified
337
+ if output is None:
338
+ output_dir = project_dir / "db" / "generated"
339
+ output_dir.mkdir(parents=True, exist_ok=True)
340
+ output = output_dir / f"schema_{env}.sql"
341
+
342
+ # Build schema
343
+ console.print(f"[cyan]šŸ”Ø Building schema for environment: {env}[/cyan]")
344
+
345
+ sql_files = builder.find_sql_files()
346
+ console.print(f"[cyan]šŸ“„ Found {len(sql_files)} SQL files[/cyan]")
347
+
348
+ schema = builder.build(output_path=output)
349
+
350
+ # Success message
351
+ console.print("[green]āœ… Schema built successfully![/green]")
352
+ console.print(f"\nšŸ“ Output: {output.absolute()}")
353
+ console.print(f"šŸ“ Size: {len(schema):,} bytes")
354
+ console.print(f"šŸ“Š Files: {len(sql_files)}")
355
+
356
+ # Show hash if requested
357
+ if show_hash:
358
+ schema_hash = builder.compute_hash()
359
+ console.print(f"šŸ” Hash: {schema_hash}")
360
+
361
+ console.print("\nšŸ’” Next steps:")
362
+ console.print(f" • Apply schema: psql -f {output}")
363
+ console.print(" • Or use: confiture migrate up")
364
+
365
+ except FileNotFoundError as e:
366
+ console.print(f"[red]āŒ File not found: {e}[/red]")
367
+ console.print("\nšŸ’” Tip: Run 'confiture init' to create project structure")
368
+ raise typer.Exit(1) from e
369
+ except Exception as e:
370
+ console.print(f"[red]āŒ Error building schema: {e}[/red]")
371
+ raise typer.Exit(1) from e
372
+
373
+
374
+ @app.command()
375
+ def lint(
376
+ env: str = typer.Option(
377
+ "local",
378
+ "--env",
379
+ "-e",
380
+ help="Environment to lint (references db/environments/{env}.yaml)",
381
+ ),
382
+ project_dir: Path = typer.Option(
383
+ Path("."),
384
+ "--project-dir",
385
+ help="Project directory (default: current directory)",
386
+ ),
387
+ format_type: str = typer.Option(
388
+ "table",
389
+ "--format",
390
+ "-f",
391
+ help="Output format (table, json, csv)",
392
+ ),
393
+ output: Path = typer.Option(
394
+ None,
395
+ "--output",
396
+ "-o",
397
+ help="Output file path (only with json/csv format)",
398
+ ),
399
+ fail_on_error: bool = typer.Option(
400
+ True,
401
+ "--fail-on-error",
402
+ help="Exit with code 1 if errors found",
403
+ ),
404
+ fail_on_warning: bool = typer.Option(
405
+ False,
406
+ "--fail-on-warning",
407
+ help="Exit with code 1 if warnings found (stricter)",
408
+ ),
409
+ ) -> None:
410
+ """Lint schema against best practices.
411
+
412
+ Validates the schema against 6 built-in linting rules:
413
+ - Naming conventions (snake_case)
414
+ - Primary keys on all tables
415
+ - Documentation (COMMENT on tables)
416
+ - Multi-tenant identifier columns
417
+ - Indexes on foreign keys
418
+ - Security best practices (passwords, tokens, secrets)
419
+
420
+ Examples:
421
+ # Lint local environment, display as table
422
+ confiture lint
423
+
424
+ # Lint production environment, output as JSON
425
+ confiture lint --env production --format json
426
+
427
+ # Save results to file
428
+ confiture lint --format json --output lint-report.json
429
+
430
+ # Strict mode: fail on warnings
431
+ confiture lint --fail-on-warning
432
+ """
433
+ try:
434
+ # Validate format option
435
+ if format_type not in LINT_FORMATS:
436
+ console.print(f"[red]āŒ Invalid format: {format_type}[/red]")
437
+ console.print(f"Valid formats: {', '.join(LINT_FORMATS)}")
438
+ raise typer.Exit(1)
439
+
440
+ # Create linter configuration (use LinterConfig for the linter)
441
+ config = LinterConfig(
442
+ enabled=True,
443
+ fail_on_error=fail_on_error,
444
+ fail_on_warning=fail_on_warning,
445
+ )
446
+
447
+ # Create linter and run linting
448
+ console.print(f"[cyan]šŸ” Linting schema for environment: {env}[/cyan]")
449
+ linter = SchemaLinter(env=env, config=config)
450
+ linter_report = linter.lint()
451
+
452
+ # Convert to model LintReport for formatting
453
+ report = _convert_linter_report(linter_report, schema_name=env)
454
+
455
+ # Display results based on format
456
+ if format_type == "table":
457
+ format_lint_report(report, format_type="table", console=console)
458
+ else:
459
+ # JSON/CSV format: format and optionally save
460
+ # Cast format_type for type checker
461
+ fmt = "json" if format_type == "json" else "csv"
462
+ formatted = format_lint_report(
463
+ report,
464
+ format_type=fmt,
465
+ console=console,
466
+ )
467
+
468
+ if output:
469
+ save_report(report, output, format_type=fmt)
470
+ console.print(f"[green]āœ… Report saved to: {output.absolute()}[/green]")
471
+ else:
472
+ console.print(formatted)
473
+
474
+ # Determine exit code based on violations and fail mode
475
+ should_fail = (report.has_errors and fail_on_error) or (
476
+ report.has_warnings and fail_on_warning
477
+ )
478
+ if should_fail:
479
+ raise typer.Exit(1)
480
+
481
+ except FileNotFoundError as e:
482
+ console.print(f"[red]āŒ File not found: {e}[/red]")
483
+ console.print("\nšŸ’” Tip: Make sure schema files exist in db/schema/")
484
+ raise typer.Exit(1) from e
485
+ except Exception as e:
486
+ console.print(f"[red]āŒ Error linting schema: {e}[/red]")
487
+ raise typer.Exit(1) from e
488
+
489
+
490
+ # Create migrate subcommand group
491
+ migrate_app = typer.Typer(help="Migration commands")
492
+ app.add_typer(migrate_app, name="migrate")
493
+
494
+
495
+ @migrate_app.command("status")
496
+ def migrate_status(
497
+ migrations_dir: Path = typer.Option(
498
+ Path("db/migrations"),
499
+ "--migrations-dir",
500
+ help="Migrations directory",
501
+ ),
502
+ config: Path = typer.Option(
503
+ None,
504
+ "--config",
505
+ "-c",
506
+ help="Configuration file (optional, to show applied status)",
507
+ ),
508
+ ) -> None:
509
+ """Show migration status.
510
+
511
+ If config is provided, shows which migrations are applied vs pending.
512
+ """
513
+ try:
514
+ if not migrations_dir.exists():
515
+ console.print("[yellow]No migrations directory found.[/yellow]")
516
+ console.print(f"Expected: {migrations_dir.absolute()}")
517
+ return
518
+
519
+ # Find migration files
520
+ migration_files = sorted(migrations_dir.glob("*.py"))
521
+
522
+ if not migration_files:
523
+ console.print("[yellow]No migrations found.[/yellow]")
524
+ return
525
+
526
+ # Get applied migrations from database if config provided
527
+ applied_versions = set()
528
+ if config and config.exists():
529
+ try:
530
+ from confiture.core.connection import create_connection, load_config
531
+ from confiture.core.migrator import Migrator
532
+
533
+ config_data = load_config(config)
534
+ conn = create_connection(config_data)
535
+ migrator = Migrator(connection=conn)
536
+ migrator.initialize()
537
+ applied_versions = set(migrator.get_applied_versions())
538
+ conn.close()
539
+ except Exception as e:
540
+ console.print(f"[yellow]āš ļø Could not connect to database: {e}[/yellow]")
541
+ console.print("[yellow]Showing file list only (status unknown)[/yellow]\n")
542
+
543
+ # Display migrations in a table
544
+ table = Table(title="Migrations")
545
+ table.add_column("Version", style="cyan")
546
+ table.add_column("Name", style="green")
547
+ table.add_column("Status", style="yellow")
548
+
549
+ pending_count = 0
550
+ applied_count = 0
551
+
552
+ for migration_file in migration_files:
553
+ # Extract version and name from filename (e.g., "001_add_users.py")
554
+ parts = migration_file.stem.split("_", 1)
555
+ version = parts[0] if len(parts) > 0 else "???"
556
+ name = parts[1] if len(parts) > 1 else migration_file.stem
557
+
558
+ # Determine status
559
+ if applied_versions:
560
+ if version in applied_versions:
561
+ status = "[green]āœ… applied[/green]"
562
+ applied_count += 1
563
+ else:
564
+ status = "[yellow]ā³ pending[/yellow]"
565
+ pending_count += 1
566
+ else:
567
+ status = "unknown"
568
+
569
+ table.add_row(version, name, status)
570
+
571
+ console.print(table)
572
+ console.print(f"\nšŸ“Š Total: {len(migration_files)} migrations", end="")
573
+ if applied_versions:
574
+ console.print(f" ({applied_count} applied, {pending_count} pending)")
575
+ else:
576
+ console.print()
577
+
578
+ except Exception as e:
579
+ console.print(f"[red]āŒ Error: {e}[/red]")
580
+ raise typer.Exit(1) from e
581
+
582
+
583
+ @migrate_app.command("up")
584
+ def migrate_up(
585
+ migrations_dir: Path = typer.Option(
586
+ Path("db/migrations"),
587
+ "--migrations-dir",
588
+ help="Migrations directory",
589
+ ),
590
+ config: Path = typer.Option(
591
+ Path("db/environments/local.yaml"),
592
+ "--config",
593
+ "-c",
594
+ help="Configuration file",
595
+ ),
596
+ target: str = typer.Option(
597
+ None,
598
+ "--target",
599
+ "-t",
600
+ help="Target migration version (applies all if not specified)",
601
+ ),
602
+ strict: bool = typer.Option(
603
+ False,
604
+ "--strict",
605
+ help="Enable strict mode (fail on warnings)",
606
+ ),
607
+ force: bool = typer.Option(
608
+ False,
609
+ "--force",
610
+ help="Force migration application, skipping state checks",
611
+ ),
612
+ lock_timeout: int = typer.Option(
613
+ 30000,
614
+ "--lock-timeout",
615
+ help="Lock acquisition timeout in milliseconds (default: 30000ms = 30s)",
616
+ ),
617
+ no_lock: bool = typer.Option(
618
+ False,
619
+ "--no-lock",
620
+ help="Disable migration locking (DANGEROUS in multi-pod environments)",
621
+ ),
622
+ dry_run: bool = typer.Option(
623
+ False,
624
+ "--dry-run",
625
+ help="Analyze migrations without executing (metadata queries only)",
626
+ ),
627
+ dry_run_execute: bool = typer.Option(
628
+ False,
629
+ "--dry-run-execute",
630
+ help="Execute migrations in SAVEPOINT for realistic testing (guaranteed rollback)",
631
+ ),
632
+ verify_checksums: bool = typer.Option(
633
+ True,
634
+ "--verify-checksums/--no-verify-checksums",
635
+ help="Verify migration file checksums before running (default: enabled)",
636
+ ),
637
+ on_checksum_mismatch: str = typer.Option(
638
+ "fail",
639
+ "--on-checksum-mismatch",
640
+ help="Behavior on checksum mismatch: fail, warn, ignore",
641
+ ),
642
+ verbose: bool = typer.Option(
643
+ False,
644
+ "--verbose",
645
+ "-v",
646
+ help="Show detailed analysis in dry-run report",
647
+ ),
648
+ format_output: str = typer.Option(
649
+ "text",
650
+ "--format",
651
+ "-f",
652
+ help="Report format (text or json)",
653
+ ),
654
+ output_file: Path | None = typer.Option(
655
+ None,
656
+ "--output",
657
+ "-o",
658
+ help="Save report to file",
659
+ ),
660
+ ) -> None:
661
+ """Apply pending migrations.
662
+
663
+ Applies all pending migrations up to the target version (or all if no target).
664
+
665
+ Uses distributed locking to ensure only one migration process runs at a time.
666
+ This is critical for Kubernetes/multi-pod deployments.
667
+
668
+ Verifies migration file checksums to detect unauthorized modifications.
669
+ Use --no-verify-checksums to skip verification.
670
+
671
+ Use --dry-run for analysis without execution, or --dry-run-execute to test in SAVEPOINT.
672
+ """
673
+ from confiture.cli.dry_run import (
674
+ ask_dry_run_execute_confirmation,
675
+ display_dry_run_header,
676
+ print_json_report,
677
+ save_json_report,
678
+ save_text_report,
679
+ )
680
+ from confiture.core.checksum import (
681
+ ChecksumConfig,
682
+ ChecksumMismatchBehavior,
683
+ ChecksumVerificationError,
684
+ MigrationChecksumVerifier,
685
+ )
686
+ from confiture.core.connection import (
687
+ create_connection,
688
+ get_migration_class,
689
+ load_config,
690
+ load_migration_module,
691
+ )
692
+ from confiture.core.locking import LockAcquisitionError, LockConfig, MigrationLock
693
+ from confiture.core.migrator import Migrator
694
+
695
+ try:
696
+ # Validate dry-run options
697
+ if dry_run and dry_run_execute:
698
+ console.print("[red]āŒ Error: Cannot use both --dry-run and --dry-run-execute[/red]")
699
+ raise typer.Exit(1)
700
+
701
+ if (dry_run or dry_run_execute) and force:
702
+ console.print("[red]āŒ Error: Cannot use --dry-run with --force[/red]")
703
+ raise typer.Exit(1)
704
+
705
+ # Validate format option
706
+ if format_output not in ("text", "json"):
707
+ console.print(
708
+ f"[red]āŒ Error: Invalid format '{format_output}'. Use 'text' or 'json'[/red]"
709
+ )
710
+ raise typer.Exit(1)
711
+
712
+ # Validate checksum mismatch option
713
+ valid_mismatch_behaviors = ("fail", "warn", "ignore")
714
+ if on_checksum_mismatch not in valid_mismatch_behaviors:
715
+ console.print(
716
+ f"[red]āŒ Error: Invalid --on-checksum-mismatch '{on_checksum_mismatch}'. "
717
+ f"Use one of: {', '.join(valid_mismatch_behaviors)}[/red]"
718
+ )
719
+ raise typer.Exit(1)
720
+
721
+ # Load configuration
722
+ config_data = load_config(config)
723
+
724
+ # Try to load environment config for migration settings
725
+ effective_strict_mode = strict
726
+ if (
727
+ not strict
728
+ and config.parent.name == "environments"
729
+ and config.parent.parent.name == "db"
730
+ ):
731
+ # Check if config is in standard environments directory
732
+ try:
733
+ from confiture.config.environment import Environment
734
+
735
+ env_name = config.stem # e.g., "local" from "local.yaml"
736
+ project_dir = config.parent.parent.parent
737
+ env_config = Environment.load(env_name, project_dir=project_dir)
738
+ effective_strict_mode = env_config.migration.strict_mode
739
+ except Exception:
740
+ # If environment config loading fails, use default (False)
741
+ pass
742
+
743
+ # Show warnings for force mode before attempting database operations
744
+ if force:
745
+ console.print(
746
+ "[yellow]āš ļø Force mode enabled - skipping migration state checks[/yellow]"
747
+ )
748
+ console.print(
749
+ "[yellow]This may cause issues if applied incorrectly. Use with caution![/yellow]\n"
750
+ )
751
+
752
+ # Show warning for no-lock mode
753
+ if no_lock:
754
+ console.print(
755
+ "[yellow]āš ļø Locking disabled - DANGEROUS in multi-pod environments![/yellow]"
756
+ )
757
+ console.print(
758
+ "[yellow]Concurrent migrations may cause race conditions or data corruption.[/yellow]\n"
759
+ )
760
+
761
+ # Create database connection
762
+ conn = create_connection(config_data)
763
+
764
+ # Create migrator
765
+ migrator = Migrator(connection=conn)
766
+ migrator.initialize()
767
+
768
+ # Verify checksums before running migrations (unless force mode)
769
+ if verify_checksums and not force:
770
+ mismatch_behavior = ChecksumMismatchBehavior(on_checksum_mismatch)
771
+ checksum_config = ChecksumConfig(
772
+ enabled=True,
773
+ on_mismatch=mismatch_behavior,
774
+ )
775
+ verifier = MigrationChecksumVerifier(conn, checksum_config)
776
+
777
+ try:
778
+ mismatches = verifier.verify_all(migrations_dir)
779
+ if not mismatches:
780
+ console.print("[cyan]šŸ” Checksum verification passed[/cyan]\n")
781
+ except ChecksumVerificationError as e:
782
+ console.print("[red]āŒ Checksum verification failed![/red]\n")
783
+ for m in e.mismatches:
784
+ console.print(f" [yellow]{m.version}_{m.name}[/yellow]")
785
+ console.print(f" Expected: {m.expected[:16]}...")
786
+ console.print(f" Actual: {m.actual[:16]}...")
787
+ console.print(
788
+ "\n[yellow]šŸ’” Tip: Use 'confiture verify --fix' to update checksums, "
789
+ "or --no-verify-checksums to skip[/yellow]"
790
+ )
791
+ conn.close()
792
+ raise typer.Exit(1) from e
793
+
794
+ # Find migrations to apply
795
+ if force:
796
+ # In force mode, apply all migrations regardless of state
797
+ migrations_to_apply = migrator.find_migration_files(migrations_dir=migrations_dir)
798
+ if not migrations_to_apply:
799
+ console.print("[yellow]āš ļø No migration files found.[/yellow]")
800
+ conn.close()
801
+ return
802
+ console.print(
803
+ f"[cyan]šŸ“¦ Force mode: Found {len(migrations_to_apply)} migration(s) to apply[/cyan]\n"
804
+ )
805
+ else:
806
+ # Normal mode: only apply pending migrations
807
+ migrations_to_apply = migrator.find_pending(migrations_dir=migrations_dir)
808
+ if not migrations_to_apply:
809
+ console.print("[green]āœ… No pending migrations. Database is up to date.[/green]")
810
+ conn.close()
811
+ return
812
+ console.print(
813
+ f"[cyan]šŸ“¦ Found {len(migrations_to_apply)} pending migration(s)[/cyan]\n"
814
+ )
815
+
816
+ # Handle dry-run modes
817
+ if dry_run or dry_run_execute:
818
+ display_dry_run_header("testing" if dry_run_execute else "analysis")
819
+
820
+ # Build migration summary
821
+ migration_summary: dict[str, Any] = {
822
+ "migration_id": f"dry_run_{config.stem}",
823
+ "mode": "execute_and_analyze" if dry_run_execute else "analysis",
824
+ "statements_analyzed": len(migrations_to_apply),
825
+ "migrations": [],
826
+ "summary": {
827
+ "unsafe_count": 0,
828
+ "total_estimated_time_ms": 0,
829
+ "total_estimated_disk_mb": 0.0,
830
+ "has_unsafe_statements": False,
831
+ },
832
+ "warnings": [],
833
+ "analyses": [],
834
+ }
835
+
836
+ try:
837
+ # Collect migration information
838
+ for migration_file in migrations_to_apply:
839
+ module = load_migration_module(migration_file)
840
+ migration_class = get_migration_class(module)
841
+ migration = migration_class(connection=conn)
842
+
843
+ migration_info = {
844
+ "version": migration.version,
845
+ "name": migration.name,
846
+ "classification": "warning", # Most migrations are complex changes
847
+ "estimated_duration_ms": 500, # Conservative estimate
848
+ "estimated_disk_usage_mb": 1.0,
849
+ "estimated_cpu_percent": 30.0,
850
+ }
851
+ migration_summary["migrations"].append(migration_info)
852
+ migration_summary["analyses"].append(migration_info)
853
+
854
+ # Display format
855
+ if format_output == "json":
856
+ if output_file:
857
+ save_json_report(migration_summary, output_file)
858
+ console.print(
859
+ f"\n[green]āœ… Report saved to: {output_file.absolute()}[/green]"
860
+ )
861
+ else:
862
+ print_json_report(migration_summary)
863
+ else:
864
+ # Text format (default)
865
+ console.print("\n[cyan]Migration Analysis Summary[/cyan]")
866
+ console.print("=" * 80)
867
+ console.print(f"Migrations to apply: {len(migrations_to_apply)}")
868
+ console.print()
869
+ for mig in migration_summary["migrations"]:
870
+ console.print(f" {mig['version']}: {mig['name']}")
871
+ console.print(
872
+ f" Estimated time: {mig['estimated_duration_ms']}ms | "
873
+ f"Disk: {mig['estimated_disk_usage_mb']:.1f}MB | "
874
+ f"CPU: {mig['estimated_cpu_percent']:.0f}%"
875
+ )
876
+ console.print()
877
+ console.print("[green]āœ“ All migrations appear safe to execute[/green]")
878
+ console.print("=" * 80)
879
+
880
+ if output_file:
881
+ # Create a simple text report for file output
882
+ text_report = "DRY-RUN MIGRATION ANALYSIS REPORT\n"
883
+ text_report += "=" * 80 + "\n\n"
884
+ for mig in migration_summary["migrations"]:
885
+ text_report += f"{mig['version']}: {mig['name']}\n"
886
+ save_text_report(text_report, output_file)
887
+ console.print(
888
+ f"[green]āœ… Report saved to: {output_file.absolute()}[/green]"
889
+ )
890
+
891
+ # Stop here if dry-run only (not execute)
892
+ if dry_run and not dry_run_execute:
893
+ conn.close()
894
+ return
895
+
896
+ # For dry_run_execute: ask for confirmation
897
+ if dry_run_execute and not ask_dry_run_execute_confirmation():
898
+ console.print("[yellow]Cancelled - no changes applied[/yellow]")
899
+ conn.close()
900
+ return
901
+
902
+ # Continue to actual execution below
903
+
904
+ except Exception as e:
905
+ console.print(f"\n[red]āŒ Dry-run analysis failed: {e}[/red]")
906
+ conn.close()
907
+ raise typer.Exit(1) from e
908
+
909
+ # Configure locking
910
+ lock_config = LockConfig(
911
+ enabled=not no_lock,
912
+ timeout_ms=lock_timeout,
913
+ )
914
+
915
+ # Create lock manager
916
+ lock = MigrationLock(conn, lock_config)
917
+
918
+ # Apply migrations with distributed lock
919
+ applied_count = 0
920
+ failed_migration = None
921
+ failed_exception = None
922
+
923
+ try:
924
+ with lock.acquire():
925
+ if not no_lock:
926
+ console.print("[cyan]šŸ”’ Acquired migration lock[/cyan]\n")
927
+
928
+ for migration_file in migrations_to_apply:
929
+ # Load migration module
930
+ module = load_migration_module(migration_file)
931
+ migration_class = get_migration_class(module)
932
+
933
+ # Create migration instance
934
+ migration = migration_class(connection=conn)
935
+ # Override strict_mode from CLI/config if not already set on class
936
+ if effective_strict_mode and not getattr(migration_class, "strict_mode", False):
937
+ migration.strict_mode = effective_strict_mode
938
+
939
+ # Check target
940
+ if target and migration.version > target:
941
+ console.print(
942
+ f"[yellow]ā­ļø Skipping {migration.version} (after target)[/yellow]"
943
+ )
944
+ break
945
+
946
+ # Apply migration
947
+ console.print(
948
+ f"[cyan]⚔ Applying {migration.version}_{migration.name}...[/cyan]", end=" "
949
+ )
950
+
951
+ try:
952
+ migrator.apply(migration, force=force, migration_file=migration_file)
953
+ console.print("[green]āœ…[/green]")
954
+ applied_count += 1
955
+ except Exception as e:
956
+ console.print("[red]āŒ[/red]")
957
+ failed_migration = migration
958
+ failed_exception = e
959
+ break
960
+
961
+ except LockAcquisitionError as e:
962
+ console.print(f"\n[red]āŒ Failed to acquire migration lock: {e}[/red]")
963
+ if e.timeout:
964
+ console.print(
965
+ f"[yellow]šŸ’” Tip: Increase timeout with --lock-timeout {lock_timeout * 2}[/yellow]"
966
+ )
967
+ else:
968
+ console.print(
969
+ "[yellow]šŸ’” Tip: Check if another migration is running, or use --no-lock (dangerous)[/yellow]"
970
+ )
971
+ conn.close()
972
+ raise typer.Exit(1) from e
973
+
974
+ # Handle results
975
+ if failed_migration:
976
+ console.print("\n[red]āŒ Migration failed![/red]")
977
+ if applied_count > 0:
978
+ console.print(
979
+ f"[yellow]āš ļø {applied_count} migration(s) were applied successfully before the failure.[/yellow]"
980
+ )
981
+
982
+ # Show detailed error information
983
+ _show_migration_error_details(failed_migration, failed_exception, applied_count)
984
+ conn.close()
985
+ raise typer.Exit(1)
986
+ else:
987
+ if force:
988
+ console.print(
989
+ f"\n[green]āœ… Force mode: Successfully applied {applied_count} migration(s)![/green]"
990
+ )
991
+ console.print(
992
+ "[yellow]āš ļø Remember to verify your database state after force application[/yellow]"
993
+ )
994
+ else:
995
+ console.print(
996
+ f"\n[green]āœ… Successfully applied {applied_count} migration(s)![/green]"
997
+ )
998
+ conn.close()
999
+
1000
+ except LockAcquisitionError:
1001
+ # Already handled above
1002
+ raise
1003
+ except Exception as e:
1004
+ console.print(f"[red]āŒ Error: {e}[/red]")
1005
+ raise typer.Exit(1) from e
1006
+
1007
+
1008
+ def _show_migration_error_details(failed_migration, exception, applied_count: int) -> None:
1009
+ """Show detailed error information for a failed migration with actionable guidance.
1010
+
1011
+ Args:
1012
+ failed_migration: The Migration instance that failed
1013
+ exception: The exception that was raised
1014
+ applied_count: Number of migrations that succeeded before this one
1015
+ """
1016
+ from confiture.exceptions import MigrationError
1017
+
1018
+ console.print("\n[red]Failed Migration Details:[/red]")
1019
+ console.print(f" Version: {failed_migration.version}")
1020
+ console.print(f" Name: {failed_migration.name}")
1021
+ console.print(f" File: db/migrations/{failed_migration.version}_{failed_migration.name}.py")
1022
+
1023
+ # Analyze error type and provide specific guidance
1024
+ error_message = str(exception)
1025
+
1026
+ # Check if this is a SQL error wrapped in a MigrationError
1027
+ if "SQL execution failed" in error_message:
1028
+ console.print(" Error Type: SQL Execution Error")
1029
+
1030
+ # Extract SQL and error details from the message
1031
+ # Message format: "...SQL execution failed | SQL: ... | Error: ..."
1032
+ parts = error_message.split(" | ")
1033
+ sql_part = next((part for part in parts if part.startswith("SQL: ")), None)
1034
+ error_part = next((part for part in parts if part.startswith("Error: ")), None)
1035
+
1036
+ if sql_part:
1037
+ sql_content = sql_part[5:].strip() # Remove "SQL: " prefix
1038
+ console.print(
1039
+ f" SQL Statement: {sql_content[:100]}{'...' if len(sql_content) > 100 else ''}"
1040
+ )
1041
+
1042
+ if error_part:
1043
+ db_error = error_part[7:].strip() # Remove "Error: " prefix
1044
+ console.print(f" Database Error: {db_error.split(chr(10))[0]}")
1045
+
1046
+ # Specific SQL error guidance
1047
+ error_msg = db_error.lower()
1048
+ if "syntax error" in error_msg:
1049
+ console.print("\n[yellow]šŸ” SQL Syntax Error Detected:[/yellow]")
1050
+ console.print(" • Check for typos in SQL keywords, table names, or column names")
1051
+ console.print(
1052
+ " • Verify quotes, parentheses, and semicolons are properly balanced"
1053
+ )
1054
+ if sql_part:
1055
+ sql_content = sql_part[5:].strip()
1056
+ console.print(f' • Test the SQL manually: psql -c "{sql_content}"')
1057
+ elif "does not exist" in error_msg:
1058
+ if "schema" in error_msg:
1059
+ console.print("\n[yellow]šŸ” Missing Schema Error:[/yellow]")
1060
+ console.print(
1061
+ " • Create the schema first: CREATE SCHEMA IF NOT EXISTS schema_name;"
1062
+ )
1063
+ console.print(" • Or use the public schema by default")
1064
+ elif "table" in error_msg or "relation" in error_msg:
1065
+ console.print("\n[yellow]šŸ” Missing Table Error:[/yellow]")
1066
+ console.print(" • Ensure dependent migrations ran first")
1067
+ console.print(" • Check table name spelling and schema qualification")
1068
+ elif "function" in error_msg:
1069
+ console.print("\n[yellow]šŸ” Missing Function Error:[/yellow]")
1070
+ console.print(" • Define the function before using it")
1071
+ console.print(" • Check function name and parameter types")
1072
+ elif "already exists" in error_msg:
1073
+ console.print("\n[yellow]šŸ” Object Already Exists:[/yellow]")
1074
+ console.print(" • Use IF NOT EXISTS clauses for safe creation")
1075
+ console.print(" • Check if migration was partially applied")
1076
+ elif "permission denied" in error_msg:
1077
+ console.print("\n[yellow]šŸ” Permission Error:[/yellow]")
1078
+ console.print(" • Verify database user has required privileges")
1079
+ console.print(" • Check GRANT statements in earlier migrations")
1080
+
1081
+ elif isinstance(exception, MigrationError):
1082
+ console.print(" Error Type: Migration Framework Error")
1083
+ console.print(f" Message: {exception}")
1084
+
1085
+ # Migration-specific guidance
1086
+ error_msg = str(exception).lower()
1087
+ if "already been applied" in error_msg:
1088
+ console.print("\n[yellow]šŸ” Migration Already Applied:[/yellow]")
1089
+ console.print(" • Check migration status: confiture migrate status")
1090
+ console.print(" • This migration may have run successfully before")
1091
+ elif "connection" in error_msg:
1092
+ console.print("\n[yellow]šŸ” Database Connection Error:[/yellow]")
1093
+ console.print(" • Verify database is running and accessible")
1094
+ console.print(" • Check connection string in config file")
1095
+ console.print(" • Test connection: psql 'your-connection-string'")
1096
+
1097
+ else:
1098
+ console.print(f" Error Type: {type(exception).__name__}")
1099
+ console.print(f" Message: {exception}")
1100
+
1101
+ # General troubleshooting
1102
+ console.print("\n[yellow]šŸ› ļø General Troubleshooting:[/yellow]")
1103
+ console.print(
1104
+ f" • View migration file: cat db/migrations/{failed_migration.version}_{failed_migration.name}.py"
1105
+ )
1106
+ console.print(" • Check database logs for more details")
1107
+ console.print(" • Test SQL manually in psql")
1108
+
1109
+ if applied_count > 0:
1110
+ console.print(f" • {applied_count} migration(s) succeeded - database is partially updated")
1111
+ console.print(" • Fix the error and re-run: confiture migrate up")
1112
+ console.print(f" • Or rollback and retry: confiture migrate down --steps {applied_count}")
1113
+ else:
1114
+ console.print(" • No migrations applied yet - database state is clean")
1115
+ console.print(" • Fix the error and re-run: confiture migrate up")
1116
+
1117
+
1118
+ @migrate_app.command("generate")
1119
+ def migrate_generate(
1120
+ name: str = typer.Argument(..., help="Migration name (snake_case)"),
1121
+ migrations_dir: Path = typer.Option(
1122
+ Path("db/migrations"),
1123
+ "--migrations-dir",
1124
+ help="Migrations directory",
1125
+ ),
1126
+ ) -> None:
1127
+ """Generate a new migration file.
1128
+
1129
+ Creates an empty migration template with the given name.
1130
+ """
1131
+ try:
1132
+ # Ensure migrations directory exists
1133
+ migrations_dir.mkdir(parents=True, exist_ok=True)
1134
+
1135
+ # Generate migration file template
1136
+ generator = MigrationGenerator(migrations_dir=migrations_dir)
1137
+
1138
+ # For empty migration, create a template manually
1139
+ version = generator._get_next_version()
1140
+ class_name = generator._to_class_name(name)
1141
+ filename = f"{version}_{name}.py"
1142
+ filepath = migrations_dir / filename
1143
+
1144
+ # Create template
1145
+ template = f'''"""Migration: {name}
1146
+
1147
+ Version: {version}
1148
+ """
1149
+
1150
+ from confiture.models.migration import Migration
1151
+
1152
+
1153
+ class {class_name}(Migration):
1154
+ """Migration: {name}."""
1155
+
1156
+ version = "{version}"
1157
+ name = "{name}"
1158
+
1159
+ def up(self) -> None:
1160
+ """Apply migration."""
1161
+ # TODO: Add your SQL statements here
1162
+ # Example:
1163
+ # self.execute("CREATE TABLE users (id SERIAL PRIMARY KEY)")
1164
+ pass
1165
+
1166
+ def down(self) -> None:
1167
+ """Rollback migration."""
1168
+ # TODO: Add your rollback SQL statements here
1169
+ # Example:
1170
+ # self.execute("DROP TABLE users")
1171
+ pass
1172
+ '''
1173
+
1174
+ filepath.write_text(template)
1175
+
1176
+ console.print("[green]āœ… Migration generated successfully![/green]")
1177
+ # Use plain print to avoid Rich wrapping long paths
1178
+ print(f"\nšŸ“„ File: {filepath.absolute()}")
1179
+ console.print("\nāœļø Edit the migration file to add your SQL statements.")
1180
+
1181
+ except Exception as e:
1182
+ console.print(f"[red]āŒ Error generating migration: {e}[/red]")
1183
+ raise typer.Exit(1) from e
1184
+
1185
+
1186
+ @migrate_app.command("diff")
1187
+ def migrate_diff(
1188
+ old_schema: Path = typer.Argument(..., help="Old schema file"),
1189
+ new_schema: Path = typer.Argument(..., help="New schema file"),
1190
+ generate: bool = typer.Option(
1191
+ False,
1192
+ "--generate",
1193
+ help="Generate migration from diff",
1194
+ ),
1195
+ name: str = typer.Option(
1196
+ None,
1197
+ "--name",
1198
+ help="Migration name (required with --generate)",
1199
+ ),
1200
+ migrations_dir: Path = typer.Option(
1201
+ Path("db/migrations"),
1202
+ "--migrations-dir",
1203
+ help="Migrations directory",
1204
+ ),
1205
+ ) -> None:
1206
+ """Compare two schema files and show differences.
1207
+
1208
+ Optionally generate a migration file from the diff.
1209
+ """
1210
+ try:
1211
+ # Validate files exist
1212
+ if not old_schema.exists():
1213
+ console.print(f"[red]āŒ Old schema file not found: {old_schema}[/red]")
1214
+ raise typer.Exit(1)
1215
+
1216
+ if not new_schema.exists():
1217
+ console.print(f"[red]āŒ New schema file not found: {new_schema}[/red]")
1218
+ raise typer.Exit(1)
1219
+
1220
+ # Read schemas
1221
+ old_sql = old_schema.read_text()
1222
+ new_sql = new_schema.read_text()
1223
+
1224
+ # Compare schemas
1225
+ differ = SchemaDiffer()
1226
+ diff = differ.compare(old_sql, new_sql)
1227
+
1228
+ # Display diff
1229
+ if not diff.has_changes():
1230
+ console.print("[green]āœ… No changes detected. Schemas are identical.[/green]")
1231
+ return
1232
+
1233
+ console.print("[cyan]šŸ“Š Schema differences detected:[/cyan]\n")
1234
+
1235
+ # Display changes in a table
1236
+ table = Table()
1237
+ table.add_column("Type", style="yellow")
1238
+ table.add_column("Details", style="white")
1239
+
1240
+ for change in diff.changes:
1241
+ table.add_row(change.type, str(change))
1242
+
1243
+ console.print(table)
1244
+ console.print(f"\nšŸ“ˆ Total changes: {len(diff.changes)}")
1245
+
1246
+ # Generate migration if requested
1247
+ if generate:
1248
+ if not name:
1249
+ console.print("[red]āŒ Migration name is required when using --generate[/red]")
1250
+ console.print(
1251
+ "Usage: confiture migrate diff old.sql new.sql --generate --name migration_name"
1252
+ )
1253
+ raise typer.Exit(1)
1254
+
1255
+ # Ensure migrations directory exists
1256
+ migrations_dir.mkdir(parents=True, exist_ok=True)
1257
+
1258
+ # Generate migration
1259
+ generator = MigrationGenerator(migrations_dir=migrations_dir)
1260
+ migration_file = generator.generate(diff, name=name)
1261
+
1262
+ console.print(f"\n[green]āœ… Migration generated: {migration_file.name}[/green]")
1263
+
1264
+ except Exception as e:
1265
+ console.print(f"[red]āŒ Error: {e}[/red]")
1266
+ raise typer.Exit(1) from e
1267
+
1268
+
1269
+ @migrate_app.command("down")
1270
+ def migrate_down(
1271
+ migrations_dir: Path = typer.Option(
1272
+ Path("db/migrations"),
1273
+ "--migrations-dir",
1274
+ help="Migrations directory",
1275
+ ),
1276
+ config: Path = typer.Option(
1277
+ Path("db/environments/local.yaml"),
1278
+ "--config",
1279
+ "-c",
1280
+ help="Configuration file",
1281
+ ),
1282
+ steps: int = typer.Option(
1283
+ 1,
1284
+ "--steps",
1285
+ "-n",
1286
+ help="Number of migrations to rollback",
1287
+ ),
1288
+ dry_run: bool = typer.Option(
1289
+ False,
1290
+ "--dry-run",
1291
+ help="Analyze rollback without executing",
1292
+ ),
1293
+ verbose: bool = typer.Option(
1294
+ False,
1295
+ "--verbose",
1296
+ "-v",
1297
+ help="Show detailed analysis in dry-run report",
1298
+ ),
1299
+ format_output: str = typer.Option(
1300
+ "text",
1301
+ "--format",
1302
+ "-f",
1303
+ help="Report format (text or json)",
1304
+ ),
1305
+ output_file: Path | None = typer.Option(
1306
+ None,
1307
+ "--output",
1308
+ "-o",
1309
+ help="Save report to file",
1310
+ ),
1311
+ ) -> None:
1312
+ """Rollback applied migrations.
1313
+
1314
+ Rolls back the last N applied migrations (default: 1).
1315
+
1316
+ Use --dry-run to analyze rollback without executing.
1317
+ """
1318
+ from confiture.core.connection import (
1319
+ create_connection,
1320
+ get_migration_class,
1321
+ load_config,
1322
+ load_migration_module,
1323
+ )
1324
+ from confiture.core.migrator import Migrator
1325
+
1326
+ try:
1327
+ # Validate format option
1328
+ if format_output not in ("text", "json"):
1329
+ console.print(
1330
+ f"[red]āŒ Error: Invalid format '{format_output}'. Use 'text' or 'json'[/red]"
1331
+ )
1332
+ raise typer.Exit(1)
1333
+
1334
+ # Load configuration
1335
+ config_data = load_config(config)
1336
+
1337
+ # Create database connection
1338
+ conn = create_connection(config_data)
1339
+
1340
+ # Create migrator
1341
+ migrator = Migrator(connection=conn)
1342
+ migrator.initialize()
1343
+
1344
+ # Get applied migrations
1345
+ applied_versions = migrator.get_applied_versions()
1346
+
1347
+ if not applied_versions:
1348
+ console.print("[yellow]āš ļø No applied migrations to rollback.[/yellow]")
1349
+ conn.close()
1350
+ return
1351
+
1352
+ # Get migrations to rollback (last N)
1353
+ versions_to_rollback = applied_versions[-steps:]
1354
+
1355
+ # Handle dry-run mode
1356
+ if dry_run:
1357
+ from confiture.cli.dry_run import (
1358
+ display_dry_run_header,
1359
+ save_json_report,
1360
+ save_text_report,
1361
+ )
1362
+
1363
+ display_dry_run_header("analysis")
1364
+
1365
+ # Build rollback summary
1366
+ rollback_summary: dict[str, Any] = {
1367
+ "migration_id": f"dry_run_rollback_{config.stem}",
1368
+ "mode": "analysis",
1369
+ "statements_analyzed": len(versions_to_rollback),
1370
+ "migrations": [],
1371
+ "summary": {
1372
+ "unsafe_count": 0,
1373
+ "total_estimated_time_ms": 0,
1374
+ "total_estimated_disk_mb": 0.0,
1375
+ "has_unsafe_statements": False,
1376
+ },
1377
+ "warnings": [],
1378
+ "analyses": [],
1379
+ }
1380
+
1381
+ # Collect rollback migration information
1382
+ for version in reversed(versions_to_rollback):
1383
+ # Find migration file
1384
+ migration_files = migrator.find_migration_files(migrations_dir=migrations_dir)
1385
+ migration_file = None
1386
+ for mf in migration_files:
1387
+ if migrator._version_from_filename(mf.name) == version:
1388
+ migration_file = mf
1389
+ break
1390
+
1391
+ if not migration_file:
1392
+ continue
1393
+
1394
+ # Load migration module
1395
+ module = load_migration_module(migration_file)
1396
+ migration_class = get_migration_class(module)
1397
+
1398
+ migration = migration_class(connection=conn)
1399
+
1400
+ migration_info = {
1401
+ "version": migration.version,
1402
+ "name": migration.name,
1403
+ "classification": "warning",
1404
+ "estimated_duration_ms": 500,
1405
+ "estimated_disk_usage_mb": 1.0,
1406
+ "estimated_cpu_percent": 30.0,
1407
+ }
1408
+ rollback_summary["migrations"].append(migration_info)
1409
+ rollback_summary["analyses"].append(migration_info)
1410
+
1411
+ # Display format
1412
+ if format_output == "json":
1413
+ if output_file:
1414
+ save_json_report(rollback_summary, output_file)
1415
+ console.print(f"\n[green]āœ… Report saved to: {output_file.absolute()}[/green]")
1416
+ else:
1417
+ from confiture.cli.dry_run import print_json_report
1418
+
1419
+ print_json_report(rollback_summary)
1420
+ else:
1421
+ # Text format (default)
1422
+ console.print("[cyan]Rollback Analysis Summary[/cyan]")
1423
+ console.print("=" * 80)
1424
+ console.print(f"Migrations to rollback: {len(versions_to_rollback)}")
1425
+ console.print()
1426
+ for mig in rollback_summary["migrations"]:
1427
+ console.print(f" {mig['version']}: {mig['name']}")
1428
+ console.print(
1429
+ f" Estimated time: {mig['estimated_duration_ms']}ms | "
1430
+ f"Disk: {mig['estimated_disk_usage_mb']:.1f}MB | "
1431
+ f"CPU: {mig['estimated_cpu_percent']:.0f}%"
1432
+ )
1433
+ console.print()
1434
+ console.print("[yellow]āš ļø Rollback will undo these migrations[/yellow]")
1435
+ console.print("=" * 80)
1436
+
1437
+ if output_file:
1438
+ text_report = "DRY-RUN ROLLBACK ANALYSIS REPORT\n"
1439
+ text_report += "=" * 80 + "\n\n"
1440
+ for mig in rollback_summary["migrations"]:
1441
+ text_report += f"{mig['version']}: {mig['name']}\n"
1442
+ save_text_report(text_report, output_file)
1443
+ console.print(f"[green]āœ… Report saved to: {output_file.absolute()}[/green]")
1444
+
1445
+ conn.close()
1446
+ return
1447
+
1448
+ console.print(f"[cyan]šŸ“¦ Rolling back {len(versions_to_rollback)} migration(s)[/cyan]\n")
1449
+
1450
+ # Rollback migrations in reverse order
1451
+ rolled_back_count = 0
1452
+ for version in reversed(versions_to_rollback):
1453
+ # Find migration file
1454
+ migration_files = migrator.find_migration_files(migrations_dir=migrations_dir)
1455
+ migration_file = None
1456
+ for mf in migration_files:
1457
+ if migrator._version_from_filename(mf.name) == version:
1458
+ migration_file = mf
1459
+ break
1460
+
1461
+ if not migration_file:
1462
+ console.print(f"[red]āŒ Migration file for version {version} not found[/red]")
1463
+ continue
1464
+
1465
+ # Load migration module
1466
+ module = load_migration_module(migration_file)
1467
+ migration_class = get_migration_class(module)
1468
+
1469
+ # Create migration instance
1470
+ migration = migration_class(connection=conn)
1471
+
1472
+ # Rollback migration
1473
+ console.print(
1474
+ f"[cyan]⚔ Rolling back {migration.version}_{migration.name}...[/cyan]", end=" "
1475
+ )
1476
+ migrator.rollback(migration)
1477
+ console.print("[green]āœ…[/green]")
1478
+ rolled_back_count += 1
1479
+
1480
+ console.print(
1481
+ f"\n[green]āœ… Successfully rolled back {rolled_back_count} migration(s)![/green]"
1482
+ )
1483
+ conn.close()
1484
+
1485
+ except Exception as e:
1486
+ console.print(f"[red]āŒ Error: {e}[/red]")
1487
+ raise typer.Exit(1) from e
1488
+
1489
+
1490
+ @app.command()
1491
+ def validate_profile(
1492
+ path: Path = typer.Argument(
1493
+ ...,
1494
+ help="Path to anonymization profile YAML file",
1495
+ ),
1496
+ ) -> None:
1497
+ """Validate anonymization profile YAML structure and schema.
1498
+
1499
+ Performs security validation:
1500
+ - Uses safe_load() to prevent YAML injection
1501
+ - Validates against Pydantic schema
1502
+ - Checks strategy types are whitelisted
1503
+ - Verifies all required fields present
1504
+
1505
+ Example:
1506
+ confiture validate-profile db/profiles/production.yaml
1507
+ """
1508
+ try:
1509
+ from confiture.core.anonymization.profile import AnonymizationProfile
1510
+
1511
+ console.print(f"[cyan]šŸ“‹ Validating profile: {path}[/cyan]")
1512
+ profile = AnonymizationProfile.load(path)
1513
+
1514
+ # Print profile summary
1515
+ console.print("[green]āœ… Valid profile![/green]")
1516
+ console.print(f" Name: {profile.name}")
1517
+ console.print(f" Version: {profile.version}")
1518
+ if profile.global_seed:
1519
+ console.print(f" Global Seed: {profile.global_seed}")
1520
+
1521
+ # List strategies
1522
+ console.print(f"\n[cyan]Strategies ({len(profile.strategies)})[/cyan]:")
1523
+ for strategy_name, strategy_def in profile.strategies.items():
1524
+ console.print(
1525
+ f" • {strategy_name}: {strategy_def.type}",
1526
+ end="",
1527
+ )
1528
+ if strategy_def.seed_env_var:
1529
+ console.print(f" [env: {strategy_def.seed_env_var}]")
1530
+ else:
1531
+ console.print()
1532
+
1533
+ # List tables
1534
+ console.print(f"\n[cyan]Tables ({len(profile.tables)})[/cyan]:")
1535
+ for table_name, table_def in profile.tables.items():
1536
+ console.print(f" • {table_name}: {len(table_def.rules)} rules")
1537
+ for rule in table_def.rules:
1538
+ console.print(f" - {rule.column} → {rule.strategy}", end="")
1539
+ if rule.seed:
1540
+ console.print(f" [seed: {rule.seed}]")
1541
+ else:
1542
+ console.print()
1543
+
1544
+ console.print("[green]\nāœ… Profile validation passed![/green]")
1545
+
1546
+ except FileNotFoundError as e:
1547
+ console.print(f"[red]āŒ File not found: {e}[/red]")
1548
+ raise typer.Exit(1) from e
1549
+ except ValueError as e:
1550
+ console.print(f"[red]āŒ Invalid profile: {e}[/red]")
1551
+ raise typer.Exit(1) from e
1552
+ except Exception as e:
1553
+ console.print(f"[red]āŒ Error validating profile: {e}[/red]")
1554
+ raise typer.Exit(1) from e
1555
+
1556
+
1557
+ @app.command()
1558
+ def verify(
1559
+ migrations_dir: Path = typer.Option(
1560
+ Path("db/migrations"),
1561
+ "--migrations-dir",
1562
+ help="Migrations directory",
1563
+ ),
1564
+ config: Path = typer.Option(
1565
+ Path("db/environments/local.yaml"),
1566
+ "--config",
1567
+ "-c",
1568
+ help="Configuration file",
1569
+ ),
1570
+ fix: bool = typer.Option(
1571
+ False,
1572
+ "--fix",
1573
+ help="Update stored checksums to match current files (dangerous)",
1574
+ ),
1575
+ ) -> None:
1576
+ """Verify migration file integrity against stored checksums.
1577
+
1578
+ Compares SHA-256 checksums of migration files against the checksums
1579
+ stored when migrations were applied. Detects if files have been
1580
+ modified after application.
1581
+
1582
+ This helps prevent:
1583
+ - Silent schema drift between environments
1584
+ - Production/staging mismatches
1585
+ - Debugging nightmares from modified migrations
1586
+
1587
+ Examples:
1588
+ # Verify all migrations
1589
+ confiture verify
1590
+
1591
+ # Verify with specific config
1592
+ confiture verify --config db/environments/production.yaml
1593
+
1594
+ # Fix checksums (update stored to match current files)
1595
+ confiture verify --fix
1596
+ """
1597
+ from confiture.core.checksum import (
1598
+ ChecksumConfig,
1599
+ ChecksumMismatchBehavior,
1600
+ MigrationChecksumVerifier,
1601
+ )
1602
+ from confiture.core.connection import create_connection, load_config
1603
+
1604
+ try:
1605
+ # Load config and connect
1606
+ config_data = load_config(config)
1607
+ conn = create_connection(config_data)
1608
+
1609
+ # Run verification (warn mode - we'll handle display)
1610
+ verifier = MigrationChecksumVerifier(
1611
+ conn,
1612
+ ChecksumConfig(
1613
+ enabled=True,
1614
+ on_mismatch=ChecksumMismatchBehavior.WARN,
1615
+ ),
1616
+ )
1617
+ mismatches = verifier.verify_all(migrations_dir)
1618
+
1619
+ if not mismatches:
1620
+ console.print("[green]āœ… All migration checksums verified![/green]")
1621
+ conn.close()
1622
+ return
1623
+
1624
+ # Display mismatches
1625
+ console.print(f"[red]āŒ Found {len(mismatches)} checksum mismatch(es):[/red]\n")
1626
+
1627
+ for m in mismatches:
1628
+ console.print(f" [yellow]{m.version}_{m.name}[/yellow]")
1629
+ console.print(f" File: {m.file_path}")
1630
+ console.print(f" Expected: {m.expected[:16]}...")
1631
+ console.print(f" Actual: {m.actual[:16]}...")
1632
+ console.print()
1633
+
1634
+ if fix:
1635
+ # Update checksums in database
1636
+ console.print("[yellow]āš ļø Updating stored checksums...[/yellow]")
1637
+ updated = verifier.update_all_checksums(migrations_dir)
1638
+ console.print(f"[green]āœ… Updated {updated} checksum(s)[/green]")
1639
+ else:
1640
+ console.print(
1641
+ "[yellow]šŸ’” Tip: Use --fix to update stored checksums (dangerous)[/yellow]"
1642
+ )
1643
+ conn.close()
1644
+ raise typer.Exit(1)
1645
+
1646
+ conn.close()
1647
+
1648
+ except typer.Exit:
1649
+ raise
1650
+ except Exception as e:
1651
+ console.print(f"[red]āŒ Error: {e}[/red]")
1652
+ raise typer.Exit(1) from e
1653
+
1654
+
1655
+ if __name__ == "__main__":
1656
+ app()