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,491 @@
|
|
|
1
|
+
"""Schema linting engine - validates PostgreSQL schemas against best practices.
|
|
2
|
+
|
|
3
|
+
This module provides the SchemaLinter class which validates database schemas
|
|
4
|
+
against configurable rules for naming conventions, primary keys, documentation,
|
|
5
|
+
and other best practices.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from confiture.config.environment import Environment
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from confiture.models.lint import LintConfig
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RuleSeverity(Enum):
|
|
26
|
+
"""Severity levels for linting violations."""
|
|
27
|
+
|
|
28
|
+
ERROR = "error"
|
|
29
|
+
WARNING = "warning"
|
|
30
|
+
INFO = "info"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class LintViolation:
|
|
35
|
+
"""Represents a single linting violation."""
|
|
36
|
+
|
|
37
|
+
rule_id: str
|
|
38
|
+
rule_name: str
|
|
39
|
+
severity: RuleSeverity
|
|
40
|
+
object_type: str # table, column, index, etc.
|
|
41
|
+
object_name: str
|
|
42
|
+
message: str
|
|
43
|
+
file_path: str | None = None
|
|
44
|
+
line_number: int | None = None
|
|
45
|
+
|
|
46
|
+
def __str__(self) -> str:
|
|
47
|
+
"""String representation of violation."""
|
|
48
|
+
prefix = f"[{self.severity.value.upper()}]"
|
|
49
|
+
return f"{prefix} {self.rule_name}: {self.message} ({self.object_type}: {self.object_name})"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class LintReport:
|
|
54
|
+
"""Result of schema linting."""
|
|
55
|
+
|
|
56
|
+
errors: list[LintViolation] = field(default_factory=list)
|
|
57
|
+
warnings: list[LintViolation] = field(default_factory=list)
|
|
58
|
+
info: list[LintViolation] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def has_errors(self) -> bool:
|
|
62
|
+
"""Check if report contains errors."""
|
|
63
|
+
return len(self.errors) > 0
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def has_warnings(self) -> bool:
|
|
67
|
+
"""Check if report contains warnings."""
|
|
68
|
+
return len(self.warnings) > 0
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def has_info(self) -> bool:
|
|
72
|
+
"""Check if report contains info messages."""
|
|
73
|
+
return len(self.info) > 0
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def total_violations(self) -> int:
|
|
77
|
+
"""Total number of violations."""
|
|
78
|
+
return len(self.errors) + len(self.warnings) + len(self.info)
|
|
79
|
+
|
|
80
|
+
def add_violation(self, violation: LintViolation) -> None:
|
|
81
|
+
"""Add a violation to the report."""
|
|
82
|
+
if violation.severity == RuleSeverity.ERROR:
|
|
83
|
+
self.errors.append(violation)
|
|
84
|
+
elif violation.severity == RuleSeverity.WARNING:
|
|
85
|
+
self.warnings.append(violation)
|
|
86
|
+
else:
|
|
87
|
+
self.info.append(violation)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class LintConfig:
|
|
91
|
+
"""Configuration for schema linting."""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
enabled: bool = True,
|
|
96
|
+
fail_on_error: bool = True,
|
|
97
|
+
fail_on_warning: bool = False,
|
|
98
|
+
check_naming: bool = True,
|
|
99
|
+
check_primary_keys: bool = True,
|
|
100
|
+
check_documentation: bool = True,
|
|
101
|
+
check_indexes: bool = True,
|
|
102
|
+
check_constraints: bool = True,
|
|
103
|
+
check_security: bool = True,
|
|
104
|
+
):
|
|
105
|
+
"""Initialize linting configuration.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
enabled: Whether linting is enabled
|
|
109
|
+
fail_on_error: Exit with error code if errors found
|
|
110
|
+
fail_on_warning: Exit with error code if warnings found
|
|
111
|
+
check_naming: Check naming conventions (snake_case)
|
|
112
|
+
check_primary_keys: Ensure all tables have primary keys
|
|
113
|
+
check_documentation: Check for COMMENT documentation
|
|
114
|
+
check_indexes: Check indexes on foreign keys
|
|
115
|
+
check_constraints: Check constraint definitions
|
|
116
|
+
check_security: Check for security issues (passwords, tokens)
|
|
117
|
+
"""
|
|
118
|
+
self.enabled = enabled
|
|
119
|
+
self.fail_on_error = fail_on_error
|
|
120
|
+
self.fail_on_warning = fail_on_warning
|
|
121
|
+
self.check_naming = check_naming
|
|
122
|
+
self.check_primary_keys = check_primary_keys
|
|
123
|
+
self.check_documentation = check_documentation
|
|
124
|
+
self.check_indexes = check_indexes
|
|
125
|
+
self.check_constraints = check_constraints
|
|
126
|
+
self.check_security = check_security
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class SchemaLinter:
|
|
130
|
+
"""Lints PostgreSQL schema against best practices.
|
|
131
|
+
|
|
132
|
+
Provides comprehensive schema validation including:
|
|
133
|
+
- Naming convention enforcement (snake_case)
|
|
134
|
+
- Primary key requirements
|
|
135
|
+
- Documentation (COMMENT statements)
|
|
136
|
+
- Index requirements on foreign keys
|
|
137
|
+
- Constraint validation
|
|
138
|
+
- Security issue detection
|
|
139
|
+
|
|
140
|
+
Example:
|
|
141
|
+
>>> config = LintConfig(enabled=True)
|
|
142
|
+
>>> linter = SchemaLinter(env="local", config=config)
|
|
143
|
+
>>>
|
|
144
|
+
>>> # Option 1: Load schema from files
|
|
145
|
+
>>> report = linter.lint()
|
|
146
|
+
>>>
|
|
147
|
+
>>> # Option 2: Pass schema directly
|
|
148
|
+
>>> schema = "CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(255));"
|
|
149
|
+
>>> report = linter.lint(schema=schema)
|
|
150
|
+
>>>
|
|
151
|
+
>>> if report.has_errors:
|
|
152
|
+
... print(f"Found {len(report.errors)} errors")
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def __init__(
|
|
156
|
+
self,
|
|
157
|
+
env: str = "local",
|
|
158
|
+
project_dir: Path | None = None,
|
|
159
|
+
config: LintConfig | None = None,
|
|
160
|
+
):
|
|
161
|
+
"""Initialize linter.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
env: Environment name (local, test, production)
|
|
165
|
+
project_dir: Project root directory
|
|
166
|
+
config: Linting configuration (optional)
|
|
167
|
+
"""
|
|
168
|
+
self.env = env
|
|
169
|
+
self.project_dir = project_dir or Path(".")
|
|
170
|
+
self.config = config or LintConfig()
|
|
171
|
+
|
|
172
|
+
# Load environment configuration
|
|
173
|
+
self.environment = Environment.load(env, project_dir=project_dir)
|
|
174
|
+
|
|
175
|
+
# Schema cache
|
|
176
|
+
self._schema_sql: str | None = None
|
|
177
|
+
self._tables: dict[str, dict[str, Any]] | None = None
|
|
178
|
+
|
|
179
|
+
def lint(self, schema: str | None = None) -> LintReport:
|
|
180
|
+
"""Run linting and return report.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
schema: Optional schema SQL to lint. If not provided, loads from files.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
LintReport with all violations found
|
|
187
|
+
"""
|
|
188
|
+
report = LintReport()
|
|
189
|
+
|
|
190
|
+
if not self.config.enabled:
|
|
191
|
+
return report
|
|
192
|
+
|
|
193
|
+
# Use provided schema or load from files
|
|
194
|
+
if schema is not None:
|
|
195
|
+
self._schema_sql = schema
|
|
196
|
+
else:
|
|
197
|
+
self._load_schema()
|
|
198
|
+
|
|
199
|
+
if not self._schema_sql:
|
|
200
|
+
logger.warning("No schema SQL found, skipping linting")
|
|
201
|
+
return report
|
|
202
|
+
|
|
203
|
+
# Run configured checks
|
|
204
|
+
if self.config.check_naming:
|
|
205
|
+
self._check_naming_conventions(report)
|
|
206
|
+
|
|
207
|
+
if self.config.check_primary_keys:
|
|
208
|
+
self._check_primary_keys(report)
|
|
209
|
+
|
|
210
|
+
if self.config.check_documentation:
|
|
211
|
+
self._check_documentation(report)
|
|
212
|
+
|
|
213
|
+
if self.config.check_indexes:
|
|
214
|
+
self._check_indexes(report)
|
|
215
|
+
|
|
216
|
+
if self.config.check_security:
|
|
217
|
+
self._check_security(report)
|
|
218
|
+
|
|
219
|
+
return report
|
|
220
|
+
|
|
221
|
+
def _load_schema(self) -> None:
|
|
222
|
+
"""Load schema SQL from files."""
|
|
223
|
+
try:
|
|
224
|
+
from confiture.core.builder import SchemaBuilder
|
|
225
|
+
|
|
226
|
+
builder = SchemaBuilder(env=self.env, project_dir=self.project_dir)
|
|
227
|
+
self._schema_sql = builder.build()
|
|
228
|
+
except Exception as e:
|
|
229
|
+
logger.error(f"Failed to load schema: {e}")
|
|
230
|
+
self._schema_sql = ""
|
|
231
|
+
|
|
232
|
+
def _check_naming_conventions(self, report: LintReport) -> None:
|
|
233
|
+
"""Check naming conventions (snake_case for identifiers).
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
report: Report to add violations to
|
|
237
|
+
"""
|
|
238
|
+
if not self._schema_sql:
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
# Find table definitions
|
|
242
|
+
table_pattern = r"CREATE TABLE\s+(?:IF NOT EXISTS\s+)?(\w+)"
|
|
243
|
+
for match in re.finditer(table_pattern, self._schema_sql, re.IGNORECASE):
|
|
244
|
+
table_name = match.group(1)
|
|
245
|
+
|
|
246
|
+
# Check if table name is snake_case
|
|
247
|
+
if not self._is_snake_case(table_name):
|
|
248
|
+
violation = LintViolation(
|
|
249
|
+
rule_id="naming_001",
|
|
250
|
+
rule_name="Table Naming Convention",
|
|
251
|
+
severity=RuleSeverity.WARNING,
|
|
252
|
+
object_type="table",
|
|
253
|
+
object_name=table_name,
|
|
254
|
+
message=f"Table name '{table_name}' should be lowercase with underscores (snake_case)",
|
|
255
|
+
)
|
|
256
|
+
report.add_violation(violation)
|
|
257
|
+
|
|
258
|
+
# Check column names in this table
|
|
259
|
+
self._check_column_names(table_name, report)
|
|
260
|
+
|
|
261
|
+
def _check_column_names(self, table_name: str, report: LintReport) -> None:
|
|
262
|
+
"""Check column naming conventions in a table.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
table_name: Name of table to check
|
|
266
|
+
report: Report to add violations to
|
|
267
|
+
"""
|
|
268
|
+
if not self._schema_sql:
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
# Extract table definition
|
|
272
|
+
table_pattern = rf"CREATE TABLE\s+(?:IF NOT EXISTS\s+)?{re.escape(table_name)}\s*\((.*?)\);"
|
|
273
|
+
match = re.search(table_pattern, self._schema_sql, re.IGNORECASE | re.DOTALL)
|
|
274
|
+
|
|
275
|
+
if not match:
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
table_def = match.group(1)
|
|
279
|
+
|
|
280
|
+
# Find column definitions
|
|
281
|
+
column_pattern = r"(\w+)\s+\w+"
|
|
282
|
+
for col_match in re.finditer(column_pattern, table_def):
|
|
283
|
+
column_name = col_match.group(1)
|
|
284
|
+
|
|
285
|
+
# Skip if it's a keyword (PRIMARY KEY, CONSTRAINT, etc.)
|
|
286
|
+
if column_name.upper() in (
|
|
287
|
+
"PRIMARY",
|
|
288
|
+
"KEY",
|
|
289
|
+
"CONSTRAINT",
|
|
290
|
+
"CHECK",
|
|
291
|
+
"DEFAULT",
|
|
292
|
+
"NOT",
|
|
293
|
+
"NULL",
|
|
294
|
+
):
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
if not self._is_snake_case(column_name):
|
|
298
|
+
violation = LintViolation(
|
|
299
|
+
rule_id="naming_002",
|
|
300
|
+
rule_name="Column Naming Convention",
|
|
301
|
+
severity=RuleSeverity.WARNING,
|
|
302
|
+
object_type="column",
|
|
303
|
+
object_name=f"{table_name}.{column_name}",
|
|
304
|
+
message=f"Column '{column_name}' should be lowercase with underscores (snake_case)",
|
|
305
|
+
)
|
|
306
|
+
report.add_violation(violation)
|
|
307
|
+
|
|
308
|
+
def _check_primary_keys(self, report: LintReport) -> None:
|
|
309
|
+
"""Check that all tables have primary keys.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
report: Report to add violations to
|
|
313
|
+
"""
|
|
314
|
+
if not self._schema_sql:
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
# Find all table definitions
|
|
318
|
+
table_pattern = r"CREATE TABLE\s+(?:IF NOT EXISTS\s+)?(\w+)\s*\((.*?)\);"
|
|
319
|
+
for match in re.finditer(table_pattern, self._schema_sql, re.IGNORECASE | re.DOTALL):
|
|
320
|
+
table_name = match.group(1)
|
|
321
|
+
table_def = match.group(2)
|
|
322
|
+
|
|
323
|
+
# Skip if table contains PRIMARY KEY definition
|
|
324
|
+
if re.search(r"PRIMARY\s+KEY", table_def, re.IGNORECASE):
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
# Skip if this is likely a junction/bridge table
|
|
328
|
+
if self._is_likely_junction_table(table_name):
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
violation = LintViolation(
|
|
332
|
+
rule_id="pk_001",
|
|
333
|
+
rule_name="Missing Primary Key",
|
|
334
|
+
severity=RuleSeverity.WARNING,
|
|
335
|
+
object_type="table",
|
|
336
|
+
object_name=table_name,
|
|
337
|
+
message=f"Table '{table_name}' should have a PRIMARY KEY",
|
|
338
|
+
)
|
|
339
|
+
report.add_violation(violation)
|
|
340
|
+
|
|
341
|
+
def _check_documentation(self, report: LintReport) -> None:
|
|
342
|
+
"""Check for documentation (COMMENT statements).
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
report: Report to add violations to
|
|
346
|
+
"""
|
|
347
|
+
if not self._schema_sql:
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
# Find all table definitions
|
|
351
|
+
table_pattern = r"CREATE TABLE\s+(?:IF NOT EXISTS\s+)?(\w+)"
|
|
352
|
+
tables_found = set()
|
|
353
|
+
|
|
354
|
+
for match in re.finditer(table_pattern, self._schema_sql, re.IGNORECASE):
|
|
355
|
+
table_name = match.group(1)
|
|
356
|
+
tables_found.add(table_name)
|
|
357
|
+
|
|
358
|
+
# Check for COMMENT statements
|
|
359
|
+
comment_pattern = r"COMMENT ON TABLE (\w+)"
|
|
360
|
+
tables_documented = set()
|
|
361
|
+
|
|
362
|
+
for match in re.finditer(comment_pattern, self._schema_sql, re.IGNORECASE):
|
|
363
|
+
tables_documented.add(match.group(1))
|
|
364
|
+
|
|
365
|
+
# Find undocumented tables
|
|
366
|
+
for table_name in tables_found:
|
|
367
|
+
if table_name not in tables_documented:
|
|
368
|
+
violation = LintViolation(
|
|
369
|
+
rule_id="doc_001",
|
|
370
|
+
rule_name="Missing Documentation",
|
|
371
|
+
severity=RuleSeverity.INFO,
|
|
372
|
+
object_type="table",
|
|
373
|
+
object_name=table_name,
|
|
374
|
+
message=f"Table '{table_name}' should have a COMMENT describing its purpose",
|
|
375
|
+
)
|
|
376
|
+
report.add_violation(violation)
|
|
377
|
+
|
|
378
|
+
def _check_indexes(self, _report: LintReport) -> None:
|
|
379
|
+
"""Check for indexes on foreign keys.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
_report: Report to add violations to
|
|
383
|
+
"""
|
|
384
|
+
if not self._schema_sql:
|
|
385
|
+
return
|
|
386
|
+
|
|
387
|
+
# Find foreign key definitions
|
|
388
|
+
fk_pattern = r"REFERENCES\s+(\w+)\s*\((\w+)\)"
|
|
389
|
+
fk_matches = list(re.finditer(fk_pattern, self._schema_sql, re.IGNORECASE))
|
|
390
|
+
|
|
391
|
+
if not fk_matches:
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
# Check for CREATE INDEX statements
|
|
395
|
+
index_pattern = r"CREATE\s+(?:UNIQUE\s+)?INDEX\s+\w+\s+ON\s+(\w+)\s*\(([^)]+)\)"
|
|
396
|
+
indexes = {}
|
|
397
|
+
|
|
398
|
+
for match in re.finditer(index_pattern, self._schema_sql, re.IGNORECASE):
|
|
399
|
+
table = match.group(1)
|
|
400
|
+
columns = match.group(2)
|
|
401
|
+
if table not in indexes:
|
|
402
|
+
indexes[table] = []
|
|
403
|
+
indexes[table].append(columns)
|
|
404
|
+
|
|
405
|
+
# Warn if foreign keys lack indexes
|
|
406
|
+
# This is simplified - a full implementation would parse more thoroughly
|
|
407
|
+
# For now, just note that checking for indexes on FK columns is important
|
|
408
|
+
for _fk_match in fk_matches:
|
|
409
|
+
pass
|
|
410
|
+
|
|
411
|
+
def _check_security(self, report: LintReport) -> None:
|
|
412
|
+
"""Check for common security issues.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
report: Report to add violations to
|
|
416
|
+
"""
|
|
417
|
+
if not self._schema_sql:
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
# Check for suspicious column names that might store sensitive data
|
|
421
|
+
security_patterns = [
|
|
422
|
+
(r"password", "password"),
|
|
423
|
+
(r"token", "token"),
|
|
424
|
+
(r"secret", "secret"),
|
|
425
|
+
(r"api_key", "API key"),
|
|
426
|
+
(r"credit_card", "credit card"),
|
|
427
|
+
(r"ssn", "social security number"),
|
|
428
|
+
]
|
|
429
|
+
|
|
430
|
+
for pattern, description in security_patterns:
|
|
431
|
+
matches = re.finditer(rf"(\w*{pattern}\w*)", self._schema_sql, re.IGNORECASE)
|
|
432
|
+
for match in matches:
|
|
433
|
+
identifier = match.group(1)
|
|
434
|
+
|
|
435
|
+
# Check if it's actually a column definition
|
|
436
|
+
context = self._schema_sql[max(0, match.start() - 50) : match.end() + 50]
|
|
437
|
+
if "CREATE TABLE" in context or "ALTER TABLE" in context:
|
|
438
|
+
violation = LintViolation(
|
|
439
|
+
rule_id="sec_001",
|
|
440
|
+
rule_name="Sensitive Data Column",
|
|
441
|
+
severity=RuleSeverity.WARNING,
|
|
442
|
+
object_type="column",
|
|
443
|
+
object_name=identifier,
|
|
444
|
+
message=f"Column '{identifier}' appears to store {description} - ensure proper encryption and access controls",
|
|
445
|
+
)
|
|
446
|
+
report.add_violation(violation)
|
|
447
|
+
|
|
448
|
+
@staticmethod
|
|
449
|
+
def _is_snake_case(identifier: str) -> bool:
|
|
450
|
+
"""Check if identifier is in snake_case.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
identifier: Identifier to check
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
True if identifier is snake_case, False otherwise
|
|
457
|
+
"""
|
|
458
|
+
# Allow uppercase letters for backward compatibility with existing code
|
|
459
|
+
# but prefer lowercase
|
|
460
|
+
if identifier != identifier.lower() and "_" not in identifier:
|
|
461
|
+
return False
|
|
462
|
+
|
|
463
|
+
# Check that it only contains alphanumeric and underscore
|
|
464
|
+
return bool(re.match(r"^[a-z_][a-z0-9_]*$", identifier, re.IGNORECASE))
|
|
465
|
+
|
|
466
|
+
@staticmethod
|
|
467
|
+
def _is_likely_junction_table(table_name: str) -> bool:
|
|
468
|
+
"""Check if table looks like a junction/bridge table.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
table_name: Name of table to check
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
True if table appears to be a junction table
|
|
475
|
+
"""
|
|
476
|
+
# Common junction table patterns
|
|
477
|
+
patterns = [
|
|
478
|
+
r"^(.+)_(.+)$", # Format: singular_singular or table1_table2
|
|
479
|
+
r"^link_", # Starts with link_
|
|
480
|
+
r"_assoc", # Ends with _assoc
|
|
481
|
+
r"_join", # Ends with _join
|
|
482
|
+
r"_rel", # Ends with _rel
|
|
483
|
+
]
|
|
484
|
+
|
|
485
|
+
# Count underscores - junction tables often have multiple
|
|
486
|
+
if table_name.count("_") >= 2:
|
|
487
|
+
for pattern in patterns:
|
|
488
|
+
if re.match(pattern, table_name, re.IGNORECASE):
|
|
489
|
+
return True
|
|
490
|
+
|
|
491
|
+
return False
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Rule versioning and compatibility management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LintSeverity(Enum):
|
|
13
|
+
"""Severity levels for linting rules."""
|
|
14
|
+
|
|
15
|
+
INFO = "info"
|
|
16
|
+
WARNING = "warning"
|
|
17
|
+
ERROR = "error"
|
|
18
|
+
CRITICAL = "critical"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class RuleVersion:
|
|
23
|
+
"""Semantic version for rules."""
|
|
24
|
+
|
|
25
|
+
major: int
|
|
26
|
+
minor: int
|
|
27
|
+
patch: int
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str:
|
|
30
|
+
return f"{self.major}.{self.minor}.{self.patch}"
|
|
31
|
+
|
|
32
|
+
def is_compatible_with(self, other: RuleVersion) -> bool:
|
|
33
|
+
"""Check if compatible (major version must match)."""
|
|
34
|
+
return self.major == other.major
|
|
35
|
+
|
|
36
|
+
def __le__(self, other: RuleVersion) -> bool:
|
|
37
|
+
return (self.major, self.minor, self.patch) <= (
|
|
38
|
+
other.major,
|
|
39
|
+
other.minor,
|
|
40
|
+
other.patch,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def __ge__(self, other: RuleVersion) -> bool:
|
|
44
|
+
return (self.major, self.minor, self.patch) >= (
|
|
45
|
+
other.major,
|
|
46
|
+
other.minor,
|
|
47
|
+
other.patch,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def __lt__(self, other: RuleVersion) -> bool:
|
|
51
|
+
return (self.major, self.minor, self.patch) < (
|
|
52
|
+
other.major,
|
|
53
|
+
other.minor,
|
|
54
|
+
other.patch,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def __gt__(self, other: RuleVersion) -> bool:
|
|
58
|
+
return (self.major, self.minor, self.patch) > (
|
|
59
|
+
other.major,
|
|
60
|
+
other.minor,
|
|
61
|
+
other.patch,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def __eq__(self, other: object) -> bool:
|
|
65
|
+
if not isinstance(other, RuleVersion):
|
|
66
|
+
return NotImplemented
|
|
67
|
+
return (self.major, self.minor, self.patch) == (
|
|
68
|
+
other.major,
|
|
69
|
+
other.minor,
|
|
70
|
+
other.patch,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Rule:
|
|
76
|
+
"""Individual linting rule with versioning."""
|
|
77
|
+
|
|
78
|
+
rule_id: str
|
|
79
|
+
name: str
|
|
80
|
+
description: str
|
|
81
|
+
version: RuleVersion
|
|
82
|
+
deprecated_in: RuleVersion | None = None
|
|
83
|
+
removed_in: RuleVersion | None = None
|
|
84
|
+
migration_path: str | None = None # Docs URL or replacement rule ID
|
|
85
|
+
severity: LintSeverity = LintSeverity.WARNING
|
|
86
|
+
enabled_by_default: bool = True
|
|
87
|
+
|
|
88
|
+
def is_deprecated(self, target_version: RuleVersion | None = None) -> bool:
|
|
89
|
+
"""Check if rule is deprecated."""
|
|
90
|
+
if not self.deprecated_in:
|
|
91
|
+
return False
|
|
92
|
+
if target_version:
|
|
93
|
+
return self.deprecated_in <= target_version
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
def is_removed(self, target_version: RuleVersion | None = None) -> bool:
|
|
97
|
+
"""Check if rule is removed."""
|
|
98
|
+
if not self.removed_in:
|
|
99
|
+
return False
|
|
100
|
+
if target_version:
|
|
101
|
+
return self.removed_in <= target_version
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class RuleRemovedError(Exception):
|
|
106
|
+
"""Exception raised when accessing a removed rule."""
|
|
107
|
+
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class RuleVersionManager:
|
|
112
|
+
"""Manage rule versions and compatibility."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, rules: list[Rule]):
|
|
115
|
+
self.rules = {r.rule_id: r for r in rules}
|
|
116
|
+
|
|
117
|
+
def get_rule(
|
|
118
|
+
self,
|
|
119
|
+
rule_id: str,
|
|
120
|
+
target_version: RuleVersion | None = None,
|
|
121
|
+
) -> Rule | None:
|
|
122
|
+
"""Get rule compatible with target version."""
|
|
123
|
+
rule = self.rules.get(rule_id)
|
|
124
|
+
if not rule:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
if rule.is_removed(target_version):
|
|
128
|
+
raise RuleRemovedError(
|
|
129
|
+
f"Rule {rule_id} was removed in {rule.removed_in}. "
|
|
130
|
+
f"Migration path: {rule.migration_path}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if rule.is_deprecated(target_version):
|
|
134
|
+
logger.warning(
|
|
135
|
+
f"Rule {rule_id} is deprecated since {rule.deprecated_in}. "
|
|
136
|
+
f"Migration path: {rule.migration_path}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return rule
|
|
140
|
+
|
|
141
|
+
def validate_compatibility(
|
|
142
|
+
self,
|
|
143
|
+
_library_version: RuleVersion,
|
|
144
|
+
min_rule_version: RuleVersion,
|
|
145
|
+
) -> list[str]:
|
|
146
|
+
"""Check if all rules are compatible with version."""
|
|
147
|
+
incompatible = []
|
|
148
|
+
for rule in self.rules.values():
|
|
149
|
+
if not rule.version.is_compatible_with(min_rule_version):
|
|
150
|
+
incompatible.append(rule.rule_id)
|
|
151
|
+
return incompatible
|