Skip to content

Commit c7835a9

Browse files
phernandezclaude
andauthored
fix: ensure external_id is set on entity creation (#512) (#513)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 85835ae commit c7835a9

4 files changed

Lines changed: 176 additions & 0 deletions

File tree

src/basic_memory/api/routers/resource_router.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Routes for getting entity content."""
22

33
import tempfile
4+
import uuid
45
from pathlib import Path
56
from typing import Annotated, Union
67

@@ -218,7 +219,9 @@ async def write_resource(
218219
status_code = 200
219220
else:
220221
# Create a new entity model
222+
# Explicitly set external_id to ensure NOT NULL constraint is satisfied (fixes #512)
221223
entity = EntityModel(
224+
external_id=str(uuid.uuid4()),
222225
title=file_name,
223226
entity_type=entity_type,
224227
content_type=content_type,

src/basic_memory/api/v2/routers/resource_router.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- More RESTful: POST for create, PUT for update, GET for read
1010
"""
1111

12+
import uuid
1213
from pathlib import Path as PathLib
1314

1415
from fastapi import APIRouter, HTTPException, Response, Path
@@ -147,7 +148,9 @@ async def create_resource(
147148
entity_type = "canvas" if data.file_path.endswith(".canvas") else "file"
148149

149150
# Create a new entity model
151+
# Explicitly set external_id to ensure NOT NULL constraint is satisfied (fixes #512)
150152
entity = EntityModel(
153+
external_id=str(uuid.uuid4()),
151154
title=file_name,
152155
entity_type=entity_type,
153156
content_type=content_type,

src/basic_memory/markdown/utils.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Utilities for converting between markdown and entity models."""
22

3+
import uuid
34
from pathlib import Path
45
from typing import Any, Optional
56

@@ -40,6 +41,12 @@ def entity_model_from_markdown(
4041
# Create or update entity
4142
model = entity or Entity()
4243

44+
# Ensure external_id is set for new entities
45+
# SQLAlchemy's Python-side default may not always evaluate,
46+
# so we explicitly set it here for reliability (fixes #512)
47+
if not model.external_id:
48+
model.external_id = str(uuid.uuid4())
49+
4350
# Update basic fields
4451
model.title = markdown.frontmatter.title
4552
model.entity_type = markdown.frontmatter.type
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""Tests for markdown/utils.py - entity model conversion utilities."""
2+
3+
from datetime import datetime, timezone
4+
from pathlib import Path
5+
6+
import pytest
7+
8+
from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter, Observation
9+
from basic_memory.markdown.utils import entity_model_from_markdown
10+
from basic_memory.models import Entity
11+
12+
13+
class TestEntityModelFromMarkdown:
14+
"""Tests for entity_model_from_markdown function."""
15+
16+
def _create_markdown(
17+
self,
18+
title: str = "Test Entity",
19+
entity_type: str = "note",
20+
permalink: str = "test/test-entity",
21+
created: datetime | None = None,
22+
modified: datetime | None = None,
23+
observations: list[Observation] | None = None,
24+
) -> EntityMarkdown:
25+
"""Helper to create test EntityMarkdown objects."""
26+
now = datetime.now(timezone.utc)
27+
return EntityMarkdown(
28+
frontmatter=EntityFrontmatter(
29+
title=title,
30+
type=entity_type,
31+
permalink=permalink,
32+
),
33+
content=f"# {title}\n\nTest content.",
34+
observations=observations or [],
35+
relations=[],
36+
created=created or now,
37+
modified=modified or now,
38+
)
39+
40+
def test_new_entity_has_external_id(self):
41+
"""Test that a new entity always gets an external_id set.
42+
43+
This is a regression test for GitHub issue #512 where SQLite failed
44+
with NOT NULL constraint on external_id because SQLAlchemy's Python-side
45+
default wasn't always evaluated.
46+
"""
47+
markdown = self._create_markdown()
48+
file_path = Path("test/test-entity.md")
49+
50+
entity = entity_model_from_markdown(file_path, markdown)
51+
52+
# external_id must be set (non-None, non-empty)
53+
assert entity.external_id is not None
54+
assert entity.external_id != ""
55+
# Should be a valid UUID format (36 chars with hyphens)
56+
assert len(entity.external_id) == 36
57+
assert entity.external_id.count("-") == 4
58+
59+
def test_existing_entity_preserves_external_id(self):
60+
"""Test that an existing entity's external_id is preserved."""
61+
markdown = self._create_markdown()
62+
file_path = Path("test/test-entity.md")
63+
64+
# Create existing entity with known external_id
65+
existing_external_id = "12345678-1234-1234-1234-123456789012"
66+
existing_entity = Entity()
67+
existing_entity.external_id = existing_external_id
68+
69+
entity = entity_model_from_markdown(file_path, markdown, entity=existing_entity)
70+
71+
# Should preserve the existing external_id
72+
assert entity.external_id == existing_external_id
73+
74+
def test_entity_with_empty_external_id_gets_new_one(self):
75+
"""Test that an entity with empty string external_id gets a new UUID."""
76+
markdown = self._create_markdown()
77+
file_path = Path("test/test-entity.md")
78+
79+
# Create existing entity with empty external_id
80+
existing_entity = Entity()
81+
existing_entity.external_id = ""
82+
83+
entity = entity_model_from_markdown(file_path, markdown, entity=existing_entity)
84+
85+
# Should have a new external_id
86+
assert entity.external_id is not None
87+
assert entity.external_id != ""
88+
assert len(entity.external_id) == 36
89+
90+
def test_entity_with_none_external_id_gets_new_one(self):
91+
"""Test that an entity with None external_id gets a new UUID."""
92+
markdown = self._create_markdown()
93+
file_path = Path("test/test-entity.md")
94+
95+
# Create existing entity with None external_id
96+
existing_entity = Entity()
97+
# Explicitly set to None to test this case
98+
object.__setattr__(existing_entity, "external_id", None)
99+
100+
entity = entity_model_from_markdown(file_path, markdown, entity=existing_entity)
101+
102+
# Should have a new external_id
103+
assert entity.external_id is not None
104+
assert entity.external_id != ""
105+
assert len(entity.external_id) == 36
106+
107+
def test_multiple_calls_generate_unique_ids(self):
108+
"""Test that multiple new entities get unique external_ids."""
109+
markdown1 = self._create_markdown(title="Entity 1", permalink="test/entity-1")
110+
markdown2 = self._create_markdown(title="Entity 2", permalink="test/entity-2")
111+
112+
entity1 = entity_model_from_markdown(Path("test/entity-1.md"), markdown1)
113+
entity2 = entity_model_from_markdown(Path("test/entity-2.md"), markdown2)
114+
115+
# Both should have external_ids
116+
assert entity1.external_id is not None
117+
assert entity2.external_id is not None
118+
119+
# They should be unique
120+
assert entity1.external_id != entity2.external_id
121+
122+
def test_entity_model_fields_populated_with_external_id(self):
123+
"""Test that entity fields are populated correctly, including external_id.
124+
125+
This is a basic sanity check that entity_model_from_markdown sets
126+
the key fields we care about for the #512 fix.
127+
"""
128+
markdown = self._create_markdown(
129+
title="My Test Entity",
130+
entity_type="component",
131+
permalink="components/my-test",
132+
)
133+
file_path = Path("components/my-test.md")
134+
135+
entity = entity_model_from_markdown(file_path, markdown, project_id=1)
136+
137+
# The key assertion: external_id must always be set
138+
assert entity.external_id is not None
139+
assert len(entity.external_id) == 36 # UUID format
140+
141+
# Verify file_path is set correctly (uses posix format)
142+
assert entity.file_path == "components/my-test.md"
143+
144+
# Timestamps should be set from markdown
145+
assert entity.created_at is not None
146+
assert entity.updated_at is not None
147+
148+
def test_missing_dates_raises_error(self):
149+
"""Test that missing created/modified dates raise ValueError."""
150+
markdown = EntityMarkdown(
151+
frontmatter=EntityFrontmatter(
152+
title="Test",
153+
type="note",
154+
),
155+
content="# Test",
156+
observations=[],
157+
relations=[],
158+
created=None,
159+
modified=None,
160+
)
161+
162+
with pytest.raises(ValueError, match="Both created and modified dates are required"):
163+
entity_model_from_markdown(Path("test.md"), markdown)

0 commit comments

Comments
 (0)