2424 Union ,
2525)
2626from 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
2931from more_itertools import ichunked
3032from pydantic import BaseModel
3133
32-
3334try :
3435 from pydantic import ConfigDict , TypeAdapter , field_validator
3536
7172from .token_escaper import TokenEscaper
7273from .types import Coordinates , CoordinateType , GeoFilter
7374
74-
7575model_registry = {}
7676_T = TypeVar ("_T" )
7777Model = 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+
389427class 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