simple-module-db 0.0.36__tar.gz → 0.0.37__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.36 → simple_module_db-0.0.37}/.gitignore +1 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/PKG-INFO +2 -2
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/pyproject.toml +2 -2
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/__init__.py +9 -0
- simple_module_db-0.0.37/simple_module_db/extensions.py +103 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/migrations.py +19 -2
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/mixins.py +6 -4
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/tenancy.py +19 -0
- simple_module_db-0.0.37/simple_module_db/types.py +94 -0
- simple_module_db-0.0.37/tests/test_aware_datetime.py +76 -0
- simple_module_db-0.0.37/tests/test_db_extensions.py +94 -0
- simple_module_db-0.0.37/tests/test_db_extensions_pg.py +174 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_migration_portability.py +29 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/LICENSE +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/README.md +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/_outer_joins.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/audit.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/base.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/callbacks.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/deps.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/flush_guard.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/insert_guard.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/listeners.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/migration_portability.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/model_registry.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/provider.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/py.typed +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/query_filter.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/search.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/session.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/subquery_guard.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/transaction.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/writes.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/_audit_models.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/_models.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/conftest.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_audit.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_base.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_db_listeners_attach.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_db_logging.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_db_sqlite_savepoint.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_default_tenant.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_migrations.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_mixins.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_multi_tenancy.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_query_filter_outer_joins.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_query_filter_rel_outer_joins.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_query_filters.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_session.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_soft_delete_shapes.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_tenant_dml.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_tenant_shapes.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_tenant_strict.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_transaction.py +0 -0
- {simple_module_db-0.0.36 → simple_module_db-0.0.37}/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.37
|
|
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.
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.37
|
|
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.
|
|
3
|
+
version = "0.0.37"
|
|
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.
|
|
31
|
+
"simple_module_core==0.0.37",
|
|
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.extensions import UnsupportedDatabaseError, ensure_db_extensions
|
|
7
8
|
from simple_module_db.migration_portability import drop_enums_if_postgres
|
|
8
9
|
from simple_module_db.migrations import (
|
|
9
10
|
build_module_metadata,
|
|
@@ -26,9 +27,11 @@ from simple_module_db.tenancy import (
|
|
|
26
27
|
bind_current_tenant,
|
|
27
28
|
current_tenant_id,
|
|
28
29
|
is_valid_tenant_id,
|
|
30
|
+
no_tenant,
|
|
29
31
|
tenant_context,
|
|
30
32
|
)
|
|
31
33
|
from simple_module_db.transaction import CommitBeforeResponseMiddleware, finalize_session
|
|
34
|
+
from simple_module_db.types import AwareDateTime, LTree, TSVector
|
|
32
35
|
from simple_module_db.writes import hard_delete, mark_written
|
|
33
36
|
|
|
34
37
|
__all__ = [
|
|
@@ -39,15 +42,19 @@ __all__ = [
|
|
|
39
42
|
"TENANT_ID_PATTERN",
|
|
40
43
|
"AuditMixin",
|
|
41
44
|
"AuditRecord",
|
|
45
|
+
"AwareDateTime",
|
|
42
46
|
"CommitBeforeResponseMiddleware",
|
|
43
47
|
"DatabaseProvider",
|
|
44
48
|
"DatabaseState",
|
|
49
|
+
"LTree",
|
|
45
50
|
"MissingTenantError",
|
|
46
51
|
"MultiTenantMixin",
|
|
47
52
|
"OnCommitCallback",
|
|
48
53
|
"RequestSession",
|
|
49
54
|
"SoftDeleteMixin",
|
|
55
|
+
"TSVector",
|
|
50
56
|
"TenantIsolationError",
|
|
57
|
+
"UnsupportedDatabaseError",
|
|
51
58
|
"VersionedMixin",
|
|
52
59
|
"all_tenants",
|
|
53
60
|
"bind_current_tenant",
|
|
@@ -56,6 +63,7 @@ __all__ = [
|
|
|
56
63
|
"current_tenant_id",
|
|
57
64
|
"detect_provider",
|
|
58
65
|
"drop_enums_if_postgres",
|
|
66
|
+
"ensure_db_extensions",
|
|
59
67
|
"finalize_session",
|
|
60
68
|
"get_db",
|
|
61
69
|
"hard_delete",
|
|
@@ -66,6 +74,7 @@ __all__ = [
|
|
|
66
74
|
"make_include_object",
|
|
67
75
|
"make_process_revision_directives",
|
|
68
76
|
"mark_written",
|
|
77
|
+
"no_tenant",
|
|
69
78
|
"render_item",
|
|
70
79
|
"tenant_context",
|
|
71
80
|
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Postgres extensions a module needs (``ModuleBase.db_extensions``).
|
|
2
|
+
|
|
3
|
+
``ensure_db_extensions`` runs from the host ``env.py`` before the migrations
|
|
4
|
+
do, so a revision that creates an ``ltree`` column finds the extension already
|
|
5
|
+
installed. It is idempotent (``CREATE EXTENSION IF NOT EXISTS``), so it costs
|
|
6
|
+
nothing on an up-to-date database.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from simple_module_core import ModuleBase
|
|
17
|
+
from sqlalchemy import text
|
|
18
|
+
|
|
19
|
+
from simple_module_db.types import LTree, TSVector
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
EXTENSION_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_-]*$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UnsupportedDatabaseError(RuntimeError):
|
|
27
|
+
"""A module needs a database feature the configured database lacks."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def declared_extensions(modules: Iterable[ModuleBase]) -> dict[str, list[str]]:
|
|
31
|
+
"""``{extension: [module names that declare it]}``, validated, in module order."""
|
|
32
|
+
wanted: dict[str, list[str]] = {}
|
|
33
|
+
for mod in modules:
|
|
34
|
+
for name in mod.db_extensions():
|
|
35
|
+
if not EXTENSION_NAME_PATTERN.fullmatch(name):
|
|
36
|
+
raise ValueError(
|
|
37
|
+
f"Module {mod.meta.name!r} declares invalid database extension "
|
|
38
|
+
f"{name!r} (must match {EXTENSION_NAME_PATTERN.pattern})"
|
|
39
|
+
)
|
|
40
|
+
owners = wanted.setdefault(name, [])
|
|
41
|
+
if mod.meta.name not in owners:
|
|
42
|
+
owners.append(mod.meta.name)
|
|
43
|
+
return wanted
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ensure_db_extensions(connection: Any, modules: Iterable[ModuleBase]) -> list[str]:
|
|
47
|
+
"""Install every extension the modules declare; return the extension names.
|
|
48
|
+
|
|
49
|
+
* Postgres: ``CREATE EXTENSION IF NOT EXISTS "<name>"``.
|
|
50
|
+
* SQLite: raises :class:`UnsupportedDatabaseError` naming the module(s) and
|
|
51
|
+
extension(s) — SQLite has none, and carrying on would fail later with a
|
|
52
|
+
less helpful message.
|
|
53
|
+
* ``connection=None`` is Alembic offline mode: the statements are emitted
|
|
54
|
+
into the generated SQL script through ``context.execute``.
|
|
55
|
+
"""
|
|
56
|
+
wanted = declared_extensions(modules)
|
|
57
|
+
if not wanted:
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
offline = connection is None
|
|
61
|
+
if offline:
|
|
62
|
+
from alembic import context
|
|
63
|
+
|
|
64
|
+
dialect = context.get_context().dialect.name
|
|
65
|
+
else:
|
|
66
|
+
dialect = connection.dialect.name
|
|
67
|
+
|
|
68
|
+
if dialect != "postgresql":
|
|
69
|
+
detail = "; ".join(f"{', '.join(owners)} needs {name!r}" for name, owners in wanted.items())
|
|
70
|
+
raise UnsupportedDatabaseError(
|
|
71
|
+
f"Database extensions require PostgreSQL, but the database is {dialect}: {detail}. "
|
|
72
|
+
"Point SM_DATABASE_URL at Postgres or uninstall the module."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
for name in wanted:
|
|
76
|
+
statement = f'CREATE EXTENSION IF NOT EXISTS "{name}"'
|
|
77
|
+
if offline:
|
|
78
|
+
context.execute(statement)
|
|
79
|
+
else:
|
|
80
|
+
logger.info("Ensuring Postgres extension %r", name)
|
|
81
|
+
connection.execute(text(statement))
|
|
82
|
+
return list(wanted)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def render_extension_type(obj: Any, imports: set[str] | None) -> str | None:
|
|
86
|
+
"""Alembic render for ``LTree`` / ``TSVector``, or ``None`` for other types.
|
|
87
|
+
|
|
88
|
+
Rendered through our own classes (not ``postgresql.TSVECTOR``) so the same
|
|
89
|
+
revision also upgrades on SQLite, where they are ``TEXT``.
|
|
90
|
+
"""
|
|
91
|
+
if isinstance(obj, (LTree, TSVector)):
|
|
92
|
+
if imports is not None:
|
|
93
|
+
imports.add("import simple_module_db.types")
|
|
94
|
+
return f"simple_module_db.types.{type(obj).__name__}()"
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = [
|
|
99
|
+
"UnsupportedDatabaseError",
|
|
100
|
+
"declared_extensions",
|
|
101
|
+
"ensure_db_extensions",
|
|
102
|
+
"render_extension_type",
|
|
103
|
+
]
|
|
@@ -27,8 +27,13 @@ from simple_module_core.diagnostics._expression_index import index_is_expression
|
|
|
27
27
|
from simple_module_core.discovery import discover_modules, get_module_package_name
|
|
28
28
|
from sqlalchemy import Index, MetaData
|
|
29
29
|
from sqlalchemy.schema import SchemaItem
|
|
30
|
+
from sqlmodel.sql.sqltypes import UTCDateTime
|
|
30
31
|
|
|
31
32
|
from simple_module_db.base import all_module_bases
|
|
33
|
+
from simple_module_db.extensions import ( # noqa: F401 (ensure_db_extensions re-exported)
|
|
34
|
+
ensure_db_extensions,
|
|
35
|
+
render_extension_type,
|
|
36
|
+
)
|
|
32
37
|
from simple_module_db.migration_portability import (
|
|
33
38
|
iter_ops_recursive,
|
|
34
39
|
render_boolean_default,
|
|
@@ -232,14 +237,18 @@ def _inject_drop_index_before_drop_table(downgrade_ops, expression_indexes) -> N
|
|
|
232
237
|
def render_item(type_, obj, autogen_context):
|
|
233
238
|
"""Alembic ``render_item`` callback for SQLModel + extension types.
|
|
234
239
|
|
|
235
|
-
* Collapses SQLModel's ``AutoString`` to ``sa.String``
|
|
236
|
-
|
|
240
|
+
* Collapses SQLModel's ``AutoString`` to ``sa.String`` and ``UTCDateTime``
|
|
241
|
+
to ``sa.DateTime(timezone=True)`` so migrations don't need to
|
|
242
|
+
``import sqlmodel`` (GH #440). Any other ``sqlmodel`` type still gets
|
|
243
|
+
the import added, so the migration stays importable.
|
|
237
244
|
* Renders Python ``StrEnum`` columns with ``values_callable`` so the
|
|
238
245
|
Postgres enum labels match the lowercase ``StrEnum`` values rather than
|
|
239
246
|
SQLAlchemy's default of using uppercase attribute names. This means raw
|
|
240
247
|
SQL like ``WHERE status = 'ready'`` actually works against the live DB.
|
|
241
248
|
* Renders ``true()``/``false()`` server defaults as ``sa.true()``/``sa.false()``
|
|
242
249
|
instead of the dialect-specific ``sa.text('1')``/``sa.text('0')`` (GH #342).
|
|
250
|
+
* Renders ``LTree``/``TSVector`` through ``simple_module_db.types`` (with
|
|
251
|
+
the import) so one revision upgrades on Postgres and SQLite alike.
|
|
243
252
|
* Adds the necessary imports for ``fastapi_users_db_sqlalchemy.generics``
|
|
244
253
|
and ``geoalchemy2`` types (rendered by their own classes elsewhere) so
|
|
245
254
|
the generated migration is importable.
|
|
@@ -257,6 +266,14 @@ def render_item(type_, obj, autogen_context):
|
|
|
257
266
|
if cls_name == "AutoString":
|
|
258
267
|
length = getattr(obj, "length", None)
|
|
259
268
|
return f"sa.String(length={length})" if length is not None else "sa.String()"
|
|
269
|
+
if isinstance(obj, UTCDateTime): # includes our ``AwareDateTime`` subclass
|
|
270
|
+
return "sa.DateTime(timezone=True)"
|
|
271
|
+
|
|
272
|
+
if (rendered := render_extension_type(obj, imports)) is not None:
|
|
273
|
+
return rendered
|
|
274
|
+
|
|
275
|
+
if imports is not None and cls_module.startswith("sqlmodel"):
|
|
276
|
+
imports.add("import sqlmodel")
|
|
260
277
|
|
|
261
278
|
if imports is not None and cls_module.startswith("fastapi_users_db_sqlalchemy"):
|
|
262
279
|
imports.add("import fastapi_users_db_sqlalchemy.generics")
|
|
@@ -10,9 +10,11 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
from datetime import UTC, datetime
|
|
12
12
|
|
|
13
|
-
from sqlalchemy import
|
|
13
|
+
from sqlalchemy import func
|
|
14
14
|
from sqlmodel import Field, SQLModel
|
|
15
15
|
|
|
16
|
+
from simple_module_db.types import AwareDateTime
|
|
17
|
+
|
|
16
18
|
|
|
17
19
|
def _utcnow() -> datetime:
|
|
18
20
|
"""Timezone-aware UTC ``now`` for the Python-side default of ``created_at``."""
|
|
@@ -32,12 +34,12 @@ class AuditMixin(SQLModel):
|
|
|
32
34
|
|
|
33
35
|
created_at: datetime = Field(
|
|
34
36
|
default_factory=_utcnow,
|
|
35
|
-
sa_type=
|
|
37
|
+
sa_type=AwareDateTime(),
|
|
36
38
|
sa_column_kwargs={"server_default": func.now()},
|
|
37
39
|
)
|
|
38
40
|
updated_at: datetime | None = Field(
|
|
39
41
|
default=None,
|
|
40
|
-
sa_type=
|
|
42
|
+
sa_type=AwareDateTime(),
|
|
41
43
|
sa_column_kwargs={"onupdate": func.now()},
|
|
42
44
|
)
|
|
43
45
|
created_by: str | None = Field(default=None, max_length=255)
|
|
@@ -55,7 +57,7 @@ class SoftDeleteMixin(SQLModel):
|
|
|
55
57
|
is_deleted: bool = Field(default=False)
|
|
56
58
|
deleted_at: datetime | None = Field(
|
|
57
59
|
default=None,
|
|
58
|
-
sa_type=
|
|
60
|
+
sa_type=AwareDateTime(),
|
|
59
61
|
)
|
|
60
62
|
deleted_by: str | None = Field(default=None, max_length=255)
|
|
61
63
|
|
|
@@ -128,6 +128,24 @@ def all_tenants() -> Generator[None]:
|
|
|
128
128
|
current_tenant_id.reset(tenant_token)
|
|
129
129
|
|
|
130
130
|
|
|
131
|
+
@contextmanager
|
|
132
|
+
def no_tenant() -> Generator[None]:
|
|
133
|
+
"""Run the block with no tenant bound and outside any ``all_tenants()``.
|
|
134
|
+
|
|
135
|
+
The scope of code that has not resolved a tenant: under strict isolation a
|
|
136
|
+
tenant-scoped query fails closed. For work replayed later on behalf of such
|
|
137
|
+
code (a durable event published from a public route), so it runs with no
|
|
138
|
+
more reach than its publisher had, whatever scope the replaying code is in.
|
|
139
|
+
"""
|
|
140
|
+
tenant_token = current_tenant_id.set(None)
|
|
141
|
+
bypass_token = _all_tenants.set(False)
|
|
142
|
+
try:
|
|
143
|
+
yield
|
|
144
|
+
finally:
|
|
145
|
+
_all_tenants.reset(bypass_token)
|
|
146
|
+
current_tenant_id.reset(tenant_token)
|
|
147
|
+
|
|
148
|
+
|
|
131
149
|
def bind_current_tenant[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
|
|
132
150
|
"""Wrap ``fn`` so it runs as the tenant bound *now*, whenever it is called.
|
|
133
151
|
|
|
@@ -197,5 +215,6 @@ __all__ = [
|
|
|
197
215
|
"is_all_tenants",
|
|
198
216
|
"is_valid_tenant_id",
|
|
199
217
|
"missing_tenant_error",
|
|
218
|
+
"no_tenant",
|
|
200
219
|
"tenant_context",
|
|
201
220
|
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Column types shared by every module.
|
|
2
|
+
|
|
3
|
+
``AwareDateTime`` is the explicit-column counterpart of the ``UTCDateTime``
|
|
4
|
+
SQLModel picks for a bare ``datetime`` field. Declaring
|
|
5
|
+
``sa_type=DateTime(timezone=True)`` bypasses that type, and on SQLite (which
|
|
6
|
+
stores no offset) such a column reads back *naive*: pydantic then serializes
|
|
7
|
+
``2026-10-14T10:40:52`` with no offset and a browser parses UTC as local time
|
|
8
|
+
(GH #468). Use ``sa_type=AwareDateTime()`` wherever a field needs an explicit
|
|
9
|
+
column type.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
|
|
16
|
+
from sqlalchemy import Text
|
|
17
|
+
from sqlalchemy.dialects import postgresql
|
|
18
|
+
from sqlalchemy.engine.interfaces import Dialect
|
|
19
|
+
from sqlalchemy.types import TypeDecorator, UserDefinedType
|
|
20
|
+
from sqlmodel.sql.sqltypes import UTCDateTime
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AwareDateTime(UTCDateTime):
|
|
24
|
+
"""``DateTime(timezone=True)`` that always reads back an aware UTC value.
|
|
25
|
+
|
|
26
|
+
Unlike SQLModel's ``UTCDateTime`` a naive value is accepted on write and
|
|
27
|
+
taken to be UTC — the semantics a plain ``DateTime(timezone=True)`` column
|
|
28
|
+
had on SQLite — so existing callers that bind naive UTC keep working.
|
|
29
|
+
Migrations render it as ``sa.DateTime(timezone=True)`` (see
|
|
30
|
+
:func:`simple_module_db.migrations.render_item`), so adopting it needs no
|
|
31
|
+
schema change.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
cache_ok = True
|
|
35
|
+
|
|
36
|
+
def __repr__(self) -> str:
|
|
37
|
+
return "AwareDateTime()"
|
|
38
|
+
|
|
39
|
+
def process_bind_param(self, value: datetime | None, dialect: Dialect) -> datetime | None:
|
|
40
|
+
if value is not None and value.utcoffset() is None:
|
|
41
|
+
value = value.replace(tzinfo=UTC)
|
|
42
|
+
return super().process_bind_param(value, dialect)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _PgLTree(UserDefinedType):
|
|
46
|
+
cache_ok = True
|
|
47
|
+
|
|
48
|
+
def get_col_spec(self, **kw: object) -> str:
|
|
49
|
+
return "LTREE"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LTree(TypeDecorator):
|
|
53
|
+
"""Postgres ``ltree`` (label tree paths); ``TEXT`` on SQLite.
|
|
54
|
+
|
|
55
|
+
The ``ltree`` extension must be declared by the owning module through
|
|
56
|
+
:meth:`~simple_module_core.ModuleBase.db_extensions` (``["ltree"]``).
|
|
57
|
+
Values are plain dotted-path strings (``"top.science.physics"``). Query
|
|
58
|
+
operators (``<@``, ``@>``, ``~``) are Postgres-only: use ``column.op("<@")``.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
impl = Text
|
|
62
|
+
cache_ok = True
|
|
63
|
+
|
|
64
|
+
def load_dialect_impl(self, dialect: Dialect):
|
|
65
|
+
if dialect.name == "postgresql":
|
|
66
|
+
return dialect.type_descriptor(_PgLTree())
|
|
67
|
+
return dialect.type_descriptor(Text())
|
|
68
|
+
|
|
69
|
+
def __repr__(self) -> str:
|
|
70
|
+
return "LTree()"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Let Postgres reflection (autogenerate, ``alembic check``) return ``LTree``
|
|
74
|
+
# instead of warning "Did not recognize type 'ltree'" and falling back to NullType.
|
|
75
|
+
postgresql.base.ischema_names["ltree"] = LTree
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TSVector(TypeDecorator):
|
|
79
|
+
"""Postgres ``tsvector`` (full-text search); ``TEXT`` on SQLite.
|
|
80
|
+
|
|
81
|
+
Built in to Postgres, so no extension is needed. Pair with a GIN index and
|
|
82
|
+
``func.to_tsvector`` / ``column.op("@@")(func.plainto_tsquery(...))``.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
impl = Text
|
|
86
|
+
cache_ok = True
|
|
87
|
+
|
|
88
|
+
def load_dialect_impl(self, dialect: Dialect):
|
|
89
|
+
if dialect.name == "postgresql":
|
|
90
|
+
return dialect.type_descriptor(postgresql.TSVECTOR())
|
|
91
|
+
return dialect.type_descriptor(Text())
|
|
92
|
+
|
|
93
|
+
def __repr__(self) -> str:
|
|
94
|
+
return "TSVector()"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""GH #468: timezone-aware columns must read back aware on SQLite.
|
|
2
|
+
|
|
3
|
+
SQLite stores no offset, so a plain ``DateTime(timezone=True)`` column reads
|
|
4
|
+
back naive and pydantic serializes it without one — the browser then parses
|
|
5
|
+
UTC as local time. ``AwareDateTime`` attaches UTC on read.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import UTC, datetime, timedelta, timezone
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
|
|
13
|
+
import sqlalchemy as sa
|
|
14
|
+
from simple_module_db import AuditMixin, AwareDateTime, render_item
|
|
15
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
16
|
+
|
|
17
|
+
_md = sa.MetaData()
|
|
18
|
+
_stamps = sa.Table(
|
|
19
|
+
"aware_dt_stamps",
|
|
20
|
+
_md,
|
|
21
|
+
sa.Column("id", sa.Integer, primary_key=True),
|
|
22
|
+
sa.Column("at", AwareDateTime()),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def _roundtrip(value: datetime) -> datetime:
|
|
27
|
+
engine = create_async_engine("sqlite+aiosqlite://")
|
|
28
|
+
try:
|
|
29
|
+
async with engine.begin() as conn:
|
|
30
|
+
await conn.run_sync(_md.create_all)
|
|
31
|
+
await conn.execute(_stamps.insert().values(id=1, at=value))
|
|
32
|
+
return (await conn.execute(sa.select(_stamps.c.at))).scalar_one()
|
|
33
|
+
finally:
|
|
34
|
+
await engine.dispose()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def test_aware_value_reads_back_aware_utc():
|
|
38
|
+
stamp = datetime(2026, 10, 14, 10, 40, 52, 123456, tzinfo=UTC)
|
|
39
|
+
|
|
40
|
+
loaded = await _roundtrip(stamp)
|
|
41
|
+
|
|
42
|
+
assert loaded == stamp
|
|
43
|
+
assert loaded.utcoffset() == timedelta(0)
|
|
44
|
+
assert loaded.isoformat().endswith("+00:00")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def test_non_utc_offset_is_normalised_to_utc():
|
|
48
|
+
stamp = datetime(2026, 10, 14, 12, 40, tzinfo=timezone(timedelta(hours=2)))
|
|
49
|
+
|
|
50
|
+
loaded = await _roundtrip(stamp)
|
|
51
|
+
|
|
52
|
+
assert loaded == stamp
|
|
53
|
+
assert loaded.tzinfo == UTC
|
|
54
|
+
assert loaded.hour == 10
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def test_naive_value_is_taken_as_utc():
|
|
58
|
+
"""Unlike SQLModel's ``UTCDateTime``, a naive bind is accepted as UTC."""
|
|
59
|
+
loaded = await _roundtrip(datetime(2026, 10, 14, 10, 40))
|
|
60
|
+
|
|
61
|
+
assert loaded == datetime(2026, 10, 14, 10, 40, tzinfo=UTC)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_audit_mixin_columns_use_aware_type():
|
|
65
|
+
fields = AuditMixin.model_fields
|
|
66
|
+
for name in ("created_at", "updated_at"):
|
|
67
|
+
sa_type = getattr(fields[name], "sa_type", None)
|
|
68
|
+
assert isinstance(sa_type, AwareDateTime), name
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_renders_as_plain_timezone_datetime():
|
|
72
|
+
"""Adopting the type must not make autogenerate emit a diff or a sqlmodel import."""
|
|
73
|
+
ctx = SimpleNamespace(imports=set())
|
|
74
|
+
|
|
75
|
+
assert render_item("type", AwareDateTime(), ctx) == "sa.DateTime(timezone=True)"
|
|
76
|
+
assert ctx.imports == set()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Postgres extensions + LTree/TSVector fallbacks, on SQLite (GH #458)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import sqlalchemy as sa
|
|
7
|
+
from simple_module_core import ModuleBase, ModuleMeta
|
|
8
|
+
from simple_module_db import LTree, TSVector, UnsupportedDatabaseError, ensure_db_extensions
|
|
9
|
+
from simple_module_db.migrations import render_item
|
|
10
|
+
from sqlalchemy.dialects import postgresql, sqlite
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _module(name: str, *extensions: str) -> ModuleBase:
|
|
14
|
+
class _M(ModuleBase):
|
|
15
|
+
meta = ModuleMeta(name=name)
|
|
16
|
+
|
|
17
|
+
def db_extensions(self) -> list[str]:
|
|
18
|
+
return list(extensions)
|
|
19
|
+
|
|
20
|
+
return _M()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_no_extensions_is_a_noop_even_on_sqlite():
|
|
24
|
+
engine = sa.create_engine("sqlite://")
|
|
25
|
+
with engine.connect() as conn:
|
|
26
|
+
assert ensure_db_extensions(conn, [_module("a")]) == []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_sqlite_raises_naming_module_and_extensions():
|
|
30
|
+
engine = sa.create_engine("sqlite://")
|
|
31
|
+
with engine.connect() as conn, pytest.raises(UnsupportedDatabaseError) as exc:
|
|
32
|
+
ensure_db_extensions(conn, [_module("taxonomy", "ltree")])
|
|
33
|
+
assert "taxonomy" in str(exc.value)
|
|
34
|
+
assert "ltree" in str(exc.value)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.parametrize("bad", ["LTree", "X", "1abc", 'x"; DROP', 'bad"name', "x;drop", "-a", ""])
|
|
38
|
+
def test_invalid_extension_name_is_rejected(bad: str):
|
|
39
|
+
engine = sa.create_engine("sqlite://")
|
|
40
|
+
with engine.connect() as conn, pytest.raises(ValueError, match="invalid database extension"):
|
|
41
|
+
ensure_db_extensions(conn, [_module("m", bad)])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_hyphenated_extension_name_is_accepted_and_quoted(monkeypatch):
|
|
45
|
+
from alembic import context
|
|
46
|
+
from simple_module_db.extensions import declared_extensions
|
|
47
|
+
|
|
48
|
+
assert declared_extensions([_module("m", "uuid-ossp")]) == {"uuid-ossp": ["m"]}
|
|
49
|
+
|
|
50
|
+
emitted: list[str] = []
|
|
51
|
+
|
|
52
|
+
class _Ctx:
|
|
53
|
+
dialect = type("D", (), {"name": "postgresql"})()
|
|
54
|
+
|
|
55
|
+
monkeypatch.setattr(context, "get_context", lambda: _Ctx())
|
|
56
|
+
monkeypatch.setattr(context, "execute", emitted.append)
|
|
57
|
+
ensure_db_extensions(None, [_module("m", "uuid-ossp")])
|
|
58
|
+
assert emitted == ['CREATE EXTENSION IF NOT EXISTS "uuid-ossp"']
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_types_are_text_on_sqlite_and_native_on_postgres():
|
|
62
|
+
md = sa.MetaData()
|
|
63
|
+
t = sa.Table(
|
|
64
|
+
"dbext_t",
|
|
65
|
+
md,
|
|
66
|
+
sa.Column("id", sa.Integer, primary_key=True),
|
|
67
|
+
sa.Column("path", LTree()),
|
|
68
|
+
sa.Column("doc", TSVector()),
|
|
69
|
+
)
|
|
70
|
+
sqlite_ddl = str(sa.schema.CreateTable(t).compile(dialect=sqlite.dialect()))
|
|
71
|
+
pg_ddl = str(sa.schema.CreateTable(t).compile(dialect=postgresql.dialect()))
|
|
72
|
+
assert "path TEXT" in sqlite_ddl and "doc TEXT" in sqlite_ddl
|
|
73
|
+
assert "path LTREE" in pg_ddl and "doc TSVECTOR" in pg_ddl
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_types_round_trip_strings_on_sqlite():
|
|
77
|
+
md = sa.MetaData()
|
|
78
|
+
t = sa.Table("dbext_t", md, sa.Column("path", LTree()), sa.Column("doc", TSVector()))
|
|
79
|
+
engine = sa.create_engine("sqlite://")
|
|
80
|
+
md.create_all(engine)
|
|
81
|
+
with engine.begin() as conn:
|
|
82
|
+
conn.execute(t.insert().values(path="top.science", doc="'a':1"))
|
|
83
|
+
assert conn.execute(sa.select(t.c.path, t.c.doc)).one() == ("top.science", "'a':1")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.parametrize(("type_", "name"), [(LTree(), "LTree"), (TSVector(), "TSVector")])
|
|
87
|
+
def test_render_item_emits_runnable_code_with_import(type_, name: str):
|
|
88
|
+
class _Ctx:
|
|
89
|
+
imports: set[str] = set() # noqa: RUF012
|
|
90
|
+
|
|
91
|
+
ctx = _Ctx()
|
|
92
|
+
ctx.imports = set()
|
|
93
|
+
assert render_item("type", type_, ctx) == f"simple_module_db.types.{name}()"
|
|
94
|
+
assert "import simple_module_db.types" in ctx.imports
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""LTree/TSVector + extension migrations against a real Postgres (GH #458).
|
|
2
|
+
|
|
3
|
+
Needs ``SM_TEST_DATABASE_URL=postgresql+asyncpg://...``; skipped otherwise.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import warnings
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
import sqlalchemy as sa
|
|
12
|
+
from alembic.autogenerate import compare_metadata, produce_migrations
|
|
13
|
+
from alembic.autogenerate.api import AutogenContext
|
|
14
|
+
from alembic.autogenerate.render import render_op_text
|
|
15
|
+
from alembic.migration import MigrationContext
|
|
16
|
+
from alembic.operations import Operations
|
|
17
|
+
from simple_module_core import ModuleBase, ModuleMeta
|
|
18
|
+
from simple_module_db import LTree, TSVector, ensure_db_extensions
|
|
19
|
+
from simple_module_db.migrations import render_item
|
|
20
|
+
from simple_module_test.database import database_url_for_tests
|
|
21
|
+
from sqlalchemy.exc import SAWarning
|
|
22
|
+
|
|
23
|
+
pytestmark = pytest.mark.postgres
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _TaxonomyModule(ModuleBase):
|
|
27
|
+
meta = ModuleMeta(name="taxonomy")
|
|
28
|
+
|
|
29
|
+
def db_extensions(self) -> list[str]:
|
|
30
|
+
return ["ltree"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _metadata() -> sa.MetaData:
|
|
34
|
+
md = sa.MetaData()
|
|
35
|
+
sa.Table(
|
|
36
|
+
"dbext_node",
|
|
37
|
+
md,
|
|
38
|
+
sa.Column("id", sa.Integer, primary_key=True),
|
|
39
|
+
sa.Column("path", LTree(), nullable=False),
|
|
40
|
+
sa.Column("search", TSVector()),
|
|
41
|
+
)
|
|
42
|
+
return md
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture
|
|
46
|
+
def pg_engine():
|
|
47
|
+
url = database_url_for_tests().replace("+asyncpg", "+psycopg2")
|
|
48
|
+
engine = sa.create_engine(url)
|
|
49
|
+
with engine.begin() as conn:
|
|
50
|
+
conn.execute(sa.text("DROP TABLE IF EXISTS dbext_node"))
|
|
51
|
+
yield engine
|
|
52
|
+
with engine.begin() as conn:
|
|
53
|
+
conn.execute(sa.text("DROP TABLE IF EXISTS dbext_node"))
|
|
54
|
+
engine.dispose()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_ensure_db_extensions_is_idempotent(pg_engine):
|
|
58
|
+
with pg_engine.begin() as conn:
|
|
59
|
+
assert ensure_db_extensions(conn, [_TaxonomyModule()]) == ["ltree"]
|
|
60
|
+
assert ensure_db_extensions(conn, [_TaxonomyModule()]) == ["ltree"]
|
|
61
|
+
assert conn.execute(sa.text("SELECT 1 FROM pg_extension WHERE extname='ltree'")).scalar()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_hyphenated_extension_creates(pg_engine):
|
|
65
|
+
class _Mod(ModuleBase):
|
|
66
|
+
meta = ModuleMeta(name="uuidmod")
|
|
67
|
+
|
|
68
|
+
def db_extensions(self) -> list[str]:
|
|
69
|
+
return ["uuid-ossp"]
|
|
70
|
+
|
|
71
|
+
with pg_engine.begin() as conn:
|
|
72
|
+
assert ensure_db_extensions(conn, [_Mod()]) == ["uuid-ossp"]
|
|
73
|
+
assert conn.execute(sa.text("SELECT uuid_generate_v4()")).scalar()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_ltree_and_tsvector_queries(pg_engine):
|
|
77
|
+
md = _metadata()
|
|
78
|
+
table = md.tables["dbext_node"]
|
|
79
|
+
with pg_engine.begin() as conn:
|
|
80
|
+
ensure_db_extensions(conn, [_TaxonomyModule()])
|
|
81
|
+
md.create_all(conn)
|
|
82
|
+
conn.execute(
|
|
83
|
+
table.insert().values(
|
|
84
|
+
id=1, path="top.science", search=sa.func.to_tsvector("english", "red fox")
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
conn.execute(
|
|
88
|
+
table.insert(),
|
|
89
|
+
[{"id": 2, "path": "top.science.physics"}, {"id": 3, "path": "top.arts"}],
|
|
90
|
+
)
|
|
91
|
+
under = conn.execute(
|
|
92
|
+
sa.select(table.c.id).where(table.c.path.op("<@")(sa.literal("top.science", LTree())))
|
|
93
|
+
).scalars()
|
|
94
|
+
assert sorted(under) == [1, 2]
|
|
95
|
+
hit = conn.execute(
|
|
96
|
+
sa.select(table.c.id).where(
|
|
97
|
+
table.c.search.op("@@")(sa.func.plainto_tsquery("english", "fox"))
|
|
98
|
+
)
|
|
99
|
+
).scalars()
|
|
100
|
+
assert list(hit) == [1]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_autogenerated_migration_round_trips(pg_engine):
|
|
104
|
+
"""Autogenerate -> upgrade -> downgrade -> upgrade -> no drift left."""
|
|
105
|
+
md = _metadata()
|
|
106
|
+
with pg_engine.begin() as conn:
|
|
107
|
+
ensure_db_extensions(conn, [_TaxonomyModule()])
|
|
108
|
+
# Only this test's table: other tests leave theirs in the shared schema,
|
|
109
|
+
# and autogenerate would otherwise emit DROPs for them.
|
|
110
|
+
mc = MigrationContext.configure(
|
|
111
|
+
conn,
|
|
112
|
+
opts={
|
|
113
|
+
"render_item": render_item,
|
|
114
|
+
"include_name": lambda name, type_, _parent: (
|
|
115
|
+
type_ != "table" or name == "dbext_node"
|
|
116
|
+
),
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
ops = produce_migrations(mc, md)
|
|
120
|
+
assert ops.upgrade_ops is not None
|
|
121
|
+
auto = AutogenContext(
|
|
122
|
+
mc,
|
|
123
|
+
md,
|
|
124
|
+
opts={
|
|
125
|
+
"render_item": render_item,
|
|
126
|
+
"sqlalchemy_module_prefix": "sa.",
|
|
127
|
+
"alembic_module_prefix": "op.",
|
|
128
|
+
"user_module_prefix": None,
|
|
129
|
+
"render_as_batch": False,
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
upgrade = "\n".join(render_op_text(auto, op) for op in ops.upgrade_ops.ops)
|
|
133
|
+
downgrade = "\n".join(render_op_text(auto, op) for op in ops.upgrade_ops.reverse().ops)
|
|
134
|
+
assert "simple_module_db.types.LTree()" in upgrade
|
|
135
|
+
assert "simple_module_db.types.TSVector()" in upgrade
|
|
136
|
+
|
|
137
|
+
# The generated imports alone must make the code runnable.
|
|
138
|
+
namespace: dict = {"op": Operations(mc)}
|
|
139
|
+
exec("import sqlalchemy as sa\n" + "\n".join(sorted(auto.imports)), namespace)
|
|
140
|
+
|
|
141
|
+
def run(code: str) -> None:
|
|
142
|
+
with Operations.context(mc):
|
|
143
|
+
exec(code, namespace)
|
|
144
|
+
|
|
145
|
+
def has_table() -> bool:
|
|
146
|
+
return sa.inspect(conn).has_table("dbext_node")
|
|
147
|
+
|
|
148
|
+
run(upgrade)
|
|
149
|
+
assert has_table()
|
|
150
|
+
# No "Did not recognize type 'ltree'" warning: reflection knows LTree.
|
|
151
|
+
with warnings.catch_warnings():
|
|
152
|
+
warnings.simplefilter("error", SAWarning)
|
|
153
|
+
assert compare_metadata(mc, md) == []
|
|
154
|
+
run(downgrade)
|
|
155
|
+
assert not sa.inspect(conn).has_table("dbext_node")
|
|
156
|
+
run(upgrade)
|
|
157
|
+
assert has_table()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def test_reset_schema_recreates_declared_extensions(monkeypatch):
|
|
161
|
+
from simple_module_core import discovery
|
|
162
|
+
from simple_module_test.database import init_db_kwargs, reset_schema
|
|
163
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
164
|
+
|
|
165
|
+
monkeypatch.setattr(discovery, "discover_modules", lambda *a, **k: [_TaxonomyModule()])
|
|
166
|
+
url = database_url_for_tests()
|
|
167
|
+
engine = create_async_engine(url, **init_db_kwargs(url))
|
|
168
|
+
try:
|
|
169
|
+
await reset_schema(engine)
|
|
170
|
+
async with engine.connect() as conn:
|
|
171
|
+
found = await conn.execute(sa.text("SELECT 1 FROM pg_extension WHERE extname='ltree'"))
|
|
172
|
+
assert found.scalar() == 1
|
|
173
|
+
finally:
|
|
174
|
+
await engine.dispose()
|
|
@@ -162,3 +162,32 @@ def test_downgrade_recreated_boolean_defaults_render_portably():
|
|
|
162
162
|
|
|
163
163
|
assert code.count("sa.false()") == 2
|
|
164
164
|
assert code.count("sa.true()") == 2
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_sqlmodel_utc_datetime_renders_without_sqlmodel():
|
|
168
|
+
"""GH #440: sqlmodel>=0.0.48 maps ``datetime`` fields to ``UTCDateTime``."""
|
|
169
|
+
from sqlmodel.sql.sqltypes import UTCDateTime
|
|
170
|
+
|
|
171
|
+
md = sa.MetaData()
|
|
172
|
+
sa.Table(
|
|
173
|
+
"stamps",
|
|
174
|
+
md,
|
|
175
|
+
sa.Column("id", sa.Integer, primary_key=True),
|
|
176
|
+
sa.Column("created_at", UTCDateTime(), nullable=False),
|
|
177
|
+
)
|
|
178
|
+
code, _, _ = _autogenerate(md)
|
|
179
|
+
|
|
180
|
+
assert "sa.Column('created_at', sa.DateTime(timezone=True), nullable=False)" in code
|
|
181
|
+
assert "sqlmodel" not in code
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_other_sqlmodel_types_add_the_import():
|
|
185
|
+
class FakeType(sa.types.TypeDecorator):
|
|
186
|
+
impl = sa.Integer
|
|
187
|
+
cache_ok = True
|
|
188
|
+
|
|
189
|
+
FakeType.__module__ = "sqlmodel.sql.sqltypes"
|
|
190
|
+
autogen_context = SimpleNamespace(imports=set())
|
|
191
|
+
|
|
192
|
+
assert render_item("type", FakeType(), autogen_context) is False
|
|
193
|
+
assert autogen_context.imports == {"import sqlmodel"}
|
|
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.36 → simple_module_db-0.0.37}/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
|
{simple_module_db-0.0.36 → simple_module_db-0.0.37}/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
|