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.
Files changed (94) hide show
  1. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/.gitignore +1 -2
  2. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/PKG-INFO +8 -8
  3. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/pyproject.toml +8 -8
  4. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/__init__.py +17 -0
  5. simple_module_db-0.0.39/simple_module_db/_bulk_audit_records.py +129 -0
  6. simple_module_db-0.0.39/simple_module_db/_core_joins.py +239 -0
  7. simple_module_db-0.0.39/simple_module_db/_core_walk.py +188 -0
  8. simple_module_db-0.0.39/simple_module_db/_migration_render.py +72 -0
  9. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/_outer_joins.py +1 -1
  10. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/audit.py +9 -7
  11. simple_module_db-0.0.39/simple_module_db/audit_sinks.py +59 -0
  12. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/base.py +1 -1
  13. simple_module_db-0.0.39/simple_module_db/bulk_audit.py +193 -0
  14. simple_module_db-0.0.39/simple_module_db/bulk_audit_targets.py +96 -0
  15. simple_module_db-0.0.39/simple_module_db/core_scope.py +93 -0
  16. simple_module_db-0.0.39/simple_module_db/engine_options.py +53 -0
  17. simple_module_db-0.0.39/simple_module_db/independent.py +52 -0
  18. simple_module_db-0.0.39/simple_module_db/leases.py +188 -0
  19. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/listeners.py +42 -21
  20. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/migrations.py +9 -69
  21. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/model_registry.py +4 -0
  22. simple_module_db-0.0.39/simple_module_db/pagination.py +96 -0
  23. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/query_filter.py +87 -75
  24. simple_module_db-0.0.39/simple_module_db/resource_policy.py +158 -0
  25. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/session.py +22 -3
  26. simple_module_db-0.0.39/simple_module_db/stored_settings.py +192 -0
  27. simple_module_db-0.0.39/simple_module_db/tenant_cleanup.py +22 -0
  28. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/transaction.py +7 -3
  29. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/types.py +1 -1
  30. simple_module_db-0.0.39/simple_module_db/user_directory_sql.py +41 -0
  31. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/_audit_models.py +14 -5
  32. simple_module_db-0.0.39/tests/_bulk_audit_support.py +44 -0
  33. simple_module_db-0.0.39/tests/_core_scope_models.py +88 -0
  34. simple_module_db-0.0.39/tests/_db_shape_matrix.py +44 -0
  35. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/_models.py +39 -0
  36. simple_module_db-0.0.39/tests/conftest.py +38 -0
  37. simple_module_db-0.0.39/tests/test_audit_per_engine.py +134 -0
  38. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_base.py +1 -1
  39. simple_module_db-0.0.39/tests/test_db_bulk_audit.py +193 -0
  40. simple_module_db-0.0.39/tests/test_db_bulk_audit_targets.py +166 -0
  41. simple_module_db-0.0.39/tests/test_db_core_full_setup_join.py +122 -0
  42. simple_module_db-0.0.39/tests/test_db_core_in_orm_select.py +142 -0
  43. simple_module_db-0.0.39/tests/test_db_core_join_scope.py +131 -0
  44. simple_module_db-0.0.39/tests/test_db_core_nested_scope.py +136 -0
  45. simple_module_db-0.0.39/tests/test_db_engine_options.py +106 -0
  46. simple_module_db-0.0.39/tests/test_db_independent_checkpoint.py +72 -0
  47. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_db_logging.py +2 -2
  48. simple_module_db-0.0.39/tests/test_db_pagination.py +75 -0
  49. simple_module_db-0.0.39/tests/test_db_pagination_options.py +47 -0
  50. simple_module_db-0.0.39/tests/test_db_shape_matrix_dml.py +35 -0
  51. simple_module_db-0.0.39/tests/test_db_shape_matrix_joins.py +55 -0
  52. simple_module_db-0.0.39/tests/test_db_shape_matrix_selects.py +58 -0
  53. simple_module_db-0.0.39/tests/test_db_stored_settings.py +85 -0
  54. simple_module_db-0.0.39/tests/test_db_tenant_cleanup.py +58 -0
  55. simple_module_db-0.0.39/tests/test_db_user_directory_sql.py +29 -0
  56. simple_module_db-0.0.39/tests/test_independent_session.py +47 -0
  57. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_mixins.py +4 -4
  58. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_query_filter_outer_joins.py +9 -11
  59. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_query_filter_rel_outer_joins.py +2 -2
  60. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_query_filters.py +2 -2
  61. simple_module_db-0.0.39/tests/test_rollback_logging.py +23 -0
  62. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_writes.py +2 -2
  63. simple_module_db-0.0.37/simple_module_db/subquery_guard.py +0 -49
  64. simple_module_db-0.0.37/tests/conftest.py +0 -54
  65. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/LICENSE +0 -0
  66. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/README.md +0 -0
  67. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/callbacks.py +0 -0
  68. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/deps.py +0 -0
  69. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/extensions.py +0 -0
  70. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/flush_guard.py +0 -0
  71. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/insert_guard.py +0 -0
  72. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/migration_portability.py +0 -0
  73. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/mixins.py +0 -0
  74. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/provider.py +0 -0
  75. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/py.typed +0 -0
  76. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/search.py +0 -0
  77. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/tenancy.py +0 -0
  78. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/simple_module_db/writes.py +0 -0
  79. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_audit.py +0 -0
  80. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_aware_datetime.py +0 -0
  81. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_db_extensions.py +0 -0
  82. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_db_extensions_pg.py +0 -0
  83. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_db_listeners_attach.py +0 -0
  84. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_db_sqlite_savepoint.py +0 -0
  85. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_default_tenant.py +0 -0
  86. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_migration_portability.py +0 -0
  87. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_migrations.py +0 -0
  88. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_multi_tenancy.py +0 -0
  89. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_session.py +0 -0
  90. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_soft_delete_shapes.py +0 -0
  91. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_tenant_dml.py +0 -0
  92. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_tenant_shapes.py +0 -0
  93. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/tests/test_tenant_strict.py +0 -0
  94. {simple_module_db-0.0.37 → simple_module_db-0.0.39}/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.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>=0.20
25
- Requires-Dist: alembic>=1.14
26
- Requires-Dist: asyncpg>=0.30
27
- Requires-Dist: psycopg2-binary>=2.9
28
- Requires-Dist: simple-module-core==0.0.37
29
- Requires-Dist: sqlalchemy[asyncio]>=2.0
30
- Requires-Dist: sqlmodel>=0.0.22
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.37"
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.37",
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)