Compare commits

...

1 Commits

Author SHA1 Message Date
claude[bot] 8c5795705e fix: intercept note_type in metadata_filters and route to note_types
metadata_filters={"note_type": "note"} returned empty results because
note_type is stored in search_index.metadata (set during indexing), NOT
in entity.entity_metadata which metadata_filters queries.

Add a model_validator to SearchQuery that intercepts note_type from
metadata_filters and moves it to the note_types parameter, which
correctly queries search_index.metadata via json_extract/jsonb_extract.
This fixes both Postgres and SQLite backends with a single schema-level
change and handles string or list values, deduplication, and merging
with existing note_types.

Fixes #642

Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-03-02 16:57:51 +00:00
2 changed files with 79 additions and 1 deletions
+31 -1
View File
@@ -9,7 +9,7 @@ The search system supports three primary modes:
from typing import Optional, List, Union, Any
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, field_validator
from pydantic import BaseModel, field_validator, model_validator
from basic_memory.schemas.base import Permalink
@@ -70,6 +70,36 @@ class SearchQuery(BaseModel):
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS
min_similarity: Optional[float] = None # Per-query override for semantic_min_similarity
@model_validator(mode="after")
def intercept_system_fields_in_metadata_filters(self) -> "SearchQuery":
"""Route system fields from metadata_filters to their dedicated parameters.
`note_type` is stored in search_index.metadata (set during indexing), NOT in
entity.entity_metadata. The metadata_filters parameter queries entity.entity_metadata,
so passing note_type there always returns empty results. Intercept it here and
move it to the note_types parameter which correctly queries search_index.metadata.
"""
if not self.metadata_filters:
return self
note_type_value = self.metadata_filters.pop("note_type", None)
if note_type_value is not None:
# Normalize to list — note_type_value may be a single string or a list
if isinstance(note_type_value, list):
new_note_types = note_type_value
else:
new_note_types = [note_type_value]
# Merge with any existing note_types values
existing = self.note_types or []
self.note_types = existing + [t for t in new_note_types if t not in existing]
# If metadata_filters is now empty, set it to None so no_criteria() works correctly
if not self.metadata_filters:
self.metadata_filters = None
return self
@field_validator("after_date")
@classmethod
def validate_date(cls, v: Optional[Union[datetime, str]]) -> Optional[str]:
+48
View File
@@ -102,6 +102,54 @@ def test_relation_result():
assert result.relation_type == "depends_on"
def test_metadata_filters_note_type_routed_to_note_types():
"""metadata_filters with note_type should be intercepted and routed to note_types.
note_type lives in search_index.metadata, NOT entity.entity_metadata.
Passing it through metadata_filters would query the wrong column and return
empty results. The validator intercepts it and moves it to note_types.
"""
query = SearchQuery(metadata_filters={"note_type": "note"})
assert query.note_types == ["note"]
assert query.metadata_filters is None
def test_metadata_filters_note_type_list_routed_to_note_types():
"""metadata_filters with note_type as list is normalized and merged into note_types."""
query = SearchQuery(metadata_filters={"note_type": ["note", "spec"]})
assert query.note_types == ["note", "spec"]
assert query.metadata_filters is None
def test_metadata_filters_note_type_merges_with_existing_note_types():
"""note_type from metadata_filters is merged with existing note_types, deduped."""
query = SearchQuery(note_types=["spec"], metadata_filters={"note_type": "note"})
assert "spec" in query.note_types
assert "note" in query.note_types
assert query.metadata_filters is None
def test_metadata_filters_note_type_deduplicates():
"""note_type already in note_types is not added twice."""
query = SearchQuery(note_types=["note"], metadata_filters={"note_type": "note"})
assert query.note_types.count("note") == 1
assert query.metadata_filters is None
def test_metadata_filters_non_system_fields_untouched():
"""metadata_filters with non-system fields are passed through unchanged."""
query = SearchQuery(metadata_filters={"status": "active", "priority": "high"})
assert query.metadata_filters == {"status": "active", "priority": "high"}
assert query.note_types is None
def test_metadata_filters_mixed_system_and_user_fields():
"""note_type is extracted; remaining metadata_filters are preserved."""
query = SearchQuery(metadata_filters={"note_type": "spec", "status": "active"})
assert query.note_types == ["spec"]
assert query.metadata_filters == {"status": "active"}
def test_search_response():
"""Test search response wrapper."""
results = [