From 031a009245ae6ce4cdf630394b88fcff86592033 Mon Sep 17 00:00:00 2001 From: phernandez Date: Sun, 8 Dec 2024 15:28:32 -0600 Subject: [PATCH] split up repository logic --- memory.json | 13 +- src/basic_memory/deps.py | 4 +- .../{repository.py => repository/__init__.py} | 93 +------ .../repository/entity_repository.py | 62 +++++ .../repository/observation_repository.py | 25 ++ .../repository/relation_repository.py | 30 +++ src/basic_memory/services/entity_service.py | 7 +- src/basic_memory/services/memory_service.py | 2 +- .../services/observation_service.py | 2 +- src/basic_memory/services/relation_service.py | 2 +- tests/conftest.py | 100 ++++--- tests/test_entity_repository.py | 104 ++++++++ tests/test_entity_service.py | 11 +- tests/test_observation_repository.py | 58 +++++ tests/test_relation_repository.py | 83 ++++++ tests/test_repository.py | 245 ------------------ 16 files changed, 460 insertions(+), 381 deletions(-) rename src/basic_memory/{repository.py => repository/__init__.py} (50%) create mode 100644 src/basic_memory/repository/entity_repository.py create mode 100644 src/basic_memory/repository/observation_repository.py create mode 100644 src/basic_memory/repository/relation_repository.py create mode 100644 tests/test_entity_repository.py create mode 100644 tests/test_observation_repository.py create mode 100644 tests/test_relation_repository.py delete mode 100644 tests/test_repository.py diff --git a/memory.json b/memory.json index 83b56cb7..578667fc 100644 --- a/memory.json +++ b/memory.json @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/src/basic_memory/deps.py b/src/basic_memory/deps.py index 64e7fc44..4339d20e 100644 --- a/src/basic_memory/deps.py +++ b/src/basic_memory/deps.py @@ -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 diff --git a/src/basic_memory/repository.py b/src/basic_memory/repository/__init__.py similarity index 50% rename from src/basic_memory/repository.py rename to src/basic_memory/repository/__init__.py index 15333e76..6c77b80f 100644 --- a/src/basic_memory/repository.py +++ b/src/basic_memory/repository/__init__.py @@ -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() \ No newline at end of file diff --git a/src/basic_memory/repository/entity_repository.py b/src/basic_memory/repository/entity_repository.py new file mode 100644 index 00000000..9fecd699 --- /dev/null +++ b/src/basic_memory/repository/entity_repository.py @@ -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()) \ No newline at end of file diff --git a/src/basic_memory/repository/observation_repository.py b/src/basic_memory/repository/observation_repository.py new file mode 100644 index 00000000..831829d7 --- /dev/null +++ b/src/basic_memory/repository/observation_repository.py @@ -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() \ No newline at end of file diff --git a/src/basic_memory/repository/relation_repository.py b/src/basic_memory/repository/relation_repository.py new file mode 100644 index 00000000..5db2be0c --- /dev/null +++ b/src/basic_memory/repository/relation_repository.py @@ -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() \ No newline at end of file diff --git a/src/basic_memory/services/entity_service.py b/src/basic_memory/services/entity_service.py index 4b0d2d6a..27d4337c 100644 --- a/src/basic_memory/services/entity_service.py +++ b/src/basic_memory/services/entity_service.py @@ -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. diff --git a/src/basic_memory/services/memory_service.py b/src/basic_memory/services/memory_service.py index 56283616..0c767450 100644 --- a/src/basic_memory/services/memory_service.py +++ b/src/basic_memory/services/memory_service.py @@ -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 diff --git a/src/basic_memory/services/observation_service.py b/src/basic_memory/services/observation_service.py index 0ced1cd8..5193f6ac 100644 --- a/src/basic_memory/services/observation_service.py +++ b/src/basic_memory/services/observation_service.py @@ -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 diff --git a/src/basic_memory/services/relation_service.py b/src/basic_memory/services/relation_service.py index 7162be16..c039af45 100644 --- a/src/basic_memory/services/relation_service.py +++ b/src/basic_memory/services/relation_service.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 3e21a60d..7e843b53 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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.""" diff --git a/tests/test_entity_repository.py b/tests/test_entity_repository.py new file mode 100644 index 00000000..e661fcdf --- /dev/null +++ b/tests/test_entity_repository.py @@ -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 \ No newline at end of file diff --git a/tests/test_entity_service.py b/tests/test_entity_service.py index 30feebee..1cb5914f 100644 --- a/tests/test_entity_service.py +++ b/tests/test_entity_service.py @@ -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 diff --git a/tests/test_observation_repository.py b/tests/test_observation_repository.py new file mode 100644 index 00000000..68941629 --- /dev/null +++ b/tests/test_observation_repository.py @@ -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 \ No newline at end of file diff --git a/tests/test_relation_repository.py b/tests/test_relation_repository.py new file mode 100644 index 00000000..56d2f00f --- /dev/null +++ b/tests/test_relation_repository.py @@ -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 \ No newline at end of file diff --git a/tests/test_repository.py b/tests/test_repository.py deleted file mode 100644 index 0154458c..00000000 --- a/tests/test_repository.py +++ /dev/null @@ -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 \ No newline at end of file