diff --git a/store/store.py b/store/store.py index 1530d8b..6655bde 100644 --- a/store/store.py +++ b/store/store.py @@ -19,6 +19,7 @@ # +import uuid import atexit from sqlitedict import SqliteDict @@ -54,6 +55,14 @@ def __init__( self.file_name, tablename=attribute_function_space, autocommit=True ) + # Internal registry to persist dependency relationships derived from AF subscriptions. + # This is not intended for direct user interaction. + self._registry_key = "__dependency_registry__" + + if self._registry_key not in self.sqlite_dict: + self.sqlite_dict[self._registry_key] = {} + self.sqlite_dict.commit() + # register to be called at exit: atexit.register(self.close) @@ -76,18 +85,15 @@ def register(self, af: AttributeFunction): (i.e. persisted). @param af: The AttributeFunction instance to register. """ + uuid_str: str = str(af.uuid) - self.sqlite_dict[af.uuid] = af + self.sqlite_dict[uuid_str] = af self.sqlite_dict.commit() self.attribute_function_buffer[af.uuid] = af def load(self, afid: int) -> None: - """Load an afid from the persistent store into the buffer. - @param afid: The ID of the item to load. - """ - try: - af: AttributeFunction = self.sqlite_dict[afid] + af: AttributeFunction = self.sqlite_dict[str(afid)] if self.add_reference_to_store_on_read: af.__dict__["store"] = self @@ -95,8 +101,73 @@ def load(self, afid: int) -> None: except KeyError as e: raise KeyError(f"ID '{afid}' not found in the store.") from e + def _get_registry(self) -> dict[str, list[str]]: + return self.sqlite_dict.get(self._registry_key, {}) + + def put(self, af: AttributeFunction): + """Store an AttributeFunction in the persistent store.""" + uuid_str: str = str(af.uuid) + + self.sqlite_dict[uuid_str] = af + self.sqlite_dict.commit() + self.attribute_function_buffer[af.uuid] = af + + if hasattr(af, "inputs"): + for parent_af in af.inputs: + if hasattr(parent_af, "uuid"): + self.register_dependency(parent_af.uuid, af.uuid) + + self._notify(af.uuid) + + def register_dependency(self, parent_uuid: uuid.UUID, child_uuid: uuid.UUID): + """ + Register a persistent dependency between two AttributeFunctions. + + @param parent_uuid: The UUID of the AF being observed. + @param child_uuid: The UUID of the AF that depends on the parent. + """ + registry: dict[str, list[str]] = self._get_registry() + + p_uuid_str: str = str(parent_uuid) + c_uuid_str: str = str(child_uuid) + + if p_uuid_str not in registry: + registry[p_uuid_str] = [] + + if c_uuid_str not in registry[p_uuid_str]: + registry[p_uuid_str].append(c_uuid_str) + + self.sqlite_dict[self._registry_key] = registry + self.sqlite_dict.commit() + + def _notify(self, parent_uuid: uuid.UUID): + registry = self._get_registry() + p_uuid_str = str(parent_uuid) + + if p_uuid_str not in registry: + return + + parent_af: AttributeFunction = self.get(parent_uuid) + + dependent_id: str + for dependent_id in registry[p_uuid_str]: + try: + dependent_af: AttributeFunction = self.get(dependent_id) + if dependent_af and hasattr(dependent_af, "update"): + dependent_af.update(other=parent_af) + self.put(dependent_af) + except KeyError: + continue + def __len__(self) -> int: """Return the number of items in the store. @return: The number of items in the store. """ - return len(self.sqlite_dict) + size = len(self.sqlite_dict) + + if self._registry_key in self.sqlite_dict: + size -= 1 + + return size + + diff --git a/tests/store/test_store.py b/tests/store/test_store.py index 7300061..37aa628 100644 --- a/tests/store/test_store.py +++ b/tests/store/test_store.py @@ -19,12 +19,28 @@ # import pickle +import uuid from fdm.API import AttributeFunction, AttributeFunctionSentinel from fdm.attribute_functions import TF from fql.util import Item from store.store import Store +WAS_UPDATED = False + +def global_update_mock(self, other=None, *args, **kwargs): + """ + Picklable global mock that accepts the 'other' argument. + This function is required because the Store triggers update() internally + when notifying dependent AttributeFunctions. The test itself cannot directly + observe this call, so this mock sets the global flag WAS_UPDATED to True + when invoked. + + The function is defined at module level to ensure it is picklable, as + AttributeFunctions may be serialized when stored. + """ + global WAS_UPDATED + WAS_UPDATED = True def test_pickle_Item(tmp_path): @@ -215,3 +231,44 @@ def test_store_get_put_with_sentinel_replacement(tmp_path): assert type(outer_tuple.observers[0]) == TF store_read.close() + +def test_store_dependency_notification(tmp_path): + """ + Test that updates propagate through the Store via subscriptions, + using the persistent dependency mechanism transparently. + This test covers: + 1. Creating AttributeFunctions (AFs) with dependencies + 2. Verifying that updating a parent AF triggers the child's update method + 3. Ensuring that update propagation works across store persistence + """ + global WAS_UPDATED + WAS_UPDATED = False + + TF.update = global_update_mock + + file_name = str(tmp_path / "test_dependency.sqlite") + store = Store(file_name=file_name) + + parent_af = TF({"value": 1}, store=store) + child_af = TF({"value": 2}, store=store) + + child_af.inputs = [parent_af] + + store.put(child_af) + store.put(parent_af) + + assert WAS_UPDATED is True + + # verify registry persisted correctly + registry = store._get_registry() + parent_uuid_str = str(parent_af.uuid) + assert parent_uuid_str in registry + assert str(child_af.uuid) in registry[parent_uuid_str] + + store.close() + store = Store(file_name=file_name) + + # Check that the data is STILL there after re-opening + new_registry = store._get_registry() + assert str(parent_af.uuid) in new_registry +