Skip to content

Commit 537e58a

Browse files
phernandezgithub-actions[bot]claude
authored
fix: make RelationResponse.from_id optional to handle null permalinks (#484)
Signed-off-by: phernandez <[email protected]> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent 48e6e84 commit 537e58a

File tree

2 files changed

+123
-27
lines changed

2 files changed

+123
-27
lines changed

src/basic_memory/schemas/response.py

Lines changed: 84 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from datetime import datetime
1515
from typing import List, Optional, Dict
1616

17-
from pydantic import BaseModel, ConfigDict, Field, AliasPath, AliasChoices
17+
from pydantic import BaseModel, ConfigDict, Field, model_validator
1818

1919
from basic_memory.schemas.base import Relation, Permalink, EntityType, ContentType, Observation
2020

@@ -64,32 +64,89 @@ class RelationResponse(Relation, SQLAlchemyModel):
6464

6565
permalink: Permalink
6666

67-
from_id: Permalink = Field(
68-
# use the permalink from the associated Entity
69-
# or the from_id value
70-
validation_alias=AliasChoices(
71-
AliasPath("from_entity", "permalink"),
72-
"from_id",
73-
)
74-
)
75-
to_id: Optional[Permalink] = Field( # pyright: ignore
76-
# use the permalink from the associated Entity
77-
# or the to_id value
78-
validation_alias=AliasChoices(
79-
AliasPath("to_entity", "permalink"),
80-
"to_id",
81-
),
82-
default=None,
83-
)
84-
to_name: Optional[Permalink] = Field(
85-
# use the permalink from the associated Entity
86-
# or the to_id value
87-
validation_alias=AliasChoices(
88-
AliasPath("to_entity", "title"),
89-
"to_name",
90-
),
91-
default=None,
92-
)
67+
# Override base Relation fields to allow Optional values
68+
from_id: Optional[Permalink] = Field(default=None) # pyright: ignore[reportIncompatibleVariableOverride]
69+
to_id: Optional[Permalink] = Field(default=None) # pyright: ignore[reportIncompatibleVariableOverride]
70+
to_name: Optional[str] = Field(default=None)
71+
72+
@model_validator(mode="before")
73+
@classmethod
74+
def resolve_entity_references(cls, data):
75+
"""Resolve from_id and to_id from joined entities, falling back to file_path.
76+
77+
When loading from SQLAlchemy models, the from_entity and to_entity relationships
78+
are joined. We extract the permalink from these entities, falling back to
79+
file_path when permalink is None.
80+
81+
We use file_path directly (not converted to permalink format) because if the
82+
entity doesn't have a permalink, the system won't be able to find it by a
83+
generated one anyway. Using the actual file_path preserves the real identifier.
84+
"""
85+
# Handle dict input (e.g., from API or tests)
86+
if isinstance(data, dict):
87+
from_entity = data.get("from_entity")
88+
to_entity = data.get("to_entity")
89+
90+
# Resolve from_id: prefer permalink, fall back to file_path
91+
if from_entity and isinstance(from_entity, dict):
92+
permalink = from_entity.get("permalink")
93+
if permalink:
94+
data["from_id"] = permalink
95+
elif from_entity.get("file_path"):
96+
data["from_id"] = from_entity["file_path"]
97+
98+
# Resolve to_id: prefer permalink, fall back to file_path
99+
if to_entity and isinstance(to_entity, dict):
100+
permalink = to_entity.get("permalink")
101+
if permalink:
102+
data["to_id"] = permalink
103+
elif to_entity.get("file_path"):
104+
data["to_id"] = to_entity["file_path"]
105+
106+
# Also resolve to_name from entity title
107+
if to_entity.get("title") and not data.get("to_name"):
108+
data["to_name"] = to_entity["title"]
109+
110+
return data
111+
112+
# Handle SQLAlchemy model input (from_attributes=True)
113+
# Access attributes directly from the ORM model
114+
from_entity = getattr(data, "from_entity", None)
115+
to_entity = getattr(data, "to_entity", None)
116+
117+
# Build a dict from the model's attributes
118+
result = {}
119+
120+
# Copy base fields
121+
for field in ["permalink", "relation_type", "context", "to_name"]:
122+
if hasattr(data, field):
123+
result[field] = getattr(data, field)
124+
125+
# Resolve from_id: prefer permalink, fall back to file_path
126+
if from_entity:
127+
permalink = getattr(from_entity, "permalink", None)
128+
file_path = getattr(from_entity, "file_path", None)
129+
if permalink:
130+
result["from_id"] = permalink
131+
elif file_path:
132+
result["from_id"] = file_path
133+
134+
# Resolve to_id: prefer permalink, fall back to file_path
135+
if to_entity:
136+
permalink = getattr(to_entity, "permalink", None)
137+
file_path = getattr(to_entity, "file_path", None)
138+
if permalink:
139+
result["to_id"] = permalink
140+
elif file_path:
141+
result["to_id"] = file_path
142+
143+
# Also resolve to_name from entity title if not set
144+
if not result.get("to_name"):
145+
title = getattr(to_entity, "title", None)
146+
if title:
147+
result["to_name"] = title
148+
149+
return result
93150

94151

95152
class EntityResponse(SQLAlchemyModel):

tests/schemas/test_schemas.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,45 @@ def test_relation_response():
9191
assert relation.context is None
9292

9393

94+
def test_relation_response_with_null_permalink():
95+
"""Test RelationResponse handles null permalinks by falling back to file_path (fixes issue #483).
96+
97+
When entities are imported from environments without permalinks enabled,
98+
the from_entity.permalink and to_entity.permalink can be None.
99+
In this case, we fall back to file_path to ensure the API always returns
100+
a usable identifier for the related entities.
101+
102+
We use file_path directly (not converted to permalink format) because if the
103+
entity doesn't have a permalink, the system won't find it by a generated one.
104+
"""
105+
data = {
106+
"permalink": "test/relation/123",
107+
"relation_type": "relates_to",
108+
"from_entity": {"permalink": None, "file_path": "notes/source-note.md"},
109+
"to_entity": {"permalink": None, "file_path": "notes/target-note.md", "title": "Target Note"},
110+
}
111+
relation = RelationResponse.model_validate(data)
112+
# Falls back to file_path directly (not converted to permalink)
113+
assert relation.from_id == "notes/source-note.md"
114+
assert relation.to_id == "notes/target-note.md"
115+
assert relation.to_name == "Target Note"
116+
assert relation.relation_type == "relates_to"
117+
118+
119+
def test_relation_response_with_permalink_preferred_over_file_path():
120+
"""Test that permalink is preferred over file_path when both are available."""
121+
data = {
122+
"permalink": "test/relation/123",
123+
"relation_type": "links_to",
124+
"from_entity": {"permalink": "from-permalink", "file_path": "notes/from-file.md"},
125+
"to_entity": {"permalink": "to-permalink", "file_path": "notes/to-file.md"},
126+
}
127+
relation = RelationResponse.model_validate(data)
128+
# Prefers permalink over file_path
129+
assert relation.from_id == "from-permalink"
130+
assert relation.to_id == "to-permalink"
131+
132+
94133
def test_entity_out_from_attributes():
95134
"""Test EntityOut creation from database model attributes."""
96135
# Simulate database model attributes

0 commit comments

Comments
 (0)