fraiseql-confiture 0.3.7__cp311-cp311-macosx_11_0_arm64.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 (124) hide show
  1. confiture/__init__.py +48 -0
  2. confiture/_core.cpython-311-darwin.so +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 +1893 -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 +184 -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 +882 -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 +24 -0
  95. confiture/models/lint.py +193 -0
  96. confiture/models/migration.py +265 -0
  97. confiture/models/schema.py +203 -0
  98. confiture/models/sql_file_migration.py +225 -0
  99. confiture/scenarios/__init__.py +36 -0
  100. confiture/scenarios/compliance.py +586 -0
  101. confiture/scenarios/ecommerce.py +199 -0
  102. confiture/scenarios/financial.py +253 -0
  103. confiture/scenarios/healthcare.py +315 -0
  104. confiture/scenarios/multi_tenant.py +340 -0
  105. confiture/scenarios/saas.py +295 -0
  106. confiture/testing/FRAMEWORK_API.md +722 -0
  107. confiture/testing/__init__.py +100 -0
  108. confiture/testing/fixtures/__init__.py +11 -0
  109. confiture/testing/fixtures/data_validator.py +229 -0
  110. confiture/testing/fixtures/migration_runner.py +167 -0
  111. confiture/testing/fixtures/schema_snapshotter.py +352 -0
  112. confiture/testing/frameworks/__init__.py +10 -0
  113. confiture/testing/frameworks/mutation.py +587 -0
  114. confiture/testing/frameworks/performance.py +479 -0
  115. confiture/testing/loader.py +225 -0
  116. confiture/testing/pytest/__init__.py +38 -0
  117. confiture/testing/pytest_plugin.py +190 -0
  118. confiture/testing/sandbox.py +304 -0
  119. confiture/testing/utils/__init__.py +0 -0
  120. fraiseql_confiture-0.3.7.dist-info/METADATA +438 -0
  121. fraiseql_confiture-0.3.7.dist-info/RECORD +124 -0
  122. fraiseql_confiture-0.3.7.dist-info/WHEEL +4 -0
  123. fraiseql_confiture-0.3.7.dist-info/entry_points.txt +4 -0
  124. fraiseql_confiture-0.3.7.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,882 @@
1
+ """Migration executor for applying and rolling back database migrations."""
2
+
3
+ import logging
4
+ import time
5
+ from pathlib import Path
6
+
7
+ import psycopg
8
+
9
+ from confiture.core.checksum import (
10
+ ChecksumConfig,
11
+ MigrationChecksumVerifier,
12
+ compute_checksum,
13
+ )
14
+ from confiture.core.connection import get_migration_class, load_migration_module
15
+ from confiture.core.dry_run import DryRunExecutor, DryRunResult
16
+ from confiture.core.hooks import HookError
17
+ from confiture.core.locking import LockConfig, MigrationLock
18
+ from confiture.exceptions import MigrationError, SQLError
19
+ from confiture.models.migration import Migration
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class Migrator:
25
+ """Executes database migrations and tracks their state.
26
+
27
+ The Migrator class is responsible for:
28
+ - Creating and managing the confiture_migrations tracking table
29
+ - Applying migrations (running up() methods)
30
+ - Rolling back migrations (running down() methods)
31
+ - Recording execution time and checksums
32
+ - Ensuring transaction safety
33
+
34
+ Example:
35
+ >>> conn = psycopg.connect("postgresql://localhost/mydb")
36
+ >>> migrator = Migrator(connection=conn)
37
+ >>> migrator.initialize()
38
+ >>> migrator.apply(my_migration)
39
+ """
40
+
41
+ def __init__(self, connection: psycopg.Connection):
42
+ """Initialize migrator with database connection.
43
+
44
+ Args:
45
+ connection: psycopg3 database connection
46
+ """
47
+ self.connection = connection
48
+
49
+ def _execute_sql(self, sql: str, params: tuple[str, ...] | None = None) -> None:
50
+ """Execute SQL with detailed error reporting.
51
+
52
+ Args:
53
+ sql: SQL statement to execute
54
+ params: Optional query parameters
55
+
56
+ Raises:
57
+ SQLError: If SQL execution fails with detailed context
58
+ """
59
+ try:
60
+ with self.connection.cursor() as cursor:
61
+ if params:
62
+ cursor.execute(sql, params)
63
+ else:
64
+ cursor.execute(sql)
65
+ except Exception as e:
66
+ raise SQLError(sql, params, e) from e
67
+
68
+ def initialize(self) -> None:
69
+ """Create confiture_migrations tracking table with modern identity trinity.
70
+
71
+ Identity pattern:
72
+ - id: Auto-incrementing BIGINT (internal, sequential)
73
+ - pk_migration: UUID (stable identifier, external APIs)
74
+ - slug: Human-readable (migration_name + timestamp)
75
+
76
+ This method is idempotent - safe to call multiple times.
77
+ Handles migration from old table structure.
78
+
79
+ Raises:
80
+ MigrationError: If table creation fails
81
+ """
82
+ try:
83
+ # Enable UUID extension
84
+ self._execute_sql('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"')
85
+
86
+ # Check if table exists
87
+ with self.connection.cursor() as cursor:
88
+ cursor.execute("""
89
+ SELECT EXISTS (
90
+ SELECT FROM information_schema.tables
91
+ WHERE table_name = 'confiture_migrations'
92
+ )
93
+ """)
94
+ result = cursor.fetchone()
95
+ table_exists = result[0] if result else False
96
+
97
+ if table_exists:
98
+ # Check if we need to migrate old table structure
99
+ with self.connection.cursor() as cursor:
100
+ cursor.execute("""
101
+ SELECT EXISTS (
102
+ SELECT FROM information_schema.columns
103
+ WHERE table_name = 'confiture_migrations'
104
+ AND column_name = 'pk_migration'
105
+ )
106
+ """)
107
+ result = cursor.fetchone()
108
+ has_new_structure = result[0] if result else False
109
+
110
+ if not has_new_structure:
111
+ # Migrate old table structure to new trinity pattern
112
+ self._execute_sql("""
113
+ ALTER TABLE confiture_migrations
114
+ ADD COLUMN pk_migration UUID DEFAULT uuid_generate_v4() UNIQUE,
115
+ ADD COLUMN slug TEXT,
116
+ ALTER COLUMN id SET DATA TYPE BIGINT,
117
+ ALTER COLUMN applied_at SET DATA TYPE TIMESTAMPTZ
118
+ """)
119
+
120
+ # Generate slugs for existing migrations
121
+ self._execute_sql("""
122
+ UPDATE confiture_migrations
123
+ SET slug = name || '_' || to_char(applied_at, 'YYYYMMDD_HH24MISS')
124
+ WHERE slug IS NULL
125
+ """)
126
+
127
+ # Make slug NOT NULL and UNIQUE
128
+ self._execute_sql("""
129
+ ALTER TABLE confiture_migrations
130
+ ALTER COLUMN slug SET NOT NULL,
131
+ ADD CONSTRAINT confiture_migrations_slug_unique UNIQUE (slug)
132
+ """)
133
+
134
+ # Create new indexes
135
+ self._execute_sql("""
136
+ CREATE INDEX IF NOT EXISTS idx_confiture_migrations_pk_migration
137
+ ON confiture_migrations(pk_migration)
138
+ """)
139
+ self._execute_sql("""
140
+ CREATE INDEX IF NOT EXISTS idx_confiture_migrations_slug
141
+ ON confiture_migrations(slug)
142
+ """)
143
+
144
+ else:
145
+ # Create new table with trinity pattern
146
+ self._execute_sql("""
147
+ CREATE TABLE confiture_migrations (
148
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
149
+ pk_migration UUID NOT NULL DEFAULT uuid_generate_v4() UNIQUE,
150
+ slug TEXT NOT NULL UNIQUE,
151
+ version VARCHAR(255) NOT NULL UNIQUE,
152
+ name VARCHAR(255) NOT NULL,
153
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
154
+ execution_time_ms INTEGER,
155
+ checksum VARCHAR(64)
156
+ )
157
+ """)
158
+
159
+ # Create indexes
160
+ self._execute_sql("""
161
+ CREATE INDEX idx_confiture_migrations_pk_migration
162
+ ON confiture_migrations(pk_migration)
163
+ """)
164
+ self._execute_sql("""
165
+ CREATE INDEX idx_confiture_migrations_slug
166
+ ON confiture_migrations(slug)
167
+ """)
168
+ self._execute_sql("""
169
+ CREATE INDEX idx_confiture_migrations_version
170
+ ON confiture_migrations(version)
171
+ """)
172
+ self._execute_sql("""
173
+ CREATE INDEX idx_confiture_migrations_applied_at
174
+ ON confiture_migrations(applied_at DESC)
175
+ """)
176
+
177
+ self.connection.commit()
178
+ except Exception as e:
179
+ self.connection.rollback()
180
+ if isinstance(e, SQLError):
181
+ raise MigrationError(f"Failed to initialize migrations table: {e}") from e
182
+ else:
183
+ raise MigrationError(f"Failed to initialize migrations table: {e}") from e
184
+
185
+ def apply(
186
+ self,
187
+ migration: Migration,
188
+ force: bool = False,
189
+ migration_file: Path | None = None,
190
+ ) -> None:
191
+ """Apply a migration and record it in the tracking table.
192
+
193
+ For transactional migrations (default):
194
+ - Uses savepoints for clean rollback on failure
195
+ - Executes hooks before and after DDL execution
196
+
197
+ For non-transactional migrations (transactional=False):
198
+ - Runs in autocommit mode
199
+ - No automatic rollback on failure
200
+ - Required for CREATE INDEX CONCURRENTLY, etc.
201
+
202
+ Args:
203
+ migration: Migration instance to apply
204
+ force: If True, skip the "already applied" check
205
+ migration_file: Path to migration file for checksum computation
206
+
207
+ Raises:
208
+ MigrationError: If migration fails or hooks fail
209
+ """
210
+ already_applied = self._is_applied(migration.version)
211
+
212
+ if not force and already_applied:
213
+ raise MigrationError(
214
+ f"Migration {migration.version} ({migration.name}) has already been applied"
215
+ )
216
+
217
+ if migration.transactional:
218
+ self._apply_transactional(migration, already_applied, migration_file)
219
+ else:
220
+ self._apply_non_transactional(migration, already_applied, migration_file)
221
+
222
+ def _apply_transactional(
223
+ self,
224
+ migration: Migration,
225
+ already_applied: bool,
226
+ migration_file: Path | None = None,
227
+ ) -> None:
228
+ """Apply migration within a transaction using savepoints.
229
+
230
+ Args:
231
+ migration: Migration instance to apply
232
+ already_applied: Whether migration was already applied (force mode)
233
+ migration_file: Path to migration file for checksum computation
234
+ """
235
+ savepoint_name = f"migration_{migration.version}"
236
+ try:
237
+ self._create_savepoint(savepoint_name)
238
+
239
+ # Execute migration DDL
240
+ logger.debug(f"Executing DDL for migration {migration.version}")
241
+ start_time = time.perf_counter()
242
+ migration.up()
243
+ execution_time_ms = int((time.perf_counter() - start_time) * 1000)
244
+
245
+ # Only record the migration if it's not already applied
246
+ # In force mode, we re-apply but don't re-record
247
+ if not already_applied:
248
+ self._record_migration(migration, execution_time_ms, migration_file)
249
+ self._release_savepoint(savepoint_name)
250
+
251
+ self.connection.commit()
252
+ logger.info(f"Successfully applied migration {migration.version} ({migration.name})")
253
+
254
+ except Exception as e:
255
+ self._rollback_to_savepoint(savepoint_name)
256
+ if isinstance(e, (MigrationError, HookError)):
257
+ raise
258
+ else:
259
+ raise MigrationError(
260
+ f"Failed to apply migration {migration.version} ({migration.name}): {e}"
261
+ ) from e
262
+
263
+ def _apply_non_transactional(
264
+ self,
265
+ migration: Migration,
266
+ already_applied: bool,
267
+ migration_file: Path | None = None,
268
+ ) -> None:
269
+ """Apply migration in autocommit mode (no transaction).
270
+
271
+ WARNING: If this fails, manual cleanup may be required.
272
+
273
+ Args:
274
+ migration: Migration instance to apply
275
+ already_applied: Whether migration was already applied (force mode)
276
+ migration_file: Path to migration file for checksum computation
277
+ """
278
+ logger.warning(
279
+ f"Running migration {migration.version} in non-transactional mode. "
280
+ "Manual cleanup may be required on failure."
281
+ )
282
+
283
+ # Ensure any pending transaction is committed
284
+ self.connection.commit()
285
+
286
+ # Set autocommit mode
287
+ original_autocommit = self.connection.autocommit
288
+ self.connection.autocommit = True
289
+
290
+ try:
291
+ logger.debug(f"Executing DDL for migration {migration.version} (autocommit)")
292
+ start_time = time.perf_counter()
293
+ migration.up()
294
+ execution_time_ms = int((time.perf_counter() - start_time) * 1000)
295
+
296
+ # Record migration (in autocommit, this commits immediately)
297
+ if not already_applied:
298
+ self._record_migration(migration, execution_time_ms, migration_file)
299
+
300
+ logger.info(
301
+ f"Successfully applied non-transactional migration "
302
+ f"{migration.version} ({migration.name})"
303
+ )
304
+
305
+ except Exception as e:
306
+ logger.error(
307
+ f"Non-transactional migration {migration.version} failed. "
308
+ "Manual cleanup may be required."
309
+ )
310
+ raise MigrationError(
311
+ f"Failed to apply non-transactional migration "
312
+ f"{migration.version} ({migration.name}): {e}. "
313
+ "Manual cleanup may be required."
314
+ ) from e
315
+
316
+ finally:
317
+ # Restore original autocommit setting
318
+ self.connection.autocommit = original_autocommit
319
+
320
+ def _create_savepoint(self, name: str) -> None:
321
+ """Create a savepoint for transaction rollback."""
322
+ with self.connection.cursor() as cursor:
323
+ cursor.execute(f"SAVEPOINT {name}")
324
+
325
+ def _release_savepoint(self, name: str) -> None:
326
+ """Release a savepoint (commit nested transaction)."""
327
+ with self.connection.cursor() as cursor:
328
+ cursor.execute(f"RELEASE SAVEPOINT {name}")
329
+
330
+ def _rollback_to_savepoint(self, name: str) -> None:
331
+ """Rollback to a savepoint (undo nested transaction)."""
332
+ try:
333
+ with self.connection.cursor() as cursor:
334
+ cursor.execute(f"ROLLBACK TO SAVEPOINT {name}")
335
+ self.connection.commit()
336
+ except Exception:
337
+ # Savepoint rollback failed, do full rollback
338
+ self.connection.rollback()
339
+
340
+ def _record_migration(
341
+ self,
342
+ migration: Migration,
343
+ execution_time_ms: int,
344
+ migration_file: Path | None = None,
345
+ ) -> None:
346
+ """Record migration in tracking table with checksum.
347
+
348
+ Args:
349
+ migration: Migration that was applied
350
+ execution_time_ms: Time taken to apply migration
351
+ migration_file: Path to migration file for checksum computation
352
+ """
353
+ from datetime import datetime
354
+
355
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
356
+ slug = f"{migration.name}_{timestamp}"
357
+
358
+ # Compute checksum if file path provided
359
+ checksum = None
360
+ if migration_file is not None and migration_file.exists():
361
+ checksum = compute_checksum(migration_file)
362
+ logger.debug(f"Computed checksum for {migration.version}: {checksum[:16]}...")
363
+
364
+ with self.connection.cursor() as cursor:
365
+ cursor.execute(
366
+ """
367
+ INSERT INTO confiture_migrations
368
+ (slug, version, name, execution_time_ms, checksum)
369
+ VALUES (%s, %s, %s, %s, %s)
370
+ """,
371
+ (slug, migration.version, migration.name, execution_time_ms, checksum),
372
+ )
373
+
374
+ def mark_applied(
375
+ self,
376
+ migration_file: Path,
377
+ reason: str = "baseline",
378
+ ) -> str:
379
+ """Mark a migration as applied without executing it.
380
+
381
+ Records the migration in the tracking table without running the up() method.
382
+ Useful for:
383
+ - Establishing a baseline when adopting confiture on an existing database
384
+ - Setting up a new environment from a backup
385
+ - Recovering from a failed migration state
386
+
387
+ Args:
388
+ migration_file: Path to migration file (.py or .up.sql)
389
+ reason: Reason for marking as applied (stored in notes)
390
+
391
+ Returns:
392
+ Version of the migration that was marked as applied
393
+
394
+ Raises:
395
+ MigrationError: If migration is already applied or cannot be loaded
396
+
397
+ Example:
398
+ >>> migrator.mark_applied(Path("db/migrations/001_create_users.py"))
399
+ "001"
400
+ """
401
+ from datetime import datetime
402
+
403
+ from confiture.core.connection import load_migration_class
404
+
405
+ # Load the migration class to get version and name
406
+ migration_class = load_migration_class(migration_file)
407
+
408
+ # Create a minimal instance just to read attributes
409
+ # We need to pass a connection but won't use it
410
+ migration = migration_class(connection=self.connection)
411
+
412
+ # Check if already applied
413
+ applied_versions = set(self.get_applied_versions())
414
+ if migration.version in applied_versions:
415
+ logger.info(f"Migration {migration.version} already applied, skipping")
416
+ return migration.version
417
+
418
+ # Generate slug with baseline marker
419
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
420
+ slug = f"{migration.name}_{timestamp}_baseline"
421
+
422
+ # Compute checksum
423
+ checksum = compute_checksum(migration_file)
424
+
425
+ # Record in tracking table with execution_time_ms = 0 (not executed)
426
+ with self.connection.cursor() as cursor:
427
+ cursor.execute(
428
+ """
429
+ INSERT INTO confiture_migrations
430
+ (slug, version, name, execution_time_ms, checksum)
431
+ VALUES (%s, %s, %s, %s, %s)
432
+ """,
433
+ (slug, migration.version, migration.name, 0, checksum),
434
+ )
435
+
436
+ self.connection.commit()
437
+ logger.info(
438
+ f"Marked migration {migration.version} ({migration.name}) as applied ({reason})"
439
+ )
440
+
441
+ return migration.version
442
+
443
+ def rollback(self, migration: Migration) -> None:
444
+ """Rollback a migration and remove it from tracking table.
445
+
446
+ For transactional migrations (default):
447
+ - Executes within a transaction with automatic rollback on failure
448
+ - Safe and consistent
449
+
450
+ For non-transactional migrations (transactional=False):
451
+ - Runs in autocommit mode
452
+ - No automatic rollback on failure
453
+ - Manual cleanup may be required
454
+
455
+ Args:
456
+ migration: Migration instance to rollback
457
+
458
+ Raises:
459
+ MigrationError: If migration fails or was not applied
460
+ """
461
+ # Check if applied
462
+ if not self._is_applied(migration.version):
463
+ raise MigrationError(
464
+ f"Migration {migration.version} ({migration.name}) "
465
+ "has not been applied, cannot rollback"
466
+ )
467
+
468
+ if migration.transactional:
469
+ self._rollback_transactional(migration)
470
+ else:
471
+ self._rollback_non_transactional(migration)
472
+
473
+ def _rollback_transactional(self, migration: Migration) -> None:
474
+ """Rollback a migration within a transaction.
475
+
476
+ Args:
477
+ migration: Migration instance to rollback
478
+ """
479
+ try:
480
+ # Execute down() method
481
+ logger.debug(f"Executing rollback (down) for migration {migration.version}")
482
+ migration.down()
483
+
484
+ # Remove from tracking table
485
+ self._execute_sql(
486
+ """
487
+ DELETE FROM confiture_migrations
488
+ WHERE version = %s
489
+ """,
490
+ (migration.version,),
491
+ )
492
+
493
+ # Commit transaction
494
+ self.connection.commit()
495
+ logger.info(
496
+ f"Successfully rolled back migration {migration.version} ({migration.name})"
497
+ )
498
+
499
+ except Exception as e:
500
+ self.connection.rollback()
501
+ raise MigrationError(
502
+ f"Failed to rollback migration {migration.version} ({migration.name}): {e}"
503
+ ) from e
504
+
505
+ def _rollback_non_transactional(self, migration: Migration) -> None:
506
+ """Rollback a migration in autocommit mode (no transaction).
507
+
508
+ WARNING: If this fails, manual cleanup may be required.
509
+
510
+ Args:
511
+ migration: Migration instance to rollback
512
+ """
513
+ logger.warning(
514
+ f"Rolling back migration {migration.version} in non-transactional mode. "
515
+ "Manual cleanup may be required on failure."
516
+ )
517
+
518
+ # Ensure any pending transaction is committed
519
+ self.connection.commit()
520
+
521
+ # Set autocommit mode
522
+ original_autocommit = self.connection.autocommit
523
+ self.connection.autocommit = True
524
+
525
+ try:
526
+ # Execute down() method
527
+ logger.debug(
528
+ f"Executing rollback (down) for migration {migration.version} (autocommit)"
529
+ )
530
+ migration.down()
531
+
532
+ # Remove from tracking table
533
+ self._execute_sql(
534
+ """
535
+ DELETE FROM confiture_migrations
536
+ WHERE version = %s
537
+ """,
538
+ (migration.version,),
539
+ )
540
+
541
+ logger.info(
542
+ f"Successfully rolled back non-transactional migration "
543
+ f"{migration.version} ({migration.name})"
544
+ )
545
+
546
+ except Exception as e:
547
+ logger.error(
548
+ f"Non-transactional rollback of migration {migration.version} failed. "
549
+ "Manual cleanup may be required."
550
+ )
551
+ raise MigrationError(
552
+ f"Failed to rollback non-transactional migration "
553
+ f"{migration.version} ({migration.name}): {e}. "
554
+ "Manual cleanup may be required."
555
+ ) from e
556
+
557
+ finally:
558
+ # Restore original autocommit setting
559
+ self.connection.autocommit = original_autocommit
560
+
561
+ def _is_applied(self, version: str) -> bool:
562
+ """Check if migration version has been applied.
563
+
564
+ Args:
565
+ version: Migration version to check
566
+
567
+ Returns:
568
+ True if migration has been applied, False otherwise
569
+ """
570
+ with self.connection.cursor() as cursor:
571
+ cursor.execute(
572
+ """
573
+ SELECT COUNT(*)
574
+ FROM confiture_migrations
575
+ WHERE version = %s
576
+ """,
577
+ (version,),
578
+ )
579
+ result = cursor.fetchone()
580
+ if result is None:
581
+ return False
582
+ count: int = result[0]
583
+ return count > 0
584
+
585
+ def get_applied_versions(self) -> list[str]:
586
+ """Get list of all applied migration versions.
587
+
588
+ Returns:
589
+ List of migration versions, sorted by applied_at timestamp
590
+ """
591
+ with self.connection.cursor() as cursor:
592
+ cursor.execute("""
593
+ SELECT version
594
+ FROM confiture_migrations
595
+ ORDER BY applied_at ASC
596
+ """)
597
+ return [row[0] for row in cursor.fetchall()]
598
+
599
+ def find_migration_files(self, migrations_dir: Path | None = None) -> list[Path]:
600
+ """Find all migration files in the migrations directory.
601
+
602
+ Discovers both Python migrations (.py) and SQL file migrations (.up.sql).
603
+ For SQL migrations, returns the .up.sql file path (the .down.sql is
604
+ inferred when loading).
605
+
606
+ Args:
607
+ migrations_dir: Optional custom migrations directory.
608
+ If None, uses db/migrations/ (default)
609
+
610
+ Returns:
611
+ List of migration file paths, sorted by version number.
612
+ Includes both .py files and .up.sql files.
613
+
614
+ Example:
615
+ >>> migrator = Migrator(connection=conn)
616
+ >>> files = migrator.find_migration_files()
617
+ >>> # [Path("db/migrations/001_create_users.py"),
618
+ >>> # Path("db/migrations/002_add_posts.up.sql"), ...]
619
+ """
620
+ if migrations_dir is None:
621
+ migrations_dir = Path("db") / "migrations"
622
+
623
+ if not migrations_dir.exists():
624
+ return []
625
+
626
+ # Find all .py files (excluding __pycache__, __init__.py)
627
+ py_files = [
628
+ f
629
+ for f in migrations_dir.glob("*.py")
630
+ if f.name != "__init__.py" and not f.name.startswith("_")
631
+ ]
632
+
633
+ # Find all .up.sql files (SQL migrations)
634
+ sql_files = list(migrations_dir.glob("*.up.sql"))
635
+
636
+ # Combine and sort by version
637
+ all_files = py_files + sql_files
638
+ migration_files = sorted(all_files, key=lambda f: self._version_from_filename(f.name))
639
+
640
+ return migration_files
641
+
642
+ def find_pending(self, migrations_dir: Path | None = None) -> list[Path]:
643
+ """Find migrations that have not been applied yet.
644
+
645
+ Args:
646
+ migrations_dir: Optional custom migrations directory
647
+
648
+ Returns:
649
+ List of pending migration file paths
650
+
651
+ Example:
652
+ >>> migrator = Migrator(connection=conn)
653
+ >>> pending = migrator.find_pending()
654
+ >>> print(f"Found {len(pending)} pending migrations")
655
+ """
656
+ # Get all migration files
657
+ all_migrations = self.find_migration_files(migrations_dir)
658
+
659
+ # Get applied versions
660
+ applied_versions = set(self.get_applied_versions())
661
+
662
+ # Filter to pending only
663
+ pending_migrations = [
664
+ migration_file
665
+ for migration_file in all_migrations
666
+ if self._version_from_filename(migration_file.name) not in applied_versions
667
+ ]
668
+
669
+ return pending_migrations
670
+
671
+ def _version_from_filename(self, filename: str) -> str:
672
+ """Extract version from migration filename.
673
+
674
+ Supports both Python and SQL migrations:
675
+ - Python: {version}_{name}.py -> "001_create_users.py" -> "001"
676
+ - SQL: {version}_{name}.up.sql -> "001_create_users.up.sql" -> "001"
677
+
678
+ Args:
679
+ filename: Migration filename
680
+
681
+ Returns:
682
+ Version string
683
+
684
+ Example:
685
+ >>> migrator._version_from_filename("042_add_column.py")
686
+ "042"
687
+ >>> migrator._version_from_filename("042_add_column.up.sql")
688
+ "042"
689
+ """
690
+ # Remove SQL file extensions if present
691
+ if filename.endswith(".up.sql"):
692
+ filename = filename[:-7] # Remove ".up.sql"
693
+ elif filename.endswith(".down.sql"):
694
+ filename = filename[:-9] # Remove ".down.sql"
695
+
696
+ # Split on first underscore
697
+ version = filename.split("_")[0]
698
+ return version
699
+
700
+ def migrate_up(
701
+ self,
702
+ force: bool = False,
703
+ migrations_dir: Path | None = None,
704
+ target: str | None = None,
705
+ lock_config: LockConfig | None = None,
706
+ checksum_config: ChecksumConfig | None = None,
707
+ ) -> list[str]:
708
+ """Apply pending migrations up to target version.
709
+
710
+ Uses distributed locking to ensure only one migration process runs
711
+ at a time. This is critical for multi-pod Kubernetes deployments.
712
+
713
+ Optionally verifies checksums before running migrations to detect
714
+ unauthorized modifications to migration files.
715
+
716
+ Args:
717
+ force: If True, skip migration state checks and apply all migrations
718
+ migrations_dir: Custom migrations directory (default: db/migrations)
719
+ target: Target migration version (applies all if None)
720
+ lock_config: Locking configuration. If None, uses default (enabled,
721
+ 30s timeout, blocking mode). Pass LockConfig(enabled=False)
722
+ to disable locking.
723
+ checksum_config: Checksum verification configuration. If None, uses
724
+ default (enabled, fail on mismatch). Pass
725
+ ChecksumConfig(enabled=False) to disable verification.
726
+
727
+ Returns:
728
+ List of applied migration versions
729
+
730
+ Raises:
731
+ MigrationError: If migration application fails
732
+ LockAcquisitionError: If lock cannot be acquired within timeout
733
+ ChecksumVerificationError: If checksum mismatch and behavior is FAIL
734
+
735
+ Example:
736
+ >>> migrator = Migrator(connection=conn)
737
+ >>> migrator.initialize()
738
+ >>> # Default: verify checksums, fail on mismatch
739
+ >>> applied = migrator.migrate_up()
740
+ >>>
741
+ >>> # Custom checksum behavior
742
+ >>> from confiture.core.checksum import ChecksumConfig, ChecksumMismatchBehavior
743
+ >>> applied = migrator.migrate_up(
744
+ ... checksum_config=ChecksumConfig(
745
+ ... on_mismatch=ChecksumMismatchBehavior.WARN
746
+ ... )
747
+ ... )
748
+ >>>
749
+ >>> # Disable checksum verification
750
+ >>> applied = migrator.migrate_up(
751
+ ... checksum_config=ChecksumConfig(enabled=False)
752
+ ... )
753
+ """
754
+ effective_migrations_dir = migrations_dir or Path("db/migrations")
755
+
756
+ # Verify checksums before running migrations (unless force mode)
757
+ if checksum_config is None:
758
+ checksum_config = ChecksumConfig()
759
+
760
+ if checksum_config.enabled and not force:
761
+ verifier = MigrationChecksumVerifier(self.connection, checksum_config)
762
+ verifier.verify_all(effective_migrations_dir)
763
+
764
+ # Create lock manager
765
+ lock = MigrationLock(self.connection, lock_config)
766
+
767
+ # Acquire lock and run migrations
768
+ with lock.acquire():
769
+ return self._migrate_up_internal(force, migrations_dir, target)
770
+
771
+ def _migrate_up_internal(
772
+ self,
773
+ force: bool = False,
774
+ migrations_dir: Path | None = None,
775
+ target: str | None = None,
776
+ ) -> list[str]:
777
+ """Internal implementation of migrate_up (called within lock).
778
+
779
+ Args:
780
+ force: If True, skip migration state checks
781
+ migrations_dir: Custom migrations directory
782
+ target: Target migration version
783
+
784
+ Returns:
785
+ List of applied migration versions
786
+ """
787
+ # Find migrations to apply
788
+ if force:
789
+ # In force mode, apply all migrations regardless of state
790
+ migrations_to_apply = self.find_migration_files(migrations_dir)
791
+ else:
792
+ # Normal mode: only apply pending migrations
793
+ migrations_to_apply = self.find_pending(migrations_dir)
794
+
795
+ # Check for mixed transactional modes and warn
796
+ self._warn_mixed_transactional_modes(migrations_to_apply)
797
+
798
+ applied_versions = []
799
+
800
+ for migration_file in migrations_to_apply:
801
+ # Load migration module
802
+ module = load_migration_module(migration_file)
803
+ migration_class = get_migration_class(module)
804
+
805
+ # Create migration instance
806
+ migration = migration_class(connection=self.connection)
807
+
808
+ # Check target
809
+ if target and migration.version > target:
810
+ break
811
+
812
+ # Apply migration with file path for checksum computation
813
+ self.apply(migration, force=force, migration_file=migration_file)
814
+ applied_versions.append(migration.version)
815
+
816
+ return applied_versions
817
+
818
+ def _warn_mixed_transactional_modes(self, migration_files: list[Path]) -> None:
819
+ """Warn if batch contains both transactional and non-transactional migrations.
820
+
821
+ Mixed batches can be problematic because non-transactional migrations
822
+ cannot be automatically rolled back if a later transactional migration fails.
823
+
824
+ Args:
825
+ migration_files: List of migration files to check
826
+ """
827
+ if len(migration_files) <= 1:
828
+ return
829
+
830
+ transactional_migrations: list[str] = []
831
+ non_transactional_migrations: list[str] = []
832
+
833
+ for migration_file in migration_files:
834
+ module = load_migration_module(migration_file)
835
+ migration_class = get_migration_class(module)
836
+
837
+ # Check transactional attribute (default is True)
838
+ is_transactional = getattr(migration_class, "transactional", True)
839
+
840
+ if is_transactional:
841
+ transactional_migrations.append(migration_file.name)
842
+ else:
843
+ non_transactional_migrations.append(migration_file.name)
844
+
845
+ if transactional_migrations and non_transactional_migrations:
846
+ logger.warning(
847
+ "Batch contains both transactional and non-transactional migrations. "
848
+ "If a transactional migration fails after a non-transactional one succeeds, "
849
+ "manual cleanup of the non-transactional changes may be required.\n"
850
+ f" Non-transactional: {', '.join(non_transactional_migrations)}\n"
851
+ f" Transactional: {', '.join(transactional_migrations[:3])}"
852
+ f"{'...' if len(transactional_migrations) > 3 else ''}"
853
+ )
854
+
855
+ def dry_run(self, migration: Migration) -> DryRunResult:
856
+ """Test a migration without making permanent changes.
857
+
858
+ Executes the migration in dry-run mode using DryRunExecutor,
859
+ which automatically rolls back all changes. Useful for:
860
+ - Verifying migrations work before production deployment
861
+ - Estimating execution time
862
+ - Detecting constraint violations
863
+ - Identifying table locking issues
864
+
865
+ Args:
866
+ migration: Migration instance to test
867
+
868
+ Returns:
869
+ DryRunResult with execution metrics and estimates
870
+
871
+ Raises:
872
+ DryRunError: If migration execution fails during dry-run
873
+
874
+ Example:
875
+ >>> migrator = Migrator(connection=conn)
876
+ >>> migration = MyMigration(connection=conn)
877
+ >>> result = migrator.dry_run(migration)
878
+ >>> print(f"Estimated time: {result.estimated_production_time_ms}ms")
879
+ >>> print(f"Confidence: {result.confidence_percent}%")
880
+ """
881
+ executor = DryRunExecutor()
882
+ return executor.run(self.connection, migration)