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,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
+ ]
@@ -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)