simple-module-db 0.0.34__tar.gz → 0.0.36__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.34 → simple_module_db-0.0.36}/PKG-INFO +2 -2
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/pyproject.toml +2 -2
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/simple_module_db/__init__.py +27 -1
- simple_module_db-0.0.36/simple_module_db/_outer_joins.py +97 -0
- simple_module_db-0.0.36/simple_module_db/flush_guard.py +92 -0
- simple_module_db-0.0.36/simple_module_db/insert_guard.py +136 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/simple_module_db/listeners.py +62 -93
- simple_module_db-0.0.36/simple_module_db/migration_portability.py +164 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/simple_module_db/migrations.py +30 -29
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/simple_module_db/mixins.py +3 -3
- simple_module_db-0.0.36/simple_module_db/model_registry.py +56 -0
- simple_module_db-0.0.36/simple_module_db/query_filter.py +250 -0
- simple_module_db-0.0.36/simple_module_db/session.py +180 -0
- simple_module_db-0.0.36/simple_module_db/subquery_guard.py +49 -0
- simple_module_db-0.0.36/simple_module_db/tenancy.py +201 -0
- simple_module_db-0.0.36/simple_module_db/writes.py +107 -0
- simple_module_db-0.0.36/tests/conftest.py +54 -0
- simple_module_db-0.0.36/tests/test_db_listeners_attach.py +83 -0
- simple_module_db-0.0.36/tests/test_db_sqlite_savepoint.py +75 -0
- simple_module_db-0.0.36/tests/test_default_tenant.py +164 -0
- simple_module_db-0.0.36/tests/test_migration_portability.py +164 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/tests/test_multi_tenancy.py +5 -6
- simple_module_db-0.0.36/tests/test_query_filter_outer_joins.py +250 -0
- simple_module_db-0.0.36/tests/test_query_filter_rel_outer_joins.py +165 -0
- simple_module_db-0.0.36/tests/test_query_filters.py +150 -0
- simple_module_db-0.0.36/tests/test_session.py +138 -0
- simple_module_db-0.0.36/tests/test_soft_delete_shapes.py +89 -0
- simple_module_db-0.0.36/tests/test_tenant_dml.py +165 -0
- simple_module_db-0.0.36/tests/test_tenant_shapes.py +121 -0
- simple_module_db-0.0.36/tests/test_tenant_strict.py +124 -0
- simple_module_db-0.0.36/tests/test_writes.py +203 -0
- simple_module_db-0.0.34/simple_module_db/session.py +0 -93
- simple_module_db-0.0.34/tests/conftest.py +0 -25
- simple_module_db-0.0.34/tests/test_session.py +0 -66
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/.gitignore +0 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/LICENSE +0 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/README.md +0 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/simple_module_db/audit.py +0 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/simple_module_db/base.py +0 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/simple_module_db/callbacks.py +0 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/simple_module_db/deps.py +0 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/simple_module_db/provider.py +0 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/simple_module_db/py.typed +0 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/simple_module_db/search.py +0 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/simple_module_db/transaction.py +0 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/tests/_audit_models.py +0 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/tests/_models.py +0 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/tests/test_audit.py +0 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/tests/test_base.py +0 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/tests/test_db_logging.py +0 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/tests/test_migrations.py +0 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/tests/test_mixins.py +0 -0
- {simple_module_db-0.0.34 → simple_module_db-0.0.36}/tests/test_transaction.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.36
|
|
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.20
|
|
|
25
25
|
Requires-Dist: alembic>=1.14
|
|
26
26
|
Requires-Dist: asyncpg>=0.30
|
|
27
27
|
Requires-Dist: psycopg2-binary>=2.9
|
|
28
|
-
Requires-Dist: simple-module-core==0.0.
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.36
|
|
29
29
|
Requires-Dist: sqlalchemy[asyncio]>=2.0
|
|
30
30
|
Requires-Dist: sqlmodel>=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.36"
|
|
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",
|
|
31
|
-
"simple_module_core==0.0.
|
|
31
|
+
"simple_module_core==0.0.36",
|
|
32
32
|
"sqlalchemy[asyncio]>=2.0",
|
|
33
33
|
"sqlmodel>=0.0.22",
|
|
34
34
|
]
|
|
@@ -4,7 +4,7 @@ from simple_module_db.audit import AuditRecord
|
|
|
4
4
|
from simple_module_db.base import create_module_base
|
|
5
5
|
from simple_module_db.callbacks import OnCommitCallback
|
|
6
6
|
from simple_module_db.deps import get_db
|
|
7
|
-
from simple_module_db.
|
|
7
|
+
from simple_module_db.migration_portability import drop_enums_if_postgres
|
|
8
8
|
from simple_module_db.migrations import (
|
|
9
9
|
build_module_metadata,
|
|
10
10
|
make_include_object,
|
|
@@ -15,31 +15,57 @@ from simple_module_db.mixins import AuditMixin, MultiTenantMixin, SoftDeleteMixi
|
|
|
15
15
|
from simple_module_db.provider import DatabaseProvider, detect_provider
|
|
16
16
|
from simple_module_db.search import LIKE_ESCAPE_CHAR, like_contains_pattern, like_prefix_pattern
|
|
17
17
|
from simple_module_db.session import DatabaseState, RequestSession, init_db
|
|
18
|
+
from simple_module_db.tenancy import (
|
|
19
|
+
ALL_TENANTS_OPTION,
|
|
20
|
+
DEFAULT_TENANT_ID,
|
|
21
|
+
PLATFORM_TENANT_ID,
|
|
22
|
+
TENANT_ID_PATTERN,
|
|
23
|
+
MissingTenantError,
|
|
24
|
+
TenantIsolationError,
|
|
25
|
+
all_tenants,
|
|
26
|
+
bind_current_tenant,
|
|
27
|
+
current_tenant_id,
|
|
28
|
+
is_valid_tenant_id,
|
|
29
|
+
tenant_context,
|
|
30
|
+
)
|
|
18
31
|
from simple_module_db.transaction import CommitBeforeResponseMiddleware, finalize_session
|
|
32
|
+
from simple_module_db.writes import hard_delete, mark_written
|
|
19
33
|
|
|
20
34
|
__all__ = [
|
|
35
|
+
"ALL_TENANTS_OPTION",
|
|
36
|
+
"DEFAULT_TENANT_ID",
|
|
21
37
|
"LIKE_ESCAPE_CHAR",
|
|
38
|
+
"PLATFORM_TENANT_ID",
|
|
39
|
+
"TENANT_ID_PATTERN",
|
|
22
40
|
"AuditMixin",
|
|
23
41
|
"AuditRecord",
|
|
24
42
|
"CommitBeforeResponseMiddleware",
|
|
25
43
|
"DatabaseProvider",
|
|
26
44
|
"DatabaseState",
|
|
45
|
+
"MissingTenantError",
|
|
27
46
|
"MultiTenantMixin",
|
|
28
47
|
"OnCommitCallback",
|
|
29
48
|
"RequestSession",
|
|
30
49
|
"SoftDeleteMixin",
|
|
31
50
|
"TenantIsolationError",
|
|
32
51
|
"VersionedMixin",
|
|
52
|
+
"all_tenants",
|
|
53
|
+
"bind_current_tenant",
|
|
33
54
|
"build_module_metadata",
|
|
34
55
|
"create_module_base",
|
|
35
56
|
"current_tenant_id",
|
|
36
57
|
"detect_provider",
|
|
58
|
+
"drop_enums_if_postgres",
|
|
37
59
|
"finalize_session",
|
|
38
60
|
"get_db",
|
|
61
|
+
"hard_delete",
|
|
39
62
|
"init_db",
|
|
63
|
+
"is_valid_tenant_id",
|
|
40
64
|
"like_contains_pattern",
|
|
41
65
|
"like_prefix_pattern",
|
|
42
66
|
"make_include_object",
|
|
43
67
|
"make_process_revision_directives",
|
|
68
|
+
"mark_written",
|
|
44
69
|
"render_item",
|
|
70
|
+
"tenant_context",
|
|
45
71
|
]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Tables an ORM entity LEFT-joins in, whose criteria already sit in ``ON`` (#417).
|
|
2
|
+
|
|
3
|
+
``select(Parent, func.count(Child.id)).outerjoin(Child, ...)``: the column
|
|
4
|
+
wrapped in ``func.count`` strips the ORM annotation, so ``columns_clause_froms``
|
|
5
|
+
lists a bare ``Table child``. ``_plain_tables`` took that for a Core
|
|
6
|
+
``Model.__table__`` reference and added ``WHERE child.tenant_id = :t``, which
|
|
7
|
+
is false for every row the outer join padded with NULLs — the LEFT JOIN became
|
|
8
|
+
an inner one and parents without children vanished.
|
|
9
|
+
|
|
10
|
+
Excluding such a table from the ``WHERE`` is only safe when something else
|
|
11
|
+
filters it. That holds for exactly one shape: the target of a LEFT outer join
|
|
12
|
+
is a plain ORM entity (``.outerjoin(Child, ...)``) whose class carries the
|
|
13
|
+
loader criteria, because ``with_loader_criteria`` renders them into the join's
|
|
14
|
+
``ON``, and an ``ON`` predicate fully filters a LEFT join's nullable side.
|
|
15
|
+
A relationship path (``.outerjoin(Parent.kids)``, ``.outerjoin(Parent.kids.
|
|
16
|
+
of_type(Kid))``) lands on the same ``ON`` and counts as that entity. So:
|
|
17
|
+
|
|
18
|
+
* a FULL outer join (``full=True``) is deliberately NOT exempted: it preserves
|
|
19
|
+
its right side too, and an ``ON`` predicate never removes a preserved row —
|
|
20
|
+
another tenant's (or a trashed) child that fails ``ON`` still comes back as
|
|
21
|
+
an unmatched row. Only the ``WHERE`` predicate keeps it out (over-filters,
|
|
22
|
+
never leaks);
|
|
23
|
+
|
|
24
|
+
* a relationship with ``secondary`` keeps the child's ``WHERE``: the child
|
|
25
|
+
sits behind an association table, outside the outer join's own ``ON``;
|
|
26
|
+
* a raw ``Model.__table__`` target gets no loader criteria and keeps its
|
|
27
|
+
``WHERE`` predicate (over-filters, never leaks);
|
|
28
|
+
* an ``aliased(Model)`` target is left alone: its name never matches a table
|
|
29
|
+
name, so it was never a ``WHERE`` candidate in the first place;
|
|
30
|
+
* a target whose mapper does not map exactly one table (joined inheritance)
|
|
31
|
+
is left alone, conservatively;
|
|
32
|
+
* a target whose class is not in ``covered`` (the classes the caller attaches
|
|
33
|
+
loader criteria for) is left alone — no criteria, no ``ON`` predicate.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from sqlalchemy.orm import Mapper, RelationshipProperty
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _plain(element: Any) -> Any:
|
|
44
|
+
deannotate = getattr(element, "_deannotate", None)
|
|
45
|
+
return deannotate() if deannotate is not None else element
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def orm_outer_join_tables(stmt: Any, covered: set[type] | frozenset[type]) -> list[Any]:
|
|
49
|
+
"""Un-annotated tables of ORM entities ``stmt`` LEFT-outer-joins in (never FULL).
|
|
50
|
+
|
|
51
|
+
Only entities whose class is in ``covered`` count: those are the ones the
|
|
52
|
+
filter gives ``with_loader_criteria``, which is what puts their predicate
|
|
53
|
+
in ``ON``. Compare the result by identity against FROM candidates.
|
|
54
|
+
"""
|
|
55
|
+
tables: list[Any] = []
|
|
56
|
+
for entry in getattr(stmt, "_setup_joins", ()):
|
|
57
|
+
# SQLAlchemy 2.0: (target, onclause, from_, {"isouter": .., "full": ..})
|
|
58
|
+
target, flags = entry[0], entry[-1]
|
|
59
|
+
# LEFT only: ON cannot filter a FULL join's preserved right side.
|
|
60
|
+
if not isinstance(flags, dict) or not flags.get("isouter") or flags.get("full"):
|
|
61
|
+
continue
|
|
62
|
+
prop = getattr(target, "property", None)
|
|
63
|
+
if isinstance(prop, RelationshipProperty):
|
|
64
|
+
mapper, table = _relationship_target(target, prop)
|
|
65
|
+
else:
|
|
66
|
+
mapper = getattr(target, "_annotations", {}).get("parententity")
|
|
67
|
+
table = _plain(target)
|
|
68
|
+
if not isinstance(mapper, Mapper) or mapper.class_ not in covered:
|
|
69
|
+
continue
|
|
70
|
+
if table is mapper.local_table and mapper.persist_selectable is mapper.local_table:
|
|
71
|
+
tables.append(table)
|
|
72
|
+
return tables
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _relationship_target(attr: Any, prop: RelationshipProperty) -> tuple[Any, Any]:
|
|
76
|
+
"""Mapper and table a ``.outerjoin(Parent.kids)`` path lands on, or ``(None, None)``.
|
|
77
|
+
|
|
78
|
+
The child's loader criteria render into that join's ``ON`` exactly as for
|
|
79
|
+
an entity target. Two shapes are refused: a ``secondary`` relationship
|
|
80
|
+
(the child sits behind an association table, with no ``ON`` guarantee for
|
|
81
|
+
the outer join itself) and an aliased ``of_type`` (aliases never matched a
|
|
82
|
+
``WHERE`` candidate anyway).
|
|
83
|
+
"""
|
|
84
|
+
if prop.secondary is not None:
|
|
85
|
+
return None, None
|
|
86
|
+
of_type = getattr(attr, "_of_type", None)
|
|
87
|
+
mapper = prop.mapper if of_type is None else of_type
|
|
88
|
+
if not isinstance(mapper, Mapper): # aliased(Child) is an AliasedInsp
|
|
89
|
+
return None, None
|
|
90
|
+
return mapper, mapper.local_table
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_excluded(from_obj: Any, excluded: list[Any]) -> bool:
|
|
94
|
+
# Table-object identity on purpose: matching by name would also drop the
|
|
95
|
+
# WHERE on a raw duplicate occurrence (an alias, another FROM) of the table.
|
|
96
|
+
plain = _plain(from_obj)
|
|
97
|
+
return any(plain is t for t in excluded)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Unit-of-work tenant rules, applied in ``before_flush``.
|
|
2
|
+
|
|
3
|
+
The query filter scopes what a statement *reads*; this guards what a flush
|
|
4
|
+
*writes*. It covers the paths that never reach ``do_orm_execute``: objects
|
|
5
|
+
added with ``session.add``, and objects that came out of the identity map —
|
|
6
|
+
``session.get`` answers from it without SQL, so a session reused across a
|
|
7
|
+
``tenant_context`` switch can hand back, and then write, another tenant's row.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import inspect as sa_inspect
|
|
13
|
+
from sqlalchemy.orm import Session
|
|
14
|
+
|
|
15
|
+
from simple_module_db.mixins import MultiTenantMixin
|
|
16
|
+
from simple_module_db.query_filter import fallback_tenant_id, is_strict, strict_configured
|
|
17
|
+
from simple_module_db.tenancy import (
|
|
18
|
+
TenantIsolationError,
|
|
19
|
+
current_tenant_id,
|
|
20
|
+
is_all_tenants,
|
|
21
|
+
missing_tenant_error,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _owner(obj: MultiTenantMixin) -> str | None:
|
|
26
|
+
"""The tenant the row belongs to in the database (before this flush)."""
|
|
27
|
+
hist = sa_inspect(obj).attrs.tenant_id.history
|
|
28
|
+
if hist.deleted:
|
|
29
|
+
return hist.deleted[0]
|
|
30
|
+
return obj.tenant_id
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _stamp_unbound(session: Session) -> None:
|
|
34
|
+
"""Non-strict and unscoped (unbound, or an ``all_tenants()`` block).
|
|
35
|
+
|
|
36
|
+
New rows go to the bound tenant if there is one — a bypass widens what a
|
|
37
|
+
block may *read*, it does not move its writes elsewhere — else to the
|
|
38
|
+
install's fallback (``default_tenant`` or ``DEFAULT_TENANT_ID``).
|
|
39
|
+
"""
|
|
40
|
+
stamp = current_tenant_id.get() or fallback_tenant_id(session)
|
|
41
|
+
for obj in session.new:
|
|
42
|
+
if isinstance(obj, MultiTenantMixin) and obj.tenant_id is None:
|
|
43
|
+
obj.tenant_id = stamp
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def guard_flush(session: Session) -> None:
|
|
47
|
+
if is_all_tenants():
|
|
48
|
+
# A bypass block is never scoped; on a single-tenant install its new
|
|
49
|
+
# rows still need a tenant to satisfy NOT NULL (strict: left to the DB).
|
|
50
|
+
if not strict_configured(session):
|
|
51
|
+
_stamp_unbound(session)
|
|
52
|
+
return
|
|
53
|
+
tenant_id = current_tenant_id.get()
|
|
54
|
+
strict = is_strict(session)
|
|
55
|
+
if tenant_id is None and not strict:
|
|
56
|
+
_stamp_unbound(session)
|
|
57
|
+
|
|
58
|
+
for obj in session.new:
|
|
59
|
+
if not isinstance(obj, MultiTenantMixin):
|
|
60
|
+
continue
|
|
61
|
+
if obj.tenant_id is None:
|
|
62
|
+
if tenant_id is not None:
|
|
63
|
+
obj.tenant_id = tenant_id
|
|
64
|
+
elif strict:
|
|
65
|
+
raise missing_tenant_error(type(obj).__name__, "INSERT")
|
|
66
|
+
elif tenant_id is not None and obj.tenant_id != tenant_id:
|
|
67
|
+
raise TenantIsolationError(
|
|
68
|
+
f"Cannot create object for tenant '{obj.tenant_id}' "
|
|
69
|
+
f"in context of tenant '{tenant_id}'"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
for obj in list(session.dirty) + list(session.deleted):
|
|
73
|
+
if not isinstance(obj, MultiTenantMixin):
|
|
74
|
+
continue
|
|
75
|
+
is_deleted = obj in session.deleted
|
|
76
|
+
if not is_deleted and not session.is_modified(obj):
|
|
77
|
+
continue
|
|
78
|
+
# Moving a row between tenants is never a routine edit, bound or not
|
|
79
|
+
# (#356); only a deliberate all_tenants() block may do it.
|
|
80
|
+
if not is_deleted and sa_inspect(obj).attrs.tenant_id.history.has_changes():
|
|
81
|
+
raise TenantIsolationError("Cannot change tenant_id of an existing object")
|
|
82
|
+
owner = _owner(obj)
|
|
83
|
+
op = "DELETE" if is_deleted else "UPDATE"
|
|
84
|
+
if tenant_id is not None and owner != tenant_id:
|
|
85
|
+
raise TenantIsolationError(
|
|
86
|
+
f"Cannot {op} an object of tenant '{owner}' in context of tenant '{tenant_id}'"
|
|
87
|
+
)
|
|
88
|
+
if tenant_id is None and strict:
|
|
89
|
+
raise missing_tenant_error(type(obj).__name__, op)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
__all__ = ["guard_flush"]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""INSERT guard and statement-value helpers for the tenant query filter.
|
|
2
|
+
|
|
3
|
+
Explicit ``tenant_id`` values in an insert must match the bound tenant, and
|
|
4
|
+
missing ones are stamped with it (#357) — or, unbound on a non-strict
|
|
5
|
+
install, with the install's fallback tenant (#380); the same value readers let the
|
|
6
|
+
query filter refuse an UPDATE that assigns ``tenant_id`` (#356).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from sqlalchemy.orm import ORMExecuteState
|
|
14
|
+
|
|
15
|
+
from simple_module_db import model_registry as registry
|
|
16
|
+
from simple_module_db.tenancy import (
|
|
17
|
+
ALL_TENANTS_OPTION,
|
|
18
|
+
DEFAULT_TENANT_ID,
|
|
19
|
+
TenantIsolationError,
|
|
20
|
+
current_tenant_id,
|
|
21
|
+
is_all_tenants,
|
|
22
|
+
missing_tenant_error,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
TENANT_COLUMN = "tenant_id"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _bound_value(value: Any) -> Any:
|
|
29
|
+
return getattr(value, "value", value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _column_name(key: Any) -> str:
|
|
33
|
+
return getattr(key, "key", None) or getattr(key, "name", None) or str(key)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _statement_values(execute_state: ORMExecuteState) -> dict[str, Any]:
|
|
37
|
+
raw = getattr(execute_state.statement, "_values", None) or {}
|
|
38
|
+
return {_column_name(k): _bound_value(v) for k, v in raw.items()}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _param_rows(execute_state: ORMExecuteState) -> list[dict[str, Any]]:
|
|
42
|
+
params = execute_state.parameters
|
|
43
|
+
if isinstance(params, dict):
|
|
44
|
+
return [params] if params else []
|
|
45
|
+
return [p for p in (params or []) if isinstance(p, dict)]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def assigned_tenant_ids(execute_state: ORMExecuteState) -> list[Any]:
|
|
49
|
+
found = []
|
|
50
|
+
stmt_values = _statement_values(execute_state)
|
|
51
|
+
if TENANT_COLUMN in stmt_values:
|
|
52
|
+
found.append(stmt_values[TENANT_COLUMN])
|
|
53
|
+
found.extend(row[TENANT_COLUMN] for row in _param_rows(execute_state) if TENANT_COLUMN in row)
|
|
54
|
+
return found
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _foreign_insert(value: Any, tenant_id: str) -> TenantIsolationError:
|
|
58
|
+
return TenantIsolationError(
|
|
59
|
+
f"Cannot insert rows for tenant '{value}' in context of tenant '{tenant_id}'"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def guard_insert(
|
|
64
|
+
execute_state: ORMExecuteState, *, strict: bool, fallback: str = DEFAULT_TENANT_ID
|
|
65
|
+
) -> Any:
|
|
66
|
+
mappers = [m.class_.__name__ for m in execute_state.all_mappers if registry.flags(m.class_)[1]]
|
|
67
|
+
target = getattr(execute_state.statement, "table", None)
|
|
68
|
+
if not mappers and getattr(target, "name", None) in registry.tenant_table_names:
|
|
69
|
+
mappers = [target.name] # Core insert(Model.__table__)
|
|
70
|
+
if not mappers:
|
|
71
|
+
return None
|
|
72
|
+
bypass = execute_state.execution_options.get(ALL_TENANTS_OPTION, False) or is_all_tenants()
|
|
73
|
+
if bypass and strict:
|
|
74
|
+
return None
|
|
75
|
+
# A non-strict bypass checks no explicit values; missing ones still get
|
|
76
|
+
# the bound tenant, else the install's fallback (#380).
|
|
77
|
+
bound = current_tenant_id.get()
|
|
78
|
+
tenant_id = None if bypass else bound
|
|
79
|
+
stamp = bound or fallback
|
|
80
|
+
if getattr(execute_state.statement, "_multi_values", None):
|
|
81
|
+
return _guard_multi_values(execute_state, tenant_id, stamp, mappers[0], strict)
|
|
82
|
+
for value in assigned_tenant_ids(execute_state):
|
|
83
|
+
if tenant_id is not None and value != tenant_id:
|
|
84
|
+
raise _foreign_insert(value, tenant_id)
|
|
85
|
+
|
|
86
|
+
rows = _param_rows(execute_state)
|
|
87
|
+
stmt_has_tenant = TENANT_COLUMN in _statement_values(execute_state)
|
|
88
|
+
missing = (
|
|
89
|
+
[r for r in rows if TENANT_COLUMN not in r] if rows else ([] if stmt_has_tenant else [None])
|
|
90
|
+
)
|
|
91
|
+
if not missing:
|
|
92
|
+
return None
|
|
93
|
+
if tenant_id is None and strict:
|
|
94
|
+
raise missing_tenant_error(mappers[0], "INSERT")
|
|
95
|
+
if rows:
|
|
96
|
+
stamped = [{**r, TENANT_COLUMN: r.get(TENANT_COLUMN, stamp)} for r in rows]
|
|
97
|
+
payload = stamped if isinstance(execute_state.parameters, list) else stamped[0]
|
|
98
|
+
return execute_state.invoke_statement(params=payload)
|
|
99
|
+
execute_state.statement = execute_state.statement.values(**{TENANT_COLUMN: stamp})
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _guard_multi_values(
|
|
104
|
+
execute_state: ORMExecuteState, tenant_id: str | None, stamp: str, entity: str, strict: bool
|
|
105
|
+
) -> Any:
|
|
106
|
+
"""``insert(M).values([{...}, {...}])``: check and stamp each row.
|
|
107
|
+
|
|
108
|
+
``.values(tenant_id=...)`` cannot be appended to a multi-VALUES insert
|
|
109
|
+
(SQLAlchemy refuses to mix the two forms), so the rows are rewritten.
|
|
110
|
+
"""
|
|
111
|
+
stmt = execute_state.statement
|
|
112
|
+
columns = list(stmt.table.c)
|
|
113
|
+
tenant_col = stmt.table.c[TENANT_COLUMN]
|
|
114
|
+
groups, missing = [], False
|
|
115
|
+
for group in stmt._multi_values:
|
|
116
|
+
rows = []
|
|
117
|
+
for row in group:
|
|
118
|
+
mapping = dict(row) if isinstance(row, dict) else dict(zip(columns, row, strict=False))
|
|
119
|
+
keys = {_column_name(k): k for k in mapping}
|
|
120
|
+
if TENANT_COLUMN in keys:
|
|
121
|
+
value = _bound_value(mapping[keys[TENANT_COLUMN]])
|
|
122
|
+
if tenant_id is not None and value != tenant_id:
|
|
123
|
+
raise _foreign_insert(value, tenant_id)
|
|
124
|
+
else:
|
|
125
|
+
missing = True
|
|
126
|
+
mapping[tenant_col] = stamp
|
|
127
|
+
rows.append(mapping)
|
|
128
|
+
groups.append(rows)
|
|
129
|
+
if not missing:
|
|
130
|
+
return None
|
|
131
|
+
if tenant_id is None and strict:
|
|
132
|
+
raise missing_tenant_error(entity, "INSERT")
|
|
133
|
+
stamped = stmt._generate()
|
|
134
|
+
stamped._multi_values = tuple(groups)
|
|
135
|
+
execute_state.statement = stamped
|
|
136
|
+
return None
|
|
@@ -8,10 +8,35 @@ from datetime import UTC, datetime
|
|
|
8
8
|
|
|
9
9
|
from sqlalchemy import event
|
|
10
10
|
from sqlalchemy import inspect as sa_inspect
|
|
11
|
-
from sqlalchemy.orm import
|
|
11
|
+
from sqlalchemy.orm import Session
|
|
12
12
|
|
|
13
|
-
from simple_module_db.
|
|
13
|
+
from simple_module_db.flush_guard import guard_flush
|
|
14
|
+
from simple_module_db.mixins import AuditMixin, SoftDeleteMixin, VersionedMixin
|
|
15
|
+
from simple_module_db.query_filter import bind_engine_policy, filter_statements
|
|
14
16
|
from simple_module_db.session import DatabaseState
|
|
17
|
+
from simple_module_db.writes import (
|
|
18
|
+
_HARD_DELETE_KEY,
|
|
19
|
+
SESSION_HAS_WRITES_KEY,
|
|
20
|
+
_is_purge,
|
|
21
|
+
_mark_dml_written,
|
|
22
|
+
_mark_session_written,
|
|
23
|
+
hard_delete,
|
|
24
|
+
mark_written,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Re-exported: ``transaction`` and published modules import these from here.
|
|
28
|
+
__all__ = [
|
|
29
|
+
"SESSION_HAS_WRITES_KEY",
|
|
30
|
+
"TenantIsolationError",
|
|
31
|
+
"current_tenant_id",
|
|
32
|
+
"current_user_id",
|
|
33
|
+
"hard_delete",
|
|
34
|
+
"mark_written",
|
|
35
|
+
"register_listeners",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
# Re-exported: callers import these from here since before tenancy.py existed.
|
|
39
|
+
from simple_module_db.tenancy import TenantIsolationError, current_tenant_id
|
|
15
40
|
|
|
16
41
|
logger = logging.getLogger(__name__)
|
|
17
42
|
_db_logger = logging.getLogger("simple_module.db")
|
|
@@ -19,19 +44,6 @@ _db_logger = logging.getLogger("simple_module.db")
|
|
|
19
44
|
# Set by auth middleware on each request
|
|
20
45
|
current_user_id: ContextVar[str | None] = ContextVar("current_user_id", default=None)
|
|
21
46
|
|
|
22
|
-
# Set by tenant middleware on each request
|
|
23
|
-
current_tenant_id: ContextVar[str | None] = ContextVar("current_tenant_id", default=None)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class TenantIsolationError(Exception):
|
|
27
|
-
"""Raised when a multi-tenancy isolation constraint is violated."""
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
# Key on ``Session.info`` stamped by the after_flush listener so
|
|
31
|
-
# ``get_db`` can distinguish read-only requests from write requests after
|
|
32
|
-
# flush has cleared ``session.new/.dirty/.deleted``.
|
|
33
|
-
SESSION_HAS_WRITES_KEY = "has_writes"
|
|
34
|
-
|
|
35
47
|
# Key on ``Session.info`` for pending audit snapshots produced in before_flush
|
|
36
48
|
# and consumed in after_flush_postexec (when DB-assigned PKs are populated).
|
|
37
49
|
_AUDIT_PENDING_KEY = "_audit_pending"
|
|
@@ -50,6 +62,8 @@ _OP_UPDATE = "update"
|
|
|
50
62
|
_OP_SOFT_DELETE = "soft_delete"
|
|
51
63
|
_OP_DELETE = "delete"
|
|
52
64
|
|
|
65
|
+
_LISTENERS_ATTACHED = "_sm_entity_listeners_attached"
|
|
66
|
+
|
|
53
67
|
|
|
54
68
|
def _entity_label(obj: object) -> str:
|
|
55
69
|
"""Return 'ClassName' for a mapped entity instance."""
|
|
@@ -70,17 +84,6 @@ def _entity_pk(obj: object) -> object:
|
|
|
70
84
|
return None
|
|
71
85
|
|
|
72
86
|
|
|
73
|
-
def _mark_session_written(session: Session, flush_context: object) -> None:
|
|
74
|
-
"""Flag the session as having performed write work.
|
|
75
|
-
|
|
76
|
-
``get_db`` reads this flag to decide between commit and rollback at
|
|
77
|
-
request end. Checking ``session.new/.dirty/.deleted`` directly after
|
|
78
|
-
a flush is useless — flush empties those sets — so we stash a tag on
|
|
79
|
-
``session.info`` that survives the rest of the request.
|
|
80
|
-
"""
|
|
81
|
-
session.info[SESSION_HAS_WRITES_KEY] = True
|
|
82
|
-
|
|
83
|
-
|
|
84
87
|
def register_listeners(db_state: DatabaseState) -> None:
|
|
85
88
|
"""Register SQLAlchemy event listeners for audit, soft delete, versioning, and tenancy.
|
|
86
89
|
|
|
@@ -93,22 +96,47 @@ def register_listeners(db_state: DatabaseState) -> None:
|
|
|
93
96
|
|
|
94
97
|
global _db_state
|
|
95
98
|
_db_state = db_state
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
event.listen(db_state.sync_session_class, "after_flush_postexec", _after_flush_audit)
|
|
100
|
-
event.listen(db_state.sync_session_class, "do_orm_execute", _filter_select_statements)
|
|
99
|
+
# Strict mode is read from db_state at query time, per engine.
|
|
100
|
+
bind_engine_policy(db_state.engine.sync_engine, db_state)
|
|
101
|
+
attach_session_listeners(db_state.sync_session_class)
|
|
101
102
|
db_state._listeners_registered = True
|
|
102
103
|
logger.info("Registered SQLAlchemy entity listeners")
|
|
103
104
|
|
|
104
105
|
|
|
106
|
+
def attach_session_listeners(session_class: type[Session]) -> None:
|
|
107
|
+
"""Attach the entity listeners to a session class; idempotent.
|
|
108
|
+
|
|
109
|
+
For a process with no ``DatabaseState`` (the Celery worker's sync engine),
|
|
110
|
+
call this and ``bind_engine_policy(engine, EngineTenancy(...))``.
|
|
111
|
+
|
|
112
|
+
Idempotence is a marker in the ``__dict__`` of the class or a base, not
|
|
113
|
+
``event.contains``: SQLAlchemy keys its event registry on ``id(target)``,
|
|
114
|
+
so a fresh session class that reuses a garbage-collected one's id reads as
|
|
115
|
+
already wired and would silently get no tenant, soft-delete or write
|
|
116
|
+
listeners at all. A subclass of a wired class is skipped because SQLAlchemy
|
|
117
|
+
propagates class-level session listeners to subclasses (even ones defined
|
|
118
|
+
later); wiring it again would fire every listener twice.
|
|
119
|
+
"""
|
|
120
|
+
if any(base.__dict__.get(_LISTENERS_ATTACHED) for base in session_class.__mro__):
|
|
121
|
+
return
|
|
122
|
+
for name, fn in (
|
|
123
|
+
("before_flush", _before_flush_listener),
|
|
124
|
+
("after_flush", _mark_session_written),
|
|
125
|
+
("after_flush_postexec", _after_flush_audit),
|
|
126
|
+
("do_orm_execute", _mark_dml_written),
|
|
127
|
+
("do_orm_execute", filter_statements),
|
|
128
|
+
):
|
|
129
|
+
event.listen(session_class, name, fn)
|
|
130
|
+
setattr(session_class, _LISTENERS_ATTACHED, True)
|
|
131
|
+
|
|
132
|
+
|
|
105
133
|
def _before_flush_listener(
|
|
106
134
|
session: Session,
|
|
107
135
|
flush_context: object,
|
|
108
136
|
instances: object,
|
|
109
137
|
) -> None:
|
|
138
|
+
guard_flush(session)
|
|
110
139
|
user_id = current_user_id.get()
|
|
111
|
-
tenant_id = current_tenant_id.get()
|
|
112
140
|
now = datetime.now(UTC)
|
|
113
141
|
|
|
114
142
|
for obj in session.new:
|
|
@@ -118,16 +146,6 @@ def _before_flush_listener(
|
|
|
118
146
|
if obj.updated_by is None:
|
|
119
147
|
obj.updated_by = user_id
|
|
120
148
|
|
|
121
|
-
# Auto-populate tenant_id; reject cross-tenant creation
|
|
122
|
-
if isinstance(obj, MultiTenantMixin):
|
|
123
|
-
if obj.tenant_id is None and tenant_id is not None:
|
|
124
|
-
obj.tenant_id = tenant_id
|
|
125
|
-
elif tenant_id is not None and obj.tenant_id != tenant_id:
|
|
126
|
-
raise TenantIsolationError(
|
|
127
|
-
f"Cannot create object for tenant '{obj.tenant_id}' "
|
|
128
|
-
f"in context of tenant '{tenant_id}'"
|
|
129
|
-
)
|
|
130
|
-
|
|
131
149
|
_db_logger.info(
|
|
132
150
|
_EVENT_ENTITY_CREATED,
|
|
133
151
|
extra={
|
|
@@ -148,12 +166,6 @@ def _before_flush_listener(
|
|
|
148
166
|
if isinstance(obj, VersionedMixin):
|
|
149
167
|
obj.version += 1
|
|
150
168
|
|
|
151
|
-
# Prevent tenant_id from being changed on existing objects
|
|
152
|
-
if isinstance(obj, MultiTenantMixin) and tenant_id is not None:
|
|
153
|
-
hist = sa_inspect(obj).attrs.tenant_id.history
|
|
154
|
-
if hist.has_changes():
|
|
155
|
-
raise TenantIsolationError("Cannot change tenant_id of an existing object")
|
|
156
|
-
|
|
157
169
|
_db_logger.info(
|
|
158
170
|
_EVENT_ENTITY_UPDATED,
|
|
159
171
|
extra={
|
|
@@ -165,8 +177,9 @@ def _before_flush_listener(
|
|
|
165
177
|
)
|
|
166
178
|
|
|
167
179
|
# Deleted objects — convert to soft delete if applicable
|
|
180
|
+
hard_delete_ids = session.info.pop(_HARD_DELETE_KEY, frozenset())
|
|
168
181
|
for obj in list(session.deleted):
|
|
169
|
-
if isinstance(obj, SoftDeleteMixin):
|
|
182
|
+
if isinstance(obj, SoftDeleteMixin) and not _is_purge(obj, hard_delete_ids):
|
|
170
183
|
# Cancel the hard delete
|
|
171
184
|
session.expunge(obj)
|
|
172
185
|
# Merge back as modified with soft-delete fields set
|
|
@@ -233,47 +246,3 @@ def _after_flush_audit(session: Session, flush_context: object) -> None:
|
|
|
233
246
|
records = finalize_records(pending)
|
|
234
247
|
if records:
|
|
235
248
|
_db_state.audit_callback(session, records)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
# Cache ``(is_soft_delete, is_multi_tenant)`` flags per mapper class so the
|
|
239
|
-
# ``do_orm_execute`` hot path skips redundant ``issubclass`` work on every query.
|
|
240
|
-
_mixin_flags_cache: dict[type, tuple[bool, bool]] = {}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
def _filter_select_statements(execute_state: ORMExecuteState) -> None:
|
|
244
|
-
"""Attach per-mapper ``with_loader_criteria`` for soft-delete and tenant isolation.
|
|
245
|
-
|
|
246
|
-
The criteria are attached per concrete mapper because SQLModel mixins
|
|
247
|
-
expose Pydantic ``FieldInfo`` (not SQLAlchemy ``InstrumentedAttribute``)
|
|
248
|
-
at the mixin-class level, which breaks the lambda form of
|
|
249
|
-
``with_loader_criteria`` that was used before the SQLModel migration.
|
|
250
|
-
|
|
251
|
-
Soft-delete bypass: ``stmt.execution_options(include_deleted=True)``.
|
|
252
|
-
"""
|
|
253
|
-
if not execute_state.is_select:
|
|
254
|
-
return
|
|
255
|
-
|
|
256
|
-
skip_soft_delete = execute_state.execution_options.get("include_deleted", False)
|
|
257
|
-
tenant_id = current_tenant_id.get()
|
|
258
|
-
if skip_soft_delete and tenant_id is None:
|
|
259
|
-
return
|
|
260
|
-
|
|
261
|
-
options = []
|
|
262
|
-
for mapper in execute_state.all_mappers:
|
|
263
|
-
cls = mapper.class_
|
|
264
|
-
flags = _mixin_flags_cache.get(cls)
|
|
265
|
-
if flags is None:
|
|
266
|
-
flags = (issubclass(cls, SoftDeleteMixin), issubclass(cls, MultiTenantMixin))
|
|
267
|
-
_mixin_flags_cache[cls] = flags
|
|
268
|
-
is_soft_delete, is_multi_tenant = flags
|
|
269
|
-
if is_soft_delete and not skip_soft_delete:
|
|
270
|
-
options.append(
|
|
271
|
-
with_loader_criteria(cls, cls.is_deleted.is_(False), include_aliases=True)
|
|
272
|
-
)
|
|
273
|
-
if is_multi_tenant and tenant_id is not None:
|
|
274
|
-
options.append(
|
|
275
|
-
with_loader_criteria(cls, cls.tenant_id == tenant_id, include_aliases=True)
|
|
276
|
-
)
|
|
277
|
-
|
|
278
|
-
if options:
|
|
279
|
-
execute_state.statement = execute_state.statement.options(*options)
|