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.
@@ -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.1.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://github.com/Aprova-GmbH/lightodm#readme
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.1.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://github.com/Aprova-GmbH/lightodm#readme"
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.1.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