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,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