objectdb 0.7.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.
@@ -0,0 +1,13 @@
1
+ .mypy_cache
2
+ .pytest_cache
3
+ __pycache__
4
+ .coverage
5
+ *.py,cover
6
+ /build/
7
+ /metrics/
8
+ /.devcontainer/.user_env
9
+ dist/
10
+
11
+ # Secrets
12
+ .env
13
+ .rustc_info.json
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: objectdb
3
+ Version: 0.7.0
4
+ Author: Felix Trautwein <mail@felixtrautwein.de>
5
+ Requires-Dist: bson>=0.5.10
6
+ Requires-Dist: mongomock-motor>=0.0.36
7
+ Requires-Dist: motor>=3.7.1
8
+ Requires-Dist: pytest-asyncio>=1.2.0
File without changes
@@ -0,0 +1,17 @@
1
+ [project]
2
+ name = "objectdb"
3
+ version = "0.7.0"
4
+ description = ""
5
+ authors = [{name = "Felix Trautwein <mail@felixtrautwein.de>"}]
6
+
7
+
8
+ dependencies = [
9
+ "motor>=3.7.1",
10
+ "mongomock_motor>=0.0.36",
11
+ "pytest-asyncio>=1.2.0",
12
+ "bson>=0.5.10"
13
+ ]
14
+
15
+ [build-system]
16
+ requires = ["hatchling"]
17
+ build-backend = "hatchling.build"
File without changes
@@ -0,0 +1,200 @@
1
+ """Database abstraction layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from abc import ABC, abstractmethod
7
+ from typing import Any, Dict, Generic, Optional, Type, TypeVar
8
+
9
+ import pydantic
10
+ from bson import ObjectId
11
+ from pydantic import GetCoreSchemaHandler
12
+ from pydantic_core import core_schema
13
+
14
+ T = TypeVar("T", bound="DatabaseItem")
15
+
16
+
17
+ class ForeignKey(Generic[T]):
18
+ """A reference to another DatabaseItem."""
19
+
20
+ def __init__(self, target_type: type[T], identifier: str):
21
+ self.target_type = target_type
22
+ self.identifier = identifier
23
+
24
+ def __eq__(self, other: object) -> bool:
25
+ return (
26
+ isinstance(other, ForeignKey)
27
+ and self.target_type == other.target_type
28
+ and self.identifier == other.identifier
29
+ )
30
+
31
+ def __hash__(self) -> int:
32
+ return hash((self.target_type, self.identifier))
33
+
34
+ def __repr__(self) -> str:
35
+ return f"ForeignKey({self.target_type.__name__}:{self.identifier})"
36
+
37
+ #
38
+ # --- Pydantic integration ---
39
+ #
40
+ @classmethod
41
+ def __class_getitem__(cls, item: type[T]):
42
+ target_type = item
43
+
44
+ class _ForeignKey(cls): # type: ignore
45
+ __origin__ = cls
46
+ __args__ = (item,)
47
+
48
+ @classmethod
49
+ def __get_pydantic_core_schema__(cls, source_type, handler: GetCoreSchemaHandler):
50
+ def validator(v):
51
+ if isinstance(v, ForeignKey):
52
+ return v
53
+ if isinstance(v, target_type):
54
+ return ForeignKey(target_type, v.identifier)
55
+ if isinstance(v, str):
56
+ return ForeignKey(target_type, v)
57
+ raise TypeError(f"Cannot convert {v!r} to ForeignKey[{target_type.__name__}]")
58
+
59
+ return core_schema.no_info_after_validator_function(
60
+ validator,
61
+ core_schema.union_schema(
62
+ [
63
+ core_schema.is_instance_schema(target_type),
64
+ core_schema.str_schema(),
65
+ core_schema.is_instance_schema(ForeignKey),
66
+ ]
67
+ ),
68
+ )
69
+
70
+ @classmethod
71
+ def __get_pydantic_json_schema__(cls, _core_schema, handler):
72
+ # Expose as string in OpenAPI
73
+ return handler(core_schema.str_schema())
74
+
75
+ return _ForeignKey
76
+
77
+
78
+ class PyObjectId(ObjectId):
79
+ """Custom ObjectId type for Pydantic."""
80
+
81
+ @classmethod
82
+ def __get_pydantic_core_schema__(cls, _source, _handler) -> core_schema.PlainValidatorFunctionSchema:
83
+ return core_schema.no_info_plain_validator_function(cls.validate)
84
+
85
+ @classmethod
86
+ def validate(cls, v: Any) -> PyObjectId:
87
+ """Validate and convert to ObjectId."""
88
+ if isinstance(v, ObjectId):
89
+ return cls(v)
90
+ if not ObjectId.is_valid(v):
91
+ raise ValueError(f"Invalid ObjectId: {v}")
92
+ return cls(v)
93
+
94
+
95
+ class DatabaseItem(ABC, pydantic.BaseModel):
96
+ """Base class for database items."""
97
+
98
+ model_config = pydantic.ConfigDict(
99
+ revalidate_instances="always", json_encoders={ObjectId: str}, populate_by_name=True
100
+ )
101
+ identifier: PyObjectId = pydantic.Field(default_factory=PyObjectId, alias="_id")
102
+
103
+ def __eq__(self, other: object) -> bool:
104
+ if not isinstance(other, DatabaseItem):
105
+ return NotImplemented
106
+ return self.identifier == other.identifier
107
+
108
+ def __hash__(self) -> int:
109
+ return hash(self.identifier)
110
+
111
+
112
+ class DatabaseError(Exception):
113
+ """Errors related to database operations."""
114
+
115
+
116
+ class UnknownEntityError(DatabaseError):
117
+ """Requested entity does not exist."""
118
+
119
+
120
+ class Database(ABC):
121
+ """Database abstraction."""
122
+
123
+ @abstractmethod
124
+ async def update(self, item: DatabaseItem) -> None:
125
+ """Update entity."""
126
+
127
+ @abstractmethod
128
+ async def get(self, schema: Type[T], identifier: PyObjectId) -> T:
129
+ """Return entity, raise UnknownEntityError if entity does not exist."""
130
+
131
+ @abstractmethod
132
+ async def get_all(self, schema: Type[T]) -> Dict[str, T]:
133
+ """Return all entities of schema."""
134
+
135
+ @abstractmethod
136
+ async def delete(self, schema: Type[T], identifier: PyObjectId, cascade: bool = False) -> None:
137
+ """Delete entity."""
138
+
139
+ @abstractmethod
140
+ async def find(self, schema: Type[T], **kwargs: str) -> Optional[Dict[PyObjectId, T]]:
141
+ """Return all entities of schema matching the filter criteria."""
142
+
143
+ @abstractmethod
144
+ async def find_one(self, schema: Type[T], **kwargs: str) -> Optional[T]:
145
+ """Return one entitiy of schema matching the filter criteria, raise if multiple exist."""
146
+
147
+
148
+ class DictDatabase(Database):
149
+ """Simple Database implementation with dictionary."""
150
+
151
+ def __init__(self) -> None:
152
+ self.data: Dict[Type[DatabaseItem], Dict[PyObjectId, DatabaseItem]] = {}
153
+
154
+ async def update(self, item: DatabaseItem) -> None:
155
+ """Update data."""
156
+ item_type = type(item)
157
+ if item_type not in self.data:
158
+ self.data[item_type] = {}
159
+ self.data[item_type][item.identifier] = copy.deepcopy(item)
160
+
161
+ async def get(self, schema: Type[T], identifier: PyObjectId) -> T:
162
+ try:
163
+ return self.data[schema][identifier] # type: ignore
164
+ except KeyError as exc:
165
+ raise UnknownEntityError(f"Unknown identifier: {identifier}") from exc
166
+
167
+ async def get_all(self, schema: Type[T]) -> Dict[str, T]:
168
+ try:
169
+ return self.data[schema] # type: ignore
170
+ except KeyError as exc:
171
+ raise DatabaseError(f"Unkonwn schema: {schema}") from exc
172
+
173
+ async def delete(self, schema: Type[T], identifier: PyObjectId, cascade: bool = False) -> None:
174
+ try:
175
+ del self.data[schema][identifier]
176
+ except KeyError as exc:
177
+ raise UnknownEntityError(f"Unknown identifier: {identifier}") from exc
178
+ if cascade:
179
+ for db in self.data:
180
+ for identifier, item in self.data[db].items():
181
+ for attribute in item.__class__.model_fields:
182
+ if isinstance(attribute, ForeignKey) and attribute == item.identifier:
183
+ del self.data[db][identifier]
184
+
185
+ async def find(self, schema: Type[T], **kwargs: str) -> Optional[Dict[PyObjectId, T]]:
186
+ try:
187
+ results = []
188
+ for item in self.data[schema].values(): # type: ignore
189
+ if all(getattr(item, k) == v for k, v in kwargs.items()):
190
+ results.append(item) # type: ignore
191
+ return {item.identifier: item for item in results} # type: ignore
192
+ except KeyError as exc:
193
+ raise DatabaseError(f"Unkonwn schema: {schema}") from exc
194
+
195
+ async def find_one(self, schema: Type[T], **kwargs: str) -> Optional[T]:
196
+ if results := await self.find(schema, **kwargs):
197
+ if len(results) > 1:
198
+ raise DatabaseError(f"Multiple entities found for {schema} with {kwargs}")
199
+ return list(results.values())[0]
200
+ return None
@@ -0,0 +1,52 @@
1
+ """Redis Database implementation."""
2
+
3
+ from typing import Any, Dict, Mapping, Optional, Type
4
+
5
+ from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
6
+ from objectdb.database import Database, DatabaseItem, PyObjectId, T, UnknownEntityError
7
+
8
+
9
+ class MongoDBDatabase(Database):
10
+ """MongoDB database implementation."""
11
+
12
+ def __init__(self, mongodb_client: AsyncIOMotorClient, name: str) -> None:
13
+ self.connection: AsyncIOMotorClient[Mapping[str, dict[str, Any]]] = mongodb_client
14
+ self.database: AsyncIOMotorDatabase[Mapping[str, dict[str, Any]]] = self.connection[name]
15
+
16
+ async def update(self, item: DatabaseItem):
17
+ """Update data."""
18
+ item_type = type(item)
19
+ item.model_validate(item)
20
+ await self.database[item_type.__name__].update_one(
21
+ filter={"_id": item.identifier},
22
+ update={"$set": item.model_dump(by_alias=True, exclude={"_id"})},
23
+ upsert=True,
24
+ )
25
+
26
+ async def get(self, schema: Type[T], identifier: PyObjectId) -> T:
27
+ collection = self.database[schema.__name__]
28
+ if res := await collection.find_one(filter={"_id": identifier}):
29
+ return schema.model_validate(res)
30
+ raise UnknownEntityError(f"Unknown identifier: {identifier}")
31
+
32
+ async def get_all(self, schema: Type[T]) -> Dict[str, T]:
33
+ raise NotImplementedError
34
+
35
+ async def delete(self, schema: Type[T], identifier: PyObjectId, cascade: bool = False) -> None:
36
+ collection = self.database[schema.__name__]
37
+ result = await collection.delete_one(filter={"_id": identifier})
38
+ if result.deleted_count != 1:
39
+ raise UnknownEntityError(f"Unknown identifier: {identifier}")
40
+
41
+ async def find(self, schema: Type[T], **kwargs: Any) -> Optional[Dict[PyObjectId, T]]:
42
+ collection = self.database[schema.__name__]
43
+ if results := collection.find(filter=kwargs):
44
+ return {res["_id"]: schema.model_validate(res) async for res in results}
45
+ return None
46
+
47
+ async def find_one(self, schema: Type[T], **kwargs: Any) -> Optional[T]:
48
+ """Find one item matching the criteria."""
49
+ collection = self.database[schema.__name__]
50
+ if result := await collection.find_one(filter=kwargs):
51
+ return schema.model_validate(result)
52
+ return None
File without changes
File without changes
@@ -0,0 +1,15 @@
1
+ from typing import AsyncGenerator
2
+
3
+ import pytest
4
+ from mongomock_motor import AsyncMongoMockClient
5
+ from motor.motor_asyncio import AsyncIOMotorClient
6
+ from objectdb.database_mongodb import MongoDBDatabase # your class
7
+
8
+
9
+ @pytest.fixture
10
+ async def mongo_db() -> AsyncGenerator[MongoDBDatabase, None]:
11
+ """Return a MongoDBDatabase instance backed by mongomock_motor."""
12
+ client: AsyncIOMotorClient = AsyncMongoMockClient()
13
+ db = MongoDBDatabase(mongodb_client=client, name="test_db")
14
+ yield db
15
+ await client.drop_database("test_db")
@@ -0,0 +1,66 @@
1
+ """Tests for database module."""
2
+
3
+ import pytest
4
+ from objectdb.database import DatabaseItem, DictDatabase, ForeignKey, UnknownEntityError
5
+
6
+
7
+ class Customer(DatabaseItem):
8
+ """Sample item for testing."""
9
+
10
+ name: str
11
+ city: str
12
+
13
+
14
+ class Product(DatabaseItem):
15
+ """Sample item for testing."""
16
+
17
+ name: str
18
+ customer: ForeignKey[Customer]
19
+
20
+
21
+ @pytest.mark.asyncio
22
+ async def test_add_item() -> None:
23
+ """Test adding item to database."""
24
+ test_item = Customer(name="name", city="city")
25
+ db = DictDatabase()
26
+ await db.update(test_item)
27
+ assert db.get(Customer, test_item.identifier)
28
+
29
+
30
+ @pytest.mark.asyncio
31
+ async def test_update_item() -> None:
32
+ """Test updating item in database."""
33
+ test_item = Customer(name="name", city="city")
34
+ db = DictDatabase()
35
+ await db.update(test_item)
36
+ item_to_change = await db.get(Customer, test_item.identifier)
37
+ item_to_change.city = "changed_city"
38
+ await db.update(item_to_change)
39
+ updated_item = await db.get(Customer, test_item.identifier)
40
+ assert updated_item.city == "changed_city"
41
+
42
+
43
+ @pytest.mark.asyncio
44
+ async def test_delete_item() -> None:
45
+ """Test deleting items."""
46
+ test_item = Customer(name="name", city="city")
47
+ db = DictDatabase()
48
+ await db.update(test_item)
49
+ assert db.get(type(test_item), test_item.identifier)
50
+ await db.delete(type(test_item), test_item.identifier)
51
+ with pytest.raises(UnknownEntityError):
52
+ await db.get(type(test_item), test_item.identifier)
53
+
54
+
55
+ @pytest.mark.skip(reason="Not implemented yet")
56
+ @pytest.mark.asyncio
57
+ async def test_cascading_delete() -> None:
58
+ """Test cascading delete of items with foreign keys."""
59
+ customer = Customer(name="name", city="city")
60
+ db = DictDatabase()
61
+ await db.update(customer)
62
+ product = Product(name="product", customer=customer) # type: ignore
63
+ await db.update(product)
64
+ await db.delete(type(customer), customer.identifier)
65
+ with pytest.raises(UnknownEntityError):
66
+ await db.get(type(product), product.identifier)
@@ -0,0 +1,109 @@
1
+ """Tests for the MongoDB database implementation."""
2
+
3
+ import pytest
4
+ from objectdb.database import DatabaseItem
5
+ from objectdb.database_mongodb import MongoDBDatabase, UnknownEntityError
6
+
7
+
8
+ class User(DatabaseItem):
9
+ """Test user entity."""
10
+
11
+ name: str
12
+ email: str
13
+
14
+
15
+ @pytest.mark.asyncio
16
+ async def test_insert_and_get(mongo_db: MongoDBDatabase) -> None:
17
+ """Test inserting and retrieving an item."""
18
+ # GIVEN a user
19
+ user = User(name="Alice", email="alice@example.com")
20
+ # WHEN inserting it into the database
21
+ await mongo_db.update(user)
22
+ # THEN it can be retrieved by its identifier
23
+ fetched = await mongo_db.get(User, identifier=user.identifier)
24
+ assert fetched.name == "Alice"
25
+ assert fetched.identifier == user.identifier
26
+
27
+
28
+ @pytest.mark.asyncio
29
+ async def test_update_existing(mongo_db: MongoDBDatabase) -> None:
30
+ """Test updating an existing item."""
31
+ # GIVEN a user in the database
32
+ user = User(name="Bob", email="box@example.com")
33
+ await mongo_db.update(user)
34
+ # WHEN updating the user's email
35
+ user.email = "bob@example.com"
36
+ await mongo_db.update(user)
37
+ # THEN the change is reflected in the database
38
+ fetched = await mongo_db.get(User, identifier=user.identifier)
39
+ assert fetched.email == "bob@example.com"
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_delete_existing(mongo_db: MongoDBDatabase) -> None:
44
+ """Test deleting an item."""
45
+ # GIVEN a user in the database
46
+ user = User(name="Charlie", email="charlie@example.com")
47
+ await mongo_db.update(user)
48
+ # WHEN deleting the user
49
+ await mongo_db.delete(type(user), user.identifier)
50
+ # THEN the user can no longer be retrieved
51
+ with pytest.raises(UnknownEntityError):
52
+ await mongo_db.get(User, identifier=user.identifier)
53
+
54
+
55
+ @pytest.mark.asyncio
56
+ async def test_get_unknown(mongo_db: MongoDBDatabase) -> None:
57
+ """Test retrieving an unknown item raises an error."""
58
+ # GIVEN a user that does not exist in the database
59
+ user = User(name="Dave", email="dave@example.com")
60
+ # WHEN trying to get a user with a random identifier
61
+ with pytest.raises(UnknownEntityError):
62
+ await mongo_db.get(User, identifier=user.identifier)
63
+
64
+
65
+ @pytest.mark.asyncio
66
+ async def test_find_users(mongo_db: MongoDBDatabase) -> None:
67
+ """Test finding users by attribute."""
68
+ # GIVEN multiple users in the database
69
+ user1 = User(name="Eve", email="eve@example.com")
70
+ user2 = User(name="Frank", email="frank@example.com")
71
+ await mongo_db.update(user1)
72
+ await mongo_db.update(user2)
73
+ # WHEN finding users by name
74
+ results = await mongo_db.find(User, name="Eve")
75
+ # THEN only the matching user is returned
76
+ assert results == {user1.identifier: user1}
77
+
78
+
79
+ @pytest.mark.asyncio
80
+ async def test_find_one_user(mongo_db: MongoDBDatabase) -> None:
81
+ """Test finding a single user by attribute."""
82
+ # GIVEN two users in the database
83
+ user = User(name="Grace", email="grace@example.com")
84
+ User(name="Heidi", email="heidi@example.com")
85
+ await mongo_db.update(user)
86
+ # WHEN finding the user by name
87
+ result = await mongo_db.find_one(User, name="Grace")
88
+ # THEN the correct user is returned
89
+ assert result == user
90
+
91
+
92
+ @pytest.mark.asyncio
93
+ async def test_find_one_user_not_found(mongo_db: MongoDBDatabase) -> None:
94
+ """Test finding a single user that does not exist."""
95
+ # GIVEN no users in the database
96
+ # WHEN finding a user by name that does not exist
97
+ # THEN None is returned
98
+ assert await mongo_db.find_one(User, name="NonExistentUser") is None
99
+
100
+
101
+ @pytest.mark.asyncio
102
+ async def test_delete_unknown(mongo_db: MongoDBDatabase) -> None:
103
+ """Test deleting an unknown item raises an error."""
104
+ # GIVEN a user that does not exist in the database
105
+ user = User(name="Ivan", email="ivan@example.com")
106
+ # WHEN trying to delete the user
107
+ # THEN an UnknownEntityError is raised
108
+ with pytest.raises(UnknownEntityError):
109
+ await mongo_db.delete(User, user.identifier)