simple-module-db 0.0.37__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.37 → simple_module_db-0.0.39}/.gitignore +1 -2
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/PKG-INFO +8 -8
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/pyproject.toml +8 -8
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/__init__.py +17 -0
- simple_module_db-0.0.39/simple_module_db/_bulk_audit_records.py +129 -0
- simple_module_db-0.0.39/simple_module_db/_core_joins.py +239 -0
- simple_module_db-0.0.39/simple_module_db/_core_walk.py +188 -0
- simple_module_db-0.0.39/simple_module_db/_migration_render.py +72 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/_outer_joins.py +1 -1
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/audit.py +9 -7
- simple_module_db-0.0.39/simple_module_db/audit_sinks.py +59 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/base.py +1 -1
- simple_module_db-0.0.39/simple_module_db/bulk_audit.py +193 -0
- simple_module_db-0.0.39/simple_module_db/bulk_audit_targets.py +96 -0
- simple_module_db-0.0.39/simple_module_db/core_scope.py +93 -0
- simple_module_db-0.0.39/simple_module_db/engine_options.py +53 -0
- simple_module_db-0.0.39/simple_module_db/independent.py +52 -0
- simple_module_db-0.0.39/simple_module_db/leases.py +188 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/listeners.py +42 -21
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/migrations.py +9 -69
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/model_registry.py +4 -0
- simple_module_db-0.0.39/simple_module_db/pagination.py +96 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/query_filter.py +87 -75
- simple_module_db-0.0.39/simple_module_db/resource_policy.py +158 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/session.py +22 -3
- simple_module_db-0.0.39/simple_module_db/stored_settings.py +192 -0
- simple_module_db-0.0.39/simple_module_db/tenant_cleanup.py +22 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/transaction.py +7 -3
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/types.py +1 -1
- simple_module_db-0.0.39/simple_module_db/user_directory_sql.py +41 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/_audit_models.py +14 -5
- simple_module_db-0.0.39/tests/_bulk_audit_support.py +44 -0
- simple_module_db-0.0.39/tests/_core_scope_models.py +88 -0
- simple_module_db-0.0.39/tests/_db_shape_matrix.py +44 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/_models.py +39 -0
- simple_module_db-0.0.39/tests/conftest.py +38 -0
- simple_module_db-0.0.39/tests/test_audit_per_engine.py +134 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_base.py +1 -1
- simple_module_db-0.0.39/tests/test_db_bulk_audit.py +193 -0
- simple_module_db-0.0.39/tests/test_db_bulk_audit_targets.py +166 -0
- simple_module_db-0.0.39/tests/test_db_core_full_setup_join.py +122 -0
- simple_module_db-0.0.39/tests/test_db_core_in_orm_select.py +142 -0
- simple_module_db-0.0.39/tests/test_db_core_join_scope.py +131 -0
- simple_module_db-0.0.39/tests/test_db_core_nested_scope.py +136 -0
- simple_module_db-0.0.39/tests/test_db_engine_options.py +106 -0
- simple_module_db-0.0.39/tests/test_db_independent_checkpoint.py +72 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_db_logging.py +2 -2
- simple_module_db-0.0.39/tests/test_db_pagination.py +75 -0
- simple_module_db-0.0.39/tests/test_db_pagination_options.py +47 -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_stored_settings.py +85 -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_independent_session.py +47 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_mixins.py +4 -4
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_query_filter_outer_joins.py +9 -11
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_query_filter_rel_outer_joins.py +2 -2
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_query_filters.py +2 -2
- simple_module_db-0.0.39/tests/test_rollback_logging.py +23 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_writes.py +2 -2
- simple_module_db-0.0.37/simple_module_db/subquery_guard.py +0 -49
- simple_module_db-0.0.37/tests/conftest.py +0 -54
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/LICENSE +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/README.md +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/callbacks.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/deps.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/extensions.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/flush_guard.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/insert_guard.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/migration_portability.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/mixins.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/provider.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/py.typed +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/search.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/tenancy.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/writes.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_audit.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_aware_datetime.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_db_extensions.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_db_extensions_pg.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_db_listeners_attach.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_db_sqlite_savepoint.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_default_tenant.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_migration_portability.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_migrations.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_multi_tenancy.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_session.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_soft_delete_shapes.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_tenant_dml.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_tenant_shapes.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_tenant_strict.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.39}/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.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]
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from simple_module_db.audit import AuditRecord
|
|
4
4
|
from simple_module_db.base import create_module_base
|
|
5
|
+
from simple_module_db.bulk_audit import UnauditedWriteError, record_bulk_audit
|
|
5
6
|
from simple_module_db.callbacks import OnCommitCallback
|
|
6
7
|
from simple_module_db.deps import get_db
|
|
7
8
|
from simple_module_db.extensions import UnsupportedDatabaseError, ensure_db_extensions
|
|
9
|
+
from simple_module_db.independent import independent_session
|
|
8
10
|
from simple_module_db.migration_portability import drop_enums_if_postgres
|
|
9
11
|
from simple_module_db.migrations import (
|
|
10
12
|
build_module_metadata,
|
|
@@ -13,6 +15,13 @@ from simple_module_db.migrations import (
|
|
|
13
15
|
render_item,
|
|
14
16
|
)
|
|
15
17
|
from simple_module_db.mixins import AuditMixin, MultiTenantMixin, SoftDeleteMixin, VersionedMixin
|
|
18
|
+
from simple_module_db.pagination import (
|
|
19
|
+
Page,
|
|
20
|
+
PageParams,
|
|
21
|
+
clamp_page,
|
|
22
|
+
paginate,
|
|
23
|
+
total_pages,
|
|
24
|
+
)
|
|
16
25
|
from simple_module_db.provider import DatabaseProvider, detect_provider
|
|
17
26
|
from simple_module_db.search import LIKE_ESCAPE_CHAR, like_contains_pattern, like_prefix_pattern
|
|
18
27
|
from simple_module_db.session import DatabaseState, RequestSession, init_db
|
|
@@ -50,15 +59,19 @@ __all__ = [
|
|
|
50
59
|
"MissingTenantError",
|
|
51
60
|
"MultiTenantMixin",
|
|
52
61
|
"OnCommitCallback",
|
|
62
|
+
"Page",
|
|
63
|
+
"PageParams",
|
|
53
64
|
"RequestSession",
|
|
54
65
|
"SoftDeleteMixin",
|
|
55
66
|
"TSVector",
|
|
56
67
|
"TenantIsolationError",
|
|
68
|
+
"UnauditedWriteError",
|
|
57
69
|
"UnsupportedDatabaseError",
|
|
58
70
|
"VersionedMixin",
|
|
59
71
|
"all_tenants",
|
|
60
72
|
"bind_current_tenant",
|
|
61
73
|
"build_module_metadata",
|
|
74
|
+
"clamp_page",
|
|
62
75
|
"create_module_base",
|
|
63
76
|
"current_tenant_id",
|
|
64
77
|
"detect_provider",
|
|
@@ -67,6 +80,7 @@ __all__ = [
|
|
|
67
80
|
"finalize_session",
|
|
68
81
|
"get_db",
|
|
69
82
|
"hard_delete",
|
|
83
|
+
"independent_session",
|
|
70
84
|
"init_db",
|
|
71
85
|
"is_valid_tenant_id",
|
|
72
86
|
"like_contains_pattern",
|
|
@@ -75,6 +89,9 @@ __all__ = [
|
|
|
75
89
|
"make_process_revision_directives",
|
|
76
90
|
"mark_written",
|
|
77
91
|
"no_tenant",
|
|
92
|
+
"paginate",
|
|
93
|
+
"record_bulk_audit",
|
|
78
94
|
"render_item",
|
|
79
95
|
"tenant_context",
|
|
96
|
+
"total_pages",
|
|
80
97
|
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Audit record building and delivery shared by the bulk-audit hook and its manual entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from typing import Any, Final
|
|
8
|
+
|
|
9
|
+
from simple_module_core.correlation import correlation_id
|
|
10
|
+
from sqlalchemy import inspect as sa_inspect
|
|
11
|
+
from sqlalchemy.orm import ORMExecuteState, Session
|
|
12
|
+
from sqlalchemy.sql.elements import BindParameter
|
|
13
|
+
from sqlalchemy.sql.visitors import iterate
|
|
14
|
+
|
|
15
|
+
from simple_module_db.audit import AuditRecord, _serialize, format_pk
|
|
16
|
+
from simple_module_db.query_filter import engine_policy
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("simple_module_db.bulk_audit")
|
|
19
|
+
|
|
20
|
+
ACTION_BULK_INSERTED: Final = "bulk_inserted"
|
|
21
|
+
ACTION_BULK_UPDATED: Final = "bulk_updated"
|
|
22
|
+
ACTION_BULK_DELETED: Final = "bulk_deleted"
|
|
23
|
+
ACTION_BULK_SOFT_DELETED: Final = "bulk_soft_deleted"
|
|
24
|
+
ACTION_BULK_RESTORED: Final = "bulk_restored"
|
|
25
|
+
SUMMARY_ENTITY_ID: Final = "*"
|
|
26
|
+
|
|
27
|
+
_SOFT_DELETE_FLAG: Final = "is_deleted"
|
|
28
|
+
_ACTIVE_KEY: Final = "_sm_bulk_audit_active"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Ctx:
|
|
32
|
+
"""What one audited statement records, built before it runs."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, entity_type: str, action: str, changes: list[dict[str, Any]]) -> None:
|
|
35
|
+
self.entity_type = entity_type
|
|
36
|
+
self.action = action
|
|
37
|
+
self.row_actions = [action]
|
|
38
|
+
self.changes = changes
|
|
39
|
+
self.user_id = _current_user()
|
|
40
|
+
self.correlation_id = correlation_id.get("") or None
|
|
41
|
+
|
|
42
|
+
def record(
|
|
43
|
+
self,
|
|
44
|
+
entity_id: str,
|
|
45
|
+
changes: list[dict[str, Any]] | None = None,
|
|
46
|
+
action: str | None = None,
|
|
47
|
+
) -> AuditRecord:
|
|
48
|
+
return AuditRecord(
|
|
49
|
+
entity_type=self.entity_type,
|
|
50
|
+
entity_id=entity_id,
|
|
51
|
+
action=action or self.action,
|
|
52
|
+
changes=self.changes if changes is None else changes,
|
|
53
|
+
user_id=self.user_id,
|
|
54
|
+
correlation_id=self.correlation_id,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _current_user() -> str | None:
|
|
59
|
+
from simple_module_db.listeners import current_user_id
|
|
60
|
+
|
|
61
|
+
return current_user_id.get()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _where_params(stmt: Any, params: Any) -> dict[str, Any] | None:
|
|
65
|
+
"""The execute params the ``WHERE`` clause binds, so the prefetch can run it."""
|
|
66
|
+
where = getattr(stmt, "whereclause", None)
|
|
67
|
+
if where is None or not isinstance(params, dict):
|
|
68
|
+
return None
|
|
69
|
+
keys = {e.key for e in iterate(where) if isinstance(e, BindParameter)}
|
|
70
|
+
return {k: v for k, v in params.items() if k in keys} or None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _bulk_by_pk_ids(execute_state: ORMExecuteState, cls: type) -> list[str] | None:
|
|
74
|
+
"""``session.execute(update(Model), [{pk..., col...}, ...])``: ids from the rows."""
|
|
75
|
+
params = execute_state.parameters
|
|
76
|
+
if not (execute_state.is_update and isinstance(params, list) and params):
|
|
77
|
+
return None
|
|
78
|
+
pk = [col.key for col in sa_inspect(cls).primary_key]
|
|
79
|
+
if not all(all(k in row for k in pk) for row in params):
|
|
80
|
+
return None
|
|
81
|
+
return [format_pk([row[k] for k in pk]) for row in params]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _summary(ctx: _Ctx, stmt: Any, rowcount: Any, action: str) -> AuditRecord:
|
|
85
|
+
where = getattr(stmt, "whereclause", None)
|
|
86
|
+
summary = [
|
|
87
|
+
{"field": "where_sql", "new": str(where) if where is not None else None},
|
|
88
|
+
{"field": "rowcount", "new": rowcount},
|
|
89
|
+
{"field": "truncated", "new": True},
|
|
90
|
+
]
|
|
91
|
+
return ctx.record(SUMMARY_ENTITY_ID, [*ctx.changes, *summary], action)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _deliver(session: Session, callback: Any, records: list[AuditRecord]) -> None:
|
|
95
|
+
if not records:
|
|
96
|
+
return
|
|
97
|
+
session.info[_ACTIVE_KEY] = True
|
|
98
|
+
try:
|
|
99
|
+
callback(session, records)
|
|
100
|
+
except Exception:
|
|
101
|
+
logger.exception("Audit sink failed for %d bulk record(s)", len(records))
|
|
102
|
+
finally:
|
|
103
|
+
session.info.pop(_ACTIVE_KEY, None)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def record_bulk_audit(
|
|
107
|
+
session: Any,
|
|
108
|
+
entity_type: type | str,
|
|
109
|
+
action: str,
|
|
110
|
+
ids: Iterable[object] | None,
|
|
111
|
+
changes: dict[str, Any] | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Audit a write the listeners cannot see — a ``text("UPDATE ...")`` statement.
|
|
114
|
+
|
|
115
|
+
One record per id (``ids=None`` records one row with ``entity_id`` ``"*"``),
|
|
116
|
+
in the session's transaction: a rollback discards them with the write.
|
|
117
|
+
``changes`` maps column name to new value. A no-op on an unaudited engine;
|
|
118
|
+
never raises because of the audit sink. Accepts an ``AsyncSession`` or its
|
|
119
|
+
sync ``Session``.
|
|
120
|
+
"""
|
|
121
|
+
sync = getattr(session, "sync_session", session)
|
|
122
|
+
callback = getattr(engine_policy(sync), "audit_callback", None)
|
|
123
|
+
if callback is None:
|
|
124
|
+
return
|
|
125
|
+
name = entity_type if isinstance(entity_type, str) else entity_type.__name__
|
|
126
|
+
diff = [{"field": k, "new": _serialize(v)} for k, v in (changes or {}).items()]
|
|
127
|
+
ctx = _Ctx(name, action, diff)
|
|
128
|
+
keys = [SUMMARY_ENTITY_ID] if ids is None else [str(i) for i in ids]
|
|
129
|
+
_deliver(sync, callback, [ctx.record(k) for k in keys])
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Tenant / soft-delete criteria for Core joins over ``Model.__table__`` (#506).
|
|
2
|
+
|
|
3
|
+
ORM joins get their criteria from ``with_loader_criteria``, which renders into
|
|
4
|
+
each join's ``ON``. A Core join — ``tag.outerjoin(link, ...)`` in
|
|
5
|
+
``select_from``, or ``select(...).outerjoin(link, ...)`` on a raw table — has
|
|
6
|
+
no loader criteria, and a ``WHERE`` on its nullable side would turn the outer
|
|
7
|
+
join into an inner one. So this module rewrites the joins themselves:
|
|
8
|
+
|
|
9
|
+
* a LEFT join's nullable (right) side gets its predicates in ``ON``, which
|
|
10
|
+
filters it exactly;
|
|
11
|
+
* a FULL join preserves both sides, and ``ON`` never removes a preserved row,
|
|
12
|
+
so both sides' predicates go in ``ON`` *and* each side owes
|
|
13
|
+
``pred OR <side padded>`` (its primary key ``IS NULL``) to whatever encloses
|
|
14
|
+
the join — that drops another tenant's (or a trashed) row coming back
|
|
15
|
+
unmatched while keeping the NULL padding the join adds;
|
|
16
|
+
* an INNER join's sides, and a LEFT join's preserved side, owe their
|
|
17
|
+
predicates to the enclosing join's ``ON`` — or, at the top, to ``WHERE``.
|
|
18
|
+
|
|
19
|
+
The FULL-join form is exact, not merely safe: a row survives only if every
|
|
20
|
+
side that is present passed its own predicates, which is what pre-filtering
|
|
21
|
+
both inputs would give, without wrapping the tables in subqueries (which
|
|
22
|
+
would break every column reference to them).
|
|
23
|
+
|
|
24
|
+
Every plain leaf inside a join is reported back, so the caller does not also
|
|
25
|
+
``WHERE``-filter it through the columns clause. ORM-annotated leaves are left
|
|
26
|
+
to their loader criteria. ``core_scope`` calls this for each SELECT in a
|
|
27
|
+
statement, nested ones included.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from sqlalchemy import and_, exc, or_
|
|
36
|
+
from sqlalchemy.sql.selectable import Alias, FromGrouping, Join, Select, TableClause
|
|
37
|
+
|
|
38
|
+
Predicates = Callable[[Any], list[Any]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _base_name(from_obj: Any) -> str | None:
|
|
42
|
+
while isinstance(from_obj, Alias): # ``table.alias().alias()`` nests
|
|
43
|
+
from_obj = from_obj.element
|
|
44
|
+
return getattr(from_obj, "name", None)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_orm(el: Any) -> bool:
|
|
48
|
+
return "parententity" in getattr(el, "_annotations", {})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_plain_leaf(node: Any) -> bool:
|
|
52
|
+
"""A Core table or alias of one — not an ORM-annotated occurrence."""
|
|
53
|
+
if _is_orm(node):
|
|
54
|
+
return False
|
|
55
|
+
base = node
|
|
56
|
+
while isinstance(base, Alias):
|
|
57
|
+
base = base.element
|
|
58
|
+
return isinstance(base, TableClause)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _padded(leaf: Any) -> Any | None:
|
|
62
|
+
"""``leaf``'s row is the NULL padding of an outer join: its primary key is NULL."""
|
|
63
|
+
pk = list(leaf.primary_key)
|
|
64
|
+
return and_(*(c.is_(None) for c in pk)) if pk else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _Scoper:
|
|
68
|
+
def __init__(self, predicates_for: Predicates) -> None:
|
|
69
|
+
self.predicates_for = predicates_for
|
|
70
|
+
self.covered: list[Any] = []
|
|
71
|
+
|
|
72
|
+
def scope(self, node: Any) -> tuple[Any, list[Any]]:
|
|
73
|
+
"""``node`` with its joins scoped, and the predicates its rows still owe."""
|
|
74
|
+
if isinstance(node, Join):
|
|
75
|
+
return self._join(node)
|
|
76
|
+
if isinstance(node, FromGrouping): # a join nested as another join's side
|
|
77
|
+
inner, owed = self.scope(node.element)
|
|
78
|
+
return (node if inner is node.element else FromGrouping(inner)), owed
|
|
79
|
+
if not is_plain_leaf(node):
|
|
80
|
+
return node, []
|
|
81
|
+
preds = self.predicates_for(node)
|
|
82
|
+
if preds:
|
|
83
|
+
self.covered.append(node)
|
|
84
|
+
return node, [(node, preds)] if preds else []
|
|
85
|
+
|
|
86
|
+
def _join(self, node: Join) -> tuple[Any, list[Any]]:
|
|
87
|
+
left, owed_left = self.scope(node.left)
|
|
88
|
+
right, owed_right = self.scope(node.right)
|
|
89
|
+
if not node.isouter:
|
|
90
|
+
on, owed = [], owed_left + owed_right
|
|
91
|
+
elif node.full:
|
|
92
|
+
on, owed = owed_left + owed_right, [_or_padded(o) for o in owed_left + owed_right]
|
|
93
|
+
else:
|
|
94
|
+
on, owed = owed_right, owed_left
|
|
95
|
+
if not on and left is node.left and right is node.right:
|
|
96
|
+
return node, owed
|
|
97
|
+
onclause = and_(node.onclause, *_flatten(on)) if on else node.onclause # ty: ignore[invalid-argument-type]
|
|
98
|
+
return Join(left, right, onclause, isouter=node.isouter, full=node.full), owed
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _or_padded(owed: tuple) -> tuple:
|
|
102
|
+
"""``owed`` as ``pred OR <leaf padded>``: tagged with a third item so it is padded once."""
|
|
103
|
+
if len(owed) == 3:
|
|
104
|
+
return owed
|
|
105
|
+
leaf, preds = owed
|
|
106
|
+
padded = _padded(leaf)
|
|
107
|
+
# No primary key to tell padding apart: keep the plain predicates, which
|
|
108
|
+
# over-filter (drop the padded rows) but never leak.
|
|
109
|
+
return (leaf, preds) if padded is None else (leaf, [or_(and_(*preds), padded)], True)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _flatten(owed: list[tuple]) -> list[Any]:
|
|
113
|
+
return [p for _leaf, preds, *_ in owed for p in preds]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class JoinScope:
|
|
117
|
+
"""What scoping one SELECT's joins produced, for the caller to install."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, from_obj: tuple, setup_joins: tuple, where: list, covered: list) -> None:
|
|
120
|
+
self.from_obj = from_obj
|
|
121
|
+
self.setup_joins = setup_joins
|
|
122
|
+
self.where = where
|
|
123
|
+
self.covered = covered
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def scope_joins(stmt: Select, predicates_for: Predicates) -> JoinScope:
|
|
127
|
+
"""Scope ``stmt``'s Core joins: its ``FROM`` joins and ``.join()`` steps.
|
|
128
|
+
|
|
129
|
+
``covered`` lists every plain leaf filtered here — in ``ON`` or through
|
|
130
|
+
``where`` — which must not get another ``WHERE`` predicate from the
|
|
131
|
+
caller: on a nullable side it would make the outer join inner.
|
|
132
|
+
``predicates_for`` raises under strict mode with no tenant bound, so a
|
|
133
|
+
Core join fails closed like everything else.
|
|
134
|
+
|
|
135
|
+
Once any ``.join()`` step is FULL, every ``WHERE`` predicate owed here
|
|
136
|
+
takes the padded form ``pred OR <pk> IS NULL``. That is never a leak —
|
|
137
|
+
a primary key is NULL only on a row an outer join padded, which carries
|
|
138
|
+
nothing of that table — and it is what keeps the FULL join's left side
|
|
139
|
+
from being filtered down to a RIGHT join.
|
|
140
|
+
"""
|
|
141
|
+
scoper = _Scoper(predicates_for)
|
|
142
|
+
owed: list[tuple] = []
|
|
143
|
+
froms: list[Any] = []
|
|
144
|
+
for from_obj in stmt._from_obj:
|
|
145
|
+
if isinstance(from_obj, Join):
|
|
146
|
+
from_obj, more = scoper.scope(from_obj)
|
|
147
|
+
owed.extend(more)
|
|
148
|
+
froms.append(from_obj)
|
|
149
|
+
joins = [_scope_setup_join(stmt, i, scoper, owed) for i in range(len(stmt._setup_joins))]
|
|
150
|
+
if any(entry[3].get("full") for entry in stmt._setup_joins):
|
|
151
|
+
owed = [_or_padded(o) for o in owed]
|
|
152
|
+
return JoinScope(tuple(froms), tuple(joins), _flatten(owed), scoper.covered)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _scope_setup_join(stmt: Select, index: int, scoper: _Scoper, owed: list[tuple]) -> tuple:
|
|
156
|
+
"""One ``select(...).join(target, onclause)`` step over a Core target.
|
|
157
|
+
|
|
158
|
+
The target is scoped like a ``Join``'s right side. A FULL step also
|
|
159
|
+
preserves its left side — the FROM built so far — so that side's plain
|
|
160
|
+
leaves get their (padded) predicates in this ``ON`` and owe the padded
|
|
161
|
+
form to ``WHERE``. Without an explicit ``onclause`` the one SQLAlchemy
|
|
162
|
+
infers is used; if it cannot be resolved, predicates fall back to plain
|
|
163
|
+
``WHERE`` (over-filters, never leaks).
|
|
164
|
+
"""
|
|
165
|
+
entry = stmt._setup_joins[index]
|
|
166
|
+
target, onclause, from_, flags = entry
|
|
167
|
+
full = bool(flags.get("full"))
|
|
168
|
+
outer = full or bool(flags.get("isouter"))
|
|
169
|
+
target, target_owed = scoper.scope(target)
|
|
170
|
+
resolved = None
|
|
171
|
+
if full or (outer and onclause is None and target_owed):
|
|
172
|
+
resolved = _resolved_join(stmt, index)
|
|
173
|
+
left_owed = _left_owed(resolved.left, scoper, owed) if full and resolved is not None else []
|
|
174
|
+
if not target_owed and not left_owed:
|
|
175
|
+
return (target, onclause, from_, flags)
|
|
176
|
+
if onclause is None and resolved is not None:
|
|
177
|
+
onclause = resolved.onclause
|
|
178
|
+
if not outer or onclause is None:
|
|
179
|
+
owed.extend(target_owed)
|
|
180
|
+
return (target, onclause, from_, flags)
|
|
181
|
+
on = [*target_owed, *(_or_padded(o) for o in left_owed)]
|
|
182
|
+
if full:
|
|
183
|
+
owed.extend(target_owed)
|
|
184
|
+
return (target, and_(onclause, *_flatten(on)), from_, flags) # ty: ignore[invalid-argument-type]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _resolved_join(stmt: Select, index: int) -> Join | None:
|
|
188
|
+
"""The ``Join`` SQLAlchemy builds for setup join ``index``: its left side and ``ON``."""
|
|
189
|
+
target = stmt._setup_joins[index][0]
|
|
190
|
+
probe = stmt._generate()
|
|
191
|
+
probe._setup_joins = stmt._setup_joins[: index + 1]
|
|
192
|
+
try:
|
|
193
|
+
froms = probe.get_final_froms()
|
|
194
|
+
except exc.SQLAlchemyError:
|
|
195
|
+
return None
|
|
196
|
+
stack = list(froms)
|
|
197
|
+
while stack:
|
|
198
|
+
node = stack.pop()
|
|
199
|
+
if isinstance(node, FromGrouping):
|
|
200
|
+
stack.append(node.element)
|
|
201
|
+
elif isinstance(node, Join):
|
|
202
|
+
if node.right is target or getattr(node.right, "element", None) is target:
|
|
203
|
+
return node
|
|
204
|
+
stack.extend((node.left, node.right))
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _left_owed(left: Any, scoper: _Scoper, owed: list[tuple]) -> list[tuple]:
|
|
209
|
+
"""What the plain leaves preserved on a FULL step's left side owe.
|
|
210
|
+
|
|
211
|
+
A leaf already filtered in an earlier outer join's ``ON`` owes nothing;
|
|
212
|
+
one already owing ``WHERE`` predicates is reported as is; one nobody has
|
|
213
|
+
scoped yet (a columns-clause FROM) is scoped here and joins ``owed``.
|
|
214
|
+
"""
|
|
215
|
+
found: list[tuple] = []
|
|
216
|
+
stack = [left]
|
|
217
|
+
while stack:
|
|
218
|
+
node = stack.pop()
|
|
219
|
+
if isinstance(node, FromGrouping):
|
|
220
|
+
stack.append(node.element)
|
|
221
|
+
elif isinstance(node, Join):
|
|
222
|
+
stack.extend((node.left, node.right))
|
|
223
|
+
elif is_plain_leaf(node):
|
|
224
|
+
mine = [o for o in owed if same_from(o[0], node)]
|
|
225
|
+
if mine:
|
|
226
|
+
found.extend(mine)
|
|
227
|
+
elif not any(same_from(node, c) for c in scoper.covered):
|
|
228
|
+
preds = scoper.predicates_for(node)
|
|
229
|
+
if preds:
|
|
230
|
+
scoper.covered.append(node)
|
|
231
|
+
owed.append((node, preds))
|
|
232
|
+
found.append((node, preds))
|
|
233
|
+
return found
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def same_from(a: Any, b: Any) -> bool:
|
|
237
|
+
"""One FROM occurrence: the same object, or clones of one (an alias the traversal
|
|
238
|
+
copied while a column still points at the original — SQL renders them as one)."""
|
|
239
|
+
return a is b or not a._cloned_set.isdisjoint(b._cloned_set)
|