fraiseql-confiture 0.1.0__cp311-cp311-manylinux_2_34_x86_64.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.
Potentially problematic release.
This version of fraiseql-confiture might be problematic. Click here for more details.
- confiture/__init__.py +45 -0
- confiture/_core.cpython-311-x86_64-linux-gnu.so +0 -0
- confiture/cli/__init__.py +0 -0
- confiture/cli/main.py +720 -0
- confiture/config/__init__.py +0 -0
- confiture/config/environment.py +190 -0
- confiture/core/__init__.py +0 -0
- confiture/core/builder.py +336 -0
- confiture/core/connection.py +120 -0
- confiture/core/differ.py +522 -0
- confiture/core/migration_generator.py +298 -0
- confiture/core/migrator.py +369 -0
- confiture/core/schema_to_schema.py +592 -0
- confiture/core/syncer.py +540 -0
- confiture/exceptions.py +141 -0
- confiture/integrations/__init__.py +0 -0
- confiture/models/__init__.py +0 -0
- confiture/models/migration.py +95 -0
- confiture/models/schema.py +203 -0
- fraiseql_confiture-0.1.0.dist-info/METADATA +350 -0
- fraiseql_confiture-0.1.0.dist-info/RECORD +24 -0
- fraiseql_confiture-0.1.0.dist-info/WHEEL +4 -0
- fraiseql_confiture-0.1.0.dist-info/entry_points.txt +2 -0
- fraiseql_confiture-0.1.0.dist-info/licenses/LICENSE +21 -0
confiture/cli/main.py
ADDED
|
@@ -0,0 +1,720 @@
|
|
|
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
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from confiture.core.builder import SchemaBuilder
|
|
13
|
+
from confiture.core.differ import SchemaDiffer
|
|
14
|
+
from confiture.core.migration_generator import MigrationGenerator
|
|
15
|
+
|
|
16
|
+
# Create Typer app
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
name="confiture",
|
|
19
|
+
help="PostgreSQL migrations, sweetly done 🍓",
|
|
20
|
+
add_completion=False,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Create Rich console for pretty output
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
# Version
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def version_callback(value: bool) -> None:
|
|
31
|
+
"""Print version and exit."""
|
|
32
|
+
if value:
|
|
33
|
+
console.print(f"confiture version {__version__}")
|
|
34
|
+
raise typer.Exit()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.callback()
|
|
38
|
+
def main(
|
|
39
|
+
version: bool = typer.Option(
|
|
40
|
+
False,
|
|
41
|
+
"--version",
|
|
42
|
+
callback=version_callback,
|
|
43
|
+
is_eager=True,
|
|
44
|
+
help="Show version and exit",
|
|
45
|
+
),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Confiture - PostgreSQL migrations, sweetly done 🍓."""
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command()
|
|
52
|
+
def init(
|
|
53
|
+
path: Path = typer.Argument(
|
|
54
|
+
Path("."),
|
|
55
|
+
help="Project directory to initialize",
|
|
56
|
+
),
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Initialize a new Confiture project.
|
|
59
|
+
|
|
60
|
+
Creates necessary directory structure and configuration files.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
# Create directory structure
|
|
64
|
+
db_dir = path / "db"
|
|
65
|
+
schema_dir = db_dir / "schema"
|
|
66
|
+
seeds_dir = db_dir / "seeds"
|
|
67
|
+
migrations_dir = db_dir / "migrations"
|
|
68
|
+
environments_dir = db_dir / "environments"
|
|
69
|
+
|
|
70
|
+
# Check if already initialized
|
|
71
|
+
if db_dir.exists():
|
|
72
|
+
console.print(
|
|
73
|
+
"[yellow]⚠️ Project already exists. Some files may be overwritten.[/yellow]"
|
|
74
|
+
)
|
|
75
|
+
if not typer.confirm("Continue?"):
|
|
76
|
+
raise typer.Exit()
|
|
77
|
+
|
|
78
|
+
# Create directories
|
|
79
|
+
schema_dir.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
(seeds_dir / "common").mkdir(parents=True, exist_ok=True)
|
|
81
|
+
(seeds_dir / "development").mkdir(parents=True, exist_ok=True)
|
|
82
|
+
(seeds_dir / "test").mkdir(parents=True, exist_ok=True)
|
|
83
|
+
migrations_dir.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
environments_dir.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
|
|
86
|
+
# Create example schema directory structure
|
|
87
|
+
(schema_dir / "00_common").mkdir(exist_ok=True)
|
|
88
|
+
(schema_dir / "10_tables").mkdir(exist_ok=True)
|
|
89
|
+
|
|
90
|
+
# Create example schema file
|
|
91
|
+
example_schema = schema_dir / "00_common" / "extensions.sql"
|
|
92
|
+
example_schema.write_text(
|
|
93
|
+
"""-- PostgreSQL extensions
|
|
94
|
+
-- Add commonly used extensions here
|
|
95
|
+
|
|
96
|
+
-- Example:
|
|
97
|
+
-- CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
98
|
+
-- CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
|
99
|
+
"""
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Create example table
|
|
103
|
+
example_table = schema_dir / "10_tables" / "example.sql"
|
|
104
|
+
example_table.write_text(
|
|
105
|
+
"""-- Example table
|
|
106
|
+
-- Replace with your actual schema
|
|
107
|
+
|
|
108
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
109
|
+
id SERIAL PRIMARY KEY,
|
|
110
|
+
username TEXT NOT NULL UNIQUE,
|
|
111
|
+
email TEXT NOT NULL UNIQUE,
|
|
112
|
+
created_at TIMESTAMP DEFAULT NOW()
|
|
113
|
+
);
|
|
114
|
+
"""
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Create example seed file
|
|
118
|
+
example_seed = seeds_dir / "common" / "00_example.sql"
|
|
119
|
+
example_seed.write_text(
|
|
120
|
+
"""-- Common seed data
|
|
121
|
+
-- These records are included in all non-production environments
|
|
122
|
+
|
|
123
|
+
-- Example: Test users
|
|
124
|
+
-- INSERT INTO users (username, email) VALUES
|
|
125
|
+
-- ('admin', 'admin@example.com'),
|
|
126
|
+
-- ('editor', 'editor@example.com'),
|
|
127
|
+
-- ('reader', 'reader@example.com');
|
|
128
|
+
"""
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Create local environment config
|
|
132
|
+
local_config = environments_dir / "local.yaml"
|
|
133
|
+
local_config.write_text(
|
|
134
|
+
"""# Local development environment configuration
|
|
135
|
+
|
|
136
|
+
name: local
|
|
137
|
+
include_dirs:
|
|
138
|
+
- db/schema/00_common
|
|
139
|
+
- db/schema/10_tables
|
|
140
|
+
exclude_dirs: []
|
|
141
|
+
|
|
142
|
+
database:
|
|
143
|
+
host: localhost
|
|
144
|
+
port: 5432
|
|
145
|
+
database: myapp_local
|
|
146
|
+
user: postgres
|
|
147
|
+
password: postgres
|
|
148
|
+
"""
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Create README
|
|
152
|
+
readme = db_dir / "README.md"
|
|
153
|
+
readme.write_text(
|
|
154
|
+
"""# Database Schema
|
|
155
|
+
|
|
156
|
+
This directory contains your database schema and migrations.
|
|
157
|
+
|
|
158
|
+
## Directory Structure
|
|
159
|
+
|
|
160
|
+
- `schema/` - DDL files organized by category
|
|
161
|
+
- `00_common/` - Extensions, types, functions
|
|
162
|
+
- `10_tables/` - Table definitions
|
|
163
|
+
- `migrations/` - Python migration files
|
|
164
|
+
- `environments/` - Environment-specific configurations
|
|
165
|
+
|
|
166
|
+
## Quick Start
|
|
167
|
+
|
|
168
|
+
1. Edit schema files in `schema/`
|
|
169
|
+
2. Generate migrations: `confiture migrate diff old.sql new.sql --generate`
|
|
170
|
+
3. Apply migrations: `confiture migrate up`
|
|
171
|
+
|
|
172
|
+
## Learn More
|
|
173
|
+
|
|
174
|
+
Documentation: https://github.com/evoludigit/confiture
|
|
175
|
+
"""
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
console.print("[green]✅ Confiture project initialized successfully![/green]")
|
|
179
|
+
console.print(f"\n📁 Created structure in: {path.absolute()}")
|
|
180
|
+
console.print("\n📝 Next steps:")
|
|
181
|
+
console.print(" 1. Edit your schema files in db/schema/")
|
|
182
|
+
console.print(" 2. Configure environments in db/environments/")
|
|
183
|
+
console.print(" 3. Run 'confiture migrate diff' to detect changes")
|
|
184
|
+
|
|
185
|
+
except Exception as e:
|
|
186
|
+
console.print(f"[red]❌ Error initializing project: {e}[/red]")
|
|
187
|
+
raise typer.Exit(1) from e
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@app.command()
|
|
191
|
+
def build(
|
|
192
|
+
env: str = typer.Option(
|
|
193
|
+
"local",
|
|
194
|
+
"--env",
|
|
195
|
+
"-e",
|
|
196
|
+
help="Environment to build (references db/environments/{env}.yaml)",
|
|
197
|
+
),
|
|
198
|
+
output: Path = typer.Option(
|
|
199
|
+
None,
|
|
200
|
+
"--output",
|
|
201
|
+
"-o",
|
|
202
|
+
help="Output file path (default: db/generated/schema_{env}.sql)",
|
|
203
|
+
),
|
|
204
|
+
project_dir: Path = typer.Option(
|
|
205
|
+
Path("."),
|
|
206
|
+
"--project-dir",
|
|
207
|
+
help="Project directory (default: current directory)",
|
|
208
|
+
),
|
|
209
|
+
show_hash: bool = typer.Option(
|
|
210
|
+
False,
|
|
211
|
+
"--show-hash",
|
|
212
|
+
help="Display schema hash after build",
|
|
213
|
+
),
|
|
214
|
+
schema_only: bool = typer.Option(
|
|
215
|
+
False,
|
|
216
|
+
"--schema-only",
|
|
217
|
+
help="Build schema only, exclude seed data",
|
|
218
|
+
),
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Build complete schema from DDL files.
|
|
221
|
+
|
|
222
|
+
This command builds a complete schema by concatenating all SQL files
|
|
223
|
+
from the db/schema/ directory in deterministic order. This is the
|
|
224
|
+
fastest way to create or recreate a database from scratch.
|
|
225
|
+
|
|
226
|
+
The build process:
|
|
227
|
+
1. Reads environment configuration (db/environments/{env}.yaml)
|
|
228
|
+
2. Discovers all .sql files in configured include_dirs
|
|
229
|
+
3. Concatenates files in alphabetical order
|
|
230
|
+
4. Adds metadata headers (environment, file count, timestamp)
|
|
231
|
+
5. Writes to output file (default: db/generated/schema_{env}.sql)
|
|
232
|
+
|
|
233
|
+
Examples:
|
|
234
|
+
# Build local environment schema
|
|
235
|
+
confiture build
|
|
236
|
+
|
|
237
|
+
# Build for specific environment
|
|
238
|
+
confiture build --env production
|
|
239
|
+
|
|
240
|
+
# Custom output location
|
|
241
|
+
confiture build --output /tmp/schema.sql
|
|
242
|
+
|
|
243
|
+
# Show hash for change detection
|
|
244
|
+
confiture build --show-hash
|
|
245
|
+
"""
|
|
246
|
+
try:
|
|
247
|
+
# Create schema builder
|
|
248
|
+
builder = SchemaBuilder(env=env, project_dir=project_dir)
|
|
249
|
+
|
|
250
|
+
# Override to exclude seeds if --schema-only is specified
|
|
251
|
+
if schema_only:
|
|
252
|
+
builder.include_dirs = [d for d in builder.include_dirs if "seed" not in str(d).lower()]
|
|
253
|
+
# Recalculate base_dir after filtering
|
|
254
|
+
if builder.include_dirs:
|
|
255
|
+
builder.base_dir = builder._find_common_parent(builder.include_dirs)
|
|
256
|
+
|
|
257
|
+
# Set default output path if not specified
|
|
258
|
+
if output is None:
|
|
259
|
+
output_dir = project_dir / "db" / "generated"
|
|
260
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
output = output_dir / f"schema_{env}.sql"
|
|
262
|
+
|
|
263
|
+
# Build schema
|
|
264
|
+
console.print(f"[cyan]🔨 Building schema for environment: {env}[/cyan]")
|
|
265
|
+
|
|
266
|
+
sql_files = builder.find_sql_files()
|
|
267
|
+
console.print(f"[cyan]📄 Found {len(sql_files)} SQL files[/cyan]")
|
|
268
|
+
|
|
269
|
+
schema = builder.build(output_path=output)
|
|
270
|
+
|
|
271
|
+
# Success message
|
|
272
|
+
console.print("[green]✅ Schema built successfully![/green]")
|
|
273
|
+
console.print(f"\n📁 Output: {output.absolute()}")
|
|
274
|
+
console.print(f"📏 Size: {len(schema):,} bytes")
|
|
275
|
+
console.print(f"📊 Files: {len(sql_files)}")
|
|
276
|
+
|
|
277
|
+
# Show hash if requested
|
|
278
|
+
if show_hash:
|
|
279
|
+
schema_hash = builder.compute_hash()
|
|
280
|
+
console.print(f"🔐 Hash: {schema_hash}")
|
|
281
|
+
|
|
282
|
+
console.print("\n💡 Next steps:")
|
|
283
|
+
console.print(f" • Apply schema: psql -f {output}")
|
|
284
|
+
console.print(" • Or use: confiture migrate up")
|
|
285
|
+
|
|
286
|
+
except FileNotFoundError as e:
|
|
287
|
+
console.print(f"[red]❌ File not found: {e}[/red]")
|
|
288
|
+
console.print("\n💡 Tip: Run 'confiture init' to create project structure")
|
|
289
|
+
raise typer.Exit(1) from e
|
|
290
|
+
except Exception as e:
|
|
291
|
+
console.print(f"[red]❌ Error building schema: {e}[/red]")
|
|
292
|
+
raise typer.Exit(1) from e
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# Create migrate subcommand group
|
|
296
|
+
migrate_app = typer.Typer(help="Migration commands")
|
|
297
|
+
app.add_typer(migrate_app, name="migrate")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@migrate_app.command("status")
|
|
301
|
+
def migrate_status(
|
|
302
|
+
migrations_dir: Path = typer.Option(
|
|
303
|
+
Path("db/migrations"),
|
|
304
|
+
"--migrations-dir",
|
|
305
|
+
help="Migrations directory",
|
|
306
|
+
),
|
|
307
|
+
config: Path = typer.Option(
|
|
308
|
+
None,
|
|
309
|
+
"--config",
|
|
310
|
+
"-c",
|
|
311
|
+
help="Configuration file (optional, to show applied status)",
|
|
312
|
+
),
|
|
313
|
+
) -> None:
|
|
314
|
+
"""Show migration status.
|
|
315
|
+
|
|
316
|
+
If config is provided, shows which migrations are applied vs pending.
|
|
317
|
+
"""
|
|
318
|
+
try:
|
|
319
|
+
if not migrations_dir.exists():
|
|
320
|
+
console.print("[yellow]No migrations directory found.[/yellow]")
|
|
321
|
+
console.print(f"Expected: {migrations_dir.absolute()}")
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
# Find migration files
|
|
325
|
+
migration_files = sorted(migrations_dir.glob("*.py"))
|
|
326
|
+
|
|
327
|
+
if not migration_files:
|
|
328
|
+
console.print("[yellow]No migrations found.[/yellow]")
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
# Get applied migrations from database if config provided
|
|
332
|
+
applied_versions = set()
|
|
333
|
+
if config and config.exists():
|
|
334
|
+
try:
|
|
335
|
+
from confiture.core.connection import create_connection, load_config
|
|
336
|
+
from confiture.core.migrator import Migrator
|
|
337
|
+
|
|
338
|
+
config_data = load_config(config)
|
|
339
|
+
conn = create_connection(config_data)
|
|
340
|
+
migrator = Migrator(connection=conn)
|
|
341
|
+
migrator.initialize()
|
|
342
|
+
applied_versions = set(migrator.get_applied_versions())
|
|
343
|
+
conn.close()
|
|
344
|
+
except Exception as e:
|
|
345
|
+
console.print(f"[yellow]⚠️ Could not connect to database: {e}[/yellow]")
|
|
346
|
+
console.print("[yellow]Showing file list only (status unknown)[/yellow]\n")
|
|
347
|
+
|
|
348
|
+
# Display migrations in a table
|
|
349
|
+
table = Table(title="Migrations")
|
|
350
|
+
table.add_column("Version", style="cyan")
|
|
351
|
+
table.add_column("Name", style="green")
|
|
352
|
+
table.add_column("Status", style="yellow")
|
|
353
|
+
|
|
354
|
+
pending_count = 0
|
|
355
|
+
applied_count = 0
|
|
356
|
+
|
|
357
|
+
for migration_file in migration_files:
|
|
358
|
+
# Extract version and name from filename (e.g., "001_add_users.py")
|
|
359
|
+
parts = migration_file.stem.split("_", 1)
|
|
360
|
+
version = parts[0] if len(parts) > 0 else "???"
|
|
361
|
+
name = parts[1] if len(parts) > 1 else migration_file.stem
|
|
362
|
+
|
|
363
|
+
# Determine status
|
|
364
|
+
if applied_versions:
|
|
365
|
+
if version in applied_versions:
|
|
366
|
+
status = "[green]✅ applied[/green]"
|
|
367
|
+
applied_count += 1
|
|
368
|
+
else:
|
|
369
|
+
status = "[yellow]⏳ pending[/yellow]"
|
|
370
|
+
pending_count += 1
|
|
371
|
+
else:
|
|
372
|
+
status = "unknown"
|
|
373
|
+
|
|
374
|
+
table.add_row(version, name, status)
|
|
375
|
+
|
|
376
|
+
console.print(table)
|
|
377
|
+
console.print(f"\n📊 Total: {len(migration_files)} migrations", end="")
|
|
378
|
+
if applied_versions:
|
|
379
|
+
console.print(f" ({applied_count} applied, {pending_count} pending)")
|
|
380
|
+
else:
|
|
381
|
+
console.print()
|
|
382
|
+
|
|
383
|
+
except Exception as e:
|
|
384
|
+
console.print(f"[red]❌ Error: {e}[/red]")
|
|
385
|
+
raise typer.Exit(1) from e
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@migrate_app.command("generate")
|
|
389
|
+
def migrate_generate(
|
|
390
|
+
name: str = typer.Argument(..., help="Migration name (snake_case)"),
|
|
391
|
+
migrations_dir: Path = typer.Option(
|
|
392
|
+
Path("db/migrations"),
|
|
393
|
+
"--migrations-dir",
|
|
394
|
+
help="Migrations directory",
|
|
395
|
+
),
|
|
396
|
+
) -> None:
|
|
397
|
+
"""Generate a new migration file.
|
|
398
|
+
|
|
399
|
+
Creates an empty migration template with the given name.
|
|
400
|
+
"""
|
|
401
|
+
try:
|
|
402
|
+
# Ensure migrations directory exists
|
|
403
|
+
migrations_dir.mkdir(parents=True, exist_ok=True)
|
|
404
|
+
|
|
405
|
+
# Generate migration file template
|
|
406
|
+
generator = MigrationGenerator(migrations_dir=migrations_dir)
|
|
407
|
+
|
|
408
|
+
# For empty migration, create a template manually
|
|
409
|
+
version = generator._get_next_version()
|
|
410
|
+
class_name = generator._to_class_name(name)
|
|
411
|
+
filename = f"{version}_{name}.py"
|
|
412
|
+
filepath = migrations_dir / filename
|
|
413
|
+
|
|
414
|
+
# Create template
|
|
415
|
+
template = f'''"""Migration: {name}
|
|
416
|
+
|
|
417
|
+
Version: {version}
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
from confiture.models.migration import Migration
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class {class_name}(Migration):
|
|
424
|
+
"""Migration: {name}."""
|
|
425
|
+
|
|
426
|
+
version = "{version}"
|
|
427
|
+
name = "{name}"
|
|
428
|
+
|
|
429
|
+
def up(self) -> None:
|
|
430
|
+
"""Apply migration."""
|
|
431
|
+
# TODO: Add your SQL statements here
|
|
432
|
+
# Example:
|
|
433
|
+
# self.execute("CREATE TABLE users (id SERIAL PRIMARY KEY)")
|
|
434
|
+
pass
|
|
435
|
+
|
|
436
|
+
def down(self) -> None:
|
|
437
|
+
"""Rollback migration."""
|
|
438
|
+
# TODO: Add your rollback SQL statements here
|
|
439
|
+
# Example:
|
|
440
|
+
# self.execute("DROP TABLE users")
|
|
441
|
+
pass
|
|
442
|
+
'''
|
|
443
|
+
|
|
444
|
+
filepath.write_text(template)
|
|
445
|
+
|
|
446
|
+
console.print("[green]✅ Migration generated successfully![/green]")
|
|
447
|
+
# Use plain print to avoid Rich wrapping long paths
|
|
448
|
+
print(f"\n📄 File: {filepath.absolute()}")
|
|
449
|
+
console.print("\n✏️ Edit the migration file to add your SQL statements.")
|
|
450
|
+
|
|
451
|
+
except Exception as e:
|
|
452
|
+
console.print(f"[red]❌ Error generating migration: {e}[/red]")
|
|
453
|
+
raise typer.Exit(1) from e
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@migrate_app.command("diff")
|
|
457
|
+
def migrate_diff(
|
|
458
|
+
old_schema: Path = typer.Argument(..., help="Old schema file"),
|
|
459
|
+
new_schema: Path = typer.Argument(..., help="New schema file"),
|
|
460
|
+
generate: bool = typer.Option(
|
|
461
|
+
False,
|
|
462
|
+
"--generate",
|
|
463
|
+
help="Generate migration from diff",
|
|
464
|
+
),
|
|
465
|
+
name: str = typer.Option(
|
|
466
|
+
None,
|
|
467
|
+
"--name",
|
|
468
|
+
help="Migration name (required with --generate)",
|
|
469
|
+
),
|
|
470
|
+
migrations_dir: Path = typer.Option(
|
|
471
|
+
Path("db/migrations"),
|
|
472
|
+
"--migrations-dir",
|
|
473
|
+
help="Migrations directory",
|
|
474
|
+
),
|
|
475
|
+
) -> None:
|
|
476
|
+
"""Compare two schema files and show differences.
|
|
477
|
+
|
|
478
|
+
Optionally generate a migration file from the diff.
|
|
479
|
+
"""
|
|
480
|
+
try:
|
|
481
|
+
# Validate files exist
|
|
482
|
+
if not old_schema.exists():
|
|
483
|
+
console.print(f"[red]❌ Old schema file not found: {old_schema}[/red]")
|
|
484
|
+
raise typer.Exit(1)
|
|
485
|
+
|
|
486
|
+
if not new_schema.exists():
|
|
487
|
+
console.print(f"[red]❌ New schema file not found: {new_schema}[/red]")
|
|
488
|
+
raise typer.Exit(1)
|
|
489
|
+
|
|
490
|
+
# Read schemas
|
|
491
|
+
old_sql = old_schema.read_text()
|
|
492
|
+
new_sql = new_schema.read_text()
|
|
493
|
+
|
|
494
|
+
# Compare schemas
|
|
495
|
+
differ = SchemaDiffer()
|
|
496
|
+
diff = differ.compare(old_sql, new_sql)
|
|
497
|
+
|
|
498
|
+
# Display diff
|
|
499
|
+
if not diff.has_changes():
|
|
500
|
+
console.print("[green]✅ No changes detected. Schemas are identical.[/green]")
|
|
501
|
+
return
|
|
502
|
+
|
|
503
|
+
console.print("[cyan]📊 Schema differences detected:[/cyan]\n")
|
|
504
|
+
|
|
505
|
+
# Display changes in a table
|
|
506
|
+
table = Table()
|
|
507
|
+
table.add_column("Type", style="yellow")
|
|
508
|
+
table.add_column("Details", style="white")
|
|
509
|
+
|
|
510
|
+
for change in diff.changes:
|
|
511
|
+
table.add_row(change.type, str(change))
|
|
512
|
+
|
|
513
|
+
console.print(table)
|
|
514
|
+
console.print(f"\n📈 Total changes: {len(diff.changes)}")
|
|
515
|
+
|
|
516
|
+
# Generate migration if requested
|
|
517
|
+
if generate:
|
|
518
|
+
if not name:
|
|
519
|
+
console.print("[red]❌ Migration name is required when using --generate[/red]")
|
|
520
|
+
console.print(
|
|
521
|
+
"Usage: confiture migrate diff old.sql new.sql --generate --name migration_name"
|
|
522
|
+
)
|
|
523
|
+
raise typer.Exit(1)
|
|
524
|
+
|
|
525
|
+
# Ensure migrations directory exists
|
|
526
|
+
migrations_dir.mkdir(parents=True, exist_ok=True)
|
|
527
|
+
|
|
528
|
+
# Generate migration
|
|
529
|
+
generator = MigrationGenerator(migrations_dir=migrations_dir)
|
|
530
|
+
migration_file = generator.generate(diff, name=name)
|
|
531
|
+
|
|
532
|
+
console.print(f"\n[green]✅ Migration generated: {migration_file.name}[/green]")
|
|
533
|
+
|
|
534
|
+
except Exception as e:
|
|
535
|
+
console.print(f"[red]❌ Error: {e}[/red]")
|
|
536
|
+
raise typer.Exit(1) from e
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@migrate_app.command("up")
|
|
540
|
+
def migrate_up(
|
|
541
|
+
migrations_dir: Path = typer.Option(
|
|
542
|
+
Path("db/migrations"),
|
|
543
|
+
"--migrations-dir",
|
|
544
|
+
help="Migrations directory",
|
|
545
|
+
),
|
|
546
|
+
config: Path = typer.Option(
|
|
547
|
+
Path("db/environments/local.yaml"),
|
|
548
|
+
"--config",
|
|
549
|
+
"-c",
|
|
550
|
+
help="Configuration file",
|
|
551
|
+
),
|
|
552
|
+
target: str = typer.Option(
|
|
553
|
+
None,
|
|
554
|
+
"--target",
|
|
555
|
+
"-t",
|
|
556
|
+
help="Target migration version (applies all if not specified)",
|
|
557
|
+
),
|
|
558
|
+
) -> None:
|
|
559
|
+
"""Apply pending migrations.
|
|
560
|
+
|
|
561
|
+
Applies all pending migrations up to the target version (or all if no target).
|
|
562
|
+
"""
|
|
563
|
+
from confiture.core.connection import (
|
|
564
|
+
create_connection,
|
|
565
|
+
get_migration_class,
|
|
566
|
+
load_config,
|
|
567
|
+
load_migration_module,
|
|
568
|
+
)
|
|
569
|
+
from confiture.core.migrator import Migrator
|
|
570
|
+
|
|
571
|
+
try:
|
|
572
|
+
# Load configuration
|
|
573
|
+
config_data = load_config(config)
|
|
574
|
+
|
|
575
|
+
# Create database connection
|
|
576
|
+
conn = create_connection(config_data)
|
|
577
|
+
|
|
578
|
+
# Create migrator
|
|
579
|
+
migrator = Migrator(connection=conn)
|
|
580
|
+
migrator.initialize()
|
|
581
|
+
|
|
582
|
+
# Find pending migrations
|
|
583
|
+
pending_migrations = migrator.find_pending(migrations_dir=migrations_dir)
|
|
584
|
+
|
|
585
|
+
if not pending_migrations:
|
|
586
|
+
console.print("[green]✅ No pending migrations. Database is up to date.[/green]")
|
|
587
|
+
conn.close()
|
|
588
|
+
return
|
|
589
|
+
|
|
590
|
+
console.print(f"[cyan]📦 Found {len(pending_migrations)} pending migration(s)[/cyan]\n")
|
|
591
|
+
|
|
592
|
+
# Apply migrations
|
|
593
|
+
applied_count = 0
|
|
594
|
+
for migration_file in pending_migrations:
|
|
595
|
+
# Load migration module
|
|
596
|
+
module = load_migration_module(migration_file)
|
|
597
|
+
migration_class = get_migration_class(module)
|
|
598
|
+
|
|
599
|
+
# Create migration instance
|
|
600
|
+
migration = migration_class(connection=conn)
|
|
601
|
+
|
|
602
|
+
# Check target
|
|
603
|
+
if target and migration.version > target:
|
|
604
|
+
console.print(f"[yellow]⏭️ Skipping {migration.version} (after target)[/yellow]")
|
|
605
|
+
break
|
|
606
|
+
|
|
607
|
+
# Apply migration
|
|
608
|
+
console.print(
|
|
609
|
+
f"[cyan]⚡ Applying {migration.version}_{migration.name}...[/cyan]", end=" "
|
|
610
|
+
)
|
|
611
|
+
migrator.apply(migration)
|
|
612
|
+
console.print("[green]✅[/green]")
|
|
613
|
+
applied_count += 1
|
|
614
|
+
|
|
615
|
+
console.print(f"\n[green]✅ Successfully applied {applied_count} migration(s)![/green]")
|
|
616
|
+
conn.close()
|
|
617
|
+
|
|
618
|
+
except Exception as e:
|
|
619
|
+
console.print(f"[red]❌ Error: {e}[/red]")
|
|
620
|
+
raise typer.Exit(1) from e
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@migrate_app.command("down")
|
|
624
|
+
def migrate_down(
|
|
625
|
+
migrations_dir: Path = typer.Option(
|
|
626
|
+
Path("db/migrations"),
|
|
627
|
+
"--migrations-dir",
|
|
628
|
+
help="Migrations directory",
|
|
629
|
+
),
|
|
630
|
+
config: Path = typer.Option(
|
|
631
|
+
Path("db/environments/local.yaml"),
|
|
632
|
+
"--config",
|
|
633
|
+
"-c",
|
|
634
|
+
help="Configuration file",
|
|
635
|
+
),
|
|
636
|
+
steps: int = typer.Option(
|
|
637
|
+
1,
|
|
638
|
+
"--steps",
|
|
639
|
+
"-n",
|
|
640
|
+
help="Number of migrations to rollback",
|
|
641
|
+
),
|
|
642
|
+
) -> None:
|
|
643
|
+
"""Rollback applied migrations.
|
|
644
|
+
|
|
645
|
+
Rolls back the last N applied migrations (default: 1).
|
|
646
|
+
"""
|
|
647
|
+
from confiture.core.connection import (
|
|
648
|
+
create_connection,
|
|
649
|
+
get_migration_class,
|
|
650
|
+
load_config,
|
|
651
|
+
load_migration_module,
|
|
652
|
+
)
|
|
653
|
+
from confiture.core.migrator import Migrator
|
|
654
|
+
|
|
655
|
+
try:
|
|
656
|
+
# Load configuration
|
|
657
|
+
config_data = load_config(config)
|
|
658
|
+
|
|
659
|
+
# Create database connection
|
|
660
|
+
conn = create_connection(config_data)
|
|
661
|
+
|
|
662
|
+
# Create migrator
|
|
663
|
+
migrator = Migrator(connection=conn)
|
|
664
|
+
migrator.initialize()
|
|
665
|
+
|
|
666
|
+
# Get applied migrations
|
|
667
|
+
applied_versions = migrator.get_applied_versions()
|
|
668
|
+
|
|
669
|
+
if not applied_versions:
|
|
670
|
+
console.print("[yellow]⚠️ No applied migrations to rollback.[/yellow]")
|
|
671
|
+
conn.close()
|
|
672
|
+
return
|
|
673
|
+
|
|
674
|
+
# Get migrations to rollback (last N)
|
|
675
|
+
versions_to_rollback = applied_versions[-steps:]
|
|
676
|
+
|
|
677
|
+
console.print(f"[cyan]📦 Rolling back {len(versions_to_rollback)} migration(s)[/cyan]\n")
|
|
678
|
+
|
|
679
|
+
# Rollback migrations in reverse order
|
|
680
|
+
rolled_back_count = 0
|
|
681
|
+
for version in reversed(versions_to_rollback):
|
|
682
|
+
# Find migration file
|
|
683
|
+
migration_files = migrator.find_migration_files(migrations_dir=migrations_dir)
|
|
684
|
+
migration_file = None
|
|
685
|
+
for mf in migration_files:
|
|
686
|
+
if migrator._version_from_filename(mf.name) == version:
|
|
687
|
+
migration_file = mf
|
|
688
|
+
break
|
|
689
|
+
|
|
690
|
+
if not migration_file:
|
|
691
|
+
console.print(f"[red]❌ Migration file for version {version} not found[/red]")
|
|
692
|
+
continue
|
|
693
|
+
|
|
694
|
+
# Load migration module
|
|
695
|
+
module = load_migration_module(migration_file)
|
|
696
|
+
migration_class = get_migration_class(module)
|
|
697
|
+
|
|
698
|
+
# Create migration instance
|
|
699
|
+
migration = migration_class(connection=conn)
|
|
700
|
+
|
|
701
|
+
# Rollback migration
|
|
702
|
+
console.print(
|
|
703
|
+
f"[cyan]⚡ Rolling back {migration.version}_{migration.name}...[/cyan]", end=" "
|
|
704
|
+
)
|
|
705
|
+
migrator.rollback(migration)
|
|
706
|
+
console.print("[green]✅[/green]")
|
|
707
|
+
rolled_back_count += 1
|
|
708
|
+
|
|
709
|
+
console.print(
|
|
710
|
+
f"\n[green]✅ Successfully rolled back {rolled_back_count} migration(s)![/green]"
|
|
711
|
+
)
|
|
712
|
+
conn.close()
|
|
713
|
+
|
|
714
|
+
except Exception as e:
|
|
715
|
+
console.print(f"[red]❌ Error: {e}[/red]")
|
|
716
|
+
raise typer.Exit(1) from e
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
if __name__ == "__main__":
|
|
720
|
+
app()
|