|
3 | 3 | from collections.abc import AsyncIterator, Iterator |
4 | 4 | from contextlib import asynccontextmanager |
5 | 5 | from dataclasses import dataclass, field |
| 6 | +from types import SimpleNamespace |
6 | 7 | from typing import cast |
7 | 8 | from uuid import UUID |
8 | 9 |
|
@@ -154,9 +155,13 @@ class FakeProjectIndexSession: |
154 | 155 | """Record repository maintenance statements without a real database.""" |
155 | 156 |
|
156 | 157 | results: list[FakeProjectIndexResult] |
| 158 | + dialect_name: str = "sqlite" |
157 | 159 | statements: list[object] = field(default_factory=list) |
158 | 160 | params: list[object | None] = field(default_factory=list) |
159 | 161 |
|
| 162 | + def get_bind(self) -> SimpleNamespace: |
| 163 | + return SimpleNamespace(dialect=SimpleNamespace(name=self.dialect_name)) |
| 164 | + |
160 | 165 | async def execute( |
161 | 166 | self, |
162 | 167 | statement: object, |
@@ -776,7 +781,7 @@ async def fake_scoped_session( |
776 | 781 | assert "SELECT entity.id, entity.file_path" in str(session.statements[1]) |
777 | 782 | assert "SELECT DISTINCT relation.from_id" in str(session.statements[2]) |
778 | 783 | assert "DELETE FROM search_index" in str(session.statements[3]) |
779 | | - assert "DELETE FROM search_vector_chunks" in str(session.statements[4]) |
| 784 | + assert "sqlite_master" in str(session.statements[4]) |
780 | 785 | assert "DELETE FROM entity" in str(session.statements[5]) |
781 | 786 | assert "UPDATE entity" in str(session.statements[6]) |
782 | 787 | assert "UPDATE note_content" in str(session.statements[7]) |
@@ -907,10 +912,133 @@ async def fake_scoped_session( |
907 | 912 | assert "SELECT entity.id, entity.file_path" in str(session.statements[0]) |
908 | 913 | assert "SELECT DISTINCT relation.from_id" in str(session.statements[1]) |
909 | 914 | assert "DELETE FROM search_index" in str(session.statements[2]) |
910 | | - assert "DELETE FROM search_vector_chunks" in str(session.statements[3]) |
| 915 | + assert "sqlite_master" in str(session.statements[3]) |
911 | 916 | assert "DELETE FROM entity" in str(session.statements[4]) |
912 | 917 |
|
913 | 918 |
|
| 919 | +@pytest.mark.asyncio |
| 920 | +async def test_repository_project_index_maintenance_store_deletes_vector_embeddings_before_chunks( |
| 921 | + monkeypatch: pytest.MonkeyPatch, |
| 922 | +) -> None: |
| 923 | + session_maker = cast(async_sessionmaker[AsyncSession], object()) |
| 924 | + session = FakeProjectIndexSession( |
| 925 | + results=[ |
| 926 | + FakeProjectIndexResult( |
| 927 | + mapping_rows=[ |
| 928 | + {"id": 10, "file_path": "notes/a.md"}, |
| 929 | + ] |
| 930 | + ), |
| 931 | + FakeProjectIndexResult(scalar_values=[]), |
| 932 | + FakeProjectIndexResult(), |
| 933 | + FakeProjectIndexResult( |
| 934 | + scalar_values=["search_vector_chunks", "search_vector_embeddings"] |
| 935 | + ), |
| 936 | + ] |
| 937 | + ) |
| 938 | + |
| 939 | + @asynccontextmanager |
| 940 | + async def fake_scoped_session( |
| 941 | + scoped_session_maker: async_sessionmaker[AsyncSession], |
| 942 | + ) -> AsyncIterator[FakeProjectIndexSession]: |
| 943 | + assert scoped_session_maker is session_maker |
| 944 | + yield session |
| 945 | + |
| 946 | + monkeypatch.setattr( |
| 947 | + project_index_workflow_module.db, |
| 948 | + "scoped_session", |
| 949 | + fake_scoped_session, |
| 950 | + ) |
| 951 | + sqlite_vec_sessions: list[FakeProjectIndexSession] = [] |
| 952 | + |
| 953 | + async def fake_load_sqlite_vec_on_session( |
| 954 | + loaded_session: FakeProjectIndexSession, |
| 955 | + ) -> bool: |
| 956 | + sqlite_vec_sessions.append(loaded_session) |
| 957 | + return True |
| 958 | + |
| 959 | + monkeypatch.setattr( |
| 960 | + project_index_workflow_module, |
| 961 | + "_load_sqlite_vec_on_session", |
| 962 | + fake_load_sqlite_vec_on_session, |
| 963 | + ) |
| 964 | + |
| 965 | + store = RepositoryProjectIndexMaintenanceStore( |
| 966 | + session_maker=session_maker, |
| 967 | + project_id=42, |
| 968 | + ) |
| 969 | + |
| 970 | + result = await store.apply_project_index_delete_batch( |
| 971 | + ProjectIndexDeleteBatch( |
| 972 | + completed_batches=1, |
| 973 | + paths=("notes/a.md",), |
| 974 | + ) |
| 975 | + ) |
| 976 | + |
| 977 | + assert result.deleted_entities == 1 |
| 978 | + assert sqlite_vec_sessions == [session] |
| 979 | + statements = [str(statement) for statement in session.statements] |
| 980 | + embedding_delete_index = next( |
| 981 | + index |
| 982 | + for index, statement in enumerate(statements) |
| 983 | + if "DELETE FROM search_vector_embeddings" in statement |
| 984 | + ) |
| 985 | + chunk_delete_index = next( |
| 986 | + index |
| 987 | + for index, statement in enumerate(statements) |
| 988 | + if "DELETE FROM search_vector_chunks" in statement |
| 989 | + ) |
| 990 | + assert embedding_delete_index < chunk_delete_index |
| 991 | + |
| 992 | + |
| 993 | +@pytest.mark.asyncio |
| 994 | +async def test_repository_project_index_maintenance_store_skips_vector_cleanup_when_tables_missing( |
| 995 | + monkeypatch: pytest.MonkeyPatch, |
| 996 | +) -> None: |
| 997 | + session_maker = cast(async_sessionmaker[AsyncSession], object()) |
| 998 | + session = FakeProjectIndexSession( |
| 999 | + results=[ |
| 1000 | + FakeProjectIndexResult( |
| 1001 | + mapping_rows=[ |
| 1002 | + {"id": 10, "file_path": "notes/a.md"}, |
| 1003 | + ] |
| 1004 | + ), |
| 1005 | + FakeProjectIndexResult(scalar_values=[]), |
| 1006 | + FakeProjectIndexResult(), |
| 1007 | + FakeProjectIndexResult(scalar_values=[]), |
| 1008 | + ] |
| 1009 | + ) |
| 1010 | + |
| 1011 | + @asynccontextmanager |
| 1012 | + async def fake_scoped_session( |
| 1013 | + scoped_session_maker: async_sessionmaker[AsyncSession], |
| 1014 | + ) -> AsyncIterator[FakeProjectIndexSession]: |
| 1015 | + assert scoped_session_maker is session_maker |
| 1016 | + yield session |
| 1017 | + |
| 1018 | + monkeypatch.setattr( |
| 1019 | + project_index_workflow_module.db, |
| 1020 | + "scoped_session", |
| 1021 | + fake_scoped_session, |
| 1022 | + ) |
| 1023 | + |
| 1024 | + store = RepositoryProjectIndexMaintenanceStore( |
| 1025 | + session_maker=session_maker, |
| 1026 | + project_id=42, |
| 1027 | + ) |
| 1028 | + |
| 1029 | + result = await store.apply_project_index_delete_batch( |
| 1030 | + ProjectIndexDeleteBatch( |
| 1031 | + completed_batches=1, |
| 1032 | + paths=("notes/a.md",), |
| 1033 | + ) |
| 1034 | + ) |
| 1035 | + |
| 1036 | + assert result.deleted_entities == 1 |
| 1037 | + statements = [str(statement) for statement in session.statements] |
| 1038 | + assert not any("DELETE FROM search_vector_chunks" in statement for statement in statements) |
| 1039 | + assert not any("DELETE FROM search_vector_embeddings" in statement for statement in statements) |
| 1040 | + |
| 1041 | + |
914 | 1042 | def test_project_index_workflow_start_builds_existing_metadata_and_attempt_event() -> None: |
915 | 1043 | tenant_id = UUID("11111111-1111-1111-1111-111111111111") |
916 | 1044 | workflow_id = UUID("22222222-2222-2222-2222-222222222222") |
|
0 commit comments