diff --git a/mgqpy/__init__.py b/mgqpy/__init__.py index d534fe0..86e3271 100644 --- a/mgqpy/__init__.py +++ b/mgqpy/__init__.py @@ -1,6 +1,6 @@ """mongo query as a predicate function""" -__version__ = "0.7.1" +__version__ = "0.7.2" # Terminology # diff --git a/mgqpy/operators/eq_ne_not.py b/mgqpy/operators/eq_ne_not.py index 39b087a..0161d16 100644 --- a/mgqpy/operators/eq_ne_not.py +++ b/mgqpy/operators/eq_ne_not.py @@ -1,6 +1,8 @@ import re from typing import List +from mgqpy.utils import coerce + import mgqpy @@ -10,9 +12,9 @@ def _match_eq(doc, path: List[str], ov) -> bool: return True if isinstance(ov, re.Pattern) and isinstance(doc, str): - if ov.search(doc): - return True + return bool(ov.search(doc)) + doc, ov = coerce(doc, ov) return doc == ov key = path[0] diff --git a/mgqpy/operators/gt.py b/mgqpy/operators/gt.py index 0f0db14..def1a2f 100644 --- a/mgqpy/operators/gt.py +++ b/mgqpy/operators/gt.py @@ -3,6 +3,8 @@ from numbers import Number from typing import List +from mgqpy.utils import coerce + def _match_gt(doc, path: List[str], ov) -> bool: if len(path) == 0: @@ -29,11 +31,11 @@ def _match_gt(doc, path: List[str], ov) -> bool: return False return False - if isinstance(doc, Number) and isinstance(ov, Number): - return operator.gt(doc, ov) # type: ignore - - if isinstance(doc, str) and isinstance(ov, str): + try: + doc, ov = coerce(doc, ov) return operator.gt(doc, ov) + except Exception: + pass return False diff --git a/mgqpy/operators/gte.py b/mgqpy/operators/gte.py index 2876e3c..c898d5f 100644 --- a/mgqpy/operators/gte.py +++ b/mgqpy/operators/gte.py @@ -3,6 +3,8 @@ from numbers import Number from typing import List +from mgqpy.utils import coerce + def _match_gte(doc, path: List[str], ov) -> bool: if len(path) == 0: @@ -32,15 +34,15 @@ def _match_gte(doc, path: List[str], ov) -> bool: return False return True - if isinstance(doc, Number) and isinstance(ov, Number): - return operator.ge(doc, ov) # type: ignore - - if isinstance(doc, str) and isinstance(ov, str): - return operator.ge(doc, ov) - if doc is None and ov is None: return True + try: + doc, ov = coerce(doc, ov) + return operator.ge(doc, ov) + except Exception: + pass + return False key = path[0] diff --git a/mgqpy/operators/lt.py b/mgqpy/operators/lt.py index a613348..102173e 100644 --- a/mgqpy/operators/lt.py +++ b/mgqpy/operators/lt.py @@ -3,6 +3,8 @@ from numbers import Number from typing import List +from mgqpy.utils import coerce + def _match_lt(doc, path: List[str], ov) -> bool: if len(path) == 0: @@ -32,11 +34,11 @@ def _match_lt(doc, path: List[str], ov) -> bool: return True return False - if isinstance(doc, Number) and isinstance(ov, Number): - return operator.lt(doc, ov) # type: ignore - - if isinstance(doc, str) and isinstance(ov, str): + try: + doc, ov = coerce(doc, ov) return operator.lt(doc, ov) + except Exception: + pass return False diff --git a/mgqpy/operators/lte.py b/mgqpy/operators/lte.py index 6f8032e..5a65423 100644 --- a/mgqpy/operators/lte.py +++ b/mgqpy/operators/lte.py @@ -3,6 +3,8 @@ from numbers import Number from typing import List +from mgqpy.utils import coerce + def _match_lte(doc, path: List[str], ov) -> bool: if len(path) == 0: @@ -32,15 +34,15 @@ def _match_lte(doc, path: List[str], ov) -> bool: return False return True - if isinstance(doc, Number) and isinstance(ov, Number): - return operator.le(doc, ov) # type: ignore - - if isinstance(doc, str) and isinstance(ov, str): - return operator.le(doc, ov) - if doc is None and ov is None: return True + try: + doc, ov = coerce(doc, ov) + return operator.le(doc, ov) + except Exception: + pass + return False key = path[0] diff --git a/mgqpy/utils.py b/mgqpy/utils.py new file mode 100644 index 0000000..b629d7a --- /dev/null +++ b/mgqpy/utils.py @@ -0,0 +1,82 @@ +"""Utility helpers for mgqpy.""" + +import datetime +import decimal +import uuid +from typing import Any, Sequence + + +def coerce(a: Any, b: Any) -> tuple[Any, Any]: + """Make *a* and *b* directly comparable by aligning their types. + + The coercion rules are intentionally conservative - we only convert + where the *intent* is unambiguous (e.g. ISO-8601 date strings). The + function never raises: if conversion fails we fall back to the + original values so the caller can attempt a normal comparison. + """ + + match (a, b): + # one is date/datetime, other is str + case (datetime.date() | datetime.datetime(), str()): + return a, _try_date(b, type(a)) + case (str(), datetime.date() | datetime.datetime()): + return _try_date(a, type(b)), b + + # one is decimal + case (decimal.Decimal(), _): + return a, _try_decimal(b) + case (_, decimal.Decimal()): + return _try_decimal(a), b + + # one is uuid + case (uuid.UUID(), _): + return a, _try_uuid(b) + case (_, uuid.UUID()): + return _try_uuid(a), b + + # default return original values + case _: + return a, b + + +# Datetime ↔︎ ISO 8601 string +def _try_date(s: str, target): + """Attempt to convert *val* to target date or datetime""" + try: + if target is datetime.date: + return datetime.date.fromisoformat(s) + return datetime.datetime.fromisoformat(s) + except ValueError: + # Not a valid ISO format – leave as string so normal + # comparison semantics apply (and tests expecting a failure + # still pass). + return s + + +# UUID ↔︎ string +def _try_uuid(val: object): + """Attempt to convert *val* to UUID""" + if isinstance(val, uuid.UUID): + return val + if isinstance(val, str): + try: + return uuid.UUID(val) + except ValueError: + return val + return val + + +# Decimal ↔︎ number / numeric-string +def _try_decimal(val: object): + """Attempt to convert *val* to Decimal""" + if isinstance(val, decimal.Decimal): + return val + if isinstance(val, (int, float, str)) and not isinstance(val, bool): + try: + return decimal.Decimal(str(val)) + except (decimal.InvalidOperation, ValueError): + pass + return val + + +__all__: Sequence[str] = ("coerce",) diff --git a/tests/test_python_types.py b/tests/test_python_types.py new file mode 100644 index 0000000..1a4c56f --- /dev/null +++ b/tests/test_python_types.py @@ -0,0 +1,154 @@ +"""Additional tests for python-native types that require coercion. + +These tests cover: +* datetime.date ↔︎ ISO-8601 strings +* decimal.Decimal ↔︎ numbers / numeric strings +* uuid.UUID ↔︎ strings + +The document structures mimic the shape that would result from +`pydantic.BaseModel.model_dump()` without importing pydantic here – the +library should work with plain dict / list inputs provided by the user. +""" + +import datetime as _dt +import decimal as _dec +import uuid as _uuid + +import pytest + +from mgqpy import Query + + +@pytest.mark.parametrize( + "doc, query, expected", + [ + ( + {"ts": _dt.datetime(2025, 7, 31, 12, 0, 0)}, + {"ts": {"$eq": "2025-07-31T12:00:00"}}, + True, + ), + ( + {"ts": _dt.datetime(2025, 7, 31, 12, 0, 0)}, + {"ts": {"$gt": "2025-07-30T23:59:59"}}, + True, + ), + ( + {"ts": "2025-07-31T12:00:00"}, + {"ts": {"$lt": _dt.datetime(2025, 8, 1)}}, + True, + ), + ( + {"ts": "asdf"}, + {"ts": {"$eq": _dt.datetime(2025, 8, 1)}}, + False, + ), + ], +) +def test_datetime_coercion(doc, query, expected): + assert Query(query).test(doc) is expected + + +def test_datetime_with_timezone(): + aware = _dt.datetime(2025, 7, 31, 10, 0, 0, tzinfo=_dt.timezone.utc) + + assert Query({"ts": {"$eq": "2025-07-31T10:00:00+00:00"}}).test({"ts": aware}) + assert Query({"ts": {"$eq": aware}}).test({"ts": "2025-07-31T10:00:00+00:00"}) + + +@pytest.fixture() +def fruit_basket_dict(): + """Dictionary shaped like the output of a dumped Pydantic model.""" + + return { + "fruits": [ + {"type": "berry", "harvested": _dt.date(2024, 8, 1)}, + {"type": "aggregate", "harvested": _dt.date(2024, 8, 2)}, + ] + } + + +@pytest.mark.parametrize( + "query, expected", + [ + ( + {"fruits.0.harvested": {"$eq": "2024-08-01"}}, + True, + ), + ( + {"fruits.1.type": {"$eq": "aggregate"}}, + True, + ), + ( + {"fruits.harvested": {"$in": ["2024-08-02"]}}, + True, + ), + ( + {"fruits.0.harvested": {"$gt": "2024-07-31"}}, + True, + ), + ( + {"fruits.0.harvested": {"$lt": "2024-08-02"}}, + True, + ), + ], +) +def test_date_coercion(fruit_basket_dict, query, expected): + assert Query(query).test(fruit_basket_dict) is expected + + +@pytest.mark.parametrize( + "doc, query, expected", + [ + ( + {"x": _dec.Decimal("3.14")}, + {"x": {"$eq": "3.14"}}, + True, + ), + ( + {"x": "2.71"}, + {"x": {"$eq": _dec.Decimal("2.71")}}, + True, + ), + ( + {"x": _dec.Decimal("10")}, + {"x": {"$gt": "9"}}, + True, + ), + ( + {"x": _dec.Decimal("10")}, + {"x": {"$gt": _dec.Decimal("9")}}, + True, + ), + ( + {"x": _dec.Decimal("10")}, + {"x": {"$gt": "a"}}, + False, + ), + ], +) +def test_decimal_coercion(doc, query, expected): + assert Query(query).test(doc) is expected + + +@pytest.mark.parametrize( + "doc, query, expected", + [ + ( + {"u": _uuid.UUID("0123456789abcdef0123456789abcdef")}, + {"u": {"$eq": "0123456789abcdef0123456789abcdef"}}, + True, + ), + ( + {"u": "0123456789abcdef0123456789abcdef"}, + {"u": {"$eq": _uuid.UUID("0123456789abcdef0123456789abcdef")}}, + True, + ), + ( + {"u": ["123", None, _uuid.uuid1(), "0123456789abcdef0123456789abcdef"]}, + {"u": {"$eq": _uuid.UUID("0123456789abcdef0123456789abcdef")}}, + True, + ), + ], +) +def test_uuid_coercion(doc, query, expected): + assert Query(query).test(doc) is expected diff --git a/tests/test_regex.py b/tests/test_regex.py index c2a41af..6f9ae77 100644 --- a/tests/test_regex.py +++ b/tests/test_regex.py @@ -161,6 +161,22 @@ {"foo": "BAZ"}, ], ), + ( + "implicit $regex with nested dict/lists", + {"foo.bar": re.compile("^baz", re.IGNORECASE)}, + [ + {"foo": {}}, + {"foo": None}, + {"foo": 1}, + {"foo": "bar"}, + {"foo": {"bar": ["qux", "baz"]}}, + {"foo": [{"bar": ["qux"]}, {"bar": "baz"}]}, + ], + [ + {"foo": {"bar": ["qux", "baz"]}}, + {"foo": [{"bar": ["qux"]}, {"bar": "baz"}]}, + ], + ), ( "$in with implicit $regex", {