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,248 @@
|
|
|
1
|
+
"""Transparent risk scoring formula with explicit weights."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import math
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RiskLevel(Enum):
|
|
14
|
+
"""Risk classification."""
|
|
15
|
+
|
|
16
|
+
LOW = 1 # <100ms estimated downtime
|
|
17
|
+
MEDIUM = 2 # 100ms - 1s estimated downtime
|
|
18
|
+
HIGH = 3 # 1s - 10s estimated downtime
|
|
19
|
+
CRITICAL = 4 # >10s estimated downtime
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Severity(Enum):
|
|
23
|
+
"""Severity levels for anomalies."""
|
|
24
|
+
|
|
25
|
+
LOW = "low"
|
|
26
|
+
MEDIUM = "medium"
|
|
27
|
+
HIGH = "high"
|
|
28
|
+
CRITICAL = "critical"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class DataAnomaly:
|
|
33
|
+
"""Represents a data anomaly."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
severity: Severity
|
|
37
|
+
description: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class RiskFactor:
|
|
42
|
+
"""Individual risk factor with scoring."""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
value: float # 0.0-1.0
|
|
46
|
+
unit: str # "percent", "seconds", "bytes", etc.
|
|
47
|
+
weight: float # Contribution to overall score
|
|
48
|
+
description: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RiskScoringFormula:
|
|
52
|
+
"""
|
|
53
|
+
EXPLICIT RISK SCORING FORMULA
|
|
54
|
+
|
|
55
|
+
This class documents the exact algorithm used to calculate risk scores.
|
|
56
|
+
All weights and thresholds are configurable.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
# Weighting factors (must sum to 1.0)
|
|
60
|
+
WEIGHT_DATA_VOLUME = 0.25
|
|
61
|
+
WEIGHT_LOCK_TIME = 0.35
|
|
62
|
+
WEIGHT_DEPENDENCIES = 0.15
|
|
63
|
+
WEIGHT_ANOMALIES = 0.15
|
|
64
|
+
WEIGHT_CONCURRENT_LOAD = 0.10
|
|
65
|
+
|
|
66
|
+
# Thresholds for scoring (in consistent units)
|
|
67
|
+
DATA_VOLUME_CRITICAL_GB = 1024 # >1TB = 1.0
|
|
68
|
+
DATA_VOLUME_LOW_MB = 1 # <1MB = 0.0
|
|
69
|
+
LOCK_TIME_CRITICAL_MS = 10000 # >10 seconds
|
|
70
|
+
LOCK_TIME_HIGH_MS = 1000
|
|
71
|
+
LOCK_TIME_MEDIUM_MS = 100
|
|
72
|
+
DEPENDENCY_COUNT_CRITICAL = 10
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def calculate_data_volume_score(table_size_mb: int) -> RiskFactor:
|
|
76
|
+
"""
|
|
77
|
+
Calculate risk from data volume.
|
|
78
|
+
|
|
79
|
+
Formula: linear interpolation
|
|
80
|
+
- <1MB = 0.0 (low risk)
|
|
81
|
+
- 1GB = 0.5 (medium risk)
|
|
82
|
+
- >1TB = 1.0 (critical risk)
|
|
83
|
+
"""
|
|
84
|
+
if table_size_mb > 1024 * 1024: # >1TB
|
|
85
|
+
score = 1.0
|
|
86
|
+
elif table_size_mb < 1:
|
|
87
|
+
score = 0.0
|
|
88
|
+
else:
|
|
89
|
+
# Linear interpolation
|
|
90
|
+
score = min(table_size_mb / (1024 * 1024), 1.0)
|
|
91
|
+
|
|
92
|
+
return RiskFactor(
|
|
93
|
+
name="data_volume",
|
|
94
|
+
value=score,
|
|
95
|
+
unit="bytes",
|
|
96
|
+
weight=RiskScoringFormula.WEIGHT_DATA_VOLUME,
|
|
97
|
+
description=f"Table size: {table_size_mb}MB",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def calculate_lock_time_score(estimated_lock_ms: int) -> RiskFactor:
|
|
102
|
+
"""
|
|
103
|
+
Calculate risk from lock time using piecewise smooth function.
|
|
104
|
+
|
|
105
|
+
Formula: smooth exponential scaling without discontinuities
|
|
106
|
+
- 0ms = 0.0 (no risk)
|
|
107
|
+
- 100ms = 0.1 (low risk)
|
|
108
|
+
- 1s = 0.5 (medium risk)
|
|
109
|
+
- 10s = 0.95 (high risk)
|
|
110
|
+
- 10s+ = 1.0 (critical risk)
|
|
111
|
+
"""
|
|
112
|
+
if estimated_lock_ms >= 10000:
|
|
113
|
+
score = 1.0
|
|
114
|
+
elif estimated_lock_ms <= 0:
|
|
115
|
+
score = 0.0
|
|
116
|
+
else:
|
|
117
|
+
# Use logarithmic scaling for all positive values
|
|
118
|
+
# log(estimated_lock_ms) ranges from 0 (at 1ms) to ~4 (at 10s)
|
|
119
|
+
# This creates a smooth curve without discontinuities
|
|
120
|
+
log_value = math.log10(estimated_lock_ms) # -3 to 4 for 1ms to 10s
|
|
121
|
+
# Normalize to 0-1 range: map -3 (1ms) to 0, map 4 (10s) to 1
|
|
122
|
+
score = min((log_value + 3) / 7 * 0.95, 1.0)
|
|
123
|
+
|
|
124
|
+
return RiskFactor(
|
|
125
|
+
name="lock_time",
|
|
126
|
+
value=score,
|
|
127
|
+
unit="milliseconds",
|
|
128
|
+
weight=RiskScoringFormula.WEIGHT_LOCK_TIME,
|
|
129
|
+
description=f"Estimated lock time: {estimated_lock_ms}ms",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def calculate_dependency_score(
|
|
134
|
+
foreign_keys: int,
|
|
135
|
+
triggers: int,
|
|
136
|
+
views: int,
|
|
137
|
+
) -> RiskFactor:
|
|
138
|
+
"""
|
|
139
|
+
Calculate risk from dependencies.
|
|
140
|
+
|
|
141
|
+
Formula: linear in dependency count
|
|
142
|
+
- 0 dependencies = 0.0
|
|
143
|
+
- 10+ dependencies = 1.0
|
|
144
|
+
"""
|
|
145
|
+
dependency_count = foreign_keys + triggers + views
|
|
146
|
+
score = min(dependency_count / 10, 1.0)
|
|
147
|
+
|
|
148
|
+
return RiskFactor(
|
|
149
|
+
name="dependencies",
|
|
150
|
+
value=score,
|
|
151
|
+
unit="count",
|
|
152
|
+
weight=RiskScoringFormula.WEIGHT_DEPENDENCIES,
|
|
153
|
+
description=f"FKs: {foreign_keys}, Triggers: {triggers}, Views: {views}",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def calculate_anomaly_score(anomalies: list[DataAnomaly]) -> RiskFactor:
|
|
158
|
+
"""
|
|
159
|
+
Calculate risk from detected anomalies.
|
|
160
|
+
|
|
161
|
+
Formula: average severity if anomalies exist
|
|
162
|
+
- CRITICAL anomaly = 1.0
|
|
163
|
+
- HIGH = 0.7
|
|
164
|
+
- MEDIUM = 0.3
|
|
165
|
+
- LOW = 0.1
|
|
166
|
+
"""
|
|
167
|
+
if not anomalies:
|
|
168
|
+
score = 0.0
|
|
169
|
+
else:
|
|
170
|
+
severity_scores = [
|
|
171
|
+
1.0
|
|
172
|
+
if a.severity == Severity.CRITICAL
|
|
173
|
+
else 0.7
|
|
174
|
+
if a.severity == Severity.HIGH
|
|
175
|
+
else 0.3
|
|
176
|
+
if a.severity == Severity.MEDIUM
|
|
177
|
+
else 0.1
|
|
178
|
+
for a in anomalies
|
|
179
|
+
]
|
|
180
|
+
score = sum(severity_scores) / len(severity_scores)
|
|
181
|
+
|
|
182
|
+
return RiskFactor(
|
|
183
|
+
name="anomalies",
|
|
184
|
+
value=min(score, 1.0),
|
|
185
|
+
unit="count",
|
|
186
|
+
weight=RiskScoringFormula.WEIGHT_ANOMALIES,
|
|
187
|
+
description=f"Anomalies detected: {len(anomalies)}",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def calculate_concurrent_load_score(
|
|
192
|
+
active_connections: int,
|
|
193
|
+
max_connections: int = 100,
|
|
194
|
+
) -> RiskFactor:
|
|
195
|
+
"""
|
|
196
|
+
Calculate risk from concurrent load.
|
|
197
|
+
|
|
198
|
+
Formula: linear in connection utilization
|
|
199
|
+
- <10% = 0.0
|
|
200
|
+
- 50% = 0.5
|
|
201
|
+
- >90% = 1.0
|
|
202
|
+
"""
|
|
203
|
+
utilization = active_connections / max_connections if max_connections > 0 else 0
|
|
204
|
+
score = min(max(utilization - 0.1, 0) / 0.9, 1.0)
|
|
205
|
+
|
|
206
|
+
return RiskFactor(
|
|
207
|
+
name="concurrent_load",
|
|
208
|
+
value=score,
|
|
209
|
+
unit="percent",
|
|
210
|
+
weight=RiskScoringFormula.WEIGHT_CONCURRENT_LOAD,
|
|
211
|
+
description=f"Connection utilization: {utilization * 100:.1f}%",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def calculate_overall_risk(
|
|
216
|
+
factors: dict[str, RiskFactor],
|
|
217
|
+
) -> tuple[RiskLevel, float]:
|
|
218
|
+
"""
|
|
219
|
+
Calculate overall risk score from factors.
|
|
220
|
+
|
|
221
|
+
Formula: weighted sum with automatic weight normalization
|
|
222
|
+
overall_score = Σ(factor.value * (factor.weight / sum(weights)))
|
|
223
|
+
|
|
224
|
+
If not all factors are provided, weights are automatically renormalized
|
|
225
|
+
to sum to 1.0 for the provided factors.
|
|
226
|
+
"""
|
|
227
|
+
if not factors:
|
|
228
|
+
return RiskLevel.LOW, 0.0
|
|
229
|
+
|
|
230
|
+
# Calculate total weight from provided factors
|
|
231
|
+
total_weight = sum(factor.weight for factor in factors.values())
|
|
232
|
+
|
|
233
|
+
# Calculate overall score with renormalized weights
|
|
234
|
+
overall_score = sum(
|
|
235
|
+
factor.value * (factor.weight / total_weight) for factor in factors.values()
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Map score to risk level
|
|
239
|
+
if overall_score >= 0.75:
|
|
240
|
+
level = RiskLevel.CRITICAL
|
|
241
|
+
elif overall_score >= 0.50:
|
|
242
|
+
level = RiskLevel.HIGH
|
|
243
|
+
elif overall_score >= 0.25:
|
|
244
|
+
level = RiskLevel.MEDIUM
|
|
245
|
+
else:
|
|
246
|
+
level = RiskLevel.LOW
|
|
247
|
+
|
|
248
|
+
return level, overall_score
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Auto-generate rollback SQL for simple operations.
|
|
2
|
+
|
|
3
|
+
Provides utilities to generate rollback SQL for common DDL operations
|
|
4
|
+
and test rollback safety.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class RollbackSuggestion:
|
|
18
|
+
"""Suggested rollback SQL for a statement."""
|
|
19
|
+
|
|
20
|
+
original_sql: str
|
|
21
|
+
rollback_sql: str
|
|
22
|
+
confidence: str # "high", "medium", "low"
|
|
23
|
+
notes: str | None = None
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, Any]:
|
|
26
|
+
"""Convert to dictionary."""
|
|
27
|
+
return {
|
|
28
|
+
"original_sql": self.original_sql[:100],
|
|
29
|
+
"rollback_sql": self.rollback_sql,
|
|
30
|
+
"confidence": self.confidence,
|
|
31
|
+
"notes": self.notes,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class RollbackTestResult:
|
|
37
|
+
"""Result of rollback testing."""
|
|
38
|
+
|
|
39
|
+
migration_version: str
|
|
40
|
+
migration_name: str
|
|
41
|
+
clean_state: bool
|
|
42
|
+
tables_before: set[str] = field(default_factory=set)
|
|
43
|
+
tables_after: set[str] = field(default_factory=set)
|
|
44
|
+
indexes_before: set[str] = field(default_factory=set)
|
|
45
|
+
indexes_after: set[str] = field(default_factory=set)
|
|
46
|
+
duration_ms: int = 0
|
|
47
|
+
error: str | None = None
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_successful(self) -> bool:
|
|
51
|
+
"""Check if rollback test was successful."""
|
|
52
|
+
return self.clean_state and self.error is None
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
"""Convert to dictionary."""
|
|
56
|
+
return {
|
|
57
|
+
"migration_version": self.migration_version,
|
|
58
|
+
"migration_name": self.migration_name,
|
|
59
|
+
"clean_state": self.clean_state,
|
|
60
|
+
"is_successful": self.is_successful,
|
|
61
|
+
"tables_before": list(self.tables_before),
|
|
62
|
+
"tables_after": list(self.tables_after),
|
|
63
|
+
"indexes_before": list(self.indexes_before),
|
|
64
|
+
"indexes_after": list(self.indexes_after),
|
|
65
|
+
"duration_ms": self.duration_ms,
|
|
66
|
+
"error": self.error,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def generate_rollback(sql: str) -> RollbackSuggestion | None:
|
|
71
|
+
"""Generate rollback SQL for a statement.
|
|
72
|
+
|
|
73
|
+
Supports automatic rollback generation for:
|
|
74
|
+
- CREATE TABLE -> DROP TABLE
|
|
75
|
+
- CREATE INDEX -> DROP INDEX
|
|
76
|
+
- ADD COLUMN -> DROP COLUMN
|
|
77
|
+
- ALTER TABLE ADD CONSTRAINT -> DROP CONSTRAINT
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
sql: SQL statement to generate rollback for
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
RollbackSuggestion if generation is possible, None otherwise
|
|
84
|
+
|
|
85
|
+
Example:
|
|
86
|
+
>>> result = generate_rollback("CREATE TABLE users (id INT)")
|
|
87
|
+
>>> print(result.rollback_sql)
|
|
88
|
+
DROP TABLE IF EXISTS users
|
|
89
|
+
"""
|
|
90
|
+
sql_upper = sql.upper().strip()
|
|
91
|
+
sql_stripped = sql.strip()
|
|
92
|
+
|
|
93
|
+
# CREATE TABLE -> DROP TABLE
|
|
94
|
+
match = re.search(
|
|
95
|
+
r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:\")?(\w+)(?:\")?",
|
|
96
|
+
sql_upper,
|
|
97
|
+
)
|
|
98
|
+
if match:
|
|
99
|
+
table_name = match.group(1).lower()
|
|
100
|
+
return RollbackSuggestion(
|
|
101
|
+
original_sql=sql_stripped,
|
|
102
|
+
rollback_sql=f"DROP TABLE IF EXISTS {table_name}",
|
|
103
|
+
confidence="high",
|
|
104
|
+
notes="Table will be dropped with all data",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# CREATE INDEX -> DROP INDEX
|
|
108
|
+
match = re.search(
|
|
109
|
+
r"CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(?:\")?(\w+)(?:\")?",
|
|
110
|
+
sql_upper,
|
|
111
|
+
)
|
|
112
|
+
if match:
|
|
113
|
+
index_name = match.group(1).lower()
|
|
114
|
+
# Check if CONCURRENTLY was used
|
|
115
|
+
concurrent = "CONCURRENTLY" in sql_upper
|
|
116
|
+
drop_sql = f"DROP INDEX {'CONCURRENTLY ' if concurrent else ''}IF EXISTS {index_name}"
|
|
117
|
+
return RollbackSuggestion(
|
|
118
|
+
original_sql=sql_stripped,
|
|
119
|
+
rollback_sql=drop_sql,
|
|
120
|
+
confidence="high",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# ALTER TABLE ADD COLUMN -> DROP COLUMN
|
|
124
|
+
match = re.search(
|
|
125
|
+
r"ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(?:\")?(\w+)(?:\")?\s+"
|
|
126
|
+
r"ADD\s+(?:COLUMN\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(?:\")?(\w+)(?:\")?",
|
|
127
|
+
sql_upper,
|
|
128
|
+
)
|
|
129
|
+
if match and "ADD CONSTRAINT" not in sql_upper:
|
|
130
|
+
table_name = match.group(1).lower()
|
|
131
|
+
col_name = match.group(2).lower()
|
|
132
|
+
return RollbackSuggestion(
|
|
133
|
+
original_sql=sql_stripped,
|
|
134
|
+
rollback_sql=f"ALTER TABLE {table_name} DROP COLUMN IF EXISTS {col_name}",
|
|
135
|
+
confidence="high",
|
|
136
|
+
notes="Column data will be lost",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# ALTER TABLE ADD CONSTRAINT -> DROP CONSTRAINT
|
|
140
|
+
match = re.search(
|
|
141
|
+
r"ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(?:\")?(\w+)(?:\")?\s+"
|
|
142
|
+
r"ADD\s+CONSTRAINT\s+(?:\")?(\w+)(?:\")?",
|
|
143
|
+
sql_upper,
|
|
144
|
+
)
|
|
145
|
+
if match:
|
|
146
|
+
table_name = match.group(1).lower()
|
|
147
|
+
constraint_name = match.group(2).lower()
|
|
148
|
+
return RollbackSuggestion(
|
|
149
|
+
original_sql=sql_stripped,
|
|
150
|
+
rollback_sql=f"ALTER TABLE {table_name} DROP CONSTRAINT IF EXISTS {constraint_name}",
|
|
151
|
+
confidence="high",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# CREATE SEQUENCE -> DROP SEQUENCE
|
|
155
|
+
match = re.search(
|
|
156
|
+
r"CREATE\s+SEQUENCE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:\")?(\w+)(?:\")?",
|
|
157
|
+
sql_upper,
|
|
158
|
+
)
|
|
159
|
+
if match:
|
|
160
|
+
seq_name = match.group(1).lower()
|
|
161
|
+
return RollbackSuggestion(
|
|
162
|
+
original_sql=sql_stripped,
|
|
163
|
+
rollback_sql=f"DROP SEQUENCE IF EXISTS {seq_name}",
|
|
164
|
+
confidence="high",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# CREATE TYPE -> DROP TYPE
|
|
168
|
+
match = re.search(
|
|
169
|
+
r"CREATE\s+TYPE\s+(?:\")?(\w+)(?:\")?",
|
|
170
|
+
sql_upper,
|
|
171
|
+
)
|
|
172
|
+
if match:
|
|
173
|
+
type_name = match.group(1).lower()
|
|
174
|
+
return RollbackSuggestion(
|
|
175
|
+
original_sql=sql_stripped,
|
|
176
|
+
rollback_sql=f"DROP TYPE IF EXISTS {type_name}",
|
|
177
|
+
confidence="high",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# CREATE EXTENSION -> DROP EXTENSION
|
|
181
|
+
match = re.search(
|
|
182
|
+
r"CREATE\s+EXTENSION\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:\")?(\w+)(?:\")?",
|
|
183
|
+
sql_upper,
|
|
184
|
+
)
|
|
185
|
+
if match:
|
|
186
|
+
ext_name = match.group(1).lower()
|
|
187
|
+
return RollbackSuggestion(
|
|
188
|
+
original_sql=sql_stripped,
|
|
189
|
+
rollback_sql=f"DROP EXTENSION IF EXISTS {ext_name}",
|
|
190
|
+
confidence="medium",
|
|
191
|
+
notes="Extension may be used by other objects",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def generate_rollback_script(sql: str) -> list[RollbackSuggestion]:
|
|
198
|
+
"""Generate rollback SQL for multiple statements.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
sql: SQL script with multiple statements
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
List of RollbackSuggestions in reverse order (for proper rollback)
|
|
205
|
+
"""
|
|
206
|
+
import sqlparse
|
|
207
|
+
|
|
208
|
+
suggestions: list[RollbackSuggestion] = []
|
|
209
|
+
statements = sqlparse.parse(sql)
|
|
210
|
+
|
|
211
|
+
for stmt in statements:
|
|
212
|
+
stmt_str = str(stmt).strip()
|
|
213
|
+
if not stmt_str or stmt_str == ";":
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
suggestion = generate_rollback(stmt_str)
|
|
217
|
+
if suggestion:
|
|
218
|
+
suggestions.append(suggestion)
|
|
219
|
+
|
|
220
|
+
# Reverse for proper rollback order
|
|
221
|
+
return list(reversed(suggestions))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class RollbackTester:
|
|
225
|
+
"""Helper for testing rollback safety.
|
|
226
|
+
|
|
227
|
+
Tests the apply -> rollback -> verify cycle to ensure
|
|
228
|
+
migrations can be safely rolled back.
|
|
229
|
+
|
|
230
|
+
Example:
|
|
231
|
+
>>> tester = RollbackTester(conn)
|
|
232
|
+
>>> result = tester.test_migration(migration)
|
|
233
|
+
>>> if not result.is_successful:
|
|
234
|
+
... print(f"Rollback test failed: {result.error}")
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
def __init__(self, connection: Any):
|
|
238
|
+
"""Initialize rollback tester.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
connection: Database connection
|
|
242
|
+
"""
|
|
243
|
+
self.connection = connection
|
|
244
|
+
|
|
245
|
+
def test_migration(self, migration: Any) -> RollbackTestResult:
|
|
246
|
+
"""Test apply -> rollback -> verify cycle.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
migration: Migration instance with up() and down() methods
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
RollbackTestResult with test outcome
|
|
253
|
+
"""
|
|
254
|
+
import time
|
|
255
|
+
|
|
256
|
+
start_time = time.perf_counter()
|
|
257
|
+
|
|
258
|
+
result = RollbackTestResult(
|
|
259
|
+
migration_version=getattr(migration, "version", "unknown"),
|
|
260
|
+
migration_name=getattr(migration, "name", "unknown"),
|
|
261
|
+
clean_state=False,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
# Capture schema before
|
|
266
|
+
result.tables_before = self._get_tables()
|
|
267
|
+
result.indexes_before = self._get_indexes()
|
|
268
|
+
|
|
269
|
+
# Apply migration
|
|
270
|
+
if hasattr(migration, "up"):
|
|
271
|
+
migration.up()
|
|
272
|
+
self.connection.commit()
|
|
273
|
+
|
|
274
|
+
# Rollback migration
|
|
275
|
+
if hasattr(migration, "down"):
|
|
276
|
+
migration.down()
|
|
277
|
+
else:
|
|
278
|
+
result.error = "Migration has no down() method"
|
|
279
|
+
return result
|
|
280
|
+
self.connection.commit()
|
|
281
|
+
|
|
282
|
+
# Verify clean state
|
|
283
|
+
result.tables_after = self._get_tables()
|
|
284
|
+
result.indexes_after = self._get_indexes()
|
|
285
|
+
|
|
286
|
+
# Check if state matches
|
|
287
|
+
result.clean_state = (
|
|
288
|
+
result.tables_before == result.tables_after
|
|
289
|
+
and result.indexes_before == result.indexes_after
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
if not result.clean_state:
|
|
293
|
+
# Identify what changed
|
|
294
|
+
added_tables = result.tables_after - result.tables_before
|
|
295
|
+
removed_tables = result.tables_before - result.tables_after
|
|
296
|
+
added_indexes = result.indexes_after - result.indexes_before
|
|
297
|
+
removed_indexes = result.indexes_before - result.indexes_after
|
|
298
|
+
|
|
299
|
+
changes = []
|
|
300
|
+
if added_tables:
|
|
301
|
+
changes.append(f"Tables added: {added_tables}")
|
|
302
|
+
if removed_tables:
|
|
303
|
+
changes.append(f"Tables removed: {removed_tables}")
|
|
304
|
+
if added_indexes:
|
|
305
|
+
changes.append(f"Indexes added: {added_indexes}")
|
|
306
|
+
if removed_indexes:
|
|
307
|
+
changes.append(f"Indexes removed: {removed_indexes}")
|
|
308
|
+
|
|
309
|
+
result.error = "; ".join(changes)
|
|
310
|
+
|
|
311
|
+
except Exception as e:
|
|
312
|
+
result.error = str(e)
|
|
313
|
+
# Try to rollback the transaction
|
|
314
|
+
with contextlib.suppress(Exception):
|
|
315
|
+
self.connection.rollback()
|
|
316
|
+
|
|
317
|
+
result.duration_ms = int((time.perf_counter() - start_time) * 1000)
|
|
318
|
+
return result
|
|
319
|
+
|
|
320
|
+
def _get_tables(self) -> set[str]:
|
|
321
|
+
"""Get all tables in public schema."""
|
|
322
|
+
with self.connection.cursor() as cur:
|
|
323
|
+
cur.execute("""
|
|
324
|
+
SELECT table_name
|
|
325
|
+
FROM information_schema.tables
|
|
326
|
+
WHERE table_schema = 'public'
|
|
327
|
+
AND table_type = 'BASE TABLE'
|
|
328
|
+
""")
|
|
329
|
+
return {row[0] for row in cur.fetchall()}
|
|
330
|
+
|
|
331
|
+
def _get_indexes(self) -> set[str]:
|
|
332
|
+
"""Get all indexes in public schema."""
|
|
333
|
+
with self.connection.cursor() as cur:
|
|
334
|
+
cur.execute("""
|
|
335
|
+
SELECT indexname
|
|
336
|
+
FROM pg_indexes
|
|
337
|
+
WHERE schemaname = 'public'
|
|
338
|
+
""")
|
|
339
|
+
return {row[0] for row in cur.fetchall()}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def suggest_backup_for_destructive_operations(sql: str) -> list[str]:
|
|
343
|
+
"""Suggest backup commands for destructive operations.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
sql: SQL statement to analyze
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
List of backup suggestions
|
|
350
|
+
"""
|
|
351
|
+
suggestions: list[str] = []
|
|
352
|
+
sql_upper = sql.upper()
|
|
353
|
+
|
|
354
|
+
# DROP TABLE
|
|
355
|
+
match = re.search(r"DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:\")?(\w+)(?:\")?", sql_upper)
|
|
356
|
+
if match:
|
|
357
|
+
table_name = match.group(1).lower()
|
|
358
|
+
suggestions.append(
|
|
359
|
+
f"Consider backing up table '{table_name}' before dropping:\n"
|
|
360
|
+
f" CREATE TABLE {table_name}_backup AS SELECT * FROM {table_name};\n"
|
|
361
|
+
f" -- or use pg_dump: pg_dump -t {table_name} database_name > backup.sql"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# DROP COLUMN
|
|
365
|
+
match = re.search(
|
|
366
|
+
r"ALTER\s+TABLE\s+(?:\")?(\w+)(?:\")?\s+DROP\s+(?:COLUMN\s+)?(?:IF\s+EXISTS\s+)?(?:\")?(\w+)(?:\")?",
|
|
367
|
+
sql_upper,
|
|
368
|
+
)
|
|
369
|
+
if match:
|
|
370
|
+
table_name = match.group(1).lower()
|
|
371
|
+
col_name = match.group(2).lower()
|
|
372
|
+
suggestions.append(
|
|
373
|
+
f"Consider backing up column '{col_name}' from '{table_name}' before dropping:\n"
|
|
374
|
+
f" ALTER TABLE {table_name} ADD COLUMN {col_name}_backup <type>;\n"
|
|
375
|
+
f" UPDATE {table_name} SET {col_name}_backup = {col_name};"
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
# TRUNCATE
|
|
379
|
+
if "TRUNCATE" in sql_upper:
|
|
380
|
+
match = re.search(r"TRUNCATE\s+(?:TABLE\s+)?(?:\")?(\w+)(?:\")?", sql_upper)
|
|
381
|
+
if match:
|
|
382
|
+
table_name = match.group(1).lower()
|
|
383
|
+
suggestions.append(
|
|
384
|
+
f"Consider backing up table '{table_name}' before truncating:\n"
|
|
385
|
+
f" CREATE TABLE {table_name}_backup AS SELECT * FROM {table_name};"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
return suggestions
|