simple-module-db 0.0.35__tar.gz → 0.0.36__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 (50) hide show
  1. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/PKG-INFO +2 -2
  2. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/pyproject.toml +2 -2
  3. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/__init__.py +6 -0
  4. simple_module_db-0.0.36/simple_module_db/_outer_joins.py +97 -0
  5. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/flush_guard.py +20 -1
  6. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/insert_guard.py +23 -18
  7. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/listeners.py +14 -2
  8. simple_module_db-0.0.36/simple_module_db/migration_portability.py +164 -0
  9. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/migrations.py +30 -29
  10. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/mixins.py +3 -3
  11. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/query_filter.py +53 -14
  12. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/session.py +19 -0
  13. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/tenancy.py +37 -5
  14. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/tests/conftest.py +28 -3
  15. simple_module_db-0.0.36/tests/test_db_listeners_attach.py +83 -0
  16. simple_module_db-0.0.36/tests/test_db_sqlite_savepoint.py +75 -0
  17. simple_module_db-0.0.36/tests/test_default_tenant.py +164 -0
  18. simple_module_db-0.0.36/tests/test_migration_portability.py +164 -0
  19. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/tests/test_multi_tenancy.py +5 -6
  20. simple_module_db-0.0.36/tests/test_query_filter_outer_joins.py +250 -0
  21. simple_module_db-0.0.36/tests/test_query_filter_rel_outer_joins.py +165 -0
  22. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/tests/test_tenant_strict.py +1 -24
  23. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/.gitignore +0 -0
  24. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/LICENSE +0 -0
  25. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/README.md +0 -0
  26. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/audit.py +0 -0
  27. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/base.py +0 -0
  28. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/callbacks.py +0 -0
  29. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/deps.py +0 -0
  30. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/model_registry.py +0 -0
  31. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/provider.py +0 -0
  32. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/py.typed +0 -0
  33. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/search.py +0 -0
  34. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/subquery_guard.py +0 -0
  35. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/transaction.py +0 -0
  36. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/simple_module_db/writes.py +0 -0
  37. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/tests/_audit_models.py +0 -0
  38. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/tests/_models.py +0 -0
  39. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/tests/test_audit.py +0 -0
  40. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/tests/test_base.py +0 -0
  41. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/tests/test_db_logging.py +0 -0
  42. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/tests/test_migrations.py +0 -0
  43. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/tests/test_mixins.py +0 -0
  44. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/tests/test_query_filters.py +0 -0
  45. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/tests/test_session.py +0 -0
  46. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/tests/test_soft_delete_shapes.py +0 -0
  47. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/tests/test_tenant_dml.py +0 -0
  48. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/tests/test_tenant_shapes.py +0 -0
  49. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/tests/test_transaction.py +0 -0
  50. {simple_module_db-0.0.35 → simple_module_db-0.0.36}/tests/test_writes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_db
3
- Version: 0.0.35
3
+ Version: 0.0.36
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.35
28
+ Requires-Dist: simple-module-core==0.0.36
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.35"
3
+ version = "0.0.36"
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.35",
31
+ "simple_module_core==0.0.36",
32
32
  "sqlalchemy[asyncio]>=2.0",
33
33
  "sqlmodel>=0.0.22",
34
34
  ]
@@ -4,6 +4,7 @@ from simple_module_db.audit import AuditRecord
4
4
  from simple_module_db.base import create_module_base
5
5
  from simple_module_db.callbacks import OnCommitCallback
6
6
  from simple_module_db.deps import get_db
7
+ from simple_module_db.migration_portability import drop_enums_if_postgres
7
8
  from simple_module_db.migrations import (
8
9
  build_module_metadata,
9
10
  make_include_object,
@@ -16,6 +17,8 @@ from simple_module_db.search import LIKE_ESCAPE_CHAR, like_contains_pattern, lik
16
17
  from simple_module_db.session import DatabaseState, RequestSession, init_db
17
18
  from simple_module_db.tenancy import (
18
19
  ALL_TENANTS_OPTION,
20
+ DEFAULT_TENANT_ID,
21
+ PLATFORM_TENANT_ID,
19
22
  TENANT_ID_PATTERN,
20
23
  MissingTenantError,
21
24
  TenantIsolationError,
@@ -30,7 +33,9 @@ from simple_module_db.writes import hard_delete, mark_written
30
33
 
31
34
  __all__ = [
32
35
  "ALL_TENANTS_OPTION",
36
+ "DEFAULT_TENANT_ID",
33
37
  "LIKE_ESCAPE_CHAR",
38
+ "PLATFORM_TENANT_ID",
34
39
  "TENANT_ID_PATTERN",
35
40
  "AuditMixin",
36
41
  "AuditRecord",
@@ -50,6 +55,7 @@ __all__ = [
50
55
  "create_module_base",
51
56
  "current_tenant_id",
52
57
  "detect_provider",
58
+ "drop_enums_if_postgres",
53
59
  "finalize_session",
54
60
  "get_db",
55
61
  "hard_delete",
@@ -0,0 +1,97 @@
1
+ """Tables an ORM entity LEFT-joins in, whose criteria already sit in ``ON`` (#417).
2
+
3
+ ``select(Parent, func.count(Child.id)).outerjoin(Child, ...)``: the column
4
+ wrapped in ``func.count`` strips the ORM annotation, so ``columns_clause_froms``
5
+ lists a bare ``Table child``. ``_plain_tables`` took that for a Core
6
+ ``Model.__table__`` reference and added ``WHERE child.tenant_id = :t``, which
7
+ is false for every row the outer join padded with NULLs — the LEFT JOIN became
8
+ an inner one and parents without children vanished.
9
+
10
+ Excluding such a table from the ``WHERE`` is only safe when something else
11
+ filters it. That holds for exactly one shape: the target of a LEFT outer join
12
+ is a plain ORM entity (``.outerjoin(Child, ...)``) whose class carries the
13
+ loader criteria, because ``with_loader_criteria`` renders them into the join's
14
+ ``ON``, and an ``ON`` predicate fully filters a LEFT join's nullable side.
15
+ A relationship path (``.outerjoin(Parent.kids)``, ``.outerjoin(Parent.kids.
16
+ of_type(Kid))``) lands on the same ``ON`` and counts as that entity. So:
17
+
18
+ * a FULL outer join (``full=True``) is deliberately NOT exempted: it preserves
19
+ its right side too, and an ``ON`` predicate never removes a preserved row —
20
+ another tenant's (or a trashed) child that fails ``ON`` still comes back as
21
+ an unmatched row. Only the ``WHERE`` predicate keeps it out (over-filters,
22
+ never leaks);
23
+
24
+ * a relationship with ``secondary`` keeps the child's ``WHERE``: the child
25
+ sits behind an association table, outside the outer join's own ``ON``;
26
+ * a raw ``Model.__table__`` target gets no loader criteria and keeps its
27
+ ``WHERE`` predicate (over-filters, never leaks);
28
+ * an ``aliased(Model)`` target is left alone: its name never matches a table
29
+ name, so it was never a ``WHERE`` candidate in the first place;
30
+ * a target whose mapper does not map exactly one table (joined inheritance)
31
+ is left alone, conservatively;
32
+ * a target whose class is not in ``covered`` (the classes the caller attaches
33
+ loader criteria for) is left alone — no criteria, no ``ON`` predicate.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from typing import Any
39
+
40
+ from sqlalchemy.orm import Mapper, RelationshipProperty
41
+
42
+
43
+ def _plain(element: Any) -> Any:
44
+ deannotate = getattr(element, "_deannotate", None)
45
+ return deannotate() if deannotate is not None else element
46
+
47
+
48
+ def orm_outer_join_tables(stmt: Any, covered: set[type] | frozenset[type]) -> list[Any]:
49
+ """Un-annotated tables of ORM entities ``stmt`` LEFT-outer-joins in (never FULL).
50
+
51
+ Only entities whose class is in ``covered`` count: those are the ones the
52
+ filter gives ``with_loader_criteria``, which is what puts their predicate
53
+ in ``ON``. Compare the result by identity against FROM candidates.
54
+ """
55
+ tables: list[Any] = []
56
+ for entry in getattr(stmt, "_setup_joins", ()):
57
+ # SQLAlchemy 2.0: (target, onclause, from_, {"isouter": .., "full": ..})
58
+ target, flags = entry[0], entry[-1]
59
+ # LEFT only: ON cannot filter a FULL join's preserved right side.
60
+ if not isinstance(flags, dict) or not flags.get("isouter") or flags.get("full"):
61
+ continue
62
+ prop = getattr(target, "property", None)
63
+ if isinstance(prop, RelationshipProperty):
64
+ mapper, table = _relationship_target(target, prop)
65
+ else:
66
+ mapper = getattr(target, "_annotations", {}).get("parententity")
67
+ table = _plain(target)
68
+ if not isinstance(mapper, Mapper) or mapper.class_ not in covered:
69
+ continue
70
+ if table is mapper.local_table and mapper.persist_selectable is mapper.local_table:
71
+ tables.append(table)
72
+ return tables
73
+
74
+
75
+ def _relationship_target(attr: Any, prop: RelationshipProperty) -> tuple[Any, Any]:
76
+ """Mapper and table a ``.outerjoin(Parent.kids)`` path lands on, or ``(None, None)``.
77
+
78
+ The child's loader criteria render into that join's ``ON`` exactly as for
79
+ an entity target. Two shapes are refused: a ``secondary`` relationship
80
+ (the child sits behind an association table, with no ``ON`` guarantee for
81
+ the outer join itself) and an aliased ``of_type`` (aliases never matched a
82
+ ``WHERE`` candidate anyway).
83
+ """
84
+ if prop.secondary is not None:
85
+ return None, None
86
+ of_type = getattr(attr, "_of_type", None)
87
+ mapper = prop.mapper if of_type is None else of_type
88
+ if not isinstance(mapper, Mapper): # aliased(Child) is an AliasedInsp
89
+ return None, None
90
+ return mapper, mapper.local_table
91
+
92
+
93
+ def is_excluded(from_obj: Any, excluded: list[Any]) -> bool:
94
+ # Table-object identity on purpose: matching by name would also drop the
95
+ # WHERE on a raw duplicate occurrence (an alias, another FROM) of the table.
96
+ plain = _plain(from_obj)
97
+ return any(plain is t for t in excluded)
@@ -13,7 +13,7 @@ from sqlalchemy import inspect as sa_inspect
13
13
  from sqlalchemy.orm import Session
14
14
 
15
15
  from simple_module_db.mixins import MultiTenantMixin
16
- from simple_module_db.query_filter import is_strict
16
+ from simple_module_db.query_filter import fallback_tenant_id, is_strict, strict_configured
17
17
  from simple_module_db.tenancy import (
18
18
  TenantIsolationError,
19
19
  current_tenant_id,
@@ -30,11 +30,30 @@ def _owner(obj: MultiTenantMixin) -> str | None:
30
30
  return obj.tenant_id
31
31
 
32
32
 
33
+ def _stamp_unbound(session: Session) -> None:
34
+ """Non-strict and unscoped (unbound, or an ``all_tenants()`` block).
35
+
36
+ New rows go to the bound tenant if there is one — a bypass widens what a
37
+ block may *read*, it does not move its writes elsewhere — else to the
38
+ install's fallback (``default_tenant`` or ``DEFAULT_TENANT_ID``).
39
+ """
40
+ stamp = current_tenant_id.get() or fallback_tenant_id(session)
41
+ for obj in session.new:
42
+ if isinstance(obj, MultiTenantMixin) and obj.tenant_id is None:
43
+ obj.tenant_id = stamp
44
+
45
+
33
46
  def guard_flush(session: Session) -> None:
34
47
  if is_all_tenants():
48
+ # A bypass block is never scoped; on a single-tenant install its new
49
+ # rows still need a tenant to satisfy NOT NULL (strict: left to the DB).
50
+ if not strict_configured(session):
51
+ _stamp_unbound(session)
35
52
  return
36
53
  tenant_id = current_tenant_id.get()
37
54
  strict = is_strict(session)
55
+ if tenant_id is None and not strict:
56
+ _stamp_unbound(session)
38
57
 
39
58
  for obj in session.new:
40
59
  if not isinstance(obj, MultiTenantMixin):
@@ -1,7 +1,8 @@
1
1
  """INSERT guard and statement-value helpers for the tenant query filter.
2
2
 
3
3
  Explicit ``tenant_id`` values in an insert must match the bound tenant, and
4
- missing ones are stamped with it (#357); the same value readers let the
4
+ missing ones are stamped with it (#357) — or, unbound on a non-strict
5
+ install, with the install's fallback tenant (#380); the same value readers let the
5
6
  query filter refuse an UPDATE that assigns ``tenant_id`` (#356).
6
7
  """
7
8
 
@@ -14,6 +15,7 @@ from sqlalchemy.orm import ORMExecuteState
14
15
  from simple_module_db import model_registry as registry
15
16
  from simple_module_db.tenancy import (
16
17
  ALL_TENANTS_OPTION,
18
+ DEFAULT_TENANT_ID,
17
19
  TenantIsolationError,
18
20
  current_tenant_id,
19
21
  is_all_tenants,
@@ -58,18 +60,25 @@ def _foreign_insert(value: Any, tenant_id: str) -> TenantIsolationError:
58
60
  )
59
61
 
60
62
 
61
- def guard_insert(execute_state: ORMExecuteState, *, strict: bool) -> Any:
63
+ def guard_insert(
64
+ execute_state: ORMExecuteState, *, strict: bool, fallback: str = DEFAULT_TENANT_ID
65
+ ) -> Any:
62
66
  mappers = [m.class_.__name__ for m in execute_state.all_mappers if registry.flags(m.class_)[1]]
63
67
  target = getattr(execute_state.statement, "table", None)
64
68
  if not mappers and getattr(target, "name", None) in registry.tenant_table_names:
65
69
  mappers = [target.name] # Core insert(Model.__table__)
66
- if not mappers or execute_state.execution_options.get(ALL_TENANTS_OPTION, False):
70
+ if not mappers:
67
71
  return None
68
- if is_all_tenants():
72
+ bypass = execute_state.execution_options.get(ALL_TENANTS_OPTION, False) or is_all_tenants()
73
+ if bypass and strict:
69
74
  return None
70
- tenant_id = current_tenant_id.get()
75
+ # A non-strict bypass checks no explicit values; missing ones still get
76
+ # the bound tenant, else the install's fallback (#380).
77
+ bound = current_tenant_id.get()
78
+ tenant_id = None if bypass else bound
79
+ stamp = bound or fallback
71
80
  if getattr(execute_state.statement, "_multi_values", None):
72
- return _guard_multi_values(execute_state, tenant_id, mappers[0], strict)
81
+ return _guard_multi_values(execute_state, tenant_id, stamp, mappers[0], strict)
73
82
  for value in assigned_tenant_ids(execute_state):
74
83
  if tenant_id is not None and value != tenant_id:
75
84
  raise _foreign_insert(value, tenant_id)
@@ -81,20 +90,18 @@ def guard_insert(execute_state: ORMExecuteState, *, strict: bool) -> Any:
81
90
  )
82
91
  if not missing:
83
92
  return None
84
- if tenant_id is None:
85
- if strict:
86
- raise missing_tenant_error(mappers[0], "INSERT")
87
- return None
93
+ if tenant_id is None and strict:
94
+ raise missing_tenant_error(mappers[0], "INSERT")
88
95
  if rows:
89
- stamped = [{**r, TENANT_COLUMN: r.get(TENANT_COLUMN, tenant_id)} for r in rows]
96
+ stamped = [{**r, TENANT_COLUMN: r.get(TENANT_COLUMN, stamp)} for r in rows]
90
97
  payload = stamped if isinstance(execute_state.parameters, list) else stamped[0]
91
98
  return execute_state.invoke_statement(params=payload)
92
- execute_state.statement = execute_state.statement.values(**{TENANT_COLUMN: tenant_id})
99
+ execute_state.statement = execute_state.statement.values(**{TENANT_COLUMN: stamp})
93
100
  return None
94
101
 
95
102
 
96
103
  def _guard_multi_values(
97
- execute_state: ORMExecuteState, tenant_id: str | None, entity: str, strict: bool
104
+ execute_state: ORMExecuteState, tenant_id: str | None, stamp: str, entity: str, strict: bool
98
105
  ) -> Any:
99
106
  """``insert(M).values([{...}, {...}])``: check and stamp each row.
100
107
 
@@ -116,15 +123,13 @@ def _guard_multi_values(
116
123
  raise _foreign_insert(value, tenant_id)
117
124
  else:
118
125
  missing = True
119
- mapping[tenant_col] = tenant_id
126
+ mapping[tenant_col] = stamp
120
127
  rows.append(mapping)
121
128
  groups.append(rows)
122
129
  if not missing:
123
130
  return None
124
- if tenant_id is None:
125
- if strict:
126
- raise missing_tenant_error(entity, "INSERT")
127
- return None
131
+ if tenant_id is None and strict:
132
+ raise missing_tenant_error(entity, "INSERT")
128
133
  stamped = stmt._generate()
129
134
  stamped._multi_values = tuple(groups)
130
135
  execute_state.statement = stamped
@@ -62,6 +62,8 @@ _OP_UPDATE = "update"
62
62
  _OP_SOFT_DELETE = "soft_delete"
63
63
  _OP_DELETE = "delete"
64
64
 
65
+ _LISTENERS_ATTACHED = "_sm_entity_listeners_attached"
66
+
65
67
 
66
68
  def _entity_label(obj: object) -> str:
67
69
  """Return 'ClassName' for a mapped entity instance."""
@@ -106,7 +108,17 @@ def attach_session_listeners(session_class: type[Session]) -> None:
106
108
 
107
109
  For a process with no ``DatabaseState`` (the Celery worker's sync engine),
108
110
  call this and ``bind_engine_policy(engine, EngineTenancy(...))``.
111
+
112
+ Idempotence is a marker in the ``__dict__`` of the class or a base, not
113
+ ``event.contains``: SQLAlchemy keys its event registry on ``id(target)``,
114
+ so a fresh session class that reuses a garbage-collected one's id reads as
115
+ already wired and would silently get no tenant, soft-delete or write
116
+ listeners at all. A subclass of a wired class is skipped because SQLAlchemy
117
+ propagates class-level session listeners to subclasses (even ones defined
118
+ later); wiring it again would fire every listener twice.
109
119
  """
120
+ if any(base.__dict__.get(_LISTENERS_ATTACHED) for base in session_class.__mro__):
121
+ return
110
122
  for name, fn in (
111
123
  ("before_flush", _before_flush_listener),
112
124
  ("after_flush", _mark_session_written),
@@ -114,8 +126,8 @@ def attach_session_listeners(session_class: type[Session]) -> None:
114
126
  ("do_orm_execute", _mark_dml_written),
115
127
  ("do_orm_execute", filter_statements),
116
128
  ):
117
- if not event.contains(session_class, name, fn):
118
- event.listen(session_class, name, fn)
129
+ event.listen(session_class, name, fn)
130
+ setattr(session_class, _LISTENERS_ATTACHED, True)
119
131
 
120
132
 
121
133
  def _before_flush_listener(
@@ -0,0 +1,164 @@
1
+ """Keep revisions autogenerated on SQLite portable to PostgreSQL (GH #342).
2
+
3
+ Autogenerate against the default SQLite database emits things Postgres rejects
4
+ or never sees, and neither ``alembic check`` nor the boot check notices:
5
+
6
+ * Boolean ``server_default`` rendered as ``sa.text('0')`` — Postgres refuses an
7
+ integer default on a boolean column. :func:`rewrite_boolean_defaults` and
8
+ :func:`render_boolean_default` emit ``sa.false()`` / ``sa.true()`` instead.
9
+ * Expression indexes (``lower(email)``) are skipped on SQLite because it cannot
10
+ reflect them. :func:`warn_unverifiable_expression_indexes` says so loudly.
11
+ * ``op.drop_table`` leaves ``CREATE TYPE`` behind on Postgres, so a downgrade
12
+ followed by an upgrade fails with ``DuplicateObject``. Use
13
+ :func:`drop_enums_if_postgres` in the revision's ``downgrade``.
14
+
15
+ ``simple_module_db.migrations`` wires the first two into the hooks every
16
+ ``env.py`` already passes to Alembic.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ from collections.abc import Iterator
23
+ from typing import Any
24
+
25
+ import sqlalchemy as sa
26
+ from alembic.operations.ops import AddColumnOp, AlterColumnOp, CreateTableOp
27
+ from simple_module_core.diagnostics._expression_index import find_expression_indexes
28
+ from sqlalchemy.dialects import postgresql
29
+ from sqlalchemy.schema import DefaultClause
30
+ from sqlalchemy.sql.elements import ColumnElement, False_, TextClause, True_
31
+
32
+ logger = logging.getLogger("alembic.env")
33
+
34
+ _FALSE_LITERALS = frozenset({"0", "false"})
35
+ _TRUE_LITERALS = frozenset({"1", "true"})
36
+
37
+
38
+ def boolean_default_for(default: Any) -> ColumnElement[bool] | None:
39
+ """Map a Boolean ``server_default`` spelled as 0/1/true/false to ``sa.false()``/``sa.true()``.
40
+
41
+ Accepts a ``DefaultClause``, ``text()``, bare string, or SQLAlchemy
42
+ ``true()``/``false()``; returns ``None`` when it is none of those literals.
43
+ """
44
+ arg = default.arg if isinstance(default, DefaultClause) else default
45
+ if isinstance(arg, False_):
46
+ return sa.false()
47
+ if isinstance(arg, True_):
48
+ return sa.true()
49
+ text = arg.text if isinstance(arg, TextClause) else arg
50
+ if not isinstance(text, str):
51
+ return None
52
+ literal = text.strip().strip("()").strip("'\"").lower()
53
+ if literal in _FALSE_LITERALS:
54
+ return sa.false()
55
+ if literal in _TRUE_LITERALS:
56
+ return sa.true()
57
+ return None
58
+
59
+
60
+ def render_boolean_default(obj: Any, autogen_context: Any) -> str | bool:
61
+ """``render_item`` branch: render ``true()``/``false()`` defaults as such.
62
+
63
+ SQLAlchemy compiles ``false()`` under SQLite to ``0``, which Alembic then
64
+ renders as ``sa.text('0')``. Intercepting the ``server_default`` item keeps
65
+ the portable expression the model declared.
66
+ """
67
+ arg = obj.arg if isinstance(obj, DefaultClause) else obj
68
+ if not isinstance(arg, (True_, False_)):
69
+ return False
70
+ prefix = autogen_context.opts.get("sqlalchemy_module_prefix", "sa.")
71
+ return f"{prefix}{'true' if isinstance(arg, True_) else 'false'}()"
72
+
73
+
74
+ def iter_ops_recursive(container: Any) -> Iterator[Any]:
75
+ """Yield every op under ``container``, descending into nested op groups.
76
+
77
+ Autogenerate does not emit a flat op list: index operations for a table are
78
+ grouped inside a ``ModifyTableOps`` container alongside the top-level
79
+ ``CreateTableOp``/``DropTableOp``. A dedup check that only looks at
80
+ ``container.ops`` therefore sees no ``CreateIndexOp`` at all and re-injects
81
+ an index the dialect already emitted — which is exactly how a dialect that
82
+ *can* reflect expression-based indexes (PostgreSQL) ended up with a
83
+ duplicate ``CREATE INDEX`` in its initial migration.
84
+ """
85
+ for op in container.ops:
86
+ yield op
87
+ if hasattr(op, "ops"):
88
+ yield from iter_ops_recursive(op)
89
+
90
+
91
+ def rewrite_boolean_defaults(upgrade_ops: Any) -> None:
92
+ """Rewrite integer-looking defaults on Boolean columns in a revision's op tree.
93
+
94
+ Covers columns of ``create_table``, ``add_column`` and ``alter_column``
95
+ (the latter via ``existing_type``).
96
+ """
97
+ for op in iter_ops_recursive(upgrade_ops):
98
+ if isinstance(op, CreateTableOp):
99
+ columns = [c for c in op.columns if isinstance(c, sa.Column)]
100
+ elif isinstance(op, AddColumnOp):
101
+ columns = [op.column]
102
+ elif isinstance(op, AlterColumnOp):
103
+ replacement = None
104
+ if isinstance(op.existing_type or op.modify_type, sa.Boolean):
105
+ replacement = boolean_default_for(op.modify_server_default)
106
+ if replacement is not None:
107
+ op.modify_server_default = replacement
108
+ continue
109
+ else:
110
+ continue
111
+ for column in columns:
112
+ if not isinstance(column.type, sa.Boolean) or column.server_default is None:
113
+ continue
114
+ replacement = boolean_default_for(column.server_default)
115
+ if replacement is not None:
116
+ column.server_default = DefaultClause(replacement)
117
+
118
+
119
+ def warn_unverifiable_expression_indexes(dialect_name: str | None, metadata: sa.MetaData) -> list:
120
+ """Warn when autogenerating on SQLite while models declare expression indexes.
121
+
122
+ A warning rather than an error: autogenerate also runs for ordinary,
123
+ unrelated revisions, and failing every one of them for an index that is
124
+ already in history would block normal work. The warning is emitted on every
125
+ run, names each index, and ``alembic check`` on Postgres
126
+ (``make migrations-roundtrip-pg``) is the real gate. Returns the
127
+ ``(table, index)`` pairs it warned about.
128
+ """
129
+ if dialect_name != "sqlite":
130
+ return []
131
+ found = find_expression_indexes(metadata.tables.values())
132
+ if not found:
133
+ return []
134
+ listing = "\n".join(f" - {index} on {table}" for table, index in found)
135
+ logger.warning(
136
+ "\n%s\n"
137
+ "WARNING: autogenerating on SQLite. SQLite cannot reflect expression indexes,\n"
138
+ "so this revision does NOT account for the following (they are neither\n"
139
+ "created nor verified here) and `alembic check` will not notice drift:\n"
140
+ "%s\n"
141
+ "Add them to the revision by hand and verify on PostgreSQL before merging\n"
142
+ "(`make migrations-roundtrip-pg`). See docs/module-authoring.md, Migrations.\n"
143
+ "%s",
144
+ "=" * 78,
145
+ listing,
146
+ "=" * 78,
147
+ )
148
+ return found
149
+
150
+
151
+ def drop_enums_if_postgres(op: Any, *names: str) -> None:
152
+ """Drop the named enum types on PostgreSQL; a no-op elsewhere.
153
+
154
+ Call it at the end of ``downgrade()``, after the tables that use the types
155
+ are dropped. ``op.drop_table`` leaves the ``CREATE TYPE`` behind, and
156
+ autogenerate never emits the ``Enum.drop``, so without this a
157
+ ``downgrade`` followed by an ``upgrade`` fails with ``DuplicateObject``.
158
+ SQLite has no such object, which is why the round trip passes there.
159
+ """
160
+ bind = op.get_bind()
161
+ if bind.dialect.name != "postgresql":
162
+ return
163
+ for name in names:
164
+ postgresql.ENUM(name=name).drop(bind, checkfirst=True)
@@ -23,11 +23,18 @@ from typing import Literal
23
23
  import sqlalchemy as sa
24
24
  from alembic.operations.ops import CreateIndexOp, CreateTableOp, DropIndexOp, DropTableOp
25
25
  from simple_module_core import ModuleBase
26
+ from simple_module_core.diagnostics._expression_index import index_is_expression_based
26
27
  from simple_module_core.discovery import discover_modules, get_module_package_name
27
- from sqlalchemy import Column, Index, MetaData
28
+ from sqlalchemy import Index, MetaData
28
29
  from sqlalchemy.schema import SchemaItem
29
30
 
30
31
  from simple_module_db.base import all_module_bases
32
+ from simple_module_db.migration_portability import (
33
+ iter_ops_recursive,
34
+ render_boolean_default,
35
+ rewrite_boolean_defaults,
36
+ warn_unverifiable_expression_indexes,
37
+ )
31
38
 
32
39
  logger = logging.getLogger(__name__)
33
40
 
@@ -139,6 +146,11 @@ def make_process_revision_directives(
139
146
  Any index already named in the op tree — at any nesting depth — is left
140
147
  alone, which keeps this dialect-agnostic rather than special-casing SQLite.
141
148
 
149
+ It also rewrites ``0``/``1``/``false``/``true`` server defaults on Boolean
150
+ columns to ``sa.false()``/``sa.true()`` and, when running on SQLite, warns
151
+ that expression indexes cannot be verified (see
152
+ :mod:`simple_module_db.migration_portability`).
153
+
142
154
  Call as::
143
155
 
144
156
  context.configure(
@@ -149,50 +161,35 @@ def make_process_revision_directives(
149
161
  expression_indexes: dict[str, list[Index]] = {}
150
162
  for table in metadata.tables.values():
151
163
  for index in table.indexes:
152
- if _index_is_expression_based(index):
164
+ if index_is_expression_based(index):
153
165
  expression_indexes.setdefault(table.name, []).append(index)
154
166
 
155
167
  def process_revision_directives(context, revision, directives):
156
- if not expression_indexes:
157
- return
168
+ warn_unverifiable_expression_indexes(
169
+ getattr(getattr(context, "dialect", None), "name", None), metadata
170
+ )
158
171
  for script in directives:
159
172
  upgrade_ops = getattr(script, "upgrade_ops", None)
160
173
  if upgrade_ops is not None:
161
- _inject_create_index_after_create_table(upgrade_ops, expression_indexes)
174
+ rewrite_boolean_defaults(upgrade_ops)
175
+ if expression_indexes:
176
+ _inject_create_index_after_create_table(upgrade_ops, expression_indexes)
162
177
  downgrade_ops = getattr(script, "downgrade_ops", None)
163
178
  if downgrade_ops is not None:
164
- _inject_drop_index_before_drop_table(downgrade_ops, expression_indexes)
179
+ # A downgrade re-creates dropped tables/columns from reflected
180
+ # state, so it carries the same ``sa.text('0')`` Boolean defaults.
181
+ rewrite_boolean_defaults(downgrade_ops)
182
+ if expression_indexes:
183
+ _inject_drop_index_before_drop_table(downgrade_ops, expression_indexes)
165
184
 
166
185
  return process_revision_directives
167
186
 
168
187
 
169
- def _index_is_expression_based(index: Index) -> bool:
170
- """An index is expression-based when any of its expressions is not a plain ``Column``."""
171
- return any(not isinstance(expr, Column) for expr in index.expressions)
172
-
173
-
174
- def _iter_ops_recursive(container):
175
- """Yield every op under ``container``, descending into nested op groups.
176
-
177
- Autogenerate does not emit a flat op list: index operations for a table are
178
- grouped inside a ``ModifyTableOps`` container alongside the top-level
179
- ``CreateTableOp``/``DropTableOp``. A dedup check that only looks at
180
- ``container.ops`` therefore sees no ``CreateIndexOp`` at all and re-injects
181
- an index the dialect already emitted — which is exactly how a dialect that
182
- *can* reflect expression-based indexes (PostgreSQL) ended up with a
183
- duplicate ``CREATE INDEX`` in its initial migration.
184
- """
185
- for op in container.ops:
186
- yield op
187
- if hasattr(op, "ops"):
188
- yield from _iter_ops_recursive(op)
189
-
190
-
191
188
  def _existing_index_names(container, op_type) -> set[str | None]:
192
189
  """Names of every ``op_type`` index op already present anywhere under ``container``."""
193
190
  return {
194
191
  getattr(op, "index_name", None)
195
- for op in _iter_ops_recursive(container)
192
+ for op in iter_ops_recursive(container)
196
193
  if isinstance(op, op_type)
197
194
  }
198
195
 
@@ -241,12 +238,16 @@ def render_item(type_, obj, autogen_context):
241
238
  Postgres enum labels match the lowercase ``StrEnum`` values rather than
242
239
  SQLAlchemy's default of using uppercase attribute names. This means raw
243
240
  SQL like ``WHERE status = 'ready'`` actually works against the live DB.
241
+ * Renders ``true()``/``false()`` server defaults as ``sa.true()``/``sa.false()``
242
+ instead of the dialect-specific ``sa.text('1')``/``sa.text('0')`` (GH #342).
244
243
  * Adds the necessary imports for ``fastapi_users_db_sqlalchemy.generics``
245
244
  and ``geoalchemy2`` types (rendered by their own classes elsewhere) so
246
245
  the generated migration is importable.
247
246
 
248
247
  Pass to :func:`alembic.context.configure` as ``render_item=render_item``.
249
248
  """
249
+ if type_ == "server_default":
250
+ return render_boolean_default(obj, autogen_context)
250
251
  if type_ != "type":
251
252
  return False
252
253
  cls_name = type(obj).__name__
@@ -67,9 +67,9 @@ class MultiTenantMixin(SQLModel):
67
67
  inside a request scope without explicitly threading the tenant through —
68
68
  the ``_before_flush_listener`` in :mod:`simple_module_db.listeners`
69
69
  populates it from the ``current_tenant_id`` contextvar before the row
70
- reaches the DB. The column itself is non-nullable, so a row inserted
71
- outside any tenant context fails loudly at the DB rather than silently
72
- leaking across tenants.
70
+ reaches the DB. The column itself is non-nullable. With no tenant bound,
71
+ strict mode (``multi_tenant`` on) raises ``MissingTenantError``; a
72
+ single-tenant install stamps ``DEFAULT_TENANT_ID`` instead (#380).
73
73
  """
74
74
 
75
75
  tenant_id: str | None = Field(