|
| 1 | +"""Tests for graph context hydration in to_graph_context(). |
| 2 | +
|
| 3 | +Proves that recent-activity/build-context hydration batches entity lookups |
| 4 | +for entities, observations, and relations in a single repository call. |
| 5 | +""" |
| 6 | + |
| 7 | +from __future__ import annotations |
| 8 | + |
| 9 | +from datetime import datetime, timezone |
| 10 | +from types import SimpleNamespace |
| 11 | + |
| 12 | +import pytest |
| 13 | + |
| 14 | +from basic_memory.api.v2.utils import to_graph_context |
| 15 | +from basic_memory.schemas.search import SearchItemType |
| 16 | +from basic_memory.services.context_service import ( |
| 17 | + ContextMetadata, |
| 18 | + ContextResult as ServiceContextResult, |
| 19 | + ContextResultItem, |
| 20 | + ContextResultRow, |
| 21 | +) |
| 22 | + |
| 23 | + |
| 24 | +# --- Helpers --- |
| 25 | + |
| 26 | + |
| 27 | +def _make_entity(id: int, title: str, external_id: str) -> SimpleNamespace: |
| 28 | + return SimpleNamespace(id=id, title=title, external_id=external_id) |
| 29 | + |
| 30 | + |
| 31 | +def _make_row(*, type: str, id: int, root_id: int, **kwargs) -> ContextResultRow: |
| 32 | + now = kwargs.pop("created_at", datetime.now(timezone.utc)) |
| 33 | + defaults = dict( |
| 34 | + title=f"Item {id}", |
| 35 | + permalink=f"notes/{id}", |
| 36 | + file_path=f"notes/{id}.md", |
| 37 | + depth=0, |
| 38 | + root_id=root_id, |
| 39 | + created_at=now, |
| 40 | + ) |
| 41 | + defaults.update(kwargs) |
| 42 | + return ContextResultRow(type=type, id=id, **defaults) |
| 43 | + |
| 44 | + |
| 45 | +class SpyEntityRepository: |
| 46 | + """Tracks batched ID lookups and returns entities from a preset map.""" |
| 47 | + |
| 48 | + def __init__(self, entities_by_id: dict[int, SimpleNamespace]): |
| 49 | + self.entities_by_id = entities_by_id |
| 50 | + self.calls: list[list[int]] = [] |
| 51 | + |
| 52 | + async def find_by_ids(self, ids: list[int]): |
| 53 | + self.calls.append(ids) |
| 54 | + return [self.entities_by_id[i] for i in ids if i in self.entities_by_id] |
| 55 | + |
| 56 | + |
| 57 | +# --- Single batch fetch (N+1 elimination) --- |
| 58 | + |
| 59 | + |
| 60 | +@pytest.mark.asyncio |
| 61 | +async def test_to_graph_context_batches_entity_hydration_for_recent_activity(): |
| 62 | + """Mixed entity, observation, and relation items must hydrate in one lookup.""" |
| 63 | + repo = SpyEntityRepository( |
| 64 | + { |
| 65 | + 1: _make_entity(1, "Root", "ext-root"), |
| 66 | + 2: _make_entity(2, "Child", "ext-child"), |
| 67 | + 3: _make_entity(3, "Peer", "ext-peer"), |
| 68 | + } |
| 69 | + ) |
| 70 | + now = datetime.now(timezone.utc) |
| 71 | + |
| 72 | + root_entity = _make_row( |
| 73 | + type="entity", |
| 74 | + id=1, |
| 75 | + root_id=1, |
| 76 | + title="Root", |
| 77 | + permalink="notes/root", |
| 78 | + file_path="notes/root.md", |
| 79 | + created_at=now, |
| 80 | + ) |
| 81 | + root_observation = _make_row( |
| 82 | + type="observation", |
| 83 | + id=10, |
| 84 | + root_id=1, |
| 85 | + title="fact: observed", |
| 86 | + permalink="notes/root/observations/fact/observed", |
| 87 | + file_path="notes/root.md", |
| 88 | + category="fact", |
| 89 | + content="observed", |
| 90 | + entity_id=1, |
| 91 | + created_at=now, |
| 92 | + ) |
| 93 | + root_relation = _make_row( |
| 94 | + type="relation", |
| 95 | + id=20, |
| 96 | + root_id=1, |
| 97 | + title="links_to: Child", |
| 98 | + permalink="notes/root", |
| 99 | + file_path="notes/root.md", |
| 100 | + relation_type="links_to", |
| 101 | + from_id=1, |
| 102 | + to_id=2, |
| 103 | + depth=1, |
| 104 | + created_at=now, |
| 105 | + ) |
| 106 | + child_observation = _make_row( |
| 107 | + type="observation", |
| 108 | + id=11, |
| 109 | + root_id=11, |
| 110 | + title="note: child update", |
| 111 | + permalink="notes/child/observations/note/update", |
| 112 | + file_path="notes/child.md", |
| 113 | + category="note", |
| 114 | + content="child update", |
| 115 | + entity_id=2, |
| 116 | + created_at=now, |
| 117 | + ) |
| 118 | + peer_entity = _make_row( |
| 119 | + type="entity", |
| 120 | + id=3, |
| 121 | + root_id=11, |
| 122 | + title="Peer", |
| 123 | + permalink="notes/peer", |
| 124 | + file_path="notes/peer.md", |
| 125 | + depth=1, |
| 126 | + created_at=now, |
| 127 | + ) |
| 128 | + |
| 129 | + context = ServiceContextResult( |
| 130 | + results=[ |
| 131 | + ContextResultItem( |
| 132 | + primary_result=root_entity, |
| 133 | + observations=[root_observation], |
| 134 | + related_results=[root_relation], |
| 135 | + ), |
| 136 | + ContextResultItem( |
| 137 | + primary_result=child_observation, |
| 138 | + observations=[], |
| 139 | + related_results=[peer_entity], |
| 140 | + ), |
| 141 | + ], |
| 142 | + metadata=ContextMetadata( |
| 143 | + types=[ |
| 144 | + SearchItemType.ENTITY, |
| 145 | + SearchItemType.OBSERVATION, |
| 146 | + SearchItemType.RELATION, |
| 147 | + ], |
| 148 | + depth=1, |
| 149 | + primary_count=2, |
| 150 | + related_count=2, |
| 151 | + total_relations=1, |
| 152 | + total_observations=1, |
| 153 | + ), |
| 154 | + ) |
| 155 | + |
| 156 | + graph = await to_graph_context(context, entity_repository=repo, page=1, page_size=10) |
| 157 | + |
| 158 | + assert len(repo.calls) == 1, f"Expected 1 entity lookup, got {len(repo.calls)}" |
| 159 | + assert set(repo.calls[0]) == {1, 2, 3} |
| 160 | + |
| 161 | + first_result = graph.results[0] |
| 162 | + assert first_result.primary_result.external_id == "ext-root" |
| 163 | + assert first_result.observations[0].entity_external_id == "ext-root" |
| 164 | + assert first_result.observations[0].title == "Root" |
| 165 | + |
| 166 | + relation = first_result.related_results[0] |
| 167 | + assert relation.from_entity == "Root" |
| 168 | + assert relation.from_entity_external_id == "ext-root" |
| 169 | + assert relation.to_entity == "Child" |
| 170 | + assert relation.to_entity_external_id == "ext-child" |
| 171 | + |
| 172 | + second_result = graph.results[1] |
| 173 | + assert second_result.primary_result.entity_external_id == "ext-child" |
| 174 | + assert second_result.primary_result.title == "Child" |
| 175 | + assert second_result.related_results[0].external_id == "ext-peer" |
| 176 | + |
| 177 | + |
| 178 | +@pytest.mark.asyncio |
| 179 | +async def test_to_graph_context_empty_results_skip_entity_lookup(): |
| 180 | + """An empty context result should not perform any entity hydration lookup.""" |
| 181 | + repo = SpyEntityRepository({}) |
| 182 | + context = ServiceContextResult(results=[], metadata=ContextMetadata(depth=1)) |
| 183 | + |
| 184 | + graph = await to_graph_context(context, entity_repository=repo) |
| 185 | + |
| 186 | + assert repo.calls == [] |
| 187 | + assert list(graph.results) == [] |
0 commit comments