fraiseql-confiture 0.3.7__cp311-cp311-macosx_11_0_arm64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- confiture/__init__.py +48 -0
- confiture/_core.cpython-311-darwin.so +0 -0
- confiture/cli/__init__.py +0 -0
- confiture/cli/dry_run.py +116 -0
- confiture/cli/lint_formatter.py +193 -0
- confiture/cli/main.py +1893 -0
- confiture/config/__init__.py +0 -0
- confiture/config/environment.py +263 -0
- confiture/core/__init__.py +51 -0
- confiture/core/anonymization/__init__.py +0 -0
- confiture/core/anonymization/audit.py +485 -0
- confiture/core/anonymization/benchmarking.py +372 -0
- confiture/core/anonymization/breach_notification.py +652 -0
- confiture/core/anonymization/compliance.py +617 -0
- confiture/core/anonymization/composer.py +298 -0
- confiture/core/anonymization/data_subject_rights.py +669 -0
- confiture/core/anonymization/factory.py +319 -0
- confiture/core/anonymization/governance.py +737 -0
- confiture/core/anonymization/performance.py +1092 -0
- confiture/core/anonymization/profile.py +284 -0
- confiture/core/anonymization/registry.py +195 -0
- confiture/core/anonymization/security/kms_manager.py +547 -0
- confiture/core/anonymization/security/lineage.py +888 -0
- confiture/core/anonymization/security/token_store.py +686 -0
- confiture/core/anonymization/strategies/__init__.py +41 -0
- confiture/core/anonymization/strategies/address.py +359 -0
- confiture/core/anonymization/strategies/credit_card.py +374 -0
- confiture/core/anonymization/strategies/custom.py +161 -0
- confiture/core/anonymization/strategies/date.py +218 -0
- confiture/core/anonymization/strategies/differential_privacy.py +398 -0
- confiture/core/anonymization/strategies/email.py +141 -0
- confiture/core/anonymization/strategies/format_preserving_encryption.py +310 -0
- confiture/core/anonymization/strategies/hash.py +150 -0
- confiture/core/anonymization/strategies/ip_address.py +235 -0
- confiture/core/anonymization/strategies/masking_retention.py +252 -0
- confiture/core/anonymization/strategies/name.py +298 -0
- confiture/core/anonymization/strategies/phone.py +119 -0
- confiture/core/anonymization/strategies/preserve.py +85 -0
- confiture/core/anonymization/strategies/redact.py +101 -0
- confiture/core/anonymization/strategies/salted_hashing.py +322 -0
- confiture/core/anonymization/strategies/text_redaction.py +183 -0
- confiture/core/anonymization/strategies/tokenization.py +334 -0
- confiture/core/anonymization/strategy.py +241 -0
- confiture/core/anonymization/syncer_audit.py +357 -0
- confiture/core/blue_green.py +683 -0
- confiture/core/builder.py +500 -0
- confiture/core/checksum.py +358 -0
- confiture/core/connection.py +184 -0
- confiture/core/differ.py +522 -0
- confiture/core/drift.py +564 -0
- confiture/core/dry_run.py +182 -0
- confiture/core/health.py +313 -0
- confiture/core/hooks/__init__.py +87 -0
- confiture/core/hooks/base.py +232 -0
- confiture/core/hooks/context.py +146 -0
- confiture/core/hooks/execution_strategies.py +57 -0
- confiture/core/hooks/observability.py +220 -0
- confiture/core/hooks/phases.py +53 -0
- confiture/core/hooks/registry.py +295 -0
- confiture/core/large_tables.py +775 -0
- confiture/core/linting/__init__.py +70 -0
- confiture/core/linting/composer.py +192 -0
- confiture/core/linting/libraries/__init__.py +17 -0
- confiture/core/linting/libraries/gdpr.py +168 -0
- confiture/core/linting/libraries/general.py +184 -0
- confiture/core/linting/libraries/hipaa.py +144 -0
- confiture/core/linting/libraries/pci_dss.py +104 -0
- confiture/core/linting/libraries/sox.py +120 -0
- confiture/core/linting/schema_linter.py +491 -0
- confiture/core/linting/versioning.py +151 -0
- confiture/core/locking.py +389 -0
- confiture/core/migration_generator.py +298 -0
- confiture/core/migrator.py +882 -0
- confiture/core/observability/__init__.py +44 -0
- confiture/core/observability/audit.py +323 -0
- confiture/core/observability/logging.py +187 -0
- confiture/core/observability/metrics.py +174 -0
- confiture/core/observability/tracing.py +192 -0
- confiture/core/pg_version.py +418 -0
- confiture/core/pool.py +406 -0
- confiture/core/risk/__init__.py +39 -0
- confiture/core/risk/predictor.py +188 -0
- confiture/core/risk/scoring.py +248 -0
- confiture/core/rollback_generator.py +388 -0
- confiture/core/schema_analyzer.py +769 -0
- confiture/core/schema_to_schema.py +590 -0
- confiture/core/security/__init__.py +32 -0
- confiture/core/security/logging.py +201 -0
- confiture/core/security/validation.py +416 -0
- confiture/core/signals.py +371 -0
- confiture/core/syncer.py +540 -0
- confiture/exceptions.py +192 -0
- confiture/integrations/__init__.py +0 -0
- confiture/models/__init__.py +24 -0
- confiture/models/lint.py +193 -0
- confiture/models/migration.py +265 -0
- confiture/models/schema.py +203 -0
- confiture/models/sql_file_migration.py +225 -0
- confiture/scenarios/__init__.py +36 -0
- confiture/scenarios/compliance.py +586 -0
- confiture/scenarios/ecommerce.py +199 -0
- confiture/scenarios/financial.py +253 -0
- confiture/scenarios/healthcare.py +315 -0
- confiture/scenarios/multi_tenant.py +340 -0
- confiture/scenarios/saas.py +295 -0
- confiture/testing/FRAMEWORK_API.md +722 -0
- confiture/testing/__init__.py +100 -0
- confiture/testing/fixtures/__init__.py +11 -0
- confiture/testing/fixtures/data_validator.py +229 -0
- confiture/testing/fixtures/migration_runner.py +167 -0
- confiture/testing/fixtures/schema_snapshotter.py +352 -0
- confiture/testing/frameworks/__init__.py +10 -0
- confiture/testing/frameworks/mutation.py +587 -0
- confiture/testing/frameworks/performance.py +479 -0
- confiture/testing/loader.py +225 -0
- confiture/testing/pytest/__init__.py +38 -0
- confiture/testing/pytest_plugin.py +190 -0
- confiture/testing/sandbox.py +304 -0
- confiture/testing/utils/__init__.py +0 -0
- fraiseql_confiture-0.3.7.dist-info/METADATA +438 -0
- fraiseql_confiture-0.3.7.dist-info/RECORD +124 -0
- fraiseql_confiture-0.3.7.dist-info/WHEEL +4 -0
- fraiseql_confiture-0.3.7.dist-info/entry_points.txt +4 -0
- fraiseql_confiture-0.3.7.dist-info/licenses/LICENSE +21 -0
confiture/exceptions.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Confiture exception hierarchy
|
|
2
|
+
|
|
3
|
+
All exceptions raised by Confiture inherit from ConfiturError.
|
|
4
|
+
This allows users to catch all Confiture-specific errors with a single except clause.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfiturError(Exception):
|
|
9
|
+
"""Base exception for all Confiture errors
|
|
10
|
+
|
|
11
|
+
All Confiture-specific exceptions inherit from this base class.
|
|
12
|
+
This allows catching all Confiture errors with:
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
confiture.build()
|
|
16
|
+
except ConfiturError as e:
|
|
17
|
+
# Handle any Confiture error
|
|
18
|
+
pass
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigurationError(ConfiturError):
|
|
25
|
+
"""Invalid configuration (YAML, environment, database connection)
|
|
26
|
+
|
|
27
|
+
Raised when:
|
|
28
|
+
- Environment YAML file is malformed or missing
|
|
29
|
+
- Required configuration fields are missing
|
|
30
|
+
- Database connection string is invalid
|
|
31
|
+
- Include/exclude directory patterns are invalid
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
>>> raise ConfigurationError("Missing database_url in local.yaml")
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MigrationError(ConfiturError):
|
|
41
|
+
"""Migration execution failure
|
|
42
|
+
|
|
43
|
+
Raised when:
|
|
44
|
+
- Migration file cannot be loaded
|
|
45
|
+
- Migration up() or down() fails
|
|
46
|
+
- Migration has already been applied
|
|
47
|
+
- Migration rollback fails
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
version: Migration version that failed (e.g., "001")
|
|
51
|
+
migration_name: Human-readable migration name
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
message: str,
|
|
57
|
+
version: str | None = None,
|
|
58
|
+
migration_name: str | None = None,
|
|
59
|
+
):
|
|
60
|
+
super().__init__(message)
|
|
61
|
+
self.version = version
|
|
62
|
+
self.migration_name = migration_name
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SchemaError(ConfiturError):
|
|
66
|
+
"""Invalid schema DDL or schema build failure
|
|
67
|
+
|
|
68
|
+
Raised when:
|
|
69
|
+
- SQL syntax error in DDL files
|
|
70
|
+
- Missing required schema directories
|
|
71
|
+
- Circular dependencies between schema files
|
|
72
|
+
- Schema hash computation fails
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
>>> raise SchemaError("Syntax error in 10_tables/users.sql at line 15")
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SyncError(ConfiturError):
|
|
82
|
+
"""Production data sync failure
|
|
83
|
+
|
|
84
|
+
Raised when:
|
|
85
|
+
- Cannot connect to source database
|
|
86
|
+
- Table does not exist in source or target
|
|
87
|
+
- Anonymization rule fails
|
|
88
|
+
- Data copy operation fails
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
>>> raise SyncError("Table 'users' not found in source database")
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class DifferError(ConfiturError):
|
|
98
|
+
"""Schema diff detection error
|
|
99
|
+
|
|
100
|
+
Raised when:
|
|
101
|
+
- Cannot parse SQL DDL
|
|
102
|
+
- Schema comparison fails
|
|
103
|
+
- Ambiguous schema changes detected
|
|
104
|
+
|
|
105
|
+
Example:
|
|
106
|
+
>>> raise DifferError("Cannot parse CREATE TABLE statement")
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ValidationError(ConfiturError):
|
|
113
|
+
"""Data or schema validation error
|
|
114
|
+
|
|
115
|
+
Raised when:
|
|
116
|
+
- Row count mismatch after migration
|
|
117
|
+
- Foreign key constraints violated
|
|
118
|
+
- Custom validation rules fail
|
|
119
|
+
|
|
120
|
+
Example:
|
|
121
|
+
>>> raise ValidationError("Row count mismatch: expected 10000, got 9999")
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class RollbackError(ConfiturError):
|
|
128
|
+
"""Migration rollback failure
|
|
129
|
+
|
|
130
|
+
Raised when:
|
|
131
|
+
- Cannot rollback migration (irreversible change)
|
|
132
|
+
- Rollback SQL fails
|
|
133
|
+
- Database state is inconsistent after rollback
|
|
134
|
+
|
|
135
|
+
This is a critical error that may require manual intervention.
|
|
136
|
+
|
|
137
|
+
Example:
|
|
138
|
+
>>> raise RollbackError("Cannot rollback: data already deleted")
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class SQLError(ConfiturError):
|
|
145
|
+
"""SQL execution error with detailed context
|
|
146
|
+
|
|
147
|
+
Raised when:
|
|
148
|
+
- SQL statement fails during migration execution
|
|
149
|
+
- Provides context about which SQL statement failed
|
|
150
|
+
- Includes original SQL and error details
|
|
151
|
+
|
|
152
|
+
Attributes:
|
|
153
|
+
sql: The SQL statement that failed
|
|
154
|
+
params: Query parameters (if any)
|
|
155
|
+
original_error: The underlying database error
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
>>> raise SQLError(
|
|
159
|
+
... "CREATE TABLE users (id INT PRIMARY KEY, name TEXT)",
|
|
160
|
+
... None,
|
|
161
|
+
... psycopg_error
|
|
162
|
+
... )
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
sql: str,
|
|
168
|
+
params: tuple[str, ...] | None,
|
|
169
|
+
original_error: Exception,
|
|
170
|
+
):
|
|
171
|
+
self.sql = sql
|
|
172
|
+
self.params = params
|
|
173
|
+
self.original_error = original_error
|
|
174
|
+
|
|
175
|
+
# Create detailed error message
|
|
176
|
+
message_parts = ["SQL execution failed"]
|
|
177
|
+
|
|
178
|
+
# Add SQL snippet (first 100 chars)
|
|
179
|
+
sql_preview = sql.strip()[:100]
|
|
180
|
+
if len(sql.strip()) > 100:
|
|
181
|
+
sql_preview += "..."
|
|
182
|
+
message_parts.append(f"SQL: {sql_preview}")
|
|
183
|
+
|
|
184
|
+
# Add parameters if present
|
|
185
|
+
if params:
|
|
186
|
+
message_parts.append(f"Parameters: {params}")
|
|
187
|
+
|
|
188
|
+
# Add original error
|
|
189
|
+
message_parts.append(f"Error: {original_error}")
|
|
190
|
+
|
|
191
|
+
message = " | ".join(message_parts)
|
|
192
|
+
super().__init__(message)
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Confiture migration models.
|
|
2
|
+
|
|
3
|
+
This module provides the base classes for creating migrations:
|
|
4
|
+
- Migration: Abstract base class for Python migrations
|
|
5
|
+
- SQLMigration: Convenience class for SQL-only migrations with up_sql/down_sql attributes
|
|
6
|
+
- FileSQLMigration: Migrations loaded from .up.sql/.down.sql file pairs
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from confiture.models.migration import Migration, SQLMigration
|
|
10
|
+
from confiture.models.sql_file_migration import (
|
|
11
|
+
FileSQLMigration,
|
|
12
|
+
find_sql_migration_files,
|
|
13
|
+
get_sql_migration_version,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
# Base migration classes
|
|
18
|
+
"Migration",
|
|
19
|
+
"SQLMigration",
|
|
20
|
+
"FileSQLMigration",
|
|
21
|
+
# SQL file discovery
|
|
22
|
+
"find_sql_migration_files",
|
|
23
|
+
"get_sql_migration_version",
|
|
24
|
+
]
|
confiture/models/lint.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Linting models for schema validation.
|
|
2
|
+
|
|
3
|
+
This module provides data structures for schema linting including:
|
|
4
|
+
- Violation: A single schema quality issue
|
|
5
|
+
- LintSeverity: Severity level of violations
|
|
6
|
+
- LintConfig: Configuration for linting rules
|
|
7
|
+
- LintReport: Aggregated linting results
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LintSeverity(str, Enum):
|
|
16
|
+
"""Severity levels for linting violations.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
ERROR: Blocking issue - must fix before migration
|
|
20
|
+
WARNING: Should fix but optional
|
|
21
|
+
INFO: Informational only
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
ERROR = "error"
|
|
25
|
+
WARNING = "warning"
|
|
26
|
+
INFO = "info"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Violation:
|
|
31
|
+
"""A single schema quality violation.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
rule_name: Name of the rule that detected this violation
|
|
35
|
+
severity: Severity level (ERROR, WARNING, INFO)
|
|
36
|
+
message: Human-readable description of the issue
|
|
37
|
+
location: Where the violation occurred (table name, column, etc.)
|
|
38
|
+
suggested_fix: Optional suggestion on how to fix it
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
rule_name: str
|
|
42
|
+
severity: LintSeverity
|
|
43
|
+
message: str
|
|
44
|
+
location: str
|
|
45
|
+
suggested_fix: str | None = None
|
|
46
|
+
|
|
47
|
+
def __str__(self) -> str:
|
|
48
|
+
"""Format violation for human consumption."""
|
|
49
|
+
return f"[{self.severity.upper()}] {self.location}: {self.message}"
|
|
50
|
+
|
|
51
|
+
def __repr__(self) -> str:
|
|
52
|
+
"""Return repr for debugging."""
|
|
53
|
+
return (
|
|
54
|
+
f"Violation(rule={self.rule_name}, severity={self.severity}, location={self.location})"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class LintConfig:
|
|
60
|
+
"""Configuration for schema linting.
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
enabled: Whether linting is enabled
|
|
64
|
+
rules: Dict mapping rule names to their configs
|
|
65
|
+
fail_on_error: Exit with error code if violations found
|
|
66
|
+
fail_on_warning: Exit with error code if warnings found (stricter)
|
|
67
|
+
exclude_tables: List of table name patterns to exclude from linting
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
enabled: bool = True
|
|
71
|
+
rules: dict[str, Any] = field(default_factory=dict)
|
|
72
|
+
fail_on_error: bool = True
|
|
73
|
+
fail_on_warning: bool = False
|
|
74
|
+
exclude_tables: list[str] = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def default(cls) -> "LintConfig":
|
|
78
|
+
"""Create LintConfig with sensible defaults for all rules.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
LintConfig with all 6 rules enabled with default settings
|
|
82
|
+
|
|
83
|
+
Example:
|
|
84
|
+
>>> config = LintConfig.default()
|
|
85
|
+
>>> config.rules.keys()
|
|
86
|
+
dict_keys(['naming_convention', 'primary_key', ...])
|
|
87
|
+
"""
|
|
88
|
+
return cls(
|
|
89
|
+
enabled=True,
|
|
90
|
+
fail_on_error=True,
|
|
91
|
+
fail_on_warning=False,
|
|
92
|
+
rules={
|
|
93
|
+
"naming_convention": {
|
|
94
|
+
"enabled": True,
|
|
95
|
+
"style": "snake_case",
|
|
96
|
+
},
|
|
97
|
+
"primary_key": {
|
|
98
|
+
"enabled": True,
|
|
99
|
+
},
|
|
100
|
+
"documentation": {
|
|
101
|
+
"enabled": True,
|
|
102
|
+
},
|
|
103
|
+
"multi_tenant": {
|
|
104
|
+
"enabled": True,
|
|
105
|
+
"identifier": "tenant_id",
|
|
106
|
+
},
|
|
107
|
+
"missing_index": {
|
|
108
|
+
"enabled": True,
|
|
109
|
+
},
|
|
110
|
+
"security": {
|
|
111
|
+
"enabled": True,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class LintReport:
|
|
119
|
+
"""Results of a complete linting pass.
|
|
120
|
+
|
|
121
|
+
Attributes:
|
|
122
|
+
violations: List of all violations found
|
|
123
|
+
schema_name: Name of schema that was linted
|
|
124
|
+
tables_checked: Total number of tables checked
|
|
125
|
+
columns_checked: Total number of columns checked
|
|
126
|
+
errors_count: Number of ERROR level violations
|
|
127
|
+
warnings_count: Number of WARNING level violations
|
|
128
|
+
info_count: Number of INFO level violations
|
|
129
|
+
execution_time_ms: Time taken to lint in milliseconds
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
violations: list[Violation]
|
|
133
|
+
schema_name: str
|
|
134
|
+
tables_checked: int
|
|
135
|
+
columns_checked: int
|
|
136
|
+
errors_count: int
|
|
137
|
+
warnings_count: int
|
|
138
|
+
info_count: int
|
|
139
|
+
execution_time_ms: int
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def has_errors(self) -> bool:
|
|
143
|
+
"""Whether there are any ERROR level violations.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
True if errors_count > 0, False otherwise
|
|
147
|
+
"""
|
|
148
|
+
return self.errors_count > 0
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def has_warnings(self) -> bool:
|
|
152
|
+
"""Whether there are any WARNING level violations.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
True if warnings_count > 0, False otherwise
|
|
156
|
+
"""
|
|
157
|
+
return self.warnings_count > 0
|
|
158
|
+
|
|
159
|
+
def violations_by_severity(self) -> dict[LintSeverity, list[Violation]]:
|
|
160
|
+
"""Group violations by their severity level.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Dict mapping LintSeverity to list of violations at that level
|
|
164
|
+
|
|
165
|
+
Example:
|
|
166
|
+
>>> report.violations_by_severity()
|
|
167
|
+
{
|
|
168
|
+
<LintSeverity.ERROR: 'error'>: [Violation(...), ...],
|
|
169
|
+
<LintSeverity.WARNING: 'warning'>: [...],
|
|
170
|
+
<LintSeverity.INFO: 'info'>: [...],
|
|
171
|
+
}
|
|
172
|
+
"""
|
|
173
|
+
grouped: dict[LintSeverity, list[Violation]] = {}
|
|
174
|
+
|
|
175
|
+
for severity in LintSeverity:
|
|
176
|
+
grouped[severity] = [v for v in self.violations if v.severity == severity]
|
|
177
|
+
|
|
178
|
+
return grouped
|
|
179
|
+
|
|
180
|
+
def __str__(self) -> str:
|
|
181
|
+
"""Format report for human consumption.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Multi-line string with summary of linting results
|
|
185
|
+
"""
|
|
186
|
+
tables_with_violations = {v.location.split(".")[0] for v in self.violations}
|
|
187
|
+
lines = [
|
|
188
|
+
f"Schema: {self.schema_name}",
|
|
189
|
+
f"Tables: {self.tables_checked} checked, {len(tables_with_violations)} with violations",
|
|
190
|
+
f"Violations: {self.errors_count} errors, {self.warnings_count} warnings, {self.info_count} info",
|
|
191
|
+
f"Time: {self.execution_time_ms}ms",
|
|
192
|
+
]
|
|
193
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Migration base class for database migrations."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
import psycopg
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from confiture.core.hooks import Hook
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Migration(ABC):
|
|
13
|
+
"""Base class for all database migrations.
|
|
14
|
+
|
|
15
|
+
Each migration must:
|
|
16
|
+
- Define a version (e.g., "001", "002")
|
|
17
|
+
- Define a name (e.g., "create_users")
|
|
18
|
+
- Implement up() method for applying the migration
|
|
19
|
+
- Implement down() method for rolling back the migration
|
|
20
|
+
|
|
21
|
+
Alternatively, for simple SQL-only migrations, subclass SQLMigration
|
|
22
|
+
and define `up_sql` and `down_sql` class attributes instead of methods.
|
|
23
|
+
|
|
24
|
+
Migrations can optionally define hooks that execute before/after DDL:
|
|
25
|
+
- before_validation_hooks: Pre-flight checks before migration
|
|
26
|
+
- before_ddl_hooks: Data prep before structural changes
|
|
27
|
+
- after_ddl_hooks: Data backfill after structural changes
|
|
28
|
+
- after_validation_hooks: Verification after data operations
|
|
29
|
+
- cleanup_hooks: Final cleanup operations
|
|
30
|
+
- error_hooks: Error handlers during rollback
|
|
31
|
+
|
|
32
|
+
Transaction Control:
|
|
33
|
+
By default, migrations run inside a transaction with savepoints.
|
|
34
|
+
Set `transactional = False` for operations that cannot run in
|
|
35
|
+
a transaction, such as:
|
|
36
|
+
- CREATE INDEX CONCURRENTLY
|
|
37
|
+
- DROP INDEX CONCURRENTLY
|
|
38
|
+
- VACUUM
|
|
39
|
+
- REINDEX CONCURRENTLY
|
|
40
|
+
|
|
41
|
+
WARNING: Non-transactional migrations cannot be automatically
|
|
42
|
+
rolled back if they fail. Manual cleanup may be required.
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
>>> class CreateUsers(Migration):
|
|
46
|
+
... version = "001"
|
|
47
|
+
... name = "create_users"
|
|
48
|
+
...
|
|
49
|
+
... def up(self):
|
|
50
|
+
... self.execute('''
|
|
51
|
+
... CREATE TABLE users (
|
|
52
|
+
... id SERIAL PRIMARY KEY,
|
|
53
|
+
... username TEXT NOT NULL
|
|
54
|
+
... )
|
|
55
|
+
... ''')
|
|
56
|
+
...
|
|
57
|
+
... def down(self):
|
|
58
|
+
... self.execute('DROP TABLE users')
|
|
59
|
+
|
|
60
|
+
Example with hooks:
|
|
61
|
+
>>> class AddAnalyticsTable(Migration):
|
|
62
|
+
... version = "002"
|
|
63
|
+
... name = "add_analytics_table"
|
|
64
|
+
... after_ddl_hooks = [BackfillAnalyticsHook()]
|
|
65
|
+
...
|
|
66
|
+
... def up(self):
|
|
67
|
+
... self.execute('CREATE TABLE analytics (...)')
|
|
68
|
+
...
|
|
69
|
+
... def down(self):
|
|
70
|
+
... self.execute('DROP TABLE analytics')
|
|
71
|
+
|
|
72
|
+
Example non-transactional (CREATE INDEX CONCURRENTLY):
|
|
73
|
+
>>> class AddSearchIndex(Migration):
|
|
74
|
+
... version = "015"
|
|
75
|
+
... name = "add_search_index"
|
|
76
|
+
... transactional = False # Required for CONCURRENTLY
|
|
77
|
+
...
|
|
78
|
+
... def up(self):
|
|
79
|
+
... self.execute('CREATE INDEX CONCURRENTLY idx_search ON products(name)')
|
|
80
|
+
...
|
|
81
|
+
... def down(self):
|
|
82
|
+
... self.execute('DROP INDEX CONCURRENTLY IF EXISTS idx_search')
|
|
83
|
+
|
|
84
|
+
Example SQL-only migration (using SQLMigration):
|
|
85
|
+
>>> class MoveCatalogTables(SQLMigration):
|
|
86
|
+
... version = "003"
|
|
87
|
+
... name = "move_catalog_tables"
|
|
88
|
+
...
|
|
89
|
+
... up_sql = "ALTER TABLE tenant.products SET SCHEMA catalog;"
|
|
90
|
+
... down_sql = "ALTER TABLE catalog.products SET SCHEMA tenant;"
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
# Subclasses must define these
|
|
94
|
+
version: str
|
|
95
|
+
name: str
|
|
96
|
+
|
|
97
|
+
# Configuration attributes
|
|
98
|
+
transactional: bool = True # Default: run in transaction with savepoints
|
|
99
|
+
strict_mode: bool = False # Default: lenient error handling
|
|
100
|
+
|
|
101
|
+
# Hook attributes (optional, default to empty lists)
|
|
102
|
+
before_validation_hooks: list["Hook"] = []
|
|
103
|
+
before_ddl_hooks: list["Hook"] = []
|
|
104
|
+
after_ddl_hooks: list["Hook"] = []
|
|
105
|
+
after_validation_hooks: list["Hook"] = []
|
|
106
|
+
cleanup_hooks: list["Hook"] = []
|
|
107
|
+
error_hooks: list["Hook"] = []
|
|
108
|
+
|
|
109
|
+
def __init__(self, connection: psycopg.Connection):
|
|
110
|
+
"""Initialize migration with database connection.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
connection: psycopg3 database connection
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
TypeError: If version or name not defined in subclass
|
|
117
|
+
"""
|
|
118
|
+
self.connection = connection
|
|
119
|
+
|
|
120
|
+
# Ensure subclass defined version and name
|
|
121
|
+
if not hasattr(self.__class__, "version") or self.__class__.version is None:
|
|
122
|
+
raise TypeError(f"{self.__class__.__name__} must define a 'version' class attribute")
|
|
123
|
+
if not hasattr(self.__class__, "name") or self.__class__.name is None:
|
|
124
|
+
raise TypeError(f"{self.__class__.__name__} must define a 'name' class attribute")
|
|
125
|
+
|
|
126
|
+
@abstractmethod
|
|
127
|
+
def up(self) -> None:
|
|
128
|
+
"""Apply the migration.
|
|
129
|
+
|
|
130
|
+
This method must be implemented by subclasses to perform
|
|
131
|
+
the forward migration (e.g., CREATE TABLE, ALTER TABLE).
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
NotImplementedError: If not implemented by subclass
|
|
135
|
+
"""
|
|
136
|
+
raise NotImplementedError(f"{self.__class__.__name__}.up() must be implemented")
|
|
137
|
+
|
|
138
|
+
@abstractmethod
|
|
139
|
+
def down(self) -> None:
|
|
140
|
+
"""Rollback the migration.
|
|
141
|
+
|
|
142
|
+
This method must be implemented by subclasses to perform
|
|
143
|
+
the reverse migration (e.g., DROP TABLE, revert ALTER).
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
NotImplementedError: If not implemented by subclass
|
|
147
|
+
"""
|
|
148
|
+
raise NotImplementedError(f"{self.__class__.__name__}.down() must be implemented")
|
|
149
|
+
|
|
150
|
+
def execute(self, sql: str, params: tuple[Any, ...] | None = None) -> None:
|
|
151
|
+
"""Execute a SQL statement.
|
|
152
|
+
|
|
153
|
+
In strict mode:
|
|
154
|
+
- Validates statement success explicitly
|
|
155
|
+
- May check for PostgreSQL warnings (future enhancement)
|
|
156
|
+
|
|
157
|
+
In normal mode:
|
|
158
|
+
- Only fails on actual errors (default)
|
|
159
|
+
- Ignores notices and warnings
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
sql: SQL statement to execute
|
|
163
|
+
params: Optional query parameters (for parameterized queries)
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
SQLError: If SQL execution fails, with detailed context
|
|
167
|
+
|
|
168
|
+
Example:
|
|
169
|
+
>>> self.execute("CREATE TABLE users (id INT)")
|
|
170
|
+
>>> self.execute("INSERT INTO users (name) VALUES (%s)", ("Alice",))
|
|
171
|
+
"""
|
|
172
|
+
from confiture.exceptions import SQLError
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
with self.connection.cursor() as cursor:
|
|
176
|
+
if params:
|
|
177
|
+
cursor.execute(sql, params)
|
|
178
|
+
else:
|
|
179
|
+
cursor.execute(sql)
|
|
180
|
+
|
|
181
|
+
# In strict mode, we could check for warnings here
|
|
182
|
+
# For now, this is a placeholder for future enhancement
|
|
183
|
+
if self.strict_mode:
|
|
184
|
+
# TODO: Implement warning detection
|
|
185
|
+
# PostgreSQL notices are sent via connection.notices
|
|
186
|
+
# or through a notice handler
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
except Exception as e:
|
|
190
|
+
# Wrap the error with SQL context
|
|
191
|
+
raise SQLError(sql, params, e) from e
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class SQLMigration(Migration):
|
|
195
|
+
"""Migration that executes SQL from class attributes.
|
|
196
|
+
|
|
197
|
+
A convenience class for simple SQL-only migrations that don't need
|
|
198
|
+
Python logic. Instead of implementing up() and down() methods,
|
|
199
|
+
define `up_sql` and `down_sql` class attributes.
|
|
200
|
+
|
|
201
|
+
This reduces boilerplate for simple schema changes while maintaining
|
|
202
|
+
full compatibility with the migration system (hooks, transactions, etc.).
|
|
203
|
+
|
|
204
|
+
Attributes:
|
|
205
|
+
up_sql: SQL to execute for the forward migration
|
|
206
|
+
down_sql: SQL to execute for the rollback
|
|
207
|
+
|
|
208
|
+
Example:
|
|
209
|
+
>>> class MoveCatalogTables(SQLMigration):
|
|
210
|
+
... version = "003"
|
|
211
|
+
... name = "move_catalog_tables"
|
|
212
|
+
...
|
|
213
|
+
... up_sql = '''
|
|
214
|
+
... ALTER TABLE tenant.tb_datasupplier SET SCHEMA catalog;
|
|
215
|
+
... ALTER TABLE tenant.tb_product SET SCHEMA catalog;
|
|
216
|
+
... '''
|
|
217
|
+
...
|
|
218
|
+
... down_sql = '''
|
|
219
|
+
... ALTER TABLE catalog.tb_datasupplier SET SCHEMA tenant;
|
|
220
|
+
... ALTER TABLE catalog.tb_product SET SCHEMA tenant;
|
|
221
|
+
... '''
|
|
222
|
+
|
|
223
|
+
Example with non-transactional mode:
|
|
224
|
+
>>> class AddConcurrentIndex(SQLMigration):
|
|
225
|
+
... version = "004"
|
|
226
|
+
... name = "add_concurrent_index"
|
|
227
|
+
... transactional = False
|
|
228
|
+
...
|
|
229
|
+
... up_sql = "CREATE INDEX CONCURRENTLY idx_name ON users(name);"
|
|
230
|
+
... down_sql = "DROP INDEX CONCURRENTLY IF EXISTS idx_name;"
|
|
231
|
+
|
|
232
|
+
Note:
|
|
233
|
+
- Multi-statement SQL is supported (separate with semicolons)
|
|
234
|
+
- For complex migrations with conditional logic, use Migration base class
|
|
235
|
+
- Hooks are fully supported with SQLMigration
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
# Subclasses must define these
|
|
239
|
+
up_sql: str
|
|
240
|
+
down_sql: str
|
|
241
|
+
|
|
242
|
+
def __init__(self, connection: psycopg.Connection):
|
|
243
|
+
"""Initialize SQL migration.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
connection: psycopg3 database connection
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
TypeError: If version, name, up_sql, or down_sql not defined
|
|
250
|
+
"""
|
|
251
|
+
super().__init__(connection)
|
|
252
|
+
|
|
253
|
+
# Validate SQL attributes are defined
|
|
254
|
+
if not hasattr(self.__class__, "up_sql") or self.__class__.up_sql is None:
|
|
255
|
+
raise TypeError(f"{self.__class__.__name__} must define an 'up_sql' class attribute")
|
|
256
|
+
if not hasattr(self.__class__, "down_sql") or self.__class__.down_sql is None:
|
|
257
|
+
raise TypeError(f"{self.__class__.__name__} must define a 'down_sql' class attribute")
|
|
258
|
+
|
|
259
|
+
def up(self) -> None:
|
|
260
|
+
"""Apply the migration by executing up_sql."""
|
|
261
|
+
self.execute(self.up_sql)
|
|
262
|
+
|
|
263
|
+
def down(self) -> None:
|
|
264
|
+
"""Rollback the migration by executing down_sql."""
|
|
265
|
+
self.execute(self.down_sql)
|