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.

@@ -0,0 +1,298 @@
1
+ """Migration file generator from schema diffs.
2
+
3
+ This module generates Python migration files from SchemaDiff objects.
4
+ Each migration file contains up() and down() methods with the necessary SQL.
5
+ """
6
+
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from confiture.models.schema import SchemaChange, SchemaDiff
11
+
12
+
13
+ class MigrationGenerator:
14
+ """Generates Python migration files from schema diffs.
15
+
16
+ Example:
17
+ >>> generator = MigrationGenerator(migrations_dir=Path("db/migrations"))
18
+ >>> diff = SchemaDiff(changes=[...])
19
+ >>> migration_file = generator.generate(diff, name="add_users_table")
20
+ """
21
+
22
+ def __init__(self, migrations_dir: Path):
23
+ """Initialize migration generator.
24
+
25
+ Args:
26
+ migrations_dir: Directory where migration files will be created
27
+ """
28
+ self.migrations_dir = migrations_dir
29
+
30
+ def generate(self, diff: SchemaDiff, name: str) -> Path:
31
+ """Generate migration file from schema diff.
32
+
33
+ Args:
34
+ diff: Schema diff containing changes
35
+ name: Name for the migration (snake_case)
36
+
37
+ Returns:
38
+ Path to generated migration file
39
+
40
+ Raises:
41
+ ValueError: If diff has no changes
42
+ """
43
+ if not diff.has_changes():
44
+ raise ValueError("No changes to generate migration from")
45
+
46
+ # Get next version number
47
+ version = self._get_next_version()
48
+
49
+ # Generate file path
50
+ filename = f"{version}_{name}.py"
51
+ filepath = self.migrations_dir / filename
52
+
53
+ # Generate migration code
54
+ code = self._generate_migration_code(diff, version, name)
55
+
56
+ # Write file
57
+ filepath.write_text(code)
58
+
59
+ return filepath
60
+
61
+ def _get_next_version(self) -> str:
62
+ """Get next sequential migration version number.
63
+
64
+ Returns:
65
+ Version string (e.g., "001", "002", etc.)
66
+ """
67
+ if not self.migrations_dir.exists():
68
+ return "001"
69
+
70
+ # Find existing migration files
71
+ migration_files = sorted(self.migrations_dir.glob("*.py"))
72
+
73
+ if not migration_files:
74
+ return "001"
75
+
76
+ # Extract version from last file (e.g., "003_name.py" -> 3)
77
+ last_file = migration_files[-1]
78
+ last_version_str = last_file.name.split("_")[0]
79
+
80
+ try:
81
+ last_version = int(last_version_str)
82
+ next_version = last_version + 1
83
+ return f"{next_version:03d}"
84
+ except ValueError:
85
+ # If we can't parse version, start over
86
+ return "001"
87
+
88
+ def _generate_migration_code(self, diff: SchemaDiff, version: str, name: str) -> str:
89
+ """Generate Python migration code.
90
+
91
+ Args:
92
+ diff: Schema diff containing changes
93
+ version: Version number
94
+ name: Migration name
95
+
96
+ Returns:
97
+ Python code as string
98
+ """
99
+ class_name = self._to_class_name(name)
100
+ timestamp = datetime.now().isoformat()
101
+
102
+ # Generate up and down statements
103
+ up_statements = self._generate_up_statements(diff.changes)
104
+ down_statements = self._generate_down_statements(diff.changes)
105
+
106
+ template = '''"""Migration: {name}
107
+
108
+ Version: {version}
109
+ Generated: {timestamp}
110
+ """
111
+
112
+ from confiture.models.migration import Migration
113
+
114
+
115
+ class {class_name}(Migration):
116
+ """Migration: {name}."""
117
+
118
+ version = "{version}"
119
+ name = "{name}"
120
+
121
+ def up(self) -> None:
122
+ """Apply migration."""
123
+ {up_statements}
124
+
125
+ def down(self) -> None:
126
+ """Rollback migration."""
127
+ {down_statements}
128
+ '''
129
+
130
+ return template.format(
131
+ name=name,
132
+ version=version,
133
+ class_name=class_name,
134
+ up_statements=up_statements,
135
+ down_statements=down_statements,
136
+ timestamp=timestamp,
137
+ )
138
+
139
+ def _to_class_name(self, snake_case: str) -> str:
140
+ """Convert snake_case to PascalCase.
141
+
142
+ Args:
143
+ snake_case: String in snake_case format
144
+
145
+ Returns:
146
+ String in PascalCase format
147
+
148
+ Example:
149
+ >>> gen._to_class_name("add_users_table")
150
+ 'AddUsersTable'
151
+ """
152
+ words = snake_case.split("_")
153
+ return "".join(word.capitalize() for word in words)
154
+
155
+ def _generate_up_statements(self, changes: list[SchemaChange]) -> str:
156
+ """Generate SQL statements for up migration.
157
+
158
+ Args:
159
+ changes: List of schema changes
160
+
161
+ Returns:
162
+ Python code with execute() calls
163
+ """
164
+ statements = []
165
+
166
+ for change in changes:
167
+ sql = self._change_to_up_sql(change)
168
+ if sql:
169
+ statements.append(f' self.execute("{sql}")')
170
+
171
+ return "\n".join(statements) if statements else " pass # No operations"
172
+
173
+ def _generate_down_statements(self, changes: list[SchemaChange]) -> str:
174
+ """Generate SQL statements for down migration.
175
+
176
+ Args:
177
+ changes: List of schema changes
178
+
179
+ Returns:
180
+ Python code with execute() calls
181
+ """
182
+ statements = []
183
+
184
+ # Process changes in reverse order for rollback
185
+ for change in reversed(changes):
186
+ sql = self._change_to_down_sql(change)
187
+ if sql:
188
+ statements.append(f' self.execute("{sql}")')
189
+
190
+ return "\n".join(statements) if statements else " pass # No operations"
191
+
192
+ def _change_to_up_sql(self, change: SchemaChange) -> str | None:
193
+ """Convert schema change to SQL for up migration.
194
+
195
+ Args:
196
+ change: Schema change
197
+
198
+ Returns:
199
+ SQL string or None if not applicable
200
+ """
201
+ if change.type == "ADD_TABLE":
202
+ # We don't have full schema info, so create a placeholder
203
+ return f"# TODO: ADD_TABLE {change.table}"
204
+
205
+ elif change.type == "DROP_TABLE":
206
+ return f"DROP TABLE {change.table}"
207
+
208
+ elif change.type == "RENAME_TABLE":
209
+ return f"ALTER TABLE {change.old_value} RENAME TO {change.new_value}"
210
+
211
+ elif change.type == "ADD_COLUMN":
212
+ # For ADD_COLUMN, we might have type info in new_value
213
+ col_def = change.new_value if change.new_value else "TEXT"
214
+ return f"ALTER TABLE {change.table} ADD COLUMN {change.column} {col_def}"
215
+
216
+ elif change.type == "DROP_COLUMN":
217
+ return f"ALTER TABLE {change.table} DROP COLUMN {change.column}"
218
+
219
+ elif change.type == "RENAME_COLUMN":
220
+ return (
221
+ f"ALTER TABLE {change.table} RENAME COLUMN {change.old_value} TO {change.new_value}"
222
+ )
223
+
224
+ elif change.type == "CHANGE_COLUMN_TYPE":
225
+ return (
226
+ f"ALTER TABLE {change.table} ALTER COLUMN {change.column} TYPE {change.new_value}"
227
+ )
228
+
229
+ elif change.type == "CHANGE_COLUMN_NULLABLE":
230
+ if change.new_value == "false":
231
+ return f"ALTER TABLE {change.table} ALTER COLUMN {change.column} SET NOT NULL"
232
+ else:
233
+ return f"ALTER TABLE {change.table} ALTER COLUMN {change.column} DROP NOT NULL"
234
+
235
+ elif change.type == "CHANGE_COLUMN_DEFAULT":
236
+ if change.new_value:
237
+ return f"ALTER TABLE {change.table} ALTER COLUMN {change.column} SET DEFAULT {change.new_value}"
238
+ else:
239
+ return f"ALTER TABLE {change.table} ALTER COLUMN {change.column} DROP DEFAULT"
240
+
241
+ return None
242
+
243
+ def _change_to_down_sql(self, change: SchemaChange) -> str | None:
244
+ """Convert schema change to SQL for down migration (reverse).
245
+
246
+ Args:
247
+ change: Schema change
248
+
249
+ Returns:
250
+ SQL string or None if not applicable
251
+ """
252
+ if change.type == "ADD_TABLE":
253
+ # Reverse of ADD is DROP
254
+ return f"DROP TABLE {change.table}"
255
+
256
+ elif change.type == "DROP_TABLE":
257
+ # Can't recreate without schema info
258
+ return f"# WARNING: Cannot auto-generate down migration for DROP_TABLE {change.table}"
259
+
260
+ elif change.type == "RENAME_TABLE":
261
+ # Reverse the rename
262
+ return f"ALTER TABLE {change.new_value} RENAME TO {change.old_value}"
263
+
264
+ elif change.type == "ADD_COLUMN":
265
+ # Reverse of ADD is DROP
266
+ return f"ALTER TABLE {change.table} DROP COLUMN {change.column}"
267
+
268
+ elif change.type == "DROP_COLUMN":
269
+ # Can't recreate without schema info
270
+ return f"# WARNING: Cannot auto-generate down migration for DROP_COLUMN {change.table}.{change.column}"
271
+
272
+ elif change.type == "RENAME_COLUMN":
273
+ # Reverse the rename
274
+ return (
275
+ f"ALTER TABLE {change.table} RENAME COLUMN {change.new_value} TO {change.old_value}"
276
+ )
277
+
278
+ elif change.type == "CHANGE_COLUMN_TYPE":
279
+ # Reverse the type change
280
+ return (
281
+ f"ALTER TABLE {change.table} ALTER COLUMN {change.column} TYPE {change.old_value}"
282
+ )
283
+
284
+ elif change.type == "CHANGE_COLUMN_NULLABLE":
285
+ # Reverse the nullable change
286
+ if change.old_value == "false":
287
+ return f"ALTER TABLE {change.table} ALTER COLUMN {change.column} SET NOT NULL"
288
+ else:
289
+ return f"ALTER TABLE {change.table} ALTER COLUMN {change.column} DROP NOT NULL"
290
+
291
+ elif change.type == "CHANGE_COLUMN_DEFAULT":
292
+ # Reverse the default change
293
+ if change.old_value:
294
+ return f"ALTER TABLE {change.table} ALTER COLUMN {change.column} SET DEFAULT {change.old_value}"
295
+ else:
296
+ return f"ALTER TABLE {change.table} ALTER COLUMN {change.column} DROP DEFAULT"
297
+
298
+ return None
@@ -0,0 +1,369 @@
1
+ """Migration executor for applying and rolling back database migrations."""
2
+
3
+ import time
4
+ from pathlib import Path
5
+
6
+ import psycopg
7
+
8
+ from confiture.exceptions import MigrationError
9
+ from confiture.models.migration import Migration
10
+
11
+
12
+ class Migrator:
13
+ """Executes database migrations and tracks their state.
14
+
15
+ The Migrator class is responsible for:
16
+ - Creating and managing the confiture_migrations tracking table
17
+ - Applying migrations (running up() methods)
18
+ - Rolling back migrations (running down() methods)
19
+ - Recording execution time and checksums
20
+ - Ensuring transaction safety
21
+
22
+ Example:
23
+ >>> conn = psycopg.connect("postgresql://localhost/mydb")
24
+ >>> migrator = Migrator(connection=conn)
25
+ >>> migrator.initialize()
26
+ >>> migrator.apply(my_migration)
27
+ """
28
+
29
+ def __init__(self, connection: psycopg.Connection):
30
+ """Initialize migrator with database connection.
31
+
32
+ Args:
33
+ connection: psycopg3 database connection
34
+ """
35
+ self.connection = connection
36
+
37
+ def initialize(self) -> None:
38
+ """Create confiture_migrations tracking table with modern identity trinity.
39
+
40
+ Identity pattern:
41
+ - id: Auto-incrementing BIGINT (internal, sequential)
42
+ - pk_migration: UUID (stable identifier, external APIs)
43
+ - slug: Human-readable (migration_name + timestamp)
44
+
45
+ This method is idempotent - safe to call multiple times.
46
+ Handles migration from old table structure.
47
+
48
+ Raises:
49
+ MigrationError: If table creation fails
50
+ """
51
+ try:
52
+ with self.connection.cursor() as cursor:
53
+ # Enable UUID extension
54
+ cursor.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"')
55
+
56
+ # Check if table exists
57
+ cursor.execute("""
58
+ SELECT EXISTS (
59
+ SELECT FROM information_schema.tables
60
+ WHERE table_name = 'confiture_migrations'
61
+ )
62
+ """)
63
+ result = cursor.fetchone()
64
+ table_exists = result[0] if result else False
65
+
66
+ if table_exists:
67
+ # Check if we need to migrate old table structure
68
+ cursor.execute("""
69
+ SELECT EXISTS (
70
+ SELECT FROM information_schema.columns
71
+ WHERE table_name = 'confiture_migrations'
72
+ AND column_name = 'pk_migration'
73
+ )
74
+ """)
75
+ result = cursor.fetchone()
76
+ has_new_structure = result[0] if result else False
77
+
78
+ if not has_new_structure:
79
+ # Migrate old table structure to new trinity pattern
80
+ cursor.execute("""
81
+ ALTER TABLE confiture_migrations
82
+ ADD COLUMN pk_migration UUID DEFAULT uuid_generate_v4() UNIQUE,
83
+ ADD COLUMN slug TEXT,
84
+ ALTER COLUMN id SET DATA TYPE BIGINT,
85
+ ALTER COLUMN applied_at SET DATA TYPE TIMESTAMPTZ
86
+ """)
87
+
88
+ # Generate slugs for existing migrations
89
+ cursor.execute("""
90
+ UPDATE confiture_migrations
91
+ SET slug = name || '_' || to_char(applied_at, 'YYYYMMDD_HH24MISS')
92
+ WHERE slug IS NULL
93
+ """)
94
+
95
+ # Make slug NOT NULL and UNIQUE
96
+ cursor.execute("""
97
+ ALTER TABLE confiture_migrations
98
+ ALTER COLUMN slug SET NOT NULL,
99
+ ADD CONSTRAINT confiture_migrations_slug_unique UNIQUE (slug)
100
+ """)
101
+
102
+ # Create new indexes
103
+ cursor.execute("""
104
+ CREATE INDEX IF NOT EXISTS idx_confiture_migrations_pk_migration
105
+ ON confiture_migrations(pk_migration)
106
+ """)
107
+ cursor.execute("""
108
+ CREATE INDEX IF NOT EXISTS idx_confiture_migrations_slug
109
+ ON confiture_migrations(slug)
110
+ """)
111
+
112
+ else:
113
+ # Create new table with trinity pattern
114
+ cursor.execute("""
115
+ CREATE TABLE confiture_migrations (
116
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
117
+ pk_migration UUID NOT NULL DEFAULT uuid_generate_v4() UNIQUE,
118
+ slug TEXT NOT NULL UNIQUE,
119
+ version VARCHAR(255) NOT NULL UNIQUE,
120
+ name VARCHAR(255) NOT NULL,
121
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
122
+ execution_time_ms INTEGER,
123
+ checksum VARCHAR(64)
124
+ )
125
+ """)
126
+
127
+ # Create indexes
128
+ cursor.execute("""
129
+ CREATE INDEX idx_confiture_migrations_pk_migration
130
+ ON confiture_migrations(pk_migration)
131
+ """)
132
+ cursor.execute("""
133
+ CREATE INDEX idx_confiture_migrations_slug
134
+ ON confiture_migrations(slug)
135
+ """)
136
+ cursor.execute("""
137
+ CREATE INDEX idx_confiture_migrations_version
138
+ ON confiture_migrations(version)
139
+ """)
140
+ cursor.execute("""
141
+ CREATE INDEX idx_confiture_migrations_applied_at
142
+ ON confiture_migrations(applied_at DESC)
143
+ """)
144
+
145
+ self.connection.commit()
146
+ except psycopg.Error as e:
147
+ self.connection.rollback()
148
+ raise MigrationError(f"Failed to initialize migrations table: {e}") from e
149
+
150
+ def apply(self, migration: Migration) -> None:
151
+ """Apply a migration and record it in the tracking table.
152
+
153
+ This method:
154
+ 1. Checks if migration was already applied
155
+ 2. Executes migration.up() within a transaction
156
+ 3. Records migration metadata (version, name, execution time)
157
+ 4. Commits transaction
158
+
159
+ Args:
160
+ migration: Migration instance to apply
161
+
162
+ Raises:
163
+ MigrationError: If migration fails or was already applied
164
+ """
165
+ # Check if already applied
166
+ if self._is_applied(migration.version):
167
+ raise MigrationError(
168
+ f"Migration {migration.version} ({migration.name}) has already been applied"
169
+ )
170
+
171
+ try:
172
+ # Start timing
173
+ start_time = time.perf_counter()
174
+
175
+ # Execute migration within transaction
176
+ migration.up()
177
+
178
+ # Calculate execution time
179
+ execution_time_ms = int((time.perf_counter() - start_time) * 1000)
180
+
181
+ # Record in tracking table with human-readable slug
182
+ # Format: migration-name_YYYYMMDD_HHMMSS
183
+ from datetime import datetime
184
+
185
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
186
+ slug = f"{migration.name}_{timestamp}"
187
+
188
+ with self.connection.cursor() as cursor:
189
+ cursor.execute(
190
+ """
191
+ INSERT INTO confiture_migrations
192
+ (slug, version, name, execution_time_ms)
193
+ VALUES (%s, %s, %s, %s)
194
+ """,
195
+ (slug, migration.version, migration.name, execution_time_ms),
196
+ )
197
+
198
+ # Commit transaction
199
+ self.connection.commit()
200
+
201
+ except Exception as e:
202
+ self.connection.rollback()
203
+ raise MigrationError(
204
+ f"Failed to apply migration {migration.version} ({migration.name}): {e}"
205
+ ) from e
206
+
207
+ def rollback(self, migration: Migration) -> None:
208
+ """Rollback a migration and remove it from tracking table.
209
+
210
+ This method:
211
+ 1. Checks if migration was applied
212
+ 2. Executes migration.down() within a transaction
213
+ 3. Removes migration record from tracking table
214
+ 4. Commits transaction
215
+
216
+ Args:
217
+ migration: Migration instance to rollback
218
+
219
+ Raises:
220
+ MigrationError: If migration fails or was not applied
221
+ """
222
+ # Check if applied
223
+ if not self._is_applied(migration.version):
224
+ raise MigrationError(
225
+ f"Migration {migration.version} ({migration.name}) "
226
+ "has not been applied, cannot rollback"
227
+ )
228
+
229
+ try:
230
+ # Execute down() method
231
+ migration.down()
232
+
233
+ # Remove from tracking table
234
+ with self.connection.cursor() as cursor:
235
+ cursor.execute(
236
+ """
237
+ DELETE FROM confiture_migrations
238
+ WHERE version = %s
239
+ """,
240
+ (migration.version,),
241
+ )
242
+
243
+ # Commit transaction
244
+ self.connection.commit()
245
+
246
+ except Exception as e:
247
+ self.connection.rollback()
248
+ raise MigrationError(
249
+ f"Failed to rollback migration {migration.version} ({migration.name}): {e}"
250
+ ) from e
251
+
252
+ def _is_applied(self, version: str) -> bool:
253
+ """Check if migration version has been applied.
254
+
255
+ Args:
256
+ version: Migration version to check
257
+
258
+ Returns:
259
+ True if migration has been applied, False otherwise
260
+ """
261
+ with self.connection.cursor() as cursor:
262
+ cursor.execute(
263
+ """
264
+ SELECT COUNT(*)
265
+ FROM confiture_migrations
266
+ WHERE version = %s
267
+ """,
268
+ (version,),
269
+ )
270
+ result = cursor.fetchone()
271
+ if result is None:
272
+ return False
273
+ count: int = result[0]
274
+ return count > 0
275
+
276
+ def get_applied_versions(self) -> list[str]:
277
+ """Get list of all applied migration versions.
278
+
279
+ Returns:
280
+ List of migration versions, sorted by applied_at timestamp
281
+ """
282
+ with self.connection.cursor() as cursor:
283
+ cursor.execute("""
284
+ SELECT version
285
+ FROM confiture_migrations
286
+ ORDER BY applied_at ASC
287
+ """)
288
+ return [row[0] for row in cursor.fetchall()]
289
+
290
+ def find_migration_files(self, migrations_dir: Path | None = None) -> list[Path]:
291
+ """Find all migration files in the migrations directory.
292
+
293
+ Args:
294
+ migrations_dir: Optional custom migrations directory.
295
+ If None, uses db/migrations/ (default)
296
+
297
+ Returns:
298
+ List of migration file paths, sorted by version number
299
+
300
+ Example:
301
+ >>> migrator = Migrator(connection=conn)
302
+ >>> files = migrator.find_migration_files()
303
+ >>> # [Path("db/migrations/001_create_users.py"), ...]
304
+ """
305
+ if migrations_dir is None:
306
+ migrations_dir = Path("db") / "migrations"
307
+
308
+ if not migrations_dir.exists():
309
+ return []
310
+
311
+ # Find all .py files (excluding __pycache__, __init__.py)
312
+ migration_files = sorted(
313
+ [
314
+ f
315
+ for f in migrations_dir.glob("*.py")
316
+ if f.name != "__init__.py" and not f.name.startswith("_")
317
+ ]
318
+ )
319
+
320
+ return migration_files
321
+
322
+ def find_pending(self, migrations_dir: Path | None = None) -> list[Path]:
323
+ """Find migrations that have not been applied yet.
324
+
325
+ Args:
326
+ migrations_dir: Optional custom migrations directory
327
+
328
+ Returns:
329
+ List of pending migration file paths
330
+
331
+ Example:
332
+ >>> migrator = Migrator(connection=conn)
333
+ >>> pending = migrator.find_pending()
334
+ >>> print(f"Found {len(pending)} pending migrations")
335
+ """
336
+ # Get all migration files
337
+ all_migrations = self.find_migration_files(migrations_dir)
338
+
339
+ # Get applied versions
340
+ applied_versions = set(self.get_applied_versions())
341
+
342
+ # Filter to pending only
343
+ pending_migrations = [
344
+ migration_file
345
+ for migration_file in all_migrations
346
+ if self._version_from_filename(migration_file.name) not in applied_versions
347
+ ]
348
+
349
+ return pending_migrations
350
+
351
+ def _version_from_filename(self, filename: str) -> str:
352
+ """Extract version from migration filename.
353
+
354
+ Migration files follow the format: {version}_{name}.py
355
+ Example: "001_create_users.py" -> "001"
356
+
357
+ Args:
358
+ filename: Migration filename
359
+
360
+ Returns:
361
+ Version string
362
+
363
+ Example:
364
+ >>> migrator._version_from_filename("042_add_column.py")
365
+ "042"
366
+ """
367
+ # Split on first underscore
368
+ version = filename.split("_")[0]
369
+ return version