lightodm 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lightodm/model.py ADDED
@@ -0,0 +1,613 @@
1
+ """
2
+ MongoDB Base Model for Pydantic
3
+
4
+ Provides ODM functionality for MongoDB with both sync and async support.
5
+ """
6
+
7
+ from typing import AsyncIterator, Iterator, List, Optional, Type, TypeVar
8
+
9
+ from bson import ObjectId
10
+ from motor.motor_asyncio import AsyncIOMotorCollection
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+ from pymongo.collection import Collection as PyMongoCollection
13
+
14
+ from lightodm.connection import get_async_database, get_collection
15
+
16
+ # TypeVar for generic class methods
17
+ T = TypeVar("T", bound="MongoBaseModel")
18
+
19
+
20
+ def generate_id() -> str:
21
+ """
22
+ Generate a new MongoDB ObjectId as a string.
23
+
24
+ Returns:
25
+ String representation of a new ObjectId
26
+ """
27
+ return str(ObjectId())
28
+
29
+
30
+ class MongoBaseModel(BaseModel):
31
+ """
32
+ Base class for MongoDB document models with ODM functionality.
33
+
34
+ Provides both synchronous and asynchronous methods for CRUD operations.
35
+ Maps Pydantic 'id' field to MongoDB '_id' field.
36
+
37
+ Subclasses must define an inner Settings class with 'name' attribute:
38
+
39
+ Example:
40
+ class User(MongoBaseModel):
41
+ class Settings:
42
+ name = "users"
43
+
44
+ name: str
45
+ email: str
46
+ age: Optional[int] = None
47
+
48
+ # Sync usage
49
+ user = User(name="John", email="john@example.com")
50
+ user.save()
51
+
52
+ found_user = User.get("some_id")
53
+ users = User.find({"age": {"$gt": 18}})
54
+
55
+ # Async usage
56
+ await user.asave()
57
+ found_user = await User.aget("some_id")
58
+ users = await User.afind({"age": {"$gt": 18}})
59
+ """
60
+
61
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
62
+
63
+ # ID field that maps to MongoDB _id
64
+ id: Optional[str] = Field(default_factory=generate_id, alias="_id")
65
+
66
+ # Settings inner class - must be overridden in subclasses
67
+ class Settings:
68
+ name: Optional[str] = None # MongoDB collection name
69
+
70
+ @classmethod
71
+ def _uses_mongo_id_alias(cls) -> bool:
72
+ field = cls.model_fields.get("id")
73
+ if field is None:
74
+ return False
75
+ alias = getattr(field, "serialization_alias", None) or getattr(field, "alias", None)
76
+ if alias is None:
77
+ alias = getattr(field, "validation_alias", None)
78
+ return alias == "_id"
79
+
80
+ def __init_subclass__(cls, **kwargs):
81
+ """
82
+ Validate Settings class is properly defined in subclass.
83
+ """
84
+ super().__init_subclass__(**kwargs)
85
+ # Skip validation for the base class itself
86
+ if cls.__name__ == "MongoBaseModel":
87
+ return
88
+
89
+ # Check if Settings class exists and has name attribute
90
+ if not hasattr(cls, "Settings"):
91
+ # Allow intermediate base classes without Settings
92
+ pass
93
+ elif hasattr(cls.Settings, "name") and cls.Settings.name is None:
94
+ # Settings exists but name is None - could be intermediate class
95
+ pass
96
+
97
+ @classmethod
98
+ def _validate_collection_name(cls):
99
+ """Ensure Settings.name is defined in subclass"""
100
+ if not hasattr(cls, "Settings"):
101
+ raise NotImplementedError(f"{cls.__name__} must define an inner 'Settings' class")
102
+ if not hasattr(cls.Settings, "name") or cls.Settings.name is None:
103
+ raise NotImplementedError(
104
+ f"{cls.__name__}.Settings must define 'name' attribute with the collection name"
105
+ )
106
+
107
+ @classmethod
108
+ def _get_collection_name(cls) -> str:
109
+ """Get the collection name from Settings.name"""
110
+ cls._validate_collection_name()
111
+ return cls.Settings.name
112
+
113
+ @classmethod
114
+ def get_collection(cls) -> PyMongoCollection:
115
+ """
116
+ Get synchronous MongoDB collection.
117
+
118
+ Override this method to provide custom connection logic.
119
+
120
+ Returns:
121
+ PyMongo Collection instance
122
+ """
123
+ collection_name = cls._get_collection_name()
124
+ return get_collection(collection_name)
125
+
126
+ @classmethod
127
+ async def get_async_collection(cls) -> AsyncIOMotorCollection:
128
+ """
129
+ Get asynchronous MongoDB collection.
130
+
131
+ Override this method to provide custom connection logic.
132
+
133
+ Returns:
134
+ Motor AsyncIOMotorCollection instance
135
+ """
136
+ collection_name = cls._get_collection_name()
137
+ db = await get_async_database()
138
+ return db[collection_name]
139
+
140
+ def _to_mongo_dict(self, exclude_none: bool = False) -> dict:
141
+ """
142
+ Convert model to dictionary for MongoDB, handling id -> _id mapping.
143
+
144
+ Only serializes Pydantic fields - class attributes like collection_name
145
+ are automatically excluded.
146
+
147
+ Args:
148
+ exclude_none: If True, exclude fields with None values
149
+
150
+ Returns:
151
+ Dictionary suitable for MongoDB insertion/update
152
+ """
153
+ data = self.model_dump(by_alias=True, exclude_none=exclude_none)
154
+ if not self._uses_mongo_id_alias():
155
+ if "id" in data and "_id" not in data:
156
+ data["_id"] = data.pop("id")
157
+ else:
158
+ data.pop("id", None)
159
+ # Manually add extra fields that were captured
160
+ extra_fields = self.__pydantic_extra__
161
+ if extra_fields:
162
+ for key, value in extra_fields.items():
163
+ if not exclude_none or value is not None:
164
+ data[key] = value
165
+ return data
166
+
167
+ @classmethod
168
+ def _from_mongo_dict(cls: Type[T], data: dict) -> Optional[T]:
169
+ """
170
+ Create model instance from MongoDB document.
171
+
172
+ Args:
173
+ data: MongoDB document dictionary
174
+
175
+ Returns:
176
+ Model instance or None if data is None
177
+ """
178
+ if data is None:
179
+ return None
180
+ if not cls._uses_mongo_id_alias() and "_id" in data and "id" not in data:
181
+ data = dict(data)
182
+ data["id"] = data["_id"]
183
+
184
+ return cls.model_validate(data)
185
+
186
+ # ==================== CRUD Operations (Sync) ====================
187
+
188
+ @classmethod
189
+ def get(cls: Type[T], id: str) -> Optional[T]:
190
+ """
191
+ Retrieve a document by ID (synchronous).
192
+
193
+ Args:
194
+ id: Document ID
195
+
196
+ Returns:
197
+ Model instance or None if not found
198
+ """
199
+ collection = cls.get_collection()
200
+ doc = collection.find_one({"_id": id})
201
+ return cls._from_mongo_dict(doc)
202
+
203
+ def save(self, exclude_none: bool = False) -> str:
204
+ """
205
+ Save/upsert the document (synchronous).
206
+
207
+ Args:
208
+ exclude_none: If True, exclude fields with None values from update
209
+
210
+ Returns:
211
+ Document ID
212
+ """
213
+ collection = self.get_collection()
214
+ data = self._to_mongo_dict(exclude_none=exclude_none)
215
+ doc_id = data.get("_id")
216
+ if doc_id is None:
217
+ raise ValueError("Document ID is required")
218
+
219
+ collection.replace_one({"_id": doc_id}, data, upsert=True)
220
+ return doc_id
221
+
222
+ def delete(self) -> bool:
223
+ """
224
+ Delete the document (synchronous).
225
+
226
+ Returns:
227
+ True if document was deleted, False otherwise
228
+ """
229
+ if not self.id:
230
+ return False
231
+
232
+ collection = self.get_collection()
233
+ result = collection.delete_one({"_id": self.id})
234
+ return result.deleted_count > 0
235
+
236
+ @classmethod
237
+ def find_one(cls: Type[T], filter: dict, **kwargs) -> Optional[T]:
238
+ """
239
+ Find a single document (synchronous).
240
+
241
+ Args:
242
+ filter: MongoDB filter dictionary
243
+ **kwargs: Additional arguments passed to find_one (e.g., sort, projection)
244
+
245
+ Returns:
246
+ Model instance or None if not found
247
+ """
248
+ collection = cls.get_collection()
249
+ doc = collection.find_one(filter, **kwargs)
250
+ return cls._from_mongo_dict(doc)
251
+
252
+ @classmethod
253
+ def find(cls: Type[T], filter: dict, **kwargs) -> List[T]:
254
+ """
255
+ Find multiple documents (synchronous).
256
+
257
+ Args:
258
+ filter: MongoDB filter dictionary
259
+ **kwargs: Additional arguments passed to find (e.g., sort, limit, skip, projection)
260
+
261
+ Returns:
262
+ List of model instances
263
+ """
264
+ collection = cls.get_collection()
265
+ cursor = collection.find(filter, **kwargs)
266
+ return [cls._from_mongo_dict(doc) for doc in cursor]
267
+
268
+ @classmethod
269
+ def find_iter(cls: Type[T], filter: dict, **kwargs) -> Iterator[T]:
270
+ """
271
+ Find multiple documents with iterator (synchronous).
272
+ Useful for large result sets to avoid loading all into memory.
273
+
274
+ Args:
275
+ filter: MongoDB filter dictionary
276
+ **kwargs: Additional arguments passed to find
277
+
278
+ Yields:
279
+ Model instances one at a time
280
+ """
281
+ collection = cls.get_collection()
282
+ cursor = collection.find(filter, **kwargs)
283
+ for doc in cursor:
284
+ yield cls._from_mongo_dict(doc)
285
+
286
+ @classmethod
287
+ def count(cls, filter: dict = None) -> int:
288
+ """
289
+ Count documents matching filter (synchronous).
290
+
291
+ Args:
292
+ filter: MongoDB filter dictionary (default: {} for all documents)
293
+
294
+ Returns:
295
+ Number of matching documents
296
+ """
297
+ collection = cls.get_collection()
298
+ return collection.count_documents(filter or {})
299
+
300
+ @classmethod
301
+ def update_one(cls, filter: dict, update: dict, upsert: bool = False) -> bool:
302
+ """
303
+ Update a single document (synchronous).
304
+
305
+ Args:
306
+ filter: MongoDB filter dictionary
307
+ update: MongoDB update dictionary (should include operators like $set)
308
+ upsert: If True, insert document if not found
309
+
310
+ Returns:
311
+ True if document was modified, False otherwise
312
+ """
313
+ collection = cls.get_collection()
314
+ result = collection.update_one(filter, update, upsert=upsert)
315
+ return result.modified_count > 0 or (upsert and result.upserted_id is not None)
316
+
317
+ @classmethod
318
+ def update_many(cls, filter: dict, update: dict) -> int:
319
+ """
320
+ Update multiple documents (synchronous).
321
+
322
+ Args:
323
+ filter: MongoDB filter dictionary
324
+ update: MongoDB update dictionary (should include operators like $set)
325
+
326
+ Returns:
327
+ Number of documents modified
328
+ """
329
+ collection = cls.get_collection()
330
+ result = collection.update_many(filter, update)
331
+ return result.modified_count
332
+
333
+ @classmethod
334
+ def delete_one(cls, filter: dict) -> bool:
335
+ """
336
+ Delete a single document (synchronous).
337
+
338
+ Args:
339
+ filter: MongoDB filter dictionary
340
+
341
+ Returns:
342
+ True if document was deleted, False otherwise
343
+ """
344
+ collection = cls.get_collection()
345
+ result = collection.delete_one(filter)
346
+ return result.deleted_count > 0
347
+
348
+ @classmethod
349
+ def delete_many(cls, filter: dict) -> int:
350
+ """
351
+ Delete multiple documents (synchronous).
352
+
353
+ Args:
354
+ filter: MongoDB filter dictionary
355
+
356
+ Returns:
357
+ Number of documents deleted
358
+ """
359
+ collection = cls.get_collection()
360
+ result = collection.delete_many(filter)
361
+ return result.deleted_count
362
+
363
+ # ==================== CRUD Operations (Async) ====================
364
+
365
+ @classmethod
366
+ async def aget(cls: Type[T], id: str) -> Optional[T]:
367
+ """
368
+ Retrieve a document by ID (asynchronous).
369
+
370
+ Args:
371
+ id: Document ID
372
+
373
+ Returns:
374
+ Model instance or None if not found
375
+ """
376
+ collection = await cls.get_async_collection()
377
+ doc = await collection.find_one({"_id": id})
378
+ return cls._from_mongo_dict(doc)
379
+
380
+ async def asave(self, exclude_none: bool = False) -> str:
381
+ """
382
+ Save/upsert the document (asynchronous).
383
+
384
+ Args:
385
+ exclude_none: If True, exclude fields with None values from update
386
+
387
+ Returns:
388
+ Document ID
389
+ """
390
+ collection = await self.get_async_collection()
391
+ data = self._to_mongo_dict(exclude_none=exclude_none)
392
+ doc_id = data.get("_id")
393
+ if doc_id is None:
394
+ raise ValueError("Document ID is required")
395
+
396
+ await collection.replace_one({"_id": doc_id}, data, upsert=True)
397
+ return doc_id
398
+
399
+ async def adelete(self) -> bool:
400
+ """
401
+ Delete the document (asynchronous).
402
+
403
+ Returns:
404
+ True if document was deleted, False otherwise
405
+ """
406
+ if not self.id:
407
+ return False
408
+
409
+ collection = await self.get_async_collection()
410
+ result = await collection.delete_one({"_id": self.id})
411
+ return result.deleted_count > 0
412
+
413
+ @classmethod
414
+ async def afind_one(cls: Type[T], filter: dict, **kwargs) -> Optional[T]:
415
+ """
416
+ Find a single document (asynchronous).
417
+
418
+ Args:
419
+ filter: MongoDB filter dictionary
420
+ **kwargs: Additional arguments passed to find_one (e.g., sort, projection)
421
+
422
+ Returns:
423
+ Model instance or None if not found
424
+ """
425
+ collection = await cls.get_async_collection()
426
+ doc = await collection.find_one(filter, **kwargs)
427
+ return cls._from_mongo_dict(doc)
428
+
429
+ @classmethod
430
+ async def afind(cls: Type[T], filter: dict, **kwargs) -> List[T]:
431
+ """
432
+ Find multiple documents (asynchronous).
433
+
434
+ Args:
435
+ filter: MongoDB filter dictionary
436
+ **kwargs: Additional arguments passed to find (e.g., sort, limit, skip, projection)
437
+
438
+ Returns:
439
+ List of model instances
440
+ """
441
+ collection = await cls.get_async_collection()
442
+ cursor = collection.find(filter, **kwargs)
443
+ docs = await cursor.to_list(length=None)
444
+ return [cls._from_mongo_dict(doc) for doc in docs]
445
+
446
+ @classmethod
447
+ async def afind_iter(cls: Type[T], filter: dict, **kwargs) -> AsyncIterator[T]:
448
+ """
449
+ Find multiple documents with async iterator.
450
+ Useful for large result sets to avoid loading all into memory.
451
+
452
+ Args:
453
+ filter: MongoDB filter dictionary
454
+ **kwargs: Additional arguments passed to find
455
+
456
+ Yields:
457
+ Model instances one at a time
458
+ """
459
+ collection = await cls.get_async_collection()
460
+ cursor = collection.find(filter, **kwargs)
461
+ async for doc in cursor:
462
+ yield cls._from_mongo_dict(doc)
463
+
464
+ @classmethod
465
+ async def acount(cls, filter: dict = None) -> int:
466
+ """
467
+ Count documents matching filter (asynchronous).
468
+
469
+ Args:
470
+ filter: MongoDB filter dictionary (default: {} for all documents)
471
+
472
+ Returns:
473
+ Number of matching documents
474
+ """
475
+ collection = await cls.get_async_collection()
476
+ return await collection.count_documents(filter or {})
477
+
478
+ @classmethod
479
+ async def aupdate_one(cls, filter: dict, update: dict, upsert: bool = False) -> bool:
480
+ """
481
+ Update a single document (asynchronous).
482
+
483
+ Args:
484
+ filter: MongoDB filter dictionary
485
+ update: MongoDB update dictionary (should include operators like $set)
486
+ upsert: If True, insert document if not found
487
+
488
+ Returns:
489
+ True if document was modified, False otherwise
490
+ """
491
+ collection = await cls.get_async_collection()
492
+ result = await collection.update_one(filter, update, upsert=upsert)
493
+ return result.modified_count > 0 or (upsert and result.upserted_id is not None)
494
+
495
+ @classmethod
496
+ async def aupdate_many(cls, filter: dict, update: dict) -> int:
497
+ """
498
+ Update multiple documents (asynchronous).
499
+
500
+ Args:
501
+ filter: MongoDB filter dictionary
502
+ update: MongoDB update dictionary (should include operators like $set)
503
+
504
+ Returns:
505
+ Number of documents modified
506
+ """
507
+ collection = await cls.get_async_collection()
508
+ result = await collection.update_many(filter, update)
509
+ return result.modified_count
510
+
511
+ @classmethod
512
+ async def adelete_one(cls, filter: dict) -> bool:
513
+ """
514
+ Delete a single document (asynchronous).
515
+
516
+ Args:
517
+ filter: MongoDB filter dictionary
518
+
519
+ Returns:
520
+ True if document was deleted, False otherwise
521
+ """
522
+ collection = await cls.get_async_collection()
523
+ result = await collection.delete_one(filter)
524
+ return result.deleted_count > 0
525
+
526
+ @classmethod
527
+ async def adelete_many(cls, filter: dict) -> int:
528
+ """
529
+ Delete multiple documents (asynchronous).
530
+
531
+ Args:
532
+ filter: MongoDB filter dictionary
533
+
534
+ Returns:
535
+ Number of documents deleted
536
+ """
537
+ collection = await cls.get_async_collection()
538
+ result = await collection.delete_many(filter)
539
+ return result.deleted_count
540
+
541
+ # ==================== Aggregation Operations ====================
542
+
543
+ @classmethod
544
+ def aggregate(cls: Type[T], pipeline: List[dict], **kwargs) -> List[dict]:
545
+ """
546
+ Run aggregation pipeline (synchronous).
547
+
548
+ Args:
549
+ pipeline: MongoDB aggregation pipeline
550
+ **kwargs: Additional arguments passed to aggregate
551
+
552
+ Returns:
553
+ List of result documents
554
+ """
555
+ collection = cls.get_collection()
556
+ cursor = collection.aggregate(pipeline, **kwargs)
557
+ return list(cursor)
558
+
559
+ @classmethod
560
+ async def aaggregate(cls: Type[T], pipeline: List[dict], **kwargs) -> List[dict]:
561
+ """
562
+ Run aggregation pipeline (asynchronous).
563
+
564
+ Args:
565
+ pipeline: MongoDB aggregation pipeline
566
+ **kwargs: Additional arguments passed to aggregate
567
+
568
+ Returns:
569
+ List of result documents
570
+ """
571
+ collection = await cls.get_async_collection()
572
+ cursor = collection.aggregate(pipeline, **kwargs)
573
+ return await cursor.to_list(length=None)
574
+
575
+ # ==================== Bulk Operations ====================
576
+
577
+ @classmethod
578
+ def insert_many(cls: Type[T], documents: List[T]) -> List[str]:
579
+ """
580
+ Insert multiple documents (synchronous).
581
+
582
+ Args:
583
+ documents: List of model instances
584
+
585
+ Returns:
586
+ List of inserted document IDs
587
+ """
588
+ if not documents:
589
+ return []
590
+
591
+ collection = cls.get_collection()
592
+ docs = [doc._to_mongo_dict() for doc in documents]
593
+ result = collection.insert_many(docs)
594
+ return [str(id) for id in result.inserted_ids]
595
+
596
+ @classmethod
597
+ async def ainsert_many(cls: Type[T], documents: List[T]) -> List[str]:
598
+ """
599
+ Insert multiple documents (asynchronous).
600
+
601
+ Args:
602
+ documents: List of model instances
603
+
604
+ Returns:
605
+ List of inserted document IDs
606
+ """
607
+ if not documents:
608
+ return []
609
+
610
+ collection = await cls.get_async_collection()
611
+ docs = [doc._to_mongo_dict() for doc in documents]
612
+ result = await collection.insert_many(docs)
613
+ return [str(id) for id in result.inserted_ids]
lightodm/py.typed ADDED
File without changes