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.
Files changed (84) hide show
  1. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/.gitignore +1 -2
  2. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/PKG-INFO +2 -2
  3. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/pyproject.toml +2 -2
  4. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/__init__.py +17 -0
  5. simple_module_db-0.0.38/simple_module_db/_bulk_audit_records.py +129 -0
  6. simple_module_db-0.0.38/simple_module_db/_core_joins.py +239 -0
  7. simple_module_db-0.0.38/simple_module_db/_core_walk.py +188 -0
  8. simple_module_db-0.0.38/simple_module_db/_migration_render.py +72 -0
  9. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/_outer_joins.py +1 -1
  10. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/audit.py +9 -7
  11. simple_module_db-0.0.38/simple_module_db/audit_sinks.py +59 -0
  12. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/base.py +1 -1
  13. simple_module_db-0.0.38/simple_module_db/bulk_audit.py +193 -0
  14. simple_module_db-0.0.38/simple_module_db/bulk_audit_targets.py +96 -0
  15. simple_module_db-0.0.38/simple_module_db/core_scope.py +93 -0
  16. simple_module_db-0.0.38/simple_module_db/engine_options.py +53 -0
  17. simple_module_db-0.0.38/simple_module_db/independent.py +34 -0
  18. simple_module_db-0.0.38/simple_module_db/leases.py +188 -0
  19. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/listeners.py +37 -21
  20. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/migrations.py +9 -69
  21. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/model_registry.py +4 -0
  22. simple_module_db-0.0.38/simple_module_db/pagination.py +96 -0
  23. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/query_filter.py +87 -75
  24. simple_module_db-0.0.38/simple_module_db/resource_policy.py +158 -0
  25. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/session.py +18 -3
  26. simple_module_db-0.0.38/simple_module_db/stored_settings.py +192 -0
  27. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/transaction.py +3 -1
  28. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/types.py +1 -1
  29. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/_audit_models.py +14 -5
  30. simple_module_db-0.0.38/tests/_bulk_audit_support.py +44 -0
  31. simple_module_db-0.0.38/tests/_core_scope_models.py +88 -0
  32. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/_models.py +39 -0
  33. simple_module_db-0.0.38/tests/conftest.py +38 -0
  34. simple_module_db-0.0.38/tests/test_audit_per_engine.py +134 -0
  35. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_base.py +1 -1
  36. simple_module_db-0.0.38/tests/test_db_bulk_audit.py +193 -0
  37. simple_module_db-0.0.38/tests/test_db_bulk_audit_targets.py +166 -0
  38. simple_module_db-0.0.38/tests/test_db_core_full_setup_join.py +122 -0
  39. simple_module_db-0.0.38/tests/test_db_core_in_orm_select.py +142 -0
  40. simple_module_db-0.0.38/tests/test_db_core_join_scope.py +131 -0
  41. simple_module_db-0.0.38/tests/test_db_core_nested_scope.py +136 -0
  42. simple_module_db-0.0.38/tests/test_db_engine_options.py +106 -0
  43. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_db_logging.py +2 -2
  44. simple_module_db-0.0.38/tests/test_db_pagination.py +75 -0
  45. simple_module_db-0.0.38/tests/test_db_pagination_options.py +47 -0
  46. simple_module_db-0.0.38/tests/test_db_stored_settings.py +85 -0
  47. simple_module_db-0.0.38/tests/test_independent_session.py +47 -0
  48. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_mixins.py +4 -4
  49. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_query_filter_outer_joins.py +9 -11
  50. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_query_filter_rel_outer_joins.py +2 -2
  51. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_query_filters.py +2 -2
  52. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_writes.py +2 -2
  53. simple_module_db-0.0.37/simple_module_db/subquery_guard.py +0 -49
  54. simple_module_db-0.0.37/tests/conftest.py +0 -54
  55. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/LICENSE +0 -0
  56. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/README.md +0 -0
  57. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/callbacks.py +0 -0
  58. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/deps.py +0 -0
  59. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/extensions.py +0 -0
  60. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/flush_guard.py +0 -0
  61. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/insert_guard.py +0 -0
  62. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/migration_portability.py +0 -0
  63. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/mixins.py +0 -0
  64. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/provider.py +0 -0
  65. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/py.typed +0 -0
  66. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/search.py +0 -0
  67. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/tenancy.py +0 -0
  68. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/simple_module_db/writes.py +0 -0
  69. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_audit.py +0 -0
  70. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_aware_datetime.py +0 -0
  71. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_db_extensions.py +0 -0
  72. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_db_extensions_pg.py +0 -0
  73. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_db_listeners_attach.py +0 -0
  74. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_db_sqlite_savepoint.py +0 -0
  75. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_default_tenant.py +0 -0
  76. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_migration_portability.py +0 -0
  77. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_migrations.py +0 -0
  78. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_multi_tenancy.py +0 -0
  79. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_session.py +0 -0
  80. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_soft_delete_shapes.py +0 -0
  81. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_tenant_dml.py +0 -0
  82. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_tenant_shapes.py +0 -0
  83. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_tenant_strict.py +0 -0
  84. {simple_module_db-0.0.37 → simple_module_db-0.0.38}/tests/test_transaction.py +0 -0
@@ -7,8 +7,6 @@ build/
7
7
  .venv/
8
8
  *.egg
9
9
 
10
- # UV
11
- uv.lock
12
10
 
13
11
  # Node
14
12
  node_modules/
@@ -65,3 +63,4 @@ host/client_app/.playwright-cli/*
65
63
  .qa/
66
64
  .verify/
67
65
  /openapi.json
66
+ qa-shots/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_db
3
- Version: 0.0.37
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.37
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.37"
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.37",
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()