deletes for repositories

This commit is contained in:
phernandez
2024-12-15 11:31:51 -06:00
parent b1ec3bca26
commit fa4e55f75f
10 changed files with 789 additions and 489 deletions
+15
View File
@@ -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"
+1 -1
View File
@@ -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)
+36 -2
View File
@@ -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:
@@ -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]
return result.scalars().all()
@@ -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
return result.scalars().all()
+30 -1
View File
@@ -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
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
+410
View File
@@ -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
@@ -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
-273
View File
@@ -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"
-190
View File
@@ -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