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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Pytest integration for confiture migration testing.
|
|
2
|
+
|
|
3
|
+
This module provides both the pytest plugin and the migration_test decorator.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
# Enable the plugin in conftest.py
|
|
7
|
+
pytest_plugins = ["confiture.testing.pytest"]
|
|
8
|
+
|
|
9
|
+
# Use the decorator for migration-specific tests
|
|
10
|
+
from confiture.testing.pytest import migration_test
|
|
11
|
+
|
|
12
|
+
@migration_test("003_move_catalog_tables")
|
|
13
|
+
class TestMigration003:
|
|
14
|
+
def test_up_preserves_data(self, confiture_sandbox, migration):
|
|
15
|
+
migration.up()
|
|
16
|
+
assert confiture_sandbox.validator.constraints_valid()
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# Re-export from the plugin module
|
|
20
|
+
from confiture.testing.pytest_plugin import (
|
|
21
|
+
confiture_db_url,
|
|
22
|
+
confiture_migrations_dir,
|
|
23
|
+
confiture_sandbox,
|
|
24
|
+
confiture_snapshotter,
|
|
25
|
+
confiture_validator,
|
|
26
|
+
migration_test,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
# Decorator
|
|
31
|
+
"migration_test",
|
|
32
|
+
# Fixtures (for documentation, actual fixtures registered via plugin)
|
|
33
|
+
"confiture_db_url",
|
|
34
|
+
"confiture_migrations_dir",
|
|
35
|
+
"confiture_sandbox",
|
|
36
|
+
"confiture_validator",
|
|
37
|
+
"confiture_snapshotter",
|
|
38
|
+
]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Pytest plugin for confiture migration testing.
|
|
2
|
+
|
|
3
|
+
This module provides pytest fixtures for migration testing with automatic
|
|
4
|
+
transaction rollback.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
# In conftest.py
|
|
8
|
+
pytest_plugins = ["confiture.testing.pytest"]
|
|
9
|
+
|
|
10
|
+
# Or enable automatically when confiture is installed (via entry point)
|
|
11
|
+
|
|
12
|
+
Available fixtures:
|
|
13
|
+
- confiture_db_url: Database URL (override to customize)
|
|
14
|
+
- confiture_migrations_dir: Migrations directory (override to customize)
|
|
15
|
+
- confiture_sandbox: MigrationSandbox with automatic rollback
|
|
16
|
+
- confiture_validator: DataValidator from sandbox
|
|
17
|
+
- confiture_snapshotter: SchemaSnapshotter from sandbox
|
|
18
|
+
|
|
19
|
+
Example test file:
|
|
20
|
+
>>> def test_migration(confiture_sandbox):
|
|
21
|
+
... migration = confiture_sandbox.load("003_move_tables")
|
|
22
|
+
... migration.up()
|
|
23
|
+
... assert confiture_sandbox.validator.constraints_valid()
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
from collections.abc import Generator
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import TYPE_CHECKING
|
|
32
|
+
|
|
33
|
+
import pytest
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from confiture.testing.fixtures.data_validator import DataValidator
|
|
37
|
+
from confiture.testing.fixtures.schema_snapshotter import SchemaSnapshotter
|
|
38
|
+
from confiture.testing.sandbox import MigrationSandbox
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture
|
|
42
|
+
def confiture_db_url() -> str:
|
|
43
|
+
"""Provide database URL for migration testing.
|
|
44
|
+
|
|
45
|
+
Override this fixture to use a different database:
|
|
46
|
+
|
|
47
|
+
@pytest.fixture
|
|
48
|
+
def confiture_db_url():
|
|
49
|
+
return "postgresql://localhost/my_test_db"
|
|
50
|
+
|
|
51
|
+
Environment variable CONFITURE_TEST_DB_URL takes precedence.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Database connection URL
|
|
55
|
+
"""
|
|
56
|
+
return os.getenv(
|
|
57
|
+
"CONFITURE_TEST_DB_URL",
|
|
58
|
+
"postgresql://localhost/confiture_test",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.fixture
|
|
63
|
+
def confiture_migrations_dir() -> Path:
|
|
64
|
+
"""Provide migrations directory for testing.
|
|
65
|
+
|
|
66
|
+
Override this fixture to use a custom migrations directory:
|
|
67
|
+
|
|
68
|
+
@pytest.fixture
|
|
69
|
+
def confiture_migrations_dir():
|
|
70
|
+
return Path("custom/migrations")
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Path to migrations directory
|
|
74
|
+
"""
|
|
75
|
+
return Path("db/migrations")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.fixture
|
|
79
|
+
def confiture_sandbox(
|
|
80
|
+
confiture_db_url: str,
|
|
81
|
+
confiture_migrations_dir: Path,
|
|
82
|
+
) -> Generator[MigrationSandbox, None, None]:
|
|
83
|
+
"""Provide a migration sandbox with automatic rollback.
|
|
84
|
+
|
|
85
|
+
Creates a MigrationSandbox that automatically rolls back all changes
|
|
86
|
+
at the end of the test.
|
|
87
|
+
|
|
88
|
+
Usage:
|
|
89
|
+
def test_migration(confiture_sandbox):
|
|
90
|
+
migration = confiture_sandbox.load("003_move_tables")
|
|
91
|
+
baseline = confiture_sandbox.capture_baseline()
|
|
92
|
+
migration.up()
|
|
93
|
+
confiture_sandbox.assert_no_data_loss(baseline)
|
|
94
|
+
|
|
95
|
+
Yields:
|
|
96
|
+
MigrationSandbox instance
|
|
97
|
+
|
|
98
|
+
Note:
|
|
99
|
+
This fixture requires a running PostgreSQL database.
|
|
100
|
+
Tests will be skipped if the database is not available.
|
|
101
|
+
"""
|
|
102
|
+
from confiture.testing.sandbox import MigrationSandbox
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
with MigrationSandbox(
|
|
106
|
+
db_url=confiture_db_url,
|
|
107
|
+
migrations_dir=confiture_migrations_dir,
|
|
108
|
+
) as sandbox:
|
|
109
|
+
yield sandbox
|
|
110
|
+
except Exception as e:
|
|
111
|
+
pytest.skip(f"Database not available: {e}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.fixture
|
|
115
|
+
def confiture_validator(confiture_sandbox: MigrationSandbox) -> DataValidator:
|
|
116
|
+
"""Provide data validator from sandbox.
|
|
117
|
+
|
|
118
|
+
Convenience fixture that extracts the validator from the sandbox.
|
|
119
|
+
|
|
120
|
+
Usage:
|
|
121
|
+
def test_constraints(confiture_sandbox, confiture_validator):
|
|
122
|
+
confiture_sandbox.load("003").up()
|
|
123
|
+
assert confiture_validator.constraints_valid()
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
DataValidator instance
|
|
127
|
+
"""
|
|
128
|
+
return confiture_sandbox.validator
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@pytest.fixture
|
|
132
|
+
def confiture_snapshotter(confiture_sandbox: MigrationSandbox) -> SchemaSnapshotter:
|
|
133
|
+
"""Provide schema snapshotter from sandbox.
|
|
134
|
+
|
|
135
|
+
Convenience fixture that extracts the snapshotter from the sandbox.
|
|
136
|
+
|
|
137
|
+
Usage:
|
|
138
|
+
def test_schema_changes(confiture_sandbox, confiture_snapshotter):
|
|
139
|
+
before = confiture_snapshotter.capture()
|
|
140
|
+
confiture_sandbox.load("003").up()
|
|
141
|
+
after = confiture_snapshotter.capture()
|
|
142
|
+
changes = confiture_snapshotter.compare(before, after)
|
|
143
|
+
assert "products" in changes["tables_added"]
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
SchemaSnapshotter instance
|
|
147
|
+
"""
|
|
148
|
+
return confiture_sandbox.snapshotter
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def migration_test(migration_name: str):
|
|
152
|
+
"""Decorator to inject migration fixture for specific migration.
|
|
153
|
+
|
|
154
|
+
Use this decorator on test classes to automatically inject a `migration`
|
|
155
|
+
fixture that loads the specified migration.
|
|
156
|
+
|
|
157
|
+
Usage:
|
|
158
|
+
from confiture.testing.pytest import migration_test
|
|
159
|
+
|
|
160
|
+
@migration_test("003_move_catalog_tables")
|
|
161
|
+
class TestMigration003:
|
|
162
|
+
def test_up_preserves_data(self, confiture_sandbox, migration):
|
|
163
|
+
baseline = confiture_sandbox.capture_baseline()
|
|
164
|
+
migration.up()
|
|
165
|
+
confiture_sandbox.assert_no_data_loss(baseline)
|
|
166
|
+
|
|
167
|
+
def test_down_reverses_changes(self, confiture_sandbox, migration):
|
|
168
|
+
migration.up()
|
|
169
|
+
migration.down()
|
|
170
|
+
# Assert schema is back to original state
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
migration_name: Name of the migration to load (e.g., "003_move_catalog_tables")
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Class decorator that adds migration fixture
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def decorator(cls):
|
|
180
|
+
# Create a migration fixture for this specific test class
|
|
181
|
+
@pytest.fixture
|
|
182
|
+
def migration(self, confiture_sandbox: MigrationSandbox): # noqa: ARG001
|
|
183
|
+
"""Migration fixture injected by @migration_test decorator."""
|
|
184
|
+
return confiture_sandbox.load(migration_name)
|
|
185
|
+
|
|
186
|
+
# Add the fixture to the class
|
|
187
|
+
cls.migration = migration
|
|
188
|
+
return cls
|
|
189
|
+
|
|
190
|
+
return decorator
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Migration testing sandbox.
|
|
2
|
+
|
|
3
|
+
Provides an isolated environment for testing migrations with automatic
|
|
4
|
+
rollback and pre-loaded testing utilities.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
>>> from confiture.testing import MigrationSandbox
|
|
8
|
+
>>>
|
|
9
|
+
>>> with MigrationSandbox(db_url) as sandbox:
|
|
10
|
+
... migration = sandbox.load("003_move_tables")
|
|
11
|
+
... migration.up()
|
|
12
|
+
... assert sandbox.validator.constraints_valid()
|
|
13
|
+
...
|
|
14
|
+
>>> # Auto-rollback at end of context
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
import psycopg
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from confiture.models.migration import Migration
|
|
26
|
+
from confiture.testing.fixtures.data_validator import DataBaseline, DataValidator
|
|
27
|
+
from confiture.testing.fixtures.schema_snapshotter import SchemaSnapshotter
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MigrationSandbox:
|
|
31
|
+
"""Test migrations in isolation with automatic rollback.
|
|
32
|
+
|
|
33
|
+
A context manager that provides a sandboxed environment for migration testing:
|
|
34
|
+
- Automatic transaction management (rollback on exit)
|
|
35
|
+
- Pre-loaded testing utilities (validator, snapshotter)
|
|
36
|
+
- Migration loading via load_migration()
|
|
37
|
+
|
|
38
|
+
The sandbox can work in two modes:
|
|
39
|
+
1. **URL mode**: Creates a new connection, uses transaction with rollback
|
|
40
|
+
2. **Connection mode**: Uses existing connection, creates a savepoint for rollback
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
connection: The database connection being used
|
|
44
|
+
validator: DataValidator for data integrity checks
|
|
45
|
+
snapshotter: SchemaSnapshotter for schema comparison
|
|
46
|
+
migrations_dir: Directory where migrations are located
|
|
47
|
+
|
|
48
|
+
Example with URL:
|
|
49
|
+
>>> with MigrationSandbox("postgresql://localhost/test_db") as sandbox:
|
|
50
|
+
... migration = sandbox.load("003_move_tables")
|
|
51
|
+
... baseline = sandbox.capture_baseline()
|
|
52
|
+
... migration.up()
|
|
53
|
+
... assert sandbox.validator.no_data_loss(baseline)
|
|
54
|
+
|
|
55
|
+
Example with existing connection:
|
|
56
|
+
>>> with MigrationSandbox(connection=existing_conn) as sandbox:
|
|
57
|
+
... # Uses savepoint instead of full transaction
|
|
58
|
+
... migration = sandbox.load("003")
|
|
59
|
+
... migration.up()
|
|
60
|
+
|
|
61
|
+
Example with custom migrations directory:
|
|
62
|
+
>>> with MigrationSandbox(db_url, migrations_dir=Path("/custom/migrations")) as sandbox:
|
|
63
|
+
... migration = sandbox.load("001_initial")
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
db_url: str | None = None,
|
|
69
|
+
*,
|
|
70
|
+
connection: psycopg.Connection | None = None,
|
|
71
|
+
migrations_dir: Path | None = None,
|
|
72
|
+
):
|
|
73
|
+
"""Initialize the migration sandbox.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
db_url: Database connection URL. Creates a new connection.
|
|
77
|
+
connection: Existing database connection. Uses savepoint for rollback.
|
|
78
|
+
migrations_dir: Custom migrations directory. Defaults to "db/migrations".
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ValueError: If neither db_url nor connection is provided,
|
|
82
|
+
or if both are provided.
|
|
83
|
+
|
|
84
|
+
Note:
|
|
85
|
+
When using an existing connection, the sandbox creates a savepoint
|
|
86
|
+
that will be rolled back on exit. This preserves the connection's
|
|
87
|
+
transaction state.
|
|
88
|
+
"""
|
|
89
|
+
if db_url is None and connection is None:
|
|
90
|
+
raise ValueError("Either 'db_url' or 'connection' must be provided")
|
|
91
|
+
if db_url is not None and connection is not None:
|
|
92
|
+
raise ValueError("Provide either 'db_url' or 'connection', not both")
|
|
93
|
+
|
|
94
|
+
self._db_url = db_url
|
|
95
|
+
self._external_connection = connection
|
|
96
|
+
self._owns_connection = db_url is not None
|
|
97
|
+
self._savepoint_name = "confiture_sandbox"
|
|
98
|
+
self._active = False
|
|
99
|
+
|
|
100
|
+
self.migrations_dir = migrations_dir or Path("db/migrations")
|
|
101
|
+
self.connection: psycopg.Connection = None # type: ignore[assignment]
|
|
102
|
+
self._validator: DataValidator | None = None
|
|
103
|
+
self._snapshotter: SchemaSnapshotter | None = None
|
|
104
|
+
|
|
105
|
+
def __enter__(self) -> MigrationSandbox:
|
|
106
|
+
"""Enter the sandbox context.
|
|
107
|
+
|
|
108
|
+
Creates connection (if URL provided) and starts transaction/savepoint.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Self for use in with statement
|
|
112
|
+
"""
|
|
113
|
+
if self._owns_connection:
|
|
114
|
+
# Create new connection with autocommit=False for transaction control
|
|
115
|
+
assert self._db_url is not None
|
|
116
|
+
self.connection = psycopg.connect(self._db_url, autocommit=False)
|
|
117
|
+
else:
|
|
118
|
+
# Use provided connection, create savepoint
|
|
119
|
+
assert self._external_connection is not None
|
|
120
|
+
self.connection = self._external_connection
|
|
121
|
+
with self.connection.cursor() as cursor:
|
|
122
|
+
cursor.execute(f"SAVEPOINT {self._savepoint_name}")
|
|
123
|
+
|
|
124
|
+
self._active = True
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
def __exit__(
|
|
128
|
+
self,
|
|
129
|
+
exc_type: type[BaseException] | None,
|
|
130
|
+
exc_val: BaseException | None,
|
|
131
|
+
exc_tb: object,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Exit the sandbox context.
|
|
134
|
+
|
|
135
|
+
Rolls back all changes and closes connection (if we created it).
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
exc_type: Exception type if an error occurred
|
|
139
|
+
exc_val: Exception value
|
|
140
|
+
exc_tb: Exception traceback
|
|
141
|
+
"""
|
|
142
|
+
if not self._active:
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
if self._owns_connection:
|
|
147
|
+
# Rollback entire transaction
|
|
148
|
+
self.connection.rollback()
|
|
149
|
+
self.connection.close()
|
|
150
|
+
else:
|
|
151
|
+
# Rollback to savepoint
|
|
152
|
+
with self.connection.cursor() as cursor:
|
|
153
|
+
cursor.execute(f"ROLLBACK TO SAVEPOINT {self._savepoint_name}")
|
|
154
|
+
cursor.execute(f"RELEASE SAVEPOINT {self._savepoint_name}")
|
|
155
|
+
finally:
|
|
156
|
+
self._active = False
|
|
157
|
+
|
|
158
|
+
def load(self, name: str) -> Migration:
|
|
159
|
+
"""Load and instantiate a migration.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
name: Migration name without .py extension (e.g., "003_move_tables")
|
|
163
|
+
or version prefix (e.g., "003")
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Instantiated Migration object ready to execute
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
MigrationNotFoundError: If migration not found
|
|
170
|
+
MigrationLoadError: If migration cannot be loaded
|
|
171
|
+
|
|
172
|
+
Example:
|
|
173
|
+
>>> migration = sandbox.load("003_move_catalog_tables")
|
|
174
|
+
>>> migration.up()
|
|
175
|
+
|
|
176
|
+
>>> # Also works with version prefix
|
|
177
|
+
>>> migration = sandbox.load("003")
|
|
178
|
+
"""
|
|
179
|
+
from confiture.testing.loader import load_migration
|
|
180
|
+
|
|
181
|
+
# Determine if name is a version prefix or full name
|
|
182
|
+
if "_" in name:
|
|
183
|
+
# Full name provided
|
|
184
|
+
migration_class = load_migration(name, migrations_dir=self.migrations_dir)
|
|
185
|
+
else:
|
|
186
|
+
# Version prefix provided
|
|
187
|
+
migration_class = load_migration(version=name, migrations_dir=self.migrations_dir)
|
|
188
|
+
|
|
189
|
+
return migration_class(connection=self.connection)
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def validator(self) -> DataValidator:
|
|
193
|
+
"""Get data validator for this sandbox.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
DataValidator configured with the sandbox's connection
|
|
197
|
+
|
|
198
|
+
Example:
|
|
199
|
+
>>> assert sandbox.validator.constraints_valid()
|
|
200
|
+
"""
|
|
201
|
+
if self._validator is None:
|
|
202
|
+
from confiture.testing.fixtures.data_validator import DataValidator
|
|
203
|
+
|
|
204
|
+
self._validator = DataValidator(self.connection)
|
|
205
|
+
return self._validator
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def snapshotter(self) -> SchemaSnapshotter:
|
|
209
|
+
"""Get schema snapshotter for this sandbox.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
SchemaSnapshotter configured with the sandbox's connection
|
|
213
|
+
|
|
214
|
+
Example:
|
|
215
|
+
>>> before = sandbox.snapshotter.capture()
|
|
216
|
+
>>> migration.up()
|
|
217
|
+
>>> after = sandbox.snapshotter.capture()
|
|
218
|
+
>>> changes = sandbox.snapshotter.compare(before, after)
|
|
219
|
+
"""
|
|
220
|
+
if self._snapshotter is None:
|
|
221
|
+
from confiture.testing.fixtures.schema_snapshotter import SchemaSnapshotter
|
|
222
|
+
|
|
223
|
+
self._snapshotter = SchemaSnapshotter(self.connection)
|
|
224
|
+
return self._snapshotter
|
|
225
|
+
|
|
226
|
+
def capture_baseline(self) -> DataBaseline:
|
|
227
|
+
"""Capture data baseline before migration.
|
|
228
|
+
|
|
229
|
+
Convenience method that wraps validator.capture_baseline().
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
DataBaseline snapshot for later comparison
|
|
233
|
+
|
|
234
|
+
Example:
|
|
235
|
+
>>> baseline = sandbox.capture_baseline()
|
|
236
|
+
>>> migration.up()
|
|
237
|
+
>>> sandbox.assert_no_data_loss(baseline)
|
|
238
|
+
"""
|
|
239
|
+
return self.validator.capture_baseline()
|
|
240
|
+
|
|
241
|
+
def assert_no_data_loss(self, baseline: DataBaseline) -> None:
|
|
242
|
+
"""Assert no data was lost since baseline.
|
|
243
|
+
|
|
244
|
+
Convenience method that wraps validator.no_data_loss() with assertion.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
baseline: Baseline captured before migration
|
|
248
|
+
|
|
249
|
+
Raises:
|
|
250
|
+
AssertionError: If data loss is detected
|
|
251
|
+
|
|
252
|
+
Example:
|
|
253
|
+
>>> baseline = sandbox.capture_baseline()
|
|
254
|
+
>>> migration.up()
|
|
255
|
+
>>> sandbox.assert_no_data_loss(baseline) # Raises if data lost
|
|
256
|
+
"""
|
|
257
|
+
if not self.validator.no_data_loss(baseline):
|
|
258
|
+
raise AssertionError("Data loss detected after migration")
|
|
259
|
+
|
|
260
|
+
def assert_constraints_valid(self) -> None:
|
|
261
|
+
"""Assert all database constraints are valid.
|
|
262
|
+
|
|
263
|
+
Convenience method that wraps validator.constraints_valid() with assertion.
|
|
264
|
+
|
|
265
|
+
Raises:
|
|
266
|
+
AssertionError: If constraint violations are detected
|
|
267
|
+
|
|
268
|
+
Example:
|
|
269
|
+
>>> migration.up()
|
|
270
|
+
>>> sandbox.assert_constraints_valid() # Raises if violations found
|
|
271
|
+
"""
|
|
272
|
+
if not self.validator.constraints_valid():
|
|
273
|
+
raise AssertionError("Constraint violations detected after migration")
|
|
274
|
+
|
|
275
|
+
def execute(self, sql: str) -> None:
|
|
276
|
+
"""Execute raw SQL in the sandbox.
|
|
277
|
+
|
|
278
|
+
Useful for setting up test data or making assertions.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
sql: SQL to execute
|
|
282
|
+
|
|
283
|
+
Example:
|
|
284
|
+
>>> sandbox.execute("INSERT INTO users (name) VALUES ('test')")
|
|
285
|
+
"""
|
|
286
|
+
with self.connection.cursor() as cursor:
|
|
287
|
+
cursor.execute(sql)
|
|
288
|
+
|
|
289
|
+
def query(self, sql: str) -> list[tuple]:
|
|
290
|
+
"""Execute a query and return results.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
sql: SQL query to execute
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
List of result rows as tuples
|
|
297
|
+
|
|
298
|
+
Example:
|
|
299
|
+
>>> rows = sandbox.query("SELECT COUNT(*) FROM users")
|
|
300
|
+
>>> assert rows[0][0] > 0
|
|
301
|
+
"""
|
|
302
|
+
with self.connection.cursor() as cursor:
|
|
303
|
+
cursor.execute(sql)
|
|
304
|
+
return cursor.fetchall()
|
|
File without changes
|