Skip to content
Open
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: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,12 @@ dmypy.json
# Note: redis_om/ contains placeholder files that ARE tracked:
# - README.md (documentation)
# - __init__.py (so Poetry recognizes it as a package)
# - py.typed (so mypy recognizes it as a typed package)
# All other files in redis_om/ are generated by `make sync` and ignored.
redis_om/*
!redis_om/README.md
!redis_om/__init__.py
!redis_om/py.typed
tests_sync/

# Apple Files
Expand Down
1 change: 0 additions & 1 deletion aredis_om/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
)
from .model.types import Coordinates, GeoFilter


# Backward compatibility alias - deprecated, use SchemaDetector or SchemaMigrator
Migrator = SchemaDetector

Expand Down
1 change: 0 additions & 1 deletion aredis_om/connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from . import redis


URL = os.environ.get("REDIS_OM_URL", None)


Expand Down
3 changes: 1 addition & 2 deletions aredis_om/model/encoders.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@

from pydantic import BaseModel


try:
from pydantic.deprecated.json import ENCODERS_BY_TYPE
from pydantic_core import PydanticUndefined
Expand All @@ -52,7 +51,7 @@


def generate_encoders_by_class_tuples(
type_encoder_map: Dict[Any, Callable[[Any], Any]]
type_encoder_map: Dict[Any, Callable[[Any], Any]],
) -> Dict[Callable[[Any], Any], Tuple[Any, ...]]:
encoders_by_class_tuples: Dict[Callable[[Any], Any], Tuple[Any, ...]] = defaultdict(
tuple
Expand Down
1 change: 0 additions & 1 deletion aredis_om/model/migrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
SchemaMigrator,
)


__all__ = [
# Data migrations
"BaseMigration",
Expand Down
1 change: 0 additions & 1 deletion aredis_om/model/migrations/data/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,4 @@
from .base import BaseMigration, DataMigrationError
from .migrator import DataMigrator


__all__ = ["BaseMigration", "DataMigrationError", "DataMigrator"]
1 change: 0 additions & 1 deletion aredis_om/model/migrations/data/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import time
from typing import Any, Dict, List


try:
import psutil
except ImportError:
Expand Down
1 change: 0 additions & 1 deletion aredis_om/model/migrations/data/builtin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,4 @@
DatetimeFieldMigration,
)


__all__ = ["DatetimeFieldMigration", "DatetimeFieldDetector", "ConversionFailureMode"]
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@

from ..base import BaseMigration, DataMigrationError


log = logging.getLogger(__name__)


Expand Down
1 change: 0 additions & 1 deletion aredis_om/model/migrations/schema/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from .legacy_migrator import MigrationAction, MigrationError, Migrator, SchemaDetector
from .migrator import SchemaMigrator


__all__ = [
# Primary API
"BaseSchemaMigration",
Expand Down
116 changes: 89 additions & 27 deletions aredis_om/model/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@
Union,
)
from typing import get_args as typing_get_args
from typing import no_type_check
from typing import (
no_type_check,
)

from more_itertools import ichunked
from pydantic import BaseModel


try:
from pydantic import ConfigDict, TypeAdapter, field_validator

Expand Down Expand Up @@ -71,7 +72,6 @@
from .token_escaper import TokenEscaper
from .types import Coordinates, CoordinateType, GeoFilter


model_registry = {}
_T = TypeVar("_T")
Model = TypeVar("Model", bound="RedisModel")
Expand Down Expand Up @@ -301,9 +301,13 @@ def convert_base64_to_bytes(obj, model_fields):
and inner_type.model_fields
):
result[key] = [
convert_base64_to_bytes(item, inner_type.model_fields)
if isinstance(item, dict)
else item
(
convert_base64_to_bytes(
item, inner_type.model_fields
)
if isinstance(item, dict)
else item
)
for item in value
]
else:
Expand Down Expand Up @@ -386,6 +390,40 @@ def convert_bytes_to_vector(obj, model_fields):
return result


def convert_empty_strings_to_none(obj, model_fields):
"""Convert empty strings back to None for Optional fields in HashModel.

HashModel stores None as empty string "" because Redis HSET requires non-null
values. This function converts empty strings back to None for fields that are
Optional (Union[T, None]) so Pydantic validation succeeds. (Fixes #254)
"""
if not isinstance(obj, dict):
return obj

result = {}
for key, value in obj.items():
if key in model_fields and value == "":
field_info = model_fields[key]
field_type = (
field_info.annotation if hasattr(field_info, "annotation") else None
)

# Check if the field is Optional (Union[T, None])
is_optional = False
if hasattr(field_type, "__origin__") and field_type.__origin__ is Union:
args = getattr(field_type, "__args__", ())
if type(None) in args:
is_optional = True

if is_optional:
result[key] = None
else:
result[key] = value
else:
result[key] = value
return result


class PartialModel:
"""A partial model instance that only contains certain fields.

Expand Down Expand Up @@ -1522,27 +1560,47 @@ def resolve_value(
f"Docs: {ERRORS_URL}#E5"
)
elif field_type is RediSearchFieldTypes.NUMERIC:
# Convert datetime objects to timestamps for NUMERIC queries
if isinstance(value, (datetime.datetime, datetime.date)):
if isinstance(value, datetime.date) and not isinstance(
value, datetime.datetime
):
# Convert date to datetime at midnight
value = datetime.datetime.combine(value, datetime.time.min)
value = value.timestamp()

if op is Operators.EQ:
result += f"@{field_name}:[{value} {value}]"
elif op is Operators.NE:
result += f"-(@{field_name}:[{value} {value}])"
elif op is Operators.GT:
result += f"@{field_name}:[({value} +inf]"
elif op is Operators.LT:
result += f"@{field_name}:[-inf ({value}]"
elif op is Operators.GE:
result += f"@{field_name}:[{value} +inf]"
elif op is Operators.LE:
result += f"@{field_name}:[-inf {value}]"
# Helper to convert a single value for NUMERIC queries
def convert_numeric_value(v):
# Convert Enum to its value (fixes #108)
if isinstance(v, Enum):
v = v.value
# Convert datetime objects to timestamps
if isinstance(v, (datetime.datetime, datetime.date)):
if isinstance(v, datetime.date) and not isinstance(
v, datetime.datetime
):
# Convert date to datetime at midnight
v = datetime.datetime.combine(v, datetime.time.min)
v = v.timestamp()
return v

if op is Operators.IN:
# Handle IN operator for NUMERIC fields (fixes #499)
# Convert each value and create OR of range queries
converted_values = [convert_numeric_value(v) for v in value]
parts = [f"(@{field_name}:[{v} {v}])" for v in converted_values]
result += "|".join(parts)
elif op is Operators.NOT_IN:
# Handle NOT_IN operator for NUMERIC fields
converted_values = [convert_numeric_value(v) for v in value]
parts = [f"(@{field_name}:[{v} {v}])" for v in converted_values]
result += f"-({' | '.join(parts)})"
else:
value = convert_numeric_value(value)

if op is Operators.EQ:
result += f"@{field_name}:[{value} {value}]"
elif op is Operators.NE:
result += f"-(@{field_name}:[{value} {value}])"
elif op is Operators.GT:
result += f"@{field_name}:[({value} +inf]"
elif op is Operators.LT:
result += f"@{field_name}:[-inf ({value}]"
elif op is Operators.GE:
result += f"@{field_name}:[{value} +inf]"
elif op is Operators.LE:
result += f"@{field_name}:[-inf {value}]"
# TODO: How will we know the difference between a multi-value use of a TAG
# field and our hidden use of TAG for exact-match queries?
elif field_type is RediSearchFieldTypes.TAG:
Expand Down Expand Up @@ -3130,6 +3188,8 @@ async def get(cls: Type["Model"], pk: Any) -> "Model":
if not document:
raise NotFoundError
try:
# Convert empty strings back to None for Optional fields (fixes #254)
document = convert_empty_strings_to_none(document, cls.model_fields)
# Convert timestamps back to datetime objects before validation
document = convert_timestamp_to_datetime(document, cls.model_fields)
# Convert base64 strings back to bytes for bytes fields
Expand All @@ -3145,6 +3205,8 @@ async def get(cls: Type["Model"], pk: Any) -> "Model":
f"model class ({cls.__class__}. Encoding: {cls.Meta.encoding}."
)
document = decode_redis_value(document, cls.Meta.encoding)
# Convert empty strings back to None for Optional fields (fixes #254)
document = convert_empty_strings_to_none(document, cls.model_fields)
# Convert timestamps back to datetime objects after decoding
document = convert_timestamp_to_datetime(document, cls.model_fields)
# Convert base64 strings back to bytes for bytes fields
Expand Down
1 change: 0 additions & 1 deletion aredis_om/model/types.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from typing import Annotated, Any, Literal, Tuple, Union


try:
from pydantic import BeforeValidator, PlainSerializer
from pydantic_extra_types.coordinate import Coordinate
Expand Down
1 change: 0 additions & 1 deletion redis_om/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
)
from .model.types import Coordinates, GeoFilter


# Backward compatibility alias - deprecated, use SchemaDetector or SchemaMigrator
Migrator = SchemaDetector

Expand Down
1 change: 0 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

from aredis_om import get_redis_connection


TEST_PREFIX = "redis-om:testing"


Expand Down
Loading
Loading