fix: use DatetimeRange for datetime string values in Qdrant range filters (#4659)
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
co-authored by
kartik-mem0
parent
6cc1c15320
commit
868b63af63
@@ -1,8 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from qdrant_client import QdrantClient
|
from qdrant_client import QdrantClient
|
||||||
from qdrant_client.models import (
|
from qdrant_client.models import (
|
||||||
|
DatetimeRange,
|
||||||
Distance,
|
Distance,
|
||||||
FieldCondition,
|
FieldCondition,
|
||||||
Filter,
|
Filter,
|
||||||
@@ -139,6 +141,23 @@ class Qdrant(VectorStoreBase):
|
|||||||
]
|
]
|
||||||
self.client.upsert(collection_name=self.collection_name, points=points)
|
self.client.upsert(collection_name=self.collection_name, points=points)
|
||||||
|
|
||||||
|
# ISO 8601 datetime pattern for detecting datetime strings in range filters
|
||||||
|
_ISO_DATETIME_RE = re.compile(
|
||||||
|
r"^\d{4}-\d{2}-\d{2}" # date part
|
||||||
|
r"([T ]\d{2}:\d{2}(:\d{2})?" # optional time part
|
||||||
|
r"(\.\d+)?" # optional fractional seconds
|
||||||
|
r"(Z|[+-]\d{2}:?\d{2})?" # optional timezone
|
||||||
|
r")?$"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_datetime_range(range_kwargs: dict) -> bool:
|
||||||
|
"""Check if all values in range kwargs are ISO datetime strings."""
|
||||||
|
return all(
|
||||||
|
isinstance(v, str) and Qdrant._ISO_DATETIME_RE.match(v)
|
||||||
|
for v in range_kwargs.values()
|
||||||
|
)
|
||||||
|
|
||||||
def _build_field_condition(self, key: str, value) -> Optional[FieldCondition]:
|
def _build_field_condition(self, key: str, value) -> Optional[FieldCondition]:
|
||||||
"""
|
"""
|
||||||
Build a single FieldCondition from a key-value filter pair.
|
Build a single FieldCondition from a key-value filter pair.
|
||||||
@@ -177,6 +196,13 @@ class Qdrant(VectorStoreBase):
|
|||||||
f"Use AND to combine them as separate conditions."
|
f"Use AND to combine them as separate conditions."
|
||||||
)
|
)
|
||||||
range_kwargs = {op: value[op] for op in range_ops if op in value}
|
range_kwargs = {op: value[op] for op in range_ops if op in value}
|
||||||
|
if self._is_datetime_range(range_kwargs):
|
||||||
|
try:
|
||||||
|
return FieldCondition(key=key, range=DatetimeRange(**range_kwargs))
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid datetime value in range filter for field '{key}': {e}"
|
||||||
|
) from e
|
||||||
return FieldCondition(key=key, range=Range(**range_kwargs))
|
return FieldCondition(key=key, range=Range(**range_kwargs))
|
||||||
elif "eq" in value:
|
elif "eq" in value:
|
||||||
return FieldCondition(key=key, match=MatchValue(value=value["eq"]))
|
return FieldCondition(key=key, match=MatchValue(value=value["eq"]))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
from qdrant_client import QdrantClient
|
from qdrant_client import QdrantClient
|
||||||
from qdrant_client.models import (
|
from qdrant_client.models import (
|
||||||
|
DatetimeRange,
|
||||||
Distance,
|
Distance,
|
||||||
FieldCondition,
|
FieldCondition,
|
||||||
Filter,
|
Filter,
|
||||||
@@ -814,3 +815,111 @@ class TestQdrantEnhancedFilters(unittest.TestCase):
|
|||||||
self.assertIsNotNone(result.must_not)
|
self.assertIsNotNone(result.must_not)
|
||||||
# Deduplicated: NOT wins, $not is skipped — exactly 1 entry
|
# Deduplicated: NOT wins, $not is skipped — exactly 1 entry
|
||||||
self.assertEqual(len(result.must_not), 1)
|
self.assertEqual(len(result.must_not), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestQdrantDatetimeRangeFilters(unittest.TestCase):
|
||||||
|
"""Tests for datetime range filter support (issue #4591)."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client_mock = MagicMock(spec=QdrantClient)
|
||||||
|
self.qdrant = Qdrant(
|
||||||
|
collection_name="test_collection",
|
||||||
|
embedding_model_dims=128,
|
||||||
|
client=self.client_mock,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_iso_datetime_gte_lte_uses_datetime_range(self):
|
||||||
|
"""ISO datetime strings in range filters should use DatetimeRange."""
|
||||||
|
cond = self.qdrant._build_field_condition(
|
||||||
|
"created_at", {"gte": "2025-01-01T00:00:00Z", "lte": "2025-12-31T23:59:59Z"}
|
||||||
|
)
|
||||||
|
self.assertIsInstance(cond, FieldCondition)
|
||||||
|
self.assertIsInstance(cond.range, DatetimeRange)
|
||||||
|
self.assertIsNotNone(cond.range.gte)
|
||||||
|
self.assertIsNotNone(cond.range.lte)
|
||||||
|
|
||||||
|
def test_iso_date_only_uses_datetime_range(self):
|
||||||
|
"""Date-only strings (YYYY-MM-DD) should also use DatetimeRange."""
|
||||||
|
cond = self.qdrant._build_field_condition(
|
||||||
|
"created_at", {"gte": "2025-01-01", "lt": "2025-02-01"}
|
||||||
|
)
|
||||||
|
self.assertIsInstance(cond.range, DatetimeRange)
|
||||||
|
|
||||||
|
def test_iso_datetime_with_offset_uses_datetime_range(self):
|
||||||
|
"""Datetime with timezone offset should use DatetimeRange."""
|
||||||
|
cond = self.qdrant._build_field_condition(
|
||||||
|
"updated_at", {"gt": "2025-06-15T10:30:00+05:30"}
|
||||||
|
)
|
||||||
|
self.assertIsInstance(cond.range, DatetimeRange)
|
||||||
|
|
||||||
|
def test_numeric_range_still_uses_range(self):
|
||||||
|
"""Numeric values should still use Range (not DatetimeRange)."""
|
||||||
|
cond = self.qdrant._build_field_condition(
|
||||||
|
"priority", {"gte": 5, "lte": 10}
|
||||||
|
)
|
||||||
|
self.assertIsInstance(cond.range, Range)
|
||||||
|
self.assertEqual(cond.range.gte, 5)
|
||||||
|
self.assertEqual(cond.range.lte, 10)
|
||||||
|
|
||||||
|
def test_float_range_still_uses_range(self):
|
||||||
|
"""Float values should still use Range."""
|
||||||
|
cond = self.qdrant._build_field_condition(
|
||||||
|
"score", {"gt": 0.5, "lt": 0.9}
|
||||||
|
)
|
||||||
|
self.assertIsInstance(cond.range, Range)
|
||||||
|
|
||||||
|
def test_datetime_range_via_create_filter(self):
|
||||||
|
"""DatetimeRange should work through _create_filter."""
|
||||||
|
result = self.qdrant._create_filter(
|
||||||
|
{"created_at": {"gte": "2025-01-01T00:00:00Z", "lte": "2025-12-31T23:59:59Z"}}
|
||||||
|
)
|
||||||
|
self.assertIsInstance(result, Filter)
|
||||||
|
self.assertEqual(len(result.must), 1)
|
||||||
|
self.assertIsInstance(result.must[0].range, DatetimeRange)
|
||||||
|
|
||||||
|
def test_malformed_datetime_raises_with_field_context(self):
|
||||||
|
"""Malformed date-like string should raise ValueError with field name."""
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.qdrant._build_field_condition(
|
||||||
|
"created_at", {"gte": "2025-13-45"}
|
||||||
|
)
|
||||||
|
self.assertIn("created_at", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_mixed_datetime_and_numeric_raises_error(self):
|
||||||
|
"""Mixed datetime string + numeric value in same range should raise an error.
|
||||||
|
|
||||||
|
When not all values are datetime strings, _is_datetime_range returns False
|
||||||
|
and Range receives a string, causing a Pydantic ValidationError.
|
||||||
|
"""
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.qdrant._build_field_condition(
|
||||||
|
"field", {"gte": "2025-01-01", "lte": 100}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_iso_datetime_with_fractional_seconds(self):
|
||||||
|
"""Fractional seconds should use DatetimeRange."""
|
||||||
|
cond = self.qdrant._build_field_condition(
|
||||||
|
"created_at", {"gte": "2025-01-01T00:00:00.123456Z"}
|
||||||
|
)
|
||||||
|
self.assertIsInstance(cond.range, DatetimeRange)
|
||||||
|
|
||||||
|
def test_iso_datetime_space_separated(self):
|
||||||
|
"""Space-separated datetime should use DatetimeRange."""
|
||||||
|
cond = self.qdrant._build_field_condition(
|
||||||
|
"created_at", {"gte": "2025-01-01 10:30:00"}
|
||||||
|
)
|
||||||
|
self.assertIsInstance(cond.range, DatetimeRange)
|
||||||
|
|
||||||
|
def test_datetime_with_numeric_mixed_filters(self):
|
||||||
|
"""Datetime and numeric range filters can coexist in same query."""
|
||||||
|
result = self.qdrant._create_filter({
|
||||||
|
"created_at": {"gte": "2025-01-01"},
|
||||||
|
"priority": {"gte": 5},
|
||||||
|
})
|
||||||
|
self.assertIsInstance(result, Filter)
|
||||||
|
self.assertEqual(len(result.must), 2)
|
||||||
|
types = {type(c.range) for c in result.must}
|
||||||
|
self.assertIn(DatetimeRange, types)
|
||||||
|
self.assertIn(Range, types)
|
||||||
|
|||||||
Reference in New Issue
Block a user