fraiseql-confiture 0.3.4__cp311-cp311-win_amd64.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.cp311-win_amd64.pyd +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 +1656 -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 +132 -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 +793 -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 +0 -0
- confiture/models/lint.py +193 -0
- confiture/models/migration.py +180 -0
- confiture/models/schema.py +203 -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 +38 -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/utils/__init__.py +0 -0
- fraiseql_confiture-0.3.4.dist-info/METADATA +438 -0
- fraiseql_confiture-0.3.4.dist-info/RECORD +119 -0
- fraiseql_confiture-0.3.4.dist-info/WHEEL +4 -0
- fraiseql_confiture-0.3.4.dist-info/entry_points.txt +2 -0
- fraiseql_confiture-0.3.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
"""Schema analysis and validation for dry-run mode.
|
|
2
|
+
|
|
3
|
+
Analyzes SQL statements against current database schema to detect
|
|
4
|
+
issues before execution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import psycopg
|
|
14
|
+
import sqlparse
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ValidationSeverity(Enum):
|
|
20
|
+
"""Severity of validation issues."""
|
|
21
|
+
|
|
22
|
+
ERROR = "error" # Will definitely fail
|
|
23
|
+
WARNING = "warning" # Might fail or cause issues
|
|
24
|
+
INFO = "info" # Informational
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ValidationIssue:
|
|
29
|
+
"""A single validation issue."""
|
|
30
|
+
|
|
31
|
+
severity: ValidationSeverity
|
|
32
|
+
message: str
|
|
33
|
+
sql_fragment: str | None = None
|
|
34
|
+
line_number: int | None = None
|
|
35
|
+
suggestion: str | None = None
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, Any]:
|
|
38
|
+
"""Convert to dictionary for JSON serialization."""
|
|
39
|
+
return {
|
|
40
|
+
"severity": self.severity.value,
|
|
41
|
+
"message": self.message,
|
|
42
|
+
"sql_fragment": self.sql_fragment,
|
|
43
|
+
"line_number": self.line_number,
|
|
44
|
+
"suggestion": self.suggestion,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class SchemaInfo:
|
|
50
|
+
"""Current database schema information."""
|
|
51
|
+
|
|
52
|
+
tables: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
53
|
+
indexes: dict[str, list[str]] = field(default_factory=dict)
|
|
54
|
+
constraints: dict[str, list[str]] = field(default_factory=dict)
|
|
55
|
+
sequences: list[str] = field(default_factory=list)
|
|
56
|
+
extensions: list[str] = field(default_factory=list)
|
|
57
|
+
foreign_keys: dict[str, list[dict[str, str]]] = field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class ValidationResult:
|
|
62
|
+
"""Result of schema validation."""
|
|
63
|
+
|
|
64
|
+
migration_name: str
|
|
65
|
+
migration_version: str
|
|
66
|
+
issues: list[ValidationIssue] = field(default_factory=list)
|
|
67
|
+
statements_analyzed: int = 0
|
|
68
|
+
validation_time_ms: int = 0
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def has_errors(self) -> bool:
|
|
72
|
+
"""Check if any errors were found."""
|
|
73
|
+
return any(i.severity == ValidationSeverity.ERROR for i in self.issues)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def has_warnings(self) -> bool:
|
|
77
|
+
"""Check if any warnings were found."""
|
|
78
|
+
return any(i.severity == ValidationSeverity.WARNING for i in self.issues)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def is_valid(self) -> bool:
|
|
82
|
+
"""Check if validation passed (no errors)."""
|
|
83
|
+
return not self.has_errors
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def error_count(self) -> int:
|
|
87
|
+
"""Count of errors."""
|
|
88
|
+
return sum(1 for i in self.issues if i.severity == ValidationSeverity.ERROR)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def warning_count(self) -> int:
|
|
92
|
+
"""Count of warnings."""
|
|
93
|
+
return sum(1 for i in self.issues if i.severity == ValidationSeverity.WARNING)
|
|
94
|
+
|
|
95
|
+
def to_dict(self) -> dict[str, Any]:
|
|
96
|
+
"""Convert to dictionary for JSON serialization."""
|
|
97
|
+
return {
|
|
98
|
+
"migration_name": self.migration_name,
|
|
99
|
+
"migration_version": self.migration_version,
|
|
100
|
+
"is_valid": self.is_valid,
|
|
101
|
+
"error_count": self.error_count,
|
|
102
|
+
"warning_count": self.warning_count,
|
|
103
|
+
"statements_analyzed": self.statements_analyzed,
|
|
104
|
+
"validation_time_ms": self.validation_time_ms,
|
|
105
|
+
"issues": [issue.to_dict() for issue in self.issues],
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class SchemaAnalyzer:
|
|
110
|
+
"""Analyzes migrations against current database schema.
|
|
111
|
+
|
|
112
|
+
Validates SQL statements before execution to catch issues early:
|
|
113
|
+
- Table/column existence
|
|
114
|
+
- Foreign key references
|
|
115
|
+
- Type compatibility
|
|
116
|
+
- Index column existence
|
|
117
|
+
|
|
118
|
+
Example:
|
|
119
|
+
>>> analyzer = SchemaAnalyzer(conn)
|
|
120
|
+
>>> result = analyzer.validate_migration(migration)
|
|
121
|
+
>>> if not result.is_valid:
|
|
122
|
+
... for issue in result.issues:
|
|
123
|
+
... print(f"{issue.severity.value}: {issue.message}")
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, connection: psycopg.Connection):
|
|
127
|
+
"""Initialize schema analyzer.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
connection: Active database connection
|
|
131
|
+
"""
|
|
132
|
+
self.connection = connection
|
|
133
|
+
self._schema_info: SchemaInfo | None = None
|
|
134
|
+
|
|
135
|
+
def get_schema_info(self, refresh: bool = False) -> SchemaInfo:
|
|
136
|
+
"""Get current database schema information.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
refresh: Force refresh of cached schema info
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
SchemaInfo with current database state
|
|
143
|
+
"""
|
|
144
|
+
if self._schema_info is not None and not refresh:
|
|
145
|
+
return self._schema_info
|
|
146
|
+
|
|
147
|
+
info = SchemaInfo()
|
|
148
|
+
|
|
149
|
+
# Get tables and columns
|
|
150
|
+
with self.connection.cursor() as cur:
|
|
151
|
+
cur.execute("""
|
|
152
|
+
SELECT
|
|
153
|
+
t.table_name,
|
|
154
|
+
c.column_name,
|
|
155
|
+
c.data_type,
|
|
156
|
+
c.is_nullable,
|
|
157
|
+
c.column_default,
|
|
158
|
+
c.character_maximum_length,
|
|
159
|
+
c.numeric_precision,
|
|
160
|
+
c.numeric_scale
|
|
161
|
+
FROM information_schema.tables t
|
|
162
|
+
JOIN information_schema.columns c
|
|
163
|
+
ON t.table_name = c.table_name
|
|
164
|
+
AND t.table_schema = c.table_schema
|
|
165
|
+
WHERE t.table_schema = 'public'
|
|
166
|
+
AND t.table_type = 'BASE TABLE'
|
|
167
|
+
ORDER BY t.table_name, c.ordinal_position
|
|
168
|
+
""")
|
|
169
|
+
|
|
170
|
+
for row in cur.fetchall():
|
|
171
|
+
table_name = row[0]
|
|
172
|
+
if table_name not in info.tables:
|
|
173
|
+
info.tables[table_name] = {}
|
|
174
|
+
info.tables[table_name][row[1]] = {
|
|
175
|
+
"type": row[2],
|
|
176
|
+
"nullable": row[3] == "YES",
|
|
177
|
+
"default": row[4],
|
|
178
|
+
"max_length": row[5],
|
|
179
|
+
"precision": row[6],
|
|
180
|
+
"scale": row[7],
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# Get indexes
|
|
184
|
+
with self.connection.cursor() as cur:
|
|
185
|
+
cur.execute("""
|
|
186
|
+
SELECT
|
|
187
|
+
tablename,
|
|
188
|
+
indexname,
|
|
189
|
+
indexdef
|
|
190
|
+
FROM pg_indexes
|
|
191
|
+
WHERE schemaname = 'public'
|
|
192
|
+
""")
|
|
193
|
+
for row in cur.fetchall():
|
|
194
|
+
if row[0] not in info.indexes:
|
|
195
|
+
info.indexes[row[0]] = []
|
|
196
|
+
info.indexes[row[0]].append(row[1])
|
|
197
|
+
|
|
198
|
+
# Get constraints
|
|
199
|
+
with self.connection.cursor() as cur:
|
|
200
|
+
cur.execute("""
|
|
201
|
+
SELECT
|
|
202
|
+
tc.table_name,
|
|
203
|
+
tc.constraint_name,
|
|
204
|
+
tc.constraint_type
|
|
205
|
+
FROM information_schema.table_constraints tc
|
|
206
|
+
WHERE tc.table_schema = 'public'
|
|
207
|
+
""")
|
|
208
|
+
for row in cur.fetchall():
|
|
209
|
+
if row[0] not in info.constraints:
|
|
210
|
+
info.constraints[row[0]] = []
|
|
211
|
+
info.constraints[row[0]].append(row[1])
|
|
212
|
+
|
|
213
|
+
# Get foreign keys
|
|
214
|
+
with self.connection.cursor() as cur:
|
|
215
|
+
cur.execute("""
|
|
216
|
+
SELECT
|
|
217
|
+
tc.table_name,
|
|
218
|
+
kcu.column_name,
|
|
219
|
+
ccu.table_name AS foreign_table_name,
|
|
220
|
+
ccu.column_name AS foreign_column_name,
|
|
221
|
+
tc.constraint_name
|
|
222
|
+
FROM information_schema.table_constraints AS tc
|
|
223
|
+
JOIN information_schema.key_column_usage AS kcu
|
|
224
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
225
|
+
AND tc.table_schema = kcu.table_schema
|
|
226
|
+
JOIN information_schema.constraint_column_usage AS ccu
|
|
227
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
228
|
+
AND ccu.table_schema = tc.table_schema
|
|
229
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
230
|
+
AND tc.table_schema = 'public'
|
|
231
|
+
""")
|
|
232
|
+
for row in cur.fetchall():
|
|
233
|
+
table_name = row[0]
|
|
234
|
+
if table_name not in info.foreign_keys:
|
|
235
|
+
info.foreign_keys[table_name] = []
|
|
236
|
+
info.foreign_keys[table_name].append(
|
|
237
|
+
{
|
|
238
|
+
"column": row[1],
|
|
239
|
+
"foreign_table": row[2],
|
|
240
|
+
"foreign_column": row[3],
|
|
241
|
+
"constraint_name": row[4],
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Get extensions
|
|
246
|
+
with self.connection.cursor() as cur:
|
|
247
|
+
cur.execute("SELECT extname FROM pg_extension")
|
|
248
|
+
info.extensions = [row[0] for row in cur.fetchall()]
|
|
249
|
+
|
|
250
|
+
# Get sequences
|
|
251
|
+
with self.connection.cursor() as cur:
|
|
252
|
+
cur.execute("""
|
|
253
|
+
SELECT sequence_name
|
|
254
|
+
FROM information_schema.sequences
|
|
255
|
+
WHERE sequence_schema = 'public'
|
|
256
|
+
""")
|
|
257
|
+
info.sequences = [row[0] for row in cur.fetchall()]
|
|
258
|
+
|
|
259
|
+
self._schema_info = info
|
|
260
|
+
return info
|
|
261
|
+
|
|
262
|
+
def validate_sql(self, sql: str) -> list[ValidationIssue]:
|
|
263
|
+
"""Validate a SQL string against current schema.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
sql: SQL statement(s) to validate
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
List of validation issues found
|
|
270
|
+
"""
|
|
271
|
+
issues: list[ValidationIssue] = []
|
|
272
|
+
schema_info = self.get_schema_info()
|
|
273
|
+
|
|
274
|
+
# Parse SQL into statements
|
|
275
|
+
statements = sqlparse.parse(sql)
|
|
276
|
+
|
|
277
|
+
for i, stmt in enumerate(statements, 1):
|
|
278
|
+
stmt_str = str(stmt).strip()
|
|
279
|
+
if not stmt_str or stmt_str == ";":
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
stmt_issues = self._validate_statement(stmt_str, schema_info, i)
|
|
283
|
+
issues.extend(stmt_issues)
|
|
284
|
+
|
|
285
|
+
return issues
|
|
286
|
+
|
|
287
|
+
def validate_migration(self, migration: Any) -> ValidationResult:
|
|
288
|
+
"""Validate a migration against current schema.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
migration: Migration instance with version, name, and SQL
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
ValidationResult with any issues found
|
|
295
|
+
"""
|
|
296
|
+
import time
|
|
297
|
+
|
|
298
|
+
start_time = time.perf_counter()
|
|
299
|
+
|
|
300
|
+
result = ValidationResult(
|
|
301
|
+
migration_name=getattr(migration, "name", "unknown"),
|
|
302
|
+
migration_version=getattr(migration, "version", "unknown"),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Try to get SQL from migration
|
|
306
|
+
sql_statements = self._extract_sql_from_migration(migration)
|
|
307
|
+
|
|
308
|
+
if not sql_statements:
|
|
309
|
+
result.issues.append(
|
|
310
|
+
ValidationIssue(
|
|
311
|
+
severity=ValidationSeverity.WARNING,
|
|
312
|
+
message="Could not extract SQL from migration for validation",
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
return result
|
|
316
|
+
|
|
317
|
+
schema_info = self.get_schema_info()
|
|
318
|
+
|
|
319
|
+
for i, sql in enumerate(sql_statements, 1):
|
|
320
|
+
result.statements_analyzed += 1
|
|
321
|
+
issues = self._validate_statement(sql, schema_info, i)
|
|
322
|
+
result.issues.extend(issues)
|
|
323
|
+
|
|
324
|
+
result.validation_time_ms = int((time.perf_counter() - start_time) * 1000)
|
|
325
|
+
return result
|
|
326
|
+
|
|
327
|
+
def _extract_sql_from_migration(self, migration: Any) -> list[str]:
|
|
328
|
+
"""Extract SQL statements from a migration object.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
migration: Migration instance
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
List of SQL statements
|
|
335
|
+
"""
|
|
336
|
+
statements: list[str] = []
|
|
337
|
+
|
|
338
|
+
# Check for sql_statements attribute (if migration stores them)
|
|
339
|
+
if hasattr(migration, "sql_statements"):
|
|
340
|
+
return list(migration.sql_statements)
|
|
341
|
+
|
|
342
|
+
# Check for _sql_history attribute (captured SQL)
|
|
343
|
+
if hasattr(migration, "_sql_history"):
|
|
344
|
+
return list(migration._sql_history)
|
|
345
|
+
|
|
346
|
+
# Try to get from up() docstring or source
|
|
347
|
+
# This is a best-effort approach
|
|
348
|
+
return statements
|
|
349
|
+
|
|
350
|
+
def _validate_statement(
|
|
351
|
+
self,
|
|
352
|
+
sql: str,
|
|
353
|
+
schema: SchemaInfo,
|
|
354
|
+
line_num: int,
|
|
355
|
+
) -> list[ValidationIssue]:
|
|
356
|
+
"""Validate a single SQL statement.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
sql: SQL statement
|
|
360
|
+
schema: Current schema info
|
|
361
|
+
line_num: Statement number for error reporting
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
List of validation issues
|
|
365
|
+
"""
|
|
366
|
+
issues: list[ValidationIssue] = []
|
|
367
|
+
|
|
368
|
+
# Parse SQL
|
|
369
|
+
parsed = sqlparse.parse(sql)
|
|
370
|
+
if not parsed:
|
|
371
|
+
return issues
|
|
372
|
+
|
|
373
|
+
stmt = parsed[0]
|
|
374
|
+
stmt_type = stmt.get_type()
|
|
375
|
+
|
|
376
|
+
if stmt_type == "CREATE":
|
|
377
|
+
issues.extend(self._validate_create(sql, schema, line_num))
|
|
378
|
+
elif stmt_type == "ALTER":
|
|
379
|
+
issues.extend(self._validate_alter(sql, schema, line_num))
|
|
380
|
+
elif stmt_type == "DROP":
|
|
381
|
+
issues.extend(self._validate_drop(sql, schema, line_num))
|
|
382
|
+
elif stmt_type == "INSERT":
|
|
383
|
+
issues.extend(self._validate_insert(sql, schema, line_num))
|
|
384
|
+
elif stmt_type == "UPDATE":
|
|
385
|
+
issues.extend(self._validate_update(sql, schema, line_num))
|
|
386
|
+
elif stmt_type == "DELETE":
|
|
387
|
+
issues.extend(self._validate_delete(sql, schema, line_num))
|
|
388
|
+
|
|
389
|
+
return issues
|
|
390
|
+
|
|
391
|
+
def _validate_create(
|
|
392
|
+
self,
|
|
393
|
+
sql: str,
|
|
394
|
+
schema: SchemaInfo,
|
|
395
|
+
line_num: int,
|
|
396
|
+
) -> list[ValidationIssue]:
|
|
397
|
+
"""Validate CREATE statements."""
|
|
398
|
+
issues: list[ValidationIssue] = []
|
|
399
|
+
sql_upper = sql.upper()
|
|
400
|
+
|
|
401
|
+
# Check for CREATE TABLE that already exists
|
|
402
|
+
match = re.search(
|
|
403
|
+
r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:\")?(\w+)(?:\")?",
|
|
404
|
+
sql_upper,
|
|
405
|
+
)
|
|
406
|
+
if match:
|
|
407
|
+
table_name = match.group(1).lower()
|
|
408
|
+
if table_name in schema.tables and "IF NOT EXISTS" not in sql_upper:
|
|
409
|
+
issues.append(
|
|
410
|
+
ValidationIssue(
|
|
411
|
+
severity=ValidationSeverity.ERROR,
|
|
412
|
+
message=f"Table '{table_name}' already exists",
|
|
413
|
+
sql_fragment=sql[:100],
|
|
414
|
+
line_number=line_num,
|
|
415
|
+
suggestion="Add 'IF NOT EXISTS' or use ALTER TABLE",
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# Validate foreign key references in CREATE TABLE
|
|
420
|
+
fk_issues = self._validate_fk_references_in_create(sql, schema, line_num)
|
|
421
|
+
issues.extend(fk_issues)
|
|
422
|
+
|
|
423
|
+
# Check for CREATE INDEX on non-existent table
|
|
424
|
+
match = re.search(
|
|
425
|
+
r"CREATE\s+(?:UNIQUE\s+)?INDEX.*ON\s+(?:\")?(\w+)(?:\")?",
|
|
426
|
+
sql_upper,
|
|
427
|
+
)
|
|
428
|
+
if match:
|
|
429
|
+
table_name = match.group(1).lower()
|
|
430
|
+
if table_name not in schema.tables:
|
|
431
|
+
issues.append(
|
|
432
|
+
ValidationIssue(
|
|
433
|
+
severity=ValidationSeverity.ERROR,
|
|
434
|
+
message=f"Cannot create index: table '{table_name}' does not exist",
|
|
435
|
+
sql_fragment=sql[:100],
|
|
436
|
+
line_number=line_num,
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
else:
|
|
440
|
+
# Validate index columns exist
|
|
441
|
+
col_issues = self._validate_index_columns(sql, table_name, schema, line_num)
|
|
442
|
+
issues.extend(col_issues)
|
|
443
|
+
|
|
444
|
+
return issues
|
|
445
|
+
|
|
446
|
+
def _validate_fk_references_in_create(
|
|
447
|
+
self,
|
|
448
|
+
sql: str,
|
|
449
|
+
schema: SchemaInfo,
|
|
450
|
+
line_num: int,
|
|
451
|
+
) -> list[ValidationIssue]:
|
|
452
|
+
"""Validate foreign key references in CREATE TABLE."""
|
|
453
|
+
issues: list[ValidationIssue] = []
|
|
454
|
+
|
|
455
|
+
# Find REFERENCES clauses
|
|
456
|
+
references_pattern = r"REFERENCES\s+(?:\")?(\w+)(?:\")?\s*\((?:\")?(\w+)(?:\")?\)"
|
|
457
|
+
for match in re.finditer(references_pattern, sql, re.IGNORECASE):
|
|
458
|
+
target_table = match.group(1).lower()
|
|
459
|
+
target_column = match.group(2).lower()
|
|
460
|
+
|
|
461
|
+
if target_table not in schema.tables:
|
|
462
|
+
issues.append(
|
|
463
|
+
ValidationIssue(
|
|
464
|
+
severity=ValidationSeverity.ERROR,
|
|
465
|
+
message=f"FK target table '{target_table}' does not exist",
|
|
466
|
+
sql_fragment=sql[:100],
|
|
467
|
+
line_number=line_num,
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
elif target_column not in schema.tables[target_table]:
|
|
471
|
+
issues.append(
|
|
472
|
+
ValidationIssue(
|
|
473
|
+
severity=ValidationSeverity.ERROR,
|
|
474
|
+
message=f"FK target column '{target_table}.{target_column}' does not exist",
|
|
475
|
+
sql_fragment=sql[:100],
|
|
476
|
+
line_number=line_num,
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
return issues
|
|
481
|
+
|
|
482
|
+
def _validate_index_columns(
|
|
483
|
+
self,
|
|
484
|
+
sql: str,
|
|
485
|
+
table_name: str,
|
|
486
|
+
schema: SchemaInfo,
|
|
487
|
+
line_num: int,
|
|
488
|
+
) -> list[ValidationIssue]:
|
|
489
|
+
"""Validate that index columns exist."""
|
|
490
|
+
issues: list[ValidationIssue] = []
|
|
491
|
+
|
|
492
|
+
# Extract column names from index
|
|
493
|
+
match = re.search(r"ON\s+\w+\s*\(([^)]+)\)", sql, re.IGNORECASE)
|
|
494
|
+
if match:
|
|
495
|
+
columns_str = match.group(1)
|
|
496
|
+
# Parse column names (handle expressions, DESC, etc.)
|
|
497
|
+
for col_part in columns_str.split(","):
|
|
498
|
+
col_name = col_part.strip().split()[0].strip('"').lower()
|
|
499
|
+
# Skip expressions
|
|
500
|
+
if "(" in col_name or col_name.upper() in ("ASC", "DESC", "NULLS"):
|
|
501
|
+
continue
|
|
502
|
+
if table_name in schema.tables and col_name not in schema.tables[table_name]:
|
|
503
|
+
issues.append(
|
|
504
|
+
ValidationIssue(
|
|
505
|
+
severity=ValidationSeverity.ERROR,
|
|
506
|
+
message=f"Index column '{col_name}' does not exist in '{table_name}'",
|
|
507
|
+
sql_fragment=sql[:100],
|
|
508
|
+
line_number=line_num,
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
return issues
|
|
513
|
+
|
|
514
|
+
def _validate_alter(
|
|
515
|
+
self,
|
|
516
|
+
sql: str,
|
|
517
|
+
schema: SchemaInfo,
|
|
518
|
+
line_num: int,
|
|
519
|
+
) -> list[ValidationIssue]:
|
|
520
|
+
"""Validate ALTER statements."""
|
|
521
|
+
issues: list[ValidationIssue] = []
|
|
522
|
+
sql_upper = sql.upper()
|
|
523
|
+
|
|
524
|
+
# Check table exists
|
|
525
|
+
match = re.search(
|
|
526
|
+
r"ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(?:\")?(\w+)(?:\")?",
|
|
527
|
+
sql_upper,
|
|
528
|
+
)
|
|
529
|
+
if match:
|
|
530
|
+
table_name = match.group(1).lower()
|
|
531
|
+
if table_name not in schema.tables and "IF EXISTS" not in sql_upper:
|
|
532
|
+
issues.append(
|
|
533
|
+
ValidationIssue(
|
|
534
|
+
severity=ValidationSeverity.ERROR,
|
|
535
|
+
message=f"Cannot alter table '{table_name}': table does not exist",
|
|
536
|
+
sql_fragment=sql[:100],
|
|
537
|
+
line_number=line_num,
|
|
538
|
+
suggestion="Add 'IF EXISTS' or create the table first",
|
|
539
|
+
)
|
|
540
|
+
)
|
|
541
|
+
elif table_name in schema.tables:
|
|
542
|
+
# Check column operations
|
|
543
|
+
issues.extend(self._validate_column_operations(sql, schema, table_name, line_num))
|
|
544
|
+
|
|
545
|
+
return issues
|
|
546
|
+
|
|
547
|
+
def _validate_column_operations(
|
|
548
|
+
self,
|
|
549
|
+
sql: str,
|
|
550
|
+
schema: SchemaInfo,
|
|
551
|
+
table_name: str,
|
|
552
|
+
line_num: int,
|
|
553
|
+
) -> list[ValidationIssue]:
|
|
554
|
+
"""Validate column ADD/DROP/ALTER operations."""
|
|
555
|
+
issues: list[ValidationIssue] = []
|
|
556
|
+
sql_upper = sql.upper()
|
|
557
|
+
table_columns = schema.tables.get(table_name, {})
|
|
558
|
+
|
|
559
|
+
# ADD COLUMN that already exists
|
|
560
|
+
match = re.search(
|
|
561
|
+
r"ADD\s+(?:COLUMN\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(?:\")?(\w+)(?:\")?", sql_upper
|
|
562
|
+
)
|
|
563
|
+
if match and "ADD CONSTRAINT" not in sql_upper:
|
|
564
|
+
col_name = match.group(1).lower()
|
|
565
|
+
if col_name in table_columns and "IF NOT EXISTS" not in sql_upper:
|
|
566
|
+
issues.append(
|
|
567
|
+
ValidationIssue(
|
|
568
|
+
severity=ValidationSeverity.ERROR,
|
|
569
|
+
message=f"Column '{col_name}' already exists in '{table_name}'",
|
|
570
|
+
sql_fragment=sql[:100],
|
|
571
|
+
line_number=line_num,
|
|
572
|
+
suggestion="Add 'IF NOT EXISTS' to handle existing column",
|
|
573
|
+
)
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
# DROP COLUMN that doesn't exist
|
|
577
|
+
match = re.search(
|
|
578
|
+
r"DROP\s+(?:COLUMN\s+)?(?:IF\s+EXISTS\s+)?(?:\")?(\w+)(?:\")?",
|
|
579
|
+
sql_upper,
|
|
580
|
+
)
|
|
581
|
+
if match and "DROP CONSTRAINT" not in sql_upper:
|
|
582
|
+
col_name = match.group(1).lower()
|
|
583
|
+
if col_name not in table_columns and "IF EXISTS" not in sql_upper:
|
|
584
|
+
issues.append(
|
|
585
|
+
ValidationIssue(
|
|
586
|
+
severity=ValidationSeverity.ERROR,
|
|
587
|
+
message=f"Column '{col_name}' does not exist in '{table_name}'",
|
|
588
|
+
sql_fragment=sql[:100],
|
|
589
|
+
line_number=line_num,
|
|
590
|
+
suggestion="Add 'IF EXISTS' to handle missing column",
|
|
591
|
+
)
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
# Validate ADD CONSTRAINT with FK reference
|
|
595
|
+
if "ADD CONSTRAINT" in sql_upper and "FOREIGN KEY" in sql_upper:
|
|
596
|
+
fk_issues = self._validate_fk_references_in_create(sql, schema, line_num)
|
|
597
|
+
issues.extend(fk_issues)
|
|
598
|
+
|
|
599
|
+
return issues
|
|
600
|
+
|
|
601
|
+
def _validate_drop(
|
|
602
|
+
self,
|
|
603
|
+
sql: str,
|
|
604
|
+
schema: SchemaInfo,
|
|
605
|
+
line_num: int,
|
|
606
|
+
) -> list[ValidationIssue]:
|
|
607
|
+
"""Validate DROP statements."""
|
|
608
|
+
issues: list[ValidationIssue] = []
|
|
609
|
+
sql_upper = sql.upper()
|
|
610
|
+
|
|
611
|
+
# DROP TABLE that doesn't exist
|
|
612
|
+
match = re.search(
|
|
613
|
+
r"DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:\")?(\w+)(?:\")?",
|
|
614
|
+
sql_upper,
|
|
615
|
+
)
|
|
616
|
+
if match and "IF EXISTS" not in sql_upper:
|
|
617
|
+
table_name = match.group(1).lower()
|
|
618
|
+
if table_name not in schema.tables:
|
|
619
|
+
issues.append(
|
|
620
|
+
ValidationIssue(
|
|
621
|
+
severity=ValidationSeverity.ERROR,
|
|
622
|
+
message=f"Cannot drop table '{table_name}': does not exist",
|
|
623
|
+
sql_fragment=sql[:100],
|
|
624
|
+
line_number=line_num,
|
|
625
|
+
suggestion="Add 'IF EXISTS' to handle missing table",
|
|
626
|
+
)
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
# DROP INDEX that doesn't exist
|
|
630
|
+
match = re.search(
|
|
631
|
+
r"DROP\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+EXISTS\s+)?(?:\")?(\w+)(?:\")?",
|
|
632
|
+
sql_upper,
|
|
633
|
+
)
|
|
634
|
+
if match and "IF EXISTS" not in sql_upper:
|
|
635
|
+
index_name = match.group(1).lower()
|
|
636
|
+
# Check if index exists in any table
|
|
637
|
+
index_exists = any(index_name in indexes for indexes in schema.indexes.values())
|
|
638
|
+
if not index_exists:
|
|
639
|
+
issues.append(
|
|
640
|
+
ValidationIssue(
|
|
641
|
+
severity=ValidationSeverity.ERROR,
|
|
642
|
+
message=f"Cannot drop index '{index_name}': does not exist",
|
|
643
|
+
sql_fragment=sql[:100],
|
|
644
|
+
line_number=line_num,
|
|
645
|
+
suggestion="Add 'IF EXISTS' to handle missing index",
|
|
646
|
+
)
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
return issues
|
|
650
|
+
|
|
651
|
+
def _validate_insert(
|
|
652
|
+
self,
|
|
653
|
+
sql: str,
|
|
654
|
+
schema: SchemaInfo,
|
|
655
|
+
line_num: int,
|
|
656
|
+
) -> list[ValidationIssue]:
|
|
657
|
+
"""Validate INSERT statements."""
|
|
658
|
+
issues: list[ValidationIssue] = []
|
|
659
|
+
|
|
660
|
+
# Check target table exists
|
|
661
|
+
match = re.search(r"INSERT\s+INTO\s+(?:\")?(\w+)(?:\")?", sql, re.IGNORECASE)
|
|
662
|
+
if match:
|
|
663
|
+
table_name = match.group(1).lower()
|
|
664
|
+
if table_name not in schema.tables:
|
|
665
|
+
issues.append(
|
|
666
|
+
ValidationIssue(
|
|
667
|
+
severity=ValidationSeverity.ERROR,
|
|
668
|
+
message=f"Cannot insert into '{table_name}': table does not exist",
|
|
669
|
+
sql_fragment=sql[:100],
|
|
670
|
+
line_number=line_num,
|
|
671
|
+
)
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
return issues
|
|
675
|
+
|
|
676
|
+
def _validate_update(
|
|
677
|
+
self,
|
|
678
|
+
sql: str,
|
|
679
|
+
schema: SchemaInfo,
|
|
680
|
+
line_num: int,
|
|
681
|
+
) -> list[ValidationIssue]:
|
|
682
|
+
"""Validate UPDATE statements."""
|
|
683
|
+
issues: list[ValidationIssue] = []
|
|
684
|
+
|
|
685
|
+
# Check target table exists
|
|
686
|
+
match = re.search(r"UPDATE\s+(?:\")?(\w+)(?:\")?", sql, re.IGNORECASE)
|
|
687
|
+
if match:
|
|
688
|
+
table_name = match.group(1).lower()
|
|
689
|
+
if table_name not in schema.tables:
|
|
690
|
+
issues.append(
|
|
691
|
+
ValidationIssue(
|
|
692
|
+
severity=ValidationSeverity.ERROR,
|
|
693
|
+
message=f"Cannot update '{table_name}': table does not exist",
|
|
694
|
+
sql_fragment=sql[:100],
|
|
695
|
+
line_number=line_num,
|
|
696
|
+
)
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
return issues
|
|
700
|
+
|
|
701
|
+
def _validate_delete(
|
|
702
|
+
self,
|
|
703
|
+
sql: str,
|
|
704
|
+
schema: SchemaInfo,
|
|
705
|
+
line_num: int,
|
|
706
|
+
) -> list[ValidationIssue]:
|
|
707
|
+
"""Validate DELETE statements."""
|
|
708
|
+
issues: list[ValidationIssue] = []
|
|
709
|
+
|
|
710
|
+
# Check target table exists
|
|
711
|
+
match = re.search(r"DELETE\s+FROM\s+(?:\")?(\w+)(?:\")?", sql, re.IGNORECASE)
|
|
712
|
+
if match:
|
|
713
|
+
table_name = match.group(1).lower()
|
|
714
|
+
if table_name not in schema.tables:
|
|
715
|
+
issues.append(
|
|
716
|
+
ValidationIssue(
|
|
717
|
+
severity=ValidationSeverity.ERROR,
|
|
718
|
+
message=f"Cannot delete from '{table_name}': table does not exist",
|
|
719
|
+
sql_fragment=sql[:100],
|
|
720
|
+
line_number=line_num,
|
|
721
|
+
)
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
return issues
|
|
725
|
+
|
|
726
|
+
def validate_foreign_key(
|
|
727
|
+
self,
|
|
728
|
+
source_table: str,
|
|
729
|
+
source_column: str,
|
|
730
|
+
target_table: str,
|
|
731
|
+
target_column: str,
|
|
732
|
+
) -> ValidationIssue | None:
|
|
733
|
+
"""Validate a foreign key reference.
|
|
734
|
+
|
|
735
|
+
Args:
|
|
736
|
+
source_table: Source table name
|
|
737
|
+
source_column: Source column name
|
|
738
|
+
target_table: Target (referenced) table name
|
|
739
|
+
target_column: Target (referenced) column name
|
|
740
|
+
|
|
741
|
+
Returns:
|
|
742
|
+
ValidationIssue if invalid, None if valid
|
|
743
|
+
"""
|
|
744
|
+
schema = self.get_schema_info()
|
|
745
|
+
|
|
746
|
+
if target_table not in schema.tables:
|
|
747
|
+
return ValidationIssue(
|
|
748
|
+
severity=ValidationSeverity.ERROR,
|
|
749
|
+
message=f"FK target table '{target_table}' does not exist",
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
if target_column not in schema.tables[target_table]:
|
|
753
|
+
return ValidationIssue(
|
|
754
|
+
severity=ValidationSeverity.ERROR,
|
|
755
|
+
message=f"FK target column '{target_table}.{target_column}' does not exist",
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
# Check type compatibility
|
|
759
|
+
if source_table in schema.tables and source_column in schema.tables[source_table]:
|
|
760
|
+
source_type = schema.tables[source_table][source_column].get("type")
|
|
761
|
+
target_type = schema.tables[target_table][target_column].get("type")
|
|
762
|
+
if source_type and target_type and source_type != target_type:
|
|
763
|
+
return ValidationIssue(
|
|
764
|
+
severity=ValidationSeverity.WARNING,
|
|
765
|
+
message=f"FK type mismatch: {source_table}.{source_column} ({source_type}) -> "
|
|
766
|
+
f"{target_table}.{target_column} ({target_type})",
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
return None
|