Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion mgqpy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""mongo query as a predicate function"""

__version__ = "0.7.1"
__version__ = "0.7.2"

# Terminology
#
Expand Down
6 changes: 4 additions & 2 deletions mgqpy/operators/eq_ne_not.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import re
from typing import List

from mgqpy.utils import coerce

import mgqpy


Expand All @@ -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]
Expand Down
10 changes: 6 additions & 4 deletions mgqpy/operators/gt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
14 changes: 8 additions & 6 deletions mgqpy/operators/gte.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]
Expand Down
10 changes: 6 additions & 4 deletions mgqpy/operators/lt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
14 changes: 8 additions & 6 deletions mgqpy/operators/lte.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]
Expand Down
82 changes: 82 additions & 0 deletions mgqpy/utils.py
Original file line number Diff line number Diff line change
@@ -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",)
154 changes: 154 additions & 0 deletions tests/test_python_types.py
Original file line number Diff line number Diff line change
@@ -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
Loading