From f6670ebf22eeab8a6e9f17cfe0c73216917a54f2 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Mon, 4 May 2026 12:18:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(#251):=20SqliteStoreRepository=20=E2=80=94?= =?UTF-8?q?=20update/delete/find=5Fby=5Fname/clear=5Fdefault=5Fexcept?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étend le repo avec les méthodes manquantes pour le CRUD : - find_by_name : valider unicité name au create/update - update : remplace les champs mutables (id et created_at intacts) - clear_default_except : promotion mono-default lors d'un changement de default - delete : retourne True/False selon suppression effective Tests dédiés sur chacune des nouvelles méthodes. --- document-parser/persistence/store_repo.py | 43 ++++++++++++++ document-parser/tests/test_store_repo.py | 70 +++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/document-parser/persistence/store_repo.py b/document-parser/persistence/store_repo.py index fd67913..52d5412 100644 --- a/document-parser/persistence/store_repo.py +++ b/document-parser/persistence/store_repo.py @@ -73,6 +73,12 @@ class SqliteStoreRepository: row = await cursor.fetchone() return _row_to_store(row) if row else None + async def find_by_name(self, name: str) -> Store | None: + async with get_connection() as db: + cursor = await db.execute("SELECT * FROM stores WHERE name = ?", (name,)) + row = await cursor.fetchone() + return _row_to_store(row) if row else None + async def get_default(self) -> Store | None: """Return the seeded `default` store, if any.""" async with get_connection() as db: @@ -81,3 +87,40 @@ class SqliteStoreRepository: ) row = await cursor.fetchone() return _row_to_store(row) if row else None + + async def update(self, store: Store) -> None: + """Replace all mutable fields of an existing store. `id` and `created_at` + are not touched.""" + async with get_connection() as db: + await db.execute( + """UPDATE stores + SET name = ?, slug = ?, kind = ?, embedder = ?, + config = ?, is_default = ? + WHERE id = ?""", + ( + store.name, + store.slug, + store.kind.value, + store.embedder, + json.dumps(store.config), + 1 if store.is_default else 0, + store.id, + ), + ) + await db.commit() + + async def clear_default_except(self, store_id: str) -> None: + """Reset `is_default = 0` on every store except the given id. Used to + enforce single-default invariant when promoting a new default.""" + async with get_connection() as db: + await db.execute( + "UPDATE stores SET is_default = 0 WHERE id != ?", + (store_id,), + ) + await db.commit() + + async def delete(self, store_id: str) -> bool: + async with get_connection() as db: + cursor = await db.execute("DELETE FROM stores WHERE id = ?", (store_id,)) + await db.commit() + return cursor.rowcount > 0 diff --git a/document-parser/tests/test_store_repo.py b/document-parser/tests/test_store_repo.py index 7126d38..8a37792 100644 --- a/document-parser/tests/test_store_repo.py +++ b/document-parser/tests/test_store_repo.py @@ -92,6 +92,76 @@ class TestStoreRepo: all_stores = await store_repo.find_all() assert all_stores[0].slug == "default" # seeded default comes first + async def test_find_by_name(self, store_repo): + await store_repo.insert( + Store( + id="s-rh", + name="rh-corpus", + slug="rh-corpus", + kind=StoreKind.OPENSEARCH, + embedder="bge-m3", + ) + ) + found = await store_repo.find_by_name("rh-corpus") + assert found is not None + assert found.slug == "rh-corpus" + assert await store_repo.find_by_name("missing") is None + + async def test_update_replaces_mutable_fields(self, store_repo): + await store_repo.insert( + Store( + id="s-rh", + name="rh", + slug="rh", + kind=StoreKind.OPENSEARCH, + embedder="bge-m3", + config={"index_name": "rh"}, + ) + ) + store = await store_repo.find_by_id("s-rh") + assert store is not None + store.embedder = "bge-large" + store.config = {"index_name": "rh-v2"} + store.name = "rh-v2" + await store_repo.update(store) + + reloaded = await store_repo.find_by_id("s-rh") + assert reloaded is not None + assert reloaded.embedder == "bge-large" + assert reloaded.config == {"index_name": "rh-v2"} + assert reloaded.name == "rh-v2" + + async def test_clear_default_except_promotes_single_winner(self, store_repo): + # Seeded default is the original; insert another candidate as default. + await store_repo.insert( + Store( + id="s-new", + name="new", + slug="new", + kind=StoreKind.OPENSEARCH, + embedder="bge-m3", + is_default=True, + ) + ) + await store_repo.clear_default_except("s-new") + defaults = [s for s in await store_repo.find_all() if s.is_default] + assert len(defaults) == 1 + assert defaults[0].id == "s-new" + + async def test_delete_returns_true_when_removed(self, store_repo): + await store_repo.insert( + Store( + id="s-tmp", + name="tmp", + slug="tmp", + kind=StoreKind.OPENSEARCH, + embedder="bge-m3", + ) + ) + assert await store_repo.delete("s-tmp") is True + assert await store_repo.find_by_id("s-tmp") is None + assert await store_repo.delete("s-tmp") is False + class TestDocumentStoreLinkRepo: async def _seed_doc(self, document_repo, doc_id: str = "doc-1") -> Document: