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:
committed by
GitHub
parent
6cc1c15320
commit
868b63af63
@@ -1,8 +1,10 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.models import (
|
||||
DatetimeRange,
|
||||
Distance,
|
||||
FieldCondition,
|
||||
Filter,
|
||||
@@ -139,6 +141,23 @@ class Qdrant(VectorStoreBase):
|
||||
]
|
||||
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]:
|
||||
"""
|
||||
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."
|
||||
)
|
||||
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))
|
||||
elif "eq" in value:
|
||||
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.models import (
|
||||
DatetimeRange,
|
||||
Distance,
|
||||
FieldCondition,
|
||||
Filter,
|
||||
@@ -814,3 +815,111 @@ class TestQdrantEnhancedFilters(unittest.TestCase):
|
||||
self.assertIsNotNone(result.must_not)
|
||||
# Deduplicated: NOT wins, $not is skipped — exactly 1 entry
|
||||
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