mongo-entity-orm 0.1.3__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mongo_entity_orm-0.1.3 → mongo_entity_orm-0.2.0}/PKG-INFO +1 -1
- {mongo_entity_orm-0.1.3 → mongo_entity_orm-0.2.0}/pyproject.toml +1 -1
- {mongo_entity_orm-0.1.3 → mongo_entity_orm-0.2.0}/src/mongo_orm/core.py +116 -52
- {mongo_entity_orm-0.1.3 → mongo_entity_orm-0.2.0}/src/mongo_orm/utils.py +8 -8
- {mongo_entity_orm-0.1.3 → mongo_entity_orm-0.2.0}/LICENSE +0 -0
- {mongo_entity_orm-0.1.3 → mongo_entity_orm-0.2.0}/README.md +0 -0
- {mongo_entity_orm-0.1.3 → mongo_entity_orm-0.2.0}/src/mongo_orm/__init__.py +0 -0
|
@@ -44,6 +44,16 @@ class Config:
|
|
|
44
44
|
return os.environ.get("MONGODB_INDEX_AUTOAPPLY", "never")
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def _build_sync_client(uri: str) -> MongoClient:
|
|
48
|
+
# TLS is inferred from the URI (mongodb+srv:// / ?tls=true); we only relax
|
|
49
|
+
# certificate validation. Keeps plain mongodb://localhost working.
|
|
50
|
+
return MongoClient(uri, tlsAllowInvalidCertificates=True)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _build_async_client(uri: str) -> AsyncIOMotorClient:
|
|
54
|
+
return AsyncIOMotorClient(uri, tlsAllowInvalidCertificates=True)
|
|
55
|
+
|
|
56
|
+
|
|
47
57
|
def encode_tenant_id(tenant_id, id):
|
|
48
58
|
return str(uuid5(NAMESPACE_URL, f"{id}/{tenant_id}"))
|
|
49
59
|
|
|
@@ -57,29 +67,79 @@ class Repository:
|
|
|
57
67
|
all_entities: List[Type["BaseEntity"]] = []
|
|
58
68
|
|
|
59
69
|
def __init__(self, mongo_db: MongoDatabase = None) -> None:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
self.async_mongo_client = AsyncIOMotorClient(
|
|
78
|
-
mongodb_uri, tls=True, tlsAllowInvalidCertificates=True
|
|
70
|
+
# Nothing connects here. Clients are created lazily on first use so that
|
|
71
|
+
# importing the package / decorating entities has no network or env
|
|
72
|
+
# side effects (e.g. before load_dotenv() has run).
|
|
73
|
+
self._mongo_db = mongo_db
|
|
74
|
+
self._mongo_client = None
|
|
75
|
+
# One async client per running event loop. A motor client binds to the
|
|
76
|
+
# loop it first runs on, so it must not be reused across loops.
|
|
77
|
+
self._async_clients = {}
|
|
78
|
+
|
|
79
|
+
def _connect_sync(self) -> None:
|
|
80
|
+
if self._mongo_client is not None:
|
|
81
|
+
return
|
|
82
|
+
mongodb_uri = Config.MONGODB_URI()
|
|
83
|
+
mongo_db_name = Config.MONGO_DATABASE_NAME()
|
|
84
|
+
if not mongodb_uri and not mongo_db_name:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
"MONGODB_URI and MONGO_DATABASE_NAME environment variables are not set"
|
|
79
87
|
)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
88
|
+
if not mongodb_uri:
|
|
89
|
+
raise ValueError("MONGODB_URI environment variable is not set")
|
|
90
|
+
if not mongo_db_name:
|
|
91
|
+
raise ValueError("MONGO_DATABASE_NAME environment variable is not set")
|
|
92
|
+
|
|
93
|
+
self._mongo_client = _build_sync_client(mongodb_uri)
|
|
94
|
+
self._mongo_client.admin.command("ping")
|
|
95
|
+
self._mongo_db = self._mongo_client[str(mongo_db_name)]
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def mongo_db(self) -> MongoDatabase:
|
|
99
|
+
if self._mongo_db is None:
|
|
100
|
+
self._connect_sync()
|
|
101
|
+
return self._mongo_db
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def mongo_client(self) -> MongoClient:
|
|
105
|
+
# If a db was injected, reuse its client instead of connecting via env.
|
|
106
|
+
if self._mongo_client is None and self._mongo_db is not None:
|
|
107
|
+
return self._mongo_db.client
|
|
108
|
+
if self._mongo_client is None:
|
|
109
|
+
self._connect_sync()
|
|
110
|
+
return self._mongo_client
|
|
111
|
+
|
|
112
|
+
def get_async_client(self) -> AsyncIOMotorClient:
|
|
113
|
+
loop = asyncio.get_running_loop()
|
|
114
|
+
client = self._async_clients.get(loop)
|
|
115
|
+
if client is None:
|
|
116
|
+
# Drop clients whose loop has been closed (e.g. between asyncio.run
|
|
117
|
+
# calls or pytest cases) so the cache doesn't grow unbounded.
|
|
118
|
+
for dead in [l for l in list(self._async_clients) if l.is_closed()]:
|
|
119
|
+
stale = self._async_clients.pop(dead, None)
|
|
120
|
+
if stale is not None:
|
|
121
|
+
try:
|
|
122
|
+
stale.close()
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
client = _build_async_client(Config.MONGODB_URI())
|
|
126
|
+
self._async_clients[loop] = client
|
|
127
|
+
return client
|
|
128
|
+
|
|
129
|
+
def get_async_db(self):
|
|
130
|
+
return self.get_async_client()[str(Config.MONGO_DATABASE_NAME())]
|
|
131
|
+
|
|
132
|
+
def reset_async_client(self) -> None:
|
|
133
|
+
try:
|
|
134
|
+
loop = asyncio.get_running_loop()
|
|
135
|
+
except RuntimeError:
|
|
136
|
+
return
|
|
137
|
+
stale = self._async_clients.pop(loop, None)
|
|
138
|
+
if stale is not None:
|
|
139
|
+
try:
|
|
140
|
+
stale.close()
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
83
143
|
|
|
84
144
|
|
|
85
145
|
REPOSITORY = Repository()
|
|
@@ -115,6 +175,19 @@ class IndexSpec(TypedDict, total=False):
|
|
|
115
175
|
weights: Dict[str, int]
|
|
116
176
|
|
|
117
177
|
|
|
178
|
+
class _CollectionProperty:
|
|
179
|
+
"""Descriptor that resolves the (sync) pymongo collection lazily.
|
|
180
|
+
|
|
181
|
+
Kept so that ``Entity.collection`` / ``instance.collection`` keep working for
|
|
182
|
+
external callers, while never binding a concrete collection (and thus never
|
|
183
|
+
connecting) at decoration/import time. Works for both class and instance
|
|
184
|
+
access.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def __get__(self, obj, objtype=None):
|
|
188
|
+
return (objtype or type(obj)).get_collection()
|
|
189
|
+
|
|
190
|
+
|
|
118
191
|
class BaseEntity(BaseModel):
|
|
119
192
|
id: str = Field(
|
|
120
193
|
default_factory=lambda: str(uuid4()),
|
|
@@ -129,7 +202,9 @@ class BaseEntity(BaseModel):
|
|
|
129
202
|
__indexes__: Optional[List[IndexSpec]] = None
|
|
130
203
|
|
|
131
204
|
def __init__(self, **data) -> None:
|
|
132
|
-
|
|
205
|
+
# Check __collection_name__ (a plain attr set by @entity) rather than
|
|
206
|
+
# `collection` (now a lazy descriptor whose access would connect).
|
|
207
|
+
if not hasattr(self.__class__, "__collection_name__"):
|
|
133
208
|
raise Exception(
|
|
134
209
|
"collection is not defined... did you forget to decorate the class with @metadata_entity?"
|
|
135
210
|
)
|
|
@@ -183,27 +258,22 @@ class BaseEntity(BaseModel):
|
|
|
183
258
|
callbacks.append(callback)
|
|
184
259
|
|
|
185
260
|
def _get_self_collection(self) -> Collection:
|
|
186
|
-
return self.
|
|
261
|
+
return self.get_collection()
|
|
187
262
|
|
|
188
263
|
@classmethod
|
|
189
264
|
def get_collection(cls) -> Collection:
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if collection is not None:
|
|
195
|
-
return collection
|
|
196
|
-
_cls = _cls.__base__ if issubclass(_cls.__base__, BaseEntity) else None
|
|
265
|
+
# Read the name first so an undecorated class raises AttributeError
|
|
266
|
+
# before REPOSITORY.mongo_db would trigger a connection.
|
|
267
|
+
name = cls.__collection_name__
|
|
268
|
+
return REPOSITORY.mongo_db[name]
|
|
197
269
|
|
|
198
270
|
@classmethod
|
|
199
|
-
def get_async_collection(cls, force_refresh=
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
return cls.acollection
|
|
271
|
+
def get_async_collection(cls, force_refresh: bool = False) -> AsyncIOMotorCollection:
|
|
272
|
+
# The async client is cached per running event loop (see Repository).
|
|
273
|
+
# force_refresh drops the current loop's client and rebuilds it.
|
|
274
|
+
if force_refresh:
|
|
275
|
+
REPOSITORY.reset_async_client()
|
|
276
|
+
return REPOSITORY.get_async_db()[cls.__collection_name__]
|
|
207
277
|
|
|
208
278
|
def get_id(self) -> str:
|
|
209
279
|
return self.id
|
|
@@ -315,7 +385,7 @@ class BaseEntity(BaseModel):
|
|
|
315
385
|
if tenant_id != "*":
|
|
316
386
|
filter["tenant_id"] = tenant_id
|
|
317
387
|
|
|
318
|
-
return cls.
|
|
388
|
+
return cls.get_collection().delete_one(filter).deleted_count == 1
|
|
319
389
|
|
|
320
390
|
@classmethod
|
|
321
391
|
def get(
|
|
@@ -330,7 +400,7 @@ class BaseEntity(BaseModel):
|
|
|
330
400
|
filter["tenant_id"] = tenant_id
|
|
331
401
|
if namespace:
|
|
332
402
|
filter["namespace"] = namespace
|
|
333
|
-
data = cls.
|
|
403
|
+
data = cls.get_collection().find_one(filter)
|
|
334
404
|
if not data and raise_not_found:
|
|
335
405
|
raise Exception(
|
|
336
406
|
f"Entity {cls.__name__} with id {id} not found for tenant {tenant_id}"
|
|
@@ -391,7 +461,7 @@ class BaseEntity(BaseModel):
|
|
|
391
461
|
if limit:
|
|
392
462
|
extra["limit"] = limit
|
|
393
463
|
|
|
394
|
-
return cls.
|
|
464
|
+
return cls.get_collection().count_documents(_filter, **extra)
|
|
395
465
|
|
|
396
466
|
@classmethod
|
|
397
467
|
async def acount(cls, tenant_id: str = "*", filter: dict = None, limit=None) -> int:
|
|
@@ -589,18 +659,12 @@ def entity(
|
|
|
589
659
|
collection_name: str, version_field: str = None, tracked_fields: List[str] = None
|
|
590
660
|
):
|
|
591
661
|
def decorator(cls: Type[T]) -> Type[T]:
|
|
592
|
-
if Config.MONGODB_URI() is None:
|
|
593
|
-
raise Exception(
|
|
594
|
-
"MONGODB_URI is not set in the environment variables. Please set it to connect to MongoDB."
|
|
595
|
-
)
|
|
596
|
-
if Config.MONGO_DATABASE_NAME() is None:
|
|
597
|
-
raise Exception(
|
|
598
|
-
"MONGO_DATABASE_NAME is not set in the environment variables. Please set it to connect to MongoDB."
|
|
599
|
-
)
|
|
600
662
|
cls.__collection_name__ = collection_name
|
|
601
|
-
|
|
663
|
+
# Lazy: resolves REPOSITORY.mongo_db[collection_name] on access, so
|
|
664
|
+
# decoration opens no connection and needs no env yet (env is validated
|
|
665
|
+
# at first DB use). Env-less imports before load_dotenv() are safe.
|
|
666
|
+
cls.collection = _CollectionProperty()
|
|
602
667
|
|
|
603
|
-
cls.acollection = None
|
|
604
668
|
if "id" in cls.model_fields:
|
|
605
669
|
cls.has_id = True
|
|
606
670
|
else:
|
|
@@ -17,11 +17,11 @@ def _get_index_hash(collection_name: str, spec: "IndexSpec") -> str:
|
|
|
17
17
|
# Normalize keys to a stable tuple representation
|
|
18
18
|
if isinstance(keys, dict):
|
|
19
19
|
# Keep order as provided in dict
|
|
20
|
-
norm_keys = tuple((str(k),
|
|
20
|
+
norm_keys = tuple((str(k), str(v)) for k, v in keys.items())
|
|
21
21
|
elif isinstance(keys, list):
|
|
22
|
-
norm_keys = tuple((str(k),
|
|
22
|
+
norm_keys = tuple((str(k), str(v)) for k, v in keys)
|
|
23
23
|
else:
|
|
24
|
-
norm_keys = ((str(keys), 1.0),)
|
|
24
|
+
norm_keys = ((str(keys), "1.0"),)
|
|
25
25
|
|
|
26
26
|
# Create a stable representation of the spec including important options
|
|
27
27
|
relevant_parts = {
|
|
@@ -48,10 +48,10 @@ def index_exists(collection: Collection, spec: "IndexSpec") -> bool:
|
|
|
48
48
|
elif isinstance(keys, list):
|
|
49
49
|
target_key = keys
|
|
50
50
|
else:
|
|
51
|
-
target_key = [(keys, 1)]
|
|
51
|
+
target_key = [(keys, "1.0")]
|
|
52
52
|
|
|
53
|
-
# Normalize values to
|
|
54
|
-
target_key = [(str(k),
|
|
53
|
+
# Normalize values to strings for comparison
|
|
54
|
+
target_key = [(str(k), str(v)) for k, v in target_key]
|
|
55
55
|
|
|
56
56
|
existing_indexes = collection.index_information()
|
|
57
57
|
|
|
@@ -59,9 +59,9 @@ def index_exists(collection: Collection, spec: "IndexSpec") -> bool:
|
|
|
59
59
|
# info['key'] can be a list of tuples or a SON/dict object
|
|
60
60
|
raw_existing_key = info["key"]
|
|
61
61
|
if hasattr(raw_existing_key, "items"):
|
|
62
|
-
existing_key = [(str(k),
|
|
62
|
+
existing_key = [(str(k), str(v)) for k, v in raw_existing_key.items()]
|
|
63
63
|
else:
|
|
64
|
-
existing_key = [(str(k),
|
|
64
|
+
existing_key = [(str(k), str(v)) for k, v in raw_existing_key]
|
|
65
65
|
|
|
66
66
|
if existing_key == target_key:
|
|
67
67
|
return True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|