From 1f64cabacc847ba1836670b038b3fc8877c1f8ab Mon Sep 17 00:00:00 2001 From: gersmann Date: Wed, 30 Jul 2025 21:57:40 +0200 Subject: [PATCH 1/4] feat: support native python types for operators --- mgqpy/__init__.py | 2 +- mgqpy/operators/eq_ne_not.py | 10 ++-- mgqpy/operators/gt.py | 10 +++- mgqpy/operators/gte.py | 9 ++++ mgqpy/operators/lt.py | 9 ++++ mgqpy/operators/lte.py | 9 ++++ mgqpy/utils.py | 77 +++++++++++++++++++++++++++ tests/test_python_types.py | 100 +++++++++++++++++++++++++++++++++++ 8 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 mgqpy/utils.py create mode 100644 tests/test_python_types.py 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..818fdb9 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 @@ -9,11 +11,13 @@ def _match_eq(doc, path: List[str], ov) -> bool: if isinstance(doc, list) and any([_match_eq(d, path, ov) for d in doc]): return True + # Regex special-case takes precedence if isinstance(ov, re.Pattern) and isinstance(doc, str): - if ov.search(doc): - return True + return bool(ov.search(doc)) - return doc == ov + # Generic equality with type coercion + doc_coerced, ov_coerced = coerce(doc, ov) + return doc_coerced == ov_coerced key = path[0] rest = path[1:] diff --git a/mgqpy/operators/gt.py b/mgqpy/operators/gt.py index 0f0db14..dbbf150 100644 --- a/mgqpy/operators/gt.py +++ b/mgqpy/operators/gt.py @@ -3,12 +3,16 @@ 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: if isinstance(doc, list) and any([_match_gt(d, path, ov) for d in doc]): return True + doc, ov = coerce(doc, ov) + if isinstance(doc, list) and isinstance(ov, list): if doc > ov: return True @@ -35,7 +39,11 @@ def _match_gt(doc, path: List[str], ov) -> bool: if isinstance(doc, str) and isinstance(ov, str): return operator.gt(doc, ov) - return False + # Fallback to rich comparison if possible (e.g. datetime) + try: + return operator.gt(doc, ov) + except Exception: + return False key = path[0] rest = path[1:] diff --git a/mgqpy/operators/gte.py b/mgqpy/operators/gte.py index 2876e3c..86d639e 100644 --- a/mgqpy/operators/gte.py +++ b/mgqpy/operators/gte.py @@ -3,12 +3,16 @@ 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: if isinstance(doc, list) and any([_match_gte(d, path, ov) for d in doc]): return True + doc, ov = coerce(doc, ov) + if isinstance(doc, list) and isinstance(ov, list): if doc >= ov: return True @@ -38,6 +42,11 @@ def _match_gte(doc, path: List[str], ov) -> bool: if isinstance(doc, str) and isinstance(ov, str): return operator.ge(doc, ov) + try: + return operator.ge(doc, ov) + except Exception: + pass + if doc is None and ov is None: return True diff --git a/mgqpy/operators/lt.py b/mgqpy/operators/lt.py index a613348..432ce0d 100644 --- a/mgqpy/operators/lt.py +++ b/mgqpy/operators/lt.py @@ -3,12 +3,16 @@ 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: if isinstance(doc, list) and any([_match_lt(d, path, ov) for d in doc]): return True + doc, ov = coerce(doc, ov) + if isinstance(doc, list) and isinstance(ov, list): if doc < ov: return True @@ -38,6 +42,11 @@ def _match_lt(doc, path: List[str], ov) -> bool: if isinstance(doc, str) and isinstance(ov, str): return operator.lt(doc, ov) + try: + return operator.lt(doc, ov) + except Exception: + pass + return False key = path[0] diff --git a/mgqpy/operators/lte.py b/mgqpy/operators/lte.py index 6f8032e..a5bcdba 100644 --- a/mgqpy/operators/lte.py +++ b/mgqpy/operators/lte.py @@ -3,12 +3,16 @@ 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: if isinstance(doc, list) and any([_match_lte(d, path, ov) for d in doc]): return True + doc, ov = coerce(doc, ov) + if isinstance(doc, list) and isinstance(ov, list): if doc <= ov: return True @@ -38,6 +42,11 @@ def _match_lte(doc, path: List[str], ov) -> bool: if isinstance(doc, str) and isinstance(ov, str): return operator.le(doc, ov) + try: + return doc <= ov + except Exception: + pass + if doc is None and ov is None: return True diff --git a/mgqpy/utils.py b/mgqpy/utils.py new file mode 100644 index 0000000..3ca11c7 --- /dev/null +++ b/mgqpy/utils.py @@ -0,0 +1,77 @@ +"""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. + """ + + # Datetime ↔︎ ISO 8601 string + def _parse_date(s: str, target): + 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 + + # If one side is date/datetime and the other a string → parse. + if isinstance(a, (datetime.date, datetime.datetime)) and isinstance(b, str): + b = _parse_date(b, type(a)) + return a, b + + if isinstance(b, (datetime.date, datetime.datetime)) and isinstance(a, str): + a = _parse_date(a, type(b)) + return a, b + + # Only attempt Decimal coercion when at least *one* operand is already a + # Decimal instance. + if isinstance(a, decimal.Decimal) or isinstance(b, decimal.Decimal): + a = a if isinstance(a, decimal.Decimal) else _try_decimal(a)[1] + b = b if isinstance(b, decimal.Decimal) else _try_decimal(b)[1] + return a, b + + # UUID ↔︎ string + def _to_uuid(val: object): + if isinstance(val, uuid.UUID): + return val + if isinstance(val, str): + try: + return uuid.UUID(val) + except ValueError: + return val + return val + + if isinstance(a, uuid.UUID) ^ isinstance(b, uuid.UUID): + return _to_uuid(a), _to_uuid(b) + + # If no rule matched – leave untouched + return a, b + + +# Decimal ↔︎ number / numeric-string +def _try_decimal(val: object): + """Attempt to convert *val* to Decimal; returns (success, value).""" + if isinstance(val, decimal.Decimal): + return True, val + if isinstance(val, (int, float, str)) and not isinstance(val, bool): + try: + return True, decimal.Decimal(str(val)) + except (decimal.InvalidOperation, ValueError): + pass + return False, 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..71bd5f6 --- /dev/null +++ b/tests/test_python_types.py @@ -0,0 +1,100 @@ +"""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.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( + "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, + ), + ], +) +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.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), + ], +) +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), + ], +) +def test_uuid_coercion(doc, query, expected): + assert Query(query).test(doc) is expected From 056a894af7f691646fff70a3e28d9fe6e12a40e7 Mon Sep 17 00:00:00 2001 From: chiawei Date: Sun, 3 Aug 2025 13:53:33 +0200 Subject: [PATCH 2/4] refactor: coercion and comparison in operators --- mgqpy/operators/eq_ne_not.py | 6 ++---- mgqpy/operators/gt.py | 14 ++++---------- mgqpy/operators/gte.py | 13 +++---------- mgqpy/operators/lt.py | 9 +-------- mgqpy/operators/lte.py | 15 ++++----------- 5 files changed, 14 insertions(+), 43 deletions(-) diff --git a/mgqpy/operators/eq_ne_not.py b/mgqpy/operators/eq_ne_not.py index 818fdb9..0161d16 100644 --- a/mgqpy/operators/eq_ne_not.py +++ b/mgqpy/operators/eq_ne_not.py @@ -11,13 +11,11 @@ def _match_eq(doc, path: List[str], ov) -> bool: if isinstance(doc, list) and any([_match_eq(d, path, ov) for d in doc]): return True - # Regex special-case takes precedence if isinstance(ov, re.Pattern) and isinstance(doc, str): return bool(ov.search(doc)) - # Generic equality with type coercion - doc_coerced, ov_coerced = coerce(doc, ov) - return doc_coerced == ov_coerced + doc, ov = coerce(doc, ov) + return doc == ov key = path[0] rest = path[1:] diff --git a/mgqpy/operators/gt.py b/mgqpy/operators/gt.py index dbbf150..def1a2f 100644 --- a/mgqpy/operators/gt.py +++ b/mgqpy/operators/gt.py @@ -11,8 +11,6 @@ def _match_gt(doc, path: List[str], ov) -> bool: if isinstance(doc, list) and any([_match_gt(d, path, ov) for d in doc]): return True - doc, ov = coerce(doc, ov) - if isinstance(doc, list) and isinstance(ov, list): if doc > ov: return True @@ -33,17 +31,13 @@ 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): - return operator.gt(doc, ov) - - # Fallback to rich comparison if possible (e.g. datetime) try: + doc, ov = coerce(doc, ov) return operator.gt(doc, ov) except Exception: - return False + pass + + return False key = path[0] rest = path[1:] diff --git a/mgqpy/operators/gte.py b/mgqpy/operators/gte.py index 86d639e..c898d5f 100644 --- a/mgqpy/operators/gte.py +++ b/mgqpy/operators/gte.py @@ -11,8 +11,6 @@ def _match_gte(doc, path: List[str], ov) -> bool: if isinstance(doc, list) and any([_match_gte(d, path, ov) for d in doc]): return True - doc, ov = coerce(doc, ov) - if isinstance(doc, list) and isinstance(ov, list): if doc >= ov: return True @@ -36,20 +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 - if doc is None and ov is None: - return True - return False key = path[0] diff --git a/mgqpy/operators/lt.py b/mgqpy/operators/lt.py index 432ce0d..102173e 100644 --- a/mgqpy/operators/lt.py +++ b/mgqpy/operators/lt.py @@ -11,8 +11,6 @@ def _match_lt(doc, path: List[str], ov) -> bool: if isinstance(doc, list) and any([_match_lt(d, path, ov) for d in doc]): return True - doc, ov = coerce(doc, ov) - if isinstance(doc, list) and isinstance(ov, list): if doc < ov: return True @@ -36,13 +34,8 @@ 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): - return operator.lt(doc, ov) - try: + doc, ov = coerce(doc, ov) return operator.lt(doc, ov) except Exception: pass diff --git a/mgqpy/operators/lte.py b/mgqpy/operators/lte.py index a5bcdba..5a65423 100644 --- a/mgqpy/operators/lte.py +++ b/mgqpy/operators/lte.py @@ -11,8 +11,6 @@ def _match_lte(doc, path: List[str], ov) -> bool: if isinstance(doc, list) and any([_match_lte(d, path, ov) for d in doc]): return True - doc, ov = coerce(doc, ov) - if isinstance(doc, list) and isinstance(ov, list): if doc <= ov: return True @@ -36,20 +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: - return doc <= ov + doc, ov = coerce(doc, ov) + return operator.le(doc, ov) except Exception: pass - if doc is None and ov is None: - return True - return False key = path[0] From e63e66f91fa193249698d6fd787a36976a417e94 Mon Sep 17 00:00:00 2001 From: chiawei Date: Sun, 3 Aug 2025 13:53:44 +0200 Subject: [PATCH 3/4] refactor: coerce util --- mgqpy/utils.py | 97 ++++++++++++++++++++++++++------------------------ 1 file changed, 51 insertions(+), 46 deletions(-) diff --git a/mgqpy/utils.py b/mgqpy/utils.py index 3ca11c7..b629d7a 100644 --- a/mgqpy/utils.py +++ b/mgqpy/utils.py @@ -9,69 +9,74 @@ 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 + 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. """ - # Datetime ↔︎ ISO 8601 string - def _parse_date(s: str, target): + 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: - if target is datetime.date: - return datetime.date.fromisoformat(s) - return datetime.datetime.fromisoformat(s) + return uuid.UUID(val) except ValueError: - # Not a valid ISO format – leave as string so normal - # comparison semantics apply (and tests expecting a failure - # still pass). - return s - - # If one side is date/datetime and the other a string → parse. - if isinstance(a, (datetime.date, datetime.datetime)) and isinstance(b, str): - b = _parse_date(b, type(a)) - return a, b - - if isinstance(b, (datetime.date, datetime.datetime)) and isinstance(a, str): - a = _parse_date(a, type(b)) - return a, b - - # Only attempt Decimal coercion when at least *one* operand is already a - # Decimal instance. - if isinstance(a, decimal.Decimal) or isinstance(b, decimal.Decimal): - a = a if isinstance(a, decimal.Decimal) else _try_decimal(a)[1] - b = b if isinstance(b, decimal.Decimal) else _try_decimal(b)[1] - return a, b - - # UUID ↔︎ string - def _to_uuid(val: object): - if isinstance(val, uuid.UUID): return val - if isinstance(val, str): - try: - return uuid.UUID(val) - except ValueError: - return val - return val - - if isinstance(a, uuid.UUID) ^ isinstance(b, uuid.UUID): - return _to_uuid(a), _to_uuid(b) - - # If no rule matched – leave untouched - return a, b + return val # Decimal ↔︎ number / numeric-string def _try_decimal(val: object): - """Attempt to convert *val* to Decimal; returns (success, value).""" + """Attempt to convert *val* to Decimal""" if isinstance(val, decimal.Decimal): - return True, val + return val if isinstance(val, (int, float, str)) and not isinstance(val, bool): try: - return True, decimal.Decimal(str(val)) + return decimal.Decimal(str(val)) except (decimal.InvalidOperation, ValueError): pass - return False, val + return val __all__: Sequence[str] = ("coerce",) From 532008f9a0a7fba40401a5dd12faa6b3747b80bd Mon Sep 17 00:00:00 2001 From: chiawei Date: Sun, 3 Aug 2025 13:53:51 +0200 Subject: [PATCH 4/4] test: coverage --- tests/test_python_types.py | 100 ++++++++++++++++++++++++++++--------- tests/test_regex.py | 16 ++++++ 2 files changed, 93 insertions(+), 23 deletions(-) diff --git a/tests/test_python_types.py b/tests/test_python_types.py index 71bd5f6..1a4c56f 100644 --- a/tests/test_python_types.py +++ b/tests/test_python_types.py @@ -19,18 +19,6 @@ from mgqpy import Query -@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( "doc, query, expected", [ @@ -49,13 +37,17 @@ def fruit_basket_dict(): {"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) @@ -63,14 +55,41 @@ def test_datetime_with_timezone(): 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), + ( + {"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): @@ -80,9 +99,31 @@ def test_date_coercion(fruit_basket_dict, query, 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("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): @@ -92,8 +133,21 @@ def test_decimal_coercion(doc, query, expected): @pytest.mark.parametrize( "doc, query, expected", [ - ({"u": _uuid.UUID("0123456789abcdef0123456789abcdef")}, {"u": {"$eq": "0123456789abcdef0123456789abcdef"}}, True), - ({"u": "0123456789abcdef0123456789abcdef"}, {"u": {"$eq": _uuid.UUID("0123456789abcdef0123456789abcdef")}}, True), + ( + {"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): 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", {