simple-module-db 0.0.38__tar.gz → 0.0.39__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.38 → simple_module_db-0.0.39}/PKG-INFO +8 -8
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/pyproject.toml +8 -8
- simple_module_db-0.0.39/simple_module_db/independent.py +52 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/listeners.py +5 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/session.py +4 -0
- simple_module_db-0.0.39/simple_module_db/tenant_cleanup.py +22 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/transaction.py +4 -2
- simple_module_db-0.0.39/simple_module_db/user_directory_sql.py +41 -0
- simple_module_db-0.0.39/tests/_db_shape_matrix.py +44 -0
- simple_module_db-0.0.39/tests/test_db_independent_checkpoint.py +72 -0
- simple_module_db-0.0.39/tests/test_db_shape_matrix_dml.py +35 -0
- simple_module_db-0.0.39/tests/test_db_shape_matrix_joins.py +55 -0
- simple_module_db-0.0.39/tests/test_db_shape_matrix_selects.py +58 -0
- simple_module_db-0.0.39/tests/test_db_tenant_cleanup.py +58 -0
- simple_module_db-0.0.39/tests/test_db_user_directory_sql.py +29 -0
- simple_module_db-0.0.39/tests/test_rollback_logging.py +23 -0
- simple_module_db-0.0.38/simple_module_db/independent.py +0 -34
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/.gitignore +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/LICENSE +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/README.md +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/__init__.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/_bulk_audit_records.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/_core_joins.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/_core_walk.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/_migration_render.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/_outer_joins.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/audit.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/audit_sinks.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/base.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/bulk_audit.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/bulk_audit_targets.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/callbacks.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/core_scope.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/deps.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/engine_options.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/extensions.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/flush_guard.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/insert_guard.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/leases.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/migration_portability.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/migrations.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/mixins.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/model_registry.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/pagination.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/provider.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/py.typed +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/query_filter.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/resource_policy.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/search.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/stored_settings.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/tenancy.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/types.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/simple_module_db/writes.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/_audit_models.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/_bulk_audit_support.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/_core_scope_models.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/_models.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/conftest.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_audit.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_audit_per_engine.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_aware_datetime.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_base.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_db_bulk_audit.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_db_bulk_audit_targets.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_db_core_full_setup_join.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_db_core_in_orm_select.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_db_core_join_scope.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_db_core_nested_scope.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_db_engine_options.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_db_extensions.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_db_extensions_pg.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_db_listeners_attach.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_db_logging.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_db_pagination.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_db_pagination_options.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_db_sqlite_savepoint.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_db_stored_settings.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_default_tenant.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_independent_session.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_migration_portability.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_migrations.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_mixins.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_multi_tenancy.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_query_filter_outer_joins.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_query_filter_rel_outer_joins.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_query_filters.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_session.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_soft_delete_shapes.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_tenant_dml.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_tenant_shapes.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_tenant_strict.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/tests/test_transaction.py +0 -0
- {simple_module_db-0.0.38 → simple_module_db-0.0.39}/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.39
|
|
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
|
|
@@ -21,13 +21,13 @@ Classifier: Topic :: Database
|
|
|
21
21
|
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
|
-
Requires-Dist: aiosqlite
|
|
25
|
-
Requires-Dist: alembic
|
|
26
|
-
Requires-Dist: asyncpg
|
|
27
|
-
Requires-Dist: psycopg2-binary
|
|
28
|
-
Requires-Dist: simple-module-core==0.0.
|
|
29
|
-
Requires-Dist: sqlalchemy[asyncio]
|
|
30
|
-
Requires-Dist: sqlmodel
|
|
24
|
+
Requires-Dist: aiosqlite<0.23,>=0.20
|
|
25
|
+
Requires-Dist: alembic<2,>=1.14
|
|
26
|
+
Requires-Dist: asyncpg<0.33,>=0.30
|
|
27
|
+
Requires-Dist: psycopg2-binary<3,>=2.9
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.39
|
|
29
|
+
Requires-Dist: sqlalchemy[asyncio]<3,>=2.0
|
|
30
|
+
Requires-Dist: sqlmodel<0.1,>=0.0.22
|
|
31
31
|
Description-Content-Type: text/markdown
|
|
32
32
|
|
|
33
33
|
# simple_module_db
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_db"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.39"
|
|
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"
|
|
@@ -21,16 +21,16 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"aiosqlite>=0.20",
|
|
25
|
-
"alembic>=1.14",
|
|
26
|
-
"asyncpg>=0.30",
|
|
24
|
+
"aiosqlite>=0.20,<0.23",
|
|
25
|
+
"alembic>=1.14,<2",
|
|
26
|
+
"asyncpg>=0.30,<0.33",
|
|
27
27
|
# psycopg2 is the *sync* Postgres driver Alembic uses for migrations.
|
|
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
|
-
"psycopg2-binary>=2.9",
|
|
31
|
-
"simple_module_core==0.0.
|
|
32
|
-
"sqlalchemy[asyncio]>=2.0",
|
|
33
|
-
"sqlmodel>=0.0.22",
|
|
30
|
+
"psycopg2-binary>=2.9,<3",
|
|
31
|
+
"simple_module_core==0.0.39",
|
|
32
|
+
"sqlalchemy[asyncio]>=2.0,<3",
|
|
33
|
+
"sqlmodel>=0.0.22,<0.1",
|
|
34
34
|
]
|
|
35
35
|
|
|
36
36
|
[project.urls]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Short-lived durable transactions, independent of the request unit of work."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
10
|
+
|
|
11
|
+
from simple_module_db.callbacks import discard_on_commit_callbacks, run_on_commit_callbacks
|
|
12
|
+
from simple_module_db.session import SESSION_FACTORY_KEY, RequestSession
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@asynccontextmanager
|
|
16
|
+
async def independent_session(source: Any) -> AsyncGenerator[RequestSession]:
|
|
17
|
+
"""Commit an independent session on success; roll back and re-raise on error.
|
|
18
|
+
|
|
19
|
+
Accept an app or session factory. An existing session can also supply its
|
|
20
|
+
framework session factory; its pending work is never transferred.
|
|
21
|
+
Request code may use this for a deliberate durable checkpoint, whose writes
|
|
22
|
+
must survive a later request failure. The new session cannot see pending
|
|
23
|
+
request writes. Keep checkpoints short and avoid holding a write transaction
|
|
24
|
+
on the same database while opening one (especially on SQLite).
|
|
25
|
+
|
|
26
|
+
Post-commit callbacks run in registration order after the commit succeeds.
|
|
27
|
+
The session closes on every exit, including cancellation.
|
|
28
|
+
"""
|
|
29
|
+
if isinstance(source, async_sessionmaker):
|
|
30
|
+
factory = source
|
|
31
|
+
elif isinstance(source, AsyncSession):
|
|
32
|
+
try:
|
|
33
|
+
factory = source.info[SESSION_FACTORY_KEY]
|
|
34
|
+
except KeyError:
|
|
35
|
+
raise TypeError(
|
|
36
|
+
"independent_session(session) needs a session opened by the framework's "
|
|
37
|
+
"session factory; pass the app or the async_sessionmaker instead"
|
|
38
|
+
) from None
|
|
39
|
+
else:
|
|
40
|
+
factory = source.state.sm.db.session_factory
|
|
41
|
+
async with factory() as session:
|
|
42
|
+
try:
|
|
43
|
+
yield session
|
|
44
|
+
await session.commit()
|
|
45
|
+
except BaseException as exc:
|
|
46
|
+
discard_on_commit_callbacks(session)
|
|
47
|
+
# Closing the session handles cancellation without masking it with
|
|
48
|
+
# another cancellable await; ordinary failures roll back explicitly.
|
|
49
|
+
if isinstance(exc, Exception):
|
|
50
|
+
await session.rollback()
|
|
51
|
+
raise
|
|
52
|
+
await run_on_commit_callbacks(session)
|
|
@@ -42,6 +42,7 @@ __all__ = [
|
|
|
42
42
|
from simple_module_db.tenancy import TenantIsolationError, current_tenant_id
|
|
43
43
|
|
|
44
44
|
logger = logging.getLogger(__name__)
|
|
45
|
+
_entity_pk_failure_warned = False
|
|
45
46
|
_db_logger = logging.getLogger("simple_module.db")
|
|
46
47
|
|
|
47
48
|
# Set by auth middleware on each request
|
|
@@ -82,6 +83,10 @@ def _entity_pk(obj: object) -> object:
|
|
|
82
83
|
return identity[0]
|
|
83
84
|
return identity
|
|
84
85
|
except Exception:
|
|
86
|
+
global _entity_pk_failure_warned
|
|
87
|
+
if not _entity_pk_failure_warned:
|
|
88
|
+
_entity_pk_failure_warned = True
|
|
89
|
+
logger.warning("Audit identity inspection failed; omitting primary key", exc_info=True)
|
|
85
90
|
return None
|
|
86
91
|
|
|
87
92
|
|
|
@@ -19,6 +19,8 @@ from simple_module_db.engine_options import connect_args as timeout_connect_args
|
|
|
19
19
|
from simple_module_db.provider import DatabaseProvider, detect_provider
|
|
20
20
|
from simple_module_db.tenancy import DEFAULT_TENANT_ID
|
|
21
21
|
|
|
22
|
+
SESSION_FACTORY_KEY = "sm_session_factory"
|
|
23
|
+
|
|
22
24
|
|
|
23
25
|
class RequestSession(AsyncSession):
|
|
24
26
|
"""Async session with hooks for the request-owned transaction."""
|
|
@@ -135,6 +137,8 @@ def init_db(
|
|
|
135
137
|
sync_session_class=scoped_session_class,
|
|
136
138
|
)
|
|
137
139
|
|
|
140
|
+
session_factory.configure(info={SESSION_FACTORY_KEY: session_factory})
|
|
141
|
+
|
|
138
142
|
return DatabaseState(
|
|
139
143
|
engine=engine,
|
|
140
144
|
session_factory=session_factory,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Durable teardown of framework-owned tenant settings, including branding."""
|
|
2
|
+
|
|
3
|
+
from simple_module_core.durable_events import DurableContext
|
|
4
|
+
from simple_module_core.events import EventBus
|
|
5
|
+
from simple_module_core.tenant_events import TenantDeleted
|
|
6
|
+
from sqlmodel import col, delete
|
|
7
|
+
|
|
8
|
+
from simple_module_db.stored_settings import SCOPE_TENANT, Setting
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register_tenant_cleanup(bus: EventBus) -> None:
|
|
12
|
+
"""Register in both the host and workers, even without the settings plugin."""
|
|
13
|
+
bus.subscribe_durable(TenantDeleted, delete_tenant_settings)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def delete_tenant_settings(event: TenantDeleted, ctx: DurableContext) -> None:
|
|
17
|
+
"""Hard-delete only this tenant's explicit overrides; safe on replay."""
|
|
18
|
+
await ctx.db.execute(
|
|
19
|
+
delete(Setting).where(
|
|
20
|
+
col(Setting.scope) == SCOPE_TENANT, col(Setting.scope_id) == event.tenant_id
|
|
21
|
+
)
|
|
22
|
+
)
|
|
@@ -130,11 +130,13 @@ async def finalize_session(session: AsyncSession) -> None:
|
|
|
130
130
|
async def rollback_session(session: AsyncSession) -> None:
|
|
131
131
|
"""Roll ``session`` back and drop its pending work. Idempotent."""
|
|
132
132
|
discard_on_commit_callbacks(session)
|
|
133
|
-
|
|
133
|
+
has_pending = _has_pending(session)
|
|
134
|
+
if session.info.get(_FINALIZED_KEY) and not has_pending:
|
|
134
135
|
return
|
|
135
136
|
_settle(session)
|
|
136
137
|
await session.rollback()
|
|
137
|
-
logger.
|
|
138
|
+
logger.log(
|
|
139
|
+
logging.WARNING if has_pending else logging.DEBUG,
|
|
138
140
|
"db.session.rollback",
|
|
139
141
|
extra={"operation": "rollback", "db_duration_ms": _elapsed_ms(session)},
|
|
140
142
|
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""SQL-backed user directory helpers shared by account providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from simple_module_core.user_directory import DirectoryUser
|
|
8
|
+
from sqlalchemy import or_
|
|
9
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
10
|
+
|
|
11
|
+
from simple_module_db.search import LIKE_ESCAPE_CHAR, like_contains_pattern
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def name_or_email_contains(full_name: Any, email: Any, term: str) -> ColumnElement[bool]:
|
|
15
|
+
"""Match a literal substring in either provider column, ignoring case."""
|
|
16
|
+
pattern = like_contains_pattern(term)
|
|
17
|
+
return or_(
|
|
18
|
+
full_name.ilike(pattern, escape=LIKE_ESCAPE_CHAR),
|
|
19
|
+
email.ilike(pattern, escape=LIKE_ESCAPE_CHAR),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def to_directory_user(
|
|
24
|
+
row: Any,
|
|
25
|
+
key: str,
|
|
26
|
+
*,
|
|
27
|
+
roles: tuple[str, ...] | None = None,
|
|
28
|
+
is_active: bool = True,
|
|
29
|
+
) -> DirectoryUser:
|
|
30
|
+
"""Convert identity columns, preserving unknown versus empty roles.
|
|
31
|
+
|
|
32
|
+
Providers supply account availability and roles when known. ``None`` means
|
|
33
|
+
unknown roles; an empty tuple means no assigned roles.
|
|
34
|
+
"""
|
|
35
|
+
return DirectoryUser(
|
|
36
|
+
id=key,
|
|
37
|
+
email=row.email,
|
|
38
|
+
full_name=row.full_name,
|
|
39
|
+
is_active=is_active,
|
|
40
|
+
roles=roles,
|
|
41
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Combined tenant/soft-delete probes shared by the statement families (#597)."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from _models import _tenant_session
|
|
5
|
+
from simple_module_db.base import create_module_base
|
|
6
|
+
from simple_module_db.mixins import MultiTenantMixin, SoftDeleteMixin
|
|
7
|
+
from simple_module_db.tenancy import tenant_context
|
|
8
|
+
from sqlmodel import Field
|
|
9
|
+
|
|
10
|
+
Base = create_module_base("shape_matrix")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MatrixRow(Base, MultiTenantMixin, SoftDeleteMixin, table=True): # ty: ignore[unsupported-base]
|
|
14
|
+
__tablename__ = "shape_matrix_row"
|
|
15
|
+
id: int = Field(primary_key=True)
|
|
16
|
+
name: str = "original"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MatrixAnchor(Base, table=True): # ty: ignore[unsupported-base]
|
|
20
|
+
__tablename__ = "shape_matrix_anchor"
|
|
21
|
+
id: int = Field(primary_key=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
row, anchor = MatrixRow.__table__, MatrixAnchor.__table__
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def seed(session):
|
|
28
|
+
session.add_all([MatrixAnchor(id=i) for i in range(1, 6)])
|
|
29
|
+
for tenant, ids in (("A", (1, 2)), ("B", (3, 4))):
|
|
30
|
+
with tenant_context(tenant):
|
|
31
|
+
session.add_all([MatrixRow(id=ids[0]), MatrixRow(id=ids[1], is_deleted=True)])
|
|
32
|
+
await session.flush()
|
|
33
|
+
await session.commit()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture(params=[False, True], ids=["tenant-off", "tenant-on"])
|
|
37
|
+
async def matrix_session(request):
|
|
38
|
+
async for session in _tenant_session(Base.metadata, strict=request.param, seed=seed):
|
|
39
|
+
yield session
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def visible_ids(tenant, include_deleted):
|
|
43
|
+
ids = {"A": [1, 2], "B": [3, 4], None: [1, 2, 3, 4]}[tenant]
|
|
44
|
+
return ids if include_deleted else [i for i in ids if i % 2]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Independent checkpoints own their transaction and post-commit callbacks."""
|
|
2
|
+
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from simple_module_db import independent_session
|
|
7
|
+
from sqlalchemy import text
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
async def checkpoint_db(db_state):
|
|
12
|
+
async with db_state.engine.begin() as connection:
|
|
13
|
+
await connection.execute(text("CREATE TABLE checkpoint_row (id integer primary key)"))
|
|
14
|
+
yield db_state
|
|
15
|
+
async with db_state.engine.begin() as connection:
|
|
16
|
+
await connection.execute(text("DROP TABLE checkpoint_row"))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def test_factory_checkpoint_runs_callbacks_after_durable_commit(checkpoint_db):
|
|
20
|
+
observed = []
|
|
21
|
+
|
|
22
|
+
async def observe():
|
|
23
|
+
async with checkpoint_db.session_factory() as reader:
|
|
24
|
+
observed.append((await reader.execute(text("SELECT id FROM checkpoint_row"))).scalar())
|
|
25
|
+
|
|
26
|
+
async with independent_session(checkpoint_db.session_factory) as session:
|
|
27
|
+
await session.execute(text("INSERT INTO checkpoint_row VALUES (1)"))
|
|
28
|
+
session.on_commit(observe)
|
|
29
|
+
session.on_commit(lambda: observed.append("second"))
|
|
30
|
+
assert observed == [1, "second"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def test_app_checkpoint_discards_callbacks_on_failure(checkpoint_db):
|
|
34
|
+
app = SimpleNamespace(state=SimpleNamespace(sm=SimpleNamespace(db=checkpoint_db)))
|
|
35
|
+
observed = []
|
|
36
|
+
with pytest.raises(RuntimeError, match="abort"):
|
|
37
|
+
async with independent_session(app) as session:
|
|
38
|
+
await session.execute(text("INSERT INTO checkpoint_row VALUES (1)"))
|
|
39
|
+
session.on_commit(lambda: observed.append("committed"))
|
|
40
|
+
raise RuntimeError("abort")
|
|
41
|
+
assert observed == []
|
|
42
|
+
async with checkpoint_db.session_factory() as reader:
|
|
43
|
+
assert (await reader.execute(text("SELECT count(*) FROM checkpoint_row"))).scalar() == 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def test_session_checkpoint_leaves_request_work_and_callbacks_uncommitted(tmp_path):
|
|
47
|
+
from simple_module_db import init_db
|
|
48
|
+
from sqlmodel import Field, SQLModel
|
|
49
|
+
|
|
50
|
+
class CheckpointRow(SQLModel, table=True):
|
|
51
|
+
__tablename__ = "db_checkpoint_isolation"
|
|
52
|
+
id: int = Field(primary_key=True)
|
|
53
|
+
|
|
54
|
+
state = init_db(f"sqlite+aiosqlite:///{tmp_path / 'checkpoint.db'}")
|
|
55
|
+
observed = []
|
|
56
|
+
try:
|
|
57
|
+
async with state.engine.begin() as connection:
|
|
58
|
+
await connection.run_sync(CheckpointRow.__table__.create)
|
|
59
|
+
async with state.session_factory() as request:
|
|
60
|
+
request.add(CheckpointRow(id=1))
|
|
61
|
+
request.on_commit(lambda: observed.append("request"))
|
|
62
|
+
async with independent_session(request) as checkpoint:
|
|
63
|
+
assert checkpoint is not request
|
|
64
|
+
checkpoint.add(CheckpointRow(id=2))
|
|
65
|
+
checkpoint.on_commit(lambda: observed.append("checkpoint"))
|
|
66
|
+
assert observed == ["checkpoint"]
|
|
67
|
+
await request.rollback()
|
|
68
|
+
async with state.session_factory() as reader:
|
|
69
|
+
rows = (await reader.execute(text("SELECT id FROM db_checkpoint_isolation"))).scalars()
|
|
70
|
+
assert list(rows) == [2]
|
|
71
|
+
finally:
|
|
72
|
+
await state.engine.dispose()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Bulk DML scopes tenants but intentionally includes trashed targets (#597)."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from _db_shape_matrix import matrix_session, row, visible_ids
|
|
5
|
+
from simple_module_db.query_filter import strict_configured
|
|
6
|
+
from simple_module_db.tenancy import TenantIsolationError, no_tenant, tenant_context
|
|
7
|
+
from sqlalchemy import delete, select, update
|
|
8
|
+
|
|
9
|
+
__all__ = ["matrix_session"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.parametrize("operation", ["update", "delete"])
|
|
13
|
+
@pytest.mark.parametrize("tenant", ["A", "B", None])
|
|
14
|
+
@pytest.mark.parametrize("include_deleted", [False, True])
|
|
15
|
+
async def test_dml_matrix(matrix_session, operation, tenant, include_deleted):
|
|
16
|
+
stmt = update(row).values(name="changed") if operation == "update" else delete(row)
|
|
17
|
+
stmt = stmt.execution_options(include_deleted=include_deleted)
|
|
18
|
+
with no_tenant() if tenant is None else tenant_context(tenant):
|
|
19
|
+
if tenant is None and strict_configured(matrix_session.sync_session):
|
|
20
|
+
with pytest.raises(TenantIsolationError):
|
|
21
|
+
await matrix_session.execute(stmt)
|
|
22
|
+
affected = []
|
|
23
|
+
else:
|
|
24
|
+
result = await matrix_session.execute(stmt)
|
|
25
|
+
affected = visible_ids(tenant, True)
|
|
26
|
+
assert result.rowcount == len(affected)
|
|
27
|
+
# Bypass both filters to prove *which* rows changed, including foreign/deleted rows.
|
|
28
|
+
inspect = select(row.c.id, row.c.name).execution_options(all_tenants=True, include_deleted=True)
|
|
29
|
+
actual = sorted((await matrix_session.execute(inspect)).all())
|
|
30
|
+
expected = [
|
|
31
|
+
(i, "changed" if i in affected else "original")
|
|
32
|
+
for i in range(1, 5)
|
|
33
|
+
if operation == "update" or i not in affected
|
|
34
|
+
]
|
|
35
|
+
assert actual == expected
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Join predicates must filter nullable rows without losing their anchors (#597)."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from _db_shape_matrix import anchor, matrix_session, row, visible_ids
|
|
5
|
+
from simple_module_db._core_joins import scope_joins
|
|
6
|
+
from simple_module_db.query_filter import strict_configured
|
|
7
|
+
from simple_module_db.tenancy import TenantIsolationError, no_tenant, tenant_context
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
|
|
10
|
+
__all__ = ["matrix_session"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.mark.parametrize("outer", [False, True], ids=["join", "outerjoin"])
|
|
14
|
+
@pytest.mark.parametrize("setup", [False, True], ids=["explicit", "setup"])
|
|
15
|
+
@pytest.mark.parametrize("tenant", ["A", "B", None])
|
|
16
|
+
@pytest.mark.parametrize("include_deleted", [False, True])
|
|
17
|
+
async def test_join_matrix(matrix_session, outer, setup, tenant, include_deleted):
|
|
18
|
+
on = row.c.id == anchor.c.id
|
|
19
|
+
stmt = select(anchor.c.id, row.c.id)
|
|
20
|
+
if setup:
|
|
21
|
+
stmt = stmt.select_from(anchor).join(row, on, isouter=outer)
|
|
22
|
+
else:
|
|
23
|
+
stmt = stmt.select_from(anchor.join(row, on, isouter=outer))
|
|
24
|
+
stmt = stmt.execution_options(include_deleted=include_deleted)
|
|
25
|
+
with no_tenant() if tenant is None else tenant_context(tenant):
|
|
26
|
+
if tenant is None and strict_configured(matrix_session.sync_session):
|
|
27
|
+
with pytest.raises(TenantIsolationError):
|
|
28
|
+
await matrix_session.execute(stmt)
|
|
29
|
+
else:
|
|
30
|
+
visible = visible_ids(tenant, include_deleted)
|
|
31
|
+
expected = (
|
|
32
|
+
[(i, i if i in visible else None) for i in range(1, 6)]
|
|
33
|
+
if outer
|
|
34
|
+
else [(i, i) for i in visible]
|
|
35
|
+
)
|
|
36
|
+
assert sorted((await matrix_session.execute(stmt)).all()) == expected
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.parametrize("outer", [False, True])
|
|
40
|
+
def test_scope_joins_places_nullable_predicate_in_on(outer):
|
|
41
|
+
stmt = select(anchor.c.id, row.c.id).select_from(
|
|
42
|
+
anchor.join(row, anchor.c.id == row.c.id, isouter=outer)
|
|
43
|
+
)
|
|
44
|
+
scoped = scope_joins(
|
|
45
|
+
stmt, lambda table: [table.c.is_deleted.is_(False)] if table is row else []
|
|
46
|
+
)
|
|
47
|
+
assert scoped.covered == [row]
|
|
48
|
+
assert bool(scoped.where) is (not outer)
|
|
49
|
+
rewritten = stmt._generate()
|
|
50
|
+
rewritten._from_obj = scoped.from_obj
|
|
51
|
+
rewritten = rewritten.where(*scoped.where)
|
|
52
|
+
sql = str(rewritten)
|
|
53
|
+
before_where, _, where = sql.partition("WHERE")
|
|
54
|
+
assert ("is_deleted" in before_where) is outer
|
|
55
|
+
assert ("is_deleted" in where) is (not outer)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Select/walk matrix, using the same fixture on SQLite and Postgres (#597)."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from _db_shape_matrix import MatrixRow, anchor, matrix_session, row, visible_ids
|
|
5
|
+
from simple_module_db._core_walk import references_plain
|
|
6
|
+
from simple_module_db.query_filter import strict_configured
|
|
7
|
+
from simple_module_db.tenancy import TenantIsolationError, no_tenant, tenant_context
|
|
8
|
+
from sqlalchemy import delete, exists, select, union, update
|
|
9
|
+
|
|
10
|
+
__all__ = ["matrix_session"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def statement(shape):
|
|
14
|
+
if shape == "orm":
|
|
15
|
+
return select(MatrixRow.id)
|
|
16
|
+
if shape == "core":
|
|
17
|
+
return select(row.c.id)
|
|
18
|
+
if shape == "select_from":
|
|
19
|
+
return select(anchor.c.id).select_from(anchor, row).where(anchor.c.id == row.c.id)
|
|
20
|
+
if shape == "subquery":
|
|
21
|
+
return select(select(row.c.id).subquery().c.id)
|
|
22
|
+
if shape == "cte":
|
|
23
|
+
return select(select(row.c.id).cte("scoped").c.id)
|
|
24
|
+
if shape == "union":
|
|
25
|
+
return union(select(row.c.id).where(row.c.id <= 2), select(row.c.id).where(row.c.id > 2))
|
|
26
|
+
if shape == "exists":
|
|
27
|
+
return select(anchor.c.id).where(exists().where(row.c.id == anchor.c.id))
|
|
28
|
+
raise AssertionError(shape)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
SHAPES = ["orm", "core", "select_from", "subquery", "cte", "union", "exists"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.parametrize("shape", SHAPES)
|
|
35
|
+
@pytest.mark.parametrize("tenant", ["A", "B", None], ids=["own", "other", "unbound"])
|
|
36
|
+
@pytest.mark.parametrize("include_deleted", [False, True])
|
|
37
|
+
async def test_select_matrix(matrix_session, shape, tenant, include_deleted):
|
|
38
|
+
stmt = statement(shape).execution_options(include_deleted=include_deleted)
|
|
39
|
+
with no_tenant() if tenant is None else tenant_context(tenant):
|
|
40
|
+
if tenant is None and strict_configured(matrix_session.sync_session):
|
|
41
|
+
with pytest.raises(TenantIsolationError):
|
|
42
|
+
await matrix_session.execute(stmt)
|
|
43
|
+
else:
|
|
44
|
+
ids = (await matrix_session.execute(stmt)).scalars().all()
|
|
45
|
+
assert sorted(ids) == visible_ids(tenant, include_deleted)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize("shape", [*SHAPES, "update", "delete"])
|
|
49
|
+
def test_walk_detects_only_plain_scoped_references(shape):
|
|
50
|
+
stmt = (
|
|
51
|
+
update(row).values(name="changed")
|
|
52
|
+
if shape == "update"
|
|
53
|
+
else delete(row)
|
|
54
|
+
if shape == "delete"
|
|
55
|
+
else statement(shape)
|
|
56
|
+
)
|
|
57
|
+
assert references_plain(stmt, {row.name}) is (shape != "orm")
|
|
58
|
+
assert references_plain(stmt, {"unrelated_table"}) is False
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Framework tenant settings teardown works independently of the UI plugin."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import AsyncMock, patch
|
|
4
|
+
|
|
5
|
+
from simple_module_core.durable_events import DurableContext
|
|
6
|
+
from simple_module_core.events import EventBus
|
|
7
|
+
from simple_module_core.tenant_events import TenantDeleted
|
|
8
|
+
from simple_module_db.stored_settings import SCOPE_SYSTEM, SCOPE_TENANT, SCOPE_USER, Setting
|
|
9
|
+
from simple_module_db.tenant_cleanup import register_tenant_cleanup
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
|
11
|
+
from sqlmodel import select
|
|
12
|
+
|
|
13
|
+
_TENANT = "deleted-tenant"
|
|
14
|
+
_OTHER = "retained-tenant"
|
|
15
|
+
_KEY = "branding.logo_file_id"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def test_settings_cleanup_is_durable_scoped_and_idempotent():
|
|
19
|
+
bus = EventBus()
|
|
20
|
+
register_tenant_cleanup(bus)
|
|
21
|
+
names = bus.durable.handler_names(TenantDeleted)
|
|
22
|
+
assert len(names) == 1
|
|
23
|
+
from simple_module_core.durable_events import event_key
|
|
24
|
+
|
|
25
|
+
subscription = bus.durable.lookup(event_key(TenantDeleted), names[0])
|
|
26
|
+
assert subscription is not None
|
|
27
|
+
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
28
|
+
try:
|
|
29
|
+
async with engine.begin() as conn:
|
|
30
|
+
await conn.run_sync(Setting.metadata.create_all, tables=[Setting.__table__])
|
|
31
|
+
async with AsyncSession(engine) as session:
|
|
32
|
+
session.add_all(
|
|
33
|
+
[
|
|
34
|
+
Setting(scope=scope, scope_id=scope_id, key=_KEY, value="file-id")
|
|
35
|
+
for scope, scope_id in (
|
|
36
|
+
(SCOPE_TENANT, _TENANT),
|
|
37
|
+
(SCOPE_TENANT, _OTHER),
|
|
38
|
+
(SCOPE_USER, _TENANT),
|
|
39
|
+
(SCOPE_SYSTEM, ""),
|
|
40
|
+
)
|
|
41
|
+
]
|
|
42
|
+
)
|
|
43
|
+
await session.commit()
|
|
44
|
+
event = TenantDeleted(tenant_id=_TENANT, slug=_TENANT, name=_TENANT)
|
|
45
|
+
with patch.object(session, "commit", new_callable=AsyncMock) as commit:
|
|
46
|
+
await subscription.handler(event, DurableContext(db=session, attempt=1))
|
|
47
|
+
await subscription.handler(event, DurableContext(db=session, attempt=2))
|
|
48
|
+
commit.assert_not_awaited()
|
|
49
|
+
remaining = (await session.execute(select(Setting))).scalars().all()
|
|
50
|
+
assert {(row.scope, row.scope_id) for row in remaining} == {
|
|
51
|
+
(SCOPE_TENANT, _OTHER),
|
|
52
|
+
(SCOPE_USER, _TENANT),
|
|
53
|
+
(SCOPE_SYSTEM, ""),
|
|
54
|
+
}
|
|
55
|
+
await session.rollback()
|
|
56
|
+
assert len((await session.execute(select(Setting))).scalars().all()) == 4
|
|
57
|
+
finally:
|
|
58
|
+
await engine.dispose()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Shared directory predicates treat SQL LIKE metacharacters as literal text."""
|
|
2
|
+
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from simple_module_db.user_directory_sql import name_or_email_contains, to_directory_user
|
|
7
|
+
from sqlalchemy import literal, select
|
|
8
|
+
from sqlmodel import Session, create_engine
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize("term", ["%", "_", "\\"])
|
|
12
|
+
def test_name_or_email_contains_escapes_literal_metacharacters(term: str):
|
|
13
|
+
with Session(create_engine("sqlite://")) as db:
|
|
14
|
+
for full_name, email in [(f"Ada{term}Lovelace", "other"), (None, f"ada{term}@test")]:
|
|
15
|
+
predicate = name_or_email_contains(literal(full_name), literal(email), term)
|
|
16
|
+
assert db.exec(select(predicate)).scalar_one()
|
|
17
|
+
predicate = name_or_email_contains(literal("Ada Lovelace"), literal("ada@test"), term)
|
|
18
|
+
assert not db.exec(select(predicate)).scalar_one()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.mark.parametrize("roles", [None, (), ("editor",)])
|
|
22
|
+
def test_conversion_preserves_known_and_unknown_roles(roles: tuple[str, ...] | None):
|
|
23
|
+
row = SimpleNamespace(email="ada@test", full_name=None)
|
|
24
|
+
user = to_directory_user(row, "caller-id", roles=roles, is_active=False)
|
|
25
|
+
assert user.id == "caller-id"
|
|
26
|
+
assert user.email == row.email
|
|
27
|
+
assert user.full_name is None
|
|
28
|
+
assert user.roles == roles
|
|
29
|
+
assert not user.is_active
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Only rollbacks that discard writes warrant a warning."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from _models import _TxnThing
|
|
7
|
+
from simple_module_db import mark_written
|
|
8
|
+
from simple_module_db.transaction import rollback_session
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.parametrize("pending", ["none", "new", "written"])
|
|
13
|
+
async def test_rollback_log_level_matches_pending_work(caplog, pending):
|
|
14
|
+
async with AsyncSession() as session:
|
|
15
|
+
if pending == "new":
|
|
16
|
+
session.add(_TxnThing(name="discarded"))
|
|
17
|
+
elif pending == "written":
|
|
18
|
+
mark_written(session)
|
|
19
|
+
with caplog.at_level(logging.DEBUG, logger="simple_module.db"):
|
|
20
|
+
await rollback_session(session)
|
|
21
|
+
records = [record for record in caplog.records if record.message == "db.session.rollback"]
|
|
22
|
+
assert len(records) == 1
|
|
23
|
+
assert records[0].levelno == (logging.DEBUG if pending == "none" else logging.WARNING)
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
"""A session opened outside the request, with the commit convention in one place."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from collections.abc import AsyncGenerator
|
|
6
|
-
from contextlib import asynccontextmanager
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
@asynccontextmanager
|
|
13
|
-
async def independent_session(app: Any) -> AsyncGenerator[AsyncSession]:
|
|
14
|
-
"""Open a session from ``app``'s factory, outside any request.
|
|
15
|
-
|
|
16
|
-
Commits when the block exits cleanly and rolls back (then re-raises) when it
|
|
17
|
-
raises, so startup hooks, background cleanups and out-of-band writes do not
|
|
18
|
-
each hand-roll the pair. The session is closed either way.
|
|
19
|
-
|
|
20
|
-
Request code must not use this: the request session (``get_db``) commits in
|
|
21
|
-
``CommitBeforeResponseMiddleware``, and a second session cannot see its
|
|
22
|
-
uncommitted rows. Code that must commit mid-block, never commit, or run on a
|
|
23
|
-
different engine keeps using ``session_factory()`` directly.
|
|
24
|
-
"""
|
|
25
|
-
async with app.state.sm.db.session_factory() as session:
|
|
26
|
-
try:
|
|
27
|
-
yield session
|
|
28
|
-
await session.commit()
|
|
29
|
-
except Exception:
|
|
30
|
-
await session.rollback()
|
|
31
|
-
raise
|
|
32
|
-
# Cancellation and other BaseExceptions skip the explicit rollback: awaiting
|
|
33
|
-
# it there could be cancelled too and mask the original error, and closing
|
|
34
|
-
# the session discards the open transaction anyway.
|
|
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.38 → simple_module_db-0.0.39}/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
|
{simple_module_db-0.0.38 → simple_module_db-0.0.39}/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
|