lightodm 0.1.0__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.
- {lightodm-0.1.0 → lightodm-0.2.0}/CHANGELOG.md +19 -0
- {lightodm-0.1.0 → lightodm-0.2.0}/PKG-INFO +34 -2
- {lightodm-0.1.0 → lightodm-0.2.0}/README.md +31 -0
- {lightodm-0.1.0 → lightodm-0.2.0}/pyproject.toml +3 -2
- {lightodm-0.1.0 → lightodm-0.2.0}/src/lightodm/__init__.py +3 -2
- {lightodm-0.1.0 → lightodm-0.2.0}/src/lightodm/connection.py +3 -1
- {lightodm-0.1.0 → lightodm-0.2.0}/src/lightodm/model.py +45 -1
- lightodm-0.2.0/tests/test_async.py +311 -0
- lightodm-0.2.0/tests/test_composite_key.py +485 -0
- {lightodm-0.1.0 → lightodm-0.2.0}/tests/test_connection.py +88 -1
- lightodm-0.2.0/tests/test_edge_cases.py +446 -0
- lightodm-0.2.0/tests/test_sync.py +303 -0
- lightodm-0.1.0/tests/test_async.py +0 -107
- {lightodm-0.1.0 → lightodm-0.2.0}/.gitignore +0 -0
- {lightodm-0.1.0 → lightodm-0.2.0}/LICENSE +0 -0
- {lightodm-0.1.0 → lightodm-0.2.0}/src/lightodm/py.typed +0 -0
- {lightodm-0.1.0 → lightodm-0.2.0}/tests/conftest.py +0 -0
- {lightodm-0.1.0 → lightodm-0.2.0}/tests/test_model.py +0 -0
|
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.0] - 2026-02-05
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Composite key support** for deterministic ID generation
|
|
12
|
+
- New `composite_key` setting in `Settings` class
|
|
13
|
+
- IDs computed as MD5 hash of concatenated field values
|
|
14
|
+
- Enables multi-tenant applications with predictable document IDs
|
|
15
|
+
- New `generate_composite_id()` helper function exported from package
|
|
16
|
+
- Comprehensive integration tests for composite key functionality
|
|
17
|
+
- `CLAUDE.md` project guidance file for AI-assisted development
|
|
18
|
+
- Additional edge case tests improving code coverage
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- Improved API documentation formatting and clarity
|
|
22
|
+
- Updated test organization with clear unit/integration separation
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- Pydantic v2.11+ deprecation warning for `model_fields` access on instances
|
|
26
|
+
|
|
8
27
|
## [0.1.0] - 2026-01-17
|
|
9
28
|
|
|
10
29
|
### Added
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lightodm
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Lightweight MongoDB ODM - Simple async/sync Object-Document Mapper for MongoDB
|
|
5
5
|
Project-URL: Homepage, https://github.com/Aprova-GmbH/lightodm
|
|
6
6
|
Project-URL: Repository, https://github.com/Aprova-GmbH/lightodm
|
|
7
|
-
Project-URL: Documentation, https://
|
|
7
|
+
Project-URL: Documentation, https://lightodm.readthedocs.io
|
|
8
8
|
Project-URL: Issues, https://github.com/Aprova-GmbH/lightodm/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/Aprova-GmbH/lightodm/blob/main/CHANGELOG.md
|
|
9
10
|
Author-email: Andrey Vykhodtsev <vya@aprova.ch>
|
|
10
11
|
License: Apache-2.0
|
|
11
12
|
License-File: LICENSE
|
|
@@ -179,6 +180,37 @@ product = Product(name="Widget", sku="WDG-001")
|
|
|
179
180
|
print(product.id) # Generated ObjectId string
|
|
180
181
|
```
|
|
181
182
|
|
|
183
|
+
### Composite Keys
|
|
184
|
+
|
|
185
|
+
For multi-tenant applications or when you need deterministic IDs based on multiple fields, use composite keys:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from lightodm import MongoBaseModel
|
|
189
|
+
|
|
190
|
+
class TenantUser(MongoBaseModel):
|
|
191
|
+
class Settings:
|
|
192
|
+
name = "tenant_users"
|
|
193
|
+
composite_key = ["tenant_id", "user_id"] # Order matters
|
|
194
|
+
|
|
195
|
+
tenant_id: str
|
|
196
|
+
user_id: str
|
|
197
|
+
data: str
|
|
198
|
+
|
|
199
|
+
# ID is computed as MD5 hash of concatenated field values
|
|
200
|
+
user = TenantUser(tenant_id="tenant1", user_id="user1", data="test")
|
|
201
|
+
print(user.id) # e.g., "a1b2c3..." - always the same for same tenant_id + user_id
|
|
202
|
+
|
|
203
|
+
# Same values always produce the same ID (idempotent)
|
|
204
|
+
user2 = TenantUser(tenant_id="tenant1", user_id="user1", data="different")
|
|
205
|
+
assert user.id == user2.id # True - composite key only uses specified fields
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Composite key behavior:
|
|
209
|
+
- Field order in `composite_key` list determines concatenation order
|
|
210
|
+
- All composite key fields must have non-None values
|
|
211
|
+
- Composite key takes precedence over explicitly provided IDs
|
|
212
|
+
- Produces a 32-character hexadecimal MD5 hash
|
|
213
|
+
|
|
182
214
|
### Custom Connection
|
|
183
215
|
|
|
184
216
|
Override `get_collection()` or `get_async_collection()` for custom connection logic:
|
|
@@ -139,6 +139,37 @@ product = Product(name="Widget", sku="WDG-001")
|
|
|
139
139
|
print(product.id) # Generated ObjectId string
|
|
140
140
|
```
|
|
141
141
|
|
|
142
|
+
### Composite Keys
|
|
143
|
+
|
|
144
|
+
For multi-tenant applications or when you need deterministic IDs based on multiple fields, use composite keys:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from lightodm import MongoBaseModel
|
|
148
|
+
|
|
149
|
+
class TenantUser(MongoBaseModel):
|
|
150
|
+
class Settings:
|
|
151
|
+
name = "tenant_users"
|
|
152
|
+
composite_key = ["tenant_id", "user_id"] # Order matters
|
|
153
|
+
|
|
154
|
+
tenant_id: str
|
|
155
|
+
user_id: str
|
|
156
|
+
data: str
|
|
157
|
+
|
|
158
|
+
# ID is computed as MD5 hash of concatenated field values
|
|
159
|
+
user = TenantUser(tenant_id="tenant1", user_id="user1", data="test")
|
|
160
|
+
print(user.id) # e.g., "a1b2c3..." - always the same for same tenant_id + user_id
|
|
161
|
+
|
|
162
|
+
# Same values always produce the same ID (idempotent)
|
|
163
|
+
user2 = TenantUser(tenant_id="tenant1", user_id="user1", data="different")
|
|
164
|
+
assert user.id == user2.id # True - composite key only uses specified fields
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Composite key behavior:
|
|
168
|
+
- Field order in `composite_key` list determines concatenation order
|
|
169
|
+
- All composite key fields must have non-None values
|
|
170
|
+
- Composite key takes precedence over explicitly provided IDs
|
|
171
|
+
- Produces a 32-character hexadecimal MD5 hash
|
|
172
|
+
|
|
142
173
|
### Custom Connection
|
|
143
174
|
|
|
144
175
|
Override `get_collection()` or `get_async_collection()` for custom connection logic:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lightodm"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Lightweight MongoDB ODM - Simple async/sync Object-Document Mapper for MongoDB"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -58,8 +58,9 @@ dev = [
|
|
|
58
58
|
[project.urls]
|
|
59
59
|
Homepage = "https://github.com/Aprova-GmbH/lightodm"
|
|
60
60
|
Repository = "https://github.com/Aprova-GmbH/lightodm"
|
|
61
|
-
Documentation = "https://
|
|
61
|
+
Documentation = "https://lightodm.readthedocs.io"
|
|
62
62
|
Issues = "https://github.com/Aprova-GmbH/lightodm/issues"
|
|
63
|
+
Changelog = "https://github.com/Aprova-GmbH/lightodm/blob/main/CHANGELOG.md"
|
|
63
64
|
|
|
64
65
|
[tool.hatch.build.targets.wheel]
|
|
65
66
|
packages = ["src/lightodm"]
|
|
@@ -23,7 +23,7 @@ Example:
|
|
|
23
23
|
await user.asave()
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
|
-
__version__ = "0.
|
|
26
|
+
__version__ = "0.2.0"
|
|
27
27
|
|
|
28
28
|
from lightodm.connection import (
|
|
29
29
|
MongoConnection,
|
|
@@ -35,12 +35,13 @@ from lightodm.connection import (
|
|
|
35
35
|
get_database,
|
|
36
36
|
get_mongo_connection,
|
|
37
37
|
)
|
|
38
|
-
from lightodm.model import MongoBaseModel, generate_id
|
|
38
|
+
from lightodm.model import MongoBaseModel, generate_composite_id, generate_id
|
|
39
39
|
|
|
40
40
|
__all__ = [
|
|
41
41
|
# Model
|
|
42
42
|
"MongoBaseModel",
|
|
43
43
|
"generate_id",
|
|
44
|
+
"generate_composite_id",
|
|
44
45
|
# Connection
|
|
45
46
|
"MongoConnection",
|
|
46
47
|
"connect",
|
|
@@ -298,6 +298,7 @@ def connect(
|
|
|
298
298
|
Initialize MongoDB connection with optional explicit parameters.
|
|
299
299
|
|
|
300
300
|
If parameters are not provided, they will be read from environment variables:
|
|
301
|
+
|
|
301
302
|
- MONGO_URL
|
|
302
303
|
- MONGO_USER
|
|
303
304
|
- MONGO_PASSWORD
|
|
@@ -312,7 +313,8 @@ def connect(
|
|
|
312
313
|
Returns:
|
|
313
314
|
PyMongo Database instance
|
|
314
315
|
|
|
315
|
-
Example
|
|
316
|
+
Example::
|
|
317
|
+
|
|
316
318
|
# Connect with explicit parameters
|
|
317
319
|
db = connect(
|
|
318
320
|
url="mongodb://localhost:27017",
|
|
@@ -4,11 +4,12 @@ MongoDB Base Model for Pydantic
|
|
|
4
4
|
Provides ODM functionality for MongoDB with both sync and async support.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import hashlib
|
|
7
8
|
from typing import AsyncIterator, Iterator, List, Optional, Type, TypeVar
|
|
8
9
|
|
|
9
10
|
from bson import ObjectId
|
|
10
11
|
from motor.motor_asyncio import AsyncIOMotorCollection
|
|
11
|
-
from pydantic import BaseModel, ConfigDict, Field
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
12
13
|
from pymongo.collection import Collection as PyMongoCollection
|
|
13
14
|
|
|
14
15
|
from lightodm.connection import get_async_database, get_collection
|
|
@@ -27,6 +28,20 @@ def generate_id() -> str:
|
|
|
27
28
|
return str(ObjectId())
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
def generate_composite_id(values: list) -> str:
|
|
32
|
+
"""
|
|
33
|
+
Generate MD5 hash from list of values for composite key.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
values: List of values to concatenate and hash
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
32-character hexadecimal MD5 hash string
|
|
40
|
+
"""
|
|
41
|
+
concatenated = "".join(str(v) for v in values)
|
|
42
|
+
return hashlib.md5(concatenated.encode("utf-8")).hexdigest()
|
|
43
|
+
|
|
44
|
+
|
|
30
45
|
class MongoBaseModel(BaseModel):
|
|
31
46
|
"""
|
|
32
47
|
Base class for MongoDB document models with ODM functionality.
|
|
@@ -66,6 +81,7 @@ class MongoBaseModel(BaseModel):
|
|
|
66
81
|
# Settings inner class - must be overridden in subclasses
|
|
67
82
|
class Settings:
|
|
68
83
|
name: Optional[str] = None # MongoDB collection name
|
|
84
|
+
composite_key: Optional[List[str]] = None # Fields for composite key ID
|
|
69
85
|
|
|
70
86
|
@classmethod
|
|
71
87
|
def _uses_mongo_id_alias(cls) -> bool:
|
|
@@ -77,6 +93,34 @@ class MongoBaseModel(BaseModel):
|
|
|
77
93
|
alias = getattr(field, "validation_alias", None)
|
|
78
94
|
return alias == "_id"
|
|
79
95
|
|
|
96
|
+
@model_validator(mode="after")
|
|
97
|
+
def _compute_composite_key(self):
|
|
98
|
+
"""Compute composite key ID if defined in Settings."""
|
|
99
|
+
composite_key = getattr(self.Settings, "composite_key", None)
|
|
100
|
+
|
|
101
|
+
if composite_key is None:
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
if not isinstance(composite_key, (list, tuple)) or len(composite_key) == 0:
|
|
105
|
+
raise ValueError("composite_key must be a non-empty list")
|
|
106
|
+
|
|
107
|
+
values = []
|
|
108
|
+
for field_name in composite_key:
|
|
109
|
+
# Access model_fields from the class, not the instance (Pydantic v2.11+)
|
|
110
|
+
if field_name not in self.__class__.model_fields and not hasattr(self, field_name):
|
|
111
|
+
raise ValueError(f"Composite key field '{field_name}' not found in model")
|
|
112
|
+
|
|
113
|
+
value = getattr(self, field_name, None)
|
|
114
|
+
if value is None:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"Composite key field '{field_name}' is None. All fields must have values."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
values.append(value)
|
|
120
|
+
|
|
121
|
+
self.id = generate_composite_id(values)
|
|
122
|
+
return self
|
|
123
|
+
|
|
80
124
|
def __init_subclass__(cls, **kwargs):
|
|
81
125
|
"""
|
|
82
126
|
Validate Settings class is properly defined in subclass.
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Tests for async operations in lightodm."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from lightodm import MongoBaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AsyncTestModel(MongoBaseModel):
|
|
9
|
+
"""Test model for async operations."""
|
|
10
|
+
|
|
11
|
+
class Settings:
|
|
12
|
+
name = "async_test_collection"
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
value: int
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.mark.integration
|
|
19
|
+
@pytest.mark.asyncio
|
|
20
|
+
async def test_async_save_and_get(cleanup_test_collections):
|
|
21
|
+
"""Test async save and get operations with real MongoDB."""
|
|
22
|
+
# Create and save - uses real MongoDB via environment variables
|
|
23
|
+
model = AsyncTestModel(name="async_test", value=42)
|
|
24
|
+
doc_id = await model.asave()
|
|
25
|
+
|
|
26
|
+
assert doc_id == model.id
|
|
27
|
+
|
|
28
|
+
# Retrieve - uses real MongoDB
|
|
29
|
+
retrieved = await AsyncTestModel.aget(doc_id)
|
|
30
|
+
assert retrieved is not None
|
|
31
|
+
assert retrieved.name == "async_test"
|
|
32
|
+
assert retrieved.value == 42
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.mark.integration
|
|
36
|
+
@pytest.mark.asyncio
|
|
37
|
+
async def test_async_find(cleanup_test_collections):
|
|
38
|
+
"""Test async find operations with real MongoDB."""
|
|
39
|
+
# Create multiple documents
|
|
40
|
+
models = [AsyncTestModel(name=f"test_{i}", value=i) for i in range(5)]
|
|
41
|
+
|
|
42
|
+
for model in models:
|
|
43
|
+
await model.asave()
|
|
44
|
+
|
|
45
|
+
# Find all
|
|
46
|
+
results = await AsyncTestModel.afind({})
|
|
47
|
+
assert len(results) == 5
|
|
48
|
+
|
|
49
|
+
# Find with filter
|
|
50
|
+
results = await AsyncTestModel.afind({"value": {"$gte": 3}})
|
|
51
|
+
assert len(results) == 2
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.integration
|
|
55
|
+
@pytest.mark.asyncio
|
|
56
|
+
async def test_async_delete(cleanup_test_collections):
|
|
57
|
+
"""Test async delete operation with real MongoDB."""
|
|
58
|
+
# Create and save
|
|
59
|
+
model = AsyncTestModel(name="to_delete", value=100)
|
|
60
|
+
await model.asave()
|
|
61
|
+
|
|
62
|
+
# Verify it exists
|
|
63
|
+
retrieved = await AsyncTestModel.aget(model.id)
|
|
64
|
+
assert retrieved is not None
|
|
65
|
+
|
|
66
|
+
# Delete
|
|
67
|
+
deleted = await model.adelete()
|
|
68
|
+
assert deleted is True
|
|
69
|
+
|
|
70
|
+
# Verify it's gone
|
|
71
|
+
retrieved = await AsyncTestModel.aget(model.id)
|
|
72
|
+
assert retrieved is None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.integration
|
|
76
|
+
@pytest.mark.asyncio
|
|
77
|
+
async def test_async_update(cleanup_test_collections):
|
|
78
|
+
"""Test async update operations with real MongoDB."""
|
|
79
|
+
# Create initial document
|
|
80
|
+
model = AsyncTestModel(name="original", value=10)
|
|
81
|
+
await model.asave()
|
|
82
|
+
|
|
83
|
+
# Update
|
|
84
|
+
success = await AsyncTestModel.aupdate_one({"_id": model.id}, {"$set": {"value": 20}})
|
|
85
|
+
assert success is True
|
|
86
|
+
|
|
87
|
+
# Verify update
|
|
88
|
+
retrieved = await AsyncTestModel.aget(model.id)
|
|
89
|
+
assert retrieved.value == 20
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.mark.integration
|
|
93
|
+
@pytest.mark.asyncio
|
|
94
|
+
async def test_async_count(cleanup_test_collections):
|
|
95
|
+
"""Test async count operation with real MongoDB."""
|
|
96
|
+
# Create documents
|
|
97
|
+
for i in range(3):
|
|
98
|
+
model = AsyncTestModel(name=f"count_test_{i}", value=i)
|
|
99
|
+
await model.asave()
|
|
100
|
+
|
|
101
|
+
# Count all
|
|
102
|
+
count = await AsyncTestModel.acount()
|
|
103
|
+
assert count == 3
|
|
104
|
+
|
|
105
|
+
# Count with filter
|
|
106
|
+
count = await AsyncTestModel.acount({"value": {"$gt": 0}})
|
|
107
|
+
assert count == 2
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.integration
|
|
111
|
+
@pytest.mark.asyncio
|
|
112
|
+
async def test_async_find_one(cleanup_test_collections):
|
|
113
|
+
"""Test async find_one operation with real MongoDB."""
|
|
114
|
+
# Create documents
|
|
115
|
+
model1 = AsyncTestModel(name="first", value=1)
|
|
116
|
+
model2 = AsyncTestModel(name="second", value=2)
|
|
117
|
+
await model1.asave()
|
|
118
|
+
await model2.asave()
|
|
119
|
+
|
|
120
|
+
# Find one by filter
|
|
121
|
+
result = await AsyncTestModel.afind_one({"name": "second"})
|
|
122
|
+
assert result is not None
|
|
123
|
+
assert result.name == "second"
|
|
124
|
+
assert result.value == 2
|
|
125
|
+
|
|
126
|
+
# Find one non-existent
|
|
127
|
+
result = await AsyncTestModel.afind_one({"name": "nonexistent"})
|
|
128
|
+
assert result is None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@pytest.mark.integration
|
|
132
|
+
@pytest.mark.asyncio
|
|
133
|
+
async def test_async_find_iter(cleanup_test_collections):
|
|
134
|
+
"""Test async find_iter operation with real MongoDB."""
|
|
135
|
+
# Create documents
|
|
136
|
+
models = [AsyncTestModel(name=f"iter_{i}", value=i) for i in range(3)]
|
|
137
|
+
for model in models:
|
|
138
|
+
await model.asave()
|
|
139
|
+
|
|
140
|
+
# Use async iterator
|
|
141
|
+
results = []
|
|
142
|
+
async for doc in AsyncTestModel.afind_iter({}):
|
|
143
|
+
results.append(doc)
|
|
144
|
+
|
|
145
|
+
assert len(results) == 3
|
|
146
|
+
assert all(isinstance(r, AsyncTestModel) for r in results)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.mark.integration
|
|
150
|
+
@pytest.mark.asyncio
|
|
151
|
+
async def test_async_update_many(cleanup_test_collections):
|
|
152
|
+
"""Test async update_many operation with real MongoDB."""
|
|
153
|
+
# Create documents
|
|
154
|
+
for i in range(5):
|
|
155
|
+
model = AsyncTestModel(name=f"batch_{i}", value=i)
|
|
156
|
+
await model.asave()
|
|
157
|
+
|
|
158
|
+
# Update multiple
|
|
159
|
+
modified_count = await AsyncTestModel.aupdate_many(
|
|
160
|
+
{"value": {"$gte": 3}}, {"$set": {"name": "updated"}}
|
|
161
|
+
)
|
|
162
|
+
assert modified_count == 2
|
|
163
|
+
|
|
164
|
+
# Verify updates
|
|
165
|
+
results = await AsyncTestModel.afind({"name": "updated"})
|
|
166
|
+
assert len(results) == 2
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pytest.mark.integration
|
|
170
|
+
@pytest.mark.asyncio
|
|
171
|
+
async def test_async_delete_one(cleanup_test_collections):
|
|
172
|
+
"""Test async delete_one operation with real MongoDB."""
|
|
173
|
+
# Create documents
|
|
174
|
+
model1 = AsyncTestModel(name="delete_me", value=1)
|
|
175
|
+
model2 = AsyncTestModel(name="delete_me", value=2)
|
|
176
|
+
await model1.asave()
|
|
177
|
+
await model2.asave()
|
|
178
|
+
|
|
179
|
+
# Delete one
|
|
180
|
+
deleted = await AsyncTestModel.adelete_one({"name": "delete_me"})
|
|
181
|
+
assert deleted is True
|
|
182
|
+
|
|
183
|
+
# Verify only one was deleted
|
|
184
|
+
count = await AsyncTestModel.acount({"name": "delete_me"})
|
|
185
|
+
assert count == 1
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@pytest.mark.integration
|
|
189
|
+
@pytest.mark.asyncio
|
|
190
|
+
async def test_async_delete_one_no_match(cleanup_test_collections):
|
|
191
|
+
"""Test adelete_one returns False when no document matches."""
|
|
192
|
+
deleted = await AsyncTestModel.adelete_one({"name": "nonexistent"})
|
|
193
|
+
assert deleted is False
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@pytest.mark.integration
|
|
197
|
+
@pytest.mark.asyncio
|
|
198
|
+
async def test_async_delete_many(cleanup_test_collections):
|
|
199
|
+
"""Test async delete_many operation with real MongoDB."""
|
|
200
|
+
# Create documents
|
|
201
|
+
for i in range(5):
|
|
202
|
+
model = AsyncTestModel(name=f"batch_delete_{i}", value=i)
|
|
203
|
+
await model.asave()
|
|
204
|
+
|
|
205
|
+
# Delete multiple
|
|
206
|
+
deleted_count = await AsyncTestModel.adelete_many({"value": {"$lt": 3}})
|
|
207
|
+
assert deleted_count == 3
|
|
208
|
+
|
|
209
|
+
# Verify remaining
|
|
210
|
+
count = await AsyncTestModel.acount()
|
|
211
|
+
assert count == 2
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@pytest.mark.integration
|
|
215
|
+
@pytest.mark.asyncio
|
|
216
|
+
async def test_async_aggregate(cleanup_test_collections):
|
|
217
|
+
"""Test async aggregate operation with real MongoDB."""
|
|
218
|
+
# Create documents
|
|
219
|
+
for i in range(5):
|
|
220
|
+
model = AsyncTestModel(name=f"agg_{i}", value=i)
|
|
221
|
+
await model.asave()
|
|
222
|
+
|
|
223
|
+
# Run aggregation
|
|
224
|
+
pipeline = [{"$match": {"value": {"$gte": 2}}}, {"$count": "total"}]
|
|
225
|
+
results = await AsyncTestModel.aaggregate(pipeline)
|
|
226
|
+
|
|
227
|
+
assert len(results) == 1
|
|
228
|
+
assert results[0]["total"] == 3
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@pytest.mark.integration
|
|
232
|
+
@pytest.mark.asyncio
|
|
233
|
+
async def test_async_insert_many(cleanup_test_collections):
|
|
234
|
+
"""Test async insert_many operation with real MongoDB."""
|
|
235
|
+
# Create models
|
|
236
|
+
models = [AsyncTestModel(name=f"bulk_{i}", value=i) for i in range(3)]
|
|
237
|
+
|
|
238
|
+
# Insert many
|
|
239
|
+
ids = await AsyncTestModel.ainsert_many(models)
|
|
240
|
+
|
|
241
|
+
assert len(ids) == 3
|
|
242
|
+
assert all(isinstance(id, str) for id in ids)
|
|
243
|
+
|
|
244
|
+
# Verify all were inserted
|
|
245
|
+
count = await AsyncTestModel.acount()
|
|
246
|
+
assert count == 3
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@pytest.mark.integration
|
|
250
|
+
@pytest.mark.asyncio
|
|
251
|
+
async def test_async_insert_many_empty(cleanup_test_collections):
|
|
252
|
+
"""Test ainsert_many with empty list."""
|
|
253
|
+
ids = await AsyncTestModel.ainsert_many([])
|
|
254
|
+
assert ids == []
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@pytest.mark.integration
|
|
258
|
+
@pytest.mark.asyncio
|
|
259
|
+
async def test_async_delete_without_id(cleanup_test_collections):
|
|
260
|
+
"""Test adelete returns False when ID is not set."""
|
|
261
|
+
model = AsyncTestModel(name="test", value=1)
|
|
262
|
+
model.id = None
|
|
263
|
+
assert await model.adelete() is False
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@pytest.mark.integration
|
|
267
|
+
@pytest.mark.asyncio
|
|
268
|
+
async def test_async_save_exclude_none(cleanup_test_collections):
|
|
269
|
+
"""Test async save with exclude_none parameter."""
|
|
270
|
+
|
|
271
|
+
# Create model with optional field
|
|
272
|
+
class ModelWithOptional(AsyncTestModel):
|
|
273
|
+
optional: str = None
|
|
274
|
+
|
|
275
|
+
model = ModelWithOptional(name="test", value=42)
|
|
276
|
+
doc_id = await model.asave(exclude_none=True)
|
|
277
|
+
|
|
278
|
+
# Verify it was saved
|
|
279
|
+
retrieved = await AsyncTestModel.aget(doc_id)
|
|
280
|
+
assert retrieved is not None
|
|
281
|
+
assert retrieved.name == "test"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@pytest.mark.integration
|
|
285
|
+
@pytest.mark.asyncio
|
|
286
|
+
async def test_async_update_one_upsert(cleanup_test_collections):
|
|
287
|
+
"""Test async update_one with upsert."""
|
|
288
|
+
from lightodm import generate_id
|
|
289
|
+
|
|
290
|
+
# Update non-existent document with upsert
|
|
291
|
+
# Provide _id as string to avoid ObjectId generation
|
|
292
|
+
new_id = generate_id()
|
|
293
|
+
success = await AsyncTestModel.aupdate_one(
|
|
294
|
+
{"_id": new_id}, {"$set": {"_id": new_id, "name": "new_doc", "value": 99}}, upsert=True
|
|
295
|
+
)
|
|
296
|
+
assert success is True
|
|
297
|
+
|
|
298
|
+
# Verify it was created
|
|
299
|
+
result = await AsyncTestModel.afind_one({"name": "new_doc"})
|
|
300
|
+
assert result is not None
|
|
301
|
+
assert result.name == "new_doc"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@pytest.mark.integration
|
|
305
|
+
@pytest.mark.asyncio
|
|
306
|
+
async def test_async_update_one_no_match(cleanup_test_collections):
|
|
307
|
+
"""Test aupdate_one returns False when no document matches."""
|
|
308
|
+
success = await AsyncTestModel.aupdate_one(
|
|
309
|
+
{"name": "nonexistent"}, {"$set": {"value": 999}}, upsert=False
|
|
310
|
+
)
|
|
311
|
+
assert success is False
|