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.
- openframe/adapters/db/postgres/__init__.py +47 -0
- openframe/adapters/db/postgres/config.py +59 -0
- openframe/adapters/db/postgres/connection.py +99 -0
- openframe/adapters/db/postgres/repository.py +419 -0
- openframe_adapters_db_postgres-1.0.0.dist-info/METADATA +156 -0
- openframe_adapters_db_postgres-1.0.0.dist-info/RECORD +7 -0
- openframe_adapters_db_postgres-1.0.0.dist-info/WHEEL +4 -0
|
@@ -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,,
|