muscles-data-mongodb 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- muscles_data_mongodb-0.1.0/PKG-INFO +133 -0
- muscles_data_mongodb-0.1.0/README.md +121 -0
- muscles_data_mongodb-0.1.0/pyproject.toml +32 -0
- muscles_data_mongodb-0.1.0/setup.cfg +4 -0
- muscles_data_mongodb-0.1.0/src/muscles_data_mongodb/__init__.py +20 -0
- muscles_data_mongodb-0.1.0/src/muscles_data_mongodb/adapter.py +287 -0
- muscles_data_mongodb-0.1.0/src/muscles_data_mongodb.egg-info/PKG-INFO +133 -0
- muscles_data_mongodb-0.1.0/src/muscles_data_mongodb.egg-info/SOURCES.txt +11 -0
- muscles_data_mongodb-0.1.0/src/muscles_data_mongodb.egg-info/dependency_links.txt +1 -0
- muscles_data_mongodb-0.1.0/src/muscles_data_mongodb.egg-info/requires.txt +5 -0
- muscles_data_mongodb-0.1.0/src/muscles_data_mongodb.egg-info/top_level.txt +1 -0
- muscles_data_mongodb-0.1.0/tests/test_integration.py +61 -0
- muscles_data_mongodb-0.1.0/tests/test_mongodb_adapter.py +255 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: muscles-data-mongodb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MongoDB DocumentStorePort adapter for muscles-data
|
|
5
|
+
Author-email: "Denis B." <denis@butko.info>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: muscles-data<1.0.0,>=0.1.0
|
|
9
|
+
Requires-Dist: pymongo<5.0,>=4.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
|
|
12
|
+
|
|
13
|
+
# muscles-data-mongodb
|
|
14
|
+
|
|
15
|
+
MongoDB adapter package for `muscles-data`.
|
|
16
|
+
|
|
17
|
+
This package is intentionally separate from `muscles-data`: the core package
|
|
18
|
+
owns typed ports and runtime, while this package owns the PyMongo-backed
|
|
19
|
+
`DocumentStorePort` adapter.
|
|
20
|
+
|
|
21
|
+
## Related packages
|
|
22
|
+
|
|
23
|
+
- Core runtime and port contracts:
|
|
24
|
+
[`muscles-data`](https://github.com/butkoden/muscles-data)
|
|
25
|
+
- Elasticsearch search adapter:
|
|
26
|
+
[`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
|
|
27
|
+
- OpenSearch search adapter:
|
|
28
|
+
[`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
|
|
29
|
+
- Redis key-value/lock/stream adapter:
|
|
30
|
+
[`muscles-data-redis`](https://github.com/butkoden/muscles-data-redis)
|
|
31
|
+
- Qdrant vector adapter:
|
|
32
|
+
[`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
|
|
33
|
+
- S3 object-store adapter:
|
|
34
|
+
[`muscles-data-s3`](https://github.com/butkoden/muscles-data-s3)
|
|
35
|
+
- SQLAlchemy direct SQL resource adapter:
|
|
36
|
+
[`muscles-data-sqlalchemy`](https://github.com/butkoden/muscles-data-sqlalchemy)
|
|
37
|
+
- Executable example:
|
|
38
|
+
[`example_data_mongodb_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_mongodb_1)
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
python -m pip install muscles-data-mongodb
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
For local framework development:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
PYTHONPATH=../muscles-data/src:src python3 -m pytest -q
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Configuration
|
|
53
|
+
|
|
54
|
+
```yaml
|
|
55
|
+
data:
|
|
56
|
+
resources:
|
|
57
|
+
mongo.content:
|
|
58
|
+
type: mongodb
|
|
59
|
+
url: ${MONGO_URL}
|
|
60
|
+
database: content
|
|
61
|
+
timeout: 3
|
|
62
|
+
max_limit: 100
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
Register the external factory in the project composition root:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from muscles_data.catalog import DataAdapterCatalog
|
|
71
|
+
from muscles_data.config import DataConfig
|
|
72
|
+
from muscles_data.ports import DocumentStorePort
|
|
73
|
+
from muscles_data.runtime import DataRuntime
|
|
74
|
+
from muscles_data_mongodb import MongoDocumentStoreFactory
|
|
75
|
+
|
|
76
|
+
catalog = DataAdapterCatalog.with_defaults()
|
|
77
|
+
catalog.register(MongoDocumentStoreFactory())
|
|
78
|
+
|
|
79
|
+
runtime = DataRuntime(config=DataConfig.from_raw(config), catalog=catalog)
|
|
80
|
+
store = runtime.require_port("mongo.content", DocumentStorePort)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Then use the narrow port:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
store.upsert_document("profiles", "denis", {"name": "Denis"})
|
|
87
|
+
document = store.get_document("profiles", "denis")
|
|
88
|
+
documents = store.find_documents("profiles", filters={"role": "developer"}, limit=20)
|
|
89
|
+
store.delete_document("profiles", "denis")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The adapter maps `document_id` to MongoDB `_id` internally and returns public
|
|
93
|
+
documents without `_id` to keep the port backend-neutral.
|
|
94
|
+
|
|
95
|
+
## Capabilities
|
|
96
|
+
|
|
97
|
+
`MongoDocumentStoreFactory` provides:
|
|
98
|
+
|
|
99
|
+
- `document_store`;
|
|
100
|
+
- `healthcheck`;
|
|
101
|
+
- `native_client` only when the resource declares `native_client: true`.
|
|
102
|
+
|
|
103
|
+
Native access is an advanced project escape hatch:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from muscles_data import DataCapability
|
|
107
|
+
|
|
108
|
+
client = runtime.require_resource("mongo.content", DataCapability.NATIVE_CLIENT).native_client()
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Use the native client for backend-specific aggregations, indexes or operations
|
|
112
|
+
that do not belong in `DocumentStorePort`.
|
|
113
|
+
|
|
114
|
+
## Boundaries
|
|
115
|
+
|
|
116
|
+
This package owns:
|
|
117
|
+
|
|
118
|
+
- lazy PyMongo client creation;
|
|
119
|
+
- database binding;
|
|
120
|
+
- simple document get/find/upsert/delete;
|
|
121
|
+
- safe `inspect()` and `doctor()`;
|
|
122
|
+
- bounded `find_documents()` limits.
|
|
123
|
+
|
|
124
|
+
It does not own:
|
|
125
|
+
|
|
126
|
+
- ORM/document model layers;
|
|
127
|
+
- schema validation;
|
|
128
|
+
- migrations;
|
|
129
|
+
- complex aggregation abstraction;
|
|
130
|
+
- business repository APIs.
|
|
131
|
+
|
|
132
|
+
`data.doctor` performs a MongoDB `ping`. `data.resources.list` and package
|
|
133
|
+
initialization do not open a MongoDB connection.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# muscles-data-mongodb
|
|
2
|
+
|
|
3
|
+
MongoDB adapter package for `muscles-data`.
|
|
4
|
+
|
|
5
|
+
This package is intentionally separate from `muscles-data`: the core package
|
|
6
|
+
owns typed ports and runtime, while this package owns the PyMongo-backed
|
|
7
|
+
`DocumentStorePort` adapter.
|
|
8
|
+
|
|
9
|
+
## Related packages
|
|
10
|
+
|
|
11
|
+
- Core runtime and port contracts:
|
|
12
|
+
[`muscles-data`](https://github.com/butkoden/muscles-data)
|
|
13
|
+
- Elasticsearch search adapter:
|
|
14
|
+
[`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
|
|
15
|
+
- OpenSearch search adapter:
|
|
16
|
+
[`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
|
|
17
|
+
- Redis key-value/lock/stream adapter:
|
|
18
|
+
[`muscles-data-redis`](https://github.com/butkoden/muscles-data-redis)
|
|
19
|
+
- Qdrant vector adapter:
|
|
20
|
+
[`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
|
|
21
|
+
- S3 object-store adapter:
|
|
22
|
+
[`muscles-data-s3`](https://github.com/butkoden/muscles-data-s3)
|
|
23
|
+
- SQLAlchemy direct SQL resource adapter:
|
|
24
|
+
[`muscles-data-sqlalchemy`](https://github.com/butkoden/muscles-data-sqlalchemy)
|
|
25
|
+
- Executable example:
|
|
26
|
+
[`example_data_mongodb_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_mongodb_1)
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
python -m pip install muscles-data-mongodb
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
For local framework development:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
PYTHONPATH=../muscles-data/src:src python3 -m pytest -q
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Configuration
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
data:
|
|
44
|
+
resources:
|
|
45
|
+
mongo.content:
|
|
46
|
+
type: mongodb
|
|
47
|
+
url: ${MONGO_URL}
|
|
48
|
+
database: content
|
|
49
|
+
timeout: 3
|
|
50
|
+
max_limit: 100
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
Register the external factory in the project composition root:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from muscles_data.catalog import DataAdapterCatalog
|
|
59
|
+
from muscles_data.config import DataConfig
|
|
60
|
+
from muscles_data.ports import DocumentStorePort
|
|
61
|
+
from muscles_data.runtime import DataRuntime
|
|
62
|
+
from muscles_data_mongodb import MongoDocumentStoreFactory
|
|
63
|
+
|
|
64
|
+
catalog = DataAdapterCatalog.with_defaults()
|
|
65
|
+
catalog.register(MongoDocumentStoreFactory())
|
|
66
|
+
|
|
67
|
+
runtime = DataRuntime(config=DataConfig.from_raw(config), catalog=catalog)
|
|
68
|
+
store = runtime.require_port("mongo.content", DocumentStorePort)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Then use the narrow port:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
store.upsert_document("profiles", "denis", {"name": "Denis"})
|
|
75
|
+
document = store.get_document("profiles", "denis")
|
|
76
|
+
documents = store.find_documents("profiles", filters={"role": "developer"}, limit=20)
|
|
77
|
+
store.delete_document("profiles", "denis")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The adapter maps `document_id` to MongoDB `_id` internally and returns public
|
|
81
|
+
documents without `_id` to keep the port backend-neutral.
|
|
82
|
+
|
|
83
|
+
## Capabilities
|
|
84
|
+
|
|
85
|
+
`MongoDocumentStoreFactory` provides:
|
|
86
|
+
|
|
87
|
+
- `document_store`;
|
|
88
|
+
- `healthcheck`;
|
|
89
|
+
- `native_client` only when the resource declares `native_client: true`.
|
|
90
|
+
|
|
91
|
+
Native access is an advanced project escape hatch:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from muscles_data import DataCapability
|
|
95
|
+
|
|
96
|
+
client = runtime.require_resource("mongo.content", DataCapability.NATIVE_CLIENT).native_client()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Use the native client for backend-specific aggregations, indexes or operations
|
|
100
|
+
that do not belong in `DocumentStorePort`.
|
|
101
|
+
|
|
102
|
+
## Boundaries
|
|
103
|
+
|
|
104
|
+
This package owns:
|
|
105
|
+
|
|
106
|
+
- lazy PyMongo client creation;
|
|
107
|
+
- database binding;
|
|
108
|
+
- simple document get/find/upsert/delete;
|
|
109
|
+
- safe `inspect()` and `doctor()`;
|
|
110
|
+
- bounded `find_documents()` limits.
|
|
111
|
+
|
|
112
|
+
It does not own:
|
|
113
|
+
|
|
114
|
+
- ORM/document model layers;
|
|
115
|
+
- schema validation;
|
|
116
|
+
- migrations;
|
|
117
|
+
- complex aggregation abstraction;
|
|
118
|
+
- business repository APIs.
|
|
119
|
+
|
|
120
|
+
`data.doctor` performs a MongoDB `ping`. `data.resources.list` and package
|
|
121
|
+
initialization do not open a MongoDB connection.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "muscles-data-mongodb"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MongoDB DocumentStorePort adapter for muscles-data"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "Denis B.", email = "denis@butko.info" }]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"muscles-data>=0.1.0,<1.0.0",
|
|
14
|
+
"pymongo>=4.0,<5.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=8.0,<9.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
testpaths = ["tests"]
|
|
24
|
+
markers = [
|
|
25
|
+
"integration: tests that require the local backend integration stack",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools]
|
|
29
|
+
package-dir = {"" = "src"}
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["src"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .adapter import (
|
|
4
|
+
MongoDBAdapterError,
|
|
5
|
+
MongoDBClientMissingError,
|
|
6
|
+
MongoDBConfigError,
|
|
7
|
+
MongoDBConnectionError,
|
|
8
|
+
MongoDocumentStoreAdapter,
|
|
9
|
+
MongoDocumentStoreFactory,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"MongoDBAdapterError",
|
|
15
|
+
"MongoDBClientMissingError",
|
|
16
|
+
"MongoDBConfigError",
|
|
17
|
+
"MongoDBConnectionError",
|
|
18
|
+
"MongoDocumentStoreAdapter",
|
|
19
|
+
"MongoDocumentStoreFactory",
|
|
20
|
+
]
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from muscles_data.config import DataResourceConfig
|
|
11
|
+
from muscles_data.errors import DataError
|
|
12
|
+
from muscles_data.models import DataCapability, HealthResult, InspectResult, WriteResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_CLIENT_UNSET = object()
|
|
16
|
+
_ALLOWED_OPTIONS = {
|
|
17
|
+
"url",
|
|
18
|
+
"url_env",
|
|
19
|
+
"database",
|
|
20
|
+
"username",
|
|
21
|
+
"password",
|
|
22
|
+
"auth_source",
|
|
23
|
+
"timeout",
|
|
24
|
+
"timeout_ms",
|
|
25
|
+
"server_selection_timeout_ms",
|
|
26
|
+
"max_limit",
|
|
27
|
+
"native_client",
|
|
28
|
+
"tls",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MongoDBAdapterError(DataError):
|
|
33
|
+
"""Base error for MongoDB document-store adapter failures."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MongoDBConfigError(ValueError, MongoDBAdapterError):
|
|
37
|
+
"""Raised when a MongoDB resource config cannot be mapped safely."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MongoDBClientMissingError(MongoDBAdapterError):
|
|
41
|
+
"""Raised when MongoDB adapter is used without an available client."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MongoDBConnectionError(MongoDBAdapterError):
|
|
45
|
+
"""Raised when a MongoDB operation cannot reach or use the backend."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MongoDocumentStoreAdapter:
|
|
49
|
+
resource_type = "mongodb"
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
config: DataResourceConfig,
|
|
54
|
+
*,
|
|
55
|
+
client_factory: Callable[[DataResourceConfig], Any] | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.config = config
|
|
58
|
+
self._client_factory = client_factory
|
|
59
|
+
self._client: Any = _CLIENT_UNSET
|
|
60
|
+
self._lock = threading.RLock()
|
|
61
|
+
self.closed = False
|
|
62
|
+
|
|
63
|
+
def get_document(self, collection: str, document_id: str) -> Mapping[str, Any] | None:
|
|
64
|
+
try:
|
|
65
|
+
raw = self._collection(collection).find_one({"_id": str(document_id)})
|
|
66
|
+
except (MongoDBClientMissingError, MongoDBConfigError):
|
|
67
|
+
raise
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
raise MongoDBConnectionError(self._safe_error(exc)) from exc
|
|
70
|
+
return _public_document(raw)
|
|
71
|
+
|
|
72
|
+
def upsert_document(self, collection: str, document_id: str, document: Mapping[str, Any]) -> WriteResult:
|
|
73
|
+
stored = _storage_document(document, document_id)
|
|
74
|
+
try:
|
|
75
|
+
result = self._collection(collection).replace_one({"_id": str(document_id)}, stored, upsert=True)
|
|
76
|
+
except (MongoDBClientMissingError, MongoDBConfigError):
|
|
77
|
+
raise
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
raise MongoDBConnectionError(self._safe_error(exc)) from exc
|
|
80
|
+
matched = int(getattr(result, "matched_count", 0) or 0)
|
|
81
|
+
return WriteResult(written=1, matched=1 if matched else 0)
|
|
82
|
+
|
|
83
|
+
def find_documents(
|
|
84
|
+
self,
|
|
85
|
+
collection: str,
|
|
86
|
+
filters: Mapping[str, Any] | None = None,
|
|
87
|
+
limit: int = 100,
|
|
88
|
+
options: Mapping[str, Any] | None = None,
|
|
89
|
+
) -> list[Mapping[str, Any]]:
|
|
90
|
+
options = dict(options or {})
|
|
91
|
+
query = dict(filters or {})
|
|
92
|
+
bounded_limit = self._bounded_limit(limit)
|
|
93
|
+
try:
|
|
94
|
+
cursor = self._collection(collection).find(query)
|
|
95
|
+
if "sort" in options:
|
|
96
|
+
cursor = cursor.sort(options["sort"])
|
|
97
|
+
if "skip" in options:
|
|
98
|
+
cursor = cursor.skip(max(0, int(options["skip"])))
|
|
99
|
+
cursor = cursor.limit(bounded_limit)
|
|
100
|
+
return [document for document in (_public_document(item) for item in cursor) if document is not None]
|
|
101
|
+
except (MongoDBClientMissingError, MongoDBConfigError):
|
|
102
|
+
raise
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
raise MongoDBConnectionError(self._safe_error(exc)) from exc
|
|
105
|
+
|
|
106
|
+
def delete_document(self, collection: str, document_id: str) -> WriteResult:
|
|
107
|
+
try:
|
|
108
|
+
result = self._collection(collection).delete_one({"_id": str(document_id)})
|
|
109
|
+
except (MongoDBClientMissingError, MongoDBConfigError):
|
|
110
|
+
raise
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
raise MongoDBConnectionError(self._safe_error(exc)) from exc
|
|
113
|
+
deleted = int(getattr(result, "deleted_count", 0) or 0)
|
|
114
|
+
return WriteResult(deleted=deleted, matched=deleted)
|
|
115
|
+
|
|
116
|
+
def inspect(self) -> dict[str, Any]:
|
|
117
|
+
return asdict(
|
|
118
|
+
InspectResult(
|
|
119
|
+
name=self.config.name,
|
|
120
|
+
type=self.config.type,
|
|
121
|
+
capabilities=[],
|
|
122
|
+
initialized=self._client is not _CLIENT_UNSET,
|
|
123
|
+
status="ok",
|
|
124
|
+
options=self.config.safe_options(),
|
|
125
|
+
details={"backend": "mongodb", "database": self.database_name(), "max_limit": self.max_limit()},
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def health(self) -> HealthResult:
|
|
130
|
+
try:
|
|
131
|
+
self._client_instance().admin.command("ping")
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
return HealthResult(status="failed", message=self._safe_error(exc), details={"database": self.database_name()})
|
|
134
|
+
return HealthResult(status="ok", message="MongoDB connection is available", details={"database": self.database_name()})
|
|
135
|
+
|
|
136
|
+
def close(self) -> None:
|
|
137
|
+
if self._client is _CLIENT_UNSET:
|
|
138
|
+
self.closed = True
|
|
139
|
+
return
|
|
140
|
+
close = getattr(self._client, "close", None)
|
|
141
|
+
if callable(close):
|
|
142
|
+
close()
|
|
143
|
+
self.closed = True
|
|
144
|
+
|
|
145
|
+
def native_client(self):
|
|
146
|
+
return self._client_instance()
|
|
147
|
+
|
|
148
|
+
def database_name(self) -> str:
|
|
149
|
+
database = str(self.config.options.get("database", "")).strip()
|
|
150
|
+
if not database:
|
|
151
|
+
raise MongoDBConfigError("MongoDB resource requires database")
|
|
152
|
+
return database
|
|
153
|
+
|
|
154
|
+
def max_limit(self) -> int:
|
|
155
|
+
value = int(self.config.options.get("max_limit", 100))
|
|
156
|
+
if value <= 0:
|
|
157
|
+
raise MongoDBConfigError("MongoDB max_limit must be positive")
|
|
158
|
+
return value
|
|
159
|
+
|
|
160
|
+
def _bounded_limit(self, limit: int) -> int:
|
|
161
|
+
return min(max(0, int(limit)), self.max_limit())
|
|
162
|
+
|
|
163
|
+
def _collection(self, collection: str):
|
|
164
|
+
name = _collection_name(collection)
|
|
165
|
+
return self._client_instance()[self.database_name()][name]
|
|
166
|
+
|
|
167
|
+
def _client_instance(self):
|
|
168
|
+
if self._client is _CLIENT_UNSET:
|
|
169
|
+
with self._lock:
|
|
170
|
+
if self._client is _CLIENT_UNSET:
|
|
171
|
+
self._validate_options()
|
|
172
|
+
client = (
|
|
173
|
+
self._client_factory(self.config)
|
|
174
|
+
if self._client_factory
|
|
175
|
+
else _default_mongo_client(self.config)
|
|
176
|
+
)
|
|
177
|
+
if client is None:
|
|
178
|
+
raise MongoDBClientMissingError("MongoDB client is not available")
|
|
179
|
+
self._client = client
|
|
180
|
+
return self._client
|
|
181
|
+
|
|
182
|
+
def _validate_options(self) -> None:
|
|
183
|
+
unknown = sorted(set(self.config.options) - _ALLOWED_OPTIONS)
|
|
184
|
+
if unknown:
|
|
185
|
+
names = ", ".join(unknown)
|
|
186
|
+
raise MongoDBConfigError(f"Unsupported MongoDB resource options: {names}")
|
|
187
|
+
if not self.config.options.get("url") and not self.config.options.get("url_env"):
|
|
188
|
+
raise MongoDBConfigError("MongoDB resource requires url or url_env")
|
|
189
|
+
self.database_name()
|
|
190
|
+
self.max_limit()
|
|
191
|
+
|
|
192
|
+
def _safe_error(self, exc: Exception) -> str:
|
|
193
|
+
message = str(exc)
|
|
194
|
+
try:
|
|
195
|
+
options = self.config.resolved_options()
|
|
196
|
+
except Exception:
|
|
197
|
+
options = self.config.options
|
|
198
|
+
sensitive_values = {
|
|
199
|
+
str(options.get("url", "")),
|
|
200
|
+
str(options.get("username", "")),
|
|
201
|
+
str(options.get("password", "")),
|
|
202
|
+
}
|
|
203
|
+
try:
|
|
204
|
+
parsed = urlsplit(str(options.get("url", "")))
|
|
205
|
+
if parsed.username:
|
|
206
|
+
sensitive_values.add(parsed.username)
|
|
207
|
+
if parsed.password:
|
|
208
|
+
sensitive_values.add(parsed.password)
|
|
209
|
+
except Exception: # pragma: no cover
|
|
210
|
+
pass
|
|
211
|
+
for value in sorted((item for item in sensitive_values if item), key=len, reverse=True):
|
|
212
|
+
message = message.replace(value, "***")
|
|
213
|
+
return message
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class MongoDocumentStoreFactory:
|
|
217
|
+
resource_type = "mongodb"
|
|
218
|
+
|
|
219
|
+
def __init__(self, *, client_factory: Callable[[DataResourceConfig], Any] | None = None) -> None:
|
|
220
|
+
self._client_factory = client_factory
|
|
221
|
+
|
|
222
|
+
def capabilities(self, config: DataResourceConfig) -> set[DataCapability]:
|
|
223
|
+
native = {DataCapability.NATIVE_CLIENT} if bool(config.options.get("native_client")) else set()
|
|
224
|
+
return {DataCapability.DOCUMENT_STORE, DataCapability.HEALTHCHECK} | native
|
|
225
|
+
|
|
226
|
+
def create(self, config: DataResourceConfig) -> MongoDocumentStoreAdapter:
|
|
227
|
+
return MongoDocumentStoreAdapter(config, client_factory=self._client_factory)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _default_mongo_client(config: DataResourceConfig):
|
|
231
|
+
try:
|
|
232
|
+
pymongo = importlib.import_module("pymongo")
|
|
233
|
+
except ImportError as exc:
|
|
234
|
+
raise MongoDBClientMissingError("pymongo package is not installed") from exc
|
|
235
|
+
client_cls = getattr(pymongo, "MongoClient", None)
|
|
236
|
+
if client_cls is None:
|
|
237
|
+
raise MongoDBClientMissingError("pymongo does not expose MongoClient")
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
options = config.resolved_options()
|
|
241
|
+
except Exception as exc:
|
|
242
|
+
raise MongoDBConfigError(str(exc)) from exc
|
|
243
|
+
|
|
244
|
+
kwargs: dict[str, Any] = {}
|
|
245
|
+
if "timeout" in options:
|
|
246
|
+
timeout_ms = int(float(options["timeout"]) * 1000)
|
|
247
|
+
kwargs["timeoutMS"] = timeout_ms
|
|
248
|
+
kwargs["serverSelectionTimeoutMS"] = timeout_ms
|
|
249
|
+
if "timeout_ms" in options:
|
|
250
|
+
kwargs["timeoutMS"] = int(options["timeout_ms"])
|
|
251
|
+
if "server_selection_timeout_ms" in options:
|
|
252
|
+
kwargs["serverSelectionTimeoutMS"] = int(options["server_selection_timeout_ms"])
|
|
253
|
+
if "username" in options:
|
|
254
|
+
kwargs["username"] = options["username"]
|
|
255
|
+
if "password" in options:
|
|
256
|
+
kwargs["password"] = options["password"]
|
|
257
|
+
if "auth_source" in options:
|
|
258
|
+
kwargs["authSource"] = options["auth_source"]
|
|
259
|
+
if "tls" in options:
|
|
260
|
+
kwargs["tls"] = bool(options["tls"])
|
|
261
|
+
return client_cls(str(options["url"]), **kwargs)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _collection_name(value: str) -> str:
|
|
265
|
+
name = str(value).strip()
|
|
266
|
+
if not name:
|
|
267
|
+
raise MongoDBConfigError("MongoDB collection name must not be empty")
|
|
268
|
+
if "\x00" in name:
|
|
269
|
+
raise MongoDBConfigError("MongoDB collection name must not contain null bytes")
|
|
270
|
+
return name
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _storage_document(document: Mapping[str, Any], document_id: str) -> dict[str, Any]:
|
|
274
|
+
stored = dict(document)
|
|
275
|
+
existing_id = stored.get("_id")
|
|
276
|
+
if existing_id is not None and str(existing_id) != str(document_id):
|
|
277
|
+
raise MongoDBConfigError("MongoDB document _id must match document_id")
|
|
278
|
+
stored["_id"] = str(document_id)
|
|
279
|
+
return stored
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _public_document(raw: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
283
|
+
if raw is None:
|
|
284
|
+
return None
|
|
285
|
+
document = dict(raw)
|
|
286
|
+
document.pop("_id", None)
|
|
287
|
+
return document
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: muscles-data-mongodb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MongoDB DocumentStorePort adapter for muscles-data
|
|
5
|
+
Author-email: "Denis B." <denis@butko.info>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: muscles-data<1.0.0,>=0.1.0
|
|
9
|
+
Requires-Dist: pymongo<5.0,>=4.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
|
|
12
|
+
|
|
13
|
+
# muscles-data-mongodb
|
|
14
|
+
|
|
15
|
+
MongoDB adapter package for `muscles-data`.
|
|
16
|
+
|
|
17
|
+
This package is intentionally separate from `muscles-data`: the core package
|
|
18
|
+
owns typed ports and runtime, while this package owns the PyMongo-backed
|
|
19
|
+
`DocumentStorePort` adapter.
|
|
20
|
+
|
|
21
|
+
## Related packages
|
|
22
|
+
|
|
23
|
+
- Core runtime and port contracts:
|
|
24
|
+
[`muscles-data`](https://github.com/butkoden/muscles-data)
|
|
25
|
+
- Elasticsearch search adapter:
|
|
26
|
+
[`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
|
|
27
|
+
- OpenSearch search adapter:
|
|
28
|
+
[`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
|
|
29
|
+
- Redis key-value/lock/stream adapter:
|
|
30
|
+
[`muscles-data-redis`](https://github.com/butkoden/muscles-data-redis)
|
|
31
|
+
- Qdrant vector adapter:
|
|
32
|
+
[`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
|
|
33
|
+
- S3 object-store adapter:
|
|
34
|
+
[`muscles-data-s3`](https://github.com/butkoden/muscles-data-s3)
|
|
35
|
+
- SQLAlchemy direct SQL resource adapter:
|
|
36
|
+
[`muscles-data-sqlalchemy`](https://github.com/butkoden/muscles-data-sqlalchemy)
|
|
37
|
+
- Executable example:
|
|
38
|
+
[`example_data_mongodb_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_mongodb_1)
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
python -m pip install muscles-data-mongodb
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
For local framework development:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
PYTHONPATH=../muscles-data/src:src python3 -m pytest -q
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Configuration
|
|
53
|
+
|
|
54
|
+
```yaml
|
|
55
|
+
data:
|
|
56
|
+
resources:
|
|
57
|
+
mongo.content:
|
|
58
|
+
type: mongodb
|
|
59
|
+
url: ${MONGO_URL}
|
|
60
|
+
database: content
|
|
61
|
+
timeout: 3
|
|
62
|
+
max_limit: 100
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
Register the external factory in the project composition root:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from muscles_data.catalog import DataAdapterCatalog
|
|
71
|
+
from muscles_data.config import DataConfig
|
|
72
|
+
from muscles_data.ports import DocumentStorePort
|
|
73
|
+
from muscles_data.runtime import DataRuntime
|
|
74
|
+
from muscles_data_mongodb import MongoDocumentStoreFactory
|
|
75
|
+
|
|
76
|
+
catalog = DataAdapterCatalog.with_defaults()
|
|
77
|
+
catalog.register(MongoDocumentStoreFactory())
|
|
78
|
+
|
|
79
|
+
runtime = DataRuntime(config=DataConfig.from_raw(config), catalog=catalog)
|
|
80
|
+
store = runtime.require_port("mongo.content", DocumentStorePort)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Then use the narrow port:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
store.upsert_document("profiles", "denis", {"name": "Denis"})
|
|
87
|
+
document = store.get_document("profiles", "denis")
|
|
88
|
+
documents = store.find_documents("profiles", filters={"role": "developer"}, limit=20)
|
|
89
|
+
store.delete_document("profiles", "denis")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The adapter maps `document_id` to MongoDB `_id` internally and returns public
|
|
93
|
+
documents without `_id` to keep the port backend-neutral.
|
|
94
|
+
|
|
95
|
+
## Capabilities
|
|
96
|
+
|
|
97
|
+
`MongoDocumentStoreFactory` provides:
|
|
98
|
+
|
|
99
|
+
- `document_store`;
|
|
100
|
+
- `healthcheck`;
|
|
101
|
+
- `native_client` only when the resource declares `native_client: true`.
|
|
102
|
+
|
|
103
|
+
Native access is an advanced project escape hatch:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from muscles_data import DataCapability
|
|
107
|
+
|
|
108
|
+
client = runtime.require_resource("mongo.content", DataCapability.NATIVE_CLIENT).native_client()
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Use the native client for backend-specific aggregations, indexes or operations
|
|
112
|
+
that do not belong in `DocumentStorePort`.
|
|
113
|
+
|
|
114
|
+
## Boundaries
|
|
115
|
+
|
|
116
|
+
This package owns:
|
|
117
|
+
|
|
118
|
+
- lazy PyMongo client creation;
|
|
119
|
+
- database binding;
|
|
120
|
+
- simple document get/find/upsert/delete;
|
|
121
|
+
- safe `inspect()` and `doctor()`;
|
|
122
|
+
- bounded `find_documents()` limits.
|
|
123
|
+
|
|
124
|
+
It does not own:
|
|
125
|
+
|
|
126
|
+
- ORM/document model layers;
|
|
127
|
+
- schema validation;
|
|
128
|
+
- migrations;
|
|
129
|
+
- complex aggregation abstraction;
|
|
130
|
+
- business repository APIs.
|
|
131
|
+
|
|
132
|
+
`data.doctor` performs a MongoDB `ping`. `data.resources.list` and package
|
|
133
|
+
initialization do not open a MongoDB connection.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/muscles_data_mongodb/__init__.py
|
|
4
|
+
src/muscles_data_mongodb/adapter.py
|
|
5
|
+
src/muscles_data_mongodb.egg-info/PKG-INFO
|
|
6
|
+
src/muscles_data_mongodb.egg-info/SOURCES.txt
|
|
7
|
+
src/muscles_data_mongodb.egg-info/dependency_links.txt
|
|
8
|
+
src/muscles_data_mongodb.egg-info/requires.txt
|
|
9
|
+
src/muscles_data_mongodb.egg-info/top_level.txt
|
|
10
|
+
tests/test_integration.py
|
|
11
|
+
tests/test_mongodb_adapter.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
muscles_data_mongodb
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from muscles_data.catalog import DataAdapterCatalog
|
|
8
|
+
from muscles_data.config import DataConfig
|
|
9
|
+
from muscles_data.models import DataCapability
|
|
10
|
+
from muscles_data.ports import DocumentStorePort
|
|
11
|
+
from muscles_data.runtime import DataRuntime
|
|
12
|
+
|
|
13
|
+
from muscles_data_mongodb import MongoDocumentStoreFactory
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
pytestmark = [
|
|
17
|
+
pytest.mark.integration,
|
|
18
|
+
pytest.mark.skipif(not os.getenv("MUSCLES_DATA_INTEGRATION"), reason="backend integration is disabled"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_mongodb_real_document_lifecycle():
|
|
23
|
+
database = f"muscles_data_it_{uuid4().hex[:12]}"
|
|
24
|
+
config = DataConfig.from_raw(
|
|
25
|
+
{
|
|
26
|
+
"data": {
|
|
27
|
+
"resources": {
|
|
28
|
+
"mongo.content": {
|
|
29
|
+
"type": "mongodb",
|
|
30
|
+
"url_env": "MONGODB_URL",
|
|
31
|
+
"database": database,
|
|
32
|
+
"timeout": 3,
|
|
33
|
+
"native_client": True,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
catalog = DataAdapterCatalog.with_defaults()
|
|
40
|
+
catalog.register(MongoDocumentStoreFactory())
|
|
41
|
+
runtime = DataRuntime(config=config, catalog=catalog)
|
|
42
|
+
|
|
43
|
+
client = None
|
|
44
|
+
try:
|
|
45
|
+
store = runtime.require_port("mongo.content", DocumentStorePort)
|
|
46
|
+
contracts = pytest.importorskip("muscles_data.contracts")
|
|
47
|
+
contract = getattr(contracts, "assert_document_store_contract", None)
|
|
48
|
+
if contract is not None:
|
|
49
|
+
contract(lambda: store)
|
|
50
|
+
assert runtime.doctor()["status"] == "ok"
|
|
51
|
+
finally:
|
|
52
|
+
try:
|
|
53
|
+
if client is None:
|
|
54
|
+
try:
|
|
55
|
+
client = runtime.require_resource("mongo.content", DataCapability.NATIVE_CLIENT).native_client()
|
|
56
|
+
except Exception:
|
|
57
|
+
client = None
|
|
58
|
+
if client is not None:
|
|
59
|
+
client.drop_database(database)
|
|
60
|
+
finally:
|
|
61
|
+
runtime.close()
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from muscles_data.catalog import DataAdapterCatalog
|
|
8
|
+
from muscles_data.config import DataConfig
|
|
9
|
+
from muscles_data.models import DataCapability
|
|
10
|
+
from muscles_data.ports import DocumentStorePort
|
|
11
|
+
from muscles_data.runtime import DataRuntime
|
|
12
|
+
|
|
13
|
+
from muscles_data_mongodb import (
|
|
14
|
+
MongoDBClientMissingError,
|
|
15
|
+
MongoDBConfigError,
|
|
16
|
+
MongoDBConnectionError,
|
|
17
|
+
MongoDocumentStoreFactory,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FakeMongoAdmin:
|
|
22
|
+
def __init__(self, client: "FakeMongoClient") -> None:
|
|
23
|
+
self.client = client
|
|
24
|
+
|
|
25
|
+
def command(self, name: str):
|
|
26
|
+
self.client.admin_commands.append(name)
|
|
27
|
+
if self.client.fail_ping:
|
|
28
|
+
raise TimeoutError("mongodb password=mongo-secret timed out")
|
|
29
|
+
return {"ok": 1}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FakeMongoCursor:
|
|
33
|
+
def __init__(self, docs: list[dict[str, Any]]) -> None:
|
|
34
|
+
self.docs = docs
|
|
35
|
+
self.limits: list[int] = []
|
|
36
|
+
|
|
37
|
+
def limit(self, value: int):
|
|
38
|
+
self.limits.append(value)
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
def __iter__(self):
|
|
42
|
+
limit = self.limits[-1] if self.limits else len(self.docs)
|
|
43
|
+
return iter(self.docs[:limit])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FakeMongoCollection:
|
|
47
|
+
def __init__(self, name: str) -> None:
|
|
48
|
+
self.name = name
|
|
49
|
+
self.docs: dict[str, dict[str, Any]] = {}
|
|
50
|
+
self.find_one_calls: list[dict[str, Any]] = []
|
|
51
|
+
self.replace_one_calls: list[dict[str, Any]] = []
|
|
52
|
+
self.find_calls: list[dict[str, Any]] = []
|
|
53
|
+
self.delete_one_calls: list[dict[str, Any]] = []
|
|
54
|
+
self.last_cursor: FakeMongoCursor | None = None
|
|
55
|
+
|
|
56
|
+
def find_one(self, filter):
|
|
57
|
+
self.find_one_calls.append(dict(filter))
|
|
58
|
+
document_id = str(filter.get("_id"))
|
|
59
|
+
doc = self.docs.get(document_id)
|
|
60
|
+
return dict(doc) if doc is not None else None
|
|
61
|
+
|
|
62
|
+
def replace_one(self, filter, replacement, upsert: bool = False):
|
|
63
|
+
self.replace_one_calls.append({"filter": dict(filter), "replacement": dict(replacement), "upsert": upsert})
|
|
64
|
+
document_id = str(filter["_id"])
|
|
65
|
+
matched = 1 if document_id in self.docs else 0
|
|
66
|
+
self.docs[document_id] = dict(replacement)
|
|
67
|
+
return SimpleNamespace(matched_count=matched, modified_count=1 if matched else 0, upserted_id=None if matched else document_id)
|
|
68
|
+
|
|
69
|
+
def find(self, filter):
|
|
70
|
+
self.find_calls.append(dict(filter))
|
|
71
|
+
docs = [
|
|
72
|
+
dict(doc)
|
|
73
|
+
for doc in self.docs.values()
|
|
74
|
+
if all(doc.get(key) == value for key, value in dict(filter).items())
|
|
75
|
+
]
|
|
76
|
+
self.last_cursor = FakeMongoCursor(docs)
|
|
77
|
+
return self.last_cursor
|
|
78
|
+
|
|
79
|
+
def delete_one(self, filter):
|
|
80
|
+
self.delete_one_calls.append(dict(filter))
|
|
81
|
+
document_id = str(filter.get("_id"))
|
|
82
|
+
deleted = 1 if self.docs.pop(document_id, None) is not None else 0
|
|
83
|
+
return SimpleNamespace(deleted_count=deleted)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class FakeMongoDatabase:
|
|
87
|
+
def __init__(self, name: str) -> None:
|
|
88
|
+
self.name = name
|
|
89
|
+
self.collections: dict[str, FakeMongoCollection] = {}
|
|
90
|
+
|
|
91
|
+
def __getitem__(self, name: str) -> FakeMongoCollection:
|
|
92
|
+
return self.collections.setdefault(name, FakeMongoCollection(name))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class FakeMongoClient:
|
|
96
|
+
def __init__(self, *, fail_ping: bool = False) -> None:
|
|
97
|
+
self.fail_ping = fail_ping
|
|
98
|
+
self.admin = FakeMongoAdmin(self)
|
|
99
|
+
self.databases: dict[str, FakeMongoDatabase] = {}
|
|
100
|
+
self.admin_commands: list[str] = []
|
|
101
|
+
self.closed = False
|
|
102
|
+
|
|
103
|
+
def __getitem__(self, name: str) -> FakeMongoDatabase:
|
|
104
|
+
return self.databases.setdefault(name, FakeMongoDatabase(name))
|
|
105
|
+
|
|
106
|
+
def close(self) -> None:
|
|
107
|
+
self.closed = True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _mongodb_config(url: str = "mongodb://user:mongo-secret@localhost:27017") -> dict[str, Any]:
|
|
111
|
+
return {
|
|
112
|
+
"data": {
|
|
113
|
+
"resources": {
|
|
114
|
+
"mongo.content": {
|
|
115
|
+
"type": "mongodb",
|
|
116
|
+
"url": url,
|
|
117
|
+
"database": "content",
|
|
118
|
+
"timeout": 1.5,
|
|
119
|
+
"max_limit": 2,
|
|
120
|
+
"native_client": True,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _runtime(client: FakeMongoClient | None):
|
|
128
|
+
catalog = DataAdapterCatalog.with_defaults()
|
|
129
|
+
catalog.register(MongoDocumentStoreFactory(client_factory=lambda _config: client))
|
|
130
|
+
return DataRuntime(
|
|
131
|
+
config=DataConfig.from_raw(_mongodb_config()),
|
|
132
|
+
catalog=catalog,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_mongodb_document_store_is_registered_lazy_and_maps_operations():
|
|
137
|
+
client = FakeMongoClient()
|
|
138
|
+
runtime = _runtime(client)
|
|
139
|
+
|
|
140
|
+
listed = runtime.list_resources()
|
|
141
|
+
mongo = next(item for item in listed if item["name"] == "mongo.content")
|
|
142
|
+
inspected_before = runtime.inspect_resource("mongo.content")
|
|
143
|
+
|
|
144
|
+
assert mongo["type"] == "mongodb"
|
|
145
|
+
assert "document_store" in mongo["capabilities"]
|
|
146
|
+
assert "native_client" not in mongo["capabilities"]
|
|
147
|
+
assert mongo["initialized"] is False
|
|
148
|
+
assert inspected_before["initialized"] is False
|
|
149
|
+
assert inspected_before["options"]["url"] == "***"
|
|
150
|
+
|
|
151
|
+
store = runtime.require_port("mongo.content", DocumentStorePort)
|
|
152
|
+
write = store.upsert_document("profiles", "denis", {"name": "Denis", "role": "developer"})
|
|
153
|
+
found = store.get_document("profiles", "denis")
|
|
154
|
+
store.upsert_document("profiles", "reader", {"name": "Reader", "role": "developer"})
|
|
155
|
+
store.upsert_document("profiles", "other", {"name": "Other", "role": "developer"})
|
|
156
|
+
docs = store.find_documents("profiles", filters={"role": "developer"}, limit=10)
|
|
157
|
+
deleted = store.delete_document("profiles", "reader")
|
|
158
|
+
|
|
159
|
+
collection = client["content"]["profiles"]
|
|
160
|
+
assert write.written == 1
|
|
161
|
+
assert collection.replace_one_calls[0] == {
|
|
162
|
+
"filter": {"_id": "denis"},
|
|
163
|
+
"replacement": {"name": "Denis", "role": "developer", "_id": "denis"},
|
|
164
|
+
"upsert": True,
|
|
165
|
+
}
|
|
166
|
+
assert found == {"name": "Denis", "role": "developer"}
|
|
167
|
+
assert collection.find_one_calls[0] == {"_id": "denis"}
|
|
168
|
+
assert [doc["name"] for doc in docs] == ["Denis", "Reader"]
|
|
169
|
+
assert collection.last_cursor is not None
|
|
170
|
+
assert collection.last_cursor.limits == [2]
|
|
171
|
+
assert deleted.deleted == 1
|
|
172
|
+
assert collection.delete_one_calls[-1] == {"_id": "reader"}
|
|
173
|
+
|
|
174
|
+
native = runtime.require_resource("mongo.content", DataCapability.NATIVE_CLIENT).native_client()
|
|
175
|
+
assert native is client
|
|
176
|
+
assert client.admin_commands == []
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_mongodb_inspect_doctor_close_and_safe_failures():
|
|
180
|
+
client = FakeMongoClient()
|
|
181
|
+
runtime = _runtime(client)
|
|
182
|
+
|
|
183
|
+
doctor = runtime.doctor()
|
|
184
|
+
mongo_checks = [check for check in doctor["checks"] if check["resource"] == "mongo.content"]
|
|
185
|
+
|
|
186
|
+
assert mongo_checks[0]["status"] == "ok"
|
|
187
|
+
assert client.admin_commands == ["ping"]
|
|
188
|
+
assert "mongo-secret" not in repr(doctor)
|
|
189
|
+
assert runtime.close()["status"] == "ok"
|
|
190
|
+
assert client.closed is True
|
|
191
|
+
|
|
192
|
+
missing_client_runtime = _runtime(None)
|
|
193
|
+
with pytest.raises(MongoDBClientMissingError):
|
|
194
|
+
missing_client_runtime.require_port("mongo.content", DocumentStorePort).get_document("profiles", "denis")
|
|
195
|
+
|
|
196
|
+
failing_runtime = DataRuntime(
|
|
197
|
+
config=DataConfig.from_raw(_mongodb_config("mongodb://user:mongo-secret@localhost:27017")),
|
|
198
|
+
catalog=_catalog(MongoDocumentStoreFactory(client_factory=lambda _config: FakeMongoClient(fail_ping=True))),
|
|
199
|
+
)
|
|
200
|
+
failing_doctor = failing_runtime.doctor()
|
|
201
|
+
assert failing_doctor["status"] == "failed"
|
|
202
|
+
assert [check for check in failing_doctor["checks"] if check["resource"] == "mongo.content"][0]["status"] == "failed"
|
|
203
|
+
assert "mongo-secret" not in repr(failing_doctor)
|
|
204
|
+
|
|
205
|
+
bad_client = FakeMongoClient()
|
|
206
|
+
bad_client["content"]["profiles"].find_one = lambda _filter: (_ for _ in ()).throw(RuntimeError("mongodb://user:mongo-secret@localhost unavailable"))
|
|
207
|
+
bad_runtime = DataRuntime(
|
|
208
|
+
config=DataConfig.from_raw(_mongodb_config()),
|
|
209
|
+
catalog=_catalog(MongoDocumentStoreFactory(client_factory=lambda _config: bad_client)),
|
|
210
|
+
)
|
|
211
|
+
with pytest.raises(MongoDBConnectionError):
|
|
212
|
+
bad_runtime.require_port("mongo.content", DocumentStorePort).get_document("profiles", "denis")
|
|
213
|
+
|
|
214
|
+
unsupported_runtime = DataRuntime(
|
|
215
|
+
config=DataConfig.from_raw(
|
|
216
|
+
{
|
|
217
|
+
"data": {
|
|
218
|
+
"resources": {
|
|
219
|
+
"mongo.content": {
|
|
220
|
+
"type": "mongodb",
|
|
221
|
+
"url": "mongodb://localhost",
|
|
222
|
+
"database": "content",
|
|
223
|
+
"unsafe": True,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
),
|
|
229
|
+
catalog=_catalog(MongoDocumentStoreFactory(client_factory=lambda _config: FakeMongoClient())),
|
|
230
|
+
)
|
|
231
|
+
with pytest.raises(MongoDBConfigError, match="Unsupported MongoDB resource options"):
|
|
232
|
+
unsupported_runtime.require_port("mongo.content", DocumentStorePort).get_document("profiles", "denis")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_mongodb_resource_requires_url_and_database():
|
|
236
|
+
assert DataConfig.from_raw(_mongodb_config()).resources["mongo.content"].type == "mongodb"
|
|
237
|
+
|
|
238
|
+
with pytest.raises(MongoDBConfigError, match="requires database"):
|
|
239
|
+
MongoDocumentStoreFactory(client_factory=lambda _config: FakeMongoClient()).create(
|
|
240
|
+
DataConfig.from_raw(
|
|
241
|
+
{
|
|
242
|
+
"data": {
|
|
243
|
+
"resources": {
|
|
244
|
+
"mongo.content": {"type": "mongodb", "url": "mongodb://localhost"}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
).resources["mongo.content"]
|
|
249
|
+
).get_document("profiles", "denis")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _catalog(factory: MongoDocumentStoreFactory) -> DataAdapterCatalog:
|
|
253
|
+
catalog = DataAdapterCatalog.with_defaults()
|
|
254
|
+
catalog.register(factory)
|
|
255
|
+
return catalog
|