Skip to content

Commit 036e5b1

Browse files
committed
Automatic model handling #59
@entity decorator collects all types, Store gets them and syncs the model. Also, search for the model JSON file at the callers module path.
1 parent 402cbd5 commit 036e5b1

File tree

10 files changed

+137
-57
lines changed

10 files changed

+137
-57
lines changed

example/ollama/llamas.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
import ollama
55
import objectbox
6+
from objectbox.model import *
7+
from objectbox.model.properties import *
68

79
documents = [
810
"Llamas are members of the camelid family meaning they're pretty closely related to vicuñas and camels",
@@ -13,12 +15,6 @@
1315
"Llamas live to be about 20 years old, though some only live for 15 years and others live to be 30 years old",
1416
]
1517

16-
17-
from objectbox.model import *
18-
from objectbox.model.idsync import sync_model
19-
from objectbox.model.properties import *
20-
import numpy as np
21-
2218
# Have fresh data for each start
2319
objectbox.Store.remove_db_files("objectbox")
2420

@@ -31,11 +27,7 @@ class DocumentEmbedding:
3127
distance_type=VectorDistanceType.COSINE
3228
))
3329

34-
model = Model()
35-
model.entity(DocumentEmbedding)
36-
sync_model(model, os.path.join(os.path.dirname(__file__),"objectbox-model.json") )
37-
38-
store = objectbox.Store(model=model)
30+
store = objectbox.Store()
3931
box = store.box(DocumentEmbedding)
4032

4133
print("Documents to embed: ", len(documents))

example/tasks/__main__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from cmd import Cmd
22
import objectbox
33
import time
4-
from .model import *
4+
from model import *
55

66

77
# objectbox expects date timestamp in milliseconds since UNIX epoch
@@ -15,7 +15,7 @@ def format_date(timestamp_ms: int) -> str:
1515

1616
class TasklistCmd(Cmd):
1717
prompt = "> "
18-
_store = objectbox.Store(model=get_objectbox_model(), directory="tasklist-db")
18+
_store = objectbox.Store(directory="tasklist-db")
1919
_box = _store.box(Task)
2020

2121
def do_ls(self, _):

example/tasks/model.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,3 @@ class Task:
1010
date_created = Date(py_type=int)
1111
date_finished = Date(py_type=int)
1212

13-
14-
def get_objectbox_model():
15-
m = Model()
16-
m.entity(Task)
17-
sync_model(m, os.path.join(os.path.dirname(__file__),"objectbox-model.json") )
18-
return m

example/vectorsearch-cities/__main__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from cmd import Cmd
22
import objectbox
33
import time
4-
from .model import *
4+
from model import *
55
import csv
66
import os
77

@@ -23,7 +23,7 @@ def __init__(self, *args):
2323
Cmd.__init__(self, *args)
2424
dbdir = "cities-db"
2525
new_db = not os.path.exists(dbdir)
26-
self._store = objectbox.Store(model=get_objectbox_model(),directory=dbdir)
26+
self._store = objectbox.Store(directory=dbdir)
2727
self._box = self._store.box(City)
2828
if new_db:
2929
with open(os.path.join(os.path.dirname(__file__), 'cities.csv')) as f:

example/vectorsearch-cities/model.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,3 @@ class City:
1414
distance_type=VectorDistanceType.EUCLIDEAN
1515
))
1616

17-
def get_objectbox_model():
18-
m = Model()
19-
m.entity(City)
20-
sync_model(m, os.path.join(os.path.dirname(__file__),"objectbox-model.json") )
21-
return m

objectbox/model/entity.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,16 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
1615
import flatbuffers
1716
import flatbuffers.flexbuffers
18-
from typing import Generic
1917
import numpy as np
2018
from datetime import datetime, timezone
19+
import logging
2120
from objectbox.c import *
21+
from objectbox.model.iduid import IdUid
2222
from objectbox.model.properties import Property
2323
from objectbox.utils import date_value_to_int
2424
import threading
25-
from objectbox.c import *
26-
from objectbox.model.iduid import IdUid
27-
from objectbox.model.properties import Property
28-
2925

3026

3127
# _Entity class holds model information as well as conversions between python objects and FlatBuffers (ObjectBox data)
@@ -276,11 +272,29 @@ def _unmarshal(self, data: bytes):
276272
setattr(obj, prop.name, val)
277273
return obj
278274

275+
# Dictionary of entity types (metadata) collected by the Entity decorator
276+
obx_models_by_name: Dict[str, Set[_Entity]] = {}
277+
279278

280-
def Entity(uid: int = 0) -> Callable[[Type], _Entity]:
279+
def Entity(uid: int = 0, model: str = "default") -> Callable[[Type], _Entity]:
281280
""" Entity decorator that wraps _Entity to allow @Entity(id=, uid=); i.e. no class arguments. """
282281

283282
def wrapper(class_):
284-
return _Entity(class_, uid)
283+
metadata_set = obx_models_by_name.get(model)
284+
if metadata_set is None:
285+
metadata_set = set()
286+
obx_models_by_name[model] = metadata_set
287+
288+
metadata = _Entity(class_, uid)
289+
for existing in metadata_set:
290+
if existing.name == metadata.name:
291+
# OK for tests, where multiple models are created with the same entity name
292+
logging.warning(f"Model \"{model}\" already contains an entity \"{metadata.name}\"; replacing it.")
293+
metadata_set.remove(existing)
294+
break
295+
296+
obx_models_by_name[model].add(metadata)
297+
logging.info(f"Entity {metadata.name} added to model {model}")
298+
return metadata
285299

286300
return wrapper

objectbox/store.py

Lines changed: 81 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,28 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14-
14+
import inspect
15+
import logging
16+
import os
17+
import sys
18+
from types import ModuleType
1519

1620
import objectbox.c as c
1721
import objectbox.transaction
22+
from objectbox.model.idsync import sync_model
1823
from objectbox.store_options import StoreOptions
1924
import objectbox
2025
from objectbox.model.entity import _Entity
26+
from objectbox.model.model import Model
2127
from typing import *
2228

29+
2330
class Store:
24-
def __init__(self,
25-
model : Optional[objectbox.model.Model] = None,
26-
directory : Optional[str] = None,
27-
max_db_size_in_kb : Optional[int] = None,
31+
def __init__(self,
32+
model: Optional[Union[Model, str]] = "default",
33+
model_json_file: Optional[str] = None,
34+
directory: Optional[str] = None,
35+
max_db_size_in_kb: Optional[int] = None,
2836
max_data_size_in_kb: Optional[int] = None,
2937
file_mode: Optional[int] = None,
3038
max_readers: Optional[int] = None,
@@ -46,8 +54,8 @@ def __init__(self,
4654
async_minor_refill_max_count: Optional[int] = None,
4755
async_object_bytes_max_cache_size: Optional[int] = None,
4856
async_object_bytes_max_size_to_cache: Optional[int] = None,
49-
c_store : Optional[c.OBX_store_p] = None):
50-
57+
c_store: Optional[c.OBX_store_p] = None):
58+
5159
"""Opens an ObjectBox database Store
5260
5361
:param model:
@@ -107,13 +115,14 @@ def __init__(self,
107115
Maximum size for an object to be cached.
108116
:param c_store:
109117
Internal parameter for deprecated ObjectBox interface. Do not use it; other options would be ignored if passed.
110-
"""
111-
118+
"""
119+
112120
self._c_store = None
113121
if not c_store:
114122
options = StoreOptions()
115123
try:
116124
if model is not None:
125+
model = Store._sync_model(model, model_json_file)
117126
options.model(model)
118127
if directory is not None:
119128
options.directory(directory)
@@ -160,18 +169,77 @@ def __init__(self,
160169
if async_object_bytes_max_cache_size is not None:
161170
options.async_object_bytes_max_cache_size(async_object_bytes_max_cache_size)
162171
if async_object_bytes_max_size_to_cache is not None:
163-
options.async_object_bytes_max_size_to_cache(async_object_bytes_max_size_to_cache)
164-
172+
options.async_object_bytes_max_size_to_cache(async_object_bytes_max_size_to_cache)
173+
165174
except c.CoreException:
166175
options._free()
167176
raise
168-
self._c_store = c.obx_store_open(options._c_handle)
177+
self._c_store = c.obx_store_open(options._c_handle)
169178
else:
170179
self._c_store = c_store
171180

181+
@staticmethod
182+
def _sync_model(model: Optional[Union[Model, str]],
183+
model_json_file: Optional[str]) -> Model:
184+
if isinstance(model, str): # Model name provided; get entities collected via @Entity
185+
metadata_set = objectbox.model.entity.obx_models_by_name.get(model)
186+
if metadata_set is None:
187+
raise ValueError(
188+
f"Model \"{model}\" not found; ensure to set the name attribute on the model class.")
189+
model = Model()
190+
for metadata in metadata_set:
191+
model.entity(metadata)
192+
elif not isinstance(model, Model):
193+
raise ValueError("Model must be a Model object or a string.")
194+
195+
if not model_json_file:
196+
model_json_file = Store._locate_model_json_file()
197+
198+
sync_model(model, model_json_file)
199+
200+
return model
201+
202+
@staticmethod
203+
def _locate_model_json_file():
204+
def get_module_path(module: Optional[ModuleType]) -> Optional[str]:
205+
if module and hasattr(module, "__file__"):
206+
return os.path.dirname(os.path.realpath(module.__file__))
207+
return None
208+
209+
def json_file_inside_module_path(module: Optional[ModuleType]) -> Optional[str]:
210+
module_path = get_module_path(module)
211+
if module_path:
212+
logging.info("Using module path to locate objectbox-model.json: ", module_path)
213+
return os.path.join(module_path, "objectbox-model.json")
214+
return None
215+
216+
# The (direct) calling module seems like a good first choice
217+
this_module = sys.modules[__name__]
218+
this_module_path = get_module_path(this_module)
219+
stack = inspect.stack()
220+
calling_module: Optional[ModuleType] = None
221+
for stack_element in stack:
222+
module = inspect.getmodule(stack_element[0])
223+
if module is not this_module:
224+
path = get_module_path(module)
225+
if not path: # Cannot get the direct caller's path, so do not try further
226+
break
227+
if path != this_module_path: # Not inside the objectbox package
228+
calling_module = module
229+
break
230+
model_json_file = json_file_inside_module_path(calling_module)
231+
232+
if not model_json_file:
233+
# Note: the main module seems less reliable,
234+
# e.g. it resulted in a some pycharm dir when running tests from PyCharm.
235+
model_json_file = json_file_inside_module_path(sys.modules.get('__main__'))
236+
if not model_json_file:
237+
model_json_file = "objectbox-model.json"
238+
return model_json_file
239+
172240
def __del__(self):
173241
self.close()
174-
242+
175243
def box(self, entity: _Entity) -> 'objectbox.Box':
176244
"""
177245
Open a box for an entity.

tests/common.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,26 @@
1-
from os import path
1+
import os
22
import pytest
33
import objectbox
44
from objectbox.logger import logger
55
from objectbox.store import Store
6-
from objectbox.model.idsync import sync_model
76
from tests.model import *
87
import numpy as np
98
from datetime import timezone
109

10+
11+
def remove_json_model_file():
12+
path = os.path.dirname(os.path.realpath(__file__))
13+
json_file = os.path.join(path, "objectbox-model.json")
14+
if os.path.exists(json_file):
15+
os.remove(json_file)
16+
17+
1118
def create_default_model():
1219
model = objectbox.Model()
1320
model.entity(TestEntity)
1421
model.entity(TestEntityDatetime)
1522
model.entity(TestEntityFlex)
1623
model.entity(VectorEntity)
17-
sync_model(model) # Assign IDs/UIDs
1824
return model
1925

2026

@@ -26,6 +32,7 @@ def create_test_store(db_path: str = "testdata", clear_db: bool = True) -> objec
2632

2733
if clear_db:
2834
Store.remove_db_files(db_path)
35+
remove_json_model_file()
2936
return objectbox.Store(model=create_default_model(), directory=db_path)
3037

3138

tests/test_deprecated.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1-
import pytest
21
import tests.common
3-
import objectbox
42
from objectbox import ObjectBox
53
from objectbox.c import *
4+
from objectbox.model.idsync import sync_model
65
from objectbox.store_options import StoreOptions
76
from tests.common import *
87

98
def test_deprecated_ObjectBox():
9+
Store.remove_db_files("testdata")
10+
remove_json_model_file()
11+
1012
model = tests.common.create_default_model()
13+
sync_model(model) # It expects IDs to be already assigned
14+
1115
options = StoreOptions()
1216
options.model(model)
1317
options.directory("testdata")
@@ -19,6 +23,9 @@ def test_deprecated_ObjectBox():
1923

2024

2125
def test_deprecated_Builder():
26+
Store.remove_db_files("testdata")
27+
remove_json_model_file()
28+
2229
model = tests.common.create_default_model()
2330
with pytest.deprecated_call():
2431
ob = objectbox.Builder().model(model).directory("testdata").build()

tests/test_store_options.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
from objectbox import Store
12
from objectbox.c import * # TODO ideally we wouldn't have to import c.py
23
from objectbox.store_options import StoreOptions
3-
import objectbox
4-
import tests.common
4+
from tests.common import *
55

66
def test_set_options():
77
""" Test setting dummy values for each option.
@@ -50,8 +50,11 @@ def test_set_options():
5050
del options
5151

5252
def test_store_with_options():
53-
store = objectbox.Store(
54-
model=tests.common.create_default_model(),
53+
Store.remove_db_files("testdata")
54+
remove_json_model_file()
55+
56+
store = Store(
57+
model=create_default_model(),
5558
directory="testdata",
5659
max_db_size_in_kb=1<<20,
5760
max_data_size_in_kb=(1<<20)-(1<<10),
@@ -73,5 +76,5 @@ def test_store_with_options():
7376
async_minor_refill_max_count=100,
7477
async_object_bytes_max_cache_size=1<<20,
7578
async_object_bytes_max_size_to_cache=100<<10
76-
)
79+
)
7780
del store

0 commit comments

Comments
 (0)