simple-module-db 0.0.40__tar.gz → 0.0.42__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 (99) hide show
  1. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/PKG-INFO +2 -2
  2. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/pyproject.toml +2 -2
  3. simple_module_db-0.0.42/simple_module_db/data_compatibility.py +83 -0
  4. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/insert_guard.py +44 -0
  5. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/leases.py +31 -8
  6. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/session.py +32 -2
  7. simple_module_db-0.0.42/tests/test_db_data_compatibility_doctor.py +91 -0
  8. simple_module_db-0.0.42/tests/test_db_insert_select_tenant.py +129 -0
  9. simple_module_db-0.0.42/tests/test_db_lease_lock_wait.py +39 -0
  10. simple_module_db-0.0.42/tests/test_db_sqlite_wal_switch.py +87 -0
  11. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/.gitignore +0 -0
  12. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/LICENSE +0 -0
  13. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/README.md +0 -0
  14. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/__init__.py +0 -0
  15. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/_bulk_audit_records.py +0 -0
  16. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/_core_joins.py +0 -0
  17. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/_core_walk.py +0 -0
  18. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/_migration_render.py +0 -0
  19. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/_outer_joins.py +0 -0
  20. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/audit.py +0 -0
  21. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/audit_sinks.py +0 -0
  22. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/base.py +0 -0
  23. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/bulk_audit.py +0 -0
  24. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/bulk_audit_targets.py +0 -0
  25. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/callbacks.py +0 -0
  26. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/core_scope.py +0 -0
  27. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/deps.py +0 -0
  28. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/engine_options.py +0 -0
  29. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/extensions.py +0 -0
  30. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/flush_guard.py +0 -0
  31. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/independent.py +0 -0
  32. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/listeners.py +0 -0
  33. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/migration_portability.py +0 -0
  34. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/migrations.py +0 -0
  35. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/mixins.py +0 -0
  36. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/model_registry.py +0 -0
  37. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/pagination.py +0 -0
  38. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/provider.py +0 -0
  39. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/py.typed +0 -0
  40. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/query_filter.py +0 -0
  41. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/resource_policy.py +0 -0
  42. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/search.py +0 -0
  43. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/stored_settings.py +0 -0
  44. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/tenancy.py +0 -0
  45. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/tenant_cleanup.py +0 -0
  46. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/transaction.py +0 -0
  47. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/types.py +0 -0
  48. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/user_directory_sql.py +0 -0
  49. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/writes.py +0 -0
  50. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/_audit_models.py +0 -0
  51. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/_bulk_audit_support.py +0 -0
  52. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/_core_scope_models.py +0 -0
  53. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/_db_shape_matrix.py +0 -0
  54. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/_models.py +0 -0
  55. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/conftest.py +0 -0
  56. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_audit.py +0 -0
  57. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_audit_per_engine.py +0 -0
  58. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_aware_datetime.py +0 -0
  59. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_base.py +0 -0
  60. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_bulk_audit.py +0 -0
  61. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_bulk_audit_targets.py +0 -0
  62. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_connection_policy.py +0 -0
  63. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_core_full_setup_join.py +0 -0
  64. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_core_in_orm_select.py +0 -0
  65. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_core_join_scope.py +0 -0
  66. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_core_nested_scope.py +0 -0
  67. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_engine_options.py +0 -0
  68. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_extensions.py +0 -0
  69. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_extensions_pg.py +0 -0
  70. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_independent_checkpoint.py +0 -0
  71. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_listeners_attach.py +0 -0
  72. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_logging.py +0 -0
  73. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_pagination.py +0 -0
  74. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_pagination_options.py +0 -0
  75. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_savepoint_callbacks.py +0 -0
  76. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_shape_matrix_dml.py +0 -0
  77. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_shape_matrix_joins.py +0 -0
  78. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_shape_matrix_selects.py +0 -0
  79. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_sqlite_savepoint.py +0 -0
  80. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_stored_settings.py +0 -0
  81. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_tenant_cleanup.py +0 -0
  82. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_user_directory_sql.py +0 -0
  83. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_default_tenant.py +0 -0
  84. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_independent_session.py +0 -0
  85. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_migration_portability.py +0 -0
  86. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_migrations.py +0 -0
  87. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_mixins.py +0 -0
  88. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_multi_tenancy.py +0 -0
  89. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_query_filter_outer_joins.py +0 -0
  90. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_query_filter_rel_outer_joins.py +0 -0
  91. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_query_filters.py +0 -0
  92. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_rollback_logging.py +0 -0
  93. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_session.py +0 -0
  94. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_soft_delete_shapes.py +0 -0
  95. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_tenant_dml.py +0 -0
  96. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_tenant_shapes.py +0 -0
  97. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_tenant_strict.py +0 -0
  98. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_transaction.py +0 -0
  99. {simple_module_db-0.0.40 → simple_module_db-0.0.42}/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.40
3
+ Version: 0.0.42
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.23,>=0.20
25
25
  Requires-Dist: alembic<2,>=1.14
26
26
  Requires-Dist: asyncpg<0.33,>=0.30
27
27
  Requires-Dist: psycopg2-binary<3,>=2.9
28
- Requires-Dist: simple-module-core==0.0.40
28
+ Requires-Dist: simple-module-core==0.0.42
29
29
  Requires-Dist: sqlalchemy[asyncio]<3,>=2.0
30
30
  Requires-Dist: sqlmodel<0.1,>=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.40"
3
+ version = "0.0.42"
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,<3",
31
- "simple_module_core==0.0.40",
31
+ "simple_module_core==0.0.42",
32
32
  "sqlalchemy[asyncio]>=2.0,<3",
33
33
  "sqlmodel>=0.0.22,<0.1",
34
34
  ]
@@ -0,0 +1,83 @@
1
+ """Database-backed Doctor diagnostics, separate from core's static checks."""
2
+
3
+ from collections.abc import Sequence
4
+ from pathlib import Path
5
+
6
+ from alembic.runtime.migration import MigrationContext
7
+ from simple_module_core.data_compatibility import collect_data_compatibility
8
+ from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel
9
+ from simple_module_core.module import ModuleBase
10
+ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
11
+
12
+ DATA_COMPATIBILITY_CODE = "SM033"
13
+
14
+
15
+ async def database_compatibility_diagnostics(
16
+ modules: Sequence[ModuleBase], database_url: str
17
+ ) -> list[Diagnostic]:
18
+ try:
19
+ if not _database_exists(database_url):
20
+ return [] # Nothing migrated yet; opening it would create an empty SQLite file.
21
+ engine = create_async_engine(database_url)
22
+ except Exception:
23
+ # A malformed or non-async URL must be a finding, not a traceback; the URL can
24
+ # carry credentials, so it is not echoed.
25
+ return [_problem(DiagnosticLevel.WARNING, "Could not open the database URL.")]
26
+ try:
27
+ try:
28
+ async with engine.connect() as conn:
29
+ migrated = await conn.run_sync(
30
+ lambda sync: bool(MigrationContext.configure(sync).get_current_heads())
31
+ )
32
+ except Exception:
33
+ # Unreachable is a warning: doctor also runs where no database is up
34
+ # (CI, a laptop without the stack); boot is where errors are refused.
35
+ # Connection errors can carry credentials, so the cause is not shown.
36
+ return [_problem(DiagnosticLevel.WARNING, "Could not reach the database.")]
37
+ if not migrated:
38
+ return []
39
+ try:
40
+ async with async_sessionmaker(engine)() as db:
41
+ issues = await collect_data_compatibility(modules, db)
42
+ except Exception as exc:
43
+ # A check that crashes is a bug to fix, not a pass: doctor must fail.
44
+ return [
45
+ _problem(
46
+ DiagnosticLevel.ERROR,
47
+ f"A module data compatibility check failed ({type(exc).__name__}).",
48
+ )
49
+ ]
50
+ return [
51
+ Diagnostic(
52
+ level=DiagnosticLevel(issue.severity),
53
+ code=DATA_COMPATIBILITY_CODE,
54
+ module_name=issue.upgrade_id.split(".", 1)[0],
55
+ message=f"{issue.upgrade_id}: {issue.message}",
56
+ suggestion=issue.remediation(),
57
+ )
58
+ for issue in issues
59
+ ]
60
+ finally:
61
+ await engine.dispose()
62
+
63
+
64
+ def _problem(level: DiagnosticLevel, message: str) -> Diagnostic:
65
+ return Diagnostic(
66
+ level=level,
67
+ code=DATA_COMPATIBILITY_CODE,
68
+ module_name="<database>",
69
+ message=message,
70
+ suggestion="Check database connectivity and apply pending host schema migrations, "
71
+ "then rerun doctor.",
72
+ )
73
+
74
+
75
+ def _database_exists(database_url: str) -> bool:
76
+ """False for a SQLite file that isn't there yet; every other URL is tried."""
77
+ from sqlalchemy.engine import make_url
78
+
79
+ url = make_url(database_url)
80
+ if not url.get_backend_name().startswith("sqlite"):
81
+ return True
82
+ database = url.database or ""
83
+ return database in ("", ":memory:") or Path(database).exists()
@@ -10,7 +10,9 @@ from __future__ import annotations
10
10
 
11
11
  from typing import Any
12
12
 
13
+ from sqlalchemy import literal, select
13
14
  from sqlalchemy.orm import ORMExecuteState
15
+ from sqlalchemy.sql.elements import BindParameter, Label
14
16
 
15
17
  from simple_module_db import model_registry as registry
16
18
  from simple_module_db.tenancy import (
@@ -77,6 +79,8 @@ def guard_insert(
77
79
  bound = current_tenant_id.get()
78
80
  tenant_id = None if bypass else bound
79
81
  stamp = bound or fallback
82
+ if getattr(execute_state.statement, "select", None) is not None:
83
+ return _guard_from_select(execute_state, tenant_id, stamp, mappers[0], strict)
80
84
  if getattr(execute_state.statement, "_multi_values", None):
81
85
  return _guard_multi_values(execute_state, tenant_id, stamp, mappers[0], strict)
82
86
  for value in assigned_tenant_ids(execute_state):
@@ -100,6 +104,46 @@ def guard_insert(
100
104
  return None
101
105
 
102
106
 
107
+ def _guard_from_select(
108
+ execute_state: ORMExecuteState, tenant_id: str | None, stamp: str, entity: str, strict: bool
109
+ ) -> None:
110
+ stmt = execute_state.statement
111
+ if tenant_id is None and strict:
112
+ raise missing_tenant_error(entity, "INSERT")
113
+ names = list(stmt._select_names)
114
+ if TENANT_COLUMN in names:
115
+ if tenant_id is None:
116
+ return
117
+ # SQL expressions cannot be validated as Python values; require a bound
118
+ # literal, or an explicit all_tenants bypass for cross-tenant copies.
119
+ params = _param_rows(execute_state)
120
+ bind_names = stmt.compile().bind_names if params else {}
121
+ sources = [stmt.select]
122
+ while sources:
123
+ source = sources.pop()
124
+ if hasattr(source, "selects"):
125
+ sources.extend(source.selects)
126
+ continue
127
+ value = list(source.selected_columns)[names.index(TENANT_COLUMN)]
128
+ while isinstance(value, Label):
129
+ value = value.element
130
+ if not isinstance(value, BindParameter) or value.callable is not None:
131
+ raise TenantIsolationError("INSERT ... SELECT tenant_id must be a bound literal")
132
+ for row in params or [{}]:
133
+ assigned = row.get(
134
+ value.key, row.get(bind_names.get(value, value.key), value.value)
135
+ )
136
+ if assigned != tenant_id:
137
+ raise _foreign_insert(assigned, tenant_id)
138
+ return
139
+ source = stmt.select.subquery()
140
+ execute_state.statement = stmt.from_select(
141
+ [*names, TENANT_COLUMN],
142
+ select(*source.c, literal(stamp)),
143
+ include_defaults=stmt.include_insert_from_select_defaults,
144
+ )
145
+
146
+
103
147
  def _guard_multi_values(
104
148
  execute_state: ORMExecuteState, tenant_id: str | None, stamp: str, entity: str, strict: bool
105
149
  ) -> Any:
@@ -106,19 +106,40 @@ class DbLeaseBackend:
106
106
  await conn.execute(_SQLITE_WRITE_LOCK)
107
107
  yield conn, await _db_now(conn)
108
108
 
109
+ async def _locked_now(self, conn: AsyncConnection, *where: Any) -> datetime:
110
+ """Lock the matching rows on Postgres, then read the clock (#698).
111
+
112
+ Postgres computes an UPDATE's new values before it waits on a row lock,
113
+ so a time read first could grant (or "refresh") a lease that expired
114
+ during the wait. SQLite holds the database write lock already.
115
+ """
116
+ await conn.execute(select(_T.c.slot).where(*where).with_for_update())
117
+ return await _db_now(conn)
118
+
109
119
  async def _take_slot(
110
120
  self,
111
121
  conn: AsyncConnection,
112
122
  name: str,
113
123
  slot: int,
114
124
  token: str,
115
- expires: datetime,
125
+ ttl: float,
116
126
  now: datetime,
117
127
  ) -> int | None:
118
128
  """Claim *slot* if free (missing or expired) or already ours; return its fence."""
119
- values = {"holder": token, "expires_at": expires}
120
- ins = _insert(conn.dialect.name).values(name=name, slot=slot, fence=1, **values)
121
- taken = (await conn.execute(ins.on_conflict_do_nothing())).rowcount == 1
129
+ if self._sqlite:
130
+ values = {"holder": token, "expires_at": now + timedelta(seconds=ttl)}
131
+ ins = _insert(conn.dialect.name).values(name=name, slot=slot, fence=1, **values)
132
+ taken = (await conn.execute(ins.on_conflict_do_nothing())).rowcount == 1
133
+ else:
134
+ # An expired placeholder gives a missing slot a row to lock; the
135
+ # UPDATE below then claims it exactly like an expired lease (fence 1).
136
+ placeholder = _insert(conn.dialect.name).values(
137
+ name=name, slot=slot, holder=_FREE, fence=0, expires_at=_EPOCH
138
+ )
139
+ await conn.execute(placeholder.on_conflict_do_nothing())
140
+ now = await self._locked_now(conn, _row(name, slot))
141
+ values = {"holder": token, "expires_at": now + timedelta(seconds=ttl)}
142
+ taken = False
122
143
  if not taken:
123
144
  stmt = (
124
145
  update(_T)
@@ -144,15 +165,14 @@ class DbLeaseBackend:
144
165
 
145
166
  async def acquire(self, name: str, token: str, ttl: float) -> int | None:
146
167
  async with self._tx() as (conn, now):
147
- return await self._take_slot(conn, name, 0, token, now + timedelta(seconds=ttl), now)
168
+ return await self._take_slot(conn, name, 0, token, ttl, now)
148
169
 
149
170
  async def semaphore_acquire(self, name: str, token: str, slots: int, ttl: float) -> int | None:
150
171
  async with self._tx() as (conn, now):
151
- expires = now + timedelta(seconds=ttl)
152
172
  mine = await self._slot_of(conn, name, token, now)
153
173
  order = [mine] if mine is not None else range(slots)
154
174
  for slot in order:
155
- if await self._take_slot(conn, name, slot, token, expires, now) is not None:
175
+ if await self._take_slot(conn, name, slot, token, ttl, now) is not None:
156
176
  return await self._next_counter(conn, name)
157
177
  return None
158
178
 
@@ -169,8 +189,11 @@ class DbLeaseBackend:
169
189
  ) -> bool:
170
190
  """Extend (``ttl``) or end (``None``) *token*'s unexpired hold."""
171
191
  async with self._tx() as (conn, now):
172
- where = [_T.c.name == name, _T.c.holder == token, _T.c.expires_at > now]
192
+ where = [_T.c.name == name, _T.c.holder == token]
173
193
  where.append(_T.c.slot >= 0 if any_slot else _T.c.slot == 0)
194
+ if not self._sqlite:
195
+ now = await self._locked_now(conn, *where)
196
+ where.append(_T.c.expires_at > now)
174
197
  expires = now if ttl is None else now + timedelta(seconds=ttl)
175
198
  result = await conn.execute(update(_T).where(*where).values(expires_at=expires))
176
199
  return result.rowcount > 0
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import sqlite3
6
+ import time
5
7
  from collections.abc import Callable
6
8
  from dataclasses import dataclass, field
7
9
 
@@ -153,6 +155,34 @@ def _is_file_database(database_url: str) -> bool:
153
155
  return bool(path) and path != ":memory:"
154
156
 
155
157
 
158
+ def _ensure_wal(cursor, timeout_ms: int = 5000) -> None:
159
+ """Switch the file to WAL unless it already is.
160
+
161
+ Switching needs an exclusive lock, and SQLite fails it with "database is
162
+ locked" at once, without waiting out ``busy_timeout``, when another
163
+ connection holds a lock. That happens when two connections open a fresh
164
+ file together. So read the mode first (WAL persists in the file, so later
165
+ connections skip the switch), and retry a lost switch until the winner has
166
+ finished or ``timeout_ms`` has passed. On aiosqlite the sleep blocks the
167
+ event loop, but only while a fresh file is being switched.
168
+ """
169
+ deadline = time.monotonic() + timeout_ms / 1000
170
+ while _journal_mode(cursor) != "wal":
171
+ try:
172
+ cursor.execute("PRAGMA journal_mode=WAL")
173
+ return
174
+ except sqlite3.OperationalError:
175
+ if time.monotonic() >= deadline:
176
+ raise
177
+ time.sleep(0.02)
178
+
179
+
180
+ def _journal_mode(cursor) -> str:
181
+ # The aiosqlite adapter's ``execute`` returns None, so fetch from the cursor.
182
+ cursor.execute("PRAGMA journal_mode")
183
+ return cursor.fetchone()[0].lower()
184
+
185
+
156
186
  def _configure_sqlite(
157
187
  engine: AsyncEngine,
158
188
  database_url: str,
@@ -166,7 +196,7 @@ def _configure_sqlite(
166
196
  Registered on the *sync* engine's ``connect`` event — the async engine is a
167
197
  facade, and ``connect`` fires on the DBAPI connection underneath it. All
168
198
  three PRAGMAs must be re-issued per connection except ``journal_mode``,
169
- which is a property of the file; re-issuing it is cheap and idempotent.
199
+ which is a property of the file (see :func:`_ensure_wal`).
170
200
 
171
201
  Also registers a ``savepoint`` listener that opens the transaction before an
172
202
  outermost SAVEPOINT (pysqlite would otherwise let its RELEASE commit; GH #350).
@@ -182,7 +212,7 @@ def _configure_sqlite(
182
212
  if busy_timeout_ms is not None:
183
213
  cursor.execute(f"PRAGMA busy_timeout={int(busy_timeout_ms)}")
184
214
  if apply_wal:
185
- cursor.execute("PRAGMA journal_mode=WAL")
215
+ _ensure_wal(cursor, busy_timeout_ms or 0)
186
216
  finally:
187
217
  cursor.close()
188
218
 
@@ -0,0 +1,91 @@
1
+ """Doctor checks semantic upgrades without importing host code (#711)."""
2
+
3
+ import pytest
4
+ from simple_module_core.data_compatibility import DataCompatibilityIssue
5
+ from simple_module_core.module import ModuleBase, ModuleMeta
6
+ from sqlalchemy import text
7
+ from sqlalchemy.ext.asyncio import create_async_engine
8
+
9
+
10
+ @pytest.mark.parametrize(
11
+ ("migrated", "severity", "expected"),
12
+ [
13
+ (True, "error", "error"),
14
+ (True, "warning", "warning"),
15
+ (False, "error", None),
16
+ ],
17
+ )
18
+ async def test_db_doctor_data_findings(tmp_path, migrated, severity, expected):
19
+ from simple_module_db.data_compatibility import database_compatibility_diagnostics
20
+
21
+ url = f"sqlite+aiosqlite:///{tmp_path / 'doctor.db'}"
22
+ engine = create_async_engine(url)
23
+ try:
24
+ async with engine.begin() as conn:
25
+ if migrated:
26
+ await conn.execute(text("CREATE TABLE alembic_version (version_num VARCHAR(32))"))
27
+ await conn.execute(
28
+ text("INSERT INTO alembic_version VALUES ('host_local_revision')")
29
+ )
30
+ finally:
31
+ await engine.dispose()
32
+
33
+ class Module(ModuleBase):
34
+ meta = ModuleMeta(name="Example")
35
+
36
+ async def check_data_compatibility(self, db):
37
+ return [
38
+ DataCompatibilityIssue(
39
+ upgrade_id="example.v1", message="Upgrade required", severity=severity
40
+ )
41
+ ]
42
+
43
+ findings = await database_compatibility_diagnostics([Module()], url)
44
+ if expected:
45
+ assert len(findings) == 1
46
+ assert findings[0].level == expected
47
+ assert "example.v1" in findings[0].message
48
+ assert "smpy migrations prepare example.v1" in findings[0].suggestion
49
+ else:
50
+ assert findings == []
51
+
52
+
53
+ async def test_db_doctor_skips_a_sqlite_file_that_does_not_exist(tmp_path):
54
+ from simple_module_db.data_compatibility import database_compatibility_diagnostics
55
+
56
+ path = tmp_path / "app.db"
57
+ assert await database_compatibility_diagnostics([], f"sqlite+aiosqlite:///{path}") == []
58
+ # Doctor must not leave an empty database behind.
59
+ assert not path.exists()
60
+
61
+
62
+ async def test_db_doctor_warns_on_unreachable_database(tmp_path):
63
+ from simple_module_db.data_compatibility import database_compatibility_diagnostics
64
+
65
+ # A directory exists but cannot be opened as a SQLite database.
66
+ findings = await database_compatibility_diagnostics([], f"sqlite+aiosqlite:///{tmp_path}")
67
+ assert len(findings) == 1
68
+ assert findings[0].level == "warning"
69
+ assert "could not reach" in findings[0].message.lower()
70
+
71
+
72
+ async def test_db_doctor_fails_when_a_module_check_crashes(tmp_path):
73
+ from simple_module_core.module import ModuleBase
74
+ from simple_module_core.module_meta import ModuleMeta
75
+ from simple_module_db.data_compatibility import database_compatibility_diagnostics
76
+ from sqlalchemy import create_engine, text
77
+
78
+ path = tmp_path / "app.db"
79
+ with create_engine(f"sqlite:///{path}").begin() as conn:
80
+ conn.execute(text("CREATE TABLE alembic_version (version_num VARCHAR(32))"))
81
+ conn.execute(text("INSERT INTO alembic_version VALUES ('abc')"))
82
+
83
+ class Broken(ModuleBase):
84
+ meta = ModuleMeta(name="Broken")
85
+
86
+ async def check_data_compatibility(self, db):
87
+ raise KeyError("bug")
88
+
89
+ findings = await database_compatibility_diagnostics([Broken()], f"sqlite+aiosqlite:///{path}")
90
+ assert [f.level for f in findings] == ["error"]
91
+ assert "KeyError" in findings[0].message
@@ -0,0 +1,129 @@
1
+ """Tenant destinations for INSERT ... SELECT (#693)."""
2
+
3
+ import pytest
4
+ from _core_scope_models import Tag, _Base, tag
5
+ from simple_module_db.listeners import attach_session_listeners
6
+ from simple_module_db.query_filter import EngineTenancy, bind_engine_policy
7
+ from simple_module_db.tenancy import (
8
+ TenantIsolationError,
9
+ current_tenant_id,
10
+ no_tenant,
11
+ tenant_context,
12
+ )
13
+ from sqlalchemy import bindparam, create_engine, insert, literal, select, union_all
14
+ from sqlalchemy.orm import Session, configure_mappers
15
+
16
+
17
+ @pytest.fixture
18
+ def seeded():
19
+ configure_mappers()
20
+ engine = create_engine("sqlite://")
21
+ _Base.metadata.create_all(engine)
22
+ with engine.begin() as conn:
23
+ conn.execute(
24
+ insert(tag),
25
+ [
26
+ {"id": 1, "name": "used", "tenant_id": "A"},
27
+ {"id": 2, "name": "unused", "tenant_id": "A"},
28
+ {"id": 3, "name": "b-tag", "tenant_id": "B"},
29
+ ],
30
+ )
31
+
32
+ class TenantSession(Session):
33
+ pass
34
+
35
+ attach_session_listeners(TenantSession)
36
+ bind_engine_policy(engine, EngineTenancy(tenant_strict=False))
37
+ try:
38
+ with TenantSession(engine) as session, tenant_context("A"):
39
+ yield session
40
+ finally:
41
+ engine.dispose()
42
+
43
+
44
+ def _rows(session, stmt, **options):
45
+ return sorted(session.execute(stmt.execution_options(**options)).all(), key=repr)
46
+
47
+
48
+ @pytest.mark.parametrize("compound", [False, True])
49
+ @pytest.mark.parametrize("target", [tag, Tag])
50
+ def test_insert_select_stamps_tenant_and_scopes_source(seeded, compound, target):
51
+ source = select((tag.c.id + 100).label("id"), literal("copy").label("name"))
52
+ if compound:
53
+ source = union_all(source.where(tag.c.id == 1), source.where(tag.c.id == 2))
54
+ seeded.execute(insert(target).from_select(["id", "name"], source))
55
+ assert _rows(
56
+ seeded, select(tag.c.id, tag.c.tenant_id).where(tag.c.id >= 100), all_tenants=True
57
+ ) == [(101, "A"), (102, "A")]
58
+
59
+
60
+ def test_insert_select_accepts_matching_explicit_tenant(seeded):
61
+ source = select(literal(101), literal("copy"), literal("A"))
62
+ seeded.execute(insert(tag).from_select(["id", "name", "tenant_id"], source))
63
+ assert _rows(seeded, select(tag.c.id).where(tag.c.id == 101)) == [(101,)]
64
+
65
+
66
+ def test_insert_select_refuses_foreign_explicit_tenant(seeded):
67
+ source = select(literal(101), literal("copy"), literal("B"))
68
+ with pytest.raises(TenantIsolationError):
69
+ seeded.execute(insert(tag).from_select(["id", "name", "tenant_id"], source))
70
+
71
+
72
+ @pytest.mark.parametrize("explicit", [False, True])
73
+ def test_strict_insert_select_requires_tenant_even_for_plain_source(seeded, explicit):
74
+ bind_engine_policy(seeded.bind, EngineTenancy(tenant_strict=True))
75
+ current_tenant_id.set(None)
76
+ columns, values = ["id", "name"], [literal(101), literal("copy")]
77
+ if explicit:
78
+ columns.append("tenant_id")
79
+ values.append(literal("A"))
80
+ with pytest.raises(TenantIsolationError):
81
+ seeded.execute(insert(tag).from_select(columns, select(*values)))
82
+
83
+
84
+ def test_unbound_insert_select_uses_fallback_tenant(seeded):
85
+ with no_tenant():
86
+ seeded.execute(
87
+ insert(tag).from_select(["id", "name"], select(literal(101), literal("copy")))
88
+ )
89
+ assert _rows(seeded, select(tag.c.tenant_id).where(tag.c.id == 101), all_tenants=True) == [
90
+ ("default",)
91
+ ]
92
+
93
+
94
+ @pytest.mark.parametrize("strict", [False, True])
95
+ def test_insert_select_bypass_preserves_explicit_tenant(seeded, strict):
96
+ bind_engine_policy(seeded.bind, EngineTenancy(tenant_strict=strict))
97
+ stmt = insert(tag).from_select(
98
+ ["id", "name", "tenant_id"], select(literal(101), literal("copy"), literal("B"))
99
+ )
100
+ seeded.execute(stmt.execution_options(all_tenants=True))
101
+ assert _rows(seeded, select(tag.c.tenant_id).where(tag.c.id == 101), all_tenants=True) == [
102
+ ("B",)
103
+ ]
104
+
105
+
106
+ def test_insert_select_refuses_unverifiable_tenant_expression(seeded):
107
+ source = select(tag.c.id + 100, tag.c.name, tag.c.tenant_id)
108
+ with pytest.raises(TenantIsolationError):
109
+ seeded.execute(insert(tag).from_select(["id", "name", "tenant_id"], source))
110
+
111
+
112
+ def test_insert_select_checks_every_compound_branch(seeded):
113
+ source = union_all(
114
+ select(literal(101), literal("copy"), literal("A")),
115
+ select(literal(102), literal("copy"), literal("B")),
116
+ )
117
+ with pytest.raises(TenantIsolationError):
118
+ seeded.execute(insert(tag).from_select(["id", "name", "tenant_id"], source))
119
+
120
+
121
+ @pytest.mark.parametrize("anonymous", [False, True])
122
+ def test_insert_select_checks_execution_parameter_override(seeded, anonymous):
123
+ tenant = literal("A") if anonymous else bindparam("tenant", "A")
124
+ source = select(literal(101), literal("copy"), tenant)
125
+ parameter = "param_3" if anonymous else "tenant"
126
+ with pytest.raises(TenantIsolationError):
127
+ seeded.execute(
128
+ insert(tag).from_select(["id", "name", "tenant_id"], source), {parameter: "B"}
129
+ )
@@ -0,0 +1,39 @@
1
+ """A lease taken after waiting on a row lock is still valid when granted (#698)."""
2
+
3
+ import asyncio
4
+ import uuid
5
+
6
+ import pytest
7
+ from simple_module_db.leases import DbLeaseBackend, LeaseRow
8
+ from sqlalchemy import func, select
9
+
10
+ _T = LeaseRow.__table__
11
+
12
+
13
+ @pytest.mark.postgres
14
+ @pytest.mark.parametrize("operation", ["acquire", "refresh"])
15
+ async def test_postgres_lock_wait_does_not_consume_ttl(engine, operation):
16
+ async with engine.begin() as conn:
17
+ await conn.run_sync(LeaseRow.metadata.create_all)
18
+ backend = DbLeaseBackend(engine)
19
+ name = f"lockwait-{uuid.uuid4().hex}"
20
+ holder = "old" if operation == "acquire" else "me"
21
+ ttl = 0.01 if operation == "acquire" else 60
22
+ assert await backend.acquire(name, holder, ttl) is not None
23
+ await asyncio.sleep(0.05) # intentional-sleep: let the 10 ms lease expire
24
+
25
+ async with engine.connect() as blocker:
26
+ await blocker.begin()
27
+ row = (_T.c.name == name) & (_T.c.slot == 0)
28
+ await blocker.execute(select(_T.c.fence).where(row).with_for_update())
29
+ if operation == "acquire":
30
+ pending = asyncio.create_task(backend.acquire(name, "new", 1.0))
31
+ else:
32
+ pending = asyncio.create_task(backend.refresh(name, "me", 1.0))
33
+ await asyncio.sleep(1.5) # intentional-sleep: hold the lock past the 1 s TTL
34
+ await blocker.commit()
35
+ assert await pending
36
+
37
+ async with engine.connect() as conn:
38
+ live = await conn.execute(select(_T.c.expires_at > func.clock_timestamp()).where(row))
39
+ assert live.scalar_one() is True
@@ -0,0 +1,87 @@
1
+ """The per-connection WAL switch must tolerate a concurrent opener (fresh file)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ import threading
7
+
8
+ import pytest
9
+ from simple_module_db.session import _ensure_wal
10
+
11
+
12
+ class _Cursor:
13
+ """A cursor whose switch loses the race; the winner leaves ``after``."""
14
+
15
+ def __init__(self, before: str, after: str) -> None:
16
+ self.mode = before
17
+ self.after = after
18
+ self.switches = 0
19
+
20
+ def execute(self, sql: str):
21
+ if sql == "PRAGMA journal_mode=WAL":
22
+ self.switches += 1
23
+ self.mode = self.after
24
+ raise sqlite3.OperationalError("database is locked")
25
+ return self
26
+
27
+ def fetchone(self):
28
+ return (self.mode,)
29
+
30
+
31
+ def test_already_wal_skips_the_switch():
32
+ cursor = _Cursor("wal", "wal")
33
+ _ensure_wal(cursor)
34
+ assert cursor.switches == 0
35
+
36
+
37
+ def test_losing_the_switch_to_another_connection_is_fine():
38
+ cursor = _Cursor("delete", "wal")
39
+ _ensure_wal(cursor)
40
+ assert cursor.switches == 1
41
+
42
+
43
+ def test_a_switch_still_in_progress_is_retried():
44
+ cursor = _Cursor("delete", "delete")
45
+ original = cursor.execute
46
+
47
+ def execute(sql):
48
+ if sql == "PRAGMA journal_mode=WAL" and cursor.switches == 2:
49
+ cursor.mode = "wal"
50
+ return cursor
51
+ return original(sql)
52
+
53
+ cursor.execute = execute
54
+ _ensure_wal(cursor)
55
+ assert (cursor.switches, cursor.mode) == (2, "wal")
56
+
57
+
58
+ def test_a_switch_that_leaves_the_file_unjournaled_still_raises():
59
+ with pytest.raises(sqlite3.OperationalError, match="locked"):
60
+ _ensure_wal(_Cursor("delete", "delete"), timeout_ms=50)
61
+
62
+
63
+ def test_fresh_file_waits_for_a_concurrent_switch(tmp_path):
64
+ """The old one-shot switch failed here at once: the other opener held a lock."""
65
+ path = tmp_path / "app.db"
66
+ other = sqlite3.connect(path, check_same_thread=False)
67
+ other.execute("CREATE TABLE t (x)")
68
+ other.commit()
69
+ other.execute("BEGIN IMMEDIATE")
70
+ other.execute("INSERT INTO t VALUES (1)")
71
+
72
+ def finish_and_switch():
73
+ other.commit()
74
+ other.execute("PRAGMA journal_mode=WAL")
75
+
76
+ timer = threading.Timer(0.2, finish_and_switch)
77
+ opener = sqlite3.connect(path, timeout=0)
78
+ try:
79
+ with pytest.raises(sqlite3.OperationalError, match="locked"):
80
+ opener.execute("PRAGMA journal_mode=WAL")
81
+ timer.start()
82
+ _ensure_wal(opener.cursor(), timeout_ms=5000)
83
+ assert opener.execute("PRAGMA journal_mode").fetchone()[0] == "wal"
84
+ finally:
85
+ timer.join()
86
+ opener.close()
87
+ other.close()