diff --git a/pyproject.toml b/pyproject.toml index 26d72567..3af0666f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,3 +61,18 @@ dev-dependencies = [ [tool.uv.sources] basic-foundation = { path = "../basic-foundation", editable = true } + +[tool.pyright] +include = [ + "src/", +] +exclude = [ + "**/__pycache__", +] +ignore = [ + "test/", +] +defineConstant = { DEBUG = true } +reportMissingImports = "error" +reportMissingTypeStubs = false +pythonVersion = "3.12" diff --git a/src/basic_memory/api/routers/knowledge.py b/src/basic_memory/api/routers/knowledge.py index f346007b..138c7bc5 100644 --- a/src/basic_memory/api/routers/knowledge.py +++ b/src/basic_memory/api/routers/knowledge.py @@ -68,10 +68,10 @@ async def create_relations( @router.delete("/relations/{from_id:path}/{to_id:path}", response_model=DeleteEntityResponse) async def delete_relation( + memory_service: MemoryServiceDep, from_id: str, to_id: str, relation_type: str | None = None, - memory_service: MemoryServiceDep ) -> DeleteEntityResponse: """Delete relations between entities, optionally filtered by type.""" request = DeleteRelationRequest(from_id=from_id, to_id=to_id, relation_type=relation_type) diff --git a/src/basic_memory/repository/__init__.py b/src/basic_memory/repository/__init__.py index ee6481ea..25e659bf 100644 --- a/src/basic_memory/repository/__init__.py +++ b/src/basic_memory/repository/__init__.py @@ -1,6 +1,6 @@ """Base repository implementation.""" -from typing import Type, Optional, Any, Sequence, TypeVar, List -from sqlalchemy import select, func, Select, Executable, inspect, Result, Column, insert +from typing import Type, Optional, Any, Sequence, TypeVar, List, Dict +from sqlalchemy import select, func, Select, Executable, inspect, Result, Column, insert, and_, delete from sqlalchemy.exc import NoResultFound from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped @@ -22,6 +22,17 @@ class Repository[T: Base]: logger.debug(f"Initialized {self.__class__.__name__} for {Model.__name__}") logger.debug(f"Valid columns: {self.valid_columns}") + + def select(self, *entities: Any) -> Select: + """Create a new SELECT statement. + + Returns: + A SQLAlchemy Select object configured with the provided entities + or this repository's model if no entities provided. + """ + if not entities: + entities = (self.Model,) + return select(*entities) async def refresh(self, instance: T, relationships: list[str] | None = None) -> None: """Refresh instance and optionally specified relationships.""" @@ -162,6 +173,29 @@ class Repository[T: Base]: logger.exception(f"Failed to delete {self.Model.__name__}: {entity_id}") raise + async def delete_by_fields(self, **filters: Dict[str, Any]) -> bool: + """ + Delete records matching given field values. + + Args: + **filters: Field names and values to filter by + + Returns: + bool: True if any records were deleted + """ + logger.debug(f"Deleting {self.Model.__name__} by fields: {filters}") + try: + conditions = [getattr(self.Model, field) == value for field, value in filters.items()] + query = delete(self.Model).where(and_(*conditions)) + result = await self.execute_query(query) + await self.session.flush() + deleted = result.rowcount > 0 + logger.debug(f"Deleted {result.rowcount} records") + return deleted # pyright: ignore [reportAttributeAccessIssue] + except Exception as e: + logger.exception(f"Failed to delete {self.Model.__name__} by fields") + raise + async def count(self, query: Executable | None = None) -> int: """Count entities in the database table.""" try: diff --git a/src/basic_memory/repository/observation_repository.py b/src/basic_memory/repository/observation_repository.py index 25ecd49d..831829d7 100644 --- a/src/basic_memory/repository/observation_repository.py +++ b/src/basic_memory/repository/observation_repository.py @@ -1,6 +1,6 @@ """Repository for managing Observation objects.""" -from typing import Sequence, Dict, Any -from sqlalchemy import select, and_, delete +from typing import Sequence +from sqlalchemy import select from basic_memory.models import Observation from basic_memory.repository import Repository @@ -22,12 +22,4 @@ class ObservationRepository(Repository[Observation]): """Find observations with a specific context.""" query = select(Observation).filter(Observation.context == context) result = await self.execute_query(query) - return result.scalars().all() - - async def delete_by_fields(self, **filters: Dict[str, Any]) -> bool: - """Delete observations matching the given field values.""" - conditions = [getattr(Observation, field) == value for field, value in filters.items()] - query = delete(Observation).where(and_(*conditions)) - result = await self.execute_query(query) - await self.session.flush() - return result.rowcount > 0 # pyright: ignore [reportAttributeAccessIssue] \ No newline at end of file + return result.scalars().all() \ No newline at end of file diff --git a/src/basic_memory/repository/relation_repository.py b/src/basic_memory/repository/relation_repository.py index 5f386797..5db2be0c 100644 --- a/src/basic_memory/repository/relation_repository.py +++ b/src/basic_memory/repository/relation_repository.py @@ -1,6 +1,6 @@ """Repository for managing Relation objects.""" -from typing import Sequence, Any, Dict -from sqlalchemy import select, and_, delete +from typing import Sequence +from sqlalchemy import select, and_ from basic_memory.models import Relation from basic_memory.repository import Repository @@ -27,12 +27,4 @@ class RelationRepository(Repository[Relation]): """Find all relations of a specific type.""" query = select(Relation).filter(Relation.relation_type == relation_type) result = await self.execute_query(query) - return result.scalars().all() - - async def delete_by_fields(self, **filters: Dict[str, Any]) -> bool: - """Delete relations matching the given field values.""" - conditions = [getattr(Relation, field) == value for field, value in filters.items()] - query = delete(Relation).where(and_(*conditions)) - result = await self.execute_query(query) - await self.session.flush() - return result.rowcount > 0 \ No newline at end of file + return result.scalars().all() \ No newline at end of file diff --git a/src/basic_memory/services/relation_service.py b/src/basic_memory/services/relation_service.py index f9f61216..dbe44ec9 100644 --- a/src/basic_memory/services/relation_service.py +++ b/src/basic_memory/services/relation_service.py @@ -1,5 +1,6 @@ """Service for managing relations in the database.""" from pathlib import Path +from typing import List, Dict, Any from basic_memory.repository.relation_repository import RelationRepository from basic_memory.schemas import Entity, Relation @@ -46,4 +47,32 @@ class RelationService: return result except Exception as e: - raise DatabaseSyncError(f"Failed to delete relation: {str(e)}") from e \ No newline at end of file + raise DatabaseSyncError(f"Failed to delete relation: {str(e)}") from e + + async def delete_relations(self, relations: List[Dict[str, Any]]) -> bool: + """ + Delete relations matching specified criteria. + + Args: + relations: List of dicts with from_id, to_id, and optional relation_type + + Returns: + True if any relations were deleted + """ + try: + deleted = False + for relation in relations: + filters = { + 'from_id': relation['from_id'], + 'to_id': relation['to_id'] + } + if 'relation_type' in relation: + filters['relation_type'] = relation['relation_type'] + + result = await self.relation_repo.delete_by_fields(**filters) + if result: + deleted = True + + return deleted + except Exception as e: + raise DatabaseSyncError(f"Failed to delete relations: {str(e)}") from e \ No newline at end of file diff --git a/tests/repository/test_entity_repository.py b/tests/repository/test_entity_repository.py new file mode 100644 index 00000000..a5e57919 --- /dev/null +++ b/tests/repository/test_entity_repository.py @@ -0,0 +1,410 @@ +"""Tests for the EntityRepository.""" +from datetime import datetime, UTC + +import pytest +import pytest_asyncio +from sqlalchemy import select, text +from sqlalchemy.exc import IntegrityError + +from basic_memory.models import Entity, Observation, Relation +from basic_memory.repository.entity_repository import EntityRepository + + +@pytest_asyncio.fixture +async def entity_repo(session): + """Create an EntityRepository with test DB session.""" + return EntityRepository(session) + + +@pytest_asyncio.fixture +async def test_entity(session): + """Create a test entity.""" + entity = Entity( + id="test/test_entity", + name="test_entity", + entity_type="test", + description="Test entity" + ) + session.add(entity) + await session.flush() + return entity + + +@pytest_asyncio.fixture +async def entity_with_observations(session, test_entity): + """Create an entity with observations.""" + observations = [ + Observation(entity_id=test_entity.id, content="First observation"), + Observation(entity_id=test_entity.id, content="Second observation") + ] + session.add_all(observations) + await session.flush() + return test_entity + + +@pytest_asyncio.fixture +async def related_entities(session): + """Create entities with relations between them.""" + source = Entity( + id="source/test_entity", + name="source", + entity_type="source", + description="Source entity" + ) + target = Entity( + id="target/test_entity", + name="target", + entity_type="target", + description="Target entity" + ) + session.add_all([source, target]) + await session.flush() + + relation = Relation( + from_id=source.id, + to_id=target.id, + relation_type="connects_to" + ) + session.add(relation) + await session.flush() + + return source, target, relation + +@pytest.mark.asyncio +async def test_create_entity(entity_repository: EntityRepository): + """Test creating a new entity""" + entity_data = { + 'name': 'Test', + 'entity_type': 'test', + 'description': 'Test description', + } + entity = await entity_repository.create(entity_data) + + # Verify returned object + assert entity.id == 'test/test' + assert entity.name == 'Test' + assert entity.description == 'Test description' + assert isinstance(entity.created_at, datetime) + + # Verify in database + stmt = select(Entity).where(Entity.id == entity.id) + result = await entity_repository.session.execute(stmt) + db_entity = result.scalar_one() + assert db_entity.id == entity.id + assert db_entity.name == entity.name + assert db_entity.description == entity.description + +@pytest.mark.asyncio +async def test_entity_type_name_unique_constraint(entity_repository: EntityRepository): + """Test the unique constraint on entity_type + name combination.""" + # Create first entity + entity1_data = { + 'id': '20240102-test1', + 'name': 'Test Entity', + 'entity_type': 'type1', + 'description': 'First entity' + } + await entity_repository.create(entity1_data) + + # Try to create another entity with same type and name + entity2_data = { + 'id': '20240102-test2', + 'name': 'Test Entity', # Same name + 'entity_type': 'type1', # Same type + 'description': 'Second entity' + } + + # Should raise IntegrityError + with pytest.raises(IntegrityError) as exc_info: + await entity_repository.create(entity2_data) + assert 'UNIQUE constraint failed: entity.entity_type, entity.name' in str(exc_info.value) + +@pytest.mark.asyncio +async def test_create_entity_null_description(entity_repository: EntityRepository): + """Test creating an entity with null description""" + entity_data = { + 'id': '20240102-test', + 'name': 'Test', + 'entity_type': 'test', + 'description': None, + } + entity = await entity_repository.create(entity_data) + + # Verify in database + stmt = select(Entity).where(Entity.id == entity.id) + result = await entity_repository.session.execute(stmt) + db_entity = result.scalar_one() + assert db_entity.description is None + +@pytest.mark.asyncio +async def test_find_by_id(entity_repository: EntityRepository, sample_entity: Entity): + """Test finding an entity by ID""" + found = await entity_repository.find_by_id(sample_entity.id) + assert found is not None + assert found.id == sample_entity.id + assert found.name == sample_entity.name + + # Verify against direct database query + stmt = select(Entity).where(Entity.id == sample_entity.id) + result = await entity_repository.session.execute(stmt) + db_entity = result.scalar_one() + assert db_entity.id == found.id + assert db_entity.name == found.name + assert db_entity.description == found.description + +@pytest.mark.asyncio +async def test_find_by_name(entity_repository: EntityRepository, sample_entity: Entity): + """Test finding an entity by name""" + found = await entity_repository.find_by_name(sample_entity.name) + assert found is not None + assert found.id == sample_entity.id + assert found.name == sample_entity.name + + # Verify against direct database query + stmt = select(Entity).where(Entity.name == sample_entity.name) + result = await entity_repository.session.execute(stmt) + db_entity = result.scalar_one() + assert db_entity.id == found.id + assert db_entity.name == found.name + assert db_entity.description == found.description + +@pytest.mark.asyncio +async def test_update_entity(entity_repository: EntityRepository, sample_entity: Entity): + """Test updating an entity""" + updated = await entity_repository.update( + sample_entity.id, + {'description': 'Updated description'} + ) + assert updated is not None + assert updated.description == 'Updated description' + assert updated.name == sample_entity.name # Other fields unchanged + + # Verify in database + stmt = select(Entity).where(Entity.id == sample_entity.id) + result = await entity_repository.session.execute(stmt) + db_entity = result.scalar_one() + assert db_entity.description == 'Updated description' + assert db_entity.name == sample_entity.name + +@pytest.mark.asyncio +async def test_update_entity_to_null(entity_repository: EntityRepository, sample_entity: Entity): + """Test updating an entity's description to null""" + updated = await entity_repository.update( + sample_entity.id, + {'description': None} + ) + assert updated is not None + assert updated.description is None + + # Verify in database + stmt = select(Entity).where(Entity.id == sample_entity.id) + result = await entity_repository.session.execute(stmt) + db_entity = result.scalar_one() + assert db_entity.description is None + +@pytest.mark.asyncio +async def test_delete_entity_find_by_id(entity_repository: EntityRepository, sample_entity: Entity): + """Test deleting an entity""" + success = await entity_repository.delete(sample_entity.id) + assert success is True + + # Verify it's gone + found = await entity_repository.find_by_id(sample_entity.id) + assert found is None + + # Verify with direct query + stmt = select(Entity).where(Entity.id == sample_entity.id) + result = await entity_repository.session.execute(stmt) + assert result.first() is None + +@pytest.mark.asyncio +async def test_search(entity_repository: EntityRepository): + """Test searching entities""" + # Create test entities with observations + entity1 = await entity_repository.create({ + 'id': '20240102-test1', + 'name': 'Search Test 1', + 'entity_type': 'test', + 'description': 'First test entity' + }) + + entity2 = await entity_repository.create({ + 'id': '20240102-test2', + 'name': 'Search Test 2', + 'entity_type': 'other', + 'description': 'Second test entity' + }) + + # Verify entities in database + stmt = select(Entity).where(Entity.id.in_([entity1.id, entity2.id])) + result = await entity_repository.session.execute(stmt) + db_entities = result.scalars().all() + assert len(db_entities) == 2 + + # Add observations + stmt = text(""" + INSERT INTO observation (entity_id, content, created_at) + VALUES (:e1_id, :e1_obs, :ts), (:e2_id, :e2_obs, :ts) + """) + ts = datetime.now(UTC) + await entity_repository.session.execute(stmt, { + "e1_id": entity1.id, + "e1_obs": "First observation with searchable content", + "e2_id": entity2.id, + "e2_obs": "Another observation to find", + "ts": ts + }) + await entity_repository.session.commit() + + # Test search by name + results = await entity_repository.search('Search Test') + assert len(results) == 2 + names = {e.name for e in results} + assert 'Search Test 1' in names + assert 'Search Test 2' in names + + # Test search by type + results = await entity_repository.search('other') + assert len(results) == 1 + assert results[0].entity_type == 'other' + + # Test search by observation content + results = await entity_repository.search('searchable') + assert len(results) == 1 + assert results[0].id == entity1.id + +@pytest.mark.asyncio +async def test_find_by_type_and_name(entity_repository: EntityRepository): + """Test finding an entity by type and name combination.""" + # Create two entities with same name but different types + entity1 = await entity_repository.create({ + 'id': '20240102-test1', + 'name': 'Test Entity', + 'entity_type': 'type1', + 'description': 'First test entity' + }) + + entity2 = await entity_repository.create({ + 'id': '20240102-test2', + 'name': 'Test Entity', + 'entity_type': 'type2', + 'description': 'Second test entity' + }) + + # Should find correct entity when both type and name match + found = await entity_repository.find_by_type_and_name('type1', 'Test Entity') + assert found is not None + assert found.id == entity1.id + assert found.entity_type == 'type1' + assert found.name == 'Test Entity' + + # Should find other entity with same name but different type + found = await entity_repository.find_by_type_and_name('type2', 'Test Entity') + assert found is not None + assert found.id == entity2.id + assert found.entity_type == 'type2' + assert found.name == 'Test Entity' + + # Should return None when type doesn't match + found = await entity_repository.find_by_type_and_name('nonexistent', 'Test Entity') + assert found is None + + # Should return None when name doesn't match + found = await entity_repository.find_by_type_and_name('type1', 'Nonexistent') + assert found is None + + # Verify relationships are loaded + entity3 = await entity_repository.create({ + 'id': '20240102-test3', + 'name': 'Entity With Relations', + 'entity_type': 'type3', + 'description': 'Entity with observations and relations' + }) + + # Add an observation + stmt = text(""" + INSERT INTO observation (entity_id, content, created_at) + VALUES (:entity_id, :content, :ts) + """) + ts = datetime.now(UTC) + await entity_repository.session.execute(stmt, { + "entity_id": entity3.id, + "content": "Test observation", + "ts": ts + }) + await entity_repository.session.commit() + + # Find entity and verify relationships are loaded + found = await entity_repository.find_by_type_and_name('type3', 'Entity With Relations') + assert found is not None + assert len(found.observations) == 1 + assert found.observations[0].content == "Test observation" + +@pytest.mark.asyncio +async def test_delete_entity(entity_repo, test_entity): + """Test deleting an entity.""" + result = await entity_repo.delete(test_entity.id) + assert result is True + + # Verify deletion + deleted = await entity_repo.find_by_id(test_entity.id) + assert deleted is None + + +@pytest.mark.asyncio +async def test_delete_entity_with_observations(entity_repo, entity_with_observations): + """Test deleting an entity cascades to its observations.""" + entity = entity_with_observations + + result = await entity_repo.delete(entity.id) + assert result is True + + # Verify entity deletion + deleted = await entity_repo.find_by_id(entity.id) + assert deleted is None + + # Verify observations were cascaded + query = select(Observation).filter(Observation.entity_id == entity.id) + result = await entity_repo.execute_query(query) + remaining_observations = result.scalars().all() + assert len(remaining_observations) == 0 + + +@pytest.mark.asyncio +async def test_delete_entities_by_type(entity_repo, test_entity): + """Test deleting entities by type.""" + result = await entity_repo.delete_by_fields(entity_type=test_entity.entity_type) + assert result is True + + # Verify deletion + query = select(Entity).filter(Entity.entity_type == test_entity.entity_type) + result = await entity_repo.execute_query(query) + remaining = result.scalars().all() + assert len(remaining) == 0 + + +@pytest.mark.asyncio +async def test_delete_entity_with_relations(entity_repo, related_entities): + """Test deleting an entity cascades to its relations.""" + source, target, relation = related_entities + + # Delete source entity + result = await entity_repo.delete(source.id) + assert result is True + + # Verify relation was cascaded + query = select(Relation).filter(Relation.from_id == source.id) + result = await entity_repo.execute_query(query) + remaining_relations = result.scalars().all() + assert len(remaining_relations) == 0 + + # Verify target entity still exists + target_exists = await entity_repo.find_by_id(target.id) + assert target_exists is not None + + +@pytest.mark.asyncio +async def test_delete_nonexistent_entity(entity_repo): + """Test deleting an entity that doesn't exist.""" + result = await entity_repo.delete("nonexistent/id") + assert result is False \ No newline at end of file diff --git a/tests/repository/test_relation_repository.py b/tests/repository/test_relation_repository.py new file mode 100644 index 00000000..efb07463 --- /dev/null +++ b/tests/repository/test_relation_repository.py @@ -0,0 +1,291 @@ +"""Tests for the RelationRepository.""" +import pytest +import pytest_asyncio +from basic_memory.models import Entity, Relation +from basic_memory.repository.relation_repository import RelationRepository + + + +@pytest_asyncio.fixture +async def relation_repo(session): + """Create a RelationRepository with test DB session.""" + return RelationRepository(session) + + +@pytest_asyncio.fixture +async def source_entity(session): + """Create a source entity for testing relations.""" + entity = Entity( + id="source/test_entity", + name="test_source", + entity_type="source", + description="Source entity" + ) + session.add(entity) + await session.flush() + return entity + + +@pytest_asyncio.fixture +async def target_entity(session): + """Create a target entity for testing relations.""" + entity = Entity( + id="target/test_entity", + name="test_target", + entity_type="target", + description="Target entity" + ) + session.add(entity) + await session.flush() + return entity + + +@pytest_asyncio.fixture +async def test_relations(session, source_entity, target_entity): + """Create test relations.""" + relations = [ + Relation( + from_id=source_entity.id, + to_id=target_entity.id, + relation_type="connects_to" + ), + Relation( + from_id=source_entity.id, + to_id=target_entity.id, + relation_type="depends_on" + ) + ] + session.add_all(relations) + await session.flush() + return relations + +@pytest_asyncio.fixture(scope="function") +async def related_entity(entity_repository): + """Create a second entity for testing relations""" + entity_data = { + 'id': '20240102-related', + 'name': 'Related Entity', + 'entity_type': 'test', + 'description': 'A related test entity', + 'references': '' + } + return await entity_repository.create(entity_data) + +@pytest_asyncio.fixture(scope="function") +async def sample_relation( + relation_repository: RelationRepository, + sample_entity: Entity, + related_entity: Entity +): + """Create a sample relation for testing""" + relation_data = { + 'from_id': sample_entity.id, + 'to_id': related_entity.id, + 'relation_type': 'test_relation', + 'context': 'test-context' + } + return await relation_repository.create(relation_data) + +@pytest_asyncio.fixture(scope="function") +async def multiple_relations( + relation_repository: RelationRepository, + sample_entity: Entity, + related_entity: Entity +): + """Create multiple relations for testing""" + relations_data = [ + { + 'from_id': sample_entity.id, + 'to_id': related_entity.id, + 'relation_type': 'relation_one', + 'context': 'context_one' + }, + { + 'from_id': sample_entity.id, + 'to_id': related_entity.id, + 'relation_type': 'relation_two', + 'context': 'context_two' + }, + { + 'from_id': related_entity.id, + 'to_id': sample_entity.id, + 'relation_type': 'relation_one', + 'context': 'context_three' + } + ] + return [await relation_repository.create(data) for data in relations_data] + +@pytest.mark.asyncio +async def test_create_relation( + relation_repository: RelationRepository, + sample_entity: Entity, + related_entity: Entity +): + """Test creating a new relation""" + relation_data = { + 'from_id': sample_entity.id, + 'to_id': related_entity.id, + 'relation_type': 'test_relation', + 'context': 'test-context' + } + relation = await relation_repository.create(relation_data) + + assert relation.from_id == sample_entity.id + assert relation.to_id == related_entity.id + assert relation.relation_type == 'test_relation' + assert relation.id is not None # Should be auto-generated + +@pytest.mark.asyncio +async def test_find_by_entities( + relation_repository: RelationRepository, + sample_relation: Relation, + sample_entity: Entity, + related_entity: Entity +): + """Test finding relations between specific entities""" + relations = await relation_repository.find_by_entities( + sample_entity.id, + related_entity.id + ) + assert len(relations) == 1 + assert relations[0].id == sample_relation.id + assert relations[0].relation_type == sample_relation.relation_type + +@pytest.mark.asyncio +async def test_find_by_type( + relation_repository: RelationRepository, + sample_relation: Relation +): + """Test finding relations by type""" + relations = await relation_repository.find_by_type('test_relation') + assert len(relations) == 1 + assert relations[0].id == sample_relation.id + +@pytest.mark.asyncio +async def test_delete_by_fields_single_field( + relation_repository: RelationRepository, + multiple_relations: list[Relation] +): + """Test deleting relations by a single field.""" + # Delete all relations of type 'relation_one' + result = await relation_repository.delete_by_fields(relation_type='relation_one') # pyright: ignore [reportArgumentType] + assert result is True + + # Verify deletion + remaining = await relation_repository.find_by_type('relation_one') + assert len(remaining) == 0 + + # Other relations should still exist + others = await relation_repository.find_by_type('relation_two') + assert len(others) == 1 + +@pytest.mark.asyncio +async def test_delete_by_fields_multiple_fields( + relation_repository: RelationRepository, + multiple_relations: list[Relation], + sample_entity: Entity, + related_entity: Entity +): + """Test deleting relations by multiple fields.""" + # Delete specific relation matching both from_id and relation_type + result = await relation_repository.delete_by_fields( + from_id=sample_entity.id, # pyright: ignore [reportArgumentType] + relation_type='relation_one' # pyright: ignore [reportArgumentType] + ) + assert result is True + + # Verify correct relation was deleted + remaining = await relation_repository.find_by_entities( + sample_entity.id, + related_entity.id + ) + assert len(remaining) == 1 # Only relation_two should remain + assert remaining[0].relation_type == 'relation_two' + +@pytest.mark.asyncio +async def test_delete_by_fields_no_match( + relation_repository: RelationRepository, + multiple_relations: list[Relation] +): + """Test delete_by_fields when no relations match.""" + result = await relation_repository.delete_by_fields( + relation_type='nonexistent_type' # pyright: ignore [reportArgumentType] + ) + assert result is False + +@pytest.mark.asyncio +async def test_delete_by_fields_all_fields( + relation_repository: RelationRepository, + multiple_relations: list[Relation], + sample_entity: Entity, + related_entity: Entity +): + """Test deleting relation by matching all fields.""" + # Get first relation's data + relation = multiple_relations[0] + + # Delete using all fields + result = await relation_repository.delete_by_fields( + from_id=relation.from_id, # pyright: ignore [reportArgumentType] + to_id=relation.to_id,# pyright: ignore [reportArgumentType] + relation_type=relation.relation_type, # pyright: ignore [reportArgumentType] + context=relation.context # pyright: ignore [reportArgumentType] + ) + assert result is True + + # Verify only exact match was deleted + remaining = await relation_repository.find_by_type(relation.relation_type) + assert len(remaining) == 1 # One other relation_one should remain + assert remaining[0].context != relation.context + + + +@pytest.mark.asyncio +async def test_delete_relation_by_id(relation_repo, test_relations): + """Test deleting a relation by ID.""" + relation = test_relations[0] + + result = await relation_repo.delete(relation.id) + assert result is True + + # Verify deletion + remaining = await relation_repo.find_one( + relation_repo.select(Relation).filter(Relation.id == relation.id) + ) + assert remaining is None + + +@pytest.mark.asyncio +async def test_delete_relations_by_type(relation_repo, test_relations): + """Test deleting relations by type.""" + result = await relation_repo.delete_by_fields(relation_type="connects_to") + assert result is True + + # Verify specific type was deleted + remaining = await relation_repo.find_by_type("connects_to") + assert len(remaining) == 0 + + # Verify other type still exists + others = await relation_repo.find_by_type("depends_on") + assert len(others) == 1 + + +@pytest.mark.asyncio +async def test_delete_relations_by_entities(relation_repo, test_relations, source_entity, target_entity): + """Test deleting relations between specific entities.""" + result = await relation_repo.delete_by_fields( + from_id=source_entity.id, + to_id=target_entity.id + ) + assert result is True + + # Verify all relations between entities were deleted + remaining = await relation_repo.find_by_entities(source_entity.id, target_entity.id) + assert len(remaining) == 0 + + +@pytest.mark.asyncio +async def test_delete_nonexistent_relation(relation_repo): + """Test deleting a relation that doesn't exist.""" + result = await relation_repo.delete_by_fields(relation_type="nonexistent") + assert result is False \ No newline at end of file diff --git a/tests/test_entity_repository.py b/tests/test_entity_repository.py deleted file mode 100644 index d8987a8e..00000000 --- a/tests/test_entity_repository.py +++ /dev/null @@ -1,273 +0,0 @@ -"""Tests for EntityRepository.""" -import pytest -from datetime import datetime, UTC -from sqlalchemy import text, select -from sqlalchemy.exc import IntegrityError - -from basic_memory.models import Entity -from basic_memory.repository.entity_repository import EntityRepository - -pytestmark = pytest.mark.asyncio - - -class TestEntityRepository: - async def test_create_entity(self, entity_repository: EntityRepository): - """Test creating a new entity""" - entity_data = { - 'name': 'Test', - 'entity_type': 'test', - 'description': 'Test description', - } - entity = await entity_repository.create(entity_data) - - # Verify returned object - assert entity.id == f'test/test' - assert entity.name == 'Test' - assert entity.description == 'Test description' - assert isinstance(entity.created_at, datetime) - - # Verify in database - stmt = select(Entity).where(Entity.id == entity.id) - result = await entity_repository.session.execute(stmt) - db_entity = result.scalar_one() - assert db_entity.id == entity.id - assert db_entity.name == entity.name - assert db_entity.description == entity.description - - async def test_entity_type_name_unique_constraint(self, entity_repository: EntityRepository): - """Test the unique constraint on entity_type + name combination.""" - # Create first entity - entity1_data = { - 'id': '20240102-test1', - 'name': 'Test Entity', - 'entity_type': 'type1', - 'description': 'First entity' - } - await entity_repository.create(entity1_data) - - # Try to create another entity with same type and name - entity2_data = { - 'id': '20240102-test2', - 'name': 'Test Entity', # Same name - 'entity_type': 'type1', # Same type - 'description': 'Second entity' - } - - # Should raise IntegrityError - with pytest.raises(IntegrityError) as exc_info: - await entity_repository.create(entity2_data) - assert 'UNIQUE constraint failed: entity.entity_type, entity.name' in str(exc_info.value) - - async def test_create_entity_null_description(self, entity_repository: EntityRepository): - """Test creating an entity with null description""" - entity_data = { - 'id': '20240102-test', - 'name': 'Test', - 'entity_type': 'test', - 'description': None, - } - entity = await entity_repository.create(entity_data) - - # Verify in database - stmt = select(Entity).where(Entity.id == entity.id) - result = await entity_repository.session.execute(stmt) - db_entity = result.scalar_one() - assert db_entity.description is None - - async def test_find_by_id(self, entity_repository: EntityRepository, sample_entity: Entity): - """Test finding an entity by ID""" - found = await entity_repository.find_by_id(sample_entity.id) - assert found is not None - assert found.id == sample_entity.id - assert found.name == sample_entity.name - - # Verify against direct database query - stmt = select(Entity).where(Entity.id == sample_entity.id) - result = await entity_repository.session.execute(stmt) - db_entity = result.scalar_one() - assert db_entity.id == found.id - assert db_entity.name == found.name - assert db_entity.description == found.description - - async def test_find_by_name(self, entity_repository: EntityRepository, sample_entity: Entity): - """Test finding an entity by name""" - found = await entity_repository.find_by_name(sample_entity.name) - assert found is not None - assert found.id == sample_entity.id - assert found.name == sample_entity.name - - # Verify against direct database query - stmt = select(Entity).where(Entity.name == sample_entity.name) - result = await entity_repository.session.execute(stmt) - db_entity = result.scalar_one() - assert db_entity.id == found.id - assert db_entity.name == found.name - assert db_entity.description == found.description - - async def test_update_entity(self, entity_repository: EntityRepository, sample_entity: Entity): - """Test updating an entity""" - updated = await entity_repository.update( - sample_entity.id, - {'description': 'Updated description'} - ) - assert updated is not None - assert updated.description == 'Updated description' - assert updated.name == sample_entity.name # Other fields unchanged - - # Verify in database - stmt = select(Entity).where(Entity.id == sample_entity.id) - result = await entity_repository.session.execute(stmt) - db_entity = result.scalar_one() - assert db_entity.description == 'Updated description' - assert db_entity.name == sample_entity.name - - async def test_update_entity_to_null(self, entity_repository: EntityRepository, sample_entity: Entity): - """Test updating an entity's description to null""" - updated = await entity_repository.update( - sample_entity.id, - {'description': None} - ) - assert updated is not None - assert updated.description is None - - # Verify in database - stmt = select(Entity).where(Entity.id == sample_entity.id) - result = await entity_repository.session.execute(stmt) - db_entity = result.scalar_one() - assert db_entity.description is None - - async def test_delete_entity(self, entity_repository: EntityRepository, sample_entity: Entity): - """Test deleting an entity""" - success = await entity_repository.delete(sample_entity.id) - assert success is True - - # Verify it's gone - found = await entity_repository.find_by_id(sample_entity.id) - assert found is None - - # Verify with direct query - stmt = select(Entity).where(Entity.id == sample_entity.id) - result = await entity_repository.session.execute(stmt) - assert result.first() is None - - async def test_search(self, entity_repository: EntityRepository): - """Test searching entities""" - # Create test entities with observations - entity1 = await entity_repository.create({ - 'id': '20240102-test1', - 'name': 'Search Test 1', - 'entity_type': 'test', - 'description': 'First test entity' - }) - - entity2 = await entity_repository.create({ - 'id': '20240102-test2', - 'name': 'Search Test 2', - 'entity_type': 'other', - 'description': 'Second test entity' - }) - - # Verify entities in database - stmt = select(Entity).where(Entity.id.in_([entity1.id, entity2.id])) - result = await entity_repository.session.execute(stmt) - db_entities = result.scalars().all() - assert len(db_entities) == 2 - - # Add observations - stmt = text(""" - INSERT INTO observation (entity_id, content, created_at) - VALUES (:e1_id, :e1_obs, :ts), (:e2_id, :e2_obs, :ts) - """) - ts = datetime.now(UTC) - await entity_repository.session.execute(stmt, { - "e1_id": entity1.id, - "e1_obs": "First observation with searchable content", - "e2_id": entity2.id, - "e2_obs": "Another observation to find", - "ts": ts - }) - await entity_repository.session.commit() - - # Test search by name - results = await entity_repository.search('Search Test') - assert len(results) == 2 - names = {e.name for e in results} - assert 'Search Test 1' in names - assert 'Search Test 2' in names - - # Test search by type - results = await entity_repository.search('other') - assert len(results) == 1 - assert results[0].entity_type == 'other' - - # Test search by observation content - results = await entity_repository.search('searchable') - assert len(results) == 1 - assert results[0].id == entity1.id - - -async def test_find_by_type_and_name(entity_repository: EntityRepository): - """Test finding an entity by type and name combination.""" - # Create two entities with same name but different types - entity1 = await entity_repository.create({ - 'id': '20240102-test1', - 'name': 'Test Entity', - 'entity_type': 'type1', - 'description': 'First test entity' - }) - - entity2 = await entity_repository.create({ - 'id': '20240102-test2', - 'name': 'Test Entity', - 'entity_type': 'type2', - 'description': 'Second test entity' - }) - - # Should find correct entity when both type and name match - found = await entity_repository.find_by_type_and_name('type1', 'Test Entity') - assert found is not None - assert found.id == entity1.id - assert found.entity_type == 'type1' - assert found.name == 'Test Entity' - - # Should find other entity with same name but different type - found = await entity_repository.find_by_type_and_name('type2', 'Test Entity') - assert found is not None - assert found.id == entity2.id - assert found.entity_type == 'type2' - assert found.name == 'Test Entity' - - # Should return None when type doesn't match - found = await entity_repository.find_by_type_and_name('nonexistent', 'Test Entity') - assert found is None - - # Should return None when name doesn't match - found = await entity_repository.find_by_type_and_name('type1', 'Nonexistent') - assert found is None - - # Verify relationships are loaded - entity3 = await entity_repository.create({ - 'id': '20240102-test3', - 'name': 'Entity With Relations', - 'entity_type': 'type3', - 'description': 'Entity with observations and relations' - }) - - # Add an observation - stmt = text(""" - INSERT INTO observation (entity_id, content, created_at) - VALUES (:entity_id, :content, :ts) - """) - ts = datetime.now(UTC) - await entity_repository.session.execute(stmt, { - "entity_id": entity3.id, - "content": "Test observation", - "ts": ts - }) - await entity_repository.session.commit() - - # Find entity and verify relationships are loaded - found = await entity_repository.find_by_type_and_name('type3', 'Entity With Relations') - assert found is not None - assert len(found.observations) == 1 - assert found.observations[0].content == "Test observation" \ No newline at end of file diff --git a/tests/test_relation_repository.py b/tests/test_relation_repository.py deleted file mode 100644 index d71c5692..00000000 --- a/tests/test_relation_repository.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Tests for RelationRepository.""" -import pytest -import pytest_asyncio -from basic_memory.models import Entity, Relation -from basic_memory.repository.relation_repository import RelationRepository - -pytestmark = pytest.mark.asyncio - - -class TestRelationRepository: - @pytest_asyncio.fixture(scope="function") - async def related_entity(self, entity_repository): - """Create a second entity for testing relations""" - entity_data = { - 'id': '20240102-related', - 'name': 'Related Entity', - 'entity_type': 'test', - 'description': 'A related test entity', - 'references': '' - } - return await entity_repository.create(entity_data) - - @pytest_asyncio.fixture(scope="function") - async def sample_relation( - self, - relation_repository: RelationRepository, - sample_entity: Entity, - related_entity: Entity - ): - """Create a sample relation for testing""" - relation_data = { - 'from_id': sample_entity.id, - 'to_id': related_entity.id, - 'relation_type': 'test_relation', - 'context': 'test-context' - } - return await relation_repository.create(relation_data) - - @pytest_asyncio.fixture(scope="function") - async def multiple_relations( - self, - relation_repository: RelationRepository, - sample_entity: Entity, - related_entity: Entity - ): - """Create multiple relations for testing""" - relations_data = [ - { - 'from_id': sample_entity.id, - 'to_id': related_entity.id, - 'relation_type': 'relation_one', - 'context': 'context_one' - }, - { - 'from_id': sample_entity.id, - 'to_id': related_entity.id, - 'relation_type': 'relation_two', - 'context': 'context_two' - }, - { - 'from_id': related_entity.id, - 'to_id': sample_entity.id, - 'relation_type': 'relation_one', - 'context': 'context_three' - } - ] - return [await relation_repository.create(data) for data in relations_data] - - async def test_create_relation( - self, - relation_repository: RelationRepository, - sample_entity: Entity, - related_entity: Entity - ): - """Test creating a new relation""" - relation_data = { - 'from_id': sample_entity.id, - 'to_id': related_entity.id, - 'relation_type': 'test_relation', - 'context': 'test-context' - } - relation = await relation_repository.create(relation_data) - - assert relation.from_id == sample_entity.id - assert relation.to_id == related_entity.id - assert relation.relation_type == 'test_relation' - assert relation.id is not None # Should be auto-generated - - async def test_find_by_entities( - self, - relation_repository: RelationRepository, - sample_relation: Relation, - sample_entity: Entity, - related_entity: Entity - ): - """Test finding relations between specific entities""" - relations = await relation_repository.find_by_entities( - sample_entity.id, - related_entity.id - ) - assert len(relations) == 1 - assert relations[0].id == sample_relation.id - assert relations[0].relation_type == sample_relation.relation_type - - async def test_find_by_type( - self, - relation_repository: RelationRepository, - sample_relation: Relation - ): - """Test finding relations by type""" - relations = await relation_repository.find_by_type('test_relation') - assert len(relations) == 1 - assert relations[0].id == sample_relation.id - - async def test_delete_by_fields_single_field( - self, - relation_repository: RelationRepository, - multiple_relations: list[Relation] - ): - """Test deleting relations by a single field.""" - # Delete all relations of type 'relation_one' - result = await relation_repository.delete_by_fields(relation_type='relation_one') - assert result is True - - # Verify deletion - remaining = await relation_repository.find_by_type('relation_one') - assert len(remaining) == 0 - - # Other relations should still exist - others = await relation_repository.find_by_type('relation_two') - assert len(others) == 1 - - async def test_delete_by_fields_multiple_fields( - self, - relation_repository: RelationRepository, - multiple_relations: list[Relation], - sample_entity: Entity, - related_entity: Entity - ): - """Test deleting relations by multiple fields.""" - # Delete specific relation matching both from_id and relation_type - result = await relation_repository.delete_by_fields( - from_id=sample_entity.id, - relation_type='relation_one' - ) - assert result is True - - # Verify correct relation was deleted - remaining = await relation_repository.find_by_entities( - sample_entity.id, - related_entity.id - ) - assert len(remaining) == 1 # Only relation_two should remain - assert remaining[0].relation_type == 'relation_two' - - async def test_delete_by_fields_no_match( - self, - relation_repository: RelationRepository, - multiple_relations: list[Relation] - ): - """Test delete_by_fields when no relations match.""" - result = await relation_repository.delete_by_fields( - relation_type='nonexistent_type' - ) - assert result is False - - async def test_delete_by_fields_all_fields( - self, - relation_repository: RelationRepository, - multiple_relations: list[Relation], - sample_entity: Entity, - related_entity: Entity - ): - """Test deleting relation by matching all fields.""" - # Get first relation's data - relation = multiple_relations[0] - - # Delete using all fields - result = await relation_repository.delete_by_fields( - from_id=relation.from_id, - to_id=relation.to_id, - relation_type=relation.relation_type, - context=relation.context - ) - assert result is True - - # Verify only exact match was deleted - remaining = await relation_repository.find_by_type(relation.relation_type) - assert len(remaining) == 1 # One other relation_one should remain - assert remaining[0].context != relation.context \ No newline at end of file