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.
- confiture/__init__.py +48 -0
- confiture/_core.cpython-311-darwin.so +0 -0
- confiture/cli/__init__.py +0 -0
- confiture/cli/dry_run.py +116 -0
- confiture/cli/lint_formatter.py +193 -0
- confiture/cli/main.py +1893 -0
- confiture/config/__init__.py +0 -0
- confiture/config/environment.py +263 -0
- confiture/core/__init__.py +51 -0
- confiture/core/anonymization/__init__.py +0 -0
- confiture/core/anonymization/audit.py +485 -0
- confiture/core/anonymization/benchmarking.py +372 -0
- confiture/core/anonymization/breach_notification.py +652 -0
- confiture/core/anonymization/compliance.py +617 -0
- confiture/core/anonymization/composer.py +298 -0
- confiture/core/anonymization/data_subject_rights.py +669 -0
- confiture/core/anonymization/factory.py +319 -0
- confiture/core/anonymization/governance.py +737 -0
- confiture/core/anonymization/performance.py +1092 -0
- confiture/core/anonymization/profile.py +284 -0
- confiture/core/anonymization/registry.py +195 -0
- confiture/core/anonymization/security/kms_manager.py +547 -0
- confiture/core/anonymization/security/lineage.py +888 -0
- confiture/core/anonymization/security/token_store.py +686 -0
- confiture/core/anonymization/strategies/__init__.py +41 -0
- confiture/core/anonymization/strategies/address.py +359 -0
- confiture/core/anonymization/strategies/credit_card.py +374 -0
- confiture/core/anonymization/strategies/custom.py +161 -0
- confiture/core/anonymization/strategies/date.py +218 -0
- confiture/core/anonymization/strategies/differential_privacy.py +398 -0
- confiture/core/anonymization/strategies/email.py +141 -0
- confiture/core/anonymization/strategies/format_preserving_encryption.py +310 -0
- confiture/core/anonymization/strategies/hash.py +150 -0
- confiture/core/anonymization/strategies/ip_address.py +235 -0
- confiture/core/anonymization/strategies/masking_retention.py +252 -0
- confiture/core/anonymization/strategies/name.py +298 -0
- confiture/core/anonymization/strategies/phone.py +119 -0
- confiture/core/anonymization/strategies/preserve.py +85 -0
- confiture/core/anonymization/strategies/redact.py +101 -0
- confiture/core/anonymization/strategies/salted_hashing.py +322 -0
- confiture/core/anonymization/strategies/text_redaction.py +183 -0
- confiture/core/anonymization/strategies/tokenization.py +334 -0
- confiture/core/anonymization/strategy.py +241 -0
- confiture/core/anonymization/syncer_audit.py +357 -0
- confiture/core/blue_green.py +683 -0
- confiture/core/builder.py +500 -0
- confiture/core/checksum.py +358 -0
- confiture/core/connection.py +184 -0
- confiture/core/differ.py +522 -0
- confiture/core/drift.py +564 -0
- confiture/core/dry_run.py +182 -0
- confiture/core/health.py +313 -0
- confiture/core/hooks/__init__.py +87 -0
- confiture/core/hooks/base.py +232 -0
- confiture/core/hooks/context.py +146 -0
- confiture/core/hooks/execution_strategies.py +57 -0
- confiture/core/hooks/observability.py +220 -0
- confiture/core/hooks/phases.py +53 -0
- confiture/core/hooks/registry.py +295 -0
- confiture/core/large_tables.py +775 -0
- confiture/core/linting/__init__.py +70 -0
- confiture/core/linting/composer.py +192 -0
- confiture/core/linting/libraries/__init__.py +17 -0
- confiture/core/linting/libraries/gdpr.py +168 -0
- confiture/core/linting/libraries/general.py +184 -0
- confiture/core/linting/libraries/hipaa.py +144 -0
- confiture/core/linting/libraries/pci_dss.py +104 -0
- confiture/core/linting/libraries/sox.py +120 -0
- confiture/core/linting/schema_linter.py +491 -0
- confiture/core/linting/versioning.py +151 -0
- confiture/core/locking.py +389 -0
- confiture/core/migration_generator.py +298 -0
- confiture/core/migrator.py +882 -0
- confiture/core/observability/__init__.py +44 -0
- confiture/core/observability/audit.py +323 -0
- confiture/core/observability/logging.py +187 -0
- confiture/core/observability/metrics.py +174 -0
- confiture/core/observability/tracing.py +192 -0
- confiture/core/pg_version.py +418 -0
- confiture/core/pool.py +406 -0
- confiture/core/risk/__init__.py +39 -0
- confiture/core/risk/predictor.py +188 -0
- confiture/core/risk/scoring.py +248 -0
- confiture/core/rollback_generator.py +388 -0
- confiture/core/schema_analyzer.py +769 -0
- confiture/core/schema_to_schema.py +590 -0
- confiture/core/security/__init__.py +32 -0
- confiture/core/security/logging.py +201 -0
- confiture/core/security/validation.py +416 -0
- confiture/core/signals.py +371 -0
- confiture/core/syncer.py +540 -0
- confiture/exceptions.py +192 -0
- confiture/integrations/__init__.py +0 -0
- confiture/models/__init__.py +24 -0
- confiture/models/lint.py +193 -0
- confiture/models/migration.py +265 -0
- confiture/models/schema.py +203 -0
- confiture/models/sql_file_migration.py +225 -0
- confiture/scenarios/__init__.py +36 -0
- confiture/scenarios/compliance.py +586 -0
- confiture/scenarios/ecommerce.py +199 -0
- confiture/scenarios/financial.py +253 -0
- confiture/scenarios/healthcare.py +315 -0
- confiture/scenarios/multi_tenant.py +340 -0
- confiture/scenarios/saas.py +295 -0
- confiture/testing/FRAMEWORK_API.md +722 -0
- confiture/testing/__init__.py +100 -0
- confiture/testing/fixtures/__init__.py +11 -0
- confiture/testing/fixtures/data_validator.py +229 -0
- confiture/testing/fixtures/migration_runner.py +167 -0
- confiture/testing/fixtures/schema_snapshotter.py +352 -0
- confiture/testing/frameworks/__init__.py +10 -0
- confiture/testing/frameworks/mutation.py +587 -0
- confiture/testing/frameworks/performance.py +479 -0
- confiture/testing/loader.py +225 -0
- confiture/testing/pytest/__init__.py +38 -0
- confiture/testing/pytest_plugin.py +190 -0
- confiture/testing/sandbox.py +304 -0
- confiture/testing/utils/__init__.py +0 -0
- fraiseql_confiture-0.3.7.dist-info/METADATA +438 -0
- fraiseql_confiture-0.3.7.dist-info/RECORD +124 -0
- fraiseql_confiture-0.3.7.dist-info/WHEEL +4 -0
- fraiseql_confiture-0.3.7.dist-info/entry_points.txt +4 -0
- 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)
|