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
@@ -0,0 +1,793 @@
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 rollback(self, migration: Migration) -> None:
375
+ """Rollback a migration and remove it from tracking table.
376
+
377
+ For transactional migrations (default):
378
+ - Executes within a transaction with automatic rollback on failure
379
+ - Safe and consistent
380
+
381
+ For non-transactional migrations (transactional=False):
382
+ - Runs in autocommit mode
383
+ - No automatic rollback on failure
384
+ - Manual cleanup may be required
385
+
386
+ Args:
387
+ migration: Migration instance to rollback
388
+
389
+ Raises:
390
+ MigrationError: If migration fails or was not applied
391
+ """
392
+ # Check if applied
393
+ if not self._is_applied(migration.version):
394
+ raise MigrationError(
395
+ f"Migration {migration.version} ({migration.name}) "
396
+ "has not been applied, cannot rollback"
397
+ )
398
+
399
+ if migration.transactional:
400
+ self._rollback_transactional(migration)
401
+ else:
402
+ self._rollback_non_transactional(migration)
403
+
404
+ def _rollback_transactional(self, migration: Migration) -> None:
405
+ """Rollback a migration within a transaction.
406
+
407
+ Args:
408
+ migration: Migration instance to rollback
409
+ """
410
+ try:
411
+ # Execute down() method
412
+ logger.debug(f"Executing rollback (down) for migration {migration.version}")
413
+ migration.down()
414
+
415
+ # Remove from tracking table
416
+ self._execute_sql(
417
+ """
418
+ DELETE FROM confiture_migrations
419
+ WHERE version = %s
420
+ """,
421
+ (migration.version,),
422
+ )
423
+
424
+ # Commit transaction
425
+ self.connection.commit()
426
+ logger.info(
427
+ f"Successfully rolled back migration {migration.version} ({migration.name})"
428
+ )
429
+
430
+ except Exception as e:
431
+ self.connection.rollback()
432
+ raise MigrationError(
433
+ f"Failed to rollback migration {migration.version} ({migration.name}): {e}"
434
+ ) from e
435
+
436
+ def _rollback_non_transactional(self, migration: Migration) -> None:
437
+ """Rollback a migration in autocommit mode (no transaction).
438
+
439
+ WARNING: If this fails, manual cleanup may be required.
440
+
441
+ Args:
442
+ migration: Migration instance to rollback
443
+ """
444
+ logger.warning(
445
+ f"Rolling back migration {migration.version} in non-transactional mode. "
446
+ "Manual cleanup may be required on failure."
447
+ )
448
+
449
+ # Ensure any pending transaction is committed
450
+ self.connection.commit()
451
+
452
+ # Set autocommit mode
453
+ original_autocommit = self.connection.autocommit
454
+ self.connection.autocommit = True
455
+
456
+ try:
457
+ # Execute down() method
458
+ logger.debug(
459
+ f"Executing rollback (down) for migration {migration.version} (autocommit)"
460
+ )
461
+ migration.down()
462
+
463
+ # Remove from tracking table
464
+ self._execute_sql(
465
+ """
466
+ DELETE FROM confiture_migrations
467
+ WHERE version = %s
468
+ """,
469
+ (migration.version,),
470
+ )
471
+
472
+ logger.info(
473
+ f"Successfully rolled back non-transactional migration "
474
+ f"{migration.version} ({migration.name})"
475
+ )
476
+
477
+ except Exception as e:
478
+ logger.error(
479
+ f"Non-transactional rollback of migration {migration.version} failed. "
480
+ "Manual cleanup may be required."
481
+ )
482
+ raise MigrationError(
483
+ f"Failed to rollback non-transactional migration "
484
+ f"{migration.version} ({migration.name}): {e}. "
485
+ "Manual cleanup may be required."
486
+ ) from e
487
+
488
+ finally:
489
+ # Restore original autocommit setting
490
+ self.connection.autocommit = original_autocommit
491
+
492
+ def _is_applied(self, version: str) -> bool:
493
+ """Check if migration version has been applied.
494
+
495
+ Args:
496
+ version: Migration version to check
497
+
498
+ Returns:
499
+ True if migration has been applied, False otherwise
500
+ """
501
+ with self.connection.cursor() as cursor:
502
+ cursor.execute(
503
+ """
504
+ SELECT COUNT(*)
505
+ FROM confiture_migrations
506
+ WHERE version = %s
507
+ """,
508
+ (version,),
509
+ )
510
+ result = cursor.fetchone()
511
+ if result is None:
512
+ return False
513
+ count: int = result[0]
514
+ return count > 0
515
+
516
+ def get_applied_versions(self) -> list[str]:
517
+ """Get list of all applied migration versions.
518
+
519
+ Returns:
520
+ List of migration versions, sorted by applied_at timestamp
521
+ """
522
+ with self.connection.cursor() as cursor:
523
+ cursor.execute("""
524
+ SELECT version
525
+ FROM confiture_migrations
526
+ ORDER BY applied_at ASC
527
+ """)
528
+ return [row[0] for row in cursor.fetchall()]
529
+
530
+ def find_migration_files(self, migrations_dir: Path | None = None) -> list[Path]:
531
+ """Find all migration files in the migrations directory.
532
+
533
+ Args:
534
+ migrations_dir: Optional custom migrations directory.
535
+ If None, uses db/migrations/ (default)
536
+
537
+ Returns:
538
+ List of migration file paths, sorted by version number
539
+
540
+ Example:
541
+ >>> migrator = Migrator(connection=conn)
542
+ >>> files = migrator.find_migration_files()
543
+ >>> # [Path("db/migrations/001_create_users.py"), ...]
544
+ """
545
+ if migrations_dir is None:
546
+ migrations_dir = Path("db") / "migrations"
547
+
548
+ if not migrations_dir.exists():
549
+ return []
550
+
551
+ # Find all .py files (excluding __pycache__, __init__.py)
552
+ migration_files = sorted(
553
+ [
554
+ f
555
+ for f in migrations_dir.glob("*.py")
556
+ if f.name != "__init__.py" and not f.name.startswith("_")
557
+ ]
558
+ )
559
+
560
+ return migration_files
561
+
562
+ def find_pending(self, migrations_dir: Path | None = None) -> list[Path]:
563
+ """Find migrations that have not been applied yet.
564
+
565
+ Args:
566
+ migrations_dir: Optional custom migrations directory
567
+
568
+ Returns:
569
+ List of pending migration file paths
570
+
571
+ Example:
572
+ >>> migrator = Migrator(connection=conn)
573
+ >>> pending = migrator.find_pending()
574
+ >>> print(f"Found {len(pending)} pending migrations")
575
+ """
576
+ # Get all migration files
577
+ all_migrations = self.find_migration_files(migrations_dir)
578
+
579
+ # Get applied versions
580
+ applied_versions = set(self.get_applied_versions())
581
+
582
+ # Filter to pending only
583
+ pending_migrations = [
584
+ migration_file
585
+ for migration_file in all_migrations
586
+ if self._version_from_filename(migration_file.name) not in applied_versions
587
+ ]
588
+
589
+ return pending_migrations
590
+
591
+ def _version_from_filename(self, filename: str) -> str:
592
+ """Extract version from migration filename.
593
+
594
+ Migration files follow the format: {version}_{name}.py
595
+ Example: "001_create_users.py" -> "001"
596
+
597
+ Args:
598
+ filename: Migration filename
599
+
600
+ Returns:
601
+ Version string
602
+
603
+ Example:
604
+ >>> migrator._version_from_filename("042_add_column.py")
605
+ "042"
606
+ """
607
+ # Split on first underscore
608
+ version = filename.split("_")[0]
609
+ return version
610
+
611
+ def migrate_up(
612
+ self,
613
+ force: bool = False,
614
+ migrations_dir: Path | None = None,
615
+ target: str | None = None,
616
+ lock_config: LockConfig | None = None,
617
+ checksum_config: ChecksumConfig | None = None,
618
+ ) -> list[str]:
619
+ """Apply pending migrations up to target version.
620
+
621
+ Uses distributed locking to ensure only one migration process runs
622
+ at a time. This is critical for multi-pod Kubernetes deployments.
623
+
624
+ Optionally verifies checksums before running migrations to detect
625
+ unauthorized modifications to migration files.
626
+
627
+ Args:
628
+ force: If True, skip migration state checks and apply all migrations
629
+ migrations_dir: Custom migrations directory (default: db/migrations)
630
+ target: Target migration version (applies all if None)
631
+ lock_config: Locking configuration. If None, uses default (enabled,
632
+ 30s timeout, blocking mode). Pass LockConfig(enabled=False)
633
+ to disable locking.
634
+ checksum_config: Checksum verification configuration. If None, uses
635
+ default (enabled, fail on mismatch). Pass
636
+ ChecksumConfig(enabled=False) to disable verification.
637
+
638
+ Returns:
639
+ List of applied migration versions
640
+
641
+ Raises:
642
+ MigrationError: If migration application fails
643
+ LockAcquisitionError: If lock cannot be acquired within timeout
644
+ ChecksumVerificationError: If checksum mismatch and behavior is FAIL
645
+
646
+ Example:
647
+ >>> migrator = Migrator(connection=conn)
648
+ >>> migrator.initialize()
649
+ >>> # Default: verify checksums, fail on mismatch
650
+ >>> applied = migrator.migrate_up()
651
+ >>>
652
+ >>> # Custom checksum behavior
653
+ >>> from confiture.core.checksum import ChecksumConfig, ChecksumMismatchBehavior
654
+ >>> applied = migrator.migrate_up(
655
+ ... checksum_config=ChecksumConfig(
656
+ ... on_mismatch=ChecksumMismatchBehavior.WARN
657
+ ... )
658
+ ... )
659
+ >>>
660
+ >>> # Disable checksum verification
661
+ >>> applied = migrator.migrate_up(
662
+ ... checksum_config=ChecksumConfig(enabled=False)
663
+ ... )
664
+ """
665
+ effective_migrations_dir = migrations_dir or Path("db/migrations")
666
+
667
+ # Verify checksums before running migrations (unless force mode)
668
+ if checksum_config is None:
669
+ checksum_config = ChecksumConfig()
670
+
671
+ if checksum_config.enabled and not force:
672
+ verifier = MigrationChecksumVerifier(self.connection, checksum_config)
673
+ verifier.verify_all(effective_migrations_dir)
674
+
675
+ # Create lock manager
676
+ lock = MigrationLock(self.connection, lock_config)
677
+
678
+ # Acquire lock and run migrations
679
+ with lock.acquire():
680
+ return self._migrate_up_internal(force, migrations_dir, target)
681
+
682
+ def _migrate_up_internal(
683
+ self,
684
+ force: bool = False,
685
+ migrations_dir: Path | None = None,
686
+ target: str | None = None,
687
+ ) -> list[str]:
688
+ """Internal implementation of migrate_up (called within lock).
689
+
690
+ Args:
691
+ force: If True, skip migration state checks
692
+ migrations_dir: Custom migrations directory
693
+ target: Target migration version
694
+
695
+ Returns:
696
+ List of applied migration versions
697
+ """
698
+ # Find migrations to apply
699
+ if force:
700
+ # In force mode, apply all migrations regardless of state
701
+ migrations_to_apply = self.find_migration_files(migrations_dir)
702
+ else:
703
+ # Normal mode: only apply pending migrations
704
+ migrations_to_apply = self.find_pending(migrations_dir)
705
+
706
+ # Check for mixed transactional modes and warn
707
+ self._warn_mixed_transactional_modes(migrations_to_apply)
708
+
709
+ applied_versions = []
710
+
711
+ for migration_file in migrations_to_apply:
712
+ # Load migration module
713
+ module = load_migration_module(migration_file)
714
+ migration_class = get_migration_class(module)
715
+
716
+ # Create migration instance
717
+ migration = migration_class(connection=self.connection)
718
+
719
+ # Check target
720
+ if target and migration.version > target:
721
+ break
722
+
723
+ # Apply migration with file path for checksum computation
724
+ self.apply(migration, force=force, migration_file=migration_file)
725
+ applied_versions.append(migration.version)
726
+
727
+ return applied_versions
728
+
729
+ def _warn_mixed_transactional_modes(self, migration_files: list[Path]) -> None:
730
+ """Warn if batch contains both transactional and non-transactional migrations.
731
+
732
+ Mixed batches can be problematic because non-transactional migrations
733
+ cannot be automatically rolled back if a later transactional migration fails.
734
+
735
+ Args:
736
+ migration_files: List of migration files to check
737
+ """
738
+ if len(migration_files) <= 1:
739
+ return
740
+
741
+ transactional_migrations: list[str] = []
742
+ non_transactional_migrations: list[str] = []
743
+
744
+ for migration_file in migration_files:
745
+ module = load_migration_module(migration_file)
746
+ migration_class = get_migration_class(module)
747
+
748
+ # Check transactional attribute (default is True)
749
+ is_transactional = getattr(migration_class, "transactional", True)
750
+
751
+ if is_transactional:
752
+ transactional_migrations.append(migration_file.name)
753
+ else:
754
+ non_transactional_migrations.append(migration_file.name)
755
+
756
+ if transactional_migrations and non_transactional_migrations:
757
+ logger.warning(
758
+ "Batch contains both transactional and non-transactional migrations. "
759
+ "If a transactional migration fails after a non-transactional one succeeds, "
760
+ "manual cleanup of the non-transactional changes may be required.\n"
761
+ f" Non-transactional: {', '.join(non_transactional_migrations)}\n"
762
+ f" Transactional: {', '.join(transactional_migrations[:3])}"
763
+ f"{'...' if len(transactional_migrations) > 3 else ''}"
764
+ )
765
+
766
+ def dry_run(self, migration: Migration) -> DryRunResult:
767
+ """Test a migration without making permanent changes.
768
+
769
+ Executes the migration in dry-run mode using DryRunExecutor,
770
+ which automatically rolls back all changes. Useful for:
771
+ - Verifying migrations work before production deployment
772
+ - Estimating execution time
773
+ - Detecting constraint violations
774
+ - Identifying table locking issues
775
+
776
+ Args:
777
+ migration: Migration instance to test
778
+
779
+ Returns:
780
+ DryRunResult with execution metrics and estimates
781
+
782
+ Raises:
783
+ DryRunError: If migration execution fails during dry-run
784
+
785
+ Example:
786
+ >>> migrator = Migrator(connection=conn)
787
+ >>> migration = MyMigration(connection=conn)
788
+ >>> result = migrator.dry_run(migration)
789
+ >>> print(f"Estimated time: {result.estimated_production_time_ms}ms")
790
+ >>> print(f"Confidence: {result.confidence_percent}%")
791
+ """
792
+ executor = DryRunExecutor()
793
+ return executor.run(self.connection, migration)