Skip to content

Commit e12a343

Browse files
committed
initial design idea
1 parent 755d7b6 commit e12a343

File tree

5 files changed

+427
-3
lines changed

5 files changed

+427
-3
lines changed

README.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,85 @@ assert ress == {"8": {"a": 8}, "9": {"a": 9}} # True
198198
```
199199

200200

201+
Object Mapper
202+
========================================================================================
203+
204+
DictDataBase provides object mappers to model your data as classes and benefit from
205+
type hints and type checking.
206+
207+
208+
Mapping key-value items in one File
209+
----------------------------------------------------------------------------------------
210+
211+
In this example, we will have a file called `users.json`, inside your storage directory
212+
(`DDB.config.storage_directory`) which contains the following data:
213+
```json
214+
{
215+
"u1": {
216+
"first_name": "John",
217+
"last_name": "Doe",
218+
"age": 21
219+
},
220+
"u2": {
221+
"first_name": "Jane",
222+
"last_name": "Smith",
223+
"age": 30,
224+
"phone": "0123456"
225+
},
226+
}
227+
```
228+
229+
We will now map the data to classes:
230+
231+
```python
232+
from dictdatabase.object_mapper import FileDictModel, FileDictItemModel
233+
234+
class User(FileDictItemModel):
235+
first_name: str
236+
last_name: str
237+
age: int
238+
phone: str | None
239+
240+
def full_name(self):
241+
return f"{self.first_name} {self.last_name}"
242+
243+
244+
class Users(FileDictModel[User]):
245+
__file__ = "users"
246+
```
247+
248+
A few important things are happening here:
249+
- `FileDictItemModel` models a key-value item inside the file.
250+
- The attributes `first_name` and `last_name` and `age` of `User` are required. If they miss in the file, a `KeyError` is raised.
251+
- The attribute `phone` is optional, and will be `None` if it does not exist in the file.
252+
- When defining `Users`, the `FileDictModel` must specify it's item model type as a type argument (`FileDictModel[User]`)
253+
- `Users` only has to specify the file it refers to by passing the file name without the ending (`__file__ = "users"`)
254+
255+
256+
Now, the models can be used:
257+
258+
```python
259+
# Get user by id
260+
u1: User = Users.get_at_key("u1")
261+
print(u1.full_name())
262+
>>> "John Doe"
263+
264+
# Iterate all users:
265+
for uid, user in Users.items():
266+
print(user.last_name, user.age, user.phone)
267+
>>> "Doe", 21, None
268+
>>> "Smith", 30, "0123456"
269+
270+
# Filter
271+
u_over_25: dict[str, User] = Users.get_where(lambda uid, user: user.age > 25)
272+
```
273+
274+
275+
276+
277+
278+
279+
201280

202281
Performance
203282
========================================================================================

assets/coverage.svg

Lines changed: 3 additions & 3 deletions
Loading

dictdatabase/object_mapper.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
from __future__ import annotations
2+
from abc import ABC
3+
from typing import get_type_hints, get_origin, get_args, TypeVar, Tuple, Type, Generic
4+
from types import UnionType, NoneType
5+
from . import io_safe
6+
from .sessions import SessionFileFull, SessionFileKey, SessionFileWhere, SessionDirFull, SessionDirWhere
7+
8+
9+
10+
T = TypeVar("T")
11+
T2 = TypeVar("T2")
12+
13+
14+
def get_type_hints_excluding_internals(cls):
15+
"""
16+
Get type hints of the class, excluding double dunder variables.
17+
"""
18+
for var_name, var_type in get_type_hints(cls).items():
19+
if var_name.startswith("__") and var_name.endswith("__"):
20+
continue
21+
yield var_name, var_type
22+
23+
24+
25+
def fill_object_from_dict_using_type_hints(obj, cls, data: dict):
26+
"""
27+
Attributes of obj are set using the data dict.
28+
The type hints of the class cls are used to determine which attributes to set.
29+
"""
30+
for var_name, var_type in get_type_hints_excluding_internals(cls):
31+
var_type_args = get_args(var_type)
32+
var_type_origin = get_origin(var_type)
33+
# Check if variable is nullable (e.g. email: str | None)
34+
# When it is not nullable but not in the data, raise an error
35+
if var_name not in data:
36+
nullable = var_type_origin is UnionType and NoneType in var_type_args
37+
if not nullable:
38+
raise KeyError(f"Missing variable '{var_name}' in {cls.__name__}.")
39+
# When it is a list, fill the list with the items
40+
if var_type_origin is list and len(var_type_args) == 1:
41+
item_type = var_type_args[0]
42+
setattr(obj, var_name, [item_type.from_dict(x) for x in data[var_name]])
43+
else:
44+
setattr(obj, var_name, data.get(var_name, None))
45+
return obj
46+
47+
48+
49+
def fill_dict_from_object_using_type_hints(cls, obj):
50+
raise NotImplementedError
51+
52+
53+
54+
55+
56+
########################################################################################
57+
# Scenario 1:
58+
# Model a single file with FileDictModel, which is a dict at the top level.
59+
# Each key-value item is modeled by a DictItemModel.
60+
61+
62+
63+
class FileDictModel(ABC, Generic[T]):
64+
"""
65+
A file base refers to a file that is stored in the database.
66+
At the top level the file must contain a dictionary with strings as keys.
67+
"""
68+
69+
__file__ = None
70+
71+
@classmethod
72+
def _get_item_model(cls):
73+
for base in cls.__orig_bases__:
74+
for type_args in get_args(base):
75+
if issubclass(type_args, FileDictItemModel):
76+
return type_args
77+
raise AttributeError(
78+
"FileDictModel must specify a FileDictItemModel "
79+
"(e.g. Users(FileDictModel[User]))"
80+
)
81+
82+
83+
@classmethod
84+
def get_at_key(cls, key) -> T:
85+
"""
86+
Gets an item by key.
87+
The data is partially read from the __file__.
88+
"""
89+
data = io_safe.partial_read(cls.__file__, key)
90+
res: T = cls._get_item_model().from_key_value(key, data)
91+
return res
92+
93+
@classmethod
94+
def session_at_key(cls, key):
95+
return cls._get_item_model().session(key)
96+
97+
@classmethod
98+
def get_all(cls) -> dict[str, T]:
99+
data = io_safe.read(cls.__file__)
100+
return {k: cls._get_item_model().from_key_value(k, v) for k, v in data.items()}
101+
102+
@classmethod
103+
def session(cls):
104+
"""
105+
Enter a session with the file as (session, data) where data is a dict of
106+
<key>: <ORM model of value> pairs.
107+
"""
108+
def make_session_obj_from_dict(data):
109+
sess_obj = {}
110+
for k, v in data.items():
111+
sess_obj[k] = cls._get_item_model().from_key_value(k, v)
112+
return sess_obj
113+
return SessionFileFull(cls.__file__, make_session_obj_from_dict)
114+
115+
116+
@classmethod
117+
def get_where(cls, where: callable[str, T]) -> dict[str, T]:
118+
"""
119+
Return a dictionary of all the items for which the where function returns True.
120+
Where takes the key and the value's model object as arguments.
121+
"""
122+
return {k: v for k, v in cls.get_all().items() if where(k, v)}
123+
124+
125+
126+
127+
class FileDictItemModel(ABC):
128+
__key__: str
129+
130+
@classmethod
131+
def from_key_value(cls: Type[T2], key, value) -> T2:
132+
obj = fill_object_from_dict_using_type_hints(cls(), cls, value)
133+
obj.__key__ = key
134+
return obj
135+
136+
@classmethod
137+
def session(cls, key):
138+
def partial_func(x):
139+
return cls.from_key_value(key, x)
140+
return SessionFileKey(cls.__file__, key, partial_func)
141+
142+
143+
144+
class DictModel(ABC):
145+
146+
@classmethod
147+
def from_dict(cls, data) -> DictModel:
148+
obj = cls()
149+
return fill_object_from_dict_using_type_hints(obj, cls, data)
150+
151+
def to_dict(self) -> dict:
152+
res = {}
153+
for var_name in get_type_hints(self).keys():
154+
if (value := getattr(self, var_name)) is not None:
155+
res[var_name] = value
156+
return res
157+
158+
159+
160+
########################################################################################
161+
# Scenario 2:
162+
# Add in a later version of DDB
163+
# A folder containing multiple files, each containing json.
164+
165+
# class FolderBase(ABC):
166+
# __folder__ = None
167+
# __file_model__: FileInFolderModel = None
168+
169+
170+
# class FileInFolderModel(ABC):
171+
172+
# @classmethod
173+
# def get_by_name(cls, file_name: str) -> FileInFolderModel:
174+
# data = io_safe.read(f"{cls.__folder__}/{file_name}")
175+
# return cls(**data)

testing_orm.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
2+
3+
4+
class WorkTime(DictModel):
5+
start: str
6+
end: str
7+
8+
9+
class User(FileDictItemModel):
10+
first_name: str
11+
last_name: str
12+
email: str | None
13+
14+
work_times: list[WorkTime]
15+
16+
17+
def full_name(self):
18+
return f"{self.first_name} {self.last_name}"
19+
20+
21+
class Users(FileDictModel[User]):
22+
__file__ = "users"
23+
24+
25+
26+
u = User.from_key_value("uid1", {
27+
"first_name": "John",
28+
"last_name": "Doe",
29+
"none": "no",
30+
"work_times": [
31+
{"start": "08:00", "end": "12:00"},
32+
{"start": "13:00", "end": "17:00"},
33+
]
34+
})
35+
36+
37+
assert u.first_name == "John"
38+
assert u.last_name == "Doe"
39+
assert u.full_name() == "John Doe"
40+
assert u.work_times[0].start == "08:00"
41+
assert u.work_times[0].end == "12:00"
42+
assert u.work_times[1].start == "13:00"
43+
assert u.work_times[1].end == "17:00"
44+
assert len(u.work_times) == 2
45+
46+
47+
print("u type:", type(u))
48+
49+
50+
51+
52+
DDB.at("users").create({
53+
"uid1": {
54+
"first_name": "John",
55+
"last_name": "Doe",
56+
"none": "no",
57+
"work_times": [
58+
{"start": "08:00", "end": "12:00"},
59+
{"start": "13:00", "end": "17:00"},
60+
]
61+
},
62+
"uid2": {
63+
"first_name": "Jane",
64+
"last_name": "Smith",
65+
"none": "no",
66+
"work_times": [
67+
{"start": "08:00", "end": "12:00"},
68+
{"start": "13:00", "end": "17:00"},
69+
]
70+
},
71+
"uid3": {
72+
"first_name": "Pete",
73+
"last_name": "Griffin",
74+
"none": "no",
75+
"work_times": [
76+
{"start": "08:00", "end": "12:00"},
77+
{"start": "13:00", "end": "17:00"},
78+
]
79+
}
80+
}, force_overwrite=True)
81+
82+
83+
u1 = Users.get_at_key("uid1")
84+
assert u1.first_name == "John"
85+
assert u1.last_name == "Doe"
86+
assert u1.full_name() == "John Doe"
87+
assert u1.work_times[0].start == "08:00"
88+
assert u1.work_times[0].end == "12:00"
89+
assert u1.work_times[1].start == "13:00"
90+
assert u1.work_times[1].end == "17:00"
91+
assert len(u1.work_times) == 2
92+
93+
94+
95+
u2 = Users.get_at_key("uid2")
96+
97+
98+
99+
for uid, u in Users.items():
100+
print(u.full_name())
101+
102+
103+
104+
# # Iterate FileDictModel
105+
# for user_id, user in Users.items():
106+
# print(user_id, user.first_name, user.last_name, user.email)
107+
108+
# # Get one item
109+
# user: User = Users.get_at_key("user_id")
110+
111+
112+
# # Get by lambda
113+
# users: Users = Users.where(lambda user: user.first_name != "John")
114+
115+
116+
# with Users.session_at_key(user_id) as (session, user):
117+
# ...
118+
119+
# with Users.session() as (session, users): Dict[str, User]
120+
# ...

0 commit comments

Comments
 (0)