hajimi-sqlalchemy 0.0.3__tar.gz → 0.2.0__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 (32) hide show
  1. {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/PKG-INFO +3 -3
  2. {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/README.md +1 -1
  3. {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/hajimi_sqlalchemy/__init__.py +1 -0
  4. hajimi_sqlalchemy-0.2.0/hajimi_sqlalchemy/columns.py +26 -0
  5. hajimi_sqlalchemy-0.2.0/hajimi_sqlalchemy/dependencies.py +16 -0
  6. hajimi_sqlalchemy-0.2.0/hajimi_sqlalchemy/models.py +91 -0
  7. hajimi_sqlalchemy-0.2.0/hajimi_sqlalchemy/plugin.py +88 -0
  8. hajimi_sqlalchemy-0.2.0/hajimi_sqlalchemy/utils.py +12 -0
  9. {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/hajimi_sqlalchemy.egg-info/PKG-INFO +3 -3
  10. {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/hajimi_sqlalchemy.egg-info/SOURCES.txt +9 -1
  11. {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/hajimi_sqlalchemy.egg-info/requires.txt +1 -1
  12. {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/pyproject.toml +6 -3
  13. hajimi_sqlalchemy-0.2.0/tests/test_columns.py +308 -0
  14. hajimi_sqlalchemy-0.2.0/tests/test_dependencies.py +35 -0
  15. hajimi_sqlalchemy-0.2.0/tests/test_dependencies_extras.py +94 -0
  16. hajimi_sqlalchemy-0.2.0/tests/test_models.py +181 -0
  17. hajimi_sqlalchemy-0.2.0/tests/test_plugin.py +188 -0
  18. hajimi_sqlalchemy-0.2.0/tests/test_plugin_extras.py +89 -0
  19. hajimi_sqlalchemy-0.2.0/tests/test_utils.py +119 -0
  20. hajimi_sqlalchemy-0.0.3/hajimi_sqlalchemy/dependencies.py +0 -20
  21. hajimi_sqlalchemy-0.0.3/hajimi_sqlalchemy/plugin.py +0 -45
  22. hajimi_sqlalchemy-0.0.3/tests/test_dependencies.py +0 -65
  23. hajimi_sqlalchemy-0.0.3/tests/test_plugin.py +0 -94
  24. {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/LICENSE +0 -0
  25. {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/hajimi_sqlalchemy/base.py +0 -0
  26. {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/hajimi_sqlalchemy/config.py +0 -0
  27. {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/hajimi_sqlalchemy.egg-info/dependency_links.txt +0 -0
  28. {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/hajimi_sqlalchemy.egg-info/top_level.txt +0 -0
  29. {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/setup.cfg +0 -0
  30. {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/tests/test_base.py +0 -0
  31. {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/tests/test_config.py +0 -0
  32. {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/tests/test_init.py +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hajimi-sqlalchemy
3
- Version: 0.0.3
4
- Requires-Python: >=3.14
3
+ Version: 0.2.0
4
+ Requires-Python: >=3.12
5
5
  License-File: LICENSE
6
- Requires-Dist: hajimi-core>=0.1.0
6
+ Requires-Dist: hajimi-core>=0.4.0
7
7
  Requires-Dist: sqlalchemy[asyncio,postgresql-psycopg]>=2.0.49
8
8
  Dynamic: license-file
@@ -1,4 +1,4 @@
1
- # hajimi-core
1
+ # hajimi-sqlalchemy
2
2
 
3
3
  ## Install
4
4
 
@@ -1,4 +1,5 @@
1
1
  from .base import BaseModel
2
2
  from .config import HajimiSQLAlchemyConfig
3
3
  from .dependencies import SessionDep
4
+ from .models import collect_plugin_models
4
5
  from .plugin import HajimiSQLAlchemyPlugin
@@ -0,0 +1,26 @@
1
+ import secrets
2
+ from typing import Any
3
+
4
+ from sqlalchemy import DateTime, func
5
+ from sqlalchemy.orm import MappedColumn, mapped_column
6
+
7
+
8
+ def id_column() -> MappedColumn[Any]:
9
+ return mapped_column(primary_key=True, autoincrement=True, nullable=False)
10
+
11
+
12
+ def external_id_column() -> MappedColumn[Any]:
13
+ return mapped_column(
14
+ default=lambda: secrets.token_urlsafe(9),
15
+ unique=True,
16
+ nullable=False,
17
+ index=True,
18
+ )
19
+
20
+
21
+ def created_at_column() -> MappedColumn[Any]:
22
+ return mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
23
+
24
+
25
+ def updated_at_column() -> MappedColumn[Any]:
26
+ return mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
@@ -0,0 +1,16 @@
1
+ from typing import Annotated, AsyncGenerator
2
+
3
+ from fastapi import Depends
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from hajimi_core import AppStateDep
7
+ from .plugin import HajimiSQLAlchemyPlugin
8
+
9
+
10
+ async def get_session(app_state: AppStateDep) -> AsyncGenerator[AsyncSession, None]:
11
+ plugin: HajimiSQLAlchemyPlugin = app_state[HajimiSQLAlchemyPlugin.__name__]
12
+ async with plugin.session_maker.begin() as session:
13
+ yield session
14
+
15
+
16
+ SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -0,0 +1,91 @@
1
+ """SQLAlchemy 模型注册相关的工具:让 plugin 显式声明自己拥有的 models。
2
+
3
+ 背景:
4
+ - SQLAlchemy 的 DeclarativeBase 只在 model 类**被定义**时,把 Table 挂到
5
+ ``BaseModel.metadata`` 上。如果 ``env.py`` / 任何消费方没 import 该类,
6
+ autogenerate 就看不到这张表。
7
+ - 历史上靠 plugin 模块顶部的 ``from .models import X`` 副作用顺带触发注册,
8
+ 这种隐式契约很容易被重构(lazy import、TYPE_CHECKING 分支)静默打破。
9
+
10
+ 契约:
11
+ - 任何持有 SQLAlchemy 模型的 plugin 在类上声明
12
+ ``models: tuple[type[BaseModel], ...] = ()``。
13
+ - alembic / lifespan / 任何需要让 SQLAlchemy 看到这些表的入口,统一调用
14
+ ``collect_plugin_models(plugins_or_classes)`` 来触发注册并拿到类列表。
15
+
16
+ 接口:
17
+ - ``collect_plugin_models`` 接受 plugin **类**或**实例**(duck-typing),
18
+ 提取 ``models`` 属性、对每个类做 ``import_module(__module__)`` 触发
19
+ SQLAlchemy 注册、按首次出现顺序去重,返回 ``tuple[type[BaseModel], ...]``。
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from importlib import import_module
24
+ from typing import Any, Iterable
25
+
26
+ from .base import BaseModel
27
+
28
+
29
+ def collect_plugin_models(
30
+ plugins_or_classes: Iterable[Any],
31
+ ) -> tuple[type[BaseModel], ...]:
32
+ """收集所有 plugin 声明的 models,并触发它们注册到 ``BaseModel.metadata``。
33
+
34
+ 参数:
35
+ plugins_or_classes:plugin **类**或**实例**的可迭代对象。空集合返回空 tuple。
36
+
37
+ 返回:
38
+ 按首次出现顺序去重的 ``BaseModel`` 子类 tuple。同一 plugin 重复出现
39
+ 只计一次;同一 model 被多个 plugin 声明也只计一次。
40
+
41
+ 抛出:
42
+ TypeError:任一 plugin 的 ``models`` 不是 tuple,或其中的元素不是
43
+ ``BaseModel`` 子类。契约违反必须显式失败,而不是静默跳过。
44
+
45
+ 实现说明:
46
+ SQLAlchemy 在 model 类**定义时**就把 Table 挂到 ``BaseModel.metadata``。
47
+ 本函数对每个返回的 model 做一次 ``import_module(model.__module__)``,
48
+ 以防调用方在拿到类引用之前还没触达模块(典型场景:alembic env.py 不
49
+ 通过 plugin 模块的 import 副作用注册)。已加载模块的 import 是幂等的。
50
+ """
51
+ seen: dict[type, BaseModel] = {} # 借用 dict 保持插入顺序去重
52
+ for plugin in plugins_or_classes:
53
+ for model in _extract_models(plugin):
54
+ if model in seen:
55
+ continue
56
+ _ensure_registered(model)
57
+ seen[model] = model
58
+ return tuple(seen)
59
+
60
+
61
+ def _extract_models(plugin: Any) -> tuple[type[BaseModel], ...]:
62
+ """读取单个 plugin(类或实例)上的 ``models`` 字段并校验元素类型。"""
63
+ name = _plugin_name(plugin)
64
+ raw = getattr(plugin, 'models', ())
65
+ if raw is None:
66
+ return ()
67
+ if not isinstance(raw, tuple):
68
+ raise TypeError(
69
+ f'{name}.models must be a tuple, got {type(raw).__name__}'
70
+ )
71
+ result: list[type[BaseModel]] = []
72
+ for m in raw:
73
+ if not (isinstance(m, type) and issubclass(m, BaseModel)):
74
+ raise TypeError(
75
+ f'{name}.models contains {m!r}, '
76
+ f'which is not a BaseModel subclass'
77
+ )
78
+ result.append(m)
79
+ return tuple(result)
80
+
81
+
82
+ def _plugin_name(plugin: Any) -> str:
83
+ """取 plugin 的可读名字:类取 ``__name__``,实例取 ``type(self).__name__``。"""
84
+ if isinstance(plugin, type):
85
+ return plugin.__name__
86
+ return type(plugin).__name__
87
+
88
+
89
+ def _ensure_registered(model: type[BaseModel]) -> None:
90
+ """import 模型所在模块,确保 SQLAlchemy 把 Table 注册到 ``BaseModel.metadata``。"""
91
+ import_module(model.__module__)
@@ -0,0 +1,88 @@
1
+ from contextlib import AbstractAsyncContextManager
2
+
3
+ from fastapi import FastAPI
4
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
5
+
6
+ from hajimi_core.state import APP_STATE_KEY
7
+ from .config import HajimiSQLAlchemyConfig
8
+
9
+
10
+ class HajimiSQLAlchemyPlugin:
11
+ dependencies = ()
12
+
13
+ def __init__(self, config: HajimiSQLAlchemyConfig) -> None:
14
+ self._config = config
15
+ self._engine: AsyncEngine | None = None
16
+ self._session_maker: async_sessionmaker[AsyncSession] | None = None
17
+ # 启动阶段共享 session:在 startup() 中开启、在 finalize_startup() 中 commit
18
+ self._startup_session_cm: AbstractAsyncContextManager[AsyncSession] | None = None
19
+ self._startup_session: AsyncSession | None = None
20
+
21
+ @property
22
+ def session_maker(self) -> async_sessionmaker[AsyncSession]:
23
+ if not isinstance(self._session_maker, async_sessionmaker):
24
+ raise RuntimeError(
25
+ 'HajimiSQLAlchemyPlugin.session_maker is unavailable: '
26
+ 'startup() has not been called or shutdown() has already run.'
27
+ )
28
+
29
+ return self._session_maker
30
+
31
+ @property
32
+ def startup_session(self) -> AsyncSession:
33
+ """启动阶段共享的 ``AsyncSession``,供依赖本插件的 plugin 在自己的 ``startup`` 中使用。
34
+
35
+ 仅在所有 plugin 的 ``startup`` 都成功跑完之前可用;
36
+ 一旦 ``finalize_startup()`` 被调用、session 已被 commit/close,该属性即抛错。
37
+ """
38
+ if self._startup_session is None:
39
+ raise RuntimeError(
40
+ 'HajimiSQLAlchemyPlugin.startup_session is unavailable: '
41
+ 'startup() has not been called or finalize_startup() has already run.'
42
+ )
43
+
44
+ return self._startup_session
45
+
46
+ async def startup(self, app: FastAPI) -> None:
47
+ self._engine = create_async_engine(
48
+ url=self._config.url,
49
+ echo=self._config.echo,
50
+ pool_size=self._config.pool_size,
51
+ max_overflow=self._config.max_overflow,
52
+ pool_timeout=self._config.pool_timeout,
53
+ pool_recycle=self._config.pool_recycle,
54
+ pool_pre_ping=True,
55
+ connect_args={'connect_timeout': 6},
56
+ )
57
+ # 局部变量:让 PyCharm 从右侧赋值正确推断类型,绕过 self 字段的 Optional 标注
58
+ session_maker = async_sessionmaker(self._engine)
59
+ self._session_maker = session_maker
60
+
61
+ # 启动阶段事务:手动 enter begin(),所有依赖方共享同一个 AsyncSession;
62
+ # finalize_startup() 时 __aexit__(None, None, None) 触发 commit。
63
+ startup_cm = session_maker.begin()
64
+ self._startup_session_cm = startup_cm
65
+ self._startup_session = await startup_cm.__aenter__()
66
+
67
+ app.state[APP_STATE_KEY][type(self).__name__] = self
68
+
69
+ async def finalize_startup(self, app: FastAPI) -> None:
70
+ # 没开过事务就直接返回(例如未经过 startup() 直接调用)
71
+ if self._startup_session_cm is None:
72
+ return
73
+ # 正常退出 -> commit
74
+ await self._startup_session_cm.__aexit__(None, None, None)
75
+ self._startup_session_cm = None
76
+ self._startup_session = None
77
+
78
+ async def shutdown(self, app: FastAPI) -> None:
79
+ # 清空启动阶段 session 引用(若仍持有 cm,engine dispose 会兜底回滚事务)
80
+ self._startup_session = None
81
+ self._startup_session_cm = None
82
+ if self._session_maker is not None:
83
+ self._session_maker = None
84
+ if self._engine is not None:
85
+ await self._engine.dispose()
86
+ self._engine = None
87
+
88
+ app.state[APP_STATE_KEY].pop(type(self).__name__, None)
@@ -0,0 +1,12 @@
1
+ from typing import TypeVar
2
+
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+
5
+ T = TypeVar('T')
6
+
7
+
8
+ async def add_and_flush(session: AsyncSession, model: T) -> T:
9
+ session.add(model)
10
+ await session.flush([model])
11
+
12
+ return model
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hajimi-sqlalchemy
3
- Version: 0.0.3
4
- Requires-Python: >=3.14
3
+ Version: 0.2.0
4
+ Requires-Python: >=3.12
5
5
  License-File: LICENSE
6
- Requires-Dist: hajimi-core>=0.1.0
6
+ Requires-Dist: hajimi-core>=0.4.0
7
7
  Requires-Dist: sqlalchemy[asyncio,postgresql-psycopg]>=2.0.49
8
8
  Dynamic: license-file
@@ -3,16 +3,24 @@ README.md
3
3
  pyproject.toml
4
4
  hajimi_sqlalchemy/__init__.py
5
5
  hajimi_sqlalchemy/base.py
6
+ hajimi_sqlalchemy/columns.py
6
7
  hajimi_sqlalchemy/config.py
7
8
  hajimi_sqlalchemy/dependencies.py
9
+ hajimi_sqlalchemy/models.py
8
10
  hajimi_sqlalchemy/plugin.py
11
+ hajimi_sqlalchemy/utils.py
9
12
  hajimi_sqlalchemy.egg-info/PKG-INFO
10
13
  hajimi_sqlalchemy.egg-info/SOURCES.txt
11
14
  hajimi_sqlalchemy.egg-info/dependency_links.txt
12
15
  hajimi_sqlalchemy.egg-info/requires.txt
13
16
  hajimi_sqlalchemy.egg-info/top_level.txt
14
17
  tests/test_base.py
18
+ tests/test_columns.py
15
19
  tests/test_config.py
16
20
  tests/test_dependencies.py
21
+ tests/test_dependencies_extras.py
17
22
  tests/test_init.py
18
- tests/test_plugin.py
23
+ tests/test_models.py
24
+ tests/test_plugin.py
25
+ tests/test_plugin_extras.py
26
+ tests/test_utils.py
@@ -1,2 +1,2 @@
1
- hajimi-core>=0.1.0
1
+ hajimi-core>=0.4.0
2
2
  sqlalchemy[asyncio,postgresql-psycopg]>=2.0.49
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hajimi-sqlalchemy"
7
- version = "0.0.3"
8
- requires-python = ">=3.14"
7
+ version = "0.2.0"
8
+ requires-python = ">=3.12"
9
9
  dependencies = [
10
- "hajimi-core>=0.1.0",
10
+ "hajimi-core>=0.4.0",
11
11
  "sqlalchemy[asyncio,postgresql-psycopg]>=2.0.49",
12
12
  ]
13
13
 
@@ -22,3 +22,6 @@ dev = [
22
22
  asyncio_mode = "auto"
23
23
  pythonpath = ["."]
24
24
  testpaths = ["tests"]
25
+
26
+ [tool.uv.sources]
27
+ hajimi-core = { workspace = true }
@@ -0,0 +1,308 @@
1
+ """Tests for hajimi_sqlalchemy.columns helpers.
2
+
3
+ 覆盖四个工厂:
4
+ * ``id_column`` —— 主键 + 自增 + 非空 + Integer;
5
+ * ``external_id_column`` —— 默认生成 URL-safe token + unique + index + 非空;
6
+ * ``created_at_column`` —— tz-aware DateTime + server_default=now() + 非空;
7
+ * ``updated_at_column`` —— tz-aware DateTime + server_default=now() + onupdate=now() + 非空。
8
+
9
+ 按公开 API 表达:不直接读私有属性,通过 ``__table__.c.<col>`` 观察落到
10
+ SQLAlchemy ``Column`` 上的契约(nullable / default / type / index)。
11
+ """
12
+ from sqlalchemy import DateTime, Integer
13
+ from sqlalchemy.orm import Mapped, MappedColumn
14
+
15
+ from hajimi_sqlalchemy.base import BaseModel
16
+ from hajimi_sqlalchemy.columns import (
17
+ created_at_column,
18
+ external_id_column,
19
+ id_column,
20
+ updated_at_column,
21
+ )
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # id_column
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ def test_id_column_returns_mapped_column_instance():
30
+ """``id_column()`` must hand back a SQLAlchemy ``MappedColumn`` descriptor."""
31
+
32
+ result = id_column()
33
+
34
+ assert isinstance(result, MappedColumn)
35
+
36
+
37
+ def test_id_column_is_marked_as_primary_key():
38
+ """When attached to a model, the column produced by ``id_column()`` is the primary key."""
39
+
40
+ class _PkItem(BaseModel):
41
+ __tablename__ = 'items_for_id_column_pk'
42
+ id: Mapped[int] = id_column()
43
+
44
+ assert _PkItem.__table__.c.id.primary_key is True
45
+
46
+
47
+ def test_id_column_is_not_nullable():
48
+ """``nullable=False`` must reach the generated table column."""
49
+
50
+ class _NullableItem(BaseModel):
51
+ __tablename__ = 'items_for_id_column_nullable'
52
+ id: Mapped[int] = id_column()
53
+
54
+ assert _NullableItem.__table__.c.id.nullable is False
55
+
56
+
57
+ def test_id_column_is_autoincrement():
58
+ """``autoincrement=True`` must be preserved on the generated table column."""
59
+
60
+ class _AutoincItem(BaseModel):
61
+ __tablename__ = 'items_for_id_column_autoincrement'
62
+ id: Mapped[int] = id_column()
63
+
64
+ assert _AutoincItem.__table__.c.id.autoincrement is True
65
+
66
+
67
+ def test_id_column_defaults_to_integer_type():
68
+ """No explicit type is given, so the column must default to SQLAlchemy ``Integer``."""
69
+
70
+ class _TypedItem(BaseModel):
71
+ __tablename__ = 'items_for_id_column_type'
72
+ id: Mapped[int] = id_column()
73
+
74
+ assert isinstance(_TypedItem.__table__.c.id.type, Integer)
75
+
76
+
77
+ def test_id_column_is_the_only_column_on_the_model():
78
+ """Attaching ``id_column()`` to a model produces exactly one column named ``id``."""
79
+
80
+ class _SingleColItem(BaseModel):
81
+ __tablename__ = 'items_for_id_column_only'
82
+ id: Mapped[int] = id_column()
83
+
84
+ assert list(_SingleColItem.__table__.c.keys()) == ['id']
85
+
86
+
87
+ def test_id_column_callable_produces_independent_instances():
88
+ """Each call to ``id_column()`` must return a fresh ``MappedColumn`` instance.
89
+
90
+ Reusing a single ``MappedColumn`` across multiple models would cause
91
+ SQLAlchemy's declarative scan to complain about an attribute being
92
+ assigned to more than one class.
93
+ """
94
+
95
+ first = id_column()
96
+ second = id_column()
97
+
98
+ assert first is not second
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # external_id_column
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ def test_external_id_column_returns_mapped_column_instance():
107
+ """``external_id_column()`` 必须返回 SQLAlchemy ``MappedColumn`` 描述符。"""
108
+
109
+ result = external_id_column()
110
+
111
+ assert isinstance(result, MappedColumn)
112
+
113
+
114
+ def test_external_id_column_is_unique():
115
+ """生成的列必须 ``unique=True``,用作对外暴露的标识时不容许重复。"""
116
+
117
+ class _ExternalIdUnique(BaseModel):
118
+ __tablename__ = 'items_for_external_id_unique'
119
+ id: Mapped[int] = id_column()
120
+ external_id: Mapped[str] = external_id_column()
121
+
122
+ assert _ExternalIdUnique.__table__.c.external_id.unique is True
123
+
124
+
125
+ def test_external_id_column_is_not_nullable():
126
+ """``external_id`` 必须 ``nullable=False``,确保每行都有对外 ID。"""
127
+
128
+ class _ExternalIdNullable(BaseModel):
129
+ __tablename__ = 'items_for_external_id_nullable'
130
+ id: Mapped[int] = id_column()
131
+ external_id: Mapped[str] = external_id_column()
132
+
133
+ assert _ExternalIdNullable.__table__.c.external_id.nullable is False
134
+
135
+
136
+ def test_external_id_column_is_indexed():
137
+ """``external_id`` 必须 ``index=True``,外部查询通常按它查找,需要索引支撑。"""
138
+
139
+ class _ExternalIdIndexed(BaseModel):
140
+ __tablename__ = 'items_for_external_id_indexed'
141
+ id: Mapped[int] = id_column()
142
+ external_id: Mapped[str] = external_id_column()
143
+
144
+ assert _ExternalIdIndexed.__table__.c.external_id.index is True
145
+
146
+
147
+ def test_external_id_column_default_attaches_a_callable_default():
148
+ """``default`` 必须落到 Column 上为一个可调用 default,触发时产出 token。
149
+
150
+ 通过 ``__table__.c.external_id.default`` 观察:是 ``CallableColumnDefault``
151
+ 类型,并且 ``is_callable`` 标记为 True,确保 ORM 端会在 INSERT 时调用它。
152
+ """
153
+
154
+ class _ExternalIdDefault(BaseModel):
155
+ __tablename__ = 'items_for_external_id_default'
156
+ id: Mapped[int] = id_column()
157
+ external_id: Mapped[str] = external_id_column()
158
+
159
+ col_default = _ExternalIdDefault.__table__.c.external_id.default
160
+ # ``CallableColumnDefault.is_callable`` 是公开属性,直接断言即可
161
+ assert col_default is not None
162
+ assert col_default.is_callable is True
163
+
164
+
165
+ def test_external_id_column_default_callable_produces_non_empty_urlsafe_string():
166
+ """``default`` 触发后必须产出非空 URL-safe 字符串。
167
+
168
+ 通过 ``col.default.arg(ctx)`` 调用 —— SQLAlchemy 把原始 lambda 包装成
169
+ 接受 ``ctx`` 参数的可调用对象,这就是 ORM 端 INSERT 时实际调用的契约。
170
+ """
171
+
172
+ mc = external_id_column()
173
+ col_default = mc.column.default
174
+
175
+ assert col_default.is_callable is True
176
+ value = col_default.arg(None)
177
+ assert isinstance(value, str)
178
+ assert value != ''
179
+
180
+
181
+ def test_external_id_column_default_values_are_highly_unique():
182
+ """连续触发默认生成器应当产出几乎不重复的 token,避免外部 ID 撞车。
183
+
184
+ 抽 50 次,``secrets.token_urlsafe(9)`` 输出长度约 12 字符,
185
+ 碰撞概率 ≈ 50² / 2^72 ≈ 6.8e-19,实际不可能撞码。
186
+ """
187
+
188
+ mc = external_id_column()
189
+ col_default = mc.column.default
190
+
191
+ samples = {col_default.arg(None) for _ in range(50)}
192
+
193
+ assert len(samples) == 50
194
+
195
+
196
+ def test_external_id_column_callable_produces_independent_instances():
197
+ """Each call to ``external_id_column()`` must return a fresh ``MappedColumn`` instance.
198
+
199
+ Reusing a single ``MappedColumn`` across multiple models would cause
200
+ SQLAlchemy's declarative scan to complain about an attribute being
201
+ assigned to more than one class.
202
+ """
203
+
204
+ first = external_id_column()
205
+ second = external_id_column()
206
+
207
+ assert first is not second
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # created_at_column
212
+ # ---------------------------------------------------------------------------
213
+
214
+
215
+ def test_created_at_column_returns_mapped_column_instance():
216
+ """``created_at_column()`` 必须返回 SQLAlchemy ``MappedColumn`` 描述符。"""
217
+
218
+ result = created_at_column()
219
+
220
+ assert isinstance(result, MappedColumn)
221
+
222
+
223
+ def test_created_at_column_is_timezone_aware_datetime():
224
+ """生成的列类型必须是 ``DateTime(timezone=True)``(避开 naive datetime 隐式转换坑)。"""
225
+
226
+ class _CreatedAtItem(BaseModel):
227
+ __tablename__ = 'items_for_created_at_type'
228
+ id: Mapped[int] = id_column()
229
+ created_at = created_at_column()
230
+
231
+ assert isinstance(_CreatedAtItem.__table__.c.created_at.type, DateTime)
232
+ assert _CreatedAtItem.__table__.c.created_at.type.timezone is True
233
+
234
+
235
+ def test_created_at_column_has_server_default_now():
236
+ """``created_at`` 必须有 ``server_default=func.now()``,由 DB 端兜底写入。"""
237
+
238
+ class _CreatedAtDefault(BaseModel):
239
+ __tablename__ = 'items_for_created_at_default'
240
+ id: Mapped[int] = id_column()
241
+ created_at = created_at_column()
242
+
243
+ # server_default 在 ORM 层映射为 ``FetchedValue`` / ``ClauseElement``,断言非 None 即可。
244
+ assert _CreatedAtDefault.__table__.c.created_at.server_default is not None
245
+
246
+
247
+ def test_created_at_column_is_not_nullable():
248
+ """``created_at`` 必须 ``nullable=False``,确保每行有创建时间戳。"""
249
+
250
+ class _CreatedAtNullable(BaseModel):
251
+ __tablename__ = 'items_for_created_at_nullable'
252
+ id: Mapped[int] = id_column()
253
+ created_at = created_at_column()
254
+
255
+ assert _CreatedAtNullable.__table__.c.created_at.nullable is False
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # updated_at_column
260
+ # ---------------------------------------------------------------------------
261
+
262
+
263
+ def test_updated_at_column_returns_mapped_column_instance():
264
+ """``updated_at_column()`` 必须返回 SQLAlchemy ``MappedColumn`` 描述符。"""
265
+
266
+ result = updated_at_column()
267
+
268
+ assert isinstance(result, MappedColumn)
269
+
270
+
271
+ def test_updated_at_column_is_timezone_aware_datetime():
272
+ """``updated_at`` 必须 tz-aware,与 ``created_at`` 对齐,避免时区漂移。"""
273
+
274
+ class _UpdatedAtItem(BaseModel):
275
+ __tablename__ = 'items_for_updated_at_type'
276
+ id: Mapped[int] = id_column()
277
+ updated_at = updated_at_column()
278
+
279
+ assert isinstance(_UpdatedAtItem.__table__.c.updated_at.type, DateTime)
280
+ assert _UpdatedAtItem.__table__.c.updated_at.type.timezone is True
281
+
282
+
283
+ def test_updated_at_column_has_server_default_and_onupdate():
284
+ """``updated_at`` 必须同时有 ``server_default=func.now()`` + ``onupdate=func.now()``:
285
+
286
+ * server_default:新建行时 DB 端兜底写入;
287
+ * onupdate:任何 UPDATE 语句触发时由 ORM 端写新值(无论是否在 SET 列表里)。
288
+ """
289
+
290
+ class _UpdatedAtHooks(BaseModel):
291
+ __tablename__ = 'items_for_updated_at_hooks'
292
+ id: Mapped[int] = id_column()
293
+ updated_at = updated_at_column()
294
+
295
+ col = _UpdatedAtHooks.__table__.c.updated_at
296
+ assert col.server_default is not None
297
+ assert col.onupdate is not None
298
+
299
+
300
+ def test_updated_at_column_is_not_nullable():
301
+ """``updated_at`` 必须 ``nullable=False``,确保每行有更新时间戳。"""
302
+
303
+ class _UpdatedAtNullable(BaseModel):
304
+ __tablename__ = 'items_for_updated_at_nullable'
305
+ id: Mapped[int] = id_column()
306
+ updated_at = updated_at_column()
307
+
308
+ assert _UpdatedAtNullable.__table__.c.updated_at.nullable is False
@@ -0,0 +1,35 @@
1
+ """Tests for hajimi_sqlalchemy.dependencies."""
2
+
3
+ import typing
4
+ from types import SimpleNamespace
5
+
6
+ import pytest
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from hajimi_sqlalchemy.dependencies import SessionDep, get_session
10
+ from hajimi_sqlalchemy.plugin import HajimiSQLAlchemyPlugin
11
+
12
+
13
+ async def test_get_session_yields_session_and_finishes(fake_session, fake_session_factory):
14
+ """``get_session`` yields the session from the registered plugin's session maker."""
15
+ app_state = {
16
+ HajimiSQLAlchemyPlugin.__name__: SimpleNamespace(
17
+ session_maker=fake_session_factory,
18
+ ),
19
+ }
20
+ gen = get_session(app_state)
21
+
22
+ session = await gen.__anext__()
23
+ assert session is fake_session
24
+
25
+ with pytest.raises(StopAsyncIteration):
26
+ await gen.__anext__()
27
+
28
+
29
+ def test_session_dep_is_annotated_async_session():
30
+ """``SessionDep`` is an ``Annotated`` alias whose first type-arg is ``AsyncSession``."""
31
+ origin = typing.get_origin(SessionDep)
32
+ args = typing.get_args(SessionDep)
33
+
34
+ assert origin is not None
35
+ assert args[0] is AsyncSession