hajimi-sqlalchemy 0.0.1__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.1/LICENSE +21 -0
- hajimi_sqlalchemy-0.0.1/PKG-INFO +8 -0
- hajimi_sqlalchemy-0.0.1/README.md +7 -0
- hajimi_sqlalchemy-0.0.1/hajimi_sqlalchemy/__init__.py +4 -0
- hajimi_sqlalchemy-0.0.1/hajimi_sqlalchemy/base.py +5 -0
- hajimi_sqlalchemy-0.0.1/hajimi_sqlalchemy/config.py +29 -0
- hajimi_sqlalchemy-0.0.1/hajimi_sqlalchemy/dependencies.py +20 -0
- hajimi_sqlalchemy-0.0.1/hajimi_sqlalchemy/plugin.py +44 -0
- hajimi_sqlalchemy-0.0.1/hajimi_sqlalchemy.egg-info/PKG-INFO +8 -0
- hajimi_sqlalchemy-0.0.1/hajimi_sqlalchemy.egg-info/SOURCES.txt +18 -0
- hajimi_sqlalchemy-0.0.1/hajimi_sqlalchemy.egg-info/dependency_links.txt +1 -0
- hajimi_sqlalchemy-0.0.1/hajimi_sqlalchemy.egg-info/requires.txt +2 -0
- hajimi_sqlalchemy-0.0.1/hajimi_sqlalchemy.egg-info/top_level.txt +1 -0
- hajimi_sqlalchemy-0.0.1/pyproject.toml +24 -0
- hajimi_sqlalchemy-0.0.1/setup.cfg +4 -0
- hajimi_sqlalchemy-0.0.1/tests/test_base.py +40 -0
- hajimi_sqlalchemy-0.0.1/tests/test_config.py +121 -0
- hajimi_sqlalchemy-0.0.1/tests/test_dependencies.py +65 -0
- hajimi_sqlalchemy-0.0.1/tests/test_init.py +28 -0
- hajimi_sqlalchemy-0.0.1/tests/test_plugin.py +94 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 huruhi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import URL
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, kw_only=True)
|
|
7
|
+
class HajimiSQLAlchemyConfig:
|
|
8
|
+
username: str
|
|
9
|
+
password: str
|
|
10
|
+
host: str
|
|
11
|
+
port: int = 5432
|
|
12
|
+
database: str
|
|
13
|
+
|
|
14
|
+
echo: bool = False
|
|
15
|
+
pool_size: int = 3
|
|
16
|
+
max_overflow: int = 2
|
|
17
|
+
pool_timeout: int = 30
|
|
18
|
+
pool_recycle: int = 1800
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def url(self) -> URL:
|
|
22
|
+
return URL.create(
|
|
23
|
+
drivername='postgresql+psycopg',
|
|
24
|
+
username=self.username,
|
|
25
|
+
password=self.password,
|
|
26
|
+
host=self.host,
|
|
27
|
+
port=self.port,
|
|
28
|
+
database=self.database,
|
|
29
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""FastAPI dependencies exposing the SQLAlchemy async session."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, AsyncGenerator
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, Request
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
|
|
8
|
+
from .plugin import HajimiSQLAlchemyPlugin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def get_session(request: Request) -> AsyncGenerator[AsyncSession, None]:
|
|
12
|
+
"""Yield an ``AsyncSession`` scoped to the request, opened from the registered plugin."""
|
|
13
|
+
app_state = request.app.state._state['__app_state__']
|
|
14
|
+
plugin: HajimiSQLAlchemyPlugin = getattr(app_state, HajimiSQLAlchemyPlugin.__name__)
|
|
15
|
+
|
|
16
|
+
async with plugin.session_maker.begin() as session:
|
|
17
|
+
yield session
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from fastapi import FastAPI
|
|
2
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
|
3
|
+
|
|
4
|
+
from .config import HajimiSQLAlchemyConfig
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class HajimiSQLAlchemyPlugin:
|
|
8
|
+
def __init__(self, config: HajimiSQLAlchemyConfig) -> None:
|
|
9
|
+
self._config = config
|
|
10
|
+
self._engine: AsyncEngine | None = None
|
|
11
|
+
self._session_maker: async_sessionmaker[AsyncSession] | None = None
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def session_maker(self) -> async_sessionmaker[AsyncSession]:
|
|
15
|
+
if not isinstance(self._session_maker, async_sessionmaker):
|
|
16
|
+
raise RuntimeError(
|
|
17
|
+
'HajimiSQLAlchemyPlugin.session_maker is unavailable: '
|
|
18
|
+
'startup() has not been called or shutdown() has already run.'
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
return self._session_maker
|
|
22
|
+
|
|
23
|
+
async def startup(self, app: FastAPI) -> None:
|
|
24
|
+
self._engine = create_async_engine(
|
|
25
|
+
url=self._config.url,
|
|
26
|
+
echo=self._config.echo,
|
|
27
|
+
pool_size=self._config.pool_size,
|
|
28
|
+
max_overflow=self._config.max_overflow,
|
|
29
|
+
pool_timeout=self._config.pool_timeout,
|
|
30
|
+
pool_recycle=self._config.pool_recycle,
|
|
31
|
+
pool_pre_ping=True,
|
|
32
|
+
connect_args={'connect_timeout': 6},
|
|
33
|
+
)
|
|
34
|
+
self._session_maker = async_sessionmaker(engine=self._engine)
|
|
35
|
+
app.state.__app__state[self.__name__] = self
|
|
36
|
+
|
|
37
|
+
async def shutdown(self, app: FastAPI) -> None:
|
|
38
|
+
if self._session_maker is not None:
|
|
39
|
+
self._session_maker = None
|
|
40
|
+
if self._engine is not None:
|
|
41
|
+
await self._engine.dispose()
|
|
42
|
+
self._engine = None
|
|
43
|
+
|
|
44
|
+
app.state.__app__state.pop(self.__name__, None)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
hajimi_sqlalchemy/__init__.py
|
|
5
|
+
hajimi_sqlalchemy/base.py
|
|
6
|
+
hajimi_sqlalchemy/config.py
|
|
7
|
+
hajimi_sqlalchemy/dependencies.py
|
|
8
|
+
hajimi_sqlalchemy/plugin.py
|
|
9
|
+
hajimi_sqlalchemy.egg-info/PKG-INFO
|
|
10
|
+
hajimi_sqlalchemy.egg-info/SOURCES.txt
|
|
11
|
+
hajimi_sqlalchemy.egg-info/dependency_links.txt
|
|
12
|
+
hajimi_sqlalchemy.egg-info/requires.txt
|
|
13
|
+
hajimi_sqlalchemy.egg-info/top_level.txt
|
|
14
|
+
tests/test_base.py
|
|
15
|
+
tests/test_config.py
|
|
16
|
+
tests/test_dependencies.py
|
|
17
|
+
tests/test_init.py
|
|
18
|
+
tests/test_plugin.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hajimi_sqlalchemy
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hajimi-sqlalchemy"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
requires-python = ">=3.14"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"hajimi-core>=0.1.0",
|
|
11
|
+
"sqlalchemy[asyncio,postgresql-psycopg]>=2.0.49",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[dependency-groups]
|
|
15
|
+
dev = [
|
|
16
|
+
"pytest>=9.0.2",
|
|
17
|
+
"pytest-cov>=7.1.0",
|
|
18
|
+
"pytest-asyncio>=0.25.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[tool.pytest.ini_options]
|
|
22
|
+
asyncio_mode = "auto"
|
|
23
|
+
pythonpath = ["."]
|
|
24
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Tests for hajimi_sqlalchemy.base.BaseModel."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import Column, Integer, String
|
|
4
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
5
|
+
|
|
6
|
+
from hajimi_sqlalchemy.base import BaseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_base_model_is_subclass_of_declarative_base():
|
|
10
|
+
"""BaseModel must inherit from SQLAlchemy's DeclarativeBase."""
|
|
11
|
+
|
|
12
|
+
assert isinstance(BaseModel, type)
|
|
13
|
+
assert issubclass(BaseModel, DeclarativeBase)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_base_model_is_marked_abstract():
|
|
17
|
+
"""``__abstract__ = True`` so BaseModel never produces a table of its own."""
|
|
18
|
+
|
|
19
|
+
assert BaseModel.__abstract__ is True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_base_model_carries_metadata():
|
|
23
|
+
"""BaseModel exposes a SQLAlchemy ``MetaData`` instance for subclasses to share."""
|
|
24
|
+
|
|
25
|
+
# DeclarativeBase always carries a MetaData attribute.
|
|
26
|
+
assert hasattr(BaseModel, 'metadata')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_concrete_subclass_uses_base_model_metadata():
|
|
30
|
+
"""A concrete subclass should be wired through BaseModel.metadata."""
|
|
31
|
+
|
|
32
|
+
class Item(BaseModel):
|
|
33
|
+
__tablename__ = 'items_for_base_model_test'
|
|
34
|
+
id = Column(Integer, primary_key=True)
|
|
35
|
+
name = Column(String)
|
|
36
|
+
|
|
37
|
+
# The subclass must share BaseModel's MetaData.
|
|
38
|
+
assert Item.metadata is BaseModel.metadata
|
|
39
|
+
# And SQLAlchemy's declarative machinery has registered the table.
|
|
40
|
+
assert 'items_for_base_model_test' in BaseModel.metadata.tables
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Tests for hajimi_sqlalchemy.config.HajimiSQLAlchemyConfig."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from sqlalchemy import URL
|
|
7
|
+
|
|
8
|
+
from hajimi_sqlalchemy.config import HajimiSQLAlchemyConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _base(**overrides):
|
|
12
|
+
"""Build a config with sensible defaults, applying caller overrides."""
|
|
13
|
+
|
|
14
|
+
defaults = dict(
|
|
15
|
+
username='u',
|
|
16
|
+
password='p',
|
|
17
|
+
host='localhost',
|
|
18
|
+
port=5432,
|
|
19
|
+
database='d',
|
|
20
|
+
echo=False,
|
|
21
|
+
pool_size=3,
|
|
22
|
+
max_overflow=2,
|
|
23
|
+
pool_timeout=30,
|
|
24
|
+
pool_recycle=1800,
|
|
25
|
+
)
|
|
26
|
+
defaults.update(overrides)
|
|
27
|
+
return HajimiSQLAlchemyConfig(**defaults)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_defaults_when_only_required_fields_given():
|
|
31
|
+
"""Defaults are applied for every optional connection / pool field."""
|
|
32
|
+
|
|
33
|
+
cfg = HajimiSQLAlchemyConfig(username='u', password='p', host='h', database='d')
|
|
34
|
+
|
|
35
|
+
assert cfg.username == 'u'
|
|
36
|
+
assert cfg.password == 'p'
|
|
37
|
+
assert cfg.host == 'h'
|
|
38
|
+
assert cfg.port == 5432
|
|
39
|
+
assert cfg.database == 'd'
|
|
40
|
+
|
|
41
|
+
assert cfg.echo is False
|
|
42
|
+
assert cfg.pool_size == 3
|
|
43
|
+
assert cfg.max_overflow == 2
|
|
44
|
+
assert cfg.pool_timeout == 30
|
|
45
|
+
assert cfg.pool_recycle == 1800
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_custom_values_override_defaults():
|
|
49
|
+
"""Every optional field can be supplied explicitly."""
|
|
50
|
+
|
|
51
|
+
cfg = _base(port=6543, echo=True, pool_size=11, max_overflow=4, pool_timeout=12, pool_recycle=900)
|
|
52
|
+
|
|
53
|
+
assert cfg.port == 6543
|
|
54
|
+
assert cfg.echo is True
|
|
55
|
+
assert cfg.pool_size == 11
|
|
56
|
+
assert cfg.max_overflow == 4
|
|
57
|
+
assert cfg.pool_timeout == 12
|
|
58
|
+
assert cfg.pool_recycle == 900
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_url_property_uses_custom_port():
|
|
62
|
+
"""The SQLAlchemy URL reflects every connection parameter including a custom port."""
|
|
63
|
+
|
|
64
|
+
cfg = _base(
|
|
65
|
+
username='alice',
|
|
66
|
+
password='s3cret',
|
|
67
|
+
host='db.example.com',
|
|
68
|
+
port=6543,
|
|
69
|
+
database='appdb',
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
url = cfg.url
|
|
73
|
+
|
|
74
|
+
assert isinstance(url, URL)
|
|
75
|
+
assert url.drivername == 'postgresql+psycopg'
|
|
76
|
+
assert url.username == 'alice'
|
|
77
|
+
assert url.password == 's3cret'
|
|
78
|
+
assert url.host == 'db.example.com'
|
|
79
|
+
assert url.port == 6543
|
|
80
|
+
assert url.database == 'appdb'
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_url_property_uses_default_port():
|
|
84
|
+
"""When port is left at the default, the URL still carries it through."""
|
|
85
|
+
|
|
86
|
+
cfg = HajimiSQLAlchemyConfig(username='u', password='p', host='db', database='d')
|
|
87
|
+
|
|
88
|
+
url = cfg.url
|
|
89
|
+
|
|
90
|
+
assert url.port == 5432
|
|
91
|
+
assert url.drivername == 'postgresql+psycopg'
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_url_property_is_recomputed_each_call():
|
|
95
|
+
"""Each access returns a fresh URL object reflecting the current field values."""
|
|
96
|
+
|
|
97
|
+
cfg = _base(host='first')
|
|
98
|
+
first = cfg.url
|
|
99
|
+
|
|
100
|
+
cfg2 = dataclasses.replace(cfg, host='second')
|
|
101
|
+
second = cfg2.url
|
|
102
|
+
|
|
103
|
+
assert first.host == 'first'
|
|
104
|
+
assert second.host == 'second'
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_instance_is_frozen():
|
|
108
|
+
"""The dataclass is frozen: assignments raise FrozenInstanceError."""
|
|
109
|
+
|
|
110
|
+
cfg = HajimiSQLAlchemyConfig(username='u', password='p', host='h', database='d')
|
|
111
|
+
|
|
112
|
+
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
113
|
+
cfg.username = 'new'
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_config_is_kw_only():
|
|
117
|
+
"""Positional arguments are rejected because the dataclass is kw_only."""
|
|
118
|
+
|
|
119
|
+
with pytest.raises(TypeError):
|
|
120
|
+
# Intentionally pass positional args; should raise because of kw_only=True.
|
|
121
|
+
HajimiSQLAlchemyConfig('u', 'p', 'h', 5432, 'd')
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Tests for hajimi_sqlalchemy.dependencies."""
|
|
2
|
+
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
+
|
|
9
|
+
from hajimi_sqlalchemy.config import HajimiSQLAlchemyConfig
|
|
10
|
+
from hajimi_sqlalchemy.dependencies import SessionDep, get_session
|
|
11
|
+
from hajimi_sqlalchemy.plugin import HajimiSQLAlchemyPlugin
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _Namespace:
|
|
15
|
+
"""Container that holds the plugin attribute under ``HajimiSQLAlchemyPlugin.__name__``."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _make_request(app):
|
|
19
|
+
"""Minimal ``Request``-shaped object exposing ``app``."""
|
|
20
|
+
|
|
21
|
+
class _Request:
|
|
22
|
+
def __init__(self, app):
|
|
23
|
+
self.app = app
|
|
24
|
+
|
|
25
|
+
return _Request(app)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def test_get_session_yields_session_and_finishes(fake_session, fake_session_factory):
|
|
29
|
+
"""``get_session`` must yield the session from ``session_maker.begin()`` then exit cleanly."""
|
|
30
|
+
|
|
31
|
+
cfg = HajimiSQLAlchemyConfig(username='u', password='p', host='localhost', database='d')
|
|
32
|
+
plugin = HajimiSQLAlchemyPlugin(cfg)
|
|
33
|
+
# Bypass real startup() — this test focuses on dependencies.get_session itself.
|
|
34
|
+
plugin._session_maker = fake_session_factory
|
|
35
|
+
|
|
36
|
+
namespace = _Namespace()
|
|
37
|
+
setattr(namespace, HajimiSQLAlchemyPlugin.__name__, plugin)
|
|
38
|
+
|
|
39
|
+
app = FastAPI()
|
|
40
|
+
# Seed starlette's underlying state dict with the exact key dependencies.py looks up.
|
|
41
|
+
app.state._state['__app_state__'] = namespace
|
|
42
|
+
|
|
43
|
+
request = _make_request(app)
|
|
44
|
+
|
|
45
|
+
gen = get_session(request)
|
|
46
|
+
|
|
47
|
+
# First __anext__ enters the ``async with`` and yields the session.
|
|
48
|
+
session = await gen.__anext__()
|
|
49
|
+
assert session is fake_session
|
|
50
|
+
|
|
51
|
+
# Second __anext__ runs the cleanup path (yield -> line 13) and the generator returns.
|
|
52
|
+
with pytest.raises(StopAsyncIteration):
|
|
53
|
+
await gen.__anext__()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_session_dep_is_annotated_async_session():
|
|
57
|
+
"""``SessionDep`` is an ``Annotated`` alias whose first type-arg is ``AsyncSession``."""
|
|
58
|
+
|
|
59
|
+
origin = typing.get_origin(SessionDep)
|
|
60
|
+
args = typing.get_args(SessionDep)
|
|
61
|
+
|
|
62
|
+
# Annotated[...] has a non-None typing origin.
|
|
63
|
+
assert origin is not None
|
|
64
|
+
# The first type-arg of Annotated[..., Depends(...)] should be AsyncSession.
|
|
65
|
+
assert args[0] is AsyncSession
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Tests for the top-level ``hajimi_sqlalchemy`` package."""
|
|
2
|
+
|
|
3
|
+
import hajimi_sqlalchemy
|
|
4
|
+
from hajimi_sqlalchemy import BaseModel, HajimiSQLAlchemyConfig, HajimiSQLAlchemyPlugin
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_package_imports():
|
|
8
|
+
"""The package must import without side effects and expose ``__name__``."""
|
|
9
|
+
|
|
10
|
+
assert hajimi_sqlalchemy.__name__ == 'hajimi_sqlalchemy'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_package_reexports_base_model():
|
|
14
|
+
"""``BaseModel`` is part of the public surface."""
|
|
15
|
+
|
|
16
|
+
assert BaseModel is hajimi_sqlalchemy.base.BaseModel
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_package_reexports_config():
|
|
20
|
+
"""``HajimiSQLAlchemyConfig`` is part of the public surface."""
|
|
21
|
+
|
|
22
|
+
assert HajimiSQLAlchemyConfig is hajimi_sqlalchemy.config.HajimiSQLAlchemyConfig
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_package_reexports_plugin():
|
|
26
|
+
"""``HajimiSQLAlchemyPlugin`` is part of the public surface."""
|
|
27
|
+
|
|
28
|
+
assert HajimiSQLAlchemyPlugin is hajimi_sqlalchemy.plugin.HajimiSQLAlchemyPlugin
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Tests for hajimi_sqlalchemy.plugin.HajimiSQLAlchemyPlugin."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from hajimi_sqlalchemy.plugin import HajimiSQLAlchemyPlugin
|
|
6
|
+
|
|
7
|
+
from .stubs import FakeAsyncSessionMaker
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_init_leaves_engine_and_session_maker_uninitialised(primed_plugin):
|
|
11
|
+
"""Constructing a plugin must not eagerly create a database engine."""
|
|
12
|
+
|
|
13
|
+
assert primed_plugin._engine is None
|
|
14
|
+
assert primed_plugin._session_maker is None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_session_maker_raises_before_startup(primed_plugin):
|
|
18
|
+
"""Accessing session_maker before startup() raises a descriptive RuntimeError."""
|
|
19
|
+
|
|
20
|
+
with pytest.raises(RuntimeError, match='session_maker is unavailable'):
|
|
21
|
+
_ = primed_plugin.session_maker
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def test_startup_creates_engine_and_registers_plugin(
|
|
25
|
+
fake_engine, primed_plugin, app_for_plugin, plugin_state_key,
|
|
26
|
+
):
|
|
27
|
+
"""startup() must drive the patched factories and stash the plugin on app.state."""
|
|
28
|
+
|
|
29
|
+
await primed_plugin.startup(app_for_plugin)
|
|
30
|
+
|
|
31
|
+
assert primed_plugin._engine is fake_engine
|
|
32
|
+
# The session_maker is an instance of the stand-in sessionmaker type, built inside startup().
|
|
33
|
+
assert isinstance(primed_plugin._session_maker, FakeAsyncSessionMaker)
|
|
34
|
+
stored = app_for_plugin.state._state[plugin_state_key][HajimiSQLAlchemyPlugin.__name__]
|
|
35
|
+
assert stored is primed_plugin
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def test_session_maker_property_succeeds_after_startup(primed_plugin, app_for_plugin):
|
|
39
|
+
"""Once startup() has run, the session_maker property returns the cached factory."""
|
|
40
|
+
|
|
41
|
+
await primed_plugin.startup(app_for_plugin)
|
|
42
|
+
|
|
43
|
+
assert isinstance(primed_plugin.session_maker, FakeAsyncSessionMaker)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def test_shutdown_disposes_engine_and_clears_state(
|
|
47
|
+
fake_engine, primed_plugin, app_for_plugin, plugin_state_key,
|
|
48
|
+
):
|
|
49
|
+
"""shutdown() must dispose the engine, clear internal refs and remove the store entry."""
|
|
50
|
+
|
|
51
|
+
await primed_plugin.startup(app_for_plugin)
|
|
52
|
+
# Sanity check: startup registered the plugin.
|
|
53
|
+
assert app_for_plugin.state._state[plugin_state_key][HajimiSQLAlchemyPlugin.__name__] is primed_plugin
|
|
54
|
+
|
|
55
|
+
await primed_plugin.shutdown(app_for_plugin)
|
|
56
|
+
|
|
57
|
+
assert fake_engine.dispose_calls == 1
|
|
58
|
+
assert primed_plugin._engine is None
|
|
59
|
+
assert primed_plugin._session_maker is None
|
|
60
|
+
assert HajimiSQLAlchemyPlugin.__name__ not in app_for_plugin.state._state[plugin_state_key]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def test_shutdown_without_startup_is_safe(primed_plugin, app_for_plugin):
|
|
64
|
+
"""Calling shutdown() without a prior startup() must be a safe no-op."""
|
|
65
|
+
|
|
66
|
+
await primed_plugin.shutdown(app_for_plugin)
|
|
67
|
+
|
|
68
|
+
assert primed_plugin._engine is None
|
|
69
|
+
assert primed_plugin._session_maker is None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def test_shutdown_is_idempotent(fake_engine, primed_plugin, app_for_plugin):
|
|
73
|
+
"""A second shutdown() after the first must remain a no-op."""
|
|
74
|
+
|
|
75
|
+
await primed_plugin.startup(app_for_plugin)
|
|
76
|
+
await primed_plugin.shutdown(app_for_plugin)
|
|
77
|
+
initial_dispose_calls = fake_engine.dispose_calls
|
|
78
|
+
|
|
79
|
+
await primed_plugin.shutdown(app_for_plugin)
|
|
80
|
+
|
|
81
|
+
# The second call must not re-dispose the already-None engine.
|
|
82
|
+
assert fake_engine.dispose_calls == initial_dispose_calls
|
|
83
|
+
assert primed_plugin._engine is None
|
|
84
|
+
assert primed_plugin._session_maker is None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def test_session_maker_unavailable_after_shutdown(primed_plugin, app_for_plugin):
|
|
88
|
+
"""After shutdown(), the session_maker getter falls back to the error branch."""
|
|
89
|
+
|
|
90
|
+
await primed_plugin.startup(app_for_plugin)
|
|
91
|
+
await primed_plugin.shutdown(app_for_plugin)
|
|
92
|
+
|
|
93
|
+
with pytest.raises(RuntimeError, match='session_maker is unavailable'):
|
|
94
|
+
_ = primed_plugin.session_maker
|