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.
Files changed (124) hide show
  1. confiture/__init__.py +48 -0
  2. confiture/_core.cpython-311-darwin.so +0 -0
  3. confiture/cli/__init__.py +0 -0
  4. confiture/cli/dry_run.py +116 -0
  5. confiture/cli/lint_formatter.py +193 -0
  6. confiture/cli/main.py +1893 -0
  7. confiture/config/__init__.py +0 -0
  8. confiture/config/environment.py +263 -0
  9. confiture/core/__init__.py +51 -0
  10. confiture/core/anonymization/__init__.py +0 -0
  11. confiture/core/anonymization/audit.py +485 -0
  12. confiture/core/anonymization/benchmarking.py +372 -0
  13. confiture/core/anonymization/breach_notification.py +652 -0
  14. confiture/core/anonymization/compliance.py +617 -0
  15. confiture/core/anonymization/composer.py +298 -0
  16. confiture/core/anonymization/data_subject_rights.py +669 -0
  17. confiture/core/anonymization/factory.py +319 -0
  18. confiture/core/anonymization/governance.py +737 -0
  19. confiture/core/anonymization/performance.py +1092 -0
  20. confiture/core/anonymization/profile.py +284 -0
  21. confiture/core/anonymization/registry.py +195 -0
  22. confiture/core/anonymization/security/kms_manager.py +547 -0
  23. confiture/core/anonymization/security/lineage.py +888 -0
  24. confiture/core/anonymization/security/token_store.py +686 -0
  25. confiture/core/anonymization/strategies/__init__.py +41 -0
  26. confiture/core/anonymization/strategies/address.py +359 -0
  27. confiture/core/anonymization/strategies/credit_card.py +374 -0
  28. confiture/core/anonymization/strategies/custom.py +161 -0
  29. confiture/core/anonymization/strategies/date.py +218 -0
  30. confiture/core/anonymization/strategies/differential_privacy.py +398 -0
  31. confiture/core/anonymization/strategies/email.py +141 -0
  32. confiture/core/anonymization/strategies/format_preserving_encryption.py +310 -0
  33. confiture/core/anonymization/strategies/hash.py +150 -0
  34. confiture/core/anonymization/strategies/ip_address.py +235 -0
  35. confiture/core/anonymization/strategies/masking_retention.py +252 -0
  36. confiture/core/anonymization/strategies/name.py +298 -0
  37. confiture/core/anonymization/strategies/phone.py +119 -0
  38. confiture/core/anonymization/strategies/preserve.py +85 -0
  39. confiture/core/anonymization/strategies/redact.py +101 -0
  40. confiture/core/anonymization/strategies/salted_hashing.py +322 -0
  41. confiture/core/anonymization/strategies/text_redaction.py +183 -0
  42. confiture/core/anonymization/strategies/tokenization.py +334 -0
  43. confiture/core/anonymization/strategy.py +241 -0
  44. confiture/core/anonymization/syncer_audit.py +357 -0
  45. confiture/core/blue_green.py +683 -0
  46. confiture/core/builder.py +500 -0
  47. confiture/core/checksum.py +358 -0
  48. confiture/core/connection.py +184 -0
  49. confiture/core/differ.py +522 -0
  50. confiture/core/drift.py +564 -0
  51. confiture/core/dry_run.py +182 -0
  52. confiture/core/health.py +313 -0
  53. confiture/core/hooks/__init__.py +87 -0
  54. confiture/core/hooks/base.py +232 -0
  55. confiture/core/hooks/context.py +146 -0
  56. confiture/core/hooks/execution_strategies.py +57 -0
  57. confiture/core/hooks/observability.py +220 -0
  58. confiture/core/hooks/phases.py +53 -0
  59. confiture/core/hooks/registry.py +295 -0
  60. confiture/core/large_tables.py +775 -0
  61. confiture/core/linting/__init__.py +70 -0
  62. confiture/core/linting/composer.py +192 -0
  63. confiture/core/linting/libraries/__init__.py +17 -0
  64. confiture/core/linting/libraries/gdpr.py +168 -0
  65. confiture/core/linting/libraries/general.py +184 -0
  66. confiture/core/linting/libraries/hipaa.py +144 -0
  67. confiture/core/linting/libraries/pci_dss.py +104 -0
  68. confiture/core/linting/libraries/sox.py +120 -0
  69. confiture/core/linting/schema_linter.py +491 -0
  70. confiture/core/linting/versioning.py +151 -0
  71. confiture/core/locking.py +389 -0
  72. confiture/core/migration_generator.py +298 -0
  73. confiture/core/migrator.py +882 -0
  74. confiture/core/observability/__init__.py +44 -0
  75. confiture/core/observability/audit.py +323 -0
  76. confiture/core/observability/logging.py +187 -0
  77. confiture/core/observability/metrics.py +174 -0
  78. confiture/core/observability/tracing.py +192 -0
  79. confiture/core/pg_version.py +418 -0
  80. confiture/core/pool.py +406 -0
  81. confiture/core/risk/__init__.py +39 -0
  82. confiture/core/risk/predictor.py +188 -0
  83. confiture/core/risk/scoring.py +248 -0
  84. confiture/core/rollback_generator.py +388 -0
  85. confiture/core/schema_analyzer.py +769 -0
  86. confiture/core/schema_to_schema.py +590 -0
  87. confiture/core/security/__init__.py +32 -0
  88. confiture/core/security/logging.py +201 -0
  89. confiture/core/security/validation.py +416 -0
  90. confiture/core/signals.py +371 -0
  91. confiture/core/syncer.py +540 -0
  92. confiture/exceptions.py +192 -0
  93. confiture/integrations/__init__.py +0 -0
  94. confiture/models/__init__.py +24 -0
  95. confiture/models/lint.py +193 -0
  96. confiture/models/migration.py +265 -0
  97. confiture/models/schema.py +203 -0
  98. confiture/models/sql_file_migration.py +225 -0
  99. confiture/scenarios/__init__.py +36 -0
  100. confiture/scenarios/compliance.py +586 -0
  101. confiture/scenarios/ecommerce.py +199 -0
  102. confiture/scenarios/financial.py +253 -0
  103. confiture/scenarios/healthcare.py +315 -0
  104. confiture/scenarios/multi_tenant.py +340 -0
  105. confiture/scenarios/saas.py +295 -0
  106. confiture/testing/FRAMEWORK_API.md +722 -0
  107. confiture/testing/__init__.py +100 -0
  108. confiture/testing/fixtures/__init__.py +11 -0
  109. confiture/testing/fixtures/data_validator.py +229 -0
  110. confiture/testing/fixtures/migration_runner.py +167 -0
  111. confiture/testing/fixtures/schema_snapshotter.py +352 -0
  112. confiture/testing/frameworks/__init__.py +10 -0
  113. confiture/testing/frameworks/mutation.py +587 -0
  114. confiture/testing/frameworks/performance.py +479 -0
  115. confiture/testing/loader.py +225 -0
  116. confiture/testing/pytest/__init__.py +38 -0
  117. confiture/testing/pytest_plugin.py +190 -0
  118. confiture/testing/sandbox.py +304 -0
  119. confiture/testing/utils/__init__.py +0 -0
  120. fraiseql_confiture-0.3.7.dist-info/METADATA +438 -0
  121. fraiseql_confiture-0.3.7.dist-info/RECORD +124 -0
  122. fraiseql_confiture-0.3.7.dist-info/WHEEL +4 -0
  123. fraiseql_confiture-0.3.7.dist-info/entry_points.txt +4 -0
  124. 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