simple-module-db 0.0.39__tar.gz → 0.0.41__tar.gz
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.
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/PKG-INFO +2 -2
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/pyproject.toml +2 -2
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/audit.py +35 -28
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/callbacks.py +25 -3
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/query_filter.py +3 -1
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/user_directory_sql.py +2 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_audit.py +36 -29
- simple_module_db-0.0.41/tests/test_db_connection_policy.py +67 -0
- simple_module_db-0.0.41/tests/test_db_savepoint_callbacks.py +56 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/.gitignore +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/LICENSE +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/README.md +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/__init__.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/_bulk_audit_records.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/_core_joins.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/_core_walk.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/_migration_render.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/_outer_joins.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/audit_sinks.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/base.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/bulk_audit.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/bulk_audit_targets.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/core_scope.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/deps.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/engine_options.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/extensions.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/flush_guard.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/independent.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/insert_guard.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/leases.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/listeners.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/migration_portability.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/migrations.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/mixins.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/model_registry.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/pagination.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/provider.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/py.typed +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/resource_policy.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/search.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/session.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/stored_settings.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/tenancy.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/tenant_cleanup.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/transaction.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/types.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/writes.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/_audit_models.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/_bulk_audit_support.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/_core_scope_models.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/_db_shape_matrix.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/_models.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/conftest.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_audit_per_engine.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_aware_datetime.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_base.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_bulk_audit.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_bulk_audit_targets.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_core_full_setup_join.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_core_in_orm_select.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_core_join_scope.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_core_nested_scope.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_engine_options.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_extensions.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_extensions_pg.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_independent_checkpoint.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_listeners_attach.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_logging.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_pagination.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_pagination_options.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_shape_matrix_dml.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_shape_matrix_joins.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_shape_matrix_selects.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_sqlite_savepoint.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_stored_settings.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_tenant_cleanup.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_user_directory_sql.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_default_tenant.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_independent_session.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_migration_portability.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_migrations.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_mixins.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_multi_tenancy.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_query_filter_outer_joins.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_query_filter_rel_outer_joins.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_query_filters.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_rollback_logging.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_session.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_soft_delete_shapes.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_tenant_dml.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_tenant_shapes.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_tenant_strict.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_transaction.py +0 -0
- {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_writes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_db
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.41
|
|
4
4
|
Summary: Per-module SQLModel Base, async session, standard mixins (Audit, SoftDelete, MultiTenant, Versioned) for simple_module
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -25,7 +25,7 @@ Requires-Dist: aiosqlite<0.23,>=0.20
|
|
|
25
25
|
Requires-Dist: alembic<2,>=1.14
|
|
26
26
|
Requires-Dist: asyncpg<0.33,>=0.30
|
|
27
27
|
Requires-Dist: psycopg2-binary<3,>=2.9
|
|
28
|
-
Requires-Dist: simple-module-core==0.0.
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.41
|
|
29
29
|
Requires-Dist: sqlalchemy[asyncio]<3,>=2.0
|
|
30
30
|
Requires-Dist: sqlmodel<0.1,>=0.0.22
|
|
31
31
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_db"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.41"
|
|
4
4
|
description = "Per-module SQLModel Base, async session, standard mixins (Audit, SoftDelete, MultiTenant, Versioned) for simple_module"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -28,7 +28,7 @@ dependencies = [
|
|
|
28
28
|
# The async app code goes through asyncpg; migration env.py rewrites the
|
|
29
29
|
# URL to +psycopg2 because Alembic's online migrations are sync-only.
|
|
30
30
|
"psycopg2-binary>=2.9,<3",
|
|
31
|
-
"simple_module_core==0.0.
|
|
31
|
+
"simple_module_core==0.0.41",
|
|
32
32
|
"sqlalchemy[asyncio]>=2.0,<3",
|
|
33
33
|
"sqlmodel>=0.0.22,<0.1",
|
|
34
34
|
]
|
|
@@ -116,6 +116,23 @@ def _serialize(value: Any) -> Any:
|
|
|
116
116
|
return str(value)
|
|
117
117
|
|
|
118
118
|
|
|
119
|
+
def _column_diffs(
|
|
120
|
+
obj: object, inspector: Any, excl: frozenset[str], pk_cols: set[str]
|
|
121
|
+
) -> list[dict[str, Any]]:
|
|
122
|
+
"""Old/new diffs for the changed, non-excluded, non-PK columns of *obj*."""
|
|
123
|
+
changes: list[dict[str, Any]] = []
|
|
124
|
+
for col_name in _column_names(obj):
|
|
125
|
+
if col_name in excl or col_name in pk_cols:
|
|
126
|
+
continue
|
|
127
|
+
hist = inspector.attrs[col_name].history
|
|
128
|
+
if not hist.has_changes():
|
|
129
|
+
continue
|
|
130
|
+
old_val = hist.deleted[0] if hist.deleted else None
|
|
131
|
+
new_val = hist.added[0] if hist.added else None
|
|
132
|
+
changes.append({"field": col_name, "old": _serialize(old_val), "new": _serialize(new_val)})
|
|
133
|
+
return changes
|
|
134
|
+
|
|
135
|
+
|
|
119
136
|
# ---------------------------------------------------------------------------
|
|
120
137
|
# Two-phase capture
|
|
121
138
|
# ---------------------------------------------------------------------------
|
|
@@ -155,21 +172,6 @@ def snapshot_changes(
|
|
|
155
172
|
if _is_excluded(obj):
|
|
156
173
|
continue
|
|
157
174
|
|
|
158
|
-
# Soft-delete listener moves objects from session.deleted → session.new
|
|
159
|
-
# with is_deleted=True. Classify them as "soft_deleted", not "created".
|
|
160
|
-
if isinstance(obj, SoftDeleteMixin) and getattr(obj, "is_deleted", False):
|
|
161
|
-
pending.append(
|
|
162
|
-
_PendingChange(
|
|
163
|
-
obj_ref=obj,
|
|
164
|
-
entity_type=type(obj).__name__,
|
|
165
|
-
action="soft_deleted",
|
|
166
|
-
changes=[],
|
|
167
|
-
user_id=user_id,
|
|
168
|
-
correlation_id=correlation_id,
|
|
169
|
-
)
|
|
170
|
-
)
|
|
171
|
-
continue
|
|
172
|
-
|
|
173
175
|
excl = _get_excluded(obj)
|
|
174
176
|
pk_cols = _get_pk_cols(obj)
|
|
175
177
|
changes: list[dict[str, Any]] = []
|
|
@@ -194,21 +196,26 @@ def snapshot_changes(
|
|
|
194
196
|
continue
|
|
195
197
|
if _is_excluded(obj):
|
|
196
198
|
continue
|
|
197
|
-
excl = _get_excluded(obj)
|
|
198
|
-
pk_cols = _get_pk_cols(obj)
|
|
199
199
|
inspector = sa_inspect(obj)
|
|
200
|
-
changes =
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
200
|
+
changes = _column_diffs(obj, inspector, _get_excluded(obj), _get_pk_cols(obj))
|
|
201
|
+
# The soft-delete listener re-adds persistent objects as dirty. Edits made in
|
|
202
|
+
# the same flush stay on the soft_deleted record.
|
|
203
|
+
if (
|
|
204
|
+
isinstance(obj, SoftDeleteMixin)
|
|
205
|
+
and obj.is_deleted
|
|
206
|
+
and inspector.attrs.is_deleted.history.has_changes()
|
|
207
|
+
):
|
|
208
|
+
pending.append(
|
|
209
|
+
_PendingChange(
|
|
210
|
+
obj_ref=obj,
|
|
211
|
+
entity_type=type(obj).__name__,
|
|
212
|
+
action="soft_deleted",
|
|
213
|
+
changes=changes,
|
|
214
|
+
user_id=user_id,
|
|
215
|
+
correlation_id=correlation_id,
|
|
216
|
+
)
|
|
211
217
|
)
|
|
218
|
+
continue
|
|
212
219
|
if changes:
|
|
213
220
|
pending.append(
|
|
214
221
|
_PendingChange(
|
|
@@ -6,9 +6,12 @@ import inspect
|
|
|
6
6
|
import logging
|
|
7
7
|
from collections.abc import Awaitable, Callable
|
|
8
8
|
|
|
9
|
+
from sqlalchemy import event
|
|
9
10
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
from sqlalchemy.orm import Session, SessionTransaction
|
|
10
12
|
|
|
11
13
|
type OnCommitCallback = Callable[[], Awaitable[None] | None]
|
|
14
|
+
type _CallbackEntry = tuple[set[SessionTransaction], OnCommitCallback]
|
|
12
15
|
|
|
13
16
|
_CALLBACKS_KEY = "sm_db_on_commit_callbacks"
|
|
14
17
|
logger = logging.getLogger("simple_module.db")
|
|
@@ -18,8 +21,27 @@ def register_on_commit(session: AsyncSession, callback: OnCommitCallback) -> Non
|
|
|
18
21
|
"""Queue ``callback`` for this session's next successful finalization."""
|
|
19
22
|
if not callable(callback):
|
|
20
23
|
raise TypeError("on_commit callback must be callable")
|
|
24
|
+
sync_session = session.sync_session
|
|
25
|
+
if not event.contains(sync_session, "after_soft_rollback", _discard_rolled_back_callbacks):
|
|
26
|
+
event.listen(sync_session, "after_soft_rollback", _discard_rolled_back_callbacks)
|
|
27
|
+
savepoints: set[SessionTransaction] = set()
|
|
28
|
+
transaction = sync_session.get_nested_transaction()
|
|
29
|
+
# Keep ancestors too: a released child still belongs to its parent's rollback.
|
|
30
|
+
while transaction is not None:
|
|
31
|
+
if transaction.nested:
|
|
32
|
+
savepoints.add(transaction)
|
|
33
|
+
transaction = transaction.parent
|
|
21
34
|
callbacks = session.info.setdefault(_CALLBACKS_KEY, [])
|
|
22
|
-
callbacks.append(callback)
|
|
35
|
+
callbacks.append((savepoints, callback))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _discard_rolled_back_callbacks(session: Session, transaction: SessionTransaction) -> None:
|
|
39
|
+
if transaction.parent is None:
|
|
40
|
+
session.info.pop(_CALLBACKS_KEY, None)
|
|
41
|
+
elif transaction.nested and _CALLBACKS_KEY in session.info:
|
|
42
|
+
session.info[_CALLBACKS_KEY] = [
|
|
43
|
+
entry for entry in session.info[_CALLBACKS_KEY] if transaction not in entry[0]
|
|
44
|
+
]
|
|
23
45
|
|
|
24
46
|
|
|
25
47
|
def discard_on_commit_callbacks(session: AsyncSession) -> None:
|
|
@@ -34,8 +56,8 @@ async def run_on_commit_callbacks(session: AsyncSession) -> None:
|
|
|
34
56
|
not turn a successful database mutation into a misleading failed response.
|
|
35
57
|
Remaining callbacks still run.
|
|
36
58
|
"""
|
|
37
|
-
callbacks: list[
|
|
38
|
-
for callback in callbacks:
|
|
59
|
+
callbacks: list[_CallbackEntry] = session.info.pop(_CALLBACKS_KEY, [])
|
|
60
|
+
for _, callback in callbacks:
|
|
39
61
|
try:
|
|
40
62
|
result = callback()
|
|
41
63
|
if inspect.isawaitable(result):
|
|
@@ -16,7 +16,7 @@ from collections.abc import Callable
|
|
|
16
16
|
from typing import Any
|
|
17
17
|
from weakref import WeakKeyDictionary
|
|
18
18
|
|
|
19
|
-
from sqlalchemy.engine import Engine
|
|
19
|
+
from sqlalchemy.engine import Connection, Engine
|
|
20
20
|
from sqlalchemy.orm import ORMExecuteState, Session, with_loader_criteria
|
|
21
21
|
|
|
22
22
|
from simple_module_db import model_registry as registry
|
|
@@ -73,6 +73,8 @@ def bind_engine_policy(engine: Engine, policy: Any) -> None:
|
|
|
73
73
|
def engine_policy(session: Session) -> Any:
|
|
74
74
|
"""The policy bound to this session's engine, or ``None``."""
|
|
75
75
|
bind = session.bind
|
|
76
|
+
if isinstance(bind, Connection):
|
|
77
|
+
bind = bind.engine
|
|
76
78
|
return _engine_policy.get(bind) if isinstance(bind, Engine) else None
|
|
77
79
|
|
|
78
80
|
|
|
@@ -26,6 +26,7 @@ def to_directory_user(
|
|
|
26
26
|
*,
|
|
27
27
|
roles: tuple[str, ...] | None = None,
|
|
28
28
|
is_active: bool = True,
|
|
29
|
+
email_verified: bool | None = None,
|
|
29
30
|
) -> DirectoryUser:
|
|
30
31
|
"""Convert identity columns, preserving unknown versus empty roles.
|
|
31
32
|
|
|
@@ -38,4 +39,5 @@ def to_directory_user(
|
|
|
38
39
|
full_name=row.full_name,
|
|
39
40
|
is_active=is_active,
|
|
40
41
|
roles=roles,
|
|
42
|
+
email_verified=email_verified,
|
|
41
43
|
)
|
|
@@ -18,21 +18,28 @@ from _audit_models import (
|
|
|
18
18
|
PartialExcludeModel,
|
|
19
19
|
SoftDeleteItem,
|
|
20
20
|
)
|
|
21
|
+
from simple_module_core.correlation import correlation_id
|
|
21
22
|
from simple_module_db.audit import (
|
|
22
23
|
AuditRecord,
|
|
23
24
|
collect_audit_records,
|
|
24
25
|
finalize_records,
|
|
25
26
|
snapshot_changes,
|
|
26
27
|
)
|
|
27
|
-
from simple_module_db.listeners import register_listeners
|
|
28
|
+
from simple_module_db.listeners import current_user_id, register_listeners
|
|
28
29
|
from simple_module_db.session import init_db
|
|
29
30
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
@pytest.fixture
|
|
33
|
-
|
|
34
|
+
def audit_records() -> list[AuditRecord]:
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
async def audit_session(audit_records: list[AuditRecord]) -> AsyncGenerator[AsyncSession, None]:
|
|
34
40
|
"""Session backed by in-memory SQLite with listeners registered."""
|
|
35
41
|
db_state = init_db("sqlite+aiosqlite:///:memory:")
|
|
42
|
+
db_state.audit_callback = lambda session, records: audit_records.extend(records)
|
|
36
43
|
try:
|
|
37
44
|
register_listeners(db_state)
|
|
38
45
|
async with db_state.engine.begin() as conn:
|
|
@@ -191,48 +198,48 @@ async def test_collect_records_for_update(audit_session: AsyncSession):
|
|
|
191
198
|
# ── collect_audit_records: soft-deleted entity ────────────────────────────
|
|
192
199
|
|
|
193
200
|
|
|
201
|
+
@pytest.mark.parametrize("change_title", [False, True])
|
|
194
202
|
async def test_soft_deleted_entity_produces_soft_deleted_record(
|
|
195
203
|
audit_session: AsyncSession,
|
|
204
|
+
audit_records: list[AuditRecord],
|
|
205
|
+
change_title: bool,
|
|
196
206
|
):
|
|
197
|
-
"""
|
|
198
|
-
should produce action='soft_deleted', not action='created'."""
|
|
207
|
+
"""Deleting a persistent row emits one soft_deleted record, even with edits."""
|
|
199
208
|
item = SoftDeleteItem(title="doomed")
|
|
200
209
|
audit_session.add(item)
|
|
201
210
|
await audit_session.commit()
|
|
202
211
|
await audit_session.refresh(item)
|
|
203
212
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
make_transient(item)
|
|
216
|
-
session.add(item)
|
|
217
|
-
|
|
218
|
-
await audit_session.run_sync(_simulate_soft_delete)
|
|
219
|
-
|
|
220
|
-
records: list[AuditRecord] = []
|
|
221
|
-
|
|
222
|
-
def _collect(session):
|
|
223
|
-
records.extend(collect_audit_records(session, user_id="admin", correlation_id="req-sd"))
|
|
224
|
-
|
|
225
|
-
await audit_session.run_sync(_collect)
|
|
213
|
+
audit_records.clear()
|
|
214
|
+
if change_title:
|
|
215
|
+
item.title = "edited before deletion"
|
|
216
|
+
user_token = current_user_id.set("admin")
|
|
217
|
+
correlation_token = correlation_id.set("req-sd")
|
|
218
|
+
try:
|
|
219
|
+
await audit_session.delete(item)
|
|
220
|
+
await audit_session.flush()
|
|
221
|
+
finally:
|
|
222
|
+
current_user_id.reset(user_token)
|
|
223
|
+
correlation_id.reset(correlation_token)
|
|
226
224
|
|
|
227
|
-
assert
|
|
228
|
-
rec =
|
|
225
|
+
assert item.is_deleted is True
|
|
226
|
+
[rec] = audit_records
|
|
229
227
|
assert rec.action == "soft_deleted"
|
|
230
228
|
assert rec.entity_type == "SoftDeleteItem"
|
|
231
229
|
assert rec.entity_id == str(item.id)
|
|
232
|
-
|
|
230
|
+
if change_title:
|
|
231
|
+
assert rec.changes == [{"field": "title", "old": "doomed", "new": "edited before deletion"}]
|
|
232
|
+
else:
|
|
233
|
+
assert rec.changes == []
|
|
233
234
|
assert rec.user_id == "admin"
|
|
234
235
|
assert rec.correlation_id == "req-sd"
|
|
235
236
|
|
|
237
|
+
audit_records.clear()
|
|
238
|
+
item.title = "edited after deletion"
|
|
239
|
+
await audit_session.flush()
|
|
240
|
+
[rec] = audit_records
|
|
241
|
+
assert rec.action == "updated"
|
|
242
|
+
|
|
236
243
|
|
|
237
244
|
# ── collect_audit_records: soft-delete fields excluded from diffs ─────────
|
|
238
245
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Connection-bound sessions retain their engine's tenancy and audit policy (#630)."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from _audit_models import AuditTestItem
|
|
7
|
+
from _models import _TenantItem
|
|
8
|
+
from simple_module_db.audit import AuditRecord
|
|
9
|
+
from simple_module_db.listeners import attach_session_listeners
|
|
10
|
+
from simple_module_db.query_filter import EngineTenancy, bind_engine_policy
|
|
11
|
+
from simple_module_db.tenancy import TenantIsolationError
|
|
12
|
+
from sqlalchemy import create_engine, select
|
|
13
|
+
from sqlalchemy.orm import Session
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def connection_session() -> Iterator[tuple[Session, list[AuditRecord]]]:
|
|
18
|
+
engine = create_engine("sqlite://")
|
|
19
|
+
records: list[AuditRecord] = []
|
|
20
|
+
|
|
21
|
+
def capture(session: Session, changes: list[AuditRecord]) -> None:
|
|
22
|
+
records.extend(changes)
|
|
23
|
+
|
|
24
|
+
class ConnectionSession(Session):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
attach_session_listeners(ConnectionSession)
|
|
28
|
+
bind_engine_policy(engine, EngineTenancy(tenant_strict=True, audit_callback=capture))
|
|
29
|
+
try:
|
|
30
|
+
_TenantItem.__table__.create(engine)
|
|
31
|
+
AuditTestItem.__table__.create(engine)
|
|
32
|
+
with engine.begin() as connection:
|
|
33
|
+
connection.execute(
|
|
34
|
+
_TenantItem.__table__.insert(),
|
|
35
|
+
[{"name": "row-a", "tenant_id": "a"}, {"name": "row-b", "tenant_id": "b"}],
|
|
36
|
+
)
|
|
37
|
+
with engine.connect() as connection, ConnectionSession(bind=connection) as session:
|
|
38
|
+
yield session, records
|
|
39
|
+
finally:
|
|
40
|
+
engine.dispose()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_connection_bound_select_without_tenant_fails_closed(connection_session):
|
|
44
|
+
session, _records = connection_session
|
|
45
|
+
with pytest.raises(TenantIsolationError):
|
|
46
|
+
session.execute(select(_TenantItem)).all()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_connection_bound_flush_without_tenant_fails_closed(connection_session):
|
|
50
|
+
session, _records = connection_session
|
|
51
|
+
session.add(_TenantItem(name="missing-tenant"))
|
|
52
|
+
with pytest.raises(TenantIsolationError):
|
|
53
|
+
session.flush()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_connection_bound_flush_delivers_audit_records(connection_session):
|
|
57
|
+
session, records = connection_session
|
|
58
|
+
item = AuditTestItem(name="audited")
|
|
59
|
+
session.add(item)
|
|
60
|
+
session.flush()
|
|
61
|
+
|
|
62
|
+
[record] = records
|
|
63
|
+
assert (record.entity_type, record.action, record.entity_id) == (
|
|
64
|
+
"AuditTestItem",
|
|
65
|
+
"created",
|
|
66
|
+
str(item.id),
|
|
67
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Post-commit side effects must follow savepoint rollback boundaries (#631)."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from simple_module_db import independent_session
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def test_rolled_back_savepoint_discards_only_its_callbacks(db_state):
|
|
8
|
+
observed = []
|
|
9
|
+
async with independent_session(db_state.session_factory) as session:
|
|
10
|
+
session.on_commit(lambda: observed.append("before"))
|
|
11
|
+
with pytest.raises(RuntimeError, match="abort savepoint"):
|
|
12
|
+
async with session.begin_nested():
|
|
13
|
+
session.on_commit(lambda: observed.append("rolled back"))
|
|
14
|
+
raise RuntimeError("abort savepoint")
|
|
15
|
+
async with session.begin_nested():
|
|
16
|
+
session.on_commit(lambda: observed.append("released"))
|
|
17
|
+
session.on_commit(lambda: observed.append("after"))
|
|
18
|
+
assert observed == []
|
|
19
|
+
assert observed == ["before", "released", "after"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def test_parent_savepoint_rollback_discards_released_child_callbacks(db_state):
|
|
23
|
+
observed = []
|
|
24
|
+
async with independent_session(db_state.session_factory) as session:
|
|
25
|
+
session.on_commit(lambda: observed.append("outer"))
|
|
26
|
+
with pytest.raises(RuntimeError, match="abort parent"):
|
|
27
|
+
async with session.begin_nested():
|
|
28
|
+
session.on_commit(lambda: observed.append("parent"))
|
|
29
|
+
async with session.begin_nested():
|
|
30
|
+
session.on_commit(lambda: observed.append("child"))
|
|
31
|
+
raise RuntimeError("abort parent")
|
|
32
|
+
assert observed == ["outer"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def test_inner_rollback_preserves_parent_callbacks(db_state):
|
|
36
|
+
observed = []
|
|
37
|
+
async with independent_session(db_state.session_factory) as session:
|
|
38
|
+
async with session.begin_nested():
|
|
39
|
+
session.on_commit(lambda: observed.append("parent"))
|
|
40
|
+
with pytest.raises(RuntimeError, match="abort child"):
|
|
41
|
+
async with session.begin_nested():
|
|
42
|
+
session.on_commit(lambda: observed.append("child"))
|
|
43
|
+
raise RuntimeError("abort child")
|
|
44
|
+
assert observed == []
|
|
45
|
+
assert observed == ["parent"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def test_outer_rollback_discards_released_savepoint_callbacks(db_state):
|
|
49
|
+
observed = []
|
|
50
|
+
async with independent_session(db_state.session_factory) as session:
|
|
51
|
+
session.on_commit(lambda: observed.append("old outer"))
|
|
52
|
+
async with session.begin_nested():
|
|
53
|
+
session.on_commit(lambda: observed.append("released"))
|
|
54
|
+
await session.rollback()
|
|
55
|
+
session.on_commit(lambda: observed.append("new transaction"))
|
|
56
|
+
assert observed == ["new transaction"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/migration_portability.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_query_filter_rel_outer_joins.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|