diff --git a/mem0/vector_stores/qdrant.py b/mem0/vector_stores/qdrant.py index 06ab7edae..3241722de 100644 --- a/mem0/vector_stores/qdrant.py +++ b/mem0/vector_stores/qdrant.py @@ -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"])) diff --git a/tests/vector_stores/test_qdrant.py b/tests/vector_stores/test_qdrant.py index 7e08e515d..8cca2eb7f 100644 --- a/tests/vector_stores/test_qdrant.py +++ b/tests/vector_stores/test_qdrant.py @@ -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)