simple-module-db 0.0.37__tar.gz → 0.0.38__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.38}/.gitignore +1 -2
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/PKG-INFO +2 -2
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/pyproject.toml +2 -2
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/__init__.py +17 -0
- simple_module_db-0.0.38/simple_module_db/_bulk_audit_records.py +129 -0
- simple_module_db-0.0.38/simple_module_db/_core_joins.py +239 -0
- simple_module_db-0.0.38/simple_module_db/_core_walk.py +188 -0
- simple_module_db-0.0.38/simple_module_db/_migration_render.py +72 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/_outer_joins.py +1 -1
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/audit.py +9 -7
- simple_module_db-0.0.38/simple_module_db/audit_sinks.py +59 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/base.py +1 -1
- simple_module_db-0.0.38/simple_module_db/bulk_audit.py +193 -0
- simple_module_db-0.0.38/simple_module_db/bulk_audit_targets.py +96 -0
- simple_module_db-0.0.38/simple_module_db/core_scope.py +93 -0
- simple_module_db-0.0.38/simple_module_db/engine_options.py +53 -0
- simple_module_db-0.0.38/simple_module_db/independent.py +34 -0
- simple_module_db-0.0.38/simple_module_db/leases.py +188 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/listeners.py +37 -21
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/migrations.py +9 -69
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/model_registry.py +4 -0
- simple_module_db-0.0.38/simple_module_db/pagination.py +96 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/query_filter.py +87 -75
- simple_module_db-0.0.38/simple_module_db/resource_policy.py +158 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/session.py +18 -3
- simple_module_db-0.0.38/simple_module_db/stored_settings.py +192 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/transaction.py +3 -1
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/types.py +1 -1
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/_audit_models.py +14 -5
- simple_module_db-0.0.38/tests/_bulk_audit_support.py +44 -0
- simple_module_db-0.0.38/tests/_core_scope_models.py +88 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/_models.py +39 -0
- simple_module_db-0.0.38/tests/conftest.py +38 -0
- simple_module_db-0.0.38/tests/test_audit_per_engine.py +134 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_base.py +1 -1
- simple_module_db-0.0.38/tests/test_db_bulk_audit.py +193 -0
- simple_module_db-0.0.38/tests/test_db_bulk_audit_targets.py +166 -0
- simple_module_db-0.0.38/tests/test_db_core_full_setup_join.py +122 -0
- simple_module_db-0.0.38/tests/test_db_core_in_orm_select.py +142 -0
- simple_module_db-0.0.38/tests/test_db_core_join_scope.py +131 -0
- simple_module_db-0.0.38/tests/test_db_core_nested_scope.py +136 -0
- simple_module_db-0.0.38/tests/test_db_engine_options.py +106 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_db_logging.py +2 -2
- simple_module_db-0.0.38/tests/test_db_pagination.py +75 -0
- simple_module_db-0.0.38/tests/test_db_pagination_options.py +47 -0
- simple_module_db-0.0.38/tests/test_db_stored_settings.py +85 -0
- simple_module_db-0.0.38/tests/test_independent_session.py +47 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_mixins.py +4 -4
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_query_filter_outer_joins.py +9 -11
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_query_filter_rel_outer_joins.py +2 -2
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_query_filters.py +2 -2
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/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.38}/LICENSE +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/README.md +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/callbacks.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/deps.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/extensions.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/flush_guard.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/insert_guard.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/migration_portability.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/mixins.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/provider.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/py.typed +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/search.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/tenancy.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/writes.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_audit.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_aware_datetime.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_db_extensions.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_db_extensions_pg.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_db_listeners_attach.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_db_sqlite_savepoint.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_default_tenant.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_migration_portability.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_migrations.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_multi_tenancy.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_session.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_soft_delete_shapes.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_tenant_dml.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_tenant_shapes.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_tenant_strict.py +0 -0
- {simple_module_db-0.0.37 → simple_module_db-0.0.38}/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.38
|
|
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.38
|
|
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.38"
|
|
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.38",
|
|
32
32
|
"sqlalchemy[asyncio]>=2.0",
|
|
33
33
|
"sqlmodel>=0.0.22",
|
|
34
34
|
]
|
|
@@ -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)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""The cheap walk ``core_scope`` runs on every statement (#506, #559).
|
|
2
|
+
|
|
3
|
+
``references_plain`` answers one question — does this statement read a
|
|
4
|
+
scoped table outside the ORM's annotations? — and ORM statements usually
|
|
5
|
+
answer no, so this runs on nearly every query while tenancy is on. It is a
|
|
6
|
+
reachability search over the clause tree, pruned at ORM-annotated elements;
|
|
7
|
+
everything here is about expanding fewer nodes, or expanding them more
|
|
8
|
+
cheaply, without changing which elements are reachable (#559):
|
|
9
|
+
|
|
10
|
+
* each element is expanded once, however many parents name it;
|
|
11
|
+
* a bound parameter has no clause children and is not expanded;
|
|
12
|
+
* a SELECT's declared children skip its empty collections, and the FROMs
|
|
13
|
+
``froms_read`` would add are added only when its internals do not
|
|
14
|
+
already hold them (or they are ORM-annotated, which the walk prunes);
|
|
15
|
+
* ``table`` is looked up without the costly ``AttributeError`` that a
|
|
16
|
+
column expression's ``__getattr__`` raises for it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from sqlalchemy.sql.elements import ClauseElement, ColumnClause, ColumnElement
|
|
24
|
+
from sqlalchemy.sql.selectable import Alias, Join, Select, TableClause
|
|
25
|
+
from sqlalchemy.sql.traversals import _get_children
|
|
26
|
+
|
|
27
|
+
from simple_module_db._core_joins import _base_name, _is_orm, is_plain_leaf
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def orm_less(stmt: Any) -> bool:
|
|
31
|
+
"""A SELECT naming no ORM entity in its columns, FROM list or joins.
|
|
32
|
+
|
|
33
|
+
Loader criteria have nothing to attach to there — ``exists().where(
|
|
34
|
+
Doc.x == ...)`` — so the tables its ``WHERE`` reads must be scoped like
|
|
35
|
+
Core ones. In an ORM select those tables are the entities' own.
|
|
36
|
+
"""
|
|
37
|
+
targets = [entry[0] for entry in stmt._setup_joins]
|
|
38
|
+
return not any(_is_orm(el) for el in (*stmt._raw_columns, *stmt._from_obj, *targets))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def where_froms(stmt: Any) -> list[Any]:
|
|
42
|
+
return [f for crit in stmt._where_criteria for f in crit._from_objects]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def froms_read(stmt: Any) -> list[Any]:
|
|
46
|
+
"""The FROMs a SELECT reads besides its joins, and the extra FROMs of a DML."""
|
|
47
|
+
if not isinstance(stmt, Select):
|
|
48
|
+
target = stmt.table._deannotate()
|
|
49
|
+
return [f for f in where_froms(stmt) if f is not target]
|
|
50
|
+
froms = [*stmt.columns_clause_froms, *(f for f in stmt._from_obj if not isinstance(f, Join))]
|
|
51
|
+
return [*froms, *(where_froms(stmt) if orm_less(stmt) else core_where_froms(stmt))]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def core_where_froms(stmt: Select) -> list[Any]:
|
|
55
|
+
"""The FROMs a SELECT's ``WHERE`` pulls in through plain (non-ORM) columns.
|
|
56
|
+
|
|
57
|
+
``select(Tag.name).where(link.c.tag_id == Tag.id)`` reads ``link`` only
|
|
58
|
+
through its ``WHERE``. ``where_froms`` cannot be used for an ORM select:
|
|
59
|
+
an entity column's ``_from_objects`` is its bare table too, and an
|
|
60
|
+
``aliased()`` entity's would gain a stray cartesian FROM. So the criteria
|
|
61
|
+
are walked down to their columns, pruning ORM-annotated elements and
|
|
62
|
+
anything that adds no FROM to this SELECT (a nested select's own
|
|
63
|
+
references are scoped where that select is visited).
|
|
64
|
+
"""
|
|
65
|
+
found: list[Any] = []
|
|
66
|
+
stack: list[ClauseElement] = list(stmt._where_criteria)
|
|
67
|
+
while stack:
|
|
68
|
+
el = stack.pop()
|
|
69
|
+
if _is_orm(el) or not el._from_objects:
|
|
70
|
+
continue
|
|
71
|
+
if isinstance(el, ColumnClause):
|
|
72
|
+
found.extend(el._from_objects)
|
|
73
|
+
else:
|
|
74
|
+
stack.extend(el.get_children())
|
|
75
|
+
return found
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def references_plain(stmt: Any, names: set[str]) -> bool:
|
|
79
|
+
"""Whether ``stmt`` reads a scoped table outside the ORM's annotations.
|
|
80
|
+
|
|
81
|
+
ORM-annotated elements are not descended into: their tables are the
|
|
82
|
+
loader criteria's business, and every ORM column leads to a plain table.
|
|
83
|
+
The answer depends only on *which* elements are reachable, so an element
|
|
84
|
+
met twice is expanded once. ``seen`` keeps the objects, not just their
|
|
85
|
+
ids, so a temporary child list cannot free one and recycle its id.
|
|
86
|
+
"""
|
|
87
|
+
stack = [stmt]
|
|
88
|
+
seen: dict[int, Any] = {}
|
|
89
|
+
while stack:
|
|
90
|
+
el = stack.pop()
|
|
91
|
+
if id(el) in seen or _is_orm(el):
|
|
92
|
+
continue
|
|
93
|
+
seen[id(el)] = el
|
|
94
|
+
target = el if isinstance(el, (TableClause, Alias)) else _table_of(el)
|
|
95
|
+
if (
|
|
96
|
+
isinstance(target, (TableClause, Alias))
|
|
97
|
+
and is_plain_leaf(target)
|
|
98
|
+
and _base_name(target) in names
|
|
99
|
+
):
|
|
100
|
+
return True
|
|
101
|
+
if el.__visit_name__ != "bindparam": # a bound value: no clause children
|
|
102
|
+
stack.extend(_children(el))
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
_SELECT_OMIT = ("_correlate", "_correlate_except")
|
|
107
|
+
_EMPTYABLE = (tuple, list)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _children(el: Any) -> list[Any]:
|
|
111
|
+
# ``Select.get_children`` adds every FROM it derives from the columns and
|
|
112
|
+
# WHERE, deannotated, so every ORM statement would look Core. Its declared
|
|
113
|
+
# internals plus the FROMs ``froms_read`` would scope are what counts —
|
|
114
|
+
# less what the internals already hold: the FROM list itself, and the
|
|
115
|
+
# columns-clause FROMs of an all-entity ORM columns clause (#559).
|
|
116
|
+
if isinstance(el, Select):
|
|
117
|
+
kids = _declared_children(el)
|
|
118
|
+
if not _orm_columns_only(el):
|
|
119
|
+
kids.extend(el.columns_clause_froms)
|
|
120
|
+
if orm_less(el):
|
|
121
|
+
kids.extend(where_froms(el))
|
|
122
|
+
return kids
|
|
123
|
+
if el.__visit_name__ in ("update", "delete"):
|
|
124
|
+
return [*el.get_children(), *froms_read(el)]
|
|
125
|
+
return list(el.get_children())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _declared_children(stmt: Select) -> list[Any]:
|
|
129
|
+
"""``HasTraverseInternals.get_children(stmt, omit_attrs=_SELECT_OMIT)``, as a list.
|
|
130
|
+
|
|
131
|
+
The same generated dispatch, minus the call per empty collection: most
|
|
132
|
+
of a SELECT's ~30 declared attributes are ``()`` on any given statement.
|
|
133
|
+
"""
|
|
134
|
+
kids: list[Any] = []
|
|
135
|
+
dispatch = _get_children.run_generated_dispatch(
|
|
136
|
+
stmt, stmt._traverse_internals, "_generated_get_children_traversal"
|
|
137
|
+
)
|
|
138
|
+
for attrname, obj, meth in dispatch:
|
|
139
|
+
if obj is None or (obj.__class__ in _EMPTYABLE and not obj) or attrname in _SELECT_OMIT:
|
|
140
|
+
continue
|
|
141
|
+
kids.extend(meth(obj))
|
|
142
|
+
return kids
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _orm_columns_only(stmt: Select) -> bool:
|
|
146
|
+
"""Every ``columns_clause_froms`` entry is ORM-annotated, so the walk prunes it.
|
|
147
|
+
|
|
148
|
+
Under the ORM compile plugin an annotated column's FROM is its entity's
|
|
149
|
+
``__clause_element__()`` — a ``Mapper``'s or ``AliasedInsp``'s selectable,
|
|
150
|
+
annotated with ``parententity`` itself. The Core plugin would give the
|
|
151
|
+
column's own ``_from_objects`` instead, which may be a plain table.
|
|
152
|
+
"""
|
|
153
|
+
return stmt._propagate_attrs.get("compile_state_plugin") == "orm" and all(
|
|
154
|
+
_is_orm(c) for c in stmt._raw_columns
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# Per class: does attribute lookup of ``table`` end in ``ColumnElement.__getattr__``?
|
|
159
|
+
_COMPARATOR_LOOKUP: dict[type, bool] = {}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _table_of(el: Any) -> Any:
|
|
163
|
+
"""``getattr(el, "table", None)``, exactly — minus the ``AttributeError`` it costs.
|
|
164
|
+
|
|
165
|
+
When no class in the MRO defines ``table``, Python tries the instance
|
|
166
|
+
``__dict__`` and then ``__getattr__``; for a ``ColumnElement`` that is
|
|
167
|
+
``getattr(self.comparator, "table")``, which this calls directly instead
|
|
168
|
+
of letting ``__getattr__`` format an error message to be thrown away.
|
|
169
|
+
Any other class (one that defines ``table``, has no ``__dict__`` or its
|
|
170
|
+
own ``__getattr__``) takes the plain ``getattr``.
|
|
171
|
+
"""
|
|
172
|
+
cls = el.__class__
|
|
173
|
+
via_comparator = _COMPARATOR_LOOKUP.get(cls)
|
|
174
|
+
if via_comparator is None:
|
|
175
|
+
via_comparator = _COMPARATOR_LOOKUP[cls] = (
|
|
176
|
+
cls.__getattr__ is ColumnElement.__getattr__
|
|
177
|
+
if issubclass(cls, ColumnElement)
|
|
178
|
+
and cls.__dictoffset__ != 0
|
|
179
|
+
and not any("table" in vars(k) for k in cls.__mro__)
|
|
180
|
+
else False
|
|
181
|
+
)
|
|
182
|
+
if not via_comparator:
|
|
183
|
+
return getattr(el, "table", None)
|
|
184
|
+
found = el.__dict__.get("table", _MISSING)
|
|
185
|
+
return getattr(el.comparator, "table", None) if found is _MISSING else found
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
_MISSING = object()
|