openframe-adapters-db-postgres 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,47 @@
1
+ """
2
+ openframe.adapters.db.postgres
3
+ ================================
4
+ PostgreSQL database adapter for the OpenFrame Microservice Suite.
5
+
6
+ Public API:
7
+
8
+ PostgresSettings — Pydantic Settings subclass for connection config.
9
+ PostgresRepository — Generic async repository (BaseRepository + HealthCheck).
10
+ get_postgres_pool — Async factory that creates / returns the cached pool.
11
+
12
+ Quick start::
13
+
14
+ from openframe.adapters.db.postgres import (
15
+ PostgresSettings,
16
+ PostgresRepository,
17
+ get_postgres_pool,
18
+ )
19
+
20
+ settings = PostgresSettings(database_url="postgresql://user:pw@localhost/db")
21
+
22
+ # Raw dict mode
23
+ repo = PostgresRepository(settings, table="items", id_column="id")
24
+ item = await repo.get("abc-123") # dict | None
25
+
26
+ # Typed mode — subclass and override mapping methods
27
+ class ItemRepository(PostgresRepository[Item]):
28
+ _table = "items"
29
+ _id_column = "id"
30
+
31
+ def _row_to_entity(self, row):
32
+ return Item(**dict(row))
33
+
34
+ def _entity_to_row(self, entity):
35
+ return entity.model_dump()
36
+ """
37
+ from __future__ import annotations
38
+
39
+ from .config import PostgresSettings
40
+ from .connection import get_postgres_pool
41
+ from .repository import PostgresRepository
42
+
43
+ __all__ = [
44
+ "PostgresSettings",
45
+ "PostgresRepository",
46
+ "get_postgres_pool",
47
+ ]
@@ -0,0 +1,59 @@
1
+ """
2
+ openframe/adapters/db/postgres/config.py
3
+ =========================================
4
+ PostgreSQL adapter settings.
5
+
6
+ Reads all connection configuration from environment variables via Pydantic
7
+ Settings. Every field is validated at instantiation — missing required fields
8
+ raise ``pydantic_core.ValidationError`` immediately so misconfigured
9
+ deployments fail fast on startup.
10
+
11
+ Required env vars:
12
+ DATABASE_URL: Full asyncpg DSN.
13
+ Format: postgresql://user:pass@host:port/dbname
14
+ SSL: postgresql://user:pass@host/dbname?ssl=require
15
+
16
+ Optional env vars (all have defaults):
17
+ POOL_SIZE: int = 10
18
+ POOL_MAX_INACTIVE_CONN_LIFETIME: float = 300.0
19
+ POOL_COMMAND_TIMEOUT: float = 60.0
20
+ POOL_MAX_QUERIES: int = 50000
21
+ POSTGRES_ADAPTER_NAME: str = "postgres"
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from openframe.core.config import BaseAdapterSettings
26
+
27
+ __all__ = ["PostgresSettings"]
28
+
29
+
30
+ class PostgresSettings(BaseAdapterSettings):
31
+ """
32
+ Settings for the PostgreSQL adapter.
33
+
34
+ All fields read from environment variables. Missing required fields raise
35
+ ``pydantic_core.ValidationError`` at instantiation time.
36
+
37
+ Inherits from ``BaseAdapterSettings``:
38
+ adapter_name: str = "postgres" (overrides base default)
39
+ connection_timeout: float = 30.0
40
+ operation_timeout: float = 10.0
41
+ max_retries: int = 3
42
+
43
+ Attributes:
44
+ database_url: Full asyncpg DSN (required).
45
+ pool_size: Pool min_size and max_size. Default 10.
46
+ pool_max_inactive_conn_lifetime: Seconds before idle connection is
47
+ closed. Default 300.0.
48
+ pool_command_timeout: Per-statement timeout in the pool.
49
+ Default 60.0.
50
+ pool_max_queries: Queries per connection before recycle.
51
+ Default 50 000.
52
+ """
53
+
54
+ database_url: str
55
+ pool_size: int = 10
56
+ pool_max_inactive_conn_lifetime: float = 300.0
57
+ pool_command_timeout: float = 60.0
58
+ pool_max_queries: int = 50_000
59
+ adapter_name: str = "postgres"
@@ -0,0 +1,99 @@
1
+ """
2
+ openframe/adapters/db/postgres/connection.py
3
+ ==============================================
4
+ asyncpg connection pool factory and cache.
5
+
6
+ ``get_postgres_pool()`` is the single entry point for obtaining an asyncpg
7
+ pool. It creates the pool on first call and returns the cached instance on
8
+ every subsequent call with the same ``DATABASE_URL``. Multiple
9
+ ``PostgresRepository`` instances in the same process share the same pool.
10
+
11
+ Pool cache: ``_pool_cache`` is a module-level dict keyed by database URL.
12
+ Do NOT replace this with ``@lru_cache`` — that decorator does not support
13
+ async functions and would create a new coroutine on each call.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ from typing import Any
19
+
20
+ import asyncpg
21
+
22
+ from openframe.core.exceptions import (
23
+ AdapterConfigurationError,
24
+ AdapterConnectionError,
25
+ AdapterTimeoutError,
26
+ )
27
+
28
+ from .config import PostgresSettings
29
+
30
+ __all__ = ["get_postgres_pool", "_pool_cache"]
31
+
32
+ _pool_cache: dict[str, asyncpg.Pool] = {} # type: ignore[type-arg]
33
+
34
+
35
+ async def get_postgres_pool(settings: PostgresSettings) -> asyncpg.Pool: # type: ignore[type-arg]
36
+ """
37
+ Create or return the cached asyncpg connection pool.
38
+
39
+ Creates the pool on first call for a given ``database_url``. Subsequent
40
+ calls with the same URL return the cached pool without re-connecting. The
41
+ pool is shared across all ``PostgresRepository`` instances in the process.
42
+
43
+ Args:
44
+ settings: A fully-validated ``PostgresSettings`` instance.
45
+
46
+ Returns:
47
+ An ``asyncpg.Pool`` that is ready to use.
48
+
49
+ Raises:
50
+ AdapterConnectionError: Pool creation failed — host unreachable,
51
+ bad credentials, TLS error, etc.
52
+ AdapterConfigurationError: ``DATABASE_URL`` is syntactically invalid
53
+ (``asyncpg.InvalidCatalogNameError``).
54
+ AdapterTimeoutError: Pool creation exceeded
55
+ ``settings.connection_timeout``.
56
+ """
57
+ url = settings.database_url
58
+ if url in _pool_cache:
59
+ return _pool_cache[url]
60
+
61
+ try:
62
+ async with asyncio.timeout(settings.connection_timeout):
63
+ pool: asyncpg.Pool = await asyncpg.create_pool( # type: ignore[assignment]
64
+ dsn=url,
65
+ min_size=settings.pool_size,
66
+ max_size=settings.pool_size,
67
+ max_inactive_connection_lifetime=settings.pool_max_inactive_conn_lifetime,
68
+ command_timeout=settings.pool_command_timeout,
69
+ max_queries=settings.pool_max_queries,
70
+ )
71
+ except asyncio.TimeoutError as exc:
72
+ raise AdapterTimeoutError(
73
+ f"Pool creation exceeded {settings.connection_timeout}s connection_timeout",
74
+ adapter=settings.adapter_name,
75
+ operation="connect",
76
+ cause=exc,
77
+ ) from exc
78
+ except asyncpg.InvalidCatalogNameError as exc:
79
+ raise AdapterConfigurationError(
80
+ f"DATABASE_URL references an invalid or non-existent catalog: {url!r}",
81
+ adapter=settings.adapter_name,
82
+ operation="init",
83
+ cause=exc,
84
+ ) from exc
85
+ except (
86
+ asyncpg.InvalidPasswordError,
87
+ asyncpg.CannotConnectNowError,
88
+ asyncpg.TooManyConnectionsError,
89
+ OSError,
90
+ ) as exc:
91
+ raise AdapterConnectionError(
92
+ f"Cannot connect to Postgres at {url!r}: {exc}",
93
+ adapter=settings.adapter_name,
94
+ operation="connect",
95
+ cause=exc,
96
+ ) from exc
97
+
98
+ _pool_cache[url] = pool
99
+ return pool
@@ -0,0 +1,419 @@
1
+ """
2
+ openframe/adapters/db/postgres/repository.py
3
+ ==============================================
4
+ Generic PostgreSQL repository implementing ``BaseRepository[T]`` and
5
+ ``HealthCheck`` from ``openframe-core`` via structural subtyping.
6
+
7
+ The base class works with raw ``dict[str, Any]`` rows. Domain adapters
8
+ subclass it and override ``_row_to_entity()`` / ``_entity_to_row()`` to map
9
+ between rows and typed domain objects.
10
+
11
+ Usage — raw dict mode (no subclassing needed):
12
+
13
+ repo = PostgresRepository(settings, table="items", id_column="id")
14
+ item: dict | None = await repo.get("abc-123")
15
+
16
+ Usage — typed domain mode (subclass):
17
+
18
+ class ItemRepository(PostgresRepository[Item]):
19
+ _table = "items"
20
+ _id_column = "id"
21
+
22
+ def _row_to_entity(self, row: asyncpg.Record) -> Item:
23
+ return Item(**dict(row))
24
+
25
+ def _entity_to_row(self, entity: Item) -> dict[str, Any]:
26
+ return entity.model_dump()
27
+
28
+ Structural conformance (no inheritance from Protocols required):
29
+
30
+ assert isinstance(repo, BaseRepository)
31
+ assert isinstance(repo, HealthCheck)
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ from typing import Any, Generic, TypeVar
37
+
38
+ import asyncpg
39
+
40
+ from openframe.core.exceptions import (
41
+ AdapterConfigurationError,
42
+ AdapterQueryError,
43
+ AdapterTimeoutError,
44
+ )
45
+
46
+ from .config import PostgresSettings
47
+ from .connection import _pool_cache, get_postgres_pool
48
+
49
+ __all__ = ["PostgresRepository"]
50
+
51
+ T = TypeVar("T")
52
+
53
+
54
+ class PostgresRepository(Generic[T]):
55
+ """
56
+ Generic PostgreSQL repository.
57
+
58
+ Implements ``BaseRepository[T]`` and ``HealthCheck`` structurally — no
59
+ inheritance from either Protocol. All asyncpg exceptions are caught and
60
+ re-raised as ``AdapterError`` subclasses. Every operation wraps its
61
+ asyncpg call in ``asyncio.timeout(settings.operation_timeout)``.
62
+
63
+ Class attributes (override in subclass):
64
+ _table: Table name used when no ``table`` argument is passed.
65
+ _id_column: Primary key column. Default ``"id"``.
66
+
67
+ Args:
68
+ settings: A ``PostgresSettings`` instance.
69
+ table: Table name. Overrides the ``_table`` class attribute.
70
+ id_column: PK column name. Overrides the ``_id_column`` class attribute.
71
+
72
+ Raises:
73
+ AdapterConfigurationError: If neither ``table`` param nor ``_table``
74
+ class attribute is set.
75
+ """
76
+
77
+ _table: str = ""
78
+ _id_column: str = "id"
79
+
80
+ def __init__(
81
+ self,
82
+ settings: PostgresSettings,
83
+ table: str | None = None,
84
+ id_column: str | None = None,
85
+ ) -> None:
86
+ self._settings = settings
87
+ self._table = table or self.__class__._table
88
+ self._id_column = id_column or self.__class__._id_column
89
+
90
+ if not self._table:
91
+ raise AdapterConfigurationError(
92
+ "PostgresRepository requires a table name. "
93
+ "Pass table= to __init__ or set _table on the subclass.",
94
+ adapter=settings.adapter_name,
95
+ operation="init",
96
+ )
97
+
98
+ # ------------------------------------------------------------------
99
+ # Row ↔ entity mapping (override in typed subclasses)
100
+ # ------------------------------------------------------------------
101
+
102
+ def _row_to_entity(self, row: asyncpg.Record) -> T: # type: ignore[type-arg]
103
+ """
104
+ Convert an asyncpg Record to the entity type ``T``.
105
+
106
+ Base implementation returns ``dict(row)``. Subclasses override this
107
+ to return typed domain objects.
108
+ """
109
+ return dict(row) # type: ignore[return-value]
110
+
111
+ def _entity_to_row(self, entity: T) -> dict[str, Any]:
112
+ """
113
+ Convert the entity type ``T`` to a column→value dict for SQL.
114
+
115
+ Base implementation returns the entity unchanged if it is already a
116
+ dict, or falls back to ``vars(entity)`` for simple objects. Subclasses
117
+ override this to serialise typed domain objects correctly.
118
+ """
119
+ if isinstance(entity, dict):
120
+ return entity
121
+ return vars(entity)
122
+
123
+ # ------------------------------------------------------------------
124
+ # BaseRepository[T] interface
125
+ # ------------------------------------------------------------------
126
+
127
+ async def get(self, entity_id: str) -> T | None:
128
+ """
129
+ Retrieve a single row by primary key.
130
+
131
+ Args:
132
+ entity_id: Value of the ``_id_column`` to look up.
133
+
134
+ Returns:
135
+ The entity if a matching row exists, ``None`` otherwise.
136
+
137
+ Raises:
138
+ AdapterQueryError: Query failed after connection was established.
139
+ AdapterTimeoutError: Operation exceeded ``operation_timeout``.
140
+ """
141
+ pool = await get_postgres_pool(self._settings)
142
+ query = (
143
+ f"SELECT * FROM {self._table} "
144
+ f"WHERE {self._id_column} = $1 LIMIT 1"
145
+ )
146
+ try:
147
+ async with asyncio.timeout(self._settings.operation_timeout):
148
+ row = await pool.fetchrow(query, entity_id)
149
+ except asyncio.TimeoutError as exc:
150
+ raise AdapterTimeoutError(
151
+ f"get exceeded {self._settings.operation_timeout}s operation_timeout",
152
+ adapter=self._settings.adapter_name,
153
+ operation="get",
154
+ cause=exc,
155
+ ) from exc
156
+ except asyncpg.PostgresError as exc:
157
+ raise AdapterQueryError(
158
+ f"get failed on {self._table}: {exc}",
159
+ adapter=self._settings.adapter_name,
160
+ operation="get",
161
+ cause=exc,
162
+ ) from exc
163
+
164
+ if row is None:
165
+ return None
166
+ return self._row_to_entity(row)
167
+
168
+ async def list(self, limit: int, offset: int) -> tuple[list[T], int]:
169
+ """
170
+ Return a paginated slice of rows and the total row count.
171
+
172
+ Both queries run on a single connection acquired from the pool.
173
+
174
+ Args:
175
+ limit: Maximum number of rows to return.
176
+ offset: Number of rows to skip.
177
+
178
+ Returns:
179
+ A 2-tuple ``(entities, total_count)`` where ``total_count`` is
180
+ the number of all rows in the table (not just the slice).
181
+
182
+ Raises:
183
+ AdapterQueryError: Query failed after connection was established.
184
+ AdapterTimeoutError: Operation exceeded ``operation_timeout``.
185
+ """
186
+ pool = await get_postgres_pool(self._settings)
187
+ rows_query = (
188
+ f"SELECT * FROM {self._table} "
189
+ f"ORDER BY {self._id_column} LIMIT $1 OFFSET $2"
190
+ )
191
+ count_query = f"SELECT COUNT(*) FROM {self._table}"
192
+ try:
193
+ async with asyncio.timeout(self._settings.operation_timeout):
194
+ async with pool.acquire() as conn:
195
+ rows = await conn.fetch(rows_query, limit, offset)
196
+ count: int = await conn.fetchval(count_query)
197
+ except asyncio.TimeoutError as exc:
198
+ raise AdapterTimeoutError(
199
+ f"list exceeded {self._settings.operation_timeout}s operation_timeout",
200
+ adapter=self._settings.adapter_name,
201
+ operation="list",
202
+ cause=exc,
203
+ ) from exc
204
+ except asyncpg.PostgresError as exc:
205
+ raise AdapterQueryError(
206
+ f"list failed on {self._table}: {exc}",
207
+ adapter=self._settings.adapter_name,
208
+ operation="list",
209
+ cause=exc,
210
+ ) from exc
211
+
212
+ entities = [self._row_to_entity(r) for r in rows]
213
+ return entities, count
214
+
215
+ async def create(self, entity: T) -> T:
216
+ """
217
+ Insert a new row and return the stored row (with DB-generated fields).
218
+
219
+ Calls ``_entity_to_row(entity)`` to obtain the column dict, then
220
+ executes an ``INSERT … RETURNING *`` so that database-generated
221
+ fields (e.g. auto-increment PK, ``created_at``) are included in the
222
+ returned entity.
223
+
224
+ Args:
225
+ entity: The entity to insert.
226
+
227
+ Returns:
228
+ The entity as stored, including any backend-assigned fields.
229
+
230
+ Raises:
231
+ AdapterQueryError: Insert failed (e.g. unique-constraint violation).
232
+ AdapterTimeoutError: Operation exceeded ``operation_timeout``.
233
+ """
234
+ pool = await get_postgres_pool(self._settings)
235
+ row_dict = self._entity_to_row(entity)
236
+ columns = list(row_dict.keys())
237
+ values = list(row_dict.values())
238
+ placeholders = ", ".join(f"${i + 1}" for i in range(len(columns)))
239
+ col_list = ", ".join(columns)
240
+ query = (
241
+ f"INSERT INTO {self._table} ({col_list}) "
242
+ f"VALUES ({placeholders}) RETURNING *"
243
+ )
244
+ try:
245
+ async with asyncio.timeout(self._settings.operation_timeout):
246
+ row = await pool.fetchrow(query, *values)
247
+ except asyncio.TimeoutError as exc:
248
+ raise AdapterTimeoutError(
249
+ f"create exceeded {self._settings.operation_timeout}s operation_timeout",
250
+ adapter=self._settings.adapter_name,
251
+ operation="create",
252
+ cause=exc,
253
+ ) from exc
254
+ except asyncpg.PostgresError as exc:
255
+ raise AdapterQueryError(
256
+ f"create failed on {self._table}: {exc}",
257
+ adapter=self._settings.adapter_name,
258
+ operation="create",
259
+ cause=exc,
260
+ ) from exc
261
+
262
+ return self._row_to_entity(row) # type: ignore[arg-type]
263
+
264
+ async def update(self, entity: T) -> T | None:
265
+ """
266
+ Update an existing row and return the stored row.
267
+
268
+ The ``_id_column`` value is extracted from the row dict and used in
269
+ the ``WHERE`` clause. All other columns form the ``SET`` clause.
270
+
271
+ Args:
272
+ entity: The entity with updated fields. Must contain ``_id_column``.
273
+
274
+ Returns:
275
+ The updated entity as stored, or ``None`` if no row was matched.
276
+
277
+ Raises:
278
+ AdapterQueryError: Update failed (e.g. constraint violation).
279
+ AdapterTimeoutError: Operation exceeded ``operation_timeout``.
280
+ """
281
+ pool = await get_postgres_pool(self._settings)
282
+ row_dict = self._entity_to_row(entity)
283
+ entity_id = row_dict.get(self._id_column)
284
+
285
+ update_cols = {k: v for k, v in row_dict.items() if k != self._id_column}
286
+ if not update_cols:
287
+ raise AdapterQueryError(
288
+ f"update on {self._table}: entity has no columns to update "
289
+ f"(only id column {self._id_column!r} found).",
290
+ adapter=self._settings.adapter_name,
291
+ operation="update",
292
+ )
293
+
294
+ set_parts = [
295
+ f"{col} = ${i + 1}" for i, col in enumerate(update_cols.keys())
296
+ ]
297
+ set_clause = ", ".join(set_parts)
298
+ id_placeholder = f"${len(update_cols) + 1}"
299
+ query = (
300
+ f"UPDATE {self._table} SET {set_clause} "
301
+ f"WHERE {self._id_column} = {id_placeholder} RETURNING *"
302
+ )
303
+ values = list(update_cols.values()) + [entity_id]
304
+ try:
305
+ async with asyncio.timeout(self._settings.operation_timeout):
306
+ row = await pool.fetchrow(query, *values)
307
+ except asyncio.TimeoutError as exc:
308
+ raise AdapterTimeoutError(
309
+ f"update exceeded {self._settings.operation_timeout}s operation_timeout",
310
+ adapter=self._settings.adapter_name,
311
+ operation="update",
312
+ cause=exc,
313
+ ) from exc
314
+ except asyncpg.PostgresError as exc:
315
+ raise AdapterQueryError(
316
+ f"update failed on {self._table}: {exc}",
317
+ adapter=self._settings.adapter_name,
318
+ operation="update",
319
+ cause=exc,
320
+ ) from exc
321
+
322
+ if row is None:
323
+ return None
324
+ return self._row_to_entity(row)
325
+
326
+ async def delete(self, entity_id: str) -> bool:
327
+ """
328
+ Delete a row by primary key.
329
+
330
+ Args:
331
+ entity_id: Value of the ``_id_column`` to delete.
332
+
333
+ Returns:
334
+ ``True`` if a row was deleted, ``False`` if no row matched.
335
+
336
+ Raises:
337
+ AdapterQueryError: Deletion failed.
338
+ AdapterTimeoutError: Operation exceeded ``operation_timeout``.
339
+ """
340
+ pool = await get_postgres_pool(self._settings)
341
+ query = f"DELETE FROM {self._table} WHERE {self._id_column} = $1"
342
+ try:
343
+ async with asyncio.timeout(self._settings.operation_timeout):
344
+ status: str = await pool.execute(query, entity_id)
345
+ except asyncio.TimeoutError as exc:
346
+ raise AdapterTimeoutError(
347
+ f"delete exceeded {self._settings.operation_timeout}s operation_timeout",
348
+ adapter=self._settings.adapter_name,
349
+ operation="delete",
350
+ cause=exc,
351
+ ) from exc
352
+ except asyncpg.PostgresError as exc:
353
+ raise AdapterQueryError(
354
+ f"delete failed on {self._table}: {exc}",
355
+ adapter=self._settings.adapter_name,
356
+ operation="delete",
357
+ cause=exc,
358
+ ) from exc
359
+
360
+ # asyncpg returns "DELETE N" where N is the number of deleted rows.
361
+ return status == "DELETE 1"
362
+
363
+ # ------------------------------------------------------------------
364
+ # HealthCheck interface
365
+ # ------------------------------------------------------------------
366
+
367
+ async def ping(self) -> bool:
368
+ """
369
+ Low-cost liveness check — ``SELECT 1`` with a 5-second timeout.
370
+
371
+ Returns:
372
+ ``True`` if the backend responded, ``False`` on any failure.
373
+ Never raises.
374
+ """
375
+ try:
376
+ pool = await get_postgres_pool(self._settings)
377
+ await asyncio.wait_for(pool.fetchval("SELECT 1"), timeout=5.0)
378
+ return True
379
+ except Exception: # noqa: BLE001
380
+ return False
381
+
382
+ async def is_ready(self) -> bool:
383
+ """
384
+ Full readiness check — verifies public schema tables are accessible.
385
+
386
+ Queries ``pg_tables`` to confirm the database connection is healthy
387
+ and the schema is queryable. Uses a 10-second timeout.
388
+
389
+ Returns:
390
+ ``True`` if the database is ready, ``False`` on any failure.
391
+ Never raises.
392
+ """
393
+ try:
394
+ pool = await get_postgres_pool(self._settings)
395
+ await asyncio.wait_for(
396
+ pool.fetchval(
397
+ "SELECT COUNT(*) FROM pg_tables WHERE schemaname = 'public'"
398
+ ),
399
+ timeout=10.0,
400
+ )
401
+ return True
402
+ except Exception: # noqa: BLE001
403
+ return False
404
+
405
+ # ------------------------------------------------------------------
406
+ # Lifecycle
407
+ # ------------------------------------------------------------------
408
+
409
+ async def close(self) -> None:
410
+ """
411
+ Close the connection pool and remove it from the cache.
412
+
413
+ Call once at application shutdown. After this returns the pool is
414
+ closed and a subsequent operation will create a new pool.
415
+ """
416
+ pool = _pool_cache.get(self._settings.database_url)
417
+ if pool is not None:
418
+ await pool.close()
419
+ _pool_cache.pop(self._settings.database_url, None)
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: openframe-adapters-db-postgres
3
+ Version: 1.0.0
4
+ Summary: OpenFrame Microservice Suite — PostgreSQL database adapter.
5
+ License: MIT
6
+ Keywords: asyncpg,hexagonal,microservice,openframe,postgres
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: asyncpg>=0.29
9
+ Requires-Dist: openframe-core<2,>=1.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
12
+ Requires-Dist: pytest-mock>=3.14; extra == 'dev'
13
+ Requires-Dist: pytest>=8.0; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # openframe-adapters-db-postgres
17
+
18
+ PostgreSQL database adapter for the **OpenFrame Microservice Suite**.
19
+
20
+ Part of the `openframe-adapters` monorepo. Implements `BaseRepository[T]` and
21
+ `HealthCheck` from `openframe-core` using `asyncpg`.
22
+
23
+ ---
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pip install openframe-adapters-db-postgres
29
+ ```
30
+
31
+ Required env var:
32
+
33
+ ```
34
+ DATABASE_URL=postgresql://user:password@host:5432/dbname
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Quick start
40
+
41
+ ### Raw dict mode
42
+
43
+ ```python
44
+ from openframe.adapters.db.postgres import PostgresSettings, PostgresRepository
45
+
46
+ settings = PostgresSettings() # reads DATABASE_URL from env
47
+ repo = PostgresRepository(settings, table="items", id_column="id")
48
+
49
+ item = await repo.get("abc-123") # dict | None
50
+ items, total = await repo.list(10, 0) # ([dict, ...], int)
51
+ created = await repo.create({"name": "x"})
52
+ updated = await repo.update({"id": "abc-123", "name": "y"})
53
+ deleted = await repo.delete("abc-123") # bool
54
+ ```
55
+
56
+ ### Typed domain mode
57
+
58
+ ```python
59
+ from dataclasses import dataclass
60
+ from openframe.adapters.db.postgres import PostgresSettings, PostgresRepository
61
+
62
+ @dataclass
63
+ class Item:
64
+ id: str
65
+ name: str
66
+
67
+ class ItemRepository(PostgresRepository[Item]):
68
+ _table = "items"
69
+ _id_column = "id"
70
+
71
+ def _row_to_entity(self, row) -> Item:
72
+ return Item(**dict(row))
73
+
74
+ def _entity_to_row(self, entity: Item) -> dict:
75
+ return {"id": entity.id, "name": entity.name}
76
+
77
+ settings = PostgresSettings()
78
+ repo = ItemRepository(settings)
79
+ item: Item | None = await repo.get("abc-123")
80
+ ```
81
+
82
+ ---
83
+
84
+ ## Configuration
85
+
86
+ All settings are read from environment variables.
87
+
88
+ | Env var | Type | Default | Description |
89
+ |---|---|---|---|
90
+ | `DATABASE_URL` | `str` | **required** | Full asyncpg DSN |
91
+ | `POOL_SIZE` | `int` | `10` | Pool min/max size |
92
+ | `POOL_MAX_INACTIVE_CONN_LIFETIME` | `float` | `300.0` | Idle connection TTL (s) |
93
+ | `POOL_COMMAND_TIMEOUT` | `float` | `60.0` | Per-statement timeout (s) |
94
+ | `POOL_MAX_QUERIES` | `int` | `50000` | Queries per connection before recycle |
95
+ | `CONNECTION_TIMEOUT` | `float` | `30.0` | Pool creation timeout (s) |
96
+ | `OPERATION_TIMEOUT` | `float` | `10.0` | Per-operation timeout (s) |
97
+ | `MAX_RETRIES` | `int` | `3` | Max retry attempts |
98
+
99
+ ---
100
+
101
+ ## Health checks
102
+
103
+ `PostgresRepository` implements the `HealthCheck` protocol from `openframe-core`.
104
+
105
+ ```python
106
+ alive = await repo.ping() # SELECT 1 — fast liveness check
107
+ ready = await repo.is_ready() # pg_tables query — full readiness check
108
+ ```
109
+
110
+ Both methods return `False` on any failure and never raise.
111
+
112
+ ---
113
+
114
+ ## Exception hierarchy
115
+
116
+ All exceptions are `AdapterError` subclasses from `openframe.core.exceptions`.
117
+ Raw `asyncpg` exceptions never escape the adapter.
118
+
119
+ | Situation | Exception |
120
+ |---|---|
121
+ | Cannot connect to Postgres | `AdapterConnectionError` |
122
+ | Invalid `DATABASE_URL` catalog | `AdapterConfigurationError` |
123
+ | Query failed (constraint, syntax, etc.) | `AdapterQueryError` |
124
+ | Entity not found | `AdapterNotFoundError` |
125
+ | Operation exceeded timeout | `AdapterTimeoutError` |
126
+
127
+ ---
128
+
129
+ ## Development
130
+
131
+ ```bash
132
+ # from the package directory
133
+ pip install -e ".[dev]"
134
+ pytest tests/ -v
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Protocol conformance
140
+
141
+ ```python
142
+ from openframe.core.ports import BaseRepository
143
+ from openframe.core.health import HealthCheck
144
+
145
+ repo = PostgresRepository(settings, table="items", id_column="id")
146
+ assert isinstance(repo, BaseRepository) # True — structural check
147
+ assert isinstance(repo, HealthCheck) # True — structural check
148
+ ```
149
+
150
+ No inheritance from either Protocol is required or used.
151
+
152
+ ---
153
+
154
+ ## License
155
+
156
+ MIT
@@ -0,0 +1,7 @@
1
+ openframe/adapters/db/postgres/__init__.py,sha256=XB6XPlXDVXJNNZvvvnbJqmI_dtvfHB6htaiS-c1m9u8,1355
2
+ openframe/adapters/db/postgres/config.py,sha256=jiJEHdVFqmFxiTxrLbpJEOpZNgxK5_YVWqBH2Wu4g-I,2263
3
+ openframe/adapters/db/postgres/connection.py,sha256=BiVXCz6QJr0wSwOuvNu7YwCRs8UIPDJpatcPECyPRsc,3592
4
+ openframe/adapters/db/postgres/repository.py,sha256=JCVP_OiJbGol_u5iSPbuNJRJb1rm0e3tT03IFiLXl-8,15438
5
+ openframe_adapters_db_postgres-1.0.0.dist-info/METADATA,sha256=_EZBlpZ5N6x4KCdpa2PTf3crS8gywCUqxE6-AKfmZVA,4061
6
+ openframe_adapters_db_postgres-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
7
+ openframe_adapters_db_postgres-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any