Skip to content

Commit b6aeb32

Browse files
jope-bmclaude[bot]claude
authored
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>
1 parent 08ee7e1 commit b6aeb32

4 files changed

Lines changed: 661 additions & 0 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""fix project foreign keys
2+
3+
Revision ID: a1b2c3d4e5f6
4+
Revises: 647e7a75e2cd
5+
Create Date: 2025-08-19 22:06:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
import sqlalchemy as sa
13+
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "a1b2c3d4e5f6"
17+
down_revision: Union[str, None] = "647e7a75e2cd"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
"""Re-establish foreign key constraints that were lost during project table recreation.
24+
25+
The migration 647e7a75e2cd recreated the project table but did not re-establish
26+
the foreign key constraint from entity.project_id to project.id, causing
27+
foreign key constraint failures when trying to delete projects with related entities.
28+
"""
29+
# SQLite doesn't allow adding foreign key constraints to existing tables easily
30+
# We need to be careful and handle the case where the constraint might already exist
31+
32+
with op.batch_alter_table("entity", schema=None) as batch_op:
33+
# Try to drop existing foreign key constraint (may not exist)
34+
try:
35+
batch_op.drop_constraint("fk_entity_project_id", type_="foreignkey")
36+
except Exception:
37+
# Constraint may not exist, which is fine - we'll create it next
38+
pass
39+
40+
# Add the foreign key constraint with CASCADE DELETE
41+
# This ensures that when a project is deleted, all related entities are also deleted
42+
batch_op.create_foreign_key(
43+
"fk_entity_project_id",
44+
"project",
45+
["project_id"],
46+
["id"],
47+
ondelete="CASCADE"
48+
)
49+
50+
51+
def downgrade() -> None:
52+
"""Remove the foreign key constraint."""
53+
with op.batch_alter_table("entity", schema=None) as batch_op:
54+
batch_op.drop_constraint("fk_entity_project_id", type_="foreignkey")
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Test to verify that issue #254 is fixed.
2+
3+
Issue #254: Foreign key constraint failures when deleting projects with related entities.
4+
5+
The issue was that when migration 647e7a75e2cd recreated the project table,
6+
it did not re-establish the foreign key constraint from entity.project_id to project.id
7+
with CASCADE DELETE, causing foreign key constraint failures when trying to delete
8+
projects that have related entities.
9+
10+
Migration a1b2c3d4e5f6 was created to fix this by adding the missing foreign key
11+
constraint with CASCADE DELETE behavior.
12+
13+
This test file verifies that the fix works correctly in production databases
14+
that have had the migration applied.
15+
"""
16+
from datetime import datetime, timezone
17+
18+
import pytest
19+
20+
from basic_memory.services.project_service import ProjectService
21+
22+
23+
#@pytest.mark.skip(reason="Issue #254 not fully resolved yet - foreign key constraint errors still occur")
24+
@pytest.mark.asyncio
25+
async def test_issue_254_foreign_key_constraint_fix(project_service: ProjectService, tmp_path):
26+
"""Test to verify issue #254 is fixed: project removal with foreign key constraints.
27+
28+
This test reproduces the exact scenario from issue #254:
29+
1. Create a project
30+
2. Create entities, observations, and relations linked to that project
31+
3. Attempt to remove the project
32+
4. Verify it succeeds without "FOREIGN KEY constraint failed" errors
33+
5. Verify all related data is properly cleaned up via CASCADE DELETE
34+
35+
Once issue #254 is fully fixed, remove the @pytest.mark.skip decorator.
36+
"""
37+
test_project_name = "issue-254-verification"
38+
test_project_path = str(tmp_path / "issue-254-verification")
39+
40+
# Step 1: Create test project
41+
await project_service.add_project(test_project_name, test_project_path)
42+
project = await project_service.get_project(test_project_name)
43+
assert project is not None, "Project should be created successfully"
44+
45+
# Step 2: Create related entities that would cause foreign key constraint issues
46+
from basic_memory.repository.entity_repository import EntityRepository
47+
from basic_memory.repository.observation_repository import ObservationRepository
48+
from basic_memory.repository.relation_repository import RelationRepository
49+
50+
entity_repo = EntityRepository(project_service.repository.session_maker, project_id=project.id)
51+
obs_repo = ObservationRepository(project_service.repository.session_maker, project_id=project.id)
52+
rel_repo = RelationRepository(project_service.repository.session_maker, project_id=project.id)
53+
54+
# Create entity
55+
entity_data = {
56+
"title": "Issue 254 Test Entity",
57+
"entity_type": "note",
58+
"content_type": "text/markdown",
59+
"project_id": project.id,
60+
"permalink": "issue-254-entity",
61+
"file_path": "issue-254-entity.md",
62+
"checksum": "issue254test",
63+
"created_at": datetime.now(timezone.utc),
64+
"updated_at": datetime.now(timezone.utc),
65+
}
66+
entity = await entity_repo.create(entity_data)
67+
68+
# Create observation linked to entity
69+
observation_data = {
70+
"entity_id": entity.id,
71+
"content": "This observation should be cascade deleted",
72+
"category": "test"
73+
}
74+
observation = await obs_repo.create(observation_data)
75+
76+
# Create relation involving the entity
77+
relation_data = {
78+
"from_id": entity.id,
79+
"to_name": "some-other-entity",
80+
"relation_type": "relates-to"
81+
}
82+
relation = await rel_repo.create(relation_data)
83+
84+
# Step 3: Attempt to remove the project
85+
# This is where issue #254 manifested - should NOT raise "FOREIGN KEY constraint failed"
86+
try:
87+
await project_service.remove_project(test_project_name)
88+
except Exception as e:
89+
if "FOREIGN KEY constraint failed" in str(e):
90+
pytest.fail(
91+
f"Issue #254 not fixed - foreign key constraint error still occurs: {e}. "
92+
f"The migration a1b2c3d4e5f6 may not have been applied correctly or "
93+
f"the CASCADE DELETE constraint is not working as expected."
94+
)
95+
else:
96+
# Re-raise unexpected errors
97+
raise
98+
99+
# Step 4: Verify project was successfully removed
100+
removed_project = await project_service.get_project(test_project_name)
101+
assert removed_project is None, "Project should have been removed"
102+
103+
# Step 5: Verify related data was cascade deleted
104+
remaining_entity = await entity_repo.find_by_id(entity.id)
105+
assert remaining_entity is None, "Entity should have been cascade deleted"
106+
107+
remaining_observation = await obs_repo.find_by_id(observation.id)
108+
assert remaining_observation is None, "Observation should have been cascade deleted"
109+
110+
remaining_relation = await rel_repo.find_by_id(relation.id)
111+
assert remaining_relation is None, "Relation should have been cascade deleted"
112+
113+
114+
@pytest.mark.asyncio
115+
async def test_issue_254_reproduction(project_service: ProjectService, tmp_path):
116+
"""Test that reproduces issue #254 to document the current state.
117+
118+
This test demonstrates the current behavior and will fail until the issue is fixed.
119+
It serves as documentation of what the problem was.
120+
"""
121+
test_project_name = "issue-254-reproduction"
122+
test_project_path = str(tmp_path / "issue-254-reproduction")
123+
124+
# Create project and entity
125+
await project_service.add_project(test_project_name, test_project_path)
126+
project = await project_service.get_project(test_project_name)
127+
128+
from basic_memory.repository.entity_repository import EntityRepository
129+
entity_repo = EntityRepository(project_service.repository.session_maker, project_id=project.id)
130+
131+
entity_data = {
132+
"title": "Reproduction Entity",
133+
"entity_type": "note",
134+
"content_type": "text/markdown",
135+
"project_id": project.id,
136+
"permalink": "reproduction-entity",
137+
"file_path": "reproduction-entity.md",
138+
"checksum": "repro123",
139+
"created_at": datetime.now(timezone.utc),
140+
"updated_at": datetime.now(timezone.utc),
141+
}
142+
entity = await entity_repo.create(entity_data)
143+
144+
# This should eventually work without errors once issue #254 is fixed
145+
#with pytest.raises(Exception) as exc_info:
146+
await project_service.remove_project(test_project_name)
147+
148+
# Document the current error for tracking
149+
# error_message = str(exc_info.value)
150+
# assert any(keyword in error_message for keyword in [
151+
# "FOREIGN KEY constraint failed",
152+
# "constraint",
153+
# "integrity"
154+
# ]), f"Expected foreign key or integrity constraint error, got: {error_message}"
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Test for project removal bug #254."""
2+
3+
import os
4+
from datetime import timezone, datetime
5+
6+
import pytest
7+
8+
from basic_memory.services.project_service import ProjectService
9+
10+
11+
@pytest.mark.asyncio
12+
async def test_remove_project_with_related_entities(project_service: ProjectService, tmp_path):
13+
"""Test removing a project that has related entities (reproduces issue #254).
14+
15+
This test verifies that projects with related entities (entities, observations, relations)
16+
can be properly deleted without foreign key constraint violations.
17+
18+
The bug was caused by missing foreign key constraints with CASCADE DELETE after
19+
the project table was recreated in migration 647e7a75e2cd.
20+
"""
21+
test_project_name = f"test-remove-with-entities-{os.urandom(4).hex()}"
22+
test_project_path = str(tmp_path / "test-remove-with-entities")
23+
24+
# Make sure the test directory exists
25+
os.makedirs(test_project_path, exist_ok=True)
26+
27+
try:
28+
# Step 1: Add the test project
29+
await project_service.add_project(test_project_name, test_project_path)
30+
31+
# Verify project exists
32+
project = await project_service.get_project(test_project_name)
33+
assert project is not None
34+
35+
# Step 2: Create related entities for this project
36+
from basic_memory.repository.entity_repository import EntityRepository
37+
entity_repo = EntityRepository(project_service.repository.session_maker, project_id=project.id)
38+
39+
entity_data = {
40+
"title": "Test Entity for Deletion",
41+
"entity_type": "note",
42+
"content_type": "text/markdown",
43+
"project_id": project.id,
44+
"permalink": "test-deletion-entity",
45+
"file_path": "test-deletion-entity.md",
46+
"checksum": "test123",
47+
"created_at": datetime.now(timezone.utc),
48+
"updated_at": datetime.now(timezone.utc),
49+
}
50+
entity = await entity_repo.create(entity_data)
51+
assert entity is not None
52+
53+
# Step 3: Create observations for the entity
54+
from basic_memory.repository.observation_repository import ObservationRepository
55+
obs_repo = ObservationRepository(project_service.repository.session_maker, project_id=project.id)
56+
57+
observation_data = {
58+
"entity_id": entity.id,
59+
"content": "This is a test observation",
60+
"category": "note"
61+
}
62+
observation = await obs_repo.create(observation_data)
63+
assert observation is not None
64+
65+
# Step 4: Create relations involving the entity
66+
from basic_memory.repository.relation_repository import RelationRepository
67+
rel_repo = RelationRepository(project_service.repository.session_maker, project_id=project.id)
68+
69+
relation_data = {
70+
"from_id": entity.id,
71+
"to_name": "some-target-entity",
72+
"relation_type": "relates-to"
73+
}
74+
relation = await rel_repo.create(relation_data)
75+
assert relation is not None
76+
77+
# Step 5: Attempt to remove the project
78+
# This should work with proper cascade delete, or fail with foreign key constraint
79+
await project_service.remove_project(test_project_name)
80+
81+
# Step 6: Verify everything was properly deleted
82+
83+
# Project should be gone
84+
removed_project = await project_service.get_project(test_project_name)
85+
assert removed_project is None, "Project should have been removed"
86+
87+
# Related entities should be cascade deleted
88+
remaining_entity = await entity_repo.find_by_id(entity.id)
89+
assert remaining_entity is None, "Entity should have been cascade deleted"
90+
91+
# Observations should be cascade deleted
92+
remaining_obs = await obs_repo.find_by_id(observation.id)
93+
assert remaining_obs is None, "Observation should have been cascade deleted"
94+
95+
# Relations should be cascade deleted
96+
remaining_rel = await rel_repo.find_by_id(relation.id)
97+
assert remaining_rel is None, "Relation should have been cascade deleted"
98+
99+
except Exception as e:
100+
# Check if this is the specific foreign key constraint error from the bug report
101+
if "FOREIGN KEY constraint failed" in str(e):
102+
pytest.fail(
103+
f"Bug #254 reproduced: {e}. "
104+
"This indicates missing foreign key constraints with CASCADE DELETE. "
105+
"Run migration a1b2c3d4e5f6_fix_project_foreign_keys.py to fix this."
106+
)
107+
else:
108+
# Re-raise other unexpected errors
109+
raise e
110+
111+
finally:
112+
# Clean up - remove project if it still exists
113+
if test_project_name in project_service.projects:
114+
try:
115+
await project_service.remove_project(test_project_name)
116+
except Exception:
117+
# Manual cleanup if remove_project fails
118+
try:
119+
project_service.config_manager.remove_project(test_project_name)
120+
except Exception:
121+
pass
122+
123+
project = await project_service.get_project(test_project_name)
124+
if project:
125+
await project_service.repository.delete(project.id)

0 commit comments

Comments
 (0)