@@ -156,6 +156,10 @@ def _build_command(self, analyzed: AnalyzedQuery) -> TranslatedQuery:
156156 )
157157
158158 # Determine if we need FT.AGGREGATE
159+ # Multi-key ORDER BY also requires FT.AGGREGATE: FT.SEARCH SORTBY
160+ # accepts a single key, while FT.AGGREGATE SORTBY accepts multiple.
161+ # Routing automatically prevents the silent drop of trailing keys
162+ # that used to happen on the FT.SEARCH path.
159163 use_aggregate = (
160164 len (analyzed .aggregations ) > 0
161165 or len (analyzed .groupby_fields ) > 0
@@ -165,6 +169,7 @@ def _build_command(self, analyzed: AnalyzedQuery) -> TranslatedQuery:
165169 or len (analyzed .date_functions ) > 0
166170 or has_date_func_conditions
167171 or len (parsed .filters ) > 0 # exists() in HAVING → FILTER
172+ or len (parsed .orderby_fields ) > 1 # multi-key ORDER BY
168173 )
169174
170175 # Build query string from conditions
@@ -310,6 +315,15 @@ def _build_condition(self, condition: Condition, field_type: str | None) -> str:
310315 inorder = condition .inorder ,
311316 )
312317 elif field_type == "TAG" :
318+ # BETWEEN is meaningless for TAG values; RediSearch tags have no
319+ # ordering, so 'a' <= status <= 'z' has no defined semantics.
320+ # Previously the parser fell through and the builder emitted
321+ # @status:{\('a'\, 'z'\)} (invalid). Surface the limitation.
322+ if operator == "BETWEEN" :
323+ raise ValueError (
324+ f"BETWEEN is not supported on TAG fields ('{ condition .field } '); "
325+ "TAG values are unordered. Use IN (...) for a set match."
326+ )
313327 # Keep list value for IN clauses, convert scalar to string
314328 value = (
315329 condition .value
@@ -320,8 +334,35 @@ def _build_condition(self, condition: Condition, field_type: str | None) -> str:
320334 condition .field ,
321335 operator ,
322336 value ,
337+ negated = is_negated ,
323338 )
324339 elif field_type == "NUMERIC" :
340+ # IN (...) on a NUMERIC field was previously handed a list value
341+ # to build_numeric_condition, which then tried float([1,2,3]) and
342+ # crashed. RediSearch has no native IN for NUMERIC; expand to a
343+ # union of equality ranges (negated → AND of NOT-equals).
344+ if operator == "IN" :
345+ if not isinstance (condition .value , list ) or not condition .value :
346+ raise ValueError (
347+ f"IN on NUMERIC field '{ condition .field } ' requires a "
348+ "non-empty list of values."
349+ )
350+ parts : list [str ] = []
351+ for item in condition .value :
352+ item_num = self ._convert_to_numeric (item )
353+ parts .append (
354+ self ._query_builder .build_numeric_condition (
355+ condition .field , "=" , item_num , negated = is_negated
356+ )
357+ )
358+ if len (parts ) == 1 :
359+ return parts [0 ]
360+ # NOT IN (...) → AND of negated equalities (De Morgan)
361+ # IN (...) → OR of equalities
362+ joiner = " " if is_negated else "|"
363+ joined = joiner .join (parts )
364+ return f"({ joined } )"
365+
325366 # Cast value to expected type for numeric conditions
326367 numeric_value : int | float | tuple [int | float , int | float ]
327368 if isinstance (condition .value , tuple ):
@@ -345,6 +386,7 @@ def _build_condition(self, condition: Condition, field_type: str | None) -> str:
345386 condition .field ,
346387 operator ,
347388 numeric_value ,
389+ negated = is_negated ,
348390 )
349391 else :
350392 # GEO, VECTOR, and unknown field types - default to text search
@@ -425,6 +467,9 @@ def _build_search(
425467 # SORTBY — skip if the ORDER BY field is a score() alias, because
426468 # WITHSCORES already returns results in relevance order and the alias
427469 # is not a sortable indexed field.
470+ # Multi-key ORDER BY is routed to FT.AGGREGATE upstream (see
471+ # use_aggregate in _build_command), so by the time we reach this
472+ # branch parsed.orderby_fields has at most one entry.
428473 score_alias_name = parsed .scoring .alias if parsed .scoring else None
429474 if parsed .orderby_fields :
430475 field_name , direction = parsed .orderby_fields [0 ]
@@ -522,6 +567,18 @@ def _build_aggregate(
522567 # Load fields referenced in exists() computed fields (SELECT)
523568 for computed in analyzed .computed_fields :
524569 self ._extract_exists_fields (computed .expression , load_fields )
570+ # Load ORDER BY fields so multi-key SORTBY works on non-SORTABLE
571+ # columns. (SORTABLE fields are already in scope; loading them
572+ # again is harmless.) Skip computed/derived aliases.
573+ computed_aliases = {cf .alias for cf in analyzed .computed_fields }
574+ computed_aliases .update (df .alias for df in analyzed .date_functions )
575+ computed_aliases .update (gs .alias for gs in parsed .geo_distance_selects )
576+ for field_name , _ in parsed .orderby_fields :
577+ if (
578+ field_name in analyzed .field_types
579+ and field_name not in computed_aliases
580+ ):
581+ load_fields .add (field_name )
525582
526583 if load_all :
527584 args .extend (["LOAD" , "*" ])
0 commit comments