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.
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/PKG-INFO +2 -2
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/pyproject.toml +2 -2
- simple_module_db-0.0.42/simple_module_db/data_compatibility.py +83 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/insert_guard.py +44 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/leases.py +31 -8
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/session.py +32 -2
- simple_module_db-0.0.42/tests/test_db_data_compatibility_doctor.py +91 -0
- simple_module_db-0.0.42/tests/test_db_insert_select_tenant.py +129 -0
- simple_module_db-0.0.42/tests/test_db_lease_lock_wait.py +39 -0
- simple_module_db-0.0.42/tests/test_db_sqlite_wal_switch.py +87 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/.gitignore +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/LICENSE +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/README.md +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/__init__.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/_bulk_audit_records.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/_core_joins.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/_core_walk.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/_migration_render.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/_outer_joins.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/audit.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/audit_sinks.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/base.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/bulk_audit.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/bulk_audit_targets.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/callbacks.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/core_scope.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/deps.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/engine_options.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/extensions.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/flush_guard.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/independent.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/listeners.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/migration_portability.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/migrations.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/mixins.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/model_registry.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/pagination.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/provider.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/py.typed +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/query_filter.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/resource_policy.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/search.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/stored_settings.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/tenancy.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/tenant_cleanup.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/transaction.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/types.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/user_directory_sql.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/writes.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/_audit_models.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/_bulk_audit_support.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/_core_scope_models.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/_db_shape_matrix.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/_models.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/conftest.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_audit.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_audit_per_engine.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_aware_datetime.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_base.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_bulk_audit.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_bulk_audit_targets.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_connection_policy.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_core_full_setup_join.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_core_in_orm_select.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_core_join_scope.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_core_nested_scope.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_engine_options.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_extensions.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_extensions_pg.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_independent_checkpoint.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_listeners_attach.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_logging.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_pagination.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_pagination_options.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_savepoint_callbacks.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_shape_matrix_dml.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_shape_matrix_joins.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_shape_matrix_selects.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_sqlite_savepoint.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_stored_settings.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_tenant_cleanup.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_db_user_directory_sql.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_default_tenant.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_independent_session.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_migration_portability.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_migrations.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_mixins.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_multi_tenancy.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_query_filter_outer_joins.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_query_filter_rel_outer_joins.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_query_filters.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_rollback_logging.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_session.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_soft_delete_shapes.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_tenant_dml.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_tenant_shapes.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_tenant_strict.py +0 -0
- {simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_transaction.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_db-0.0.40 → simple_module_db-0.0.42}/simple_module_db/migration_portability.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_db-0.0.40 → simple_module_db-0.0.42}/tests/test_query_filter_rel_outer_joins.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|