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,100 @@
|
|
|
1
|
+
"""Confiture Migration Testing Framework.
|
|
2
|
+
|
|
3
|
+
Comprehensive testing framework for PostgreSQL migrations including:
|
|
4
|
+
- Migration loader utility for easy test setup
|
|
5
|
+
- Test fixtures (SchemaSnapshotter, DataValidator, MigrationRunner)
|
|
6
|
+
- Mutation testing (27 mutations across 4 categories)
|
|
7
|
+
- Performance profiling with regression detection
|
|
8
|
+
- Load testing with 100k+ row validation
|
|
9
|
+
- Advanced scenario testing
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
>>> from confiture.testing import load_migration, SchemaSnapshotter, DataValidator
|
|
13
|
+
>>> Migration003 = load_migration("003_move_catalog_tables")
|
|
14
|
+
>>> # or by version:
|
|
15
|
+
>>> Migration003 = load_migration(version="003")
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
# Fixtures - convenient top-level imports
|
|
19
|
+
from confiture.testing.fixtures import (
|
|
20
|
+
DataValidator,
|
|
21
|
+
MigrationRunner,
|
|
22
|
+
SchemaSnapshotter,
|
|
23
|
+
)
|
|
24
|
+
from confiture.testing.fixtures.data_validator import DataBaseline
|
|
25
|
+
from confiture.testing.fixtures.schema_snapshotter import (
|
|
26
|
+
ColumnInfo,
|
|
27
|
+
ConstraintInfo,
|
|
28
|
+
ForeignKeyInfo,
|
|
29
|
+
IndexInfo,
|
|
30
|
+
SchemaChange,
|
|
31
|
+
SchemaSnapshot,
|
|
32
|
+
TableSchema,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Mutation testing framework
|
|
36
|
+
from confiture.testing.frameworks.mutation import (
|
|
37
|
+
Mutation,
|
|
38
|
+
MutationCategory,
|
|
39
|
+
MutationMetrics,
|
|
40
|
+
MutationRegistry,
|
|
41
|
+
MutationReport,
|
|
42
|
+
MutationSeverity,
|
|
43
|
+
)
|
|
44
|
+
from confiture.testing.frameworks.mutation import (
|
|
45
|
+
MutationRunner as MutationTestRunner,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Performance testing framework
|
|
49
|
+
from confiture.testing.frameworks.performance import (
|
|
50
|
+
MigrationPerformanceProfiler,
|
|
51
|
+
PerformanceOptimizationReport,
|
|
52
|
+
PerformanceProfile,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Migration loader utility
|
|
56
|
+
from confiture.testing.loader import (
|
|
57
|
+
MigrationLoadError,
|
|
58
|
+
MigrationNotFoundError,
|
|
59
|
+
find_migration_by_version,
|
|
60
|
+
load_migration,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Migration sandbox
|
|
64
|
+
from confiture.testing.sandbox import MigrationSandbox
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
# Migration sandbox (context manager for testing)
|
|
68
|
+
"MigrationSandbox",
|
|
69
|
+
# Migration loader (most commonly used)
|
|
70
|
+
"load_migration",
|
|
71
|
+
"find_migration_by_version",
|
|
72
|
+
"MigrationNotFoundError",
|
|
73
|
+
"MigrationLoadError",
|
|
74
|
+
# Test fixtures
|
|
75
|
+
"SchemaSnapshotter",
|
|
76
|
+
"DataValidator",
|
|
77
|
+
"MigrationRunner",
|
|
78
|
+
# Fixture data classes
|
|
79
|
+
"DataBaseline",
|
|
80
|
+
"SchemaSnapshot",
|
|
81
|
+
"TableSchema",
|
|
82
|
+
"ColumnInfo",
|
|
83
|
+
"ConstraintInfo",
|
|
84
|
+
"IndexInfo",
|
|
85
|
+
"ForeignKeyInfo",
|
|
86
|
+
"SchemaChange",
|
|
87
|
+
# Mutation testing
|
|
88
|
+
"Mutation",
|
|
89
|
+
"MutationRegistry",
|
|
90
|
+
"MutationTestRunner",
|
|
91
|
+
"MutationRunner", # Alias for backwards compatibility
|
|
92
|
+
"MutationReport",
|
|
93
|
+
"MutationMetrics",
|
|
94
|
+
"MutationSeverity",
|
|
95
|
+
"MutationCategory",
|
|
96
|
+
# Performance testing
|
|
97
|
+
"MigrationPerformanceProfiler",
|
|
98
|
+
"PerformanceProfile",
|
|
99
|
+
"PerformanceOptimizationReport",
|
|
100
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Test fixtures and utilities for Confiture migration testing."""
|
|
2
|
+
|
|
3
|
+
from confiture.testing.fixtures.data_validator import DataValidator
|
|
4
|
+
from confiture.testing.fixtures.migration_runner import MigrationRunner
|
|
5
|
+
from confiture.testing.fixtures.schema_snapshotter import SchemaSnapshotter
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"MigrationRunner",
|
|
9
|
+
"SchemaSnapshotter",
|
|
10
|
+
"DataValidator",
|
|
11
|
+
]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Data validation utility for migration testing.
|
|
2
|
+
|
|
3
|
+
Validates data integrity after migrations by checking row counts, constraints,
|
|
4
|
+
and foreign key relationships. Can be extracted to confiture-testing package.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
import psycopg
|
|
10
|
+
from psycopg import sql
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class DataBaseline:
|
|
15
|
+
"""Baseline data snapshot before migration."""
|
|
16
|
+
|
|
17
|
+
table_row_counts: dict[str, int]
|
|
18
|
+
foreign_key_violations: int
|
|
19
|
+
null_violations: int
|
|
20
|
+
constraint_violations: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DataValidator:
|
|
24
|
+
"""Validate data integrity after migrations.
|
|
25
|
+
|
|
26
|
+
Generic data validation that can be extracted to confiture-testing.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, connection: psycopg.Connection):
|
|
30
|
+
"""Initialize data validator.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
connection: PostgreSQL connection for validation queries
|
|
34
|
+
"""
|
|
35
|
+
self.connection = connection
|
|
36
|
+
|
|
37
|
+
def capture_baseline(self) -> DataBaseline:
|
|
38
|
+
"""Capture baseline data state before migration.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
DataBaseline with row counts and constraint violation status
|
|
42
|
+
"""
|
|
43
|
+
with self.connection.cursor() as cur:
|
|
44
|
+
# Get row counts for all tables
|
|
45
|
+
cur.execute(
|
|
46
|
+
"""
|
|
47
|
+
SELECT schemaname, relname, n_live_tup
|
|
48
|
+
FROM pg_stat_user_tables
|
|
49
|
+
ORDER BY schemaname, relname
|
|
50
|
+
"""
|
|
51
|
+
)
|
|
52
|
+
row_counts = {f"{row[0]}.{row[1]}": row[2] for row in cur.fetchall()}
|
|
53
|
+
|
|
54
|
+
# Check for FK violations (should be 0)
|
|
55
|
+
fk_violations = self._count_fk_violations(cur)
|
|
56
|
+
|
|
57
|
+
# Check for null violations in NOT NULL columns
|
|
58
|
+
null_violations = self._count_null_violations(cur)
|
|
59
|
+
|
|
60
|
+
# Check for constraint violations
|
|
61
|
+
constraint_violations = 0 # Placeholder for generic checks
|
|
62
|
+
|
|
63
|
+
return DataBaseline(
|
|
64
|
+
table_row_counts=row_counts,
|
|
65
|
+
foreign_key_violations=fk_violations,
|
|
66
|
+
null_violations=null_violations,
|
|
67
|
+
constraint_violations=constraint_violations,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def no_data_loss(self, baseline: DataBaseline, allow_additions: bool = True) -> bool:
|
|
71
|
+
"""Verify no data was lost during migration.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
baseline: Baseline data state before migration
|
|
75
|
+
allow_additions: If False, reject unexpected data additions
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
True if no data loss detected, False otherwise
|
|
79
|
+
"""
|
|
80
|
+
current = self.capture_baseline()
|
|
81
|
+
|
|
82
|
+
for table, baseline_count in baseline.table_row_counts.items():
|
|
83
|
+
current_count = current.table_row_counts.get(table, 0)
|
|
84
|
+
|
|
85
|
+
if current_count < baseline_count:
|
|
86
|
+
# Data loss detected
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
if not allow_additions and current_count > baseline_count:
|
|
90
|
+
# Unexpected data additions
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
def constraints_valid(self) -> bool:
|
|
96
|
+
"""Verify all constraints are valid after migration.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
True if all constraints valid, False otherwise
|
|
100
|
+
"""
|
|
101
|
+
with self.connection.cursor() as cur:
|
|
102
|
+
# Check FK violations
|
|
103
|
+
if self._count_fk_violations(cur) > 0:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
# Check NULL violations
|
|
107
|
+
return self._count_null_violations(cur) == 0
|
|
108
|
+
|
|
109
|
+
def validate_indexes(self) -> list[str]:
|
|
110
|
+
"""Validate all indexes are valid and not broken.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
List of invalid index names (empty if all valid)
|
|
114
|
+
"""
|
|
115
|
+
with self.connection.cursor() as cur:
|
|
116
|
+
cur.execute(
|
|
117
|
+
"""
|
|
118
|
+
SELECT schemaname, tablename, indexname
|
|
119
|
+
FROM pg_indexes
|
|
120
|
+
WHERE indexdef IS NULL OR indexdef ~ 'INVALID'
|
|
121
|
+
"""
|
|
122
|
+
)
|
|
123
|
+
invalid_indexes = [f"{row[0]}.{row[1]}.{row[2]}" for row in cur.fetchall()]
|
|
124
|
+
|
|
125
|
+
return invalid_indexes
|
|
126
|
+
|
|
127
|
+
def get_row_count(self, table_name: str) -> int:
|
|
128
|
+
"""Get current row count for a specific table.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
table_name: Fully qualified table name (schema.table)
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Number of rows in the table
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
with self.connection.cursor() as cur:
|
|
138
|
+
# Handle schema.table format
|
|
139
|
+
if "." in table_name:
|
|
140
|
+
schema, table = table_name.split(".", 1)
|
|
141
|
+
cur.execute(
|
|
142
|
+
sql.SQL("SELECT COUNT(*) FROM {}.{}").format(
|
|
143
|
+
sql.Identifier(schema),
|
|
144
|
+
sql.Identifier(table),
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
cur.execute(
|
|
149
|
+
sql.SQL("SELECT COUNT(*) FROM {}").format(sql.Identifier(table_name))
|
|
150
|
+
)
|
|
151
|
+
row = cur.fetchone()
|
|
152
|
+
return row[0] if row else 0
|
|
153
|
+
except Exception:
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
def _count_fk_violations(self, cur: psycopg.Cursor) -> int:
|
|
157
|
+
"""Count foreign key constraint violations.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
cur: Database cursor
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Number of FK constraints that are not validated
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
cur.execute(
|
|
167
|
+
"""
|
|
168
|
+
SELECT COUNT(*)
|
|
169
|
+
FROM pg_constraint
|
|
170
|
+
WHERE contype = 'f'
|
|
171
|
+
AND convalidated = false
|
|
172
|
+
"""
|
|
173
|
+
)
|
|
174
|
+
row = cur.fetchone()
|
|
175
|
+
return row[0] if row else 0
|
|
176
|
+
except Exception:
|
|
177
|
+
# If query fails, assume no violations (constraint might not exist)
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
def _count_null_violations(self, _cur: psycopg.Cursor) -> int:
|
|
181
|
+
"""Count NULL violations in NOT NULL columns.
|
|
182
|
+
|
|
183
|
+
This is a simplified check - in production you'd want to check each column.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
_cur: Database cursor (unused in simplified implementation)
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Number of NULL violations detected (simplified to 0 for now)
|
|
190
|
+
"""
|
|
191
|
+
# Simplified implementation - would need to check each NOT NULL column
|
|
192
|
+
# to find actual violations. For now, return 0.
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
def check_foreign_key_integrity(self, table_name: str, _fk_column: str) -> bool:
|
|
196
|
+
"""Check if foreign key values in a column all have valid references.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
table_name: Table to check (schema.table format)
|
|
200
|
+
_fk_column: Foreign key column name (unused in simplified implementation)
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
True if all FK values are valid, False otherwise
|
|
204
|
+
"""
|
|
205
|
+
try:
|
|
206
|
+
with self.connection.cursor() as cur:
|
|
207
|
+
# Get the table and column info
|
|
208
|
+
cur.execute(
|
|
209
|
+
"""
|
|
210
|
+
SELECT constraint_name, confrelid::regclass, confkey
|
|
211
|
+
FROM pg_constraint
|
|
212
|
+
WHERE contype = 'f'
|
|
213
|
+
AND conrelid = %s::regclass
|
|
214
|
+
""",
|
|
215
|
+
(table_name,),
|
|
216
|
+
)
|
|
217
|
+
fk_info = cur.fetchone()
|
|
218
|
+
|
|
219
|
+
if not fk_info:
|
|
220
|
+
# No foreign key constraint found
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
# Simple check: just verify the constraint is valid
|
|
224
|
+
# More detailed check would require analyzing actual data
|
|
225
|
+
return True
|
|
226
|
+
|
|
227
|
+
except Exception:
|
|
228
|
+
# If validation fails, assume it's valid (prefer false negatives)
|
|
229
|
+
return True
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Migration execution utility for testing.
|
|
2
|
+
|
|
3
|
+
Wraps confiture migrations to provide structured test results and execution
|
|
4
|
+
tracking for PrintOptim's migration test suite.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import psycopg
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class MigrationResult:
|
|
16
|
+
"""Result of a migration execution."""
|
|
17
|
+
|
|
18
|
+
success: bool
|
|
19
|
+
migration_file: str
|
|
20
|
+
duration_seconds: float
|
|
21
|
+
stdout: str
|
|
22
|
+
stderr: str
|
|
23
|
+
error: Exception | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MigrationRunner:
|
|
27
|
+
"""Execute migrations in test environment using confiture.
|
|
28
|
+
|
|
29
|
+
This wraps confiture's Migrator to provide test utilities for:
|
|
30
|
+
- Executing migrations with timing
|
|
31
|
+
- Capturing structured results
|
|
32
|
+
- Supporting dry-run mode
|
|
33
|
+
- Tracking rollbacks
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, connection: psycopg.Connection, migrations_dir: Path | None = None):
|
|
37
|
+
"""Initialize migration runner.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
connection: PostgreSQL connection for migration execution
|
|
41
|
+
migrations_dir: Path to migrations directory (default: db/migrations)
|
|
42
|
+
"""
|
|
43
|
+
self.connection = connection
|
|
44
|
+
self.migrations_dir = migrations_dir or (
|
|
45
|
+
Path(__file__).parent.parent.parent.parent.parent / "db" / "migrations"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def run(self, migration_name: str, dry_run: bool = False) -> MigrationResult:
|
|
49
|
+
"""Execute a migration.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
migration_name: Name without extension (e.g., "002_add_floor_plan")
|
|
53
|
+
dry_run: If True, only show what would be executed
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
MigrationResult with execution details
|
|
57
|
+
"""
|
|
58
|
+
migration_file = self.migrations_dir / f"{migration_name}.sql"
|
|
59
|
+
|
|
60
|
+
if not migration_file.exists():
|
|
61
|
+
raise FileNotFoundError(f"Migration not found: {migration_file}")
|
|
62
|
+
|
|
63
|
+
start_time = time.time()
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
if dry_run:
|
|
67
|
+
# Parse SQL but don't execute
|
|
68
|
+
with open(migration_file) as f:
|
|
69
|
+
sql = f.read()
|
|
70
|
+
return MigrationResult(
|
|
71
|
+
success=True,
|
|
72
|
+
migration_file=str(migration_file),
|
|
73
|
+
duration_seconds=0,
|
|
74
|
+
stdout=f"DRY-RUN: Would execute migration {migration_name}\n"
|
|
75
|
+
f"SQL preview (first 500 chars):\n{sql[:500]}...",
|
|
76
|
+
stderr="",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Execute migration within a transaction
|
|
80
|
+
with self.connection.cursor() as cur:
|
|
81
|
+
with open(migration_file) as f:
|
|
82
|
+
sql = f.read()
|
|
83
|
+
|
|
84
|
+
# Execute the migration SQL
|
|
85
|
+
cur.execute(sql)
|
|
86
|
+
self.connection.commit()
|
|
87
|
+
|
|
88
|
+
duration = time.time() - start_time
|
|
89
|
+
|
|
90
|
+
return MigrationResult(
|
|
91
|
+
success=True,
|
|
92
|
+
migration_file=str(migration_file),
|
|
93
|
+
duration_seconds=duration,
|
|
94
|
+
stdout=f"✓ Migration {migration_name} executed successfully in {duration:.3f}s",
|
|
95
|
+
stderr="",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
except Exception as e:
|
|
99
|
+
duration = time.time() - start_time
|
|
100
|
+
self.connection.rollback()
|
|
101
|
+
|
|
102
|
+
return MigrationResult(
|
|
103
|
+
success=False,
|
|
104
|
+
migration_file=str(migration_file),
|
|
105
|
+
duration_seconds=duration,
|
|
106
|
+
stdout="",
|
|
107
|
+
stderr=str(e),
|
|
108
|
+
error=e,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def rollback(self, migration_name: str) -> MigrationResult:
|
|
112
|
+
"""Execute rollback for a migration.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
migration_name: Name without _rollback suffix (e.g., "002_add_floor_plan")
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
MigrationResult with execution details
|
|
119
|
+
"""
|
|
120
|
+
rollback_file = self.migrations_dir / f"{migration_name}_rollback.sql"
|
|
121
|
+
|
|
122
|
+
if not rollback_file.exists():
|
|
123
|
+
raise FileNotFoundError(f"Rollback not found: {rollback_file}")
|
|
124
|
+
|
|
125
|
+
# Execute rollback (same logic as run)
|
|
126
|
+
return self.run(f"{migration_name}_rollback")
|
|
127
|
+
|
|
128
|
+
def get_applied_migrations(self) -> list[str]:
|
|
129
|
+
"""Get list of applied migrations from confiture tracking table.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
List of migration slugs that have been applied
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
with self.connection.cursor() as cur:
|
|
136
|
+
cur.execute(
|
|
137
|
+
"""
|
|
138
|
+
SELECT slug
|
|
139
|
+
FROM confiture_migrations
|
|
140
|
+
ORDER BY applied_at ASC
|
|
141
|
+
"""
|
|
142
|
+
)
|
|
143
|
+
return [row[0] for row in cur.fetchall()]
|
|
144
|
+
except Exception:
|
|
145
|
+
# Table doesn't exist yet or other error - return empty list
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
def get_pending_migrations(self) -> list[str]:
|
|
149
|
+
"""Get list of pending migrations not yet applied.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
List of migration file names (without .sql) that haven't been applied
|
|
153
|
+
"""
|
|
154
|
+
applied = self.get_applied_migrations()
|
|
155
|
+
applied_set = set(applied)
|
|
156
|
+
|
|
157
|
+
# Find all migration files (not rollbacks)
|
|
158
|
+
migration_files = sorted(
|
|
159
|
+
[
|
|
160
|
+
f.stem
|
|
161
|
+
for f in self.migrations_dir.glob("*.sql")
|
|
162
|
+
if not f.name.endswith("_rollback.sql") and f.name[0].isdigit()
|
|
163
|
+
]
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Return only those not yet applied
|
|
167
|
+
return [m for m in migration_files if m not in applied_set]
|