openframe-adapters-db-postgres 1.0.0__tar.gz → 1.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.
- {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/.gitignore +3 -0
- {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/PKG-INFO +9 -2
- {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/openframe/adapters/db/postgres/__init__.py +2 -0
- openframe_adapters_db_postgres-1.2.0/openframe/adapters/db/postgres/plugin.py +231 -0
- openframe_adapters_db_postgres-1.2.0/pyproject.toml +46 -0
- {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/tests/conftest.py +9 -3
- openframe_adapters_db_postgres-1.2.0/tests/test_plugin.py +244 -0
- {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/tests/test_repository.py +101 -3
- openframe_adapters_db_postgres-1.0.0/pyproject.toml +0 -30
- {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/README.md +0 -0
- {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/openframe/adapters/db/postgres/config.py +0 -0
- {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/openframe/adapters/db/postgres/connection.py +0 -0
- {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/openframe/adapters/db/postgres/repository.py +0 -0
- {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/tests/test_config.py +0 -0
- {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/tests/test_connection.py +0 -0
- {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/tests/test_health.py +0 -0
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openframe-adapters-db-postgres
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: OpenFrame Microservice Suite — PostgreSQL database adapter.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Furious-Meteors/openframe-adapters
|
|
6
|
+
Project-URL: Documentation, https://furious-meteors.github.io/openframe-adapters/
|
|
7
|
+
Project-URL: Repository, https://github.com/Furious-Meteors/openframe-adapters
|
|
8
|
+
Project-URL: Changelog, https://github.com/Furious-Meteors/openframe-adapters/blob/production/.github/CHANGELOG.md
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/Furious-Meteors/openframe-adapters/issues
|
|
10
|
+
Author-email: Furious Meteors Engineering <engineering@furiousmeteors.dev>
|
|
11
|
+
Maintainer-email: Furious Meteors Engineering <engineering@furiousmeteors.dev>
|
|
5
12
|
License: MIT
|
|
6
13
|
Keywords: asyncpg,hexagonal,microservice,openframe,postgres
|
|
7
14
|
Requires-Python: >=3.11
|
|
8
15
|
Requires-Dist: asyncpg>=0.29
|
|
9
|
-
Requires-Dist: openframe-core<2
|
|
16
|
+
Requires-Dist: openframe-core<3,>=2.0
|
|
10
17
|
Provides-Extra: dev
|
|
11
18
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
12
19
|
Requires-Dist: pytest-mock>=3.14; extra == 'dev'
|
|
@@ -38,10 +38,12 @@ from __future__ import annotations
|
|
|
38
38
|
|
|
39
39
|
from .config import PostgresSettings
|
|
40
40
|
from .connection import get_postgres_pool
|
|
41
|
+
from .plugin import PostgresPlugin
|
|
41
42
|
from .repository import PostgresRepository
|
|
42
43
|
|
|
43
44
|
__all__ = [
|
|
44
45
|
"PostgresSettings",
|
|
45
46
|
"PostgresRepository",
|
|
46
47
|
"get_postgres_pool",
|
|
48
|
+
"PostgresPlugin",
|
|
47
49
|
]
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe/adapters/db/postgres/plugin.py
|
|
3
|
+
==========================================
|
|
4
|
+
OpenFrame plugin wrapper for PostgresRepository.
|
|
5
|
+
|
|
6
|
+
Stability: beta
|
|
7
|
+
|
|
8
|
+
Usage via PluginRegistry (optional)::
|
|
9
|
+
|
|
10
|
+
from openframe.core.plugins import PluginRegistry
|
|
11
|
+
from openframe.adapters.db.postgres import PostgresPlugin, PostgresSettings
|
|
12
|
+
|
|
13
|
+
registry = PluginRegistry()
|
|
14
|
+
registry.register(PostgresPlugin(PostgresSettings()))
|
|
15
|
+
await registry.initialize_all()
|
|
16
|
+
|
|
17
|
+
plugin = registry.get("persistence")
|
|
18
|
+
repo = plugin.get_repository()
|
|
19
|
+
|
|
20
|
+
Usage via deps.py (unchanged, no plugin needed)::
|
|
21
|
+
|
|
22
|
+
repo = PostgresRepository(PostgresSettings())
|
|
23
|
+
traced = TracingProxy(repo, prefix="repository.item")
|
|
24
|
+
"""
|
|
25
|
+
# Capability: "persistence"
|
|
26
|
+
# See capability taxonomy:
|
|
27
|
+
# https://furious-meteors.github.io/openframe-core/developer-guide/composition-root/
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
|
|
32
|
+
from openframe.core.exceptions import AdapterConnectionError
|
|
33
|
+
from openframe.core.plugins import PluginContext, PluginHealth, PluginStatus
|
|
34
|
+
|
|
35
|
+
from openframe.adapters.db.postgres.config import PostgresSettings
|
|
36
|
+
from openframe.adapters.db.postgres.connection import _pool_cache, get_postgres_pool
|
|
37
|
+
from openframe.adapters.db.postgres.repository import PostgresRepository
|
|
38
|
+
|
|
39
|
+
__all__ = ["PostgresPlugin"]
|
|
40
|
+
|
|
41
|
+
_logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PostgresPlugin:
|
|
45
|
+
"""
|
|
46
|
+
PostgreSQL adapter plugin for the OpenFrame plugin registry.
|
|
47
|
+
|
|
48
|
+
Capability: "persistence"
|
|
49
|
+
|
|
50
|
+
Stability: beta
|
|
51
|
+
|
|
52
|
+
Lifecycle:
|
|
53
|
+
initialize() — creates the asyncpg connection pool and verifies
|
|
54
|
+
connectivity via ping(). Raises AdapterConnectionError
|
|
55
|
+
if the database is unreachable.
|
|
56
|
+
shutdown() — closes the connection pool. Never raises.
|
|
57
|
+
health() — calls ping() and returns PluginHealth. Never raises.
|
|
58
|
+
|
|
59
|
+
The plugin exposes get_repository() after initialization for use
|
|
60
|
+
in the composition root or ApplicationBootstrap.
|
|
61
|
+
|
|
62
|
+
By default constructs a plain PostgresRepository. To use a domain-specific
|
|
63
|
+
subclass, pass it via repository_class::
|
|
64
|
+
|
|
65
|
+
registry.register(PostgresPlugin(
|
|
66
|
+
PostgresSettings(),
|
|
67
|
+
table="items",
|
|
68
|
+
id_column="id",
|
|
69
|
+
repository_class=ItemPostgresRepository,
|
|
70
|
+
))
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
name: str = "openframe-postgres"
|
|
74
|
+
version: str = "1.2.0"
|
|
75
|
+
capability: str = "persistence"
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
settings: PostgresSettings,
|
|
80
|
+
table: str = "",
|
|
81
|
+
id_column: str = "id",
|
|
82
|
+
repository_class: type[PostgresRepository] = PostgresRepository,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Args:
|
|
86
|
+
settings: PostgresSettings instance.
|
|
87
|
+
table: Table name. If omitted the plugin acts as a
|
|
88
|
+
connection manager only (no get_repository()).
|
|
89
|
+
id_column: Primary key column name. Defaults to "id".
|
|
90
|
+
repository_class: The PostgresRepository subclass to construct.
|
|
91
|
+
Defaults to the base PostgresRepository. Pass a
|
|
92
|
+
domain-specific subclass here to get proper
|
|
93
|
+
entity mapping through get_repository().
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
TypeError: repository_class is not a subclass of PostgresRepository.
|
|
97
|
+
"""
|
|
98
|
+
if not (isinstance(repository_class, type) and issubclass(repository_class, PostgresRepository)):
|
|
99
|
+
raise TypeError(
|
|
100
|
+
f"repository_class must be a subclass of PostgresRepository, "
|
|
101
|
+
f"got {repository_class!r}"
|
|
102
|
+
)
|
|
103
|
+
self._settings = settings
|
|
104
|
+
self._table = table
|
|
105
|
+
self._id_column = id_column
|
|
106
|
+
self._repository_class = repository_class
|
|
107
|
+
self._repo: PostgresRepository | None = None
|
|
108
|
+
self._status = PluginStatus.REGISTERED
|
|
109
|
+
|
|
110
|
+
async def initialize(self, context: PluginContext) -> None:
|
|
111
|
+
"""
|
|
112
|
+
Initialize the PostgreSQL connection pool and verify connectivity.
|
|
113
|
+
|
|
114
|
+
If a ``table`` was passed to the constructor, a
|
|
115
|
+
:class:`PostgresRepository` is created and connectivity is verified
|
|
116
|
+
via ``repo.ping()``. When no table is provided the plugin is used
|
|
117
|
+
purely as a connection manager: connectivity is verified with a
|
|
118
|
+
direct ``SELECT 1`` against the pool.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
context: Plugin context (config, plugin_name). Unused here —
|
|
122
|
+
settings are provided at construction time.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
AdapterConnectionError: PostgreSQL is unreachable or credentials
|
|
126
|
+
are invalid.
|
|
127
|
+
AdapterConfigurationError: DATABASE_URL is malformed.
|
|
128
|
+
"""
|
|
129
|
+
self._status = PluginStatus.INITIALIZED
|
|
130
|
+
try:
|
|
131
|
+
pool = await get_postgres_pool(self._settings)
|
|
132
|
+
if self._table:
|
|
133
|
+
self._repo = self._repository_class(
|
|
134
|
+
self._settings,
|
|
135
|
+
table=self._table,
|
|
136
|
+
id_column=self._id_column,
|
|
137
|
+
)
|
|
138
|
+
if not await self._repo.ping():
|
|
139
|
+
raise AdapterConnectionError(
|
|
140
|
+
"PostgreSQL ping failed after pool creation",
|
|
141
|
+
adapter="postgres",
|
|
142
|
+
operation="initialize",
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
# Verify pool connectivity without requiring a table.
|
|
146
|
+
try:
|
|
147
|
+
await pool.fetchval("SELECT 1")
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
raise AdapterConnectionError(
|
|
150
|
+
f"PostgreSQL connectivity check failed: {exc}",
|
|
151
|
+
adapter="postgres",
|
|
152
|
+
operation="initialize",
|
|
153
|
+
) from exc
|
|
154
|
+
self._status = PluginStatus.READY
|
|
155
|
+
_logger.info(
|
|
156
|
+
"PostgresPlugin initialized — %s (repository_class=%s)",
|
|
157
|
+
self._settings.database_url.split("@")[-1],
|
|
158
|
+
self._repository_class.__name__,
|
|
159
|
+
)
|
|
160
|
+
except Exception:
|
|
161
|
+
self._status = PluginStatus.FAILED
|
|
162
|
+
raise
|
|
163
|
+
|
|
164
|
+
async def shutdown(self) -> None:
|
|
165
|
+
"""
|
|
166
|
+
Close the PostgreSQL connection pool.
|
|
167
|
+
|
|
168
|
+
Never raises — logs errors and continues.
|
|
169
|
+
"""
|
|
170
|
+
self._status = PluginStatus.STOPPING
|
|
171
|
+
try:
|
|
172
|
+
if self._repo is not None:
|
|
173
|
+
await self._repo.close()
|
|
174
|
+
_logger.info("PostgresPlugin shutdown complete.")
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
_logger.error("PostgresPlugin shutdown error (ignored): %s", exc)
|
|
177
|
+
finally:
|
|
178
|
+
self._status = PluginStatus.STOPPED
|
|
179
|
+
|
|
180
|
+
async def health(self) -> PluginHealth:
|
|
181
|
+
"""
|
|
182
|
+
Return current health snapshot.
|
|
183
|
+
|
|
184
|
+
Never raises — returns FAILED status on any exception.
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
if self._status != PluginStatus.READY:
|
|
188
|
+
return PluginHealth(
|
|
189
|
+
status=PluginStatus.FAILED,
|
|
190
|
+
message=f"Plugin status: {self._status.name}",
|
|
191
|
+
)
|
|
192
|
+
if self._repo is not None:
|
|
193
|
+
healthy = await self._repo.ping()
|
|
194
|
+
else:
|
|
195
|
+
# No repository — ping the pool directly.
|
|
196
|
+
try:
|
|
197
|
+
pool = await get_postgres_pool(self._settings)
|
|
198
|
+
await pool.fetchval("SELECT 1")
|
|
199
|
+
healthy = True
|
|
200
|
+
except Exception:
|
|
201
|
+
healthy = False
|
|
202
|
+
return PluginHealth(
|
|
203
|
+
status=PluginStatus.READY if healthy else PluginStatus.FAILED,
|
|
204
|
+
message="" if healthy else "ping() returned False",
|
|
205
|
+
)
|
|
206
|
+
except Exception as exc:
|
|
207
|
+
return PluginHealth(
|
|
208
|
+
status=PluginStatus.FAILED,
|
|
209
|
+
message=str(exc),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def get_repository(self) -> PostgresRepository:
|
|
213
|
+
"""
|
|
214
|
+
Return the initialized repository.
|
|
215
|
+
|
|
216
|
+
Only valid after registry.initialize_all() has been called.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
RuntimeError: Plugin not yet initialized.
|
|
220
|
+
"""
|
|
221
|
+
if self._status != PluginStatus.READY:
|
|
222
|
+
raise RuntimeError(
|
|
223
|
+
f"PostgresPlugin is not ready (status: {self._status.name}). "
|
|
224
|
+
"Call await registry.initialize_all() first."
|
|
225
|
+
)
|
|
226
|
+
if self._repo is None:
|
|
227
|
+
raise RuntimeError(
|
|
228
|
+
"PostgresPlugin was initialized without a table name. "
|
|
229
|
+
"Pass table=... to PostgresPlugin() to enable get_repository()."
|
|
230
|
+
)
|
|
231
|
+
return self._repo
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "openframe-adapters-db-postgres"
|
|
7
|
+
version = "1.2.0"
|
|
8
|
+
description = "OpenFrame Microservice Suite — PostgreSQL database adapter."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["openframe", "postgres", "asyncpg", "hexagonal", "microservice"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"openframe-core>=2.0,<3",
|
|
15
|
+
"asyncpg>=0.29",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=8.0",
|
|
21
|
+
"pytest-asyncio>=0.23",
|
|
22
|
+
"pytest-mock>=3.14",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[[project.authors]]
|
|
26
|
+
name = "Furious Meteors Engineering"
|
|
27
|
+
email = "engineering@furiousmeteors.dev"
|
|
28
|
+
|
|
29
|
+
[[project.maintainers]]
|
|
30
|
+
name = "Furious Meteors Engineering"
|
|
31
|
+
email = "engineering@furiousmeteors.dev"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/Furious-Meteors/openframe-adapters"
|
|
35
|
+
Documentation = "https://furious-meteors.github.io/openframe-adapters/"
|
|
36
|
+
Repository = "https://github.com/Furious-Meteors/openframe-adapters"
|
|
37
|
+
Changelog = "https://github.com/Furious-Meteors/openframe-adapters/blob/production/.github/CHANGELOG.md"
|
|
38
|
+
"Bug Tracker" = "https://github.com/Furious-Meteors/openframe-adapters/issues"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["openframe"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
asyncio_mode = "auto"
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
|
{openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/tests/conftest.py
RENAMED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""
|
|
2
|
-
tests/conftest.py
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
tests/conftest.py — openframe-adapters-db-postgres
|
|
3
|
+
=====================================================
|
|
4
|
+
OTel reset fixtures are provided by openframe.core.testing.fixtures.
|
|
5
|
+
This file contains only adapter-specific fixtures.
|
|
5
6
|
|
|
6
7
|
All tests run with zero network calls. asyncpg is mocked at the
|
|
7
8
|
``openframe.adapters.db.postgres.connection`` import level so no real
|
|
@@ -9,6 +10,11 @@ Postgres server is needed.
|
|
|
9
10
|
"""
|
|
10
11
|
from __future__ import annotations
|
|
11
12
|
|
|
13
|
+
# Canonical OTel reset fixtures from openframe-core v2.0.
|
|
14
|
+
# Provides (autouse): reset_telemetry_state
|
|
15
|
+
# Provides (on-demand): span_exporter, metric_reader
|
|
16
|
+
from openframe.core.testing.fixtures import * # noqa: F401, F403
|
|
17
|
+
|
|
12
18
|
import pytest
|
|
13
19
|
from unittest.mock import AsyncMock, MagicMock
|
|
14
20
|
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tests/test_plugin.py — openframe-adapters-db-postgres
|
|
3
|
+
========================================================
|
|
4
|
+
Tests for PostgresPlugin: protocol conformance, lifecycle, and health.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from openframe.adapters.db.postgres import PostgresPlugin, PostgresRepository, PostgresSettings
|
|
11
|
+
from openframe.core.plugins import OpenFramePlugin, PluginContext, PluginStatus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def settings():
|
|
16
|
+
"""A PostgresSettings instance with dummy connection details."""
|
|
17
|
+
return PostgresSettings(
|
|
18
|
+
database_url="postgresql://test:test@localhost/test"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def plugin(settings):
|
|
24
|
+
"""An uninitialized PostgresPlugin pre-configured for the 'items' table."""
|
|
25
|
+
return PostgresPlugin(settings, table="items")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture
|
|
29
|
+
def plugin_context():
|
|
30
|
+
"""A minimal PluginContext for tests."""
|
|
31
|
+
return PluginContext(
|
|
32
|
+
config={},
|
|
33
|
+
plugin_name="openframe-postgres",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── Protocol conformance ───────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
def test_postgres_plugin_satisfies_openframe_plugin_protocol(plugin):
|
|
40
|
+
"""PostgresPlugin must satisfy the OpenFramePlugin runtime-checkable Protocol."""
|
|
41
|
+
assert isinstance(plugin, OpenFramePlugin)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_postgres_plugin_name(plugin):
|
|
45
|
+
assert plugin.name == "openframe-postgres"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_postgres_plugin_version(plugin):
|
|
49
|
+
assert plugin.version == "1.2.0"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_postgres_plugin_capability(plugin):
|
|
53
|
+
assert plugin.capability == "persistence"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
async def test_initialize_succeeds_when_ping_returns_true(
|
|
59
|
+
plugin, plugin_context, mock_pool, mock_settings
|
|
60
|
+
):
|
|
61
|
+
"""Successful pool creation + ping → status READY."""
|
|
62
|
+
import openframe.adapters.db.postgres.connection as conn_module
|
|
63
|
+
conn_module._pool_cache[mock_settings.database_url] = mock_pool
|
|
64
|
+
mock_pool.fetchval.return_value = 1 # ping SELECT 1
|
|
65
|
+
|
|
66
|
+
plugin._settings = mock_settings
|
|
67
|
+
await plugin.initialize(plugin_context)
|
|
68
|
+
|
|
69
|
+
assert plugin._status == PluginStatus.READY
|
|
70
|
+
conn_module._pool_cache.clear()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def test_initialize_fails_when_ping_raises(
|
|
74
|
+
plugin, plugin_context, mock_pool, mock_settings
|
|
75
|
+
):
|
|
76
|
+
"""Ping raising an exception → status FAILED, AdapterConnectionError propagated."""
|
|
77
|
+
import openframe.adapters.db.postgres.connection as conn_module
|
|
78
|
+
conn_module._pool_cache[mock_settings.database_url] = mock_pool
|
|
79
|
+
mock_pool.fetchval.side_effect = Exception("connection refused")
|
|
80
|
+
|
|
81
|
+
plugin._settings = mock_settings
|
|
82
|
+
from openframe.core.exceptions import AdapterConnectionError
|
|
83
|
+
with pytest.raises((AdapterConnectionError, Exception)):
|
|
84
|
+
await plugin.initialize(plugin_context)
|
|
85
|
+
|
|
86
|
+
assert plugin._status == PluginStatus.FAILED
|
|
87
|
+
conn_module._pool_cache.clear()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def test_shutdown_never_raises_when_repo_is_none(plugin):
|
|
91
|
+
"""shutdown() must not raise even if repo was never set."""
|
|
92
|
+
plugin._repo = None
|
|
93
|
+
await plugin.shutdown()
|
|
94
|
+
assert plugin._status == PluginStatus.STOPPED
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def test_shutdown_sets_status_stopped(plugin, mock_pool, mock_settings, plugin_context):
|
|
98
|
+
"""shutdown() always transitions to STOPPED."""
|
|
99
|
+
import openframe.adapters.db.postgres.connection as conn_module
|
|
100
|
+
conn_module._pool_cache[mock_settings.database_url] = mock_pool
|
|
101
|
+
mock_pool.fetchval.return_value = 1
|
|
102
|
+
plugin._settings = mock_settings
|
|
103
|
+
await plugin.initialize(plugin_context)
|
|
104
|
+
|
|
105
|
+
await plugin.shutdown()
|
|
106
|
+
assert plugin._status == PluginStatus.STOPPED
|
|
107
|
+
conn_module._pool_cache.clear()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def test_get_repository_raises_before_initialize(plugin):
|
|
111
|
+
"""get_repository() must raise RuntimeError when plugin is not READY."""
|
|
112
|
+
with pytest.raises(RuntimeError, match="not ready"):
|
|
113
|
+
plugin.get_repository()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def test_get_repository_returns_instance_after_initialize(
|
|
117
|
+
plugin, plugin_context, mock_pool, mock_settings
|
|
118
|
+
):
|
|
119
|
+
"""get_repository() returns a PostgresRepository after successful init."""
|
|
120
|
+
import openframe.adapters.db.postgres.connection as conn_module
|
|
121
|
+
from openframe.adapters.db.postgres import PostgresRepository
|
|
122
|
+
|
|
123
|
+
conn_module._pool_cache[mock_settings.database_url] = mock_pool
|
|
124
|
+
mock_pool.fetchval.return_value = 1
|
|
125
|
+
plugin._settings = mock_settings
|
|
126
|
+
await plugin.initialize(plugin_context)
|
|
127
|
+
|
|
128
|
+
repo = plugin.get_repository()
|
|
129
|
+
assert isinstance(repo, PostgresRepository)
|
|
130
|
+
conn_module._pool_cache.clear()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ── Health ─────────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
async def test_health_returns_failed_when_not_initialized(plugin):
|
|
136
|
+
"""health() before initialize() → FAILED status."""
|
|
137
|
+
health = await plugin.health()
|
|
138
|
+
assert health.status == PluginStatus.FAILED
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def test_health_never_raises(plugin):
|
|
142
|
+
"""health() must never raise — even with a None repo."""
|
|
143
|
+
plugin._repo = None
|
|
144
|
+
result = await plugin.health()
|
|
145
|
+
assert result is not None
|
|
146
|
+
assert result.status == PluginStatus.FAILED
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def test_health_returns_ready_after_initialize(
|
|
150
|
+
plugin, plugin_context, mock_pool, mock_settings
|
|
151
|
+
):
|
|
152
|
+
"""health() after successful init with ping returning True → READY."""
|
|
153
|
+
import openframe.adapters.db.postgres.connection as conn_module
|
|
154
|
+
conn_module._pool_cache[mock_settings.database_url] = mock_pool
|
|
155
|
+
mock_pool.fetchval.return_value = 1
|
|
156
|
+
plugin._settings = mock_settings
|
|
157
|
+
await plugin.initialize(plugin_context)
|
|
158
|
+
|
|
159
|
+
health = await plugin.health()
|
|
160
|
+
assert health.status == PluginStatus.READY
|
|
161
|
+
conn_module._pool_cache.clear()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def test_health_returns_failed_when_ping_fails(
|
|
165
|
+
plugin, plugin_context, mock_pool, mock_settings
|
|
166
|
+
):
|
|
167
|
+
"""health() when ping returns False → FAILED status, no exception raised."""
|
|
168
|
+
import openframe.adapters.db.postgres.connection as conn_module
|
|
169
|
+
conn_module._pool_cache[mock_settings.database_url] = mock_pool
|
|
170
|
+
mock_pool.fetchval.return_value = 1
|
|
171
|
+
plugin._settings = mock_settings
|
|
172
|
+
await plugin.initialize(plugin_context)
|
|
173
|
+
|
|
174
|
+
# Now make ping fail for health check
|
|
175
|
+
mock_pool.fetchval.side_effect = Exception("db gone")
|
|
176
|
+
result = await plugin.health()
|
|
177
|
+
assert result is not None
|
|
178
|
+
assert result.status == PluginStatus.FAILED
|
|
179
|
+
conn_module._pool_cache.clear()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ── repository_class parameter ─────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
def test_plugin_defaults_to_base_repository_class(settings):
|
|
185
|
+
"""Backwards compatibility — no repository_class passed."""
|
|
186
|
+
plugin = PostgresPlugin(settings, table="items")
|
|
187
|
+
assert plugin._repository_class is PostgresRepository
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_plugin_accepts_custom_repository_class(settings):
|
|
191
|
+
class CustomRepo(PostgresRepository):
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
plugin = PostgresPlugin(settings, table="items", repository_class=CustomRepo)
|
|
195
|
+
assert plugin._repository_class is CustomRepo
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_plugin_rejects_non_repository_class(settings):
|
|
199
|
+
"""repository_class must be a subclass of PostgresRepository — TypeError if not."""
|
|
200
|
+
with pytest.raises(TypeError, match="subclass of PostgresRepository"):
|
|
201
|
+
PostgresPlugin(settings, table="items", repository_class=object) # type: ignore[arg-type]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def test_initialize_constructs_custom_repository_class(
|
|
205
|
+
settings, plugin_context, mock_pool, mock_settings
|
|
206
|
+
):
|
|
207
|
+
"""
|
|
208
|
+
REGRESSION: plugin always constructed the base class, silently discarding
|
|
209
|
+
domain subclass overrides of entity mapping methods.
|
|
210
|
+
"""
|
|
211
|
+
import openframe.adapters.db.postgres.connection as conn_module
|
|
212
|
+
|
|
213
|
+
class CustomRepo(PostgresRepository):
|
|
214
|
+
marker = True
|
|
215
|
+
|
|
216
|
+
conn_module._pool_cache[mock_settings.database_url] = mock_pool
|
|
217
|
+
mock_pool.fetchval.return_value = 1
|
|
218
|
+
|
|
219
|
+
plugin = PostgresPlugin(mock_settings, table="items", repository_class=CustomRepo)
|
|
220
|
+
await plugin.initialize(plugin_context)
|
|
221
|
+
|
|
222
|
+
repo = plugin.get_repository()
|
|
223
|
+
assert isinstance(repo, CustomRepo)
|
|
224
|
+
assert hasattr(repo, "marker")
|
|
225
|
+
conn_module._pool_cache.clear()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async def test_get_repository_returns_subclass_not_base_class(
|
|
229
|
+
settings, plugin_context, mock_pool, mock_settings
|
|
230
|
+
):
|
|
231
|
+
"""type(repo) must be the subclass, not just isinstance-compatible."""
|
|
232
|
+
import openframe.adapters.db.postgres.connection as conn_module
|
|
233
|
+
|
|
234
|
+
class CustomRepo(PostgresRepository):
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
conn_module._pool_cache[mock_settings.database_url] = mock_pool
|
|
238
|
+
mock_pool.fetchval.return_value = 1
|
|
239
|
+
|
|
240
|
+
plugin = PostgresPlugin(mock_settings, table="items", repository_class=CustomRepo)
|
|
241
|
+
await plugin.initialize(plugin_context)
|
|
242
|
+
|
|
243
|
+
assert type(plugin.get_repository()) is CustomRepo
|
|
244
|
+
conn_module._pool_cache.clear()
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""
|
|
2
|
-
tests/test_repository.py
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
tests/test_repository.py — openframe-adapters-db-postgres
|
|
3
|
+
===========================================================
|
|
4
|
+
Contract tests (RepositoryContractTests) run first, then adapter-specific
|
|
5
|
+
unit tests covering PostgreSQL error mapping and driver behaviour.
|
|
5
6
|
"""
|
|
6
7
|
from __future__ import annotations
|
|
7
8
|
|
|
@@ -13,6 +14,103 @@ from openframe.adapters.db.postgres import PostgresRepository
|
|
|
13
14
|
from openframe.core.exceptions import AdapterConfigurationError, AdapterQueryError, AdapterTimeoutError
|
|
14
15
|
from openframe.core.health import HealthCheck
|
|
15
16
|
from openframe.core.ports import BaseRepository
|
|
17
|
+
from openframe.core.testing import RepositoryContractTests
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── Contract tests — must pass for every BaseRepository implementation ─────
|
|
21
|
+
|
|
22
|
+
class TestPostgresRepositoryContracts(RepositoryContractTests):
|
|
23
|
+
"""
|
|
24
|
+
PostgresRepository passes the full openframe contract suite.
|
|
25
|
+
|
|
26
|
+
All 18 RepositoryContractTests run against a mocked asyncpg pool.
|
|
27
|
+
No real database required.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def repository(self, mock_settings, mock_pool):
|
|
32
|
+
"""
|
|
33
|
+
PostgresRepository backed by a stateful in-memory mock pool.
|
|
34
|
+
|
|
35
|
+
The mock pool tracks insertions, updates, and deletions so that the
|
|
36
|
+
RepositoryContractTests behavioural assertions (create → get, list
|
|
37
|
+
pagination, etc.) pass without a real database.
|
|
38
|
+
"""
|
|
39
|
+
import re
|
|
40
|
+
from unittest.mock import AsyncMock
|
|
41
|
+
|
|
42
|
+
import openframe.adapters.db.postgres.connection as conn_module
|
|
43
|
+
|
|
44
|
+
conn_module._pool_cache[mock_settings.database_url] = mock_pool
|
|
45
|
+
|
|
46
|
+
# In-memory store that simulates the database table.
|
|
47
|
+
_store: dict[str, dict] = {}
|
|
48
|
+
|
|
49
|
+
def _fetchrow(query: str, *args):
|
|
50
|
+
q = query.upper()
|
|
51
|
+
if "INSERT" in q:
|
|
52
|
+
# Parse column list from: INSERT INTO tbl (col1, col2) VALUES ...
|
|
53
|
+
m = re.search(r"\(([^)]+)\)\s*VALUES", query, re.IGNORECASE)
|
|
54
|
+
cols = [c.strip() for c in m.group(1).split(",")] if m else []
|
|
55
|
+
row = dict(zip(cols, args))
|
|
56
|
+
_store[str(row.get("id", ""))] = row
|
|
57
|
+
return row
|
|
58
|
+
if "UPDATE" in q:
|
|
59
|
+
# Last arg is the WHERE id value.
|
|
60
|
+
entity_id = str(args[-1])
|
|
61
|
+
if entity_id not in _store:
|
|
62
|
+
return None
|
|
63
|
+
# Parse SET columns: UPDATE tbl SET col=$1,... WHERE id=$N
|
|
64
|
+
m = re.search(r"SET\s+(.+?)\s+WHERE", query, re.IGNORECASE)
|
|
65
|
+
set_cols = (
|
|
66
|
+
[p.strip().split("=")[0].strip() for p in m.group(1).split(",")]
|
|
67
|
+
if m
|
|
68
|
+
else []
|
|
69
|
+
)
|
|
70
|
+
row = dict(_store[entity_id])
|
|
71
|
+
for i, col in enumerate(set_cols):
|
|
72
|
+
row[col] = args[i]
|
|
73
|
+
_store[entity_id] = row
|
|
74
|
+
return row
|
|
75
|
+
# SELECT — first arg is entity_id.
|
|
76
|
+
entity_id = str(args[0]) if args else ""
|
|
77
|
+
return _store.get(entity_id)
|
|
78
|
+
|
|
79
|
+
def _execute(query: str, *args):
|
|
80
|
+
if "DELETE" in query.upper():
|
|
81
|
+
entity_id = str(args[0]) if args else ""
|
|
82
|
+
if entity_id in _store:
|
|
83
|
+
del _store[entity_id]
|
|
84
|
+
return "DELETE 1"
|
|
85
|
+
return "DELETE 0"
|
|
86
|
+
return ""
|
|
87
|
+
|
|
88
|
+
def _conn_fetch(query: str, limit: int, offset: int):
|
|
89
|
+
items = list(_store.values())
|
|
90
|
+
return items[offset : offset + limit]
|
|
91
|
+
|
|
92
|
+
def _conn_fetchval(query: str):
|
|
93
|
+
return len(_store)
|
|
94
|
+
|
|
95
|
+
mock_pool.fetchrow = AsyncMock(side_effect=_fetchrow)
|
|
96
|
+
mock_pool.execute = AsyncMock(side_effect=_execute)
|
|
97
|
+
mock_pool.fetchval = AsyncMock(return_value=1) # ping / is_ready
|
|
98
|
+
conn = mock_pool.acquire.return_value
|
|
99
|
+
conn.fetch = AsyncMock(side_effect=_conn_fetch)
|
|
100
|
+
conn.fetchval = AsyncMock(side_effect=_conn_fetchval)
|
|
101
|
+
|
|
102
|
+
r = PostgresRepository(mock_settings, table="items", id_column="id")
|
|
103
|
+
yield r
|
|
104
|
+
conn_module._pool_cache.clear()
|
|
105
|
+
|
|
106
|
+
@pytest.fixture
|
|
107
|
+
def make_entity(self):
|
|
108
|
+
def _make(id: str, name: str = "test") -> dict:
|
|
109
|
+
return {"id": id, "name": name}
|
|
110
|
+
return _make
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ── Adapter-specific tests — beyond what the contract covers ───────────────
|
|
16
114
|
|
|
17
115
|
|
|
18
116
|
# ---------------------------------------------------------------------------
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["hatchling"]
|
|
3
|
-
build-backend = "hatchling.build"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "openframe-adapters-db-postgres"
|
|
7
|
-
version = "1.0.0"
|
|
8
|
-
description = "OpenFrame Microservice Suite — PostgreSQL database adapter."
|
|
9
|
-
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.11"
|
|
11
|
-
license = { text = "MIT" }
|
|
12
|
-
keywords = ["openframe", "postgres", "asyncpg", "hexagonal", "microservice"]
|
|
13
|
-
dependencies = [
|
|
14
|
-
"openframe-core>=1.0,<2",
|
|
15
|
-
"asyncpg>=0.29",
|
|
16
|
-
]
|
|
17
|
-
|
|
18
|
-
[project.optional-dependencies]
|
|
19
|
-
dev = [
|
|
20
|
-
"pytest>=8.0",
|
|
21
|
-
"pytest-asyncio>=0.23",
|
|
22
|
-
"pytest-mock>=3.14",
|
|
23
|
-
]
|
|
24
|
-
|
|
25
|
-
[tool.hatch.build.targets.wheel]
|
|
26
|
-
packages = ["openframe"]
|
|
27
|
-
|
|
28
|
-
[tool.pytest.ini_options]
|
|
29
|
-
asyncio_mode = "auto"
|
|
30
|
-
testpaths = ["tests"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/tests/test_config.py
RENAMED
|
File without changes
|
|
File without changes
|
{openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/tests/test_health.py
RENAMED
|
File without changes
|