simple-module-db 0.0.35__tar.gz → 0.0.37__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.35 → simple_module_db-0.0.37}/.gitignore +1 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/PKG-INFO +2 -2
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/pyproject.toml +2 -2
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/__init__.py +15 -0
- simple_module_db-0.0.37/simple_module_db/_outer_joins.py +97 -0
- simple_module_db-0.0.37/simple_module_db/extensions.py +103 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/flush_guard.py +20 -1
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/insert_guard.py +23 -18
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/listeners.py +14 -2
- simple_module_db-0.0.37/simple_module_db/migration_portability.py +164 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/migrations.py +49 -31
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/mixins.py +9 -7
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/query_filter.py +53 -14
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/session.py +19 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/tenancy.py +56 -5
- simple_module_db-0.0.37/simple_module_db/types.py +94 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/tests/conftest.py +28 -3
- simple_module_db-0.0.37/tests/test_aware_datetime.py +76 -0
- simple_module_db-0.0.37/tests/test_db_extensions.py +94 -0
- simple_module_db-0.0.37/tests/test_db_extensions_pg.py +174 -0
- simple_module_db-0.0.37/tests/test_db_listeners_attach.py +83 -0
- simple_module_db-0.0.37/tests/test_db_sqlite_savepoint.py +75 -0
- simple_module_db-0.0.37/tests/test_default_tenant.py +164 -0
- simple_module_db-0.0.37/tests/test_migration_portability.py +193 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/tests/test_multi_tenancy.py +5 -6
- simple_module_db-0.0.37/tests/test_query_filter_outer_joins.py +250 -0
- simple_module_db-0.0.37/tests/test_query_filter_rel_outer_joins.py +165 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/tests/test_tenant_strict.py +1 -24
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/LICENSE +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/README.md +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/audit.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/base.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/callbacks.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/deps.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/model_registry.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/provider.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/py.typed +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/search.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/subquery_guard.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/transaction.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/simple_module_db/writes.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/tests/_audit_models.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/tests/_models.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/tests/test_audit.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/tests/test_base.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/tests/test_db_logging.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/tests/test_migrations.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/tests/test_mixins.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/tests/test_query_filters.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/tests/test_session.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/tests/test_soft_delete_shapes.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/tests/test_tenant_dml.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/tests/test_tenant_shapes.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/tests/test_transaction.py +0 -0
- {simple_module_db-0.0.35 → simple_module_db-0.0.37}/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.37
|
|
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.37
|
|
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.37"
|
|
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.37",
|
|
32
32
|
"sqlalchemy[asyncio]>=2.0",
|
|
33
33
|
"sqlmodel>=0.0.22",
|
|
34
34
|
]
|
|
@@ -4,6 +4,8 @@ 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.extensions import UnsupportedDatabaseError, ensure_db_extensions
|
|
8
|
+
from simple_module_db.migration_portability import drop_enums_if_postgres
|
|
7
9
|
from simple_module_db.migrations import (
|
|
8
10
|
build_module_metadata,
|
|
9
11
|
make_include_object,
|
|
@@ -16,6 +18,8 @@ from simple_module_db.search import LIKE_ESCAPE_CHAR, like_contains_pattern, lik
|
|
|
16
18
|
from simple_module_db.session import DatabaseState, RequestSession, init_db
|
|
17
19
|
from simple_module_db.tenancy import (
|
|
18
20
|
ALL_TENANTS_OPTION,
|
|
21
|
+
DEFAULT_TENANT_ID,
|
|
22
|
+
PLATFORM_TENANT_ID,
|
|
19
23
|
TENANT_ID_PATTERN,
|
|
20
24
|
MissingTenantError,
|
|
21
25
|
TenantIsolationError,
|
|
@@ -23,26 +27,34 @@ from simple_module_db.tenancy import (
|
|
|
23
27
|
bind_current_tenant,
|
|
24
28
|
current_tenant_id,
|
|
25
29
|
is_valid_tenant_id,
|
|
30
|
+
no_tenant,
|
|
26
31
|
tenant_context,
|
|
27
32
|
)
|
|
28
33
|
from simple_module_db.transaction import CommitBeforeResponseMiddleware, finalize_session
|
|
34
|
+
from simple_module_db.types import AwareDateTime, LTree, TSVector
|
|
29
35
|
from simple_module_db.writes import hard_delete, mark_written
|
|
30
36
|
|
|
31
37
|
__all__ = [
|
|
32
38
|
"ALL_TENANTS_OPTION",
|
|
39
|
+
"DEFAULT_TENANT_ID",
|
|
33
40
|
"LIKE_ESCAPE_CHAR",
|
|
41
|
+
"PLATFORM_TENANT_ID",
|
|
34
42
|
"TENANT_ID_PATTERN",
|
|
35
43
|
"AuditMixin",
|
|
36
44
|
"AuditRecord",
|
|
45
|
+
"AwareDateTime",
|
|
37
46
|
"CommitBeforeResponseMiddleware",
|
|
38
47
|
"DatabaseProvider",
|
|
39
48
|
"DatabaseState",
|
|
49
|
+
"LTree",
|
|
40
50
|
"MissingTenantError",
|
|
41
51
|
"MultiTenantMixin",
|
|
42
52
|
"OnCommitCallback",
|
|
43
53
|
"RequestSession",
|
|
44
54
|
"SoftDeleteMixin",
|
|
55
|
+
"TSVector",
|
|
45
56
|
"TenantIsolationError",
|
|
57
|
+
"UnsupportedDatabaseError",
|
|
46
58
|
"VersionedMixin",
|
|
47
59
|
"all_tenants",
|
|
48
60
|
"bind_current_tenant",
|
|
@@ -50,6 +62,8 @@ __all__ = [
|
|
|
50
62
|
"create_module_base",
|
|
51
63
|
"current_tenant_id",
|
|
52
64
|
"detect_provider",
|
|
65
|
+
"drop_enums_if_postgres",
|
|
66
|
+
"ensure_db_extensions",
|
|
53
67
|
"finalize_session",
|
|
54
68
|
"get_db",
|
|
55
69
|
"hard_delete",
|
|
@@ -60,6 +74,7 @@ __all__ = [
|
|
|
60
74
|
"make_include_object",
|
|
61
75
|
"make_process_revision_directives",
|
|
62
76
|
"mark_written",
|
|
77
|
+
"no_tenant",
|
|
63
78
|
"render_item",
|
|
64
79
|
"tenant_context",
|
|
65
80
|
]
|
|
@@ -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,103 @@
|
|
|
1
|
+
"""Postgres extensions a module needs (``ModuleBase.db_extensions``).
|
|
2
|
+
|
|
3
|
+
``ensure_db_extensions`` runs from the host ``env.py`` before the migrations
|
|
4
|
+
do, so a revision that creates an ``ltree`` column finds the extension already
|
|
5
|
+
installed. It is idempotent (``CREATE EXTENSION IF NOT EXISTS``), so it costs
|
|
6
|
+
nothing on an up-to-date database.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from simple_module_core import ModuleBase
|
|
17
|
+
from sqlalchemy import text
|
|
18
|
+
|
|
19
|
+
from simple_module_db.types import LTree, TSVector
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
EXTENSION_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_-]*$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UnsupportedDatabaseError(RuntimeError):
|
|
27
|
+
"""A module needs a database feature the configured database lacks."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def declared_extensions(modules: Iterable[ModuleBase]) -> dict[str, list[str]]:
|
|
31
|
+
"""``{extension: [module names that declare it]}``, validated, in module order."""
|
|
32
|
+
wanted: dict[str, list[str]] = {}
|
|
33
|
+
for mod in modules:
|
|
34
|
+
for name in mod.db_extensions():
|
|
35
|
+
if not EXTENSION_NAME_PATTERN.fullmatch(name):
|
|
36
|
+
raise ValueError(
|
|
37
|
+
f"Module {mod.meta.name!r} declares invalid database extension "
|
|
38
|
+
f"{name!r} (must match {EXTENSION_NAME_PATTERN.pattern})"
|
|
39
|
+
)
|
|
40
|
+
owners = wanted.setdefault(name, [])
|
|
41
|
+
if mod.meta.name not in owners:
|
|
42
|
+
owners.append(mod.meta.name)
|
|
43
|
+
return wanted
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ensure_db_extensions(connection: Any, modules: Iterable[ModuleBase]) -> list[str]:
|
|
47
|
+
"""Install every extension the modules declare; return the extension names.
|
|
48
|
+
|
|
49
|
+
* Postgres: ``CREATE EXTENSION IF NOT EXISTS "<name>"``.
|
|
50
|
+
* SQLite: raises :class:`UnsupportedDatabaseError` naming the module(s) and
|
|
51
|
+
extension(s) — SQLite has none, and carrying on would fail later with a
|
|
52
|
+
less helpful message.
|
|
53
|
+
* ``connection=None`` is Alembic offline mode: the statements are emitted
|
|
54
|
+
into the generated SQL script through ``context.execute``.
|
|
55
|
+
"""
|
|
56
|
+
wanted = declared_extensions(modules)
|
|
57
|
+
if not wanted:
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
offline = connection is None
|
|
61
|
+
if offline:
|
|
62
|
+
from alembic import context
|
|
63
|
+
|
|
64
|
+
dialect = context.get_context().dialect.name
|
|
65
|
+
else:
|
|
66
|
+
dialect = connection.dialect.name
|
|
67
|
+
|
|
68
|
+
if dialect != "postgresql":
|
|
69
|
+
detail = "; ".join(f"{', '.join(owners)} needs {name!r}" for name, owners in wanted.items())
|
|
70
|
+
raise UnsupportedDatabaseError(
|
|
71
|
+
f"Database extensions require PostgreSQL, but the database is {dialect}: {detail}. "
|
|
72
|
+
"Point SM_DATABASE_URL at Postgres or uninstall the module."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
for name in wanted:
|
|
76
|
+
statement = f'CREATE EXTENSION IF NOT EXISTS "{name}"'
|
|
77
|
+
if offline:
|
|
78
|
+
context.execute(statement)
|
|
79
|
+
else:
|
|
80
|
+
logger.info("Ensuring Postgres extension %r", name)
|
|
81
|
+
connection.execute(text(statement))
|
|
82
|
+
return list(wanted)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def render_extension_type(obj: Any, imports: set[str] | None) -> str | None:
|
|
86
|
+
"""Alembic render for ``LTree`` / ``TSVector``, or ``None`` for other types.
|
|
87
|
+
|
|
88
|
+
Rendered through our own classes (not ``postgresql.TSVECTOR``) so the same
|
|
89
|
+
revision also upgrades on SQLite, where they are ``TEXT``.
|
|
90
|
+
"""
|
|
91
|
+
if isinstance(obj, (LTree, TSVector)):
|
|
92
|
+
if imports is not None:
|
|
93
|
+
imports.add("import simple_module_db.types")
|
|
94
|
+
return f"simple_module_db.types.{type(obj).__name__}()"
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = [
|
|
99
|
+
"UnsupportedDatabaseError",
|
|
100
|
+
"declared_extensions",
|
|
101
|
+
"ensure_db_extensions",
|
|
102
|
+
"render_extension_type",
|
|
103
|
+
]
|
|
@@ -13,7 +13,7 @@ from sqlalchemy import inspect as sa_inspect
|
|
|
13
13
|
from sqlalchemy.orm import Session
|
|
14
14
|
|
|
15
15
|
from simple_module_db.mixins import MultiTenantMixin
|
|
16
|
-
from simple_module_db.query_filter import is_strict
|
|
16
|
+
from simple_module_db.query_filter import fallback_tenant_id, is_strict, strict_configured
|
|
17
17
|
from simple_module_db.tenancy import (
|
|
18
18
|
TenantIsolationError,
|
|
19
19
|
current_tenant_id,
|
|
@@ -30,11 +30,30 @@ def _owner(obj: MultiTenantMixin) -> str | None:
|
|
|
30
30
|
return obj.tenant_id
|
|
31
31
|
|
|
32
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
|
+
|
|
33
46
|
def guard_flush(session: Session) -> None:
|
|
34
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)
|
|
35
52
|
return
|
|
36
53
|
tenant_id = current_tenant_id.get()
|
|
37
54
|
strict = is_strict(session)
|
|
55
|
+
if tenant_id is None and not strict:
|
|
56
|
+
_stamp_unbound(session)
|
|
38
57
|
|
|
39
58
|
for obj in session.new:
|
|
40
59
|
if not isinstance(obj, MultiTenantMixin):
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""INSERT guard and statement-value helpers for the tenant query filter.
|
|
2
2
|
|
|
3
3
|
Explicit ``tenant_id`` values in an insert must match the bound tenant, and
|
|
4
|
-
missing ones are stamped with it (#357)
|
|
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
|
|
5
6
|
query filter refuse an UPDATE that assigns ``tenant_id`` (#356).
|
|
6
7
|
"""
|
|
7
8
|
|
|
@@ -14,6 +15,7 @@ from sqlalchemy.orm import ORMExecuteState
|
|
|
14
15
|
from simple_module_db import model_registry as registry
|
|
15
16
|
from simple_module_db.tenancy import (
|
|
16
17
|
ALL_TENANTS_OPTION,
|
|
18
|
+
DEFAULT_TENANT_ID,
|
|
17
19
|
TenantIsolationError,
|
|
18
20
|
current_tenant_id,
|
|
19
21
|
is_all_tenants,
|
|
@@ -58,18 +60,25 @@ def _foreign_insert(value: Any, tenant_id: str) -> TenantIsolationError:
|
|
|
58
60
|
)
|
|
59
61
|
|
|
60
62
|
|
|
61
|
-
def guard_insert(
|
|
63
|
+
def guard_insert(
|
|
64
|
+
execute_state: ORMExecuteState, *, strict: bool, fallback: str = DEFAULT_TENANT_ID
|
|
65
|
+
) -> Any:
|
|
62
66
|
mappers = [m.class_.__name__ for m in execute_state.all_mappers if registry.flags(m.class_)[1]]
|
|
63
67
|
target = getattr(execute_state.statement, "table", None)
|
|
64
68
|
if not mappers and getattr(target, "name", None) in registry.tenant_table_names:
|
|
65
69
|
mappers = [target.name] # Core insert(Model.__table__)
|
|
66
|
-
if not mappers
|
|
70
|
+
if not mappers:
|
|
67
71
|
return None
|
|
68
|
-
|
|
72
|
+
bypass = execute_state.execution_options.get(ALL_TENANTS_OPTION, False) or is_all_tenants()
|
|
73
|
+
if bypass and strict:
|
|
69
74
|
return None
|
|
70
|
-
|
|
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
|
|
71
80
|
if getattr(execute_state.statement, "_multi_values", None):
|
|
72
|
-
return _guard_multi_values(execute_state, tenant_id, mappers[0], strict)
|
|
81
|
+
return _guard_multi_values(execute_state, tenant_id, stamp, mappers[0], strict)
|
|
73
82
|
for value in assigned_tenant_ids(execute_state):
|
|
74
83
|
if tenant_id is not None and value != tenant_id:
|
|
75
84
|
raise _foreign_insert(value, tenant_id)
|
|
@@ -81,20 +90,18 @@ def guard_insert(execute_state: ORMExecuteState, *, strict: bool) -> Any:
|
|
|
81
90
|
)
|
|
82
91
|
if not missing:
|
|
83
92
|
return None
|
|
84
|
-
if tenant_id is None:
|
|
85
|
-
|
|
86
|
-
raise missing_tenant_error(mappers[0], "INSERT")
|
|
87
|
-
return None
|
|
93
|
+
if tenant_id is None and strict:
|
|
94
|
+
raise missing_tenant_error(mappers[0], "INSERT")
|
|
88
95
|
if rows:
|
|
89
|
-
stamped = [{**r, TENANT_COLUMN: r.get(TENANT_COLUMN,
|
|
96
|
+
stamped = [{**r, TENANT_COLUMN: r.get(TENANT_COLUMN, stamp)} for r in rows]
|
|
90
97
|
payload = stamped if isinstance(execute_state.parameters, list) else stamped[0]
|
|
91
98
|
return execute_state.invoke_statement(params=payload)
|
|
92
|
-
execute_state.statement = execute_state.statement.values(**{TENANT_COLUMN:
|
|
99
|
+
execute_state.statement = execute_state.statement.values(**{TENANT_COLUMN: stamp})
|
|
93
100
|
return None
|
|
94
101
|
|
|
95
102
|
|
|
96
103
|
def _guard_multi_values(
|
|
97
|
-
execute_state: ORMExecuteState, tenant_id: str | None, entity: str, strict: bool
|
|
104
|
+
execute_state: ORMExecuteState, tenant_id: str | None, stamp: str, entity: str, strict: bool
|
|
98
105
|
) -> Any:
|
|
99
106
|
"""``insert(M).values([{...}, {...}])``: check and stamp each row.
|
|
100
107
|
|
|
@@ -116,15 +123,13 @@ def _guard_multi_values(
|
|
|
116
123
|
raise _foreign_insert(value, tenant_id)
|
|
117
124
|
else:
|
|
118
125
|
missing = True
|
|
119
|
-
mapping[tenant_col] =
|
|
126
|
+
mapping[tenant_col] = stamp
|
|
120
127
|
rows.append(mapping)
|
|
121
128
|
groups.append(rows)
|
|
122
129
|
if not missing:
|
|
123
130
|
return None
|
|
124
|
-
if tenant_id is None:
|
|
125
|
-
|
|
126
|
-
raise missing_tenant_error(entity, "INSERT")
|
|
127
|
-
return None
|
|
131
|
+
if tenant_id is None and strict:
|
|
132
|
+
raise missing_tenant_error(entity, "INSERT")
|
|
128
133
|
stamped = stmt._generate()
|
|
129
134
|
stamped._multi_values = tuple(groups)
|
|
130
135
|
execute_state.statement = stamped
|
|
@@ -62,6 +62,8 @@ _OP_UPDATE = "update"
|
|
|
62
62
|
_OP_SOFT_DELETE = "soft_delete"
|
|
63
63
|
_OP_DELETE = "delete"
|
|
64
64
|
|
|
65
|
+
_LISTENERS_ATTACHED = "_sm_entity_listeners_attached"
|
|
66
|
+
|
|
65
67
|
|
|
66
68
|
def _entity_label(obj: object) -> str:
|
|
67
69
|
"""Return 'ClassName' for a mapped entity instance."""
|
|
@@ -106,7 +108,17 @@ def attach_session_listeners(session_class: type[Session]) -> None:
|
|
|
106
108
|
|
|
107
109
|
For a process with no ``DatabaseState`` (the Celery worker's sync engine),
|
|
108
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.
|
|
109
119
|
"""
|
|
120
|
+
if any(base.__dict__.get(_LISTENERS_ATTACHED) for base in session_class.__mro__):
|
|
121
|
+
return
|
|
110
122
|
for name, fn in (
|
|
111
123
|
("before_flush", _before_flush_listener),
|
|
112
124
|
("after_flush", _mark_session_written),
|
|
@@ -114,8 +126,8 @@ def attach_session_listeners(session_class: type[Session]) -> None:
|
|
|
114
126
|
("do_orm_execute", _mark_dml_written),
|
|
115
127
|
("do_orm_execute", filter_statements),
|
|
116
128
|
):
|
|
117
|
-
|
|
118
|
-
|
|
129
|
+
event.listen(session_class, name, fn)
|
|
130
|
+
setattr(session_class, _LISTENERS_ATTACHED, True)
|
|
119
131
|
|
|
120
132
|
|
|
121
133
|
def _before_flush_listener(
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Keep revisions autogenerated on SQLite portable to PostgreSQL (GH #342).
|
|
2
|
+
|
|
3
|
+
Autogenerate against the default SQLite database emits things Postgres rejects
|
|
4
|
+
or never sees, and neither ``alembic check`` nor the boot check notices:
|
|
5
|
+
|
|
6
|
+
* Boolean ``server_default`` rendered as ``sa.text('0')`` — Postgres refuses an
|
|
7
|
+
integer default on a boolean column. :func:`rewrite_boolean_defaults` and
|
|
8
|
+
:func:`render_boolean_default` emit ``sa.false()`` / ``sa.true()`` instead.
|
|
9
|
+
* Expression indexes (``lower(email)``) are skipped on SQLite because it cannot
|
|
10
|
+
reflect them. :func:`warn_unverifiable_expression_indexes` says so loudly.
|
|
11
|
+
* ``op.drop_table`` leaves ``CREATE TYPE`` behind on Postgres, so a downgrade
|
|
12
|
+
followed by an upgrade fails with ``DuplicateObject``. Use
|
|
13
|
+
:func:`drop_enums_if_postgres` in the revision's ``downgrade``.
|
|
14
|
+
|
|
15
|
+
``simple_module_db.migrations`` wires the first two into the hooks every
|
|
16
|
+
``env.py`` already passes to Alembic.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import sqlalchemy as sa
|
|
26
|
+
from alembic.operations.ops import AddColumnOp, AlterColumnOp, CreateTableOp
|
|
27
|
+
from simple_module_core.diagnostics._expression_index import find_expression_indexes
|
|
28
|
+
from sqlalchemy.dialects import postgresql
|
|
29
|
+
from sqlalchemy.schema import DefaultClause
|
|
30
|
+
from sqlalchemy.sql.elements import ColumnElement, False_, TextClause, True_
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("alembic.env")
|
|
33
|
+
|
|
34
|
+
_FALSE_LITERALS = frozenset({"0", "false"})
|
|
35
|
+
_TRUE_LITERALS = frozenset({"1", "true"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def boolean_default_for(default: Any) -> ColumnElement[bool] | None:
|
|
39
|
+
"""Map a Boolean ``server_default`` spelled as 0/1/true/false to ``sa.false()``/``sa.true()``.
|
|
40
|
+
|
|
41
|
+
Accepts a ``DefaultClause``, ``text()``, bare string, or SQLAlchemy
|
|
42
|
+
``true()``/``false()``; returns ``None`` when it is none of those literals.
|
|
43
|
+
"""
|
|
44
|
+
arg = default.arg if isinstance(default, DefaultClause) else default
|
|
45
|
+
if isinstance(arg, False_):
|
|
46
|
+
return sa.false()
|
|
47
|
+
if isinstance(arg, True_):
|
|
48
|
+
return sa.true()
|
|
49
|
+
text = arg.text if isinstance(arg, TextClause) else arg
|
|
50
|
+
if not isinstance(text, str):
|
|
51
|
+
return None
|
|
52
|
+
literal = text.strip().strip("()").strip("'\"").lower()
|
|
53
|
+
if literal in _FALSE_LITERALS:
|
|
54
|
+
return sa.false()
|
|
55
|
+
if literal in _TRUE_LITERALS:
|
|
56
|
+
return sa.true()
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def render_boolean_default(obj: Any, autogen_context: Any) -> str | bool:
|
|
61
|
+
"""``render_item`` branch: render ``true()``/``false()`` defaults as such.
|
|
62
|
+
|
|
63
|
+
SQLAlchemy compiles ``false()`` under SQLite to ``0``, which Alembic then
|
|
64
|
+
renders as ``sa.text('0')``. Intercepting the ``server_default`` item keeps
|
|
65
|
+
the portable expression the model declared.
|
|
66
|
+
"""
|
|
67
|
+
arg = obj.arg if isinstance(obj, DefaultClause) else obj
|
|
68
|
+
if not isinstance(arg, (True_, False_)):
|
|
69
|
+
return False
|
|
70
|
+
prefix = autogen_context.opts.get("sqlalchemy_module_prefix", "sa.")
|
|
71
|
+
return f"{prefix}{'true' if isinstance(arg, True_) else 'false'}()"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def iter_ops_recursive(container: Any) -> Iterator[Any]:
|
|
75
|
+
"""Yield every op under ``container``, descending into nested op groups.
|
|
76
|
+
|
|
77
|
+
Autogenerate does not emit a flat op list: index operations for a table are
|
|
78
|
+
grouped inside a ``ModifyTableOps`` container alongside the top-level
|
|
79
|
+
``CreateTableOp``/``DropTableOp``. A dedup check that only looks at
|
|
80
|
+
``container.ops`` therefore sees no ``CreateIndexOp`` at all and re-injects
|
|
81
|
+
an index the dialect already emitted — which is exactly how a dialect that
|
|
82
|
+
*can* reflect expression-based indexes (PostgreSQL) ended up with a
|
|
83
|
+
duplicate ``CREATE INDEX`` in its initial migration.
|
|
84
|
+
"""
|
|
85
|
+
for op in container.ops:
|
|
86
|
+
yield op
|
|
87
|
+
if hasattr(op, "ops"):
|
|
88
|
+
yield from iter_ops_recursive(op)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def rewrite_boolean_defaults(upgrade_ops: Any) -> None:
|
|
92
|
+
"""Rewrite integer-looking defaults on Boolean columns in a revision's op tree.
|
|
93
|
+
|
|
94
|
+
Covers columns of ``create_table``, ``add_column`` and ``alter_column``
|
|
95
|
+
(the latter via ``existing_type``).
|
|
96
|
+
"""
|
|
97
|
+
for op in iter_ops_recursive(upgrade_ops):
|
|
98
|
+
if isinstance(op, CreateTableOp):
|
|
99
|
+
columns = [c for c in op.columns if isinstance(c, sa.Column)]
|
|
100
|
+
elif isinstance(op, AddColumnOp):
|
|
101
|
+
columns = [op.column]
|
|
102
|
+
elif isinstance(op, AlterColumnOp):
|
|
103
|
+
replacement = None
|
|
104
|
+
if isinstance(op.existing_type or op.modify_type, sa.Boolean):
|
|
105
|
+
replacement = boolean_default_for(op.modify_server_default)
|
|
106
|
+
if replacement is not None:
|
|
107
|
+
op.modify_server_default = replacement
|
|
108
|
+
continue
|
|
109
|
+
else:
|
|
110
|
+
continue
|
|
111
|
+
for column in columns:
|
|
112
|
+
if not isinstance(column.type, sa.Boolean) or column.server_default is None:
|
|
113
|
+
continue
|
|
114
|
+
replacement = boolean_default_for(column.server_default)
|
|
115
|
+
if replacement is not None:
|
|
116
|
+
column.server_default = DefaultClause(replacement)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def warn_unverifiable_expression_indexes(dialect_name: str | None, metadata: sa.MetaData) -> list:
|
|
120
|
+
"""Warn when autogenerating on SQLite while models declare expression indexes.
|
|
121
|
+
|
|
122
|
+
A warning rather than an error: autogenerate also runs for ordinary,
|
|
123
|
+
unrelated revisions, and failing every one of them for an index that is
|
|
124
|
+
already in history would block normal work. The warning is emitted on every
|
|
125
|
+
run, names each index, and ``alembic check`` on Postgres
|
|
126
|
+
(``make migrations-roundtrip-pg``) is the real gate. Returns the
|
|
127
|
+
``(table, index)`` pairs it warned about.
|
|
128
|
+
"""
|
|
129
|
+
if dialect_name != "sqlite":
|
|
130
|
+
return []
|
|
131
|
+
found = find_expression_indexes(metadata.tables.values())
|
|
132
|
+
if not found:
|
|
133
|
+
return []
|
|
134
|
+
listing = "\n".join(f" - {index} on {table}" for table, index in found)
|
|
135
|
+
logger.warning(
|
|
136
|
+
"\n%s\n"
|
|
137
|
+
"WARNING: autogenerating on SQLite. SQLite cannot reflect expression indexes,\n"
|
|
138
|
+
"so this revision does NOT account for the following (they are neither\n"
|
|
139
|
+
"created nor verified here) and `alembic check` will not notice drift:\n"
|
|
140
|
+
"%s\n"
|
|
141
|
+
"Add them to the revision by hand and verify on PostgreSQL before merging\n"
|
|
142
|
+
"(`make migrations-roundtrip-pg`). See docs/module-authoring.md, Migrations.\n"
|
|
143
|
+
"%s",
|
|
144
|
+
"=" * 78,
|
|
145
|
+
listing,
|
|
146
|
+
"=" * 78,
|
|
147
|
+
)
|
|
148
|
+
return found
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def drop_enums_if_postgres(op: Any, *names: str) -> None:
|
|
152
|
+
"""Drop the named enum types on PostgreSQL; a no-op elsewhere.
|
|
153
|
+
|
|
154
|
+
Call it at the end of ``downgrade()``, after the tables that use the types
|
|
155
|
+
are dropped. ``op.drop_table`` leaves the ``CREATE TYPE`` behind, and
|
|
156
|
+
autogenerate never emits the ``Enum.drop``, so without this a
|
|
157
|
+
``downgrade`` followed by an ``upgrade`` fails with ``DuplicateObject``.
|
|
158
|
+
SQLite has no such object, which is why the round trip passes there.
|
|
159
|
+
"""
|
|
160
|
+
bind = op.get_bind()
|
|
161
|
+
if bind.dialect.name != "postgresql":
|
|
162
|
+
return
|
|
163
|
+
for name in names:
|
|
164
|
+
postgresql.ENUM(name=name).drop(bind, checkfirst=True)
|