From 26bc71d6d88b8eb895b9e26ded1318a450b440b7 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Thu, 28 May 2026 15:36:00 +0300 Subject: [PATCH] Cascade delete_document to children via metadata.parent_uri --- haiku_rag_slim/haiku/rag/client/__init__.py | 13 +- haiku_rag_slim/haiku/rag/client/documents.py | 11 ++ haiku_rag_slim/haiku/rag/config/models.py | 4 + tests/test_cascade_delete.py | 139 +++++++++++++++++++ 4 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 tests/test_cascade_delete.py diff --git a/haiku_rag_slim/haiku/rag/client/__init__.py b/haiku_rag_slim/haiku/rag/client/__init__.py index 3946d4b3..13b7643e 100644 --- a/haiku_rag_slim/haiku/rag/client/__init__.py +++ b/haiku_rag_slim/haiku/rag/client/__init__.py @@ -310,7 +310,18 @@ class HaikuRAG: return None async def delete_document(self, document_id: str) -> bool: - """Delete a document by its ID.""" + """Delete a document by its ID. Cascades to children linked via + ``metadata.parent_uri``.""" + from haiku.rag.client.documents import parent_uri_filter + + doc = await self.get_document_by_id(document_id) + if doc is None: + return False + if doc.uri: + children = await self.list_documents(filter=parent_uri_filter(doc.uri)) + for child in children: + if child.id and child.id != document_id: + await self.delete_document(child.id) return await self.document_repository.delete(document_id) async def list_documents( diff --git a/haiku_rag_slim/haiku/rag/client/documents.py b/haiku_rag_slim/haiku/rag/client/documents.py index 574a309e..e1defd7e 100644 --- a/haiku_rag_slim/haiku/rag/client/documents.py +++ b/haiku_rag_slim/haiku/rag/client/documents.py @@ -1,3 +1,4 @@ +import json import tempfile from pathlib import Path from typing import TYPE_CHECKING @@ -27,6 +28,16 @@ if TYPE_CHECKING: from haiku.rag.ingester.sources.base import Source +def parent_uri_filter(parent_uri: str) -> str: + """SQL `WHERE` clause matching documents whose ``metadata.parent_uri`` + equals ``parent_uri``. ``metadata`` is stored as a JSON string produced by + the standard library's ``json.dumps`` (which inserts ``": "`` between key + and value), so the match is a substring search over that serialized form — + escape JSON-meaningful chars in the URI, then SQL-escape single quotes.""" + json_fragment = json.dumps(parent_uri)[1:-1].replace("'", "''") + return f'metadata LIKE \'%"parent_uri": "{json_fragment}"%\'' + + async def _store_document_with_chunks( client: "HaikuRAG", document: Document, diff --git a/haiku_rag_slim/haiku/rag/config/models.py b/haiku_rag_slim/haiku/rag/config/models.py index 2719623d..b6102143 100644 --- a/haiku_rag_slim/haiku/rag/config/models.py +++ b/haiku_rag_slim/haiku/rag/config/models.py @@ -182,6 +182,10 @@ class ProcessingConfig(BaseModel): - ``"image"``: docling generates picture images and stores them in ``document_items.picture_data``; no VLM runs at ingest. """ + extract_pdf_attachments: bool = True + """When a PDF carries `/EmbeddedFiles`, ingest each attachment as a separate + Document linked back to the wrapper via ``metadata.parent_uri``. Cap depth + at 3 to bound nested-attachment recursion.""" auto_title: bool = False title_model: ModelConfig = Field( default_factory=lambda: ModelConfig( diff --git a/tests/test_cascade_delete.py b/tests/test_cascade_delete.py new file mode 100644 index 00000000..3963ffbf --- /dev/null +++ b/tests/test_cascade_delete.py @@ -0,0 +1,139 @@ +from haiku.rag.client import HaikuRAG +from haiku.rag.client.documents import parent_uri_filter +from haiku.rag.store.models.document import Document + + +def test_parent_uri_filter_simple(): + f = parent_uri_filter("file:///path/to/parent.pdf") + assert f == 'metadata LIKE \'%"parent_uri": "file:///path/to/parent.pdf"%\'' + + +def test_parent_uri_filter_escapes_single_quote(): + f = parent_uri_filter("file:///x's.pdf") + assert "''" in f + + +def test_parent_uri_filter_escapes_backslash(): + f = parent_uri_filter("file:///x\\y.pdf") + assert "\\\\" in f + + +async def test_delete_cascades_to_children(temp_db_path): + async with HaikuRAG(temp_db_path, create=True) as client: + parent_uri = "file:///path/to/parent.pdf" + parent = await client.document_repository.create( + Document(content="parent body", uri=parent_uri, metadata={}) + ) + child_a = await client.document_repository.create( + Document( + content="child A body", + uri=f"{parent_uri}#attachment=a.pdf", + metadata={"parent_uri": parent_uri}, + ) + ) + child_b = await client.document_repository.create( + Document( + content="child B body", + uri=f"{parent_uri}#attachment=b.pdf", + metadata={"parent_uri": parent_uri}, + ) + ) + + deleted = await client.delete_document(parent.id) + assert deleted is True + + assert await client.get_document_by_id(parent.id) is None + assert await client.get_document_by_id(child_a.id) is None + assert await client.get_document_by_id(child_b.id) is None + + +async def test_delete_leaves_unrelated_documents(temp_db_path): + async with HaikuRAG(temp_db_path, create=True) as client: + parent_uri = "file:///path/to/parent.pdf" + parent = await client.document_repository.create( + Document(content="parent", uri=parent_uri, metadata={}) + ) + child = await client.document_repository.create( + Document( + content="child", + uri=f"{parent_uri}#attachment=a.pdf", + metadata={"parent_uri": parent_uri}, + ) + ) + unrelated = await client.document_repository.create( + Document( + content="unrelated", + uri="file:///path/to/other.pdf", + metadata={}, + ) + ) + + await client.delete_document(parent.id) + + assert await client.get_document_by_id(child.id) is None + survivor = await client.get_document_by_id(unrelated.id) + assert survivor is not None + assert survivor.id == unrelated.id + + +async def test_delete_cascades_recursively(temp_db_path): + async with HaikuRAG(temp_db_path, create=True) as client: + gp_uri = "file:///path/to/grandparent.pdf" + parent_uri = f"{gp_uri}#attachment=parent.pdf" + + grandparent = await client.document_repository.create( + Document(content="gp", uri=gp_uri, metadata={}) + ) + parent = await client.document_repository.create( + Document( + content="p", + uri=parent_uri, + metadata={"parent_uri": gp_uri}, + ) + ) + child = await client.document_repository.create( + Document( + content="c", + uri=f"{parent_uri}#attachment=leaf.pdf", + metadata={"parent_uri": parent_uri}, + ) + ) + + await client.delete_document(grandparent.id) + + assert await client.get_document_by_id(grandparent.id) is None + assert await client.get_document_by_id(parent.id) is None + assert await client.get_document_by_id(child.id) is None + + +async def test_delete_nonexistent_returns_false(temp_db_path): + async with HaikuRAG(temp_db_path, create=True) as client: + result = await client.delete_document("does-not-exist") + assert result is False + + +async def test_delete_handles_self_referential_parent(temp_db_path): + """A document whose metadata.parent_uri points at its own uri must not + cascade into infinite recursion.""" + async with HaikuRAG(temp_db_path, create=True) as client: + uri = "file:///path/to/self.pdf" + doc = await client.document_repository.create( + Document(content="self-loop", uri=uri, metadata={"parent_uri": uri}) + ) + + deleted = await client.delete_document(doc.id) + assert deleted is True + assert await client.get_document_by_id(doc.id) is None + + +def test_processing_config_extract_pdf_attachments_default_true(): + from haiku.rag.config.models import ProcessingConfig + + assert ProcessingConfig().extract_pdf_attachments is True + + +def test_processing_config_extract_pdf_attachments_overridable(): + from haiku.rag.config.models import ProcessingConfig + + cfg = ProcessingConfig(extract_pdf_attachments=False) + assert cfg.extract_pdf_attachments is False