fix: Add missing foreign key constraints for project removal (#254) (#258)

Signed-off-by: Joe P <joe@basicmemory.com>
Signed-off-by: joe@basicmemory.com
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: jope-bm <jope-bm@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
jope-bm
2025-08-20 08:49:07 -06:00
committed by GitHub
parent 08ee7e1201
commit b6aeb3217c
4 changed files with 661 additions and 0 deletions
@@ -0,0 +1,54 @@
"""fix project foreign keys
Revision ID: a1b2c3d4e5f6
Revises: 647e7a75e2cd
Create Date: 2025-08-19 22:06:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, None] = "647e7a75e2cd"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Re-establish foreign key constraints that were lost during project table recreation.
The migration 647e7a75e2cd recreated the project table but did not re-establish
the foreign key constraint from entity.project_id to project.id, causing
foreign key constraint failures when trying to delete projects with related entities.
"""
# SQLite doesn't allow adding foreign key constraints to existing tables easily
# We need to be careful and handle the case where the constraint might already exist
with op.batch_alter_table("entity", schema=None) as batch_op:
# Try to drop existing foreign key constraint (may not exist)
try:
batch_op.drop_constraint("fk_entity_project_id", type_="foreignkey")
except Exception:
# Constraint may not exist, which is fine - we'll create it next
pass
# Add the foreign key constraint with CASCADE DELETE
# This ensures that when a project is deleted, all related entities are also deleted
batch_op.create_foreign_key(
"fk_entity_project_id",
"project",
["project_id"],
["id"],
ondelete="CASCADE"
)
def downgrade() -> None:
"""Remove the foreign key constraint."""
with op.batch_alter_table("entity", schema=None) as batch_op:
batch_op.drop_constraint("fk_entity_project_id", type_="foreignkey")
@@ -0,0 +1,154 @@
"""Test to verify that issue #254 is fixed.
Issue #254: Foreign key constraint failures when deleting projects with related entities.
The issue was that when migration 647e7a75e2cd recreated the project table,
it did not re-establish the foreign key constraint from entity.project_id to project.id
with CASCADE DELETE, causing foreign key constraint failures when trying to delete
projects that have related entities.
Migration a1b2c3d4e5f6 was created to fix this by adding the missing foreign key
constraint with CASCADE DELETE behavior.
This test file verifies that the fix works correctly in production databases
that have had the migration applied.
"""
from datetime import datetime, timezone
import pytest
from basic_memory.services.project_service import ProjectService
#@pytest.mark.skip(reason="Issue #254 not fully resolved yet - foreign key constraint errors still occur")
@pytest.mark.asyncio
async def test_issue_254_foreign_key_constraint_fix(project_service: ProjectService, tmp_path):
"""Test to verify issue #254 is fixed: project removal with foreign key constraints.
This test reproduces the exact scenario from issue #254:
1. Create a project
2. Create entities, observations, and relations linked to that project
3. Attempt to remove the project
4. Verify it succeeds without "FOREIGN KEY constraint failed" errors
5. Verify all related data is properly cleaned up via CASCADE DELETE
Once issue #254 is fully fixed, remove the @pytest.mark.skip decorator.
"""
test_project_name = "issue-254-verification"
test_project_path = str(tmp_path / "issue-254-verification")
# Step 1: Create test project
await project_service.add_project(test_project_name, test_project_path)
project = await project_service.get_project(test_project_name)
assert project is not None, "Project should be created successfully"
# Step 2: Create related entities that would cause foreign key constraint issues
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.observation_repository import ObservationRepository
from basic_memory.repository.relation_repository import RelationRepository
entity_repo = EntityRepository(project_service.repository.session_maker, project_id=project.id)
obs_repo = ObservationRepository(project_service.repository.session_maker, project_id=project.id)
rel_repo = RelationRepository(project_service.repository.session_maker, project_id=project.id)
# Create entity
entity_data = {
"title": "Issue 254 Test Entity",
"entity_type": "note",
"content_type": "text/markdown",
"project_id": project.id,
"permalink": "issue-254-entity",
"file_path": "issue-254-entity.md",
"checksum": "issue254test",
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
entity = await entity_repo.create(entity_data)
# Create observation linked to entity
observation_data = {
"entity_id": entity.id,
"content": "This observation should be cascade deleted",
"category": "test"
}
observation = await obs_repo.create(observation_data)
# Create relation involving the entity
relation_data = {
"from_id": entity.id,
"to_name": "some-other-entity",
"relation_type": "relates-to"
}
relation = await rel_repo.create(relation_data)
# Step 3: Attempt to remove the project
# This is where issue #254 manifested - should NOT raise "FOREIGN KEY constraint failed"
try:
await project_service.remove_project(test_project_name)
except Exception as e:
if "FOREIGN KEY constraint failed" in str(e):
pytest.fail(
f"Issue #254 not fixed - foreign key constraint error still occurs: {e}. "
f"The migration a1b2c3d4e5f6 may not have been applied correctly or "
f"the CASCADE DELETE constraint is not working as expected."
)
else:
# Re-raise unexpected errors
raise
# Step 4: Verify project was successfully removed
removed_project = await project_service.get_project(test_project_name)
assert removed_project is None, "Project should have been removed"
# Step 5: Verify related data was cascade deleted
remaining_entity = await entity_repo.find_by_id(entity.id)
assert remaining_entity is None, "Entity should have been cascade deleted"
remaining_observation = await obs_repo.find_by_id(observation.id)
assert remaining_observation is None, "Observation should have been cascade deleted"
remaining_relation = await rel_repo.find_by_id(relation.id)
assert remaining_relation is None, "Relation should have been cascade deleted"
@pytest.mark.asyncio
async def test_issue_254_reproduction(project_service: ProjectService, tmp_path):
"""Test that reproduces issue #254 to document the current state.
This test demonstrates the current behavior and will fail until the issue is fixed.
It serves as documentation of what the problem was.
"""
test_project_name = "issue-254-reproduction"
test_project_path = str(tmp_path / "issue-254-reproduction")
# Create project and entity
await project_service.add_project(test_project_name, test_project_path)
project = await project_service.get_project(test_project_name)
from basic_memory.repository.entity_repository import EntityRepository
entity_repo = EntityRepository(project_service.repository.session_maker, project_id=project.id)
entity_data = {
"title": "Reproduction Entity",
"entity_type": "note",
"content_type": "text/markdown",
"project_id": project.id,
"permalink": "reproduction-entity",
"file_path": "reproduction-entity.md",
"checksum": "repro123",
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
entity = await entity_repo.create(entity_data)
# This should eventually work without errors once issue #254 is fixed
#with pytest.raises(Exception) as exc_info:
await project_service.remove_project(test_project_name)
# Document the current error for tracking
# error_message = str(exc_info.value)
# assert any(keyword in error_message for keyword in [
# "FOREIGN KEY constraint failed",
# "constraint",
# "integrity"
# ]), f"Expected foreign key or integrity constraint error, got: {error_message}"
+125
View File
@@ -0,0 +1,125 @@
"""Test for project removal bug #254."""
import os
from datetime import timezone, datetime
import pytest
from basic_memory.services.project_service import ProjectService
@pytest.mark.asyncio
async def test_remove_project_with_related_entities(project_service: ProjectService, tmp_path):
"""Test removing a project that has related entities (reproduces issue #254).
This test verifies that projects with related entities (entities, observations, relations)
can be properly deleted without foreign key constraint violations.
The bug was caused by missing foreign key constraints with CASCADE DELETE after
the project table was recreated in migration 647e7a75e2cd.
"""
test_project_name = f"test-remove-with-entities-{os.urandom(4).hex()}"
test_project_path = str(tmp_path / "test-remove-with-entities")
# Make sure the test directory exists
os.makedirs(test_project_path, exist_ok=True)
try:
# Step 1: Add the test project
await project_service.add_project(test_project_name, test_project_path)
# Verify project exists
project = await project_service.get_project(test_project_name)
assert project is not None
# Step 2: Create related entities for this project
from basic_memory.repository.entity_repository import EntityRepository
entity_repo = EntityRepository(project_service.repository.session_maker, project_id=project.id)
entity_data = {
"title": "Test Entity for Deletion",
"entity_type": "note",
"content_type": "text/markdown",
"project_id": project.id,
"permalink": "test-deletion-entity",
"file_path": "test-deletion-entity.md",
"checksum": "test123",
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
entity = await entity_repo.create(entity_data)
assert entity is not None
# Step 3: Create observations for the entity
from basic_memory.repository.observation_repository import ObservationRepository
obs_repo = ObservationRepository(project_service.repository.session_maker, project_id=project.id)
observation_data = {
"entity_id": entity.id,
"content": "This is a test observation",
"category": "note"
}
observation = await obs_repo.create(observation_data)
assert observation is not None
# Step 4: Create relations involving the entity
from basic_memory.repository.relation_repository import RelationRepository
rel_repo = RelationRepository(project_service.repository.session_maker, project_id=project.id)
relation_data = {
"from_id": entity.id,
"to_name": "some-target-entity",
"relation_type": "relates-to"
}
relation = await rel_repo.create(relation_data)
assert relation is not None
# Step 5: Attempt to remove the project
# This should work with proper cascade delete, or fail with foreign key constraint
await project_service.remove_project(test_project_name)
# Step 6: Verify everything was properly deleted
# Project should be gone
removed_project = await project_service.get_project(test_project_name)
assert removed_project is None, "Project should have been removed"
# Related entities should be cascade deleted
remaining_entity = await entity_repo.find_by_id(entity.id)
assert remaining_entity is None, "Entity should have been cascade deleted"
# Observations should be cascade deleted
remaining_obs = await obs_repo.find_by_id(observation.id)
assert remaining_obs is None, "Observation should have been cascade deleted"
# Relations should be cascade deleted
remaining_rel = await rel_repo.find_by_id(relation.id)
assert remaining_rel is None, "Relation should have been cascade deleted"
except Exception as e:
# Check if this is the specific foreign key constraint error from the bug report
if "FOREIGN KEY constraint failed" in str(e):
pytest.fail(
f"Bug #254 reproduced: {e}. "
"This indicates missing foreign key constraints with CASCADE DELETE. "
"Run migration a1b2c3d4e5f6_fix_project_foreign_keys.py to fix this."
)
else:
# Re-raise other unexpected errors
raise e
finally:
# Clean up - remove project if it still exists
if test_project_name in project_service.projects:
try:
await project_service.remove_project(test_project_name)
except Exception:
# Manual cleanup if remove_project fails
try:
project_service.config_manager.remove_project(test_project_name)
except Exception:
pass
project = await project_service.get_project(test_project_name)
if project:
await project_service.repository.delete(project.id)
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""
Test script to verify cascade delete behavior on production SQLite database.
This script tests whether foreign key constraints with CASCADE DELETE are properly
configured in the production database at ~/.basic-memory/memory.db.
Usage: python test_production_cascade_delete.py
"""
import asyncio
import os
import sqlite3
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import asyncio
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
class ProductionCascadeTest:
"""Test cascade delete behavior on production database."""
def __init__(self, db_path: Optional[Path] = None):
"""Initialize test with database path."""
if db_path is None:
# Default to standard Basic Memory location
home_dir = Path.home()
self.db_path = home_dir / ".basic-memory" / "memory.db"
else:
self.db_path = db_path
# Create backup path
self.backup_path = self.db_path.with_suffix('.db.backup')
self.engine = None
self.session_maker = None
async def setup(self):
"""Setup database connection."""
if not self.db_path.exists():
print(f"❌ Production database not found at: {self.db_path}")
print("Please ensure Basic Memory has been initialized and the database exists.")
sys.exit(1)
print(f"📁 Using database: {self.db_path}")
# Create backup
print(f"💾 Creating backup: {self.backup_path}")
import shutil
shutil.copy2(self.db_path, self.backup_path)
# Connect to database
db_url = f"sqlite+aiosqlite:///{self.db_path}"
self.engine = create_async_engine(db_url, connect_args={"check_same_thread": False})
self.session_maker = async_sessionmaker(self.engine, expire_on_commit=False)
async def cleanup(self):
"""Cleanup database connection."""
if self.engine:
await self.engine.dispose()
async def check_foreign_keys_enabled(self) -> bool:
"""Check if foreign keys are enabled in this session."""
async with self.session_maker() as session:
# Enable foreign keys like production does
await session.execute(text("PRAGMA foreign_keys=ON"))
result = await session.execute(text("PRAGMA foreign_keys"))
fk_enabled = result.fetchone()[0]
return bool(fk_enabled)
async def check_schema(self):
"""Check current database schema for foreign key constraints."""
async with self.session_maker() as session:
await session.execute(text("PRAGMA foreign_keys=ON"))
# Check entity table foreign keys
result = await session.execute(text("PRAGMA foreign_key_list(entity)"))
entity_fks = result.fetchall()
print("🔍 Current entity table foreign key constraints:")
for fk in entity_fks:
print(f" - Column: {fk[3]} -> {fk[2]}.{fk[4]} (ON DELETE: {fk[6]})")
# Check if CASCADE DELETE is configured
has_cascade = any(fk[6] == 'CASCADE' for fk in entity_fks)
if has_cascade:
print("✅ CASCADE DELETE is configured")
else:
print("❌ CASCADE DELETE is NOT configured (uses NO ACTION)")
return has_cascade
async def create_test_data(self) -> tuple[int, int]:
"""Create test project and entity. Returns (project_id, entity_id)."""
async with self.session_maker() as session:
await session.execute(text("PRAGMA foreign_keys=ON"))
# Create test project
project_sql = """
INSERT INTO project (name, description, permalink, path, is_active, is_default, created_at, updated_at)
VALUES (:name, :description, :permalink, :path, :is_active, :is_default, :created_at, :updated_at)
"""
now = datetime.now(timezone.utc)
result = await session.execute(text(project_sql), {
"name": "cascade-test-project",
"description": "Test project for cascade delete verification",
"permalink": "cascade-test-project",
"path": "/tmp/cascade-test",
"is_active": True,
"is_default": False,
"created_at": now,
"updated_at": now
})
project_id = result.lastrowid
# Create test entity linked to project
entity_sql = """
INSERT INTO entity (title, entity_type, content_type, project_id, permalink, file_path,
checksum, created_at, updated_at)
VALUES (:title, :entity_type, :content_type, :project_id, :permalink, :file_path,
:checksum, :created_at, :updated_at)
"""
result = await session.execute(text(entity_sql), {
"title": "Cascade Test Entity",
"entity_type": "note",
"content_type": "text/markdown",
"project_id": project_id,
"permalink": "cascade-test-entity",
"file_path": "cascade-test-entity.md",
"checksum": "test-checksum",
"created_at": now,
"updated_at": now
})
entity_id = result.lastrowid
await session.commit()
print(f"📝 Created test project (ID: {project_id}) and entity (ID: {entity_id})")
return project_id, entity_id
async def verify_test_data_exists(self, project_id: int, entity_id: int) -> bool:
"""Verify test data exists before deletion."""
async with self.session_maker() as session:
# Check project exists
result = await session.execute(
text("SELECT COUNT(*) FROM project WHERE id = :project_id"), {"project_id": project_id}
)
project_count = result.fetchone()[0]
# Check entity exists
result = await session.execute(
text("SELECT COUNT(*) FROM entity WHERE id = :entity_id"), {"entity_id": entity_id}
)
entity_count = result.fetchone()[0]
exists = project_count > 0 and entity_count > 0
if exists:
print(f"✅ Test data verified: project ({project_count}) and entity ({entity_count}) exist")
else:
print(f"❌ Test data missing: project ({project_count}) and entity ({entity_count})")
return exists
async def test_cascade_delete(self, project_id: int, entity_id: int) -> bool:
"""Test if deleting project cascades to delete entity."""
async with self.session_maker() as session:
await session.execute(text("PRAGMA foreign_keys=ON"))
try:
# Attempt to delete project
print(f"🗑️ Attempting to delete project (ID: {project_id})...")
result = await session.execute(
text("DELETE FROM project WHERE id = :project_id"), {"project_id": project_id}
)
if result.rowcount == 0:
print("❌ Project deletion failed - no rows affected")
return False
await session.commit()
print("✅ Project deletion succeeded")
# Check if entity was cascade deleted
result = await session.execute(
text("SELECT COUNT(*) FROM entity WHERE id = :entity_id"), {"entity_id": entity_id}
)
entity_count = result.fetchone()[0]
if entity_count == 0:
print("✅ CASCADE DELETE working: Entity was automatically deleted")
return True
else:
print("❌ CASCADE DELETE NOT working: Entity still exists after project deletion")
return False
except Exception as e:
await session.rollback()
print(f"❌ Project deletion failed with error: {e}")
# Check if it's a foreign key constraint error
if "FOREIGN KEY constraint failed" in str(e):
print("🔍 This confirms foreign key constraints are enforced but CASCADE DELETE is not configured")
return False
async def cleanup_test_data(self, project_id: int, entity_id: int):
"""Clean up any remaining test data."""
async with self.session_maker() as session:
await session.execute(text("PRAGMA foreign_keys=ON"))
try:
# Delete entity first (in case cascade didn't work)
await session.execute(text("DELETE FROM entity WHERE id = :entity_id"), {"entity_id": entity_id})
# Delete project
await session.execute(text("DELETE FROM project WHERE id = :project_id"), {"project_id": project_id})
await session.commit()
print("🧹 Cleaned up any remaining test data")
except Exception as e:
print(f"⚠️ Error during cleanup: {e}")
await session.rollback()
async def restore_backup(self):
"""Restore database from backup."""
if self.backup_path.exists():
print(f"🔄 Restoring database from backup...")
import shutil
shutil.copy2(self.backup_path, self.db_path)
print("✅ Database restored from backup")
# Remove backup file
self.backup_path.unlink()
print("🗑️ Backup file removed")
else:
print("⚠️ No backup file found to restore")
async def run_test(self) -> bool:
"""Run the complete cascade delete test."""
print("🧪 Production Database CASCADE DELETE Test")
print("=" * 50)
try:
await self.setup()
# Check if foreign keys are enabled
fk_enabled = await self.check_foreign_keys_enabled()
print(f"🔐 Foreign keys enabled: {fk_enabled}")
if not fk_enabled:
print("❌ Foreign keys are not enabled - this test requires foreign key enforcement")
return False
# Check current schema
has_cascade = await self.check_schema()
# Create test data
project_id, entity_id = await self.create_test_data()
# Verify test data exists
if not await self.verify_test_data_exists(project_id, entity_id):
return False
# Test cascade delete
cascade_works = await self.test_cascade_delete(project_id, entity_id)
# Clean up any remaining test data
await self.cleanup_test_data(project_id, entity_id)
print("\n" + "=" * 50)
print("🧪 TEST RESULTS:")
print(f" Schema has CASCADE DELETE: {has_cascade}")
print(f" CASCADE DELETE works: {cascade_works}")
if has_cascade and cascade_works:
print("✅ PASS: Foreign key constraints are properly configured with CASCADE DELETE")
elif not has_cascade and not cascade_works:
print("❌ FAIL: Foreign key constraints are missing CASCADE DELETE configuration")
print("💡 This confirms issue #254 - migration a1b2c3d4e5f6 is needed")
else:
print("⚠️ MIXED: Unexpected result combination")
return cascade_works
except Exception as e:
print(f"💥 Test failed with error: {e}")
return False
finally:
await self.cleanup()
# Always restore backup to avoid leaving test data
await self.restore_backup()
async def main():
"""Main test function."""
import argparse
parser = argparse.ArgumentParser(description="Test cascade delete on production database")
parser.add_argument("--db-path", type=Path, help="Path to database file (default: ~/.basic-memory/memory.db)")
parser.add_argument("--no-backup", action="store_true", help="Skip creating backup (dangerous)")
args = parser.parse_args()
if args.no_backup:
print("⚠️ WARNING: Running without backup!")
response = input("Are you sure? Type 'yes' to continue: ")
if response.lower() != 'yes':
print("❌ Aborted")
return
test = ProductionCascadeTest(args.db_path)
success = await test.run_test()
sys.exit(0 if success else 1)
if __name__ == "__main__":
asyncio.run(main())