macss-modular-api-postgres 0.4.7__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.
- macss_modular_api_postgres-0.4.7/PKG-INFO +65 -0
- macss_modular_api_postgres-0.4.7/README.md +41 -0
- macss_modular_api_postgres-0.4.7/pyproject.toml +46 -0
- macss_modular_api_postgres-0.4.7/setup.cfg +4 -0
- macss_modular_api_postgres-0.4.7/src/macss_modular_api_postgres.egg-info/PKG-INFO +65 -0
- macss_modular_api_postgres-0.4.7/src/macss_modular_api_postgres.egg-info/SOURCES.txt +11 -0
- macss_modular_api_postgres-0.4.7/src/macss_modular_api_postgres.egg-info/dependency_links.txt +1 -0
- macss_modular_api_postgres-0.4.7/src/macss_modular_api_postgres.egg-info/requires.txt +3 -0
- macss_modular_api_postgres-0.4.7/src/macss_modular_api_postgres.egg-info/top_level.txt +1 -0
- macss_modular_api_postgres-0.4.7/src/modular_api_postgres/__init__.py +53 -0
- macss_modular_api_postgres-0.4.7/src/modular_api_postgres/db_client.py +425 -0
- macss_modular_api_postgres-0.4.7/tests/test_db_client.py +478 -0
- macss_modular_api_postgres-0.4.7/tests/test_db_conformance.py +68 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: macss-modular-api-postgres
|
|
3
|
+
Version: 0.4.7
|
|
4
|
+
Summary: Official MACSS Postgres integration package for Python.
|
|
5
|
+
Author: ccisne.dev
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/macss-dev/modular_api
|
|
8
|
+
Project-URL: Repository, https://github.com/macss-dev/modular_api/tree/main/code/py/modular_api_postgres
|
|
9
|
+
Project-URL: Issues, https://github.com/macss-dev/modular_api/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/macss-dev/modular_api/tree/main/code/py/modular_api_postgres#readme
|
|
11
|
+
Keywords: macss,postgres,database
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Database
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
24
|
+
|
|
25
|
+
# macss-modular-api-postgres
|
|
26
|
+
|
|
27
|
+
Official MACSS Postgres integration package for Python.
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from modular_api_postgres import DbClient, DbCommand, DbCommandKind, DbConnectionSettings
|
|
33
|
+
|
|
34
|
+
settings = DbConnectionSettings.from_environment()
|
|
35
|
+
|
|
36
|
+
client = DbClient(
|
|
37
|
+
settings=settings,
|
|
38
|
+
session_provider=my_session_provider,
|
|
39
|
+
command_executor=my_command_executor,
|
|
40
|
+
transaction_runner=my_transaction_runner,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
result = client.scalar(
|
|
44
|
+
DbCommand(
|
|
45
|
+
kind=DbCommandKind.SCALAR,
|
|
46
|
+
text="select count(*) from users",
|
|
47
|
+
label="users.count",
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if result.is_success:
|
|
52
|
+
print(result.value.value)
|
|
53
|
+
else:
|
|
54
|
+
print(result.failure.message)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
See [example/example.py](example/example.py) for a complete in-memory wiring sample.
|
|
58
|
+
|
|
59
|
+
## Current slice
|
|
60
|
+
|
|
61
|
+
- normalized Postgres connection defaults and redacted summaries
|
|
62
|
+
- engine-agnostic `DbClient`, `DbRepository`, and transaction contracts
|
|
63
|
+
- explicit lease ownership semantics for package-owned and application-owned sessions
|
|
64
|
+
- health contributor and GraphQL support bundle for higher-level integrations
|
|
65
|
+
- real driver bindings intentionally remain outside this first slice
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# macss-modular-api-postgres
|
|
2
|
+
|
|
3
|
+
Official MACSS Postgres integration package for Python.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from modular_api_postgres import DbClient, DbCommand, DbCommandKind, DbConnectionSettings
|
|
9
|
+
|
|
10
|
+
settings = DbConnectionSettings.from_environment()
|
|
11
|
+
|
|
12
|
+
client = DbClient(
|
|
13
|
+
settings=settings,
|
|
14
|
+
session_provider=my_session_provider,
|
|
15
|
+
command_executor=my_command_executor,
|
|
16
|
+
transaction_runner=my_transaction_runner,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
result = client.scalar(
|
|
20
|
+
DbCommand(
|
|
21
|
+
kind=DbCommandKind.SCALAR,
|
|
22
|
+
text="select count(*) from users",
|
|
23
|
+
label="users.count",
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if result.is_success:
|
|
28
|
+
print(result.value.value)
|
|
29
|
+
else:
|
|
30
|
+
print(result.failure.message)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
See [example/example.py](example/example.py) for a complete in-memory wiring sample.
|
|
34
|
+
|
|
35
|
+
## Current slice
|
|
36
|
+
|
|
37
|
+
- normalized Postgres connection defaults and redacted summaries
|
|
38
|
+
- engine-agnostic `DbClient`, `DbRepository`, and transaction contracts
|
|
39
|
+
- explicit lease ownership semantics for package-owned and application-owned sessions
|
|
40
|
+
- health contributor and GraphQL support bundle for higher-level integrations
|
|
41
|
+
- real driver bindings intentionally remain outside this first slice
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "macss-modular-api-postgres"
|
|
7
|
+
version = "0.4.7"
|
|
8
|
+
description = "Official MACSS Postgres integration package for Python."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "ccisne.dev" }]
|
|
13
|
+
keywords = ["macss", "postgres", "database"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Database",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = [
|
|
28
|
+
"pytest>=8.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/macss-dev/modular_api"
|
|
33
|
+
Repository = "https://github.com/macss-dev/modular_api/tree/main/code/py/modular_api_postgres"
|
|
34
|
+
Issues = "https://github.com/macss-dev/modular_api/issues"
|
|
35
|
+
Documentation = "https://github.com/macss-dev/modular_api/tree/main/code/py/modular_api_postgres#readme"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["src"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
pythonpath = ["src"]
|
|
43
|
+
|
|
44
|
+
[tool.pyright]
|
|
45
|
+
pythonVersion = "3.11"
|
|
46
|
+
typeCheckingMode = "strict"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: macss-modular-api-postgres
|
|
3
|
+
Version: 0.4.7
|
|
4
|
+
Summary: Official MACSS Postgres integration package for Python.
|
|
5
|
+
Author: ccisne.dev
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/macss-dev/modular_api
|
|
8
|
+
Project-URL: Repository, https://github.com/macss-dev/modular_api/tree/main/code/py/modular_api_postgres
|
|
9
|
+
Project-URL: Issues, https://github.com/macss-dev/modular_api/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/macss-dev/modular_api/tree/main/code/py/modular_api_postgres#readme
|
|
11
|
+
Keywords: macss,postgres,database
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Database
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
24
|
+
|
|
25
|
+
# macss-modular-api-postgres
|
|
26
|
+
|
|
27
|
+
Official MACSS Postgres integration package for Python.
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from modular_api_postgres import DbClient, DbCommand, DbCommandKind, DbConnectionSettings
|
|
33
|
+
|
|
34
|
+
settings = DbConnectionSettings.from_environment()
|
|
35
|
+
|
|
36
|
+
client = DbClient(
|
|
37
|
+
settings=settings,
|
|
38
|
+
session_provider=my_session_provider,
|
|
39
|
+
command_executor=my_command_executor,
|
|
40
|
+
transaction_runner=my_transaction_runner,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
result = client.scalar(
|
|
44
|
+
DbCommand(
|
|
45
|
+
kind=DbCommandKind.SCALAR,
|
|
46
|
+
text="select count(*) from users",
|
|
47
|
+
label="users.count",
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if result.is_success:
|
|
52
|
+
print(result.value.value)
|
|
53
|
+
else:
|
|
54
|
+
print(result.failure.message)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
See [example/example.py](example/example.py) for a complete in-memory wiring sample.
|
|
58
|
+
|
|
59
|
+
## Current slice
|
|
60
|
+
|
|
61
|
+
- normalized Postgres connection defaults and redacted summaries
|
|
62
|
+
- engine-agnostic `DbClient`, `DbRepository`, and transaction contracts
|
|
63
|
+
- explicit lease ownership semantics for package-owned and application-owned sessions
|
|
64
|
+
- health contributor and GraphQL support bundle for higher-level integrations
|
|
65
|
+
- real driver bindings intentionally remain outside this first slice
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/macss_modular_api_postgres.egg-info/PKG-INFO
|
|
4
|
+
src/macss_modular_api_postgres.egg-info/SOURCES.txt
|
|
5
|
+
src/macss_modular_api_postgres.egg-info/dependency_links.txt
|
|
6
|
+
src/macss_modular_api_postgres.egg-info/requires.txt
|
|
7
|
+
src/macss_modular_api_postgres.egg-info/top_level.txt
|
|
8
|
+
src/modular_api_postgres/__init__.py
|
|
9
|
+
src/modular_api_postgres/db_client.py
|
|
10
|
+
tests/test_db_client.py
|
|
11
|
+
tests/test_db_conformance.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
modular_api_postgres
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Public package exports for modular_api_postgres."""
|
|
2
|
+
|
|
3
|
+
from .db_client import (
|
|
4
|
+
DbClient,
|
|
5
|
+
DbCommand,
|
|
6
|
+
DbCommandExecutor,
|
|
7
|
+
DbCommandKind,
|
|
8
|
+
DbConnectionSettings,
|
|
9
|
+
DbExecutionMetadata,
|
|
10
|
+
DbExecutionSummary,
|
|
11
|
+
DbFailure,
|
|
12
|
+
DbFailureKind,
|
|
13
|
+
DbGraphqlSupport,
|
|
14
|
+
DbHealthContributor,
|
|
15
|
+
DbHealthReport,
|
|
16
|
+
DbHealthStatus,
|
|
17
|
+
DbProviderDescription,
|
|
18
|
+
DbRepository,
|
|
19
|
+
DbRepositoryContext,
|
|
20
|
+
DbResult,
|
|
21
|
+
DbRowSet,
|
|
22
|
+
DbScalar,
|
|
23
|
+
DbSessionLease,
|
|
24
|
+
DbSessionProvider,
|
|
25
|
+
DbTransactionContext,
|
|
26
|
+
DbTransactionRunner,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"DbClient",
|
|
31
|
+
"DbCommand",
|
|
32
|
+
"DbCommandExecutor",
|
|
33
|
+
"DbCommandKind",
|
|
34
|
+
"DbConnectionSettings",
|
|
35
|
+
"DbExecutionMetadata",
|
|
36
|
+
"DbExecutionSummary",
|
|
37
|
+
"DbFailure",
|
|
38
|
+
"DbFailureKind",
|
|
39
|
+
"DbGraphqlSupport",
|
|
40
|
+
"DbHealthContributor",
|
|
41
|
+
"DbHealthReport",
|
|
42
|
+
"DbHealthStatus",
|
|
43
|
+
"DbProviderDescription",
|
|
44
|
+
"DbRepository",
|
|
45
|
+
"DbRepositoryContext",
|
|
46
|
+
"DbResult",
|
|
47
|
+
"DbRowSet",
|
|
48
|
+
"DbScalar",
|
|
49
|
+
"DbSessionLease",
|
|
50
|
+
"DbSessionProvider",
|
|
51
|
+
"DbTransactionContext",
|
|
52
|
+
"DbTransactionRunner",
|
|
53
|
+
]
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Callable, Generic, Mapping, Protocol, TypeVar, cast
|
|
8
|
+
|
|
9
|
+
S = TypeVar("S")
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
R = TypeVar("R")
|
|
12
|
+
_MISSING = object()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DbCommandKind(str, Enum):
|
|
16
|
+
QUERY = "query"
|
|
17
|
+
EXECUTE = "execute"
|
|
18
|
+
BATCH = "batch"
|
|
19
|
+
SCALAR = "scalar"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DbFailureKind(str, Enum):
|
|
23
|
+
CONNECTIVITY = "connectivity"
|
|
24
|
+
TIMEOUT = "timeout"
|
|
25
|
+
AUTHENTICATION = "authentication"
|
|
26
|
+
AUTHORIZATION = "authorization"
|
|
27
|
+
CONSTRAINT = "constraint"
|
|
28
|
+
CONFLICT = "conflict"
|
|
29
|
+
NOT_FOUND = "not_found"
|
|
30
|
+
SERIALIZATION = "serialization"
|
|
31
|
+
CANCELLED = "cancelled"
|
|
32
|
+
UNKNOWN = "unknown"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DbHealthStatus(str, Enum):
|
|
36
|
+
HEALTHY = "healthy"
|
|
37
|
+
UNHEALTHY = "unhealthy"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class DbConnectionSettings:
|
|
42
|
+
host: str
|
|
43
|
+
port: int
|
|
44
|
+
database: str
|
|
45
|
+
username: str
|
|
46
|
+
password: str
|
|
47
|
+
ssl_mode: str
|
|
48
|
+
options: Mapping[str, object] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_environment(
|
|
52
|
+
cls,
|
|
53
|
+
environment: Mapping[str, str] | None = None,
|
|
54
|
+
) -> DbConnectionSettings:
|
|
55
|
+
values = os.environ if environment is None else environment
|
|
56
|
+
raw_port = values.get("MODULAR_API_POSTGRES_PORT", "")
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
port = int(raw_port)
|
|
60
|
+
except ValueError:
|
|
61
|
+
port = 5432
|
|
62
|
+
|
|
63
|
+
return cls(
|
|
64
|
+
host=values.get("MODULAR_API_POSTGRES_HOST", "127.0.0.1"),
|
|
65
|
+
port=port,
|
|
66
|
+
database=values.get("MODULAR_API_POSTGRES_DATABASE", "modular_api_graphql_v1"),
|
|
67
|
+
username=values.get("MODULAR_API_POSTGRES_USERNAME", "postgres"),
|
|
68
|
+
password=values.get("MODULAR_API_POSTGRES_PASSWORD", "postgres"),
|
|
69
|
+
ssl_mode=values.get("MODULAR_API_POSTGRES_SSLMODE", "disable"),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def engine_id(self) -> str:
|
|
74
|
+
return "postgres"
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def redacted_summary(self) -> str:
|
|
78
|
+
return (
|
|
79
|
+
f"{self.engine_id}://{self.username}@{self.host}:{self.port}/"
|
|
80
|
+
f"{self.database}?sslmode={self.ssl_mode}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class DbCommand:
|
|
86
|
+
kind: DbCommandKind
|
|
87
|
+
text: str
|
|
88
|
+
parameters: tuple[object, ...] = field(default_factory=tuple)
|
|
89
|
+
label: str | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class DbExecutionMetadata:
|
|
94
|
+
duration: int
|
|
95
|
+
command_label: str | None = None
|
|
96
|
+
row_count: int | None = None
|
|
97
|
+
affected_count: int | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class DbRowSet:
|
|
102
|
+
rows: list[Mapping[str, object]]
|
|
103
|
+
metadata: DbExecutionMetadata
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class DbExecutionSummary:
|
|
108
|
+
affected_count: int
|
|
109
|
+
metadata: DbExecutionMetadata
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True, slots=True)
|
|
113
|
+
class DbScalar(Generic[T]):
|
|
114
|
+
value: T
|
|
115
|
+
metadata: DbExecutionMetadata
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True)
|
|
119
|
+
class DbFailure:
|
|
120
|
+
kind: DbFailureKind
|
|
121
|
+
code: str
|
|
122
|
+
message: str
|
|
123
|
+
retryable: bool
|
|
124
|
+
transient: bool
|
|
125
|
+
details: object | None = None
|
|
126
|
+
cause_summary: str | None = None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class DbResult(Generic[T]):
|
|
130
|
+
def __init__(self, value: object = _MISSING, failure: DbFailure | None = None) -> None:
|
|
131
|
+
self._value = value
|
|
132
|
+
self._failure = failure
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def success(cls, value: T) -> DbResult[T]:
|
|
136
|
+
return cls(value=value)
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def from_failure(cls, failure: DbFailure) -> DbResult[T]:
|
|
140
|
+
return cls(failure=failure)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def is_success(self) -> bool:
|
|
144
|
+
return self._failure is None
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def is_failure(self) -> bool:
|
|
148
|
+
return self._failure is not None
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def value(self) -> T:
|
|
152
|
+
if self._failure is not None or self._value is _MISSING:
|
|
153
|
+
raise RuntimeError("DbResult does not contain a success value.")
|
|
154
|
+
return cast(T, self._value)
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def failure(self) -> DbFailure:
|
|
158
|
+
if self._failure is None:
|
|
159
|
+
raise RuntimeError("DbResult does not contain a failure value.")
|
|
160
|
+
return self._failure
|
|
161
|
+
|
|
162
|
+
def map(self, transform: Callable[[T], R]) -> DbResult[R]:
|
|
163
|
+
if self.is_failure:
|
|
164
|
+
return DbResult.from_failure(self.failure)
|
|
165
|
+
return DbResult.success(transform(self.value))
|
|
166
|
+
|
|
167
|
+
def flat_map(self, transform: Callable[[T], DbResult[R]]) -> DbResult[R]:
|
|
168
|
+
if self.is_failure:
|
|
169
|
+
return DbResult.from_failure(self.failure)
|
|
170
|
+
return transform(self.value)
|
|
171
|
+
|
|
172
|
+
def map_failure(self, transform: Callable[[DbFailure], DbFailure]) -> DbResult[T]:
|
|
173
|
+
if self.is_success:
|
|
174
|
+
return DbResult.success(self.value)
|
|
175
|
+
return DbResult.from_failure(transform(self.failure))
|
|
176
|
+
|
|
177
|
+
def get_or_throw(self, message: str | None = None) -> T:
|
|
178
|
+
if self.is_failure:
|
|
179
|
+
raise RuntimeError(message or self.failure.message)
|
|
180
|
+
return self.value
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True, slots=True)
|
|
184
|
+
class DbProviderDescription:
|
|
185
|
+
engine_id: str
|
|
186
|
+
database: str
|
|
187
|
+
redacted_summary: str
|
|
188
|
+
owns_resources: bool
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class DbSessionLease(Generic[S]):
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
*,
|
|
195
|
+
session: S,
|
|
196
|
+
owned_by_package: bool,
|
|
197
|
+
releaser: Callable[[], DbResult[None]],
|
|
198
|
+
) -> None:
|
|
199
|
+
self.session = session
|
|
200
|
+
self.owned_by_package = owned_by_package
|
|
201
|
+
self._releaser = releaser
|
|
202
|
+
|
|
203
|
+
def release(self) -> DbResult[None]:
|
|
204
|
+
if not self.owned_by_package:
|
|
205
|
+
return DbResult.success(None)
|
|
206
|
+
return self._releaser()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class DbSessionProvider(Protocol[S]):
|
|
210
|
+
def acquire(self) -> DbResult[DbSessionLease[S]]: ...
|
|
211
|
+
|
|
212
|
+
def close(self) -> DbResult[None]: ...
|
|
213
|
+
|
|
214
|
+
def describe(self) -> DbProviderDescription: ...
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class DbCommandExecutor(Protocol[S]):
|
|
218
|
+
def query(self, session: S, command: DbCommand) -> DbResult[DbRowSet]: ...
|
|
219
|
+
|
|
220
|
+
def execute(self, session: S, command: DbCommand) -> DbResult[DbExecutionSummary]: ...
|
|
221
|
+
|
|
222
|
+
def scalar(self, session: S, command: DbCommand) -> DbResult[DbScalar[object]]: ...
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass(frozen=True, slots=True)
|
|
226
|
+
class DbTransactionContext(Generic[S]):
|
|
227
|
+
settings: DbConnectionSettings
|
|
228
|
+
session: S
|
|
229
|
+
command_executor: DbCommandExecutor[S]
|
|
230
|
+
|
|
231
|
+
def query(self, command: DbCommand) -> DbResult[DbRowSet]:
|
|
232
|
+
return self.command_executor.query(self.session, command)
|
|
233
|
+
|
|
234
|
+
def execute(self, command: DbCommand) -> DbResult[DbExecutionSummary]:
|
|
235
|
+
return self.command_executor.execute(self.session, command)
|
|
236
|
+
|
|
237
|
+
def scalar(self, command: DbCommand) -> DbResult[DbScalar[T]]:
|
|
238
|
+
return cast(DbResult[DbScalar[T]], self.command_executor.scalar(self.session, command))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class DbTransactionRunner(Protocol[S]):
|
|
242
|
+
def run(
|
|
243
|
+
self,
|
|
244
|
+
context: DbTransactionContext[S],
|
|
245
|
+
body: Callable[[DbTransactionContext[S]], DbResult[T]],
|
|
246
|
+
) -> DbResult[T]: ...
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass(frozen=True, slots=True)
|
|
250
|
+
class DbRepositoryContext(Generic[S]):
|
|
251
|
+
settings: DbConnectionSettings
|
|
252
|
+
session_provider: DbSessionProvider[S]
|
|
253
|
+
command_executor: DbCommandExecutor[S]
|
|
254
|
+
transaction_runner: DbTransactionRunner[S]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class DbRepository(Generic[S]):
|
|
258
|
+
def __init__(self, context: DbRepositoryContext[S]) -> None:
|
|
259
|
+
self.context = context
|
|
260
|
+
|
|
261
|
+
def query(self, command: DbCommand) -> DbResult[DbRowSet]:
|
|
262
|
+
return _with_lease(
|
|
263
|
+
self.context.session_provider,
|
|
264
|
+
lambda lease: self.context.command_executor.query(lease.session, command),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def execute(self, command: DbCommand) -> DbResult[DbExecutionSummary]:
|
|
268
|
+
return _with_lease(
|
|
269
|
+
self.context.session_provider,
|
|
270
|
+
lambda lease: self.context.command_executor.execute(lease.session, command),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def scalar(self, command: DbCommand) -> DbResult[DbScalar[T]]:
|
|
274
|
+
return cast(
|
|
275
|
+
DbResult[DbScalar[T]],
|
|
276
|
+
_with_lease(
|
|
277
|
+
self.context.session_provider,
|
|
278
|
+
lambda lease: self.context.command_executor.scalar(lease.session, command),
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def transaction(self, body: Callable[[DbTransactionContext[S]], DbResult[T]]) -> DbResult[T]:
|
|
283
|
+
client = DbClient(
|
|
284
|
+
settings=self.context.settings,
|
|
285
|
+
session_provider=self.context.session_provider,
|
|
286
|
+
command_executor=self.context.command_executor,
|
|
287
|
+
transaction_runner=self.context.transaction_runner,
|
|
288
|
+
)
|
|
289
|
+
return client.transaction(body)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class DbClient(Generic[S]):
|
|
293
|
+
def __init__(
|
|
294
|
+
self,
|
|
295
|
+
*,
|
|
296
|
+
settings: DbConnectionSettings,
|
|
297
|
+
session_provider: DbSessionProvider[S],
|
|
298
|
+
command_executor: DbCommandExecutor[S],
|
|
299
|
+
transaction_runner: DbTransactionRunner[S],
|
|
300
|
+
) -> None:
|
|
301
|
+
self.settings = settings
|
|
302
|
+
self.session_provider = session_provider
|
|
303
|
+
self.command_executor = command_executor
|
|
304
|
+
self.transaction_runner = transaction_runner
|
|
305
|
+
|
|
306
|
+
def query(self, command: DbCommand) -> DbResult[DbRowSet]:
|
|
307
|
+
return _with_lease(
|
|
308
|
+
self.session_provider,
|
|
309
|
+
lambda lease: self.command_executor.query(lease.session, command),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def execute(self, command: DbCommand) -> DbResult[DbExecutionSummary]:
|
|
313
|
+
return _with_lease(
|
|
314
|
+
self.session_provider,
|
|
315
|
+
lambda lease: self.command_executor.execute(lease.session, command),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def scalar(self, command: DbCommand) -> DbResult[DbScalar[T]]:
|
|
319
|
+
return cast(
|
|
320
|
+
DbResult[DbScalar[T]],
|
|
321
|
+
_with_lease(
|
|
322
|
+
self.session_provider,
|
|
323
|
+
lambda lease: self.command_executor.scalar(lease.session, command),
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def transaction(self, body: Callable[[DbTransactionContext[S]], DbResult[T]]) -> DbResult[T]:
|
|
328
|
+
lease_result = self.session_provider.acquire()
|
|
329
|
+
if lease_result.is_failure:
|
|
330
|
+
return DbResult.from_failure(lease_result.failure)
|
|
331
|
+
|
|
332
|
+
lease = lease_result.value
|
|
333
|
+
context = DbTransactionContext(
|
|
334
|
+
settings=self.settings,
|
|
335
|
+
session=lease.session,
|
|
336
|
+
command_executor=self.command_executor,
|
|
337
|
+
)
|
|
338
|
+
result = self.transaction_runner.run(context, body)
|
|
339
|
+
release_result = lease.release()
|
|
340
|
+
|
|
341
|
+
if result.is_failure:
|
|
342
|
+
return DbResult.from_failure(result.failure)
|
|
343
|
+
if release_result.is_failure:
|
|
344
|
+
return DbResult.from_failure(release_result.failure)
|
|
345
|
+
return result
|
|
346
|
+
|
|
347
|
+
def repository_context(self) -> DbRepositoryContext[S]:
|
|
348
|
+
return DbRepositoryContext(
|
|
349
|
+
settings=self.settings,
|
|
350
|
+
session_provider=self.session_provider,
|
|
351
|
+
command_executor=self.command_executor,
|
|
352
|
+
transaction_runner=self.transaction_runner,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def close(self) -> DbResult[None]:
|
|
356
|
+
return self.session_provider.close()
|
|
357
|
+
|
|
358
|
+
def describe(self) -> DbProviderDescription:
|
|
359
|
+
return self.session_provider.describe()
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@dataclass(frozen=True, slots=True)
|
|
363
|
+
class DbHealthReport:
|
|
364
|
+
status: DbHealthStatus
|
|
365
|
+
response_time: int
|
|
366
|
+
redacted_summary: str
|
|
367
|
+
details: str | None = None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class DbHealthContributor(Generic[S]):
|
|
371
|
+
def __init__(self, *, client: DbClient[S], probe_command: DbCommand | None = None) -> None:
|
|
372
|
+
self.client = client
|
|
373
|
+
self.probe_command = probe_command or DbCommand(
|
|
374
|
+
kind=DbCommandKind.SCALAR,
|
|
375
|
+
text="SELECT 1",
|
|
376
|
+
label="db.health",
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def probe(self) -> DbHealthReport:
|
|
380
|
+
started_at = time.monotonic()
|
|
381
|
+
result = self.client.scalar(self.probe_command)
|
|
382
|
+
response_time = int((time.monotonic() - started_at) * 1000)
|
|
383
|
+
|
|
384
|
+
if result.is_success:
|
|
385
|
+
return DbHealthReport(
|
|
386
|
+
status=DbHealthStatus.HEALTHY,
|
|
387
|
+
response_time=response_time,
|
|
388
|
+
redacted_summary=self.client.describe().redacted_summary,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
return DbHealthReport(
|
|
392
|
+
status=DbHealthStatus.UNHEALTHY,
|
|
393
|
+
response_time=response_time,
|
|
394
|
+
redacted_summary=self.client.describe().redacted_summary,
|
|
395
|
+
details=result.failure.code,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@dataclass(frozen=True, slots=True)
|
|
400
|
+
class DbGraphqlSupport(Generic[S]):
|
|
401
|
+
catalog_provider: object
|
|
402
|
+
read_executor: object
|
|
403
|
+
health_contributor: DbHealthContributor[S]
|
|
404
|
+
source_digest_factory: object | None = None
|
|
405
|
+
artifact_loader: object | None = None
|
|
406
|
+
capability_registration: object | None = None
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _with_lease(
|
|
410
|
+
session_provider: DbSessionProvider[S],
|
|
411
|
+
operation: Callable[[DbSessionLease[S]], DbResult[T]],
|
|
412
|
+
) -> DbResult[T]:
|
|
413
|
+
lease_result = session_provider.acquire()
|
|
414
|
+
if lease_result.is_failure:
|
|
415
|
+
return DbResult.from_failure(lease_result.failure)
|
|
416
|
+
|
|
417
|
+
lease = lease_result.value
|
|
418
|
+
operation_result = operation(lease)
|
|
419
|
+
release_result = lease.release()
|
|
420
|
+
|
|
421
|
+
if operation_result.is_failure:
|
|
422
|
+
return DbResult.from_failure(operation_result.failure)
|
|
423
|
+
if release_result.is_failure:
|
|
424
|
+
return DbResult.from_failure(release_result.failure)
|
|
425
|
+
return operation_result
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from modular_api_postgres import (
|
|
8
|
+
DbClient,
|
|
9
|
+
DbCommand,
|
|
10
|
+
DbCommandKind,
|
|
11
|
+
DbConnectionSettings,
|
|
12
|
+
DbExecutionMetadata,
|
|
13
|
+
DbExecutionSummary,
|
|
14
|
+
DbFailure,
|
|
15
|
+
DbFailureKind,
|
|
16
|
+
DbGraphqlSupport,
|
|
17
|
+
DbHealthContributor,
|
|
18
|
+
DbHealthStatus,
|
|
19
|
+
DbProviderDescription,
|
|
20
|
+
DbRepository,
|
|
21
|
+
DbRepositoryContext,
|
|
22
|
+
DbResult,
|
|
23
|
+
DbRowSet,
|
|
24
|
+
DbScalar,
|
|
25
|
+
DbSessionLease,
|
|
26
|
+
DbTransactionContext,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_connection_settings_normalizes_environment_defaults_and_redacts_secrets() -> None:
|
|
31
|
+
settings = DbConnectionSettings.from_environment(
|
|
32
|
+
{
|
|
33
|
+
"MODULAR_API_POSTGRES_HOST": "db.local",
|
|
34
|
+
"MODULAR_API_POSTGRES_PASSWORD": "super-secret",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
assert settings.engine_id == "postgres"
|
|
39
|
+
assert settings.host == "db.local"
|
|
40
|
+
assert settings.port == 5432
|
|
41
|
+
assert settings.database == "modular_api_graphql_v1"
|
|
42
|
+
assert settings.username == "postgres"
|
|
43
|
+
assert settings.password == "super-secret"
|
|
44
|
+
assert settings.ssl_mode == "disable"
|
|
45
|
+
assert "db.local:5432" in settings.redacted_summary
|
|
46
|
+
assert "postgres@" in settings.redacted_summary
|
|
47
|
+
assert "sslmode=disable" in settings.redacted_summary
|
|
48
|
+
assert "super-secret" not in settings.redacted_summary
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_db_result_supports_map_flat_map_map_failure_and_get_or_throw() -> None:
|
|
52
|
+
success = DbResult.success(21)
|
|
53
|
+
failure = DbResult.from_failure(
|
|
54
|
+
DbFailure(
|
|
55
|
+
kind=DbFailureKind.TIMEOUT,
|
|
56
|
+
code="timeout",
|
|
57
|
+
message="Timed out",
|
|
58
|
+
retryable=True,
|
|
59
|
+
transient=True,
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
assert success.map(lambda value: value * 2).value == 42
|
|
64
|
+
assert success.flat_map(lambda value: DbResult.success(value + 1)).value == 22
|
|
65
|
+
|
|
66
|
+
mapped_failure = failure.map_failure(
|
|
67
|
+
lambda current: DbFailure(
|
|
68
|
+
kind=current.kind,
|
|
69
|
+
code="wrapped_timeout",
|
|
70
|
+
message=current.message,
|
|
71
|
+
retryable=current.retryable,
|
|
72
|
+
transient=current.transient,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
assert mapped_failure.failure.code == "wrapped_timeout"
|
|
77
|
+
assert success.get_or_throw() == 21
|
|
78
|
+
with pytest.raises(RuntimeError):
|
|
79
|
+
failure.get_or_throw()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_client_delegates_query_calls_and_releases_package_owned_leases() -> None:
|
|
83
|
+
settings = DbConnectionSettings.from_environment()
|
|
84
|
+
provider = _FakeSessionProvider(settings, session="db-session")
|
|
85
|
+
executor = _FakeCommandExecutor(
|
|
86
|
+
row_set=DbRowSet(
|
|
87
|
+
rows=[{"id": 1}],
|
|
88
|
+
metadata=DbExecutionMetadata(
|
|
89
|
+
duration=3,
|
|
90
|
+
command_label="users.list",
|
|
91
|
+
row_count=1,
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
client = DbClient(
|
|
96
|
+
settings=settings,
|
|
97
|
+
session_provider=provider,
|
|
98
|
+
command_executor=executor,
|
|
99
|
+
transaction_runner=_FakeTransactionRunner(),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
result = client.query(
|
|
103
|
+
DbCommand(
|
|
104
|
+
kind=DbCommandKind.QUERY,
|
|
105
|
+
text="select id from users",
|
|
106
|
+
label="users.list",
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
assert result.is_success is True
|
|
111
|
+
assert result.value.rows == [{"id": 1}]
|
|
112
|
+
assert result.value.metadata.row_count == 1
|
|
113
|
+
assert provider.acquire_count == 1
|
|
114
|
+
assert provider.release_count == 1
|
|
115
|
+
assert executor.last_session == "db-session"
|
|
116
|
+
assert executor.last_command.label == "users.list"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_client_returns_a_failure_when_session_acquisition_fails() -> None:
|
|
120
|
+
settings = DbConnectionSettings.from_environment()
|
|
121
|
+
provider = _FakeSessionProvider(
|
|
122
|
+
settings,
|
|
123
|
+
acquire_failure=DbFailure(
|
|
124
|
+
kind=DbFailureKind.CONNECTIVITY,
|
|
125
|
+
code="connect_failed",
|
|
126
|
+
message="Could not connect",
|
|
127
|
+
retryable=True,
|
|
128
|
+
transient=True,
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
client = DbClient(
|
|
132
|
+
settings=settings,
|
|
133
|
+
session_provider=provider,
|
|
134
|
+
command_executor=_FakeCommandExecutor(),
|
|
135
|
+
transaction_runner=_FakeTransactionRunner(),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
result = client.query(DbCommand(kind=DbCommandKind.QUERY, text="select 1"))
|
|
139
|
+
|
|
140
|
+
assert result.is_failure is True
|
|
141
|
+
assert result.failure.code == "connect_failed"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_client_returns_a_failure_when_releasing_a_package_owned_lease_fails() -> None:
|
|
145
|
+
settings = DbConnectionSettings.from_environment()
|
|
146
|
+
provider = _FakeSessionProvider(
|
|
147
|
+
settings,
|
|
148
|
+
release_failure=DbFailure(
|
|
149
|
+
kind=DbFailureKind.UNKNOWN,
|
|
150
|
+
code="release_failed",
|
|
151
|
+
message="Release failed",
|
|
152
|
+
retryable=False,
|
|
153
|
+
transient=False,
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
client = DbClient(
|
|
157
|
+
settings=settings,
|
|
158
|
+
session_provider=provider,
|
|
159
|
+
command_executor=_FakeCommandExecutor(),
|
|
160
|
+
transaction_runner=_FakeTransactionRunner(),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
result = client.query(DbCommand(kind=DbCommandKind.QUERY, text="select 1"))
|
|
164
|
+
|
|
165
|
+
assert result.is_failure is True
|
|
166
|
+
assert result.failure.code == "release_failed"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_client_does_not_release_application_owned_leases() -> None:
|
|
170
|
+
settings = DbConnectionSettings.from_environment()
|
|
171
|
+
provider = _FakeSessionProvider(settings, owned_by_package=False)
|
|
172
|
+
executor = _FakeCommandExecutor(
|
|
173
|
+
execution_summary=DbExecutionSummary(
|
|
174
|
+
affected_count=1,
|
|
175
|
+
metadata=DbExecutionMetadata(
|
|
176
|
+
duration=2,
|
|
177
|
+
command_label="users.touch",
|
|
178
|
+
affected_count=1,
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
client = DbClient(
|
|
183
|
+
settings=settings,
|
|
184
|
+
session_provider=provider,
|
|
185
|
+
command_executor=executor,
|
|
186
|
+
transaction_runner=_FakeTransactionRunner(),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
result = client.execute(
|
|
190
|
+
DbCommand(
|
|
191
|
+
kind=DbCommandKind.EXECUTE,
|
|
192
|
+
text="update users set touched = true",
|
|
193
|
+
label="users.touch",
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
assert result.is_success is True
|
|
198
|
+
assert result.value.affected_count == 1
|
|
199
|
+
assert provider.release_count == 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_client_commits_successful_transactions_and_rolls_back_failed_ones() -> None:
|
|
203
|
+
settings = DbConnectionSettings.from_environment()
|
|
204
|
+
provider = _FakeSessionProvider(settings)
|
|
205
|
+
executor = _FakeCommandExecutor(scalar_value=7)
|
|
206
|
+
runner = _FakeTransactionRunner()
|
|
207
|
+
client = DbClient(
|
|
208
|
+
settings=settings,
|
|
209
|
+
session_provider=provider,
|
|
210
|
+
command_executor=executor,
|
|
211
|
+
transaction_runner=runner,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
success = client.transaction(
|
|
215
|
+
lambda transaction: transaction.scalar(
|
|
216
|
+
DbCommand(
|
|
217
|
+
kind=DbCommandKind.SCALAR,
|
|
218
|
+
text="select count(*) from users",
|
|
219
|
+
label="users.count",
|
|
220
|
+
)
|
|
221
|
+
).map(lambda value: value.value)
|
|
222
|
+
)
|
|
223
|
+
failure = client.transaction(
|
|
224
|
+
lambda _transaction: DbResult.from_failure(
|
|
225
|
+
DbFailure(
|
|
226
|
+
kind=DbFailureKind.CONFLICT,
|
|
227
|
+
code="duplicate_key",
|
|
228
|
+
message="Duplicate key",
|
|
229
|
+
retryable=False,
|
|
230
|
+
transient=False,
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
assert success.is_success is True
|
|
236
|
+
assert success.value == 7
|
|
237
|
+
assert failure.is_failure is True
|
|
238
|
+
assert failure.failure.code == "duplicate_key"
|
|
239
|
+
assert runner.commit_count == 1
|
|
240
|
+
assert runner.rollback_count == 1
|
|
241
|
+
assert provider.release_count == 2
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_client_describes_its_provider_and_closes_cleanly() -> None:
|
|
245
|
+
settings = DbConnectionSettings.from_environment()
|
|
246
|
+
provider = _FakeSessionProvider(settings)
|
|
247
|
+
client = DbClient(
|
|
248
|
+
settings=settings,
|
|
249
|
+
session_provider=provider,
|
|
250
|
+
command_executor=_FakeCommandExecutor(),
|
|
251
|
+
transaction_runner=_FakeTransactionRunner(),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
assert client.describe().engine_id == "postgres"
|
|
255
|
+
assert client.describe().database == settings.database
|
|
256
|
+
|
|
257
|
+
closed = client.close()
|
|
258
|
+
|
|
259
|
+
assert closed.is_success is True
|
|
260
|
+
assert provider.close_count == 1
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_client_propagates_provider_close_failures() -> None:
|
|
264
|
+
settings = DbConnectionSettings.from_environment()
|
|
265
|
+
provider = _FakeSessionProvider(
|
|
266
|
+
settings,
|
|
267
|
+
close_failure=DbFailure(
|
|
268
|
+
kind=DbFailureKind.UNKNOWN,
|
|
269
|
+
code="close_failed",
|
|
270
|
+
message="Close failed",
|
|
271
|
+
retryable=False,
|
|
272
|
+
transient=False,
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
client = DbClient(
|
|
276
|
+
settings=settings,
|
|
277
|
+
session_provider=provider,
|
|
278
|
+
command_executor=_FakeCommandExecutor(),
|
|
279
|
+
transaction_runner=_FakeTransactionRunner(),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
closed = client.close()
|
|
283
|
+
|
|
284
|
+
assert closed.is_failure is True
|
|
285
|
+
assert closed.failure.code == "close_failed"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_repository_helpers_stay_thin_over_the_shared_context() -> None:
|
|
289
|
+
settings = DbConnectionSettings.from_environment()
|
|
290
|
+
provider = _FakeSessionProvider(settings)
|
|
291
|
+
executor = _FakeCommandExecutor(scalar_value=9)
|
|
292
|
+
context = DbRepositoryContext(
|
|
293
|
+
settings=settings,
|
|
294
|
+
session_provider=provider,
|
|
295
|
+
command_executor=executor,
|
|
296
|
+
transaction_runner=_FakeTransactionRunner(),
|
|
297
|
+
)
|
|
298
|
+
repository = _UserStatsRepository(context)
|
|
299
|
+
|
|
300
|
+
result = repository.total_users()
|
|
301
|
+
|
|
302
|
+
assert result.is_success is True
|
|
303
|
+
assert result.value == 9
|
|
304
|
+
assert executor.last_command.label == "users.count"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_health_probe_reports_healthy_and_graphql_support_bundles_dependencies() -> None:
|
|
308
|
+
settings = DbConnectionSettings.from_environment()
|
|
309
|
+
provider = _FakeSessionProvider(settings)
|
|
310
|
+
executor = _FakeCommandExecutor(scalar_value=1)
|
|
311
|
+
client = DbClient(
|
|
312
|
+
settings=settings,
|
|
313
|
+
session_provider=provider,
|
|
314
|
+
command_executor=executor,
|
|
315
|
+
transaction_runner=_FakeTransactionRunner(),
|
|
316
|
+
)
|
|
317
|
+
health_contributor = DbHealthContributor(client=client)
|
|
318
|
+
support = DbGraphqlSupport(
|
|
319
|
+
catalog_provider="catalog-provider",
|
|
320
|
+
read_executor="read-executor",
|
|
321
|
+
health_contributor=health_contributor,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
report = health_contributor.probe()
|
|
325
|
+
|
|
326
|
+
assert report.status is DbHealthStatus.HEALTHY
|
|
327
|
+
assert report.redacted_summary == settings.redacted_summary
|
|
328
|
+
assert report.response_time >= 0
|
|
329
|
+
assert support.catalog_provider == "catalog-provider"
|
|
330
|
+
assert support.read_executor == "read-executor"
|
|
331
|
+
assert support.health_contributor is health_contributor
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_health_probe_reports_unhealthy_when_scalar_execution_fails() -> None:
|
|
335
|
+
settings = DbConnectionSettings.from_environment()
|
|
336
|
+
provider = _FakeSessionProvider(settings)
|
|
337
|
+
executor = _FakeCommandExecutor(
|
|
338
|
+
failure=DbFailure(
|
|
339
|
+
kind=DbFailureKind.TIMEOUT,
|
|
340
|
+
code="timeout",
|
|
341
|
+
message="Timed out",
|
|
342
|
+
retryable=True,
|
|
343
|
+
transient=True,
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
client = DbClient(
|
|
347
|
+
settings=settings,
|
|
348
|
+
session_provider=provider,
|
|
349
|
+
command_executor=executor,
|
|
350
|
+
transaction_runner=_FakeTransactionRunner(),
|
|
351
|
+
)
|
|
352
|
+
health_contributor = DbHealthContributor(client=client)
|
|
353
|
+
|
|
354
|
+
report = health_contributor.probe()
|
|
355
|
+
|
|
356
|
+
assert report.status is DbHealthStatus.UNHEALTHY
|
|
357
|
+
assert report.details == "timeout"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@dataclass(slots=True)
|
|
361
|
+
class _FakeSessionProvider:
|
|
362
|
+
settings: DbConnectionSettings
|
|
363
|
+
session: str = "session-1"
|
|
364
|
+
owned_by_package: bool = True
|
|
365
|
+
acquire_failure: DbFailure | None = None
|
|
366
|
+
release_failure: DbFailure | None = None
|
|
367
|
+
close_failure: DbFailure | None = None
|
|
368
|
+
acquire_count: int = 0
|
|
369
|
+
release_count: int = 0
|
|
370
|
+
close_count: int = 0
|
|
371
|
+
|
|
372
|
+
def acquire(self) -> DbResult[DbSessionLease[str]]:
|
|
373
|
+
self.acquire_count += 1
|
|
374
|
+
if self.acquire_failure is not None:
|
|
375
|
+
return DbResult.from_failure(self.acquire_failure)
|
|
376
|
+
|
|
377
|
+
return DbResult.success(
|
|
378
|
+
DbSessionLease(
|
|
379
|
+
session=self.session,
|
|
380
|
+
owned_by_package=self.owned_by_package,
|
|
381
|
+
releaser=self._release,
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def close(self) -> DbResult[None]:
|
|
386
|
+
self.close_count += 1
|
|
387
|
+
if self.close_failure is not None:
|
|
388
|
+
return DbResult.from_failure(self.close_failure)
|
|
389
|
+
return DbResult.success(None)
|
|
390
|
+
|
|
391
|
+
def describe(self) -> DbProviderDescription:
|
|
392
|
+
return DbProviderDescription(
|
|
393
|
+
engine_id=self.settings.engine_id,
|
|
394
|
+
database=self.settings.database,
|
|
395
|
+
redacted_summary=self.settings.redacted_summary,
|
|
396
|
+
owns_resources=self.owned_by_package,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def _release(self) -> DbResult[None]:
|
|
400
|
+
self.release_count += 1
|
|
401
|
+
if self.release_failure is not None:
|
|
402
|
+
return DbResult.from_failure(self.release_failure)
|
|
403
|
+
return DbResult.success(None)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@dataclass(slots=True)
|
|
407
|
+
class _FakeCommandExecutor:
|
|
408
|
+
row_set: DbRowSet = DbRowSet(
|
|
409
|
+
rows=[],
|
|
410
|
+
metadata=DbExecutionMetadata(duration=0),
|
|
411
|
+
)
|
|
412
|
+
execution_summary: DbExecutionSummary = DbExecutionSummary(
|
|
413
|
+
affected_count=0,
|
|
414
|
+
metadata=DbExecutionMetadata(duration=0),
|
|
415
|
+
)
|
|
416
|
+
scalar_value: object | None = None
|
|
417
|
+
failure: DbFailure | None = None
|
|
418
|
+
last_session: str | None = None
|
|
419
|
+
last_command: DbCommand | None = None
|
|
420
|
+
|
|
421
|
+
def query(self, session: str, command: DbCommand) -> DbResult[DbRowSet]:
|
|
422
|
+
self.last_session = session
|
|
423
|
+
self.last_command = command
|
|
424
|
+
if self.failure is not None:
|
|
425
|
+
return DbResult.from_failure(self.failure)
|
|
426
|
+
return DbResult.success(self.row_set)
|
|
427
|
+
|
|
428
|
+
def execute(self, session: str, command: DbCommand) -> DbResult[DbExecutionSummary]:
|
|
429
|
+
self.last_session = session
|
|
430
|
+
self.last_command = command
|
|
431
|
+
if self.failure is not None:
|
|
432
|
+
return DbResult.from_failure(self.failure)
|
|
433
|
+
return DbResult.success(self.execution_summary)
|
|
434
|
+
|
|
435
|
+
def scalar(self, session: str, command: DbCommand) -> DbResult[DbScalar[object]]:
|
|
436
|
+
self.last_session = session
|
|
437
|
+
self.last_command = command
|
|
438
|
+
if self.failure is not None:
|
|
439
|
+
return DbResult.from_failure(self.failure)
|
|
440
|
+
return DbResult.success(
|
|
441
|
+
DbScalar(
|
|
442
|
+
value=self.scalar_value,
|
|
443
|
+
metadata=DbExecutionMetadata(
|
|
444
|
+
duration=0,
|
|
445
|
+
command_label=command.label,
|
|
446
|
+
),
|
|
447
|
+
)
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@dataclass(slots=True)
|
|
452
|
+
class _FakeTransactionRunner:
|
|
453
|
+
commit_count: int = 0
|
|
454
|
+
rollback_count: int = 0
|
|
455
|
+
|
|
456
|
+
def run(
|
|
457
|
+
self,
|
|
458
|
+
context: DbTransactionContext[str],
|
|
459
|
+
body: callable,
|
|
460
|
+
) -> DbResult[object]:
|
|
461
|
+
result = body(context)
|
|
462
|
+
if result.is_success:
|
|
463
|
+
self.commit_count += 1
|
|
464
|
+
else:
|
|
465
|
+
self.rollback_count += 1
|
|
466
|
+
return result
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
class _UserStatsRepository(DbRepository[str]):
|
|
470
|
+
def total_users(self) -> DbResult[int]:
|
|
471
|
+
result = self.scalar(
|
|
472
|
+
DbCommand(
|
|
473
|
+
kind=DbCommandKind.SCALAR,
|
|
474
|
+
text="select count(*) from users",
|
|
475
|
+
label="users.count",
|
|
476
|
+
)
|
|
477
|
+
)
|
|
478
|
+
return result.map(lambda value: int(value.value))
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from modular_api_postgres import DbConnectionSettings, DbFailure, DbFailureKind, DbResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _load_fixture() -> dict[str, object]:
|
|
10
|
+
return json.loads(
|
|
11
|
+
(Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "db_client" / "postgres.json").read_text(
|
|
12
|
+
encoding="utf-8"
|
|
13
|
+
)
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_matches_the_shared_postgres_connection_fixture() -> None:
|
|
18
|
+
fixture = _load_fixture()
|
|
19
|
+
connection = fixture["connection"]
|
|
20
|
+
expected = connection["expected"]
|
|
21
|
+
|
|
22
|
+
settings = DbConnectionSettings.from_environment(connection["environment"])
|
|
23
|
+
|
|
24
|
+
assert settings.engine_id == expected["engineId"]
|
|
25
|
+
assert settings.host == expected["host"]
|
|
26
|
+
assert settings.port == expected["port"]
|
|
27
|
+
assert settings.database == expected["database"]
|
|
28
|
+
assert settings.username == expected["username"]
|
|
29
|
+
assert settings.password == expected["password"]
|
|
30
|
+
assert settings.ssl_mode == expected["sslMode"]
|
|
31
|
+
|
|
32
|
+
for fragment in connection["redactedContains"]:
|
|
33
|
+
assert fragment in settings.redacted_summary
|
|
34
|
+
for fragment in connection["redactedExcludes"]:
|
|
35
|
+
assert fragment not in settings.redacted_summary
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_matches_the_shared_db_result_fixture() -> None:
|
|
39
|
+
fixture = _load_fixture()
|
|
40
|
+
result_fixture = fixture["result"]
|
|
41
|
+
|
|
42
|
+
success = DbResult.success(result_fixture["successValue"])
|
|
43
|
+
failure = DbResult.from_failure(
|
|
44
|
+
DbFailure(
|
|
45
|
+
kind=DbFailureKind.TIMEOUT,
|
|
46
|
+
code=result_fixture["timeoutCode"],
|
|
47
|
+
message="Timed out",
|
|
48
|
+
retryable=True,
|
|
49
|
+
transient=True,
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
assert success.map(lambda value: value * 2).value == result_fixture["mappedValue"]
|
|
54
|
+
assert success.flat_map(lambda value: DbResult.success(value + 1)).value == result_fixture[
|
|
55
|
+
"flatMappedValue"
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
mapped_failure = failure.map_failure(
|
|
59
|
+
lambda current: DbFailure(
|
|
60
|
+
kind=current.kind,
|
|
61
|
+
code=result_fixture["wrappedFailureCode"],
|
|
62
|
+
message=current.message,
|
|
63
|
+
retryable=current.retryable,
|
|
64
|
+
transient=current.transient,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
assert mapped_failure.failure.code == result_fixture["wrappedFailureCode"]
|