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.
Files changed (16) hide show
  1. {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/.gitignore +3 -0
  2. {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/PKG-INFO +9 -2
  3. {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/openframe/adapters/db/postgres/__init__.py +2 -0
  4. openframe_adapters_db_postgres-1.2.0/openframe/adapters/db/postgres/plugin.py +231 -0
  5. openframe_adapters_db_postgres-1.2.0/pyproject.toml +46 -0
  6. {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/tests/conftest.py +9 -3
  7. openframe_adapters_db_postgres-1.2.0/tests/test_plugin.py +244 -0
  8. {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/tests/test_repository.py +101 -3
  9. openframe_adapters_db_postgres-1.0.0/pyproject.toml +0 -30
  10. {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/README.md +0 -0
  11. {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/openframe/adapters/db/postgres/config.py +0 -0
  12. {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/openframe/adapters/db/postgres/connection.py +0 -0
  13. {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/openframe/adapters/db/postgres/repository.py +0 -0
  14. {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/tests/test_config.py +0 -0
  15. {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/tests/test_connection.py +0 -0
  16. {openframe_adapters_db_postgres-1.0.0 → openframe_adapters_db_postgres-1.2.0}/tests/test_health.py +0 -0
@@ -216,3 +216,6 @@ __marimo__/
216
216
 
217
217
  # Streamlit
218
218
  .streamlit/secrets.toml
219
+
220
+ # Miscellaneous
221
+ .DS_Store
@@ -1,12 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openframe-adapters-db-postgres
3
- Version: 1.0.0
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,>=1.0
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
+
@@ -1,7 +1,8 @@
1
1
  """
2
- tests/conftest.py
3
- ==================
4
- Shared pytest fixtures for openframe-adapters-db-postgres.
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
- Unit tests for PostgresRepository CRUD operations and Protocol conformance.
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"]