Skip to content

Commit c6cc18b

Browse files
solution of issue #9 (#124)
* solution of issue #9 * MongoDB backend, formatting, BaseSerializer restore, tests, dependencies --------- Authored-by: Katarina
1 parent 7b62b83 commit c6cc18b

File tree

5 files changed

+210
-5
lines changed

5 files changed

+210
-5
lines changed

hololinked/storage/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .database import ThingDB
1+
from .database import ThingDB, MongoThingDB
22
from .json_storage import ThingJSONStorage
33
from ..utils import get_a_filename_from_instance
44

@@ -9,6 +9,11 @@ def prepare_object_storage(instance, **kwargs):
99
):
1010
filename = kwargs.get("json_filename", f"{get_a_filename_from_instance(instance, extension='json')}")
1111
instance.db_engine = ThingJSONStorage(filename=filename, instance=instance)
12+
elif kwargs.get(
13+
"use_mongo_db", instance.__class__.use_mongo_db if hasattr(instance.__class__, "use_mongo_db") else False
14+
):
15+
config_file = kwargs.get("db_config_file", None)
16+
instance.db_engine = MongoThingDB(instance=instance, config_file=config_file)
1217
elif kwargs.get(
1318
"use_default_db", instance.__class__.use_default_db if hasattr(instance.__class__, "use_default_db") else False
1419
):

hololinked/storage/database.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import os
22
import threading
33
import typing
4+
import base64
45
from sqlalchemy import create_engine, select, inspect as inspect_database
56
from sqlalchemy.ext import asyncio as asyncio_ext
67
from sqlalchemy.orm import sessionmaker
78
from sqlalchemy import Integer, String, JSON, LargeBinary
89
from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase, MappedAsDataclass
910
from sqlite3 import DatabaseError
11+
from pymongo import MongoClient, errors as mongo_errors
12+
from ..param import Parameterized
13+
from ..core.property import Property
1014
from dataclasses import dataclass
1115

1216
from ..param import Parameterized
@@ -458,5 +462,124 @@ def __exit__(self, exc_type, exc_value, exc_tb) -> None:
458462
except Exception as ex:
459463
pass
460464

465+
class MongoThingDB:
466+
"""
467+
MongoDB-backed database engine for Thing properties and info.
468+
469+
This class provides persistence for Thing properties using MongoDB.
470+
Properties are stored in the 'properties' collection, with fields:
471+
- id: Thing instance identifier
472+
- name: property name
473+
- serialized_value: serialized property value
474+
475+
Methods mirror the interface of ThingDB for compatibility.
476+
"""
477+
def __init__(self, instance: Parameterized, config_file: typing.Union[str, None] = None) -> None:
478+
"""
479+
Initialize MongoThingDB for a Thing instance.
480+
Connects to MongoDB and sets up collections.
481+
"""
482+
self.thing_instance = instance
483+
self.id = instance.id
484+
self.config = self.load_conf(config_file)
485+
self.client = MongoClient(self.config.get("mongo_uri", "mongodb://localhost:27017"))
486+
self.db = self.client[self.config.get("database", "hololinked")]
487+
self.properties = self.db["properties"]
488+
self.things = self.db["things"]
489+
490+
@classmethod
491+
def load_conf(cls, config_file: str) -> typing.Dict[str, typing.Any]:
492+
"""
493+
Load configuration from JSON file if provided.
494+
"""
495+
if not config_file:
496+
return {}
497+
elif config_file.endswith(".json"):
498+
with open(config_file, "r") as file:
499+
return JSONSerializer.load(file)
500+
else:
501+
raise ValueError(f"config files of extension - ['json'] expected, given file name {config_file}")
502+
503+
def fetch_own_info(self):
504+
"""
505+
Fetch Thing instance metadata from the 'things' collection.
506+
"""
507+
doc = self.things.find_one({"id": self.id})
508+
return doc
509+
510+
def get_property(self, property: typing.Union[str, Property], deserialized: bool = True) -> typing.Any:
511+
"""
512+
Get a property value from MongoDB for this Thing.
513+
If deserialized=True, returns the Python value.
514+
"""
515+
name = property if isinstance(property, str) else property.name
516+
doc = self.properties.find_one({"id": self.id, "name": name})
517+
if not doc:
518+
raise mongo_errors.PyMongoError(f"property {name} not found in database")
519+
if not deserialized:
520+
return doc
521+
serializer = Serializers.for_object(self.id, self.thing_instance.__class__.__name__, name)
522+
return serializer.loads(base64.b64decode(doc["serialized_value"]))
523+
524+
def set_property(self, property: typing.Union[str, Property], value: typing.Any) -> None:
525+
"""
526+
Set a property value in MongoDB for this Thing.
527+
Value is serialized before storage.
528+
"""
529+
name = property if isinstance(property, str) else property.name
530+
serializer = Serializers.for_object(self.id, self.thing_instance.__class__.__name__, name)
531+
serialized_value = base64.b64encode(serializer.dumps(value)).decode("utf-8")
532+
self.properties.update_one(
533+
{"id": self.id, "name": name},
534+
{"$set": {"serialized_value": serialized_value}},
535+
upsert=True
536+
)
537+
538+
def get_properties(self, properties: typing.Dict[typing.Union[str, Property], typing.Any], deserialized: bool = True) -> typing.Dict[str, typing.Any]:
539+
"""
540+
Get multiple property values from MongoDB for this Thing.
541+
Returns a dict of property names to values.
542+
"""
543+
names = [obj if isinstance(obj, str) else obj.name for obj in properties.keys()]
544+
cursor = self.properties.find({"id": self.id, "name": {"$in": names}})
545+
result = {}
546+
for doc in cursor:
547+
serializer = Serializers.for_object(self.id, self.thing_instance.__class__.__name__, doc["name"])
548+
result[doc["name"]] = doc["serialized_value"] if not deserialized else serializer.loads(base64.b64decode(doc["serialized_value"]))
549+
return result
550+
551+
def set_properties(self, properties: typing.Dict[typing.Union[str, Property], typing.Any]) -> None:
552+
"""
553+
Set multiple property values in MongoDB for this Thing.
554+
"""
555+
for obj, value in properties.items():
556+
name = obj if isinstance(obj, str) else obj.name
557+
serializer = Serializers.for_object(self.id, self.thing_instance.__class__.__name__, name)
558+
serialized_value = base64.b64encode(serializer.dumps(value)).decode("utf-8")
559+
self.properties.update_one(
560+
{"id": self.id, "name": name},
561+
{"$set": {"serialized_value": serialized_value}},
562+
upsert=True
563+
)
461564

565+
def get_all_properties(self, deserialized: bool = True) -> typing.Dict[str, typing.Any]:
566+
cursor = self.properties.find({"id": self.id})
567+
result = {}
568+
for doc in cursor:
569+
serializer = Serializers.for_object(self.id, self.thing_instance.__class__.__name__, doc["name"])
570+
result[doc["name"]] = doc["serialized_value"] if not deserialized else serializer.loads(base64.b64decode(doc["serialized_value"]))
571+
return result
572+
573+
def create_missing_properties(self, properties: typing.Dict[str, Property], get_missing_property_names: bool = False) -> typing.Any:
574+
missing_props = []
575+
existing_props = self.get_all_properties()
576+
for name, new_prop in properties.items():
577+
if name not in existing_props:
578+
serializer = Serializers.for_object(self.id, self.thing_instance.__class__.__name__, new_prop.name)
579+
serialized_value = base64.b64encode(serializer.dumps(getattr(self.thing_instance, new_prop.name))).decode("utf-8")
580+
self.properties.insert_one({"id": self.id, "name": new_prop.name, "serialized_value": serialized_value})
581+
missing_props.append(name)
582+
if get_missing_property_names:
583+
return missing_props
584+
462585
__all__ = [BaseAsyncDB.__name__, BaseSyncDB.__name__, ThingDB.__name__, batch_db_commit.__name__]

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ dependencies = [
4242
"jsonschema>=4.22.0,<5.0",
4343
"httpx>=0.28.1,<29.0",
4444
"sniffio>=1.3.1,<2.0",
45+
"pymongo>=4.15.2",
4546
]
4647

4748
[project.urls]

tests/test_07_properties.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,6 @@
1111

1212

1313
class TestProperty(TestCase):
14-
@classmethod
15-
def setUpClass(self):
16-
super().setUpClass()
17-
print(f"test property with {self.__name__}")
1814

1915
def test_01_simple_class_property(self):
2016
"""Test basic class property functionality"""
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import unittest
2+
from hololinked.core.property import Property
3+
from hololinked.core import Thing
4+
from hololinked.storage.database import MongoThingDB
5+
from pymongo import MongoClient
6+
7+
class TestMongoDBOperations(unittest.TestCase):
8+
@classmethod
9+
def setUpClass(cls):
10+
# Clear MongoDB 'properties' collection before tests
11+
try:
12+
client = MongoClient("mongodb://localhost:27017")
13+
db = client["hololinked"]
14+
db["properties"].delete_many({})
15+
except Exception as e:
16+
print(f"Warning: Could not clear MongoDB test data: {e}")
17+
18+
def test_mongo_string_property(self):
19+
class MongoTestThing(Thing):
20+
str_prop = Property(default="hello", db_persist=True)
21+
instance = MongoTestThing(id="mongo_str", use_mongo_db=True)
22+
instance.str_prop = "world"
23+
value_from_db = instance.db_engine.get_property("str_prop")
24+
self.assertEqual(value_from_db, "world")
25+
26+
def test_mongo_float_property(self):
27+
class MongoTestThing(Thing):
28+
float_prop = Property(default=1.23, db_persist=True)
29+
instance = MongoTestThing(id="mongo_float", use_mongo_db=True)
30+
instance.float_prop = 4.56
31+
value_from_db = instance.db_engine.get_property("float_prop")
32+
self.assertAlmostEqual(value_from_db, 4.56)
33+
34+
def test_mongo_bool_property(self):
35+
class MongoTestThing(Thing):
36+
bool_prop = Property(default=False, db_persist=True)
37+
instance = MongoTestThing(id="mongo_bool", use_mongo_db=True)
38+
instance.bool_prop = True
39+
value_from_db = instance.db_engine.get_property("bool_prop")
40+
self.assertTrue(value_from_db)
41+
42+
def test_mongo_dict_property(self):
43+
class MongoTestThing(Thing):
44+
dict_prop = Property(default={"a": 1}, db_persist=True)
45+
instance = MongoTestThing(id="mongo_dict", use_mongo_db=True)
46+
instance.dict_prop = {"b": 2, "c": 3}
47+
value_from_db = instance.db_engine.get_property("dict_prop")
48+
self.assertEqual(value_from_db, {"b": 2, "c": 3})
49+
50+
def test_mongo_list_property(self):
51+
class MongoTestThing(Thing):
52+
list_prop = Property(default=[1, 2], db_persist=True)
53+
instance = MongoTestThing(id="mongo_list", use_mongo_db=True)
54+
instance.list_prop = [3, 4, 5]
55+
value_from_db = instance.db_engine.get_property("list_prop")
56+
self.assertEqual(value_from_db, [3, 4, 5])
57+
58+
def test_mongo_none_property(self):
59+
class MongoTestThing(Thing):
60+
none_prop = Property(default=None, db_persist=True, allow_None=True)
61+
instance = MongoTestThing(id="mongo_none", use_mongo_db=True)
62+
instance.none_prop = None
63+
value_from_db = instance.db_engine.get_property("none_prop")
64+
self.assertIsNone(value_from_db)
65+
66+
def test_mongo_property_persistence(self):
67+
thing_id = "mongo_test_persistence_unique"
68+
prop_name = "test_prop_unique"
69+
client = MongoClient("mongodb://localhost:27017")
70+
db = client["hololinked"]
71+
db["properties"].delete_many({"id": thing_id, "name": prop_name})
72+
class MongoTestThing(Thing):
73+
test_prop_unique = Property(default=123, db_persist=True)
74+
instance = MongoTestThing(id=thing_id, use_mongo_db=True)
75+
instance.test_prop_unique = 456
76+
value_from_db = instance.db_engine.get_property(prop_name)
77+
self.assertEqual(value_from_db, 456)
78+
79+
if __name__ == "__main__":
80+
unittest.main()

0 commit comments

Comments
 (0)