split up repository logic

This commit is contained in:
phernandez
2024-12-08 15:28:32 -06:00
parent e20776cd02
commit 031a009245
16 changed files with 460 additions and 381 deletions
+12 -1
View File
@@ -117,6 +117,11 @@
{"type":"entity","name":"Project_Priorities","entityType":"roadmap","observations":["P1: Dogfooding basic-memory system instead of JSON memory store","Future: Implement MCP-based reference system"]}
{"type":"entity","name":"great_observation_loading_saga_20241207","entityType":"debugging_session","observations":["Occurred on December 7, 2024 while debugging basic-memory SQLAlchemy relationship loading","Issue: selectinload() wasn't properly loading relationships in async SQLAlchemy context","Tried multiple solutions: explicit joins, manual loading, various SQLAlchemy loading strategies","Final solution: Using session.refresh() with explicit relationship names","Memorable quote: 'The Great Observation Loading Saga'","Key learning: Sometimes the obvious SQLAlchemy patterns need adaptation for async contexts","Solution preserved in basic-memory repository in EntityRepository.find_by_id()"]}
{"type":"entity","name":"basic_memory_implementation_20241208","entityType":"technical_milestone","observations":["Fixed async SQLAlchemy relationship loading issues by using explicit refresh with relationship names","Established pattern of relationship handling belonging in MemoryService not EntityService","Fixed ID generation flow through Pydantic schemas to DB layer","Standardized error handling using EntityNotFoundError","All 32 tests passing with 70% coverage","Core services (Entity, Observation, Relation) working properly","Ready for MCP server implementation","Notable debugging session: The Great Observation Loading Saga - resolved lazy loading issues","Established clear separation between MemoryService orchestration and individual service responsibilities"]}
{"type":"entity","name":"MCP_Dependency_Risk","entityType":"technical_lesson","observations":["Experienced disruption when MCP npm package disappeared - 'leftpad moment'","Need to ensure basic-memory tools are resilient to external dependency issues","Local implementation of MCP server provides better stability than npm packages","Important to maintain control of critical infrastructure components","Validates DIY/local-first philosophy of basic-memory project","Package manager fragility revealed by simple 'npx @modelcontextprotocol/server-memory' failure"]}
{"type":"entity","name":"basic_memory_project_20241208","entityType":"technical_milestone","observations":["Core MCP server implementation completed with tools: create_entities, search_nodes, open_nodes, add_observations, create_relations, delete_entities, delete_observations","ProjectConfig and dependency injection pattern established","Test framework in place with in-memory DB support","Support for both camelCase (MCP) and snake_case (internal) formats","Filesystem remains source of truth with SQLite as index","Two-way sync pattern identified between Claude MCP tools and direct markdown file editing"]}
{"type":"entity","name":"basic_memory_mcp_architecture","entityType":"technical_design","observations":["MemoryServer class extends MCP Server with custom handler registration","Uses ProjectConfig for clean dependency injection and configuration","Memory service can be injected for testing","Handlers exposed as instance attributes for testing","Tool schemas leverage existing Pydantic models"]}
{"type":"entity","name":"basic_memory_sync_considerations","entityType":"design_insight","observations":["Need to handle sync between direct markdown file edits and DB index","Watch for file system changes as potential future enhancement","Consider index rebuild patterns on startup","Keep human-friendly markdown format for direct editing"]}
{"type":"entity","name":"mcp_server_learnings","entityType":"developer_insight","observations":["MCP protocol is new and documentation is still evolving","Test patterns are not well established yet in example implementations","Supporting both camelCase and snake_case helps with protocol/internal compatibility","Server.handle_* naming convention is important for handler registration"]}
{"type":"relation","from":"Paul","to":"Basic_Machines","relationType":"created_and_maintains"}
{"type":"relation","from":"basic-memory","to":"Basic_Machines","relationType":"is_component_of"}
{"type":"relation","from":"Paul","to":"basic-memory","relationType":"develops"}
@@ -360,4 +365,10 @@
{"type":"relation","from":"great_observation_loading_saga_20241207","to":"Basic_Memory","relationType":"occurred_in"}
{"type":"relation","from":"great_observation_loading_saga_20241207","to":"SQLAlchemy","relationType":"relates_to"}
{"type":"relation","from":"basic_memory_implementation_20241208","to":"Basic_Memory","relationType":"improves"}
{"type":"relation","from":"great_observation_loading_saga_20241207","to":"basic_memory_implementation_20241208","relationType":"leads_to"}
{"type":"relation","from":"great_observation_loading_saga_20241207","to":"basic_memory_implementation_20241208","relationType":"leads_to"}
{"type":"relation","from":"MCP_Dependency_Risk","to":"DIY_Ethics","relationType":"validates"}
{"type":"relation","from":"MCP_Dependency_Risk","to":"basic-memory_core_principles","relationType":"reinforces"}
{"type":"relation","from":"MCP_Dependency_Risk","to":"Basic_Memory_Implementation_Plan","relationType":"influences"}
{"type":"relation","from":"basic_memory_mcp_architecture","to":"basic_memory_project_20241208","relationType":"implements"}
{"type":"relation","from":"basic_memory_sync_considerations","to":"basic_memory_project_20241208","relationType":"influences"}
{"type":"relation","from":"mcp_server_learnings","to":"basic_memory_mcp_architecture","relationType":"informs"}
+3 -1
View File
@@ -4,7 +4,9 @@ from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine
from basic_memory.models import Entity as DbEntity, Observation as DbObservation, Relation as DbRelation
from basic_memory.repository import EntityRepository, ObservationRepository, RelationRepository
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.observation_repository import ObservationRepository
from basic_memory.repository.relation_repository import RelationRepository
from basic_memory.services import EntityService, ObservationService, RelationService, MemoryService
from basic_memory.db import DatabaseType, get_database_url, init_database, get_session
@@ -1,12 +1,13 @@
"""Repository implementations for basic-memory models."""
from typing import Type, Optional, Any, Sequence
from sqlalchemy import select, func, Select, Executable, inspect, Result, Column, and_
"""Base repository implementation."""
from typing import Type, Optional, Any, Sequence, TypeVar
from sqlalchemy import select, func, Select, Executable, inspect, Result, Column
from sqlalchemy.exc import NoResultFound
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, selectinload
from sqlalchemy.orm import Mapped
from basic_memory.models import Entity, Observation, Relation, Base
from basic_memory.models import Base
T = TypeVar('T', bound=Base)
class Repository[T: Base]:
"""Base repository implementation with generic CRUD operations."""
@@ -39,12 +40,7 @@ class Repository[T: Base]:
return None
async def create(self, entity_data: dict, model: Type[Base] | None = None) -> T:
"""Create a new entity in the database from the provided data.
Args:
entity_data: Dictionary containing the data to insert
model: Optional model class to use (defaults to self.Model)
"""
"""Create a new entity in the database from the provided data."""
model = model or self.Model
model_data = {k: v for k, v in entity_data.items() if k in self.valid_columns}
entity = model(**model_data)
@@ -96,78 +92,3 @@ class Repository[T: Base]:
"""Execute a query and retrieve a single record."""
result = await self.execute_query(query)
return result.scalars().one_or_none()
class EntityRepository(Repository[Entity]):
"""Repository for Entity model with memory-specific operations."""
async def find_by_id(self, entity_id: str) -> Optional[Entity]:
"""Find entity by ID with all relationships eagerly loaded."""
try:
# First load base entity
result = await self.session.execute(
select(Entity).filter(Entity.id == entity_id)
)
entity = result.scalars().one()
# Force refresh of all relationships
await self.refresh(entity, ['observations', 'outgoing_relations', 'incoming_relations'])
return entity
except NoResultFound:
return None
async def find_by_name(self, name: str) -> Optional[Entity]:
"""Find an entity by its unique name."""
query = (
select(Entity)
.filter(Entity.name == name)
)
result = await self.session.execute(query)
entity = result.scalars().one_or_none()
if entity:
await self.refresh(entity, ['observations', 'outgoing_relations', 'incoming_relations'])
return entity
async def search_by_type(self, entity_type: str, skip: int = 0, limit: int = 100) -> Sequence[Entity]:
"""Search for entities of a specific type."""
query = select(Entity).filter(Entity.entity_type == entity_type).offset(skip).limit(limit)
result = await self.execute_query(query)
return result.scalars().all()
class ObservationRepository(Repository[Observation]):
"""Repository for Observation model with memory-specific operations."""
async def find_by_entity(self, entity_id: str) -> Sequence[Observation]:
"""Find all observations for a specific entity."""
query = select(Observation).filter(Observation.entity_id == entity_id)
result = await self.execute_query(query)
return result.scalars().all()
async def find_by_context(self, context: str) -> Sequence[Observation]:
"""Find observations with a specific context."""
query = select(Observation).filter(Observation.context == context)
result = await self.execute_query(query)
return result.scalars().all()
class RelationRepository(Repository[Relation]):
"""Repository for Relation model with memory-specific operations."""
async def find_by_entities(self, from_id: str, to_id: str) -> Sequence[Relation]:
"""Find all relations between two entities."""
query = select(Relation).filter(
and_(
Relation.from_id == from_id,
Relation.to_id == to_id
)
)
result = await self.execute_query(query)
return result.scalars().all()
async def find_by_type(self, relation_type: str) -> Sequence[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()
@@ -0,0 +1,62 @@
"""Repository for managing Entity objects."""
from typing import Optional, Sequence
from sqlalchemy import select, or_
from sqlalchemy.exc import NoResultFound
from basic_memory.models import Entity, Observation
from basic_memory.repository import Repository
class EntityRepository(Repository[Entity]):
"""Repository for Entity model with memory-specific operations."""
def __init__(self, session):
super().__init__(session, Entity)
async def find_by_id(self, entity_id: str) -> Optional[Entity]:
"""Find entity by ID with all relationships eagerly loaded."""
try:
# First load base entity
result = await self.session.execute(
select(Entity).filter(Entity.id == entity_id)
)
entity = result.scalars().one()
# Force refresh of all relationships
await self.refresh(entity, ['observations', 'outgoing_relations', 'incoming_relations'])
return entity
except NoResultFound:
return None
async def find_by_name(self, name: str) -> Optional[Entity]:
"""Find an entity by its unique name."""
query = (
select(Entity)
.filter(Entity.name == name)
)
result = await self.session.execute(query)
entity = result.scalars().one_or_none()
if entity:
await self.refresh(entity, ['observations', 'outgoing_relations', 'incoming_relations'])
return entity
async def search_by_type(self, entity_type: str, skip: int = 0, limit: int = 100) -> Sequence[Entity]:
"""Search for entities of a specific type."""
query = select(Entity).filter(Entity.entity_type == entity_type).offset(skip).limit(limit)
result = await self.execute_query(query)
return result.scalars().all()
async def search(self, query: str) -> Sequence[Entity]:
"""Search entities using LIKE pattern matching."""
stmt = select(Entity).distinct().where(
or_(
Entity.name.ilike(f"%{query}%"),
Entity.entity_type.ilike(f"%{query}%"),
Entity.observations.any(
Observation.content.ilike(f"%{query}%")
)
)
)
result = await self.session.execute(stmt)
return list(result.scalars())
@@ -0,0 +1,25 @@
"""Repository for managing Observation objects."""
from typing import Sequence
from sqlalchemy import select
from basic_memory.models import Observation
from basic_memory.repository import Repository
class ObservationRepository(Repository[Observation]):
"""Repository for Observation model with memory-specific operations."""
def __init__(self, session):
super().__init__(session, Observation)
async def find_by_entity(self, entity_id: str) -> Sequence[Observation]:
"""Find all observations for a specific entity."""
query = select(Observation).filter(Observation.entity_id == entity_id)
result = await self.execute_query(query)
return result.scalars().all()
async def find_by_context(self, context: str) -> Sequence[Observation]:
"""Find observations with a specific context."""
query = select(Observation).filter(Observation.context == context)
result = await self.execute_query(query)
return result.scalars().all()
@@ -0,0 +1,30 @@
"""Repository for managing Relation objects."""
from typing import Sequence
from sqlalchemy import select, and_
from basic_memory.models import Relation
from basic_memory.repository import Repository
class RelationRepository(Repository[Relation]):
"""Repository for Relation model with memory-specific operations."""
def __init__(self, session):
super().__init__(session, Relation)
async def find_by_entities(self, from_id: str, to_id: str) -> Sequence[Relation]:
"""Find all relations between two entities."""
query = select(Relation).filter(
and_(
Relation.from_id == from_id,
Relation.to_id == to_id
)
)
result = await self.execute_query(query)
return result.scalars().all()
async def find_by_type(self, relation_type: str) -> Sequence[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()
+6 -1
View File
@@ -1,8 +1,9 @@
"""Service for managing entities in the database."""
from datetime import datetime, UTC
from pathlib import Path
from typing import List
from basic_memory.repository import EntityRepository
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.schemas import EntityIn, ObservationIn
from basic_memory.models import Entity, Observation
from basic_memory.fileio import EntityNotFoundError
@@ -19,6 +20,10 @@ class EntityService:
self.project_path = project_path
self.entity_repo = entity_repo
async def search(self, query: str) -> List[Entity]:
"""Search entities using LIKE pattern matching."""
return await self.entity_repo.search(query)
async def create_entity(self, entity: EntityIn) -> Entity:
"""Create a new entity in the database.
+1 -1
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from basic_memory.models import Entity, Observation
from basic_memory.schemas import (
ObservationsIn, ObservationsOut, ObservationOut, EntityIn, RelationIn, RelationOut
ObservationsIn, EntityIn, RelationIn, RelationOut
)
from basic_memory.fileio import write_entity_file, read_entity_file, delete_entity_file
from basic_memory.services import EntityService, RelationService, ObservationService
@@ -5,7 +5,7 @@ from typing import List
from sqlalchemy import select, delete
from basic_memory.models import Observation
from basic_memory.repository import ObservationRepository
from basic_memory.repository.observation_repository import ObservationRepository
from basic_memory.schemas import EntityIn, ObservationIn
from . import DatabaseSyncError
@@ -5,7 +5,7 @@ from typing import Dict, Any
from sqlalchemy import delete
from basic_memory.models import Relation as DbRelation, Relation
from basic_memory.repository import RelationRepository
from basic_memory.repository.relation_repository import RelationRepository
from basic_memory.schemas import EntityIn, RelationIn
from . import ServiceError, DatabaseSyncError, RelationError
+65 -35
View File
@@ -1,12 +1,18 @@
"""Common test fixtures for basic-memory."""
import pytest_asyncio
from pathlib import Path
"""Common test fixtures."""
import tempfile
from pathlib import Path
from basic_memory.db import DatabaseType, get_database_url, init_database, get_session, dispose_database
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from basic_memory.models import Base, Entity, Observation, Relation
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.observation_repository import ObservationRepository
from basic_memory.repository.relation_repository import RelationRepository
from basic_memory.deps import (
get_entity_repo,
get_observation_repo,
get_entity_repo,
get_observation_repo,
get_relation_repo,
get_entity_service,
get_observation_service,
@@ -15,20 +21,30 @@ from basic_memory.deps import (
)
from basic_memory.schemas import EntityIn
@pytest_asyncio.fixture(scope="function")
async def engine():
"""Create an async engine using in-memory SQLite database."""
url = get_database_url(DatabaseType.MEMORY)
engine = await init_database(url)
"""Create an async engine using in-memory SQLite database"""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:", # In-memory database
echo=False # Set to True for SQL logging
)
# Create all tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
try:
yield engine
finally:
await dispose_database(engine)
await engine.dispose()
@pytest_asyncio.fixture(scope="function")
async def session(engine):
"""Create a database session with proper lifecycle management."""
async with get_session(engine) as session:
"""Create an async session factory and yield a session"""
async_session = async_sessionmaker(engine, expire_on_commit=False)
async with async_session() as session:
yield session
@pytest_asyncio.fixture
@@ -40,35 +56,37 @@ async def test_project_path():
entities_path.mkdir(parents=True)
yield project_path
@pytest_asyncio.fixture
async def entity_repo(session):
"""Create an EntityRepository instance."""
return await get_entity_repo(session)
@pytest_asyncio.fixture(scope="function")
async def entity_repository(session: AsyncSession):
"""Create an EntityRepository instance"""
yield EntityRepository(session)
@pytest_asyncio.fixture(scope="function")
async def observation_repository(session: AsyncSession):
"""Create an ObservationRepository instance"""
return ObservationRepository(session)
@pytest_asyncio.fixture(scope="function")
async def relation_repository(session: AsyncSession):
"""Create a RelationRepository instance"""
return RelationRepository(session)
@pytest_asyncio.fixture
async def observation_repo(session):
"""Create an ObservationRepository instance."""
return await get_observation_repo(session)
@pytest_asyncio.fixture
async def relation_repo(session):
"""Create a RelationRepository instance."""
return await get_relation_repo(session)
@pytest_asyncio.fixture
async def entity_service(test_project_path, entity_repo):
async def entity_service(test_project_path, entity_repository):
"""Fixture providing initialized EntityService."""
return await get_entity_service(test_project_path, entity_repo)
return await get_entity_service(test_project_path, entity_repository)
@pytest_asyncio.fixture
async def observation_service(test_project_path, observation_repo):
"""Fixture providing initialized ObservationService."""
return await get_observation_service(test_project_path, observation_repo)
@pytest_asyncio.fixture
async def relation_service(test_project_path, relation_repo):
async def relation_service(test_project_path, relation_repository):
"""Fixture providing initialized RelationService."""
return await get_relation_service(test_project_path, relation_repo)
return await get_relation_service(test_project_path, relation_repository)
@pytest_asyncio.fixture
async def observation_service(test_project_path, observation_repository):
"""Fixture providing initialized RelationService."""
return await get_observation_service(test_project_path, observation_repository)
@pytest_asyncio.fixture
async def memory_service(
@@ -85,6 +103,18 @@ async def memory_service(
observation_service
)
@pytest_asyncio.fixture(scope="function")
async def sample_entity(entity_repository: EntityRepository):
"""Create a sample entity for testing"""
entity_data = {
'id': '20240102-test-entity',
'name': 'Test Entity',
'entity_type': 'test',
'description': 'A test entity',
'references': 'Test references'
}
return await entity_repository.create(entity_data)
@pytest_asyncio.fixture
async def test_entity(entity_service):
"""Create a test entity for reuse in tests."""
+104
View File
@@ -0,0 +1,104 @@
"""Tests for EntityRepository."""
import pytest
from datetime import datetime, UTC
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 = {
'id': '20240102-test',
'name': 'Test',
'entity_type': 'test',
'description': 'Test description',
'references': 'Test references'
}
entity = await entity_repository.create(entity_data)
assert entity.id == '20240102-test'
assert entity.name == 'Test'
assert entity.description == 'Test description'
assert isinstance(entity.created_at, datetime)
assert entity.created_at.tzinfo == UTC
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
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
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
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
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'
})
# Add observations
await entity_repository.session.execute('''
INSERT INTO observation (entity_id, content)
VALUES (?, ?), (?, ?)
''', [
(entity1.id, 'First observation with searchable content'),
(entity2.id, 'Another observation to find')
])
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
+2 -9
View File
@@ -1,16 +1,9 @@
"""Tests for EntityService."""
import pytest
import pytest_asyncio
from pathlib import Path
import tempfile
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy.pool import StaticPool
from basic_memory.fileio import EntityNotFoundError
from basic_memory.models import Entity as DbEntity, Base, Entity
from basic_memory.repository import EntityRepository
from basic_memory.schemas import EntityIn, ObservationIn
from basic_memory.services import EntityService
from basic_memory.models import Entity
from basic_memory.schemas import EntityIn
pytestmark = pytest.mark.asyncio
+58
View File
@@ -0,0 +1,58 @@
"""Tests for ObservationRepository."""
import pytest
import pytest_asyncio
from basic_memory.models import Entity, Observation
from basic_memory.repository.observation_repository import ObservationRepository
pytestmark = pytest.mark.asyncio
class TestObservationRepository:
@pytest_asyncio.fixture(scope="function")
async def sample_observation(self, observation_repository: ObservationRepository, sample_entity: Entity):
"""Create a sample observation for testing"""
observation_data = {
'entity_id': sample_entity.id,
'content': 'Test observation',
'context': 'test-context'
}
return await observation_repository.create(observation_data)
async def test_create_observation(
self,
observation_repository: ObservationRepository,
sample_entity: Entity
):
"""Test creating a new observation"""
observation_data = {
'entity_id': sample_entity.id,
'content': 'Test content',
'context': 'test-context'
}
observation = await observation_repository.create(observation_data)
assert observation.entity_id == sample_entity.id
assert observation.content == 'Test content'
assert observation.id is not None # Should be auto-generated
async def test_find_by_entity(
self,
observation_repository: ObservationRepository,
sample_observation: Observation,
sample_entity: Entity
):
"""Test finding observations by entity"""
observations = await observation_repository.find_by_entity(sample_entity.id)
assert len(observations) == 1
assert observations[0].id == sample_observation.id
assert observations[0].content == sample_observation.content
async def test_find_by_context(
self,
observation_repository: ObservationRepository,
sample_observation: Observation
):
"""Test finding observations by context"""
observations = await observation_repository.find_by_context('test-context')
assert len(observations) == 1
assert observations[0].id == sample_observation.id
+83
View File
@@ -0,0 +1,83 @@
"""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)
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
-245
View File
@@ -1,245 +0,0 @@
import pytest
import pytest_asyncio
from datetime import datetime, UTC
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from basic_memory.models import Base, Entity, Observation, Relation
from basic_memory.repository import EntityRepository, ObservationRepository, RelationRepository
pytestmark = pytest.mark.asyncio
@pytest_asyncio.fixture(scope="function")
async def engine():
"""Create an async engine using in-memory SQLite database"""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:", # In-memory database
echo=False # Set to True for SQL logging
)
# Create all tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
try:
yield engine
finally:
await engine.dispose()
@pytest_asyncio.fixture(scope="function")
async def session(engine):
"""Create an async session factory and yield a session"""
async_session = async_sessionmaker(engine, expire_on_commit=False)
async with async_session() as session:
yield session
@pytest_asyncio.fixture(scope="function")
async def entity_repository(session: AsyncSession):
"""Create an EntityRepository instance"""
yield EntityRepository(session, Entity)
@pytest_asyncio.fixture(scope="function")
async def observation_repository(session: AsyncSession):
"""Create an ObservationRepository instance"""
return ObservationRepository(session, Observation)
@pytest_asyncio.fixture(scope="function")
async def relation_repository(session: AsyncSession):
"""Create a RelationRepository instance"""
return RelationRepository(session, Relation)
@pytest_asyncio.fixture(scope="function")
async def sample_entity(entity_repository: EntityRepository):
"""Create a sample entity for testing"""
entity_data = {
'id': '20240102-test-entity',
'name': 'Test Entity',
'entity_type': 'test',
'description': 'A test entity',
'references': 'Test references'
}
return await entity_repository.create(entity_data)
class TestEntityRepository:
async def test_create_entity(self, entity_repository: EntityRepository):
"""Test creating a new entity"""
entity_data = {
'id': '20240102-test',
'name': 'Test',
'entity_type': 'test',
'description': 'Test description',
'references': 'Test references'
}
entity = await entity_repository.create(entity_data)
assert entity.id == '20240102-test'
assert entity.name == 'Test'
assert entity.description == 'Test description'
assert isinstance(entity.created_at, datetime)
assert entity.created_at.tzinfo == UTC
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
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
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
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
class TestObservationRepository:
@pytest_asyncio.fixture(scope="function")
async def sample_observation(self, observation_repository: ObservationRepository, sample_entity: Entity):
"""Create a sample observation for testing"""
observation_data = {
'entity_id': sample_entity.id,
'content': 'Test observation',
'context': 'test-context'
}
return await observation_repository.create(observation_data)
async def test_create_observation(
self,
observation_repository: ObservationRepository,
sample_entity: Entity
):
"""Test creating a new observation"""
observation_data = {
'entity_id': sample_entity.id,
'content': 'Test content',
'context': 'test-context'
}
observation = await observation_repository.create(observation_data)
assert observation.entity_id == sample_entity.id
assert observation.content == 'Test content'
assert observation.id is not None # Should be auto-generated
async def test_find_by_entity(
self,
observation_repository: ObservationRepository,
sample_observation: Observation,
sample_entity: Entity
):
"""Test finding observations by entity"""
observations = await observation_repository.find_by_entity(sample_entity.id)
assert len(observations) == 1
assert observations[0].id == sample_observation.id
assert observations[0].content == sample_observation.content
async def test_find_by_context(
self,
observation_repository: ObservationRepository,
sample_observation: Observation
):
"""Test finding observations by context"""
observations = await observation_repository.find_by_context('test-context')
assert len(observations) == 1
assert observations[0].id == sample_observation.id
class TestRelationRepository:
@pytest_asyncio.fixture(scope="function")
async def related_entity(self, entity_repository: EntityRepository):
"""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)
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