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.
- {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/PKG-INFO +3 -3
- {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/README.md +1 -1
- {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/hajimi_sqlalchemy/__init__.py +1 -0
- hajimi_sqlalchemy-0.2.0/hajimi_sqlalchemy/columns.py +26 -0
- hajimi_sqlalchemy-0.2.0/hajimi_sqlalchemy/dependencies.py +16 -0
- hajimi_sqlalchemy-0.2.0/hajimi_sqlalchemy/models.py +91 -0
- hajimi_sqlalchemy-0.2.0/hajimi_sqlalchemy/plugin.py +88 -0
- hajimi_sqlalchemy-0.2.0/hajimi_sqlalchemy/utils.py +12 -0
- {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/hajimi_sqlalchemy.egg-info/PKG-INFO +3 -3
- {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/hajimi_sqlalchemy.egg-info/SOURCES.txt +9 -1
- {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/hajimi_sqlalchemy.egg-info/requires.txt +1 -1
- {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/pyproject.toml +6 -3
- hajimi_sqlalchemy-0.2.0/tests/test_columns.py +308 -0
- hajimi_sqlalchemy-0.2.0/tests/test_dependencies.py +35 -0
- hajimi_sqlalchemy-0.2.0/tests/test_dependencies_extras.py +94 -0
- hajimi_sqlalchemy-0.2.0/tests/test_models.py +181 -0
- hajimi_sqlalchemy-0.2.0/tests/test_plugin.py +188 -0
- hajimi_sqlalchemy-0.2.0/tests/test_plugin_extras.py +89 -0
- hajimi_sqlalchemy-0.2.0/tests/test_utils.py +119 -0
- hajimi_sqlalchemy-0.0.3/hajimi_sqlalchemy/dependencies.py +0 -20
- hajimi_sqlalchemy-0.0.3/hajimi_sqlalchemy/plugin.py +0 -45
- hajimi_sqlalchemy-0.0.3/tests/test_dependencies.py +0 -65
- hajimi_sqlalchemy-0.0.3/tests/test_plugin.py +0 -94
- {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/LICENSE +0 -0
- {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/hajimi_sqlalchemy/base.py +0 -0
- {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/hajimi_sqlalchemy/config.py +0 -0
- {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/hajimi_sqlalchemy.egg-info/dependency_links.txt +0 -0
- {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/hajimi_sqlalchemy.egg-info/top_level.txt +0 -0
- {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/setup.cfg +0 -0
- {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/tests/test_base.py +0 -0
- {hajimi_sqlalchemy-0.0.3 → hajimi_sqlalchemy-0.2.0}/tests/test_config.py +0 -0
- {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
|
|
4
|
-
Requires-Python: >=3.
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Requires-Python: >=3.12
|
|
5
5
|
License-File: LICENSE
|
|
6
|
-
Requires-Dist: hajimi-core>=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
|
|
@@ -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)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hajimi-sqlalchemy
|
|
3
|
-
Version: 0.0
|
|
4
|
-
Requires-Python: >=3.
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Requires-Python: >=3.12
|
|
5
5
|
License-File: LICENSE
|
|
6
|
-
Requires-Dist: hajimi-core>=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/
|
|
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
|
+
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
|
|
8
|
-
requires-python = ">=3.
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
9
|
dependencies = [
|
|
10
|
-
"hajimi-core>=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
|