diff --git a/README.md b/README.md index 83b296f..36f3839 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ engine.init("tenant_1") engine.save("tenant_1", "user_1", "config", {"theme": "dark", "lang": "en"}) data = engine.load("tenant_1", "config") print(data) # {"theme": "dark", "lang": "en"} + +# Delete an entry +engine.delete("tenant_1", "user_1", "config") ``` ## Core Concepts @@ -135,6 +138,32 @@ if engine.exists("tenant_1", "config"): print("Entry exists") ``` +### Deletion Operations + +```python +# Delete an entire entry (removes from head, keeps history) +deleted = engine.delete("tenant_1", "user_1", "config") +# Returns True if entry existed, False otherwise + +# Historical snapshots remain accessible by digest +old_config = engine.load("tenant_1", "config", digest=history[0]) + +# Remove a specific key from an entry (Pattern 2) +engine.put("tenant_1", "user_1", "users", "alice", {"name": "Alice", "role": "admin"}) +engine.put("tenant_1", "user_1", "users", "bob", {"name": "Bob", "role": "user"}) + +removed = engine.remove("tenant_1", "user_1", "users", "alice") +# Returns True if key existed, False otherwise + +# Entry still exists, only "alice" was removed +all_users = engine.get("tenant_1", "users") # Returns only bob +``` + +**Key differences:** +- `delete()`: Removes entire entry from `head` file (works with both Pattern 1 and Pattern 2) +- `remove()`: Removes a specific key from an entry, creates new snapshot (Pattern 2 only) +- Both operations preserve historical snapshots in `objects/` directory + ## Custom Serialization MyDbEngine supports three approaches for custom serialization: @@ -271,6 +300,8 @@ engine.save("tenant_1", "user_1", "my_data", obj) | `put(tenant_id, user_id, entry, key, value) -> bool` | Add/update single record | | `put_many(tenant_id, user_id, entry, items) -> bool` | Add/update multiple records | | `get(tenant_id, entry, key=None, digest=None) -> object` | Get record(s) | +| `delete(tenant_id, user_id, entry) -> bool` | Delete entire entry from head (keeps history) | +| `remove(tenant_id, user_id, entry, key) -> bool` | Remove specific key from entry (Pattern 2) | | `exists(tenant_id, entry) -> bool` | Check if entry exists | ### History @@ -350,4 +381,5 @@ See LICENSE file for details. ## Version History * 0.1.0 - Initial release * 0.2.0 - Added custom reference handlers -* 0.2.1 - A handler can only be registered once \ No newline at end of file +* 0.2.1 - A handler can only be registered once +* 0.3.0 - Added delete() and remove() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 542b10f..d80d795 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mydbengine" -version = "0.2.1" +version = "0.3.0" description = "A lightweight, git-inspired database engine that maintains complete history of all modifications" readme = "README.md" requires-python = ">=3.8" diff --git a/src/dbengine/dbengine.py b/src/dbengine/dbengine.py index a6d96f7..921461b 100644 --- a/src/dbengine/dbengine.py +++ b/src/dbengine/dbengine.py @@ -197,6 +197,28 @@ class DbEngine: return self._deserialize(as_dict) + def delete(self, tenant_id: str, user_id: str, entry: str): + """ + Delete a whole entry + :param tenant_id: + :param user_id: + :param entry: + :return: + """ + with self.lock: + logger.info(f"Delete {tenant_id=}, {entry=}") + + if not tenant_id: + raise DbException("tenant_id is None") + + if not user_id: + raise DbException("user_id is None") + + if not entry: + raise DbException("entry is None") + + return self._update_head(tenant_id, entry, None) + def put(self, tenant_id: str, user_id, entry, key: str, value: object): """ Save a specific record. @@ -272,6 +294,31 @@ class DbEngine: return False + def remove(self, tenant_id: str, user_id, entry: str, key: str): + """ + Remove a specific record + :param tenant_id: + :param user_id: + :param entry: + :param key: + :return: + """ + with self.lock: + logger.info(f"Removing {tenant_id=}, {entry=}, {key=}") + try: + entry_content = self.load(tenant_id, entry) + except DbException: + entry_content = {} + + # Do not update the digest if the record is not present + if key not in entry_content: + return False + + del entry_content[key] + + self.save(tenant_id, user_id, entry, entry_content) + return True + def exists(self, tenant_id, entry: str): """ Tells if an entry exist @@ -375,11 +422,19 @@ class DbEngine: head = {} # update - head[entry] = digest + if digest is not None: + head[entry] = digest + else: + if entry not in head: + return False # no need to update + else: + del head[entry] # and save with open(head_path, 'w') as file: json.dump(head, file) + + return True def _get_user_root(self, tenant_id): return os.path.join(self.root, tenant_id) diff --git a/tests/test_dbengine.py b/tests/test_dbengine.py index 2590668..2449b09 100644 --- a/tests/test_dbengine.py +++ b/tests/test_dbengine.py @@ -1,3 +1,4 @@ +import json import os.path import shutil @@ -73,6 +74,15 @@ def dummy_obj_with_ref(): return DummyObjWithRef(1, "a", data) +def load_head(engine, tenant_id): + head_path = os.path.join(engine._get_user_root(tenant_id), engine.HeadFile) + try: + with open(head_path, 'r') as file: + return json.load(file) + except FileNotFoundError: + return {} + + def test_i_can_test_init(): if os.path.exists(DB_ENGINE_ROOT): shutil.rmtree(DB_ENGINE_ROOT) @@ -271,3 +281,59 @@ def test_i_can_retrieve_history_using_save(engine): assert v2["key1"] == DummyObj(1, "a", False) assert v2[TAG_PARENT] == [None] + + +def test_i_can_delete_entries(engine): + digest1 = engine.save(FAKE_TENANT_ID, FAKE_USER_EMAIL, "MyEntry", {"key1": DummyObj(1, "a", False)}) + digest2 = engine.save(FAKE_TENANT_ID, FAKE_USER_EMAIL, "MyEntry2", {"key1": DummyObj(2, "b", True)}) + res = engine.delete(FAKE_TENANT_ID, FAKE_USER_EMAIL, "MyEntry") + + assert res is True + + # entry is no longer in the head + head = load_head(engine, FAKE_TENANT_ID) + assert "MyEntry" not in head + assert "MyEntry2" in head + + # digests are not removed from the db + assert engine.load(FAKE_TENANT_ID, "MyEntry2", digest1) is not None + assert engine.load(FAKE_TENANT_ID, "MyEntry2", digest2) is not None + + +def test_i_can_delete_an_entry_that_does_not_exist(engine): + # no exception should be raised + res = engine.delete(FAKE_TENANT_ID, FAKE_USER_EMAIL, "MyEntry") + assert res is False + + +def test_i_can_remove_an_key_from_an_entry(engine): + engine.save(FAKE_TENANT_ID, + FAKE_USER_EMAIL, + "MyEntry", + { + "key1": DummyObj(1, "a", False), + "key2": DummyObj(2, "b", True) + }) + + res = engine.remove(FAKE_TENANT_ID, FAKE_USER_EMAIL, "MyEntry", "key1") + assert res is True + + snapshot = engine.load(FAKE_TENANT_ID, "MyEntry") + assert "key1" not in snapshot + assert "key2" in snapshot + + +def test_i_can_remove_a_key_that_does_not_exist(engine): + # no exception should be raised + res = engine.remove(FAKE_TENANT_ID, FAKE_USER_EMAIL, "MyEntry", "key1") + assert res is False + + +def test_removing_the_last_key_from_an_entry_not_not_delete_the_entry(engine): + engine.save(FAKE_TENANT_ID, FAKE_USER_EMAIL, "MyEntry", {"key1": DummyObj(1, "a", False)}) + res = engine.remove(FAKE_TENANT_ID, FAKE_USER_EMAIL, "MyEntry", "key1") + assert res is True + + snapshot = engine.load(FAKE_TENANT_ID, "MyEntry") + assert "key1" not in snapshot + assert snapshot[TAG_PARENT] is not None