From 1e34892fbf1cd98ee9ab6841ac2494ada9be64cd Mon Sep 17 00:00:00 2001 From: Chris Mutel Date: Tue, 7 Jan 2025 11:07:58 +0100 Subject: [PATCH 1/2] Remove SQLite-specific search functionality --- bw2data/__init__.py | 4 +- bw2data/backends/base.py | 40 ++-- bw2data/backends/proxies.py | 20 +- bw2data/search/__init__.py | 2 - bw2data/search/indices.py | 125 ---------- bw2data/search/schema.py | 16 -- bw2data/search/search.py | 88 ------- tests/database_querying.py | 5 - tests/search.py | 359 ----------------------------- tests/unit/test_database_events.py | 2 - 10 files changed, 35 insertions(+), 626 deletions(-) delete mode 100644 bw2data/search/__init__.py delete mode 100644 bw2data/search/indices.py delete mode 100644 bw2data/search/schema.py delete mode 100644 bw2data/search/search.py delete mode 100644 tests/search.py diff --git a/bw2data/__init__.py b/bw2data/__init__.py index 74eb20db..ca590e87 100644 --- a/bw2data/__init__.py +++ b/bw2data/__init__.py @@ -27,7 +27,7 @@ "prepare_lca_inputs", "ProcessedDataStore", "projects", - "Searcher", + # "Searcher", "set_data_dir", "Weighting", "weightings", @@ -71,7 +71,7 @@ from bw2data.utils import get_activity, get_node from bw2data.data_store import DataStore, ProcessedDataStore from bw2data.method import Method -from bw2data.search import Searcher, IndexManager +# from bw2data.search import Searcher, IndexManager from bw2data.weighting_normalization import Weighting, Normalization from bw2data.backends import convert_backend, get_id, Node, Edge from bw2data.compat import prepare_lca_inputs, Mapping, get_multilca_data_objs diff --git a/bw2data/backends/base.py b/bw2data/backends/base.py index 2c7908e3..ccf36f56 100644 --- a/bw2data/backends/base.py +++ b/bw2data/backends/base.py @@ -43,7 +43,7 @@ ) from bw2data.logs import stdout_feedback_logger from bw2data.query import Query -from bw2data.search import IndexManager, Searcher +# from bw2data.search import IndexManager, Searcher from bw2data.signals import on_database_reset, on_database_write from bw2data.utils import as_uncertainty_dict, get_geocollection, get_node, set_correct_process_type @@ -732,20 +732,24 @@ def new_node(self, code: str = None, **kwargs): return obj def make_searchable(self, reset: bool = False, signal: bool = True): - if self.name not in databases: - raise UnknownObject("This database is not yet registered") - if self._searchable and not reset: - stdout_feedback_logger.info("This database is already searchable") - return - databases[self.name]["searchable"] = True - databases.flush(signal=signal) - IndexManager(self.filename).create() - IndexManager(self.filename).add_datasets(self) + return + + # if self.name not in databases: + # raise UnknownObject("This database is not yet registered") + # if self._searchable and not reset: + # stdout_feedback_logger.info("This database is already searchable") + # return + # databases[self.name]["searchable"] = True + # databases.flush(signal=signal) + # IndexManager(self.filename).create() + # IndexManager(self.filename).add_datasets(self) def make_unsearchable(self, signal: bool = True): - databases[self.name]["searchable"] = False - databases.flush(signal=signal) - IndexManager(self.filename).delete_database() + return + + # databases[self.name]["searchable"] = False + # databases.flush(signal=signal) + # IndexManager(self.filename).delete_database() def delete( self, keep_params: bool = False, warn: bool = True, vacuum: bool = True, signal: bool = True @@ -785,7 +789,7 @@ def purge(dct: dict) -> dict: ActivityDataset.delete().where(ActivityDataset.database == self.name).execute() ExchangeDataset.delete().where(ExchangeDataset.output_database == self.name).execute() - IndexManager(self.filename).delete_database() + # IndexManager(self.filename).delete_database() if not keep_params: from bw2data.parameters import ( @@ -1003,9 +1007,11 @@ def search(self, string, **kwargs): * ``proxy``: Return ``Activity`` proxies instead of dictionary index Models. Default is ``True``. Returns a list of ``Activity`` datasets.""" - with Searcher(self.filename) as s: - results = s.search(string=string, **kwargs) - return results + raise NotImplementedError + + # with Searcher(self.filename) as s: + # results = s.search(string=string, **kwargs) + # return results def set_geocollections(self): """Set ``geocollections`` attribute for databases which don't currently have it.""" diff --git a/bw2data/backends/proxies.py b/bw2data/backends/proxies.py index 14a0b395..4e82e3af 100644 --- a/bw2data/backends/proxies.py +++ b/bw2data/backends/proxies.py @@ -19,7 +19,7 @@ from bw2data.errors import ValidityError from bw2data.logs import stdout_feedback_logger from bw2data.proxies import ActivityProxyBase, ExchangeProxyBase -from bw2data.search import IndexManager +# from bw2data.search import IndexManager from bw2data.signals import on_activity_code_change, on_activity_database_change @@ -281,7 +281,7 @@ def purge(obj: Activity, dct: dict) -> dict: ).execute() except ActivityParameter.DoesNotExist: pass - IndexManager(Database(self["database"]).filename).delete_dataset(self._data) + # IndexManager(Database(self["database"]).filename).delete_dataset(self._data) self.exchanges().delete(allow_in_sourced_project=True) self.upstream().delete(allow_in_sourced_project=True) @@ -352,8 +352,8 @@ def save(self, signal: bool = True, data_already_set: bool = False, force_insert if self.get("location") and self["location"] not in geomapping: geomapping.add([self["location"]]) - if databases[self["database"]].get("searchable", True): - IndexManager(Database(self["database"]).filename).update_dataset(self._data) + # if databases[self["database"]].get("searchable", True): + # IndexManager(Database(self["database"]).filename).update_dataset(self._data) def _change_code(self, new_code: str, signal: bool = True): if self["code"] == new_code: @@ -383,11 +383,11 @@ def _change_code(self, new_code: str, signal: bool = True): ).execute() if databases[self["database"]].get("searchable"): - from bw2data import Database + # from bw2data import Database - IndexManager(Database(self["database"]).filename).delete_dataset(self) + # IndexManager(Database(self["database"]).filename).delete_dataset(self) self._data["code"] = new_code - IndexManager(Database(self["database"]).filename).add_datasets([self]) + # IndexManager(Database(self["database"]).filename).add_datasets([self]) else: self._data["code"] = new_code @@ -420,11 +420,11 @@ def _change_database(self, new_database: str, signal: bool = True): ).execute() if databases[self["database"]].get("searchable"): - from bw2data import Database + # from bw2data import Database - IndexManager(Database(self["database"]).filename).delete_dataset(self) + # IndexManager(Database(self["database"]).filename).delete_dataset(self) self._data["database"] = new_database - IndexManager(Database(self["database"]).filename).add_datasets([self]) + # IndexManager(Database(self["database"]).filename).add_datasets([self]) else: self._data["database"] = new_database diff --git a/bw2data/search/__init__.py b/bw2data/search/__init__.py deleted file mode 100644 index 9fb7a0b3..00000000 --- a/bw2data/search/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from bw2data.search.indices import IndexManager -from bw2data.search.search import Searcher diff --git a/bw2data/search/indices.py b/bw2data/search/indices.py deleted file mode 100644 index 62019b54..00000000 --- a/bw2data/search/indices.py +++ /dev/null @@ -1,125 +0,0 @@ -import os -import warnings - -from playhouse.sqlite_ext import SqliteExtDatabase - -from bw2data import projects -from bw2data.search.schema import BW2Schema - -MODELS = (BW2Schema,) - - -class IndexManager: - def __init__(self, database_path): - self.path = os.path.join(projects.request_directory("search"), database_path) - self.db = SqliteExtDatabase(self.path) - if not os.path.exists(self.path): - self.create() - - def get(self): - return self - - def create(self): - self.delete_database() - with self.db.bind_ctx(MODELS): - self.db.create_tables(MODELS) - - def _format_dataset(self, ds): - def _fix_location(string): - if isinstance(string, tuple): - string = string[1] - if isinstance(string, str): - if string.lower() == "none": - return "" - else: - return string.lower().strip() - else: - return "" - - return dict( - name=(ds.get("name") or "").lower(), - comment=(ds.get("comment") or "").lower(), - product=(ds.get("reference product") or "").lower(), - categories=", ".join(ds.get("categories") or []).lower(), - synonyms=", ".join(ds.get("synonyms") or []).lower(), - location=_fix_location(ds.get("location") or ""), - database=ds["database"], - code=ds["code"], - ) - - def add_dataset(self, ds): - self.add_datasets([ds]) - - def add_datasets(self, datasets): - all_dataset = list(datasets) - with self.db.bind_ctx(MODELS): - for chunk_range in range(0, len(datasets), 100): - for model in MODELS: - model.insert_many( - [ - self._format_dataset(ds) - for ds in all_dataset[chunk_range : chunk_range + 100] - ] - ).execute() - - def update_dataset(self, ds): - with self.db.bind_ctx(MODELS): - for model in MODELS: - model.delete().where( - model.code == ds["code"], model.database == ds["database"] - ).execute() - model.insert(**self._format_dataset(ds)).execute() - - def delete_dataset(self, ds): - with self.db.bind_ctx(MODELS): - for model in MODELS: - model.delete().where( - model.code == ds["code"], model.database == ds["database"] - ).execute() - - def delete_database(self): - with self.db.bind_ctx(MODELS): - self.db.drop_tables(MODELS) - - def close(self): - self.db.close() - - def search(self, string, limit=None, weights=None, mask=None, filter=None): - if mask: - warnings.warn( - "`mask` functionality has been deleted, and now does nothing. This input argument will be removed in the future", - DeprecationWarning, - ) - if filter: - warnings.warn( - "`filter` functionality has been deleted, and now does nothing. This input argument will be removed in the future", - DeprecationWarning, - ) - - with self.db.bind_ctx(MODELS): - if string == "*": - query = BW2Schema - else: - query = BW2Schema.search_bm25( - string.replace(",", "") - .replace("(", "") - .replace(")", "") - .replace("{", "") - .replace("}", ""), - weights=weights, - ) - return list( - query.select( - BW2Schema.name, - BW2Schema.comment, - BW2Schema.product, - BW2Schema.categories, - BW2Schema.synonyms, - BW2Schema.location, - BW2Schema.database, - BW2Schema.code, - ) - .limit(limit) - .dicts() - .execute() - ) diff --git a/bw2data/search/schema.py b/bw2data/search/schema.py deleted file mode 100644 index d3c36446..00000000 --- a/bw2data/search/schema.py +++ /dev/null @@ -1,16 +0,0 @@ -from playhouse.sqlite_ext import FTS5Model, RowIDField, SearchField - - -class BW2Schema(FTS5Model): - rowid = RowIDField() - name = SearchField() - comment = SearchField() - product = SearchField() - categories = SearchField() - synonyms = SearchField() - location = SearchField() - database = SearchField() - code = SearchField() - - class Meta: - options = {"tokenize": "unicode61 tokenchars '''&:'"} diff --git a/bw2data/search/search.py b/bw2data/search/search.py deleted file mode 100644 index 2d1056cd..00000000 --- a/bw2data/search/search.py +++ /dev/null @@ -1,88 +0,0 @@ -from itertools import groupby - -import peewee - -from bw2data.search.indices import IndexManager - - -def keysplit(strng): - """Split an activity key joined into a single string using the magic sequence `⊡|⊡`""" - return tuple(strng.split("⊡|⊡")) - - -class Searcher: - search_fields = { - "name", - "comment", - "product", - "categories", - "synonyms", - "location", - } - - def __init__(self, database): - self._database = database - - def __enter__(self): - self.index = IndexManager(self._database).get() - return self - - def __exit__(self, type, value, traceback): - self.index.close() - - def search( - self, - string, - limit=25, - facet=None, - proxy=True, - boosts=None, - filter=None, - mask=None, - node_class=None, - ): - from bw2data import get_node - - lowercase = lambda x: x.lower() if hasattr(x, "lower") else x - string = lowercase(string) - - boosts = boosts or { - "name": 5, - "comment": 1, - "product": 3, - "categories": 2, - "synonyms": 3, - "location": 3, - } - - kwargs = {"limit": limit} - if facet: - kwargs.pop("limit") - - with self: - try: - results = self.index.search(string, weights=boosts, **kwargs) - except peewee.OperationalError as e: - if "no such table" in str(e): - results = None - else: - raise - - if facet: - results = {k: list(v) for k, v in groupby(results, lambda x: x.get(facet))} - - if proxy and facet is not None: - return { - key: [ - get_node(database=obj["database"], code=obj["code"], node_class=node_class) - for obj in value - ] - for key, value in results.items() - } - elif proxy: - return [ - get_node(database=obj["database"], code=obj["code"], node_class=node_class) - for obj in results - ] - else: - return results diff --git a/tests/database_querying.py b/tests/database_querying.py index a10ab13e..16c2deea 100644 --- a/tests/database_querying.py +++ b/tests/database_querying.py @@ -139,11 +139,6 @@ def test_len_respects_filters(self): self.db.filters = {"product": "widget"} self.assertEqual(len(self.db), 2) - def test_make_searchable_unknown_object(self): - db = DatabaseChooser("mysterious") - with self.assertRaises(UnknownObject): - db.make_searchable() - def test_convert_same_backend(self): database = DatabaseChooser("a database") database.write( diff --git a/tests/search.py b/tests/search.py deleted file mode 100644 index 204020e8..00000000 --- a/tests/search.py +++ /dev/null @@ -1,359 +0,0 @@ -from bw2data import databases -from bw2data.backends import SQLiteBackend -from bw2data.search import IndexManager, Searcher -from bw2data.tests import bw2test - - -@bw2test -def test_search_dataset_containing_stop_word(): - im = IndexManager("foo") - im.add_dataset({"database": "foo", "code": "bar", "name": "foo of bar, high voltage"}) - with Searcher("foo") as s: - assert s.search("foo of bar, high voltage", proxy=False) - - -@bw2test -def test_add_dataset(): - im = IndexManager("foo") - im.add_dataset({"database": "foo", "code": "bar", "name": "lollipop"}) - with Searcher("foo") as s: - assert s.search("lollipop", proxy=False) - - -@bw2test -def test_search_dataset(): - im = IndexManager("foo") - im.add_dataset({"database": "foo", "code": "bar", "name": "lollipop"}) - with Searcher("foo") as s: - assert s.search("lollipop", proxy=False) == [ - { - "comment": "", - "product": "", - "name": "lollipop", - "database": "foo", - "location": "", - "code": "bar", - "categories": "", - "synonyms": "", - } - ] - - -@bw2test -def test_search_geocollection_location(): - im = IndexManager("foo") - im.add_dataset( - { - "database": "foo", - "code": "bar", - "name": "lollipop", - "location": ("foo", "Here"), - } - ) - with Searcher("foo") as s: - assert s.search("lollipop", proxy=False) == [ - { - "comment": "", - "product": "", - "name": "lollipop", - "database": "foo", - "location": "here", - "code": "bar", - "categories": "", - "synonyms": "", - } - ] - - -@bw2test -def test_update_dataset(): - im = IndexManager("foo") - ds = {"database": "foo", "code": "bar", "name": "lollipop"} - im.add_dataset(ds) - ds["name"] = "lemon cake" - im.update_dataset(ds) - with Searcher("foo") as s: - assert s.search("lemon", proxy=False) == [ - { - "comment": "", - "product": "", - "name": "lemon cake", - "database": "foo", - "location": "", - "code": "bar", - "categories": "", - "synonyms": "", - } - ] - - -@bw2test -def test_delete_dataset(): - im = IndexManager("foo") - ds = {"database": "foo", "code": "bar", "name": "lollipop"} - im.add_dataset(ds) - with Searcher("foo") as s: - assert s.search("lollipop", proxy=False) - im.delete_dataset(ds) - with Searcher("foo") as s: - assert not s.search("lollipop", proxy=False) - - -@bw2test -def test_add_datasets(): - im = IndexManager("foo") - ds = [{"database": "foo", "code": "bar", "name": "lollipop"}] - im.add_datasets(ds) - with Searcher("foo") as s: - assert s.search("lollipop", proxy=False) - - -@bw2test -def test_add_database(): - db = SQLiteBackend("foo") - ds = {("foo", "bar"): {"database": "foo", "code": "bar", "name": "lollipop"}} - db.write(ds) - with Searcher(db.filename) as s: - assert s.search("lollipop", proxy=False) - db.make_unsearchable() - with Searcher(db.filename) as s: - assert not s.search("lollipop", proxy=False) - - -@bw2test -def test_add_searchable_database(): - db = SQLiteBackend("foo") - ds = {("foo", "bar"): {"database": "foo", "code": "bar", "name": "lollipop"}} - db.write(ds) - with Searcher(db.filename) as s: - assert s.search("lollipop", proxy=False) - - -@bw2test -def test_modify_database(): - db = SQLiteBackend("foo") - ds = {("foo", "bar"): {"database": "foo", "code": "bar", "name": "lollipop"}} - db.write(ds) - with Searcher(db.filename) as s: - assert not s.search("cream", proxy=False) - assert s.search("lollipop", proxy=False) - ds2 = {("foo", "bar"): {"database": "foo", "code": "bar", "name": "ice cream"}} - db.write(ds2) - with Searcher(db.filename) as s: - assert s.search("cream", proxy=False) - - -@bw2test -def test_delete_database(): - db = SQLiteBackend("foo") - ds = {("foo", "bar"): {"database": "foo", "code": "bar", "name": "lollipop"}} - db.write(ds) - with Searcher(db.filename) as s: - assert s.search("lollipop", proxy=False) - db.make_unsearchable() - with Searcher(db.filename) as s: - assert not s.search("lollipop", proxy=False) - db.make_searchable() - with Searcher(db.filename) as s: - assert s.search("lollipop", proxy=False) - del databases["foo"] - with Searcher(db.filename) as s: - assert not s.search("lollipop", proxy=False) - - -@bw2test -def test_reset_index(): - im = IndexManager("foo") - ds = {"database": "foo", "code": "bar", "name": "lollipop"} - im.add_dataset(ds) - im.create() - with Searcher("foo") as s: - assert not s.search("lollipop", proxy=False) - - -@bw2test -def test_basic_search(): - im = IndexManager("foo") - im.add_dataset({"database": "foo", "code": "bar", "name": "lollipop"}) - with Searcher("foo") as s: - assert s.search("lollipop", proxy=False) - - -@bw2test -def test_product_term(): - im = IndexManager("foo") - im.add_dataset({"database": "foo", "code": "bar", "reference product": "lollipop"}) - with Searcher("foo") as s: - assert s.search("lollipop", proxy=False) - - -@bw2test -def test_comment_term(): - im = IndexManager("foo") - im.add_dataset({"database": "foo", "code": "bar", "comment": "lollipop"}) - with Searcher("foo") as s: - assert s.search("lollipop", proxy=False) - - -@bw2test -def test_categories_term(): - im = IndexManager("foo") - im.add_dataset({"database": "foo", "code": "bar", "categories": ("lollipop",)}) - with Searcher("foo") as s: - assert s.search("lollipop", proxy=False) - - -@bw2test -def test_limit(): - im = IndexManager("foo") - im.add_datasets( - [{"database": "foo", "code": "bar", "name": "lollipop {}".format(x)} for x in range(50)] - ) - with Searcher("foo") as s: - assert len(s.search("lollipop", limit=25, proxy=False)) == 25 - - -@bw2test -def test_star_search(): - im = IndexManager("foo") - im.add_datasets( - [{"database": "foo", "code": "bar", "name": "lollipop {}".format(x)} for x in range(50)] - ) - with Searcher("foo") as s: - assert len(s.search("*", limit=25, proxy=False)) == 25 - - -@bw2test -def test_search_faceting(): - im = IndexManager("foo") - ds = [ - {"database": "foo", "code": "bar", "name": "lollipop", "location": "CH"}, - {"database": "foo", "code": "bar", "name": "ice lollipop", "location": "FR"}, - ] - im.add_datasets(ds) - with Searcher("foo") as s: - res = s.search("lollipop", proxy=False, facet="location") - assert res == { - "fr": [ - { - "comment": "", - "product": "", - "name": "ice lollipop", - "database": "foo", - "location": "fr", - "code": "bar", - "categories": "", - "synonyms": "", - } - ], - "ch": [ - { - "comment": "", - "product": "", - "name": "lollipop", - "database": "foo", - "location": "ch", - "code": "bar", - "categories": "", - "synonyms": "", - } - ], - } - - -@bw2test -def test_copy_save_propogates_to_search_index(): - db = SQLiteBackend("foo") - ds = {("foo", "bar"): {"database": "foo", "code": "bar", "name": "lollipop"}} - db.write(ds) - assert db.search("lollipop") - cp = db.get("bar").copy(code="baz") - cp["name"] = "candy" - cp.save() - assert db.search("candy") - - -@bw2test -def test_case_sensitivity_convert_lowercase(): - db = SQLiteBackend("foo") - ds = {("foo", "bar"): {"database": "foo", "code": "bar", "name": "LOLLIpop"}} - db.write(ds) - assert db.search("LOLLIpop".lower()) - assert db.search("lollipop") - assert db.search("LOLLipop") - assert db.search("LOLL*") - assert db.search("Lollipop") - assert not db.search("nope") - - -@bw2test -def test_synonym_search(): - im = IndexManager("foo") - im.add_dataset( - { - "database": "foo", - "code": "bar", - "name": "polytetrafluoroethylene", - "synonyms": ["PTFE", "Teflon"], - } - ) - with Searcher("foo") as s: - assert s.search("Teflon", proxy=False) == [ - { - "comment": "", - "product": "", - "name": "polytetrafluoroethylene", - "database": "foo", - "location": "", - "code": "bar", - "categories": "", - "synonyms": "ptfe, teflon", - } - ] - - -@bw2test -def test_search_single_char(): - """Check we can disambiguate between "system 1", "system 2" and "system 3" """ - im = IndexManager("foo") - for i in [1, 2, 3]: - im.add_dataset( - { - "database": "foo", - "code": "bar", - "name": "Milk organic system %s" % i, - } - ) - with Searcher("foo") as s: - assert s.search("milk organic system 2", proxy=False) == [ - { - "comment": "", - "product": "", - "name": "milk organic system 2", - "database": "foo", - "location": "", - "code": "bar", - "categories": "", - "synonyms": "", - } - ] - - -@bw2test -def test_search_with_parentheses(): - """Test that searching with parentheses works correctly""" - im = IndexManager("foo") - im.add_dataset({"database": "foo", "code": "bar", "name": "beam dried (u=10%) planed"}) - with Searcher("foo") as s: - assert s.search("dried (u=10%)", proxy=False) == [ - { - "comment": "", - "product": "", - "name": "beam dried (u=10%) planed", - "database": "foo", - "location": "", - "code": "bar", - "categories": "", - "synonyms": "", - } - ] diff --git a/tests/unit/test_database_events.py b/tests/unit/test_database_events.py index a7a38c6c..a740e2f0 100644 --- a/tests/unit/test_database_events.py +++ b/tests/unit/test_database_events.py @@ -1007,7 +1007,6 @@ def test_database_copy_revision_expected_format(): "depends": ["biosphere"], "backend": "sqlite", "geocollections": ["world"], - "searchable": True, "format": "Copied from 'food'", } } @@ -1306,7 +1305,6 @@ def test_database_rename_revision_expected_format(): "depends": ["biosphere"], "backend": "sqlite", "geocollections": ["world"], - "searchable": True, } } }, From a4fb2168e2ee24b738053f394d0474f869775bcc Mon Sep 17 00:00:00 2001 From: Chris Mutel Date: Tue, 7 Jan 2025 11:49:12 +0100 Subject: [PATCH 2/2] Fix tests with changed geocollections I don't understand why disabling search functionality would effect these tests, but don't care enough to find out as we want to change the events in any case. --- tests/unit/test_database_events.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_database_events.py b/tests/unit/test_database_events.py index a740e2f0..c8010262 100644 --- a/tests/unit/test_database_events.py +++ b/tests/unit/test_database_events.py @@ -481,7 +481,14 @@ def test_database_write_revision_expected_format(): "type": "lci_database", "id": None, "change_type": "database_metadata_change", - "delta": {"iterable_item_added": {"root['food']['depends'][0]": "biosphere"}}, + "delta": { + "dictionary_item_added": { + "root['food']['geocollections']": ["world"] + }, + "iterable_item_added": { + "root['food']['depends'][0]": "biosphere" + }, + }, } ], }, @@ -1066,7 +1073,12 @@ def test_database_copy_revision_expected_format(): "type": "lci_database", "id": None, "change_type": "database_metadata_change", - "delta": {"iterable_item_added": {"root['yum']['depends'][0]": "biosphere"}}, + "delta": { + "iterable_item_added": { + "root['yum']['depends'][0]": "biosphere", + "root['yum']['geocollections'][0]": "world", + } + }, } ], },