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.
Files changed (55) hide show
  1. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/.gitignore +1 -0
  2. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/PKG-INFO +2 -2
  3. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/pyproject.toml +2 -2
  4. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/__init__.py +9 -0
  5. simple_module_db-0.0.37/simple_module_db/extensions.py +103 -0
  6. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/migrations.py +19 -2
  7. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/mixins.py +6 -4
  8. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/tenancy.py +19 -0
  9. simple_module_db-0.0.37/simple_module_db/types.py +94 -0
  10. simple_module_db-0.0.37/tests/test_aware_datetime.py +76 -0
  11. simple_module_db-0.0.37/tests/test_db_extensions.py +94 -0
  12. simple_module_db-0.0.37/tests/test_db_extensions_pg.py +174 -0
  13. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_migration_portability.py +29 -0
  14. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/LICENSE +0 -0
  15. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/README.md +0 -0
  16. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/_outer_joins.py +0 -0
  17. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/audit.py +0 -0
  18. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/base.py +0 -0
  19. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/callbacks.py +0 -0
  20. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/deps.py +0 -0
  21. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/flush_guard.py +0 -0
  22. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/insert_guard.py +0 -0
  23. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/listeners.py +0 -0
  24. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/migration_portability.py +0 -0
  25. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/model_registry.py +0 -0
  26. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/provider.py +0 -0
  27. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/py.typed +0 -0
  28. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/query_filter.py +0 -0
  29. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/search.py +0 -0
  30. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/session.py +0 -0
  31. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/subquery_guard.py +0 -0
  32. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/transaction.py +0 -0
  33. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/simple_module_db/writes.py +0 -0
  34. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/_audit_models.py +0 -0
  35. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/_models.py +0 -0
  36. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/conftest.py +0 -0
  37. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_audit.py +0 -0
  38. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_base.py +0 -0
  39. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_db_listeners_attach.py +0 -0
  40. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_db_logging.py +0 -0
  41. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_db_sqlite_savepoint.py +0 -0
  42. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_default_tenant.py +0 -0
  43. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_migrations.py +0 -0
  44. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_mixins.py +0 -0
  45. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_multi_tenancy.py +0 -0
  46. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_query_filter_outer_joins.py +0 -0
  47. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_query_filter_rel_outer_joins.py +0 -0
  48. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_query_filters.py +0 -0
  49. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_session.py +0 -0
  50. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_soft_delete_shapes.py +0 -0
  51. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_tenant_dml.py +0 -0
  52. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_tenant_shapes.py +0 -0
  53. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_tenant_strict.py +0 -0
  54. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_transaction.py +0 -0
  55. {simple_module_db-0.0.36 → simple_module_db-0.0.37}/tests/test_writes.py +0 -0
@@ -64,3 +64,4 @@ host/client_app/.playwright-cli/*
64
64
  .superpowers/
65
65
  .qa/
66
66
  .verify/
67
+ /openapi.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_db
3
- Version: 0.0.36
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.36
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.36"
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.36",
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`` so migrations don't
236
- need to ``import sqlmodel``.
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 DateTime, func
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=DateTime(timezone=True),
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=DateTime(timezone=True),
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=DateTime(timezone=True),
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"}