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,587 @@
|
|
|
1
|
+
"""Mutation testing framework for database migrations.
|
|
2
|
+
|
|
3
|
+
Mutation testing verifies that migration tests would catch intentional bugs
|
|
4
|
+
by creating variations of migrations and checking if tests detect them.
|
|
5
|
+
|
|
6
|
+
Architecture:
|
|
7
|
+
- MutationRegistry: Catalog of all possible mutations
|
|
8
|
+
- MutationRunner: Execute migrations with mutations applied
|
|
9
|
+
- MutationReport: Analyze which tests caught which mutations
|
|
10
|
+
- MutationMetrics: Calculate mutation kill rate and effectiveness
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import asdict, dataclass, field
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import psycopg
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MutationSeverity(Enum):
|
|
25
|
+
"""Severity level of a mutation."""
|
|
26
|
+
|
|
27
|
+
CRITICAL = "CRITICAL" # Schema/data integrity issues
|
|
28
|
+
IMPORTANT = "IMPORTANT" # Significant behavior changes
|
|
29
|
+
MINOR = "MINOR" # Edge cases, optimization
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MutationCategory(Enum):
|
|
33
|
+
"""Category of mutation."""
|
|
34
|
+
|
|
35
|
+
SCHEMA = "schema" # Table/column/constraint changes
|
|
36
|
+
DATA = "data" # Data transformations
|
|
37
|
+
ROLLBACK = "rollback" # Rollback operations
|
|
38
|
+
PERFORMANCE = "performance" # Performance optimization
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Mutation:
|
|
43
|
+
"""Definition of a single mutation."""
|
|
44
|
+
|
|
45
|
+
id: str # Unique identifier
|
|
46
|
+
name: str # Human-readable name
|
|
47
|
+
description: str # What the mutation does
|
|
48
|
+
category: MutationCategory # Type of mutation
|
|
49
|
+
severity: MutationSeverity # Impact level
|
|
50
|
+
apply_fn: Callable | None = None # Function to apply mutation
|
|
51
|
+
apply_regex: str | None = None # Regex for SQL transformation
|
|
52
|
+
|
|
53
|
+
def apply(self, sql: str) -> str:
|
|
54
|
+
"""Apply this mutation to SQL code."""
|
|
55
|
+
if self.apply_fn:
|
|
56
|
+
return self.apply_fn(sql)
|
|
57
|
+
elif self.apply_regex:
|
|
58
|
+
# Simple regex-based mutations
|
|
59
|
+
# Format: "pattern=>replacement"
|
|
60
|
+
parts = self.apply_regex.split("=>")
|
|
61
|
+
if len(parts) == 2:
|
|
62
|
+
pattern, replacement = parts
|
|
63
|
+
return re.sub(pattern, replacement, sql, flags=re.IGNORECASE)
|
|
64
|
+
return sql
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class MutationResult:
|
|
69
|
+
"""Result of executing a migration with a mutation."""
|
|
70
|
+
|
|
71
|
+
mutation_id: str
|
|
72
|
+
success: bool # Migration executed
|
|
73
|
+
mutation_applied: bool # Mutation was successfully applied
|
|
74
|
+
duration_seconds: float
|
|
75
|
+
stdout: str
|
|
76
|
+
stderr: str
|
|
77
|
+
database_state: dict | None = None # Schema state after mutation
|
|
78
|
+
error: Exception | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class MutationTestResult:
|
|
83
|
+
"""Result of testing a mutation against test suite."""
|
|
84
|
+
|
|
85
|
+
mutation_id: str
|
|
86
|
+
mutation_name: str
|
|
87
|
+
test_name: str
|
|
88
|
+
caught: bool # Test caught the mutation
|
|
89
|
+
duration_seconds: float
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class MutationMetrics:
|
|
94
|
+
"""Metrics for mutation test results."""
|
|
95
|
+
|
|
96
|
+
total_mutations: int = 0
|
|
97
|
+
killed_mutations: int = 0 # Caught by tests
|
|
98
|
+
survived_mutations: int = 0 # Missed by tests
|
|
99
|
+
equivalent_mutations: int = 0 # Logically equivalent
|
|
100
|
+
|
|
101
|
+
by_category: dict[str, dict[str, int]] = field(default_factory=dict)
|
|
102
|
+
by_severity: dict[str, dict[str, int]] = field(default_factory=dict)
|
|
103
|
+
weak_tests: list[str] = field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def kill_rate(self) -> float:
|
|
107
|
+
"""Percentage of mutations killed by tests."""
|
|
108
|
+
if self.total_mutations == 0:
|
|
109
|
+
return 0.0
|
|
110
|
+
return (self.killed_mutations / self.total_mutations) * 100
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class MutationReport:
|
|
115
|
+
"""Complete mutation testing report."""
|
|
116
|
+
|
|
117
|
+
timestamp: str
|
|
118
|
+
total_mutations: int
|
|
119
|
+
metrics: MutationMetrics
|
|
120
|
+
results_by_mutation: dict[str, list[MutationTestResult]] = field(default_factory=dict)
|
|
121
|
+
results_by_test: dict[str, list[MutationTestResult]] = field(default_factory=dict)
|
|
122
|
+
recommendations: list[str] = field(default_factory=list)
|
|
123
|
+
|
|
124
|
+
def to_dict(self) -> dict[str, Any]:
|
|
125
|
+
"""Convert report to dictionary."""
|
|
126
|
+
return {
|
|
127
|
+
"timestamp": self.timestamp,
|
|
128
|
+
"total_mutations": self.total_mutations,
|
|
129
|
+
"metrics": asdict(self.metrics),
|
|
130
|
+
"kill_rate": f"{self.metrics.kill_rate:.1f}%",
|
|
131
|
+
"recommendations": self.recommendations,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class MutationRegistry:
|
|
136
|
+
"""Registry of all available mutations."""
|
|
137
|
+
|
|
138
|
+
def __init__(self):
|
|
139
|
+
self.mutations: dict[str, Mutation] = {}
|
|
140
|
+
self._initialize_default_mutations()
|
|
141
|
+
|
|
142
|
+
def _initialize_default_mutations(self):
|
|
143
|
+
"""Initialize default mutation set."""
|
|
144
|
+
# Schema mutations
|
|
145
|
+
self._add_schema_mutations()
|
|
146
|
+
# Data mutations
|
|
147
|
+
self._add_data_mutations()
|
|
148
|
+
# Rollback mutations
|
|
149
|
+
self._add_rollback_mutations()
|
|
150
|
+
# Performance mutations
|
|
151
|
+
self._add_performance_mutations()
|
|
152
|
+
|
|
153
|
+
def _add_schema_mutations(self):
|
|
154
|
+
"""Add schema-related mutations."""
|
|
155
|
+
schema_mutations = [
|
|
156
|
+
Mutation(
|
|
157
|
+
id="schema_001",
|
|
158
|
+
name="remove_primary_key",
|
|
159
|
+
description="Remove PRIMARY KEY constraint from table",
|
|
160
|
+
category=MutationCategory.SCHEMA,
|
|
161
|
+
severity=MutationSeverity.CRITICAL,
|
|
162
|
+
apply_regex=r"PRIMARY KEY\s*,?\s*" + "=>" + " ",
|
|
163
|
+
),
|
|
164
|
+
Mutation(
|
|
165
|
+
id="schema_002",
|
|
166
|
+
name="remove_not_null",
|
|
167
|
+
description="Remove NOT NULL constraint from column",
|
|
168
|
+
category=MutationCategory.SCHEMA,
|
|
169
|
+
severity=MutationSeverity.CRITICAL,
|
|
170
|
+
apply_regex=r"\s+NOT\s+NULL" + "=>" + " ",
|
|
171
|
+
),
|
|
172
|
+
Mutation(
|
|
173
|
+
id="schema_003",
|
|
174
|
+
name="remove_unique",
|
|
175
|
+
description="Remove UNIQUE constraint",
|
|
176
|
+
category=MutationCategory.SCHEMA,
|
|
177
|
+
severity=MutationSeverity.IMPORTANT,
|
|
178
|
+
apply_regex=r"\s+UNIQUE" + "=>" + " ",
|
|
179
|
+
),
|
|
180
|
+
Mutation(
|
|
181
|
+
id="schema_004",
|
|
182
|
+
name="remove_foreign_key",
|
|
183
|
+
description="Remove FOREIGN KEY constraint",
|
|
184
|
+
category=MutationCategory.SCHEMA,
|
|
185
|
+
severity=MutationSeverity.CRITICAL,
|
|
186
|
+
apply_regex=r"FOREIGN\s+KEY\s+\([^)]+\)\s+REFERENCES\s+\S+\s*\([^)]+\)"
|
|
187
|
+
+ "=>"
|
|
188
|
+
+ " ",
|
|
189
|
+
),
|
|
190
|
+
Mutation(
|
|
191
|
+
id="schema_005",
|
|
192
|
+
name="skip_index_creation",
|
|
193
|
+
description="Skip index creation in migration",
|
|
194
|
+
category=MutationCategory.SCHEMA,
|
|
195
|
+
severity=MutationSeverity.IMPORTANT,
|
|
196
|
+
apply_regex=r"CREATE\s+(?:UNIQUE\s+)?INDEX" + "=>" + "-- CREATE INDEX",
|
|
197
|
+
),
|
|
198
|
+
Mutation(
|
|
199
|
+
id="schema_006",
|
|
200
|
+
name="change_column_type",
|
|
201
|
+
description="Change column data type",
|
|
202
|
+
category=MutationCategory.SCHEMA,
|
|
203
|
+
severity=MutationSeverity.CRITICAL,
|
|
204
|
+
apply_fn=lambda sql: sql.replace("TEXT", "VARCHAR(50)") if "TEXT" in sql else sql,
|
|
205
|
+
),
|
|
206
|
+
Mutation(
|
|
207
|
+
id="schema_007",
|
|
208
|
+
name="remove_default_value",
|
|
209
|
+
description="Remove DEFAULT value from column",
|
|
210
|
+
category=MutationCategory.SCHEMA,
|
|
211
|
+
severity=MutationSeverity.IMPORTANT,
|
|
212
|
+
apply_regex=r"\s+DEFAULT\s+['\"]?[^,)]+['\"]?" + "=>" + " ",
|
|
213
|
+
),
|
|
214
|
+
Mutation(
|
|
215
|
+
id="schema_008",
|
|
216
|
+
name="add_unnecessary_column",
|
|
217
|
+
description="Add extra unrequired column",
|
|
218
|
+
category=MutationCategory.SCHEMA,
|
|
219
|
+
severity=MutationSeverity.MINOR,
|
|
220
|
+
apply_fn=lambda sql: sql + "\nALTER TABLE ADD COLUMN mutation_marker BOOLEAN;",
|
|
221
|
+
),
|
|
222
|
+
Mutation(
|
|
223
|
+
id="schema_009",
|
|
224
|
+
name="skip_constraint_check",
|
|
225
|
+
description="Skip CHECK constraint",
|
|
226
|
+
category=MutationCategory.SCHEMA,
|
|
227
|
+
severity=MutationSeverity.IMPORTANT,
|
|
228
|
+
apply_regex=r"CHECK\s*\([^)]+\)" + "=>" + " ",
|
|
229
|
+
),
|
|
230
|
+
Mutation(
|
|
231
|
+
id="schema_010",
|
|
232
|
+
name="wrong_column_order",
|
|
233
|
+
description="Change column ordering in table",
|
|
234
|
+
category=MutationCategory.SCHEMA,
|
|
235
|
+
severity=MutationSeverity.MINOR,
|
|
236
|
+
apply_fn=lambda sql: sql, # Complex to implement
|
|
237
|
+
),
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
for mutation in schema_mutations:
|
|
241
|
+
self.mutations[mutation.id] = mutation
|
|
242
|
+
|
|
243
|
+
def _add_data_mutations(self):
|
|
244
|
+
"""Add data-related mutations."""
|
|
245
|
+
data_mutations = [
|
|
246
|
+
Mutation(
|
|
247
|
+
id="data_001",
|
|
248
|
+
name="skip_update",
|
|
249
|
+
description="Skip UPDATE statement in migration",
|
|
250
|
+
category=MutationCategory.DATA,
|
|
251
|
+
severity=MutationSeverity.CRITICAL,
|
|
252
|
+
apply_regex=r"UPDATE\s+\w+\s+SET.*?;" + "=>" + "-- UPDATE (skipped);",
|
|
253
|
+
),
|
|
254
|
+
Mutation(
|
|
255
|
+
id="data_002",
|
|
256
|
+
name="wrong_update_value",
|
|
257
|
+
description="Use wrong value in UPDATE",
|
|
258
|
+
category=MutationCategory.DATA,
|
|
259
|
+
severity=MutationSeverity.CRITICAL,
|
|
260
|
+
apply_fn=lambda sql: sql.replace("'active'", "'inactive'")
|
|
261
|
+
if "'active'" in sql
|
|
262
|
+
else sql,
|
|
263
|
+
),
|
|
264
|
+
Mutation(
|
|
265
|
+
id="data_003",
|
|
266
|
+
name="skip_delete",
|
|
267
|
+
description="Skip DELETE statement",
|
|
268
|
+
category=MutationCategory.DATA,
|
|
269
|
+
severity=MutationSeverity.CRITICAL,
|
|
270
|
+
apply_regex=r"DELETE\s+FROM.*?;" + "=>" + "-- DELETE (skipped);",
|
|
271
|
+
),
|
|
272
|
+
Mutation(
|
|
273
|
+
id="data_004",
|
|
274
|
+
name="incomplete_insert",
|
|
275
|
+
description="Skip INSERT statement",
|
|
276
|
+
category=MutationCategory.DATA,
|
|
277
|
+
severity=MutationSeverity.CRITICAL,
|
|
278
|
+
apply_regex=r"INSERT\s+INTO.*?;" + "=>" + "-- INSERT (skipped);",
|
|
279
|
+
),
|
|
280
|
+
Mutation(
|
|
281
|
+
id="data_005",
|
|
282
|
+
name="wrong_where_clause",
|
|
283
|
+
description="Change WHERE condition",
|
|
284
|
+
category=MutationCategory.DATA,
|
|
285
|
+
severity=MutationSeverity.CRITICAL,
|
|
286
|
+
apply_fn=lambda sql: sql.replace("WHERE id > 0", "WHERE id < 0")
|
|
287
|
+
if "WHERE id > 0" in sql
|
|
288
|
+
else sql,
|
|
289
|
+
),
|
|
290
|
+
Mutation(
|
|
291
|
+
id="data_006",
|
|
292
|
+
name="missing_coalesce",
|
|
293
|
+
description="Don't use COALESCE for NULLs",
|
|
294
|
+
category=MutationCategory.DATA,
|
|
295
|
+
severity=MutationSeverity.IMPORTANT,
|
|
296
|
+
apply_fn=lambda sql: sql.replace("COALESCE(", "") if "COALESCE(" in sql else sql,
|
|
297
|
+
),
|
|
298
|
+
Mutation(
|
|
299
|
+
id="data_007",
|
|
300
|
+
name="partial_update",
|
|
301
|
+
description="Update only some rows when should update all",
|
|
302
|
+
category=MutationCategory.DATA,
|
|
303
|
+
severity=MutationSeverity.CRITICAL,
|
|
304
|
+
apply_fn=lambda sql: sql.replace("UPDATE table", "UPDATE table WHERE id IN (1,2,3)")
|
|
305
|
+
if "UPDATE table" in sql
|
|
306
|
+
else sql,
|
|
307
|
+
),
|
|
308
|
+
Mutation(
|
|
309
|
+
id="data_008",
|
|
310
|
+
name="wrong_cast",
|
|
311
|
+
description="Use wrong type cast",
|
|
312
|
+
category=MutationCategory.DATA,
|
|
313
|
+
severity=MutationSeverity.IMPORTANT,
|
|
314
|
+
apply_fn=lambda sql: sql.replace("::TEXT", "::INTEGER") if "::TEXT" in sql else sql,
|
|
315
|
+
),
|
|
316
|
+
]
|
|
317
|
+
|
|
318
|
+
for mutation in data_mutations:
|
|
319
|
+
self.mutations[mutation.id] = mutation
|
|
320
|
+
|
|
321
|
+
def _add_rollback_mutations(self):
|
|
322
|
+
"""Add rollback-related mutations."""
|
|
323
|
+
rollback_mutations = [
|
|
324
|
+
Mutation(
|
|
325
|
+
id="rollback_001",
|
|
326
|
+
name="incomplete_drop",
|
|
327
|
+
description="Don't drop all created objects",
|
|
328
|
+
category=MutationCategory.ROLLBACK,
|
|
329
|
+
severity=MutationSeverity.CRITICAL,
|
|
330
|
+
apply_regex=r"DROP\s+TABLE" + "=>" + "-- DROP TABLE",
|
|
331
|
+
),
|
|
332
|
+
Mutation(
|
|
333
|
+
id="rollback_002",
|
|
334
|
+
name="skip_data_restore",
|
|
335
|
+
description="Skip restoring backup data",
|
|
336
|
+
category=MutationCategory.ROLLBACK,
|
|
337
|
+
severity=MutationSeverity.CRITICAL,
|
|
338
|
+
apply_regex=r"INSERT\s+INTO.*backup" + "=>" + "-- RESTORE (skipped)",
|
|
339
|
+
),
|
|
340
|
+
Mutation(
|
|
341
|
+
id="rollback_003",
|
|
342
|
+
name="partial_rollback",
|
|
343
|
+
description="Rollback only partially",
|
|
344
|
+
category=MutationCategory.ROLLBACK,
|
|
345
|
+
severity=MutationSeverity.CRITICAL,
|
|
346
|
+
apply_fn=lambda sql: sql.replace("DROP COLUMN", "-- DROP COLUMN"),
|
|
347
|
+
),
|
|
348
|
+
Mutation(
|
|
349
|
+
id="rollback_004",
|
|
350
|
+
name="wrong_constraint_restoration",
|
|
351
|
+
description="Restore wrong constraint definition",
|
|
352
|
+
category=MutationCategory.ROLLBACK,
|
|
353
|
+
severity=MutationSeverity.IMPORTANT,
|
|
354
|
+
apply_fn=lambda sql: sql, # Complex to implement
|
|
355
|
+
),
|
|
356
|
+
Mutation(
|
|
357
|
+
id="rollback_005",
|
|
358
|
+
name="skip_index_drop",
|
|
359
|
+
description="Don't drop indexes when rolling back",
|
|
360
|
+
category=MutationCategory.ROLLBACK,
|
|
361
|
+
severity=MutationSeverity.IMPORTANT,
|
|
362
|
+
apply_regex=r"DROP\s+INDEX" + "=>" + "-- DROP INDEX",
|
|
363
|
+
),
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
for mutation in rollback_mutations:
|
|
367
|
+
self.mutations[mutation.id] = mutation
|
|
368
|
+
|
|
369
|
+
def _add_performance_mutations(self):
|
|
370
|
+
"""Add performance-related mutations."""
|
|
371
|
+
performance_mutations = [
|
|
372
|
+
Mutation(
|
|
373
|
+
id="perf_001",
|
|
374
|
+
name="missing_index",
|
|
375
|
+
description="Skip index that should be created",
|
|
376
|
+
category=MutationCategory.PERFORMANCE,
|
|
377
|
+
severity=MutationSeverity.IMPORTANT,
|
|
378
|
+
apply_regex=r"CREATE\s+INDEX" + "=>" + "-- CREATE INDEX (skipped)",
|
|
379
|
+
),
|
|
380
|
+
Mutation(
|
|
381
|
+
id="perf_002",
|
|
382
|
+
name="inefficient_join",
|
|
383
|
+
description="Use inefficient JOIN instead of WHERE",
|
|
384
|
+
category=MutationCategory.PERFORMANCE,
|
|
385
|
+
severity=MutationSeverity.IMPORTANT,
|
|
386
|
+
apply_fn=lambda sql: sql, # Complex implementation
|
|
387
|
+
),
|
|
388
|
+
Mutation(
|
|
389
|
+
id="perf_003",
|
|
390
|
+
name="missing_bulk_operation",
|
|
391
|
+
description="Process rows one by one instead of bulk",
|
|
392
|
+
category=MutationCategory.PERFORMANCE,
|
|
393
|
+
severity=MutationSeverity.IMPORTANT,
|
|
394
|
+
apply_fn=lambda sql: sql, # Complex implementation
|
|
395
|
+
),
|
|
396
|
+
Mutation(
|
|
397
|
+
id="perf_004",
|
|
398
|
+
name="scan_full_table",
|
|
399
|
+
description="Scan entire table instead of using index",
|
|
400
|
+
category=MutationCategory.PERFORMANCE,
|
|
401
|
+
severity=MutationSeverity.IMPORTANT,
|
|
402
|
+
apply_fn=lambda sql: sql.replace("WHERE id =", "WHERE TRUE")
|
|
403
|
+
if "WHERE id =" in sql
|
|
404
|
+
else sql,
|
|
405
|
+
),
|
|
406
|
+
]
|
|
407
|
+
|
|
408
|
+
for mutation in performance_mutations:
|
|
409
|
+
self.mutations[mutation.id] = mutation
|
|
410
|
+
|
|
411
|
+
def get_mutation(self, mutation_id: str) -> Mutation | None:
|
|
412
|
+
"""Get a specific mutation by ID."""
|
|
413
|
+
return self.mutations.get(mutation_id)
|
|
414
|
+
|
|
415
|
+
def get_by_category(self, category: MutationCategory) -> list[Mutation]:
|
|
416
|
+
"""Get all mutations in a category."""
|
|
417
|
+
return [m for m in self.mutations.values() if m.category == category]
|
|
418
|
+
|
|
419
|
+
def get_by_severity(self, severity: MutationSeverity) -> list[Mutation]:
|
|
420
|
+
"""Get all mutations of a severity level."""
|
|
421
|
+
return [m for m in self.mutations.values() if m.severity == severity]
|
|
422
|
+
|
|
423
|
+
def list_all(self) -> list[Mutation]:
|
|
424
|
+
"""Get all mutations."""
|
|
425
|
+
return list(self.mutations.values())
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
class MutationRunner:
|
|
429
|
+
"""Execute migrations with mutations applied."""
|
|
430
|
+
|
|
431
|
+
def __init__(self, db_connection: psycopg.Connection, migrations_dir: Path):
|
|
432
|
+
self.connection = db_connection
|
|
433
|
+
self.migrations_dir = migrations_dir
|
|
434
|
+
self.registry = MutationRegistry()
|
|
435
|
+
self.test_results: list[MutationTestResult] = []
|
|
436
|
+
|
|
437
|
+
def run_migration_with_mutation(
|
|
438
|
+
self,
|
|
439
|
+
migration_name: str,
|
|
440
|
+
mutation: Mutation,
|
|
441
|
+
) -> MutationResult:
|
|
442
|
+
"""Execute migration with a mutation applied."""
|
|
443
|
+
try:
|
|
444
|
+
# Load migration SQL
|
|
445
|
+
migration_file = self.migrations_dir / f"{migration_name}.sql"
|
|
446
|
+
if not migration_file.exists():
|
|
447
|
+
raise FileNotFoundError(f"Migration not found: {migration_file}")
|
|
448
|
+
|
|
449
|
+
with open(migration_file) as f:
|
|
450
|
+
original_sql = f.read()
|
|
451
|
+
|
|
452
|
+
# Apply mutation
|
|
453
|
+
mutated_sql = mutation.apply(original_sql)
|
|
454
|
+
mutation_applied = mutated_sql != original_sql
|
|
455
|
+
|
|
456
|
+
# Execute mutated migration
|
|
457
|
+
if not mutation_applied:
|
|
458
|
+
# Mutation couldn't be applied
|
|
459
|
+
return MutationResult(
|
|
460
|
+
mutation_id=mutation.id,
|
|
461
|
+
success=False,
|
|
462
|
+
mutation_applied=False,
|
|
463
|
+
duration_seconds=0.0,
|
|
464
|
+
stdout="",
|
|
465
|
+
stderr="Mutation could not be applied to SQL",
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
# Execute in isolated transaction
|
|
469
|
+
import time
|
|
470
|
+
|
|
471
|
+
start_time = time.time()
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
with self.connection.cursor() as cur:
|
|
475
|
+
cur.execute(mutated_sql)
|
|
476
|
+
self.connection.commit()
|
|
477
|
+
|
|
478
|
+
duration = time.time() - start_time
|
|
479
|
+
|
|
480
|
+
return MutationResult(
|
|
481
|
+
mutation_id=mutation.id,
|
|
482
|
+
success=True,
|
|
483
|
+
mutation_applied=True,
|
|
484
|
+
duration_seconds=duration,
|
|
485
|
+
stdout=f"Mutation {mutation.id} executed successfully",
|
|
486
|
+
stderr="",
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
except Exception as e:
|
|
490
|
+
self.connection.rollback()
|
|
491
|
+
duration = time.time() - start_time
|
|
492
|
+
|
|
493
|
+
return MutationResult(
|
|
494
|
+
mutation_id=mutation.id,
|
|
495
|
+
success=False,
|
|
496
|
+
mutation_applied=True,
|
|
497
|
+
duration_seconds=duration,
|
|
498
|
+
stdout="",
|
|
499
|
+
stderr=str(e),
|
|
500
|
+
error=e,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
except Exception as e:
|
|
504
|
+
return MutationResult(
|
|
505
|
+
mutation_id=mutation.id,
|
|
506
|
+
success=False,
|
|
507
|
+
mutation_applied=False,
|
|
508
|
+
duration_seconds=0.0,
|
|
509
|
+
stdout="",
|
|
510
|
+
stderr=str(e),
|
|
511
|
+
error=e,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
def record_test_result(
|
|
515
|
+
self,
|
|
516
|
+
mutation_id: str,
|
|
517
|
+
mutation_name: str,
|
|
518
|
+
test_name: str,
|
|
519
|
+
caught: bool,
|
|
520
|
+
duration: float,
|
|
521
|
+
):
|
|
522
|
+
"""Record test result for a mutation."""
|
|
523
|
+
result = MutationTestResult(
|
|
524
|
+
mutation_id=mutation_id,
|
|
525
|
+
mutation_name=mutation_name,
|
|
526
|
+
test_name=test_name,
|
|
527
|
+
caught=caught,
|
|
528
|
+
duration_seconds=duration,
|
|
529
|
+
)
|
|
530
|
+
self.test_results.append(result)
|
|
531
|
+
|
|
532
|
+
def generate_report(self) -> MutationReport:
|
|
533
|
+
"""Generate comprehensive mutation report."""
|
|
534
|
+
from datetime import datetime
|
|
535
|
+
|
|
536
|
+
# Calculate metrics
|
|
537
|
+
total = len(self.registry.list_all())
|
|
538
|
+
killed = sum(1 for r in self.test_results if r.caught)
|
|
539
|
+
survived = total - killed
|
|
540
|
+
|
|
541
|
+
metrics = MutationMetrics(
|
|
542
|
+
total_mutations=total,
|
|
543
|
+
killed_mutations=killed,
|
|
544
|
+
survived_mutations=survived,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
# Generate recommendations
|
|
548
|
+
recommendations = self._generate_recommendations()
|
|
549
|
+
|
|
550
|
+
report = MutationReport(
|
|
551
|
+
timestamp=datetime.now().isoformat(),
|
|
552
|
+
total_mutations=total,
|
|
553
|
+
metrics=metrics,
|
|
554
|
+
recommendations=recommendations,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
return report
|
|
558
|
+
|
|
559
|
+
def _generate_recommendations(self) -> list[str]:
|
|
560
|
+
"""Generate recommendations based on test results."""
|
|
561
|
+
recommendations = []
|
|
562
|
+
|
|
563
|
+
if self.test_results:
|
|
564
|
+
# Find weak tests
|
|
565
|
+
test_catches = {}
|
|
566
|
+
for result in self.test_results:
|
|
567
|
+
if result.test_name not in test_catches:
|
|
568
|
+
test_catches[result.test_name] = {"caught": 0, "total": 0}
|
|
569
|
+
test_catches[result.test_name]["total"] += 1
|
|
570
|
+
if result.caught:
|
|
571
|
+
test_catches[result.test_name]["caught"] += 1
|
|
572
|
+
|
|
573
|
+
# Identify tests that catch < 50% of mutations
|
|
574
|
+
for test_name, stats in test_catches.items():
|
|
575
|
+
catch_rate = stats["caught"] / stats["total"]
|
|
576
|
+
if catch_rate < 0.5:
|
|
577
|
+
recommendations.append(
|
|
578
|
+
f"Test '{test_name}' has low mutation kill rate ({catch_rate * 100:.0f}%). "
|
|
579
|
+
f"Consider adding more assertions or validations."
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
return recommendations
|
|
583
|
+
|
|
584
|
+
def export_report(self, report: MutationReport, path: Path):
|
|
585
|
+
"""Export report to file."""
|
|
586
|
+
with open(path, "w") as f:
|
|
587
|
+
json.dump(report.to_dict(), f, indent=2)
|