Skip to content

Commit 6bb86bc

Browse files
committed
Fix bugs #108, #254, #499 for 1.0 release
- #108: Extract .value from Enum objects in NUMERIC queries - #499: Add IN/NOT_IN operator handling for NUMERIC fields - #254: Convert empty strings back to None for Optional fields in HashModel.get()
1 parent e080e06 commit 6bb86bc

26 files changed

Lines changed: 597 additions & 96 deletions

aredis_om/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
)
2020
from .model.types import Coordinates, GeoFilter
2121

22-
2322
# Backward compatibility alias - deprecated, use SchemaDetector or SchemaMigrator
2423
Migrator = SchemaDetector
2524

aredis_om/connections.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from . import redis
44

5-
65
URL = os.environ.get("REDIS_OM_URL", None)
76

87

aredis_om/model/encoders.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333

3434
from pydantic import BaseModel
3535

36-
3736
try:
3837
from pydantic.deprecated.json import ENCODERS_BY_TYPE
3938
from pydantic_core import PydanticUndefined
@@ -52,7 +51,7 @@
5251

5352

5453
def generate_encoders_by_class_tuples(
55-
type_encoder_map: Dict[Any, Callable[[Any], Any]]
54+
type_encoder_map: Dict[Any, Callable[[Any], Any]],
5655
) -> Dict[Callable[[Any], Any], Tuple[Any, ...]]:
5756
encoders_by_class_tuples: Dict[Callable[[Any], Any], Tuple[Any, ...]] = defaultdict(
5857
tuple

aredis_om/model/migrations/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
SchemaMigrator,
1919
)
2020

21-
2221
__all__ = [
2322
# Data migrations
2423
"BaseMigration",

aredis_om/model/migrations/data/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,4 @@
88
from .base import BaseMigration, DataMigrationError
99
from .migrator import DataMigrator
1010

11-
1211
__all__ = ["BaseMigration", "DataMigrationError", "DataMigrator"]

aredis_om/model/migrations/data/base.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import time
1010
from typing import Any, Dict, List
1111

12-
1312
try:
1413
import psutil
1514
except ImportError:

aredis_om/model/migrations/data/builtin/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,4 @@
1111
DatetimeFieldMigration,
1212
)
1313

14-
1514
__all__ = ["DatetimeFieldMigration", "DatetimeFieldDetector", "ConversionFailureMode"]

aredis_om/model/migrations/data/builtin/datetime_migration.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
from ..base import BaseMigration, DataMigrationError
1818

19-
2019
log = logging.getLogger(__name__)
2120

2221

aredis_om/model/migrations/schema/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from .legacy_migrator import MigrationAction, MigrationError, Migrator, SchemaDetector
1313
from .migrator import SchemaMigrator
1414

15-
1615
__all__ = [
1716
# Primary API
1817
"BaseSchemaMigration",

aredis_om/model/model.py

Lines changed: 89 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,13 @@
2424
Union,
2525
)
2626
from typing import get_args as typing_get_args
27-
from typing import no_type_check
27+
from typing import (
28+
no_type_check,
29+
)
2830

2931
from more_itertools import ichunked
3032
from pydantic import BaseModel
3133

32-
3334
try:
3435
from pydantic import ConfigDict, TypeAdapter, field_validator
3536

@@ -71,7 +72,6 @@
7172
from .token_escaper import TokenEscaper
7273
from .types import Coordinates, CoordinateType, GeoFilter
7374

74-
7575
model_registry = {}
7676
_T = TypeVar("_T")
7777
Model = TypeVar("Model", bound="RedisModel")
@@ -301,9 +301,13 @@ def convert_base64_to_bytes(obj, model_fields):
301301
and inner_type.model_fields
302302
):
303303
result[key] = [
304-
convert_base64_to_bytes(item, inner_type.model_fields)
305-
if isinstance(item, dict)
306-
else item
304+
(
305+
convert_base64_to_bytes(
306+
item, inner_type.model_fields
307+
)
308+
if isinstance(item, dict)
309+
else item
310+
)
307311
for item in value
308312
]
309313
else:
@@ -386,6 +390,40 @@ def convert_bytes_to_vector(obj, model_fields):
386390
return result
387391

388392

393+
def convert_empty_strings_to_none(obj, model_fields):
394+
"""Convert empty strings back to None for Optional fields in HashModel.
395+
396+
HashModel stores None as empty string "" because Redis HSET requires non-null
397+
values. This function converts empty strings back to None for fields that are
398+
Optional (Union[T, None]) so Pydantic validation succeeds. (Fixes #254)
399+
"""
400+
if not isinstance(obj, dict):
401+
return obj
402+
403+
result = {}
404+
for key, value in obj.items():
405+
if key in model_fields and value == "":
406+
field_info = model_fields[key]
407+
field_type = (
408+
field_info.annotation if hasattr(field_info, "annotation") else None
409+
)
410+
411+
# Check if the field is Optional (Union[T, None])
412+
is_optional = False
413+
if hasattr(field_type, "__origin__") and field_type.__origin__ is Union:
414+
args = getattr(field_type, "__args__", ())
415+
if type(None) in args:
416+
is_optional = True
417+
418+
if is_optional:
419+
result[key] = None
420+
else:
421+
result[key] = value
422+
else:
423+
result[key] = value
424+
return result
425+
426+
389427
class PartialModel:
390428
"""A partial model instance that only contains certain fields.
391429
@@ -1522,27 +1560,47 @@ def resolve_value(
15221560
f"Docs: {ERRORS_URL}#E5"
15231561
)
15241562
elif field_type is RediSearchFieldTypes.NUMERIC:
1525-
# Convert datetime objects to timestamps for NUMERIC queries
1526-
if isinstance(value, (datetime.datetime, datetime.date)):
1527-
if isinstance(value, datetime.date) and not isinstance(
1528-
value, datetime.datetime
1529-
):
1530-
# Convert date to datetime at midnight
1531-
value = datetime.datetime.combine(value, datetime.time.min)
1532-
value = value.timestamp()
1533-
1534-
if op is Operators.EQ:
1535-
result += f"@{field_name}:[{value} {value}]"
1536-
elif op is Operators.NE:
1537-
result += f"-(@{field_name}:[{value} {value}])"
1538-
elif op is Operators.GT:
1539-
result += f"@{field_name}:[({value} +inf]"
1540-
elif op is Operators.LT:
1541-
result += f"@{field_name}:[-inf ({value}]"
1542-
elif op is Operators.GE:
1543-
result += f"@{field_name}:[{value} +inf]"
1544-
elif op is Operators.LE:
1545-
result += f"@{field_name}:[-inf {value}]"
1563+
# Helper to convert a single value for NUMERIC queries
1564+
def convert_numeric_value(v):
1565+
# Convert Enum to its value (fixes #108)
1566+
if isinstance(v, Enum):
1567+
v = v.value
1568+
# Convert datetime objects to timestamps
1569+
if isinstance(v, (datetime.datetime, datetime.date)):
1570+
if isinstance(v, datetime.date) and not isinstance(
1571+
v, datetime.datetime
1572+
):
1573+
# Convert date to datetime at midnight
1574+
v = datetime.datetime.combine(v, datetime.time.min)
1575+
v = v.timestamp()
1576+
return v
1577+
1578+
if op is Operators.IN:
1579+
# Handle IN operator for NUMERIC fields (fixes #499)
1580+
# Convert each value and create OR of range queries
1581+
converted_values = [convert_numeric_value(v) for v in value]
1582+
parts = [f"(@{field_name}:[{v} {v}])" for v in converted_values]
1583+
result += "|".join(parts)
1584+
elif op is Operators.NOT_IN:
1585+
# Handle NOT_IN operator for NUMERIC fields
1586+
converted_values = [convert_numeric_value(v) for v in value]
1587+
parts = [f"(@{field_name}:[{v} {v}])" for v in converted_values]
1588+
result += f"-({' | '.join(parts)})"
1589+
else:
1590+
value = convert_numeric_value(value)
1591+
1592+
if op is Operators.EQ:
1593+
result += f"@{field_name}:[{value} {value}]"
1594+
elif op is Operators.NE:
1595+
result += f"-(@{field_name}:[{value} {value}])"
1596+
elif op is Operators.GT:
1597+
result += f"@{field_name}:[({value} +inf]"
1598+
elif op is Operators.LT:
1599+
result += f"@{field_name}:[-inf ({value}]"
1600+
elif op is Operators.GE:
1601+
result += f"@{field_name}:[{value} +inf]"
1602+
elif op is Operators.LE:
1603+
result += f"@{field_name}:[-inf {value}]"
15461604
# TODO: How will we know the difference between a multi-value use of a TAG
15471605
# field and our hidden use of TAG for exact-match queries?
15481606
elif field_type is RediSearchFieldTypes.TAG:
@@ -3130,6 +3188,8 @@ async def get(cls: Type["Model"], pk: Any) -> "Model":
31303188
if not document:
31313189
raise NotFoundError
31323190
try:
3191+
# Convert empty strings back to None for Optional fields (fixes #254)
3192+
document = convert_empty_strings_to_none(document, cls.model_fields)
31333193
# Convert timestamps back to datetime objects before validation
31343194
document = convert_timestamp_to_datetime(document, cls.model_fields)
31353195
# Convert base64 strings back to bytes for bytes fields
@@ -3145,6 +3205,8 @@ async def get(cls: Type["Model"], pk: Any) -> "Model":
31453205
f"model class ({cls.__class__}. Encoding: {cls.Meta.encoding}."
31463206
)
31473207
document = decode_redis_value(document, cls.Meta.encoding)
3208+
# Convert empty strings back to None for Optional fields (fixes #254)
3209+
document = convert_empty_strings_to_none(document, cls.model_fields)
31483210
# Convert timestamps back to datetime objects after decoding
31493211
document = convert_timestamp_to_datetime(document, cls.model_fields)
31503212
# Convert base64 strings back to bytes for bytes fields

0 commit comments

Comments
 (0)