|
14 | 14 | from datetime import datetime |
15 | 15 | from typing import List, Optional, Dict |
16 | 16 |
|
17 | | -from pydantic import BaseModel, ConfigDict, Field, AliasPath, AliasChoices |
| 17 | +from pydantic import BaseModel, ConfigDict, Field, model_validator |
18 | 18 |
|
19 | 19 | from basic_memory.schemas.base import Relation, Permalink, EntityType, ContentType, Observation |
20 | 20 |
|
@@ -64,32 +64,89 @@ class RelationResponse(Relation, SQLAlchemyModel): |
64 | 64 |
|
65 | 65 | permalink: Permalink |
66 | 66 |
|
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 |
93 | 150 |
|
94 | 151 |
|
95 | 152 | class EntityResponse(SQLAlchemyModel): |
|
0 commit comments