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.
Files changed (94) hide show
  1. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/PKG-INFO +2 -2
  2. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/pyproject.toml +2 -2
  3. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/audit.py +35 -28
  4. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/callbacks.py +25 -3
  5. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/query_filter.py +3 -1
  6. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/user_directory_sql.py +2 -0
  7. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_audit.py +36 -29
  8. simple_module_db-0.0.41/tests/test_db_connection_policy.py +67 -0
  9. simple_module_db-0.0.41/tests/test_db_savepoint_callbacks.py +56 -0
  10. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/.gitignore +0 -0
  11. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/LICENSE +0 -0
  12. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/README.md +0 -0
  13. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/__init__.py +0 -0
  14. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/_bulk_audit_records.py +0 -0
  15. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/_core_joins.py +0 -0
  16. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/_core_walk.py +0 -0
  17. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/_migration_render.py +0 -0
  18. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/_outer_joins.py +0 -0
  19. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/audit_sinks.py +0 -0
  20. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/base.py +0 -0
  21. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/bulk_audit.py +0 -0
  22. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/bulk_audit_targets.py +0 -0
  23. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/core_scope.py +0 -0
  24. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/deps.py +0 -0
  25. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/engine_options.py +0 -0
  26. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/extensions.py +0 -0
  27. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/flush_guard.py +0 -0
  28. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/independent.py +0 -0
  29. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/insert_guard.py +0 -0
  30. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/leases.py +0 -0
  31. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/listeners.py +0 -0
  32. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/migration_portability.py +0 -0
  33. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/migrations.py +0 -0
  34. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/mixins.py +0 -0
  35. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/model_registry.py +0 -0
  36. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/pagination.py +0 -0
  37. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/provider.py +0 -0
  38. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/py.typed +0 -0
  39. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/resource_policy.py +0 -0
  40. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/search.py +0 -0
  41. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/session.py +0 -0
  42. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/stored_settings.py +0 -0
  43. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/tenancy.py +0 -0
  44. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/tenant_cleanup.py +0 -0
  45. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/transaction.py +0 -0
  46. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/types.py +0 -0
  47. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/simple_module_db/writes.py +0 -0
  48. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/_audit_models.py +0 -0
  49. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/_bulk_audit_support.py +0 -0
  50. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/_core_scope_models.py +0 -0
  51. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/_db_shape_matrix.py +0 -0
  52. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/_models.py +0 -0
  53. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/conftest.py +0 -0
  54. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_audit_per_engine.py +0 -0
  55. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_aware_datetime.py +0 -0
  56. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_base.py +0 -0
  57. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_bulk_audit.py +0 -0
  58. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_bulk_audit_targets.py +0 -0
  59. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_core_full_setup_join.py +0 -0
  60. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_core_in_orm_select.py +0 -0
  61. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_core_join_scope.py +0 -0
  62. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_core_nested_scope.py +0 -0
  63. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_engine_options.py +0 -0
  64. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_extensions.py +0 -0
  65. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_extensions_pg.py +0 -0
  66. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_independent_checkpoint.py +0 -0
  67. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_listeners_attach.py +0 -0
  68. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_logging.py +0 -0
  69. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_pagination.py +0 -0
  70. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_pagination_options.py +0 -0
  71. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_shape_matrix_dml.py +0 -0
  72. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_shape_matrix_joins.py +0 -0
  73. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_shape_matrix_selects.py +0 -0
  74. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_sqlite_savepoint.py +0 -0
  75. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_stored_settings.py +0 -0
  76. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_tenant_cleanup.py +0 -0
  77. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_db_user_directory_sql.py +0 -0
  78. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_default_tenant.py +0 -0
  79. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_independent_session.py +0 -0
  80. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_migration_portability.py +0 -0
  81. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_migrations.py +0 -0
  82. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_mixins.py +0 -0
  83. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_multi_tenancy.py +0 -0
  84. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_query_filter_outer_joins.py +0 -0
  85. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_query_filter_rel_outer_joins.py +0 -0
  86. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_query_filters.py +0 -0
  87. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_rollback_logging.py +0 -0
  88. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_session.py +0 -0
  89. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_soft_delete_shapes.py +0 -0
  90. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_tenant_dml.py +0 -0
  91. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_tenant_shapes.py +0 -0
  92. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_tenant_strict.py +0 -0
  93. {simple_module_db-0.0.39 → simple_module_db-0.0.41}/tests/test_transaction.py +0 -0
  94. {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.39
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.39
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.39"
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.39",
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
- for col_name in _column_names(obj):
202
- if col_name in excl or col_name in pk_cols:
203
- continue
204
- hist = inspector.attrs[col_name].history
205
- if not hist.has_changes():
206
- continue
207
- old_val = hist.deleted[0] if hist.deleted else None
208
- new_val = hist.added[0] if hist.added else None
209
- changes.append(
210
- {"field": col_name, "old": _serialize(old_val), "new": _serialize(new_val)}
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[OnCommitCallback] = session.info.pop(_CALLBACKS_KEY, [])
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
- async def audit_session() -> AsyncGenerator[AsyncSession, None]:
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
- """A SoftDeleteMixin object re-added to session.new with is_deleted=True
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
- # Simulate what the soft-delete listener does: expunge from deleted,
205
- # set is_deleted=True, re-add to session.new. We use make_transient
206
- # so SQLAlchemy treats the re-added object as new (matching the
207
- # session state that triggers Bug 1).
208
- await audit_session.delete(item)
209
-
210
- def _simulate_soft_delete(session):
211
- from sqlalchemy.orm import make_transient
212
-
213
- session.expunge(item)
214
- item.is_deleted = True
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 len(records) == 1
228
- rec = records[0]
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
- assert rec.changes == []
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"]