From b2e06976dabaf16e8fbb1742734352049cb2fa21 Mon Sep 17 00:00:00 2001 From: Vandit Gupta Date: Mon, 19 Oct 2026 21:54:07 -0400 Subject: [PATCH] fix(model): allow sort_by on embedded model field paths Assisted-by: Claude --- aredis_om/model/model.py | 36 +++++++++++++++++++++++++++++++++--- tests/test_json_model.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/aredis_om/model/model.py b/aredis_om/model/model.py index 35636c26..909ae413 100644 --- a/aredis_om/model/model.py +++ b/aredis_om/model/model.py @@ -1427,17 +1427,42 @@ def query_params(self): params += [attr for kv in self.knn.query_params.items() for attr in kv] return params + def _resolve_sort_field(self, field_name: str): + """ + Resolve a sort field name to its field proxy and RediSearch field name. + + Embedded model fields can be given as 'parent__child' or 'parent.child'. + """ + parts = field_name.replace("__", ".").split(".") + if len(parts) == 1: + if field_name not in self.model.model_fields: # type: ignore + return None + return getattr(self.model, field_name), field_name + + try: + field_proxy = getattr(self.model, parts[0]) + for part in parts[1:]: + field_proxy = getattr(field_proxy, part) + except (AttributeError, QuerySyntaxError): + return None + if not isinstance(field_proxy, ExpressionProxy): + return None + # Embedded fields are indexed as "_", as in find(). + prefix = "_".join(parent[0] for parent in field_proxy.parents) + return field_proxy, f"{prefix}_{field_proxy.field.name}" + def validate_sort_fields(self, sort_fields: List[str]): for sort_field in sort_fields: field_name = sort_field.lstrip("-") if self.knn and field_name == self.knn.score_field_name: continue - if field_name not in self.model.model_fields: # type: ignore + resolved = self._resolve_sort_field(field_name) + if resolved is None: raise QueryNotSupportedError( f"You tried sort by {field_name}, but that field " f"does not exist on the model {self.model}" ) - field_proxy: ExpressionProxy = getattr(self.model, field_name) + field_proxy: ExpressionProxy = resolved[0] if ( field_proxy.field.sortable is not True @@ -1710,7 +1735,12 @@ def resolve_redisearch_sort_fields(self): fields = [] for f in self.sort_fields: direction = "desc" if f.startswith("-") else "asc" - fields.extend([f.lstrip("-"), direction]) + field_name = f.lstrip("-") + if self.knn and field_name == self.knn.score_field_name: + resolved_name = field_name + else: + resolved_name = self._resolve_sort_field(field_name)[1] # type: ignore + fields.extend([resolved_name, direction]) if self.sort_fields: return ["SORTBY", *fields] diff --git a/tests/test_json_model.py b/tests/test_json_model.py index c8a2a5aa..5d20cb93 100644 --- a/tests/test_json_model.py +++ b/tests/test_json_model.py @@ -756,6 +756,46 @@ async def test_sorting(members, m): await m.Member.find().sort_by("join_date").all() +@py_test_mark_asyncio +async def test_sorting_on_embedded_field(key_prefix, redis): + class Metrics(EmbeddedJsonModel, index=True): + score: int = Field(index=True, sortable=True) + label: str = Field(index=True) + unindexed: int = 0 + + class Book(JsonModel, index=True): + title: str = Field(index=True) + metrics: Metrics + + class Meta: + global_key_prefix = key_prefix + database = redis + + await Migrator(conn=redis).run() + + low = await Book(title="Low", metrics=Metrics(score=1, label="a")).save() + mid = await Book(title="Mid", metrics=Metrics(score=2, label="b")).save() + high = await Book(title="High", metrics=Metrics(score=5, label="c")).save() + + for path in ("metrics__score", "metrics.score"): + actual = await Book.find(Book.metrics.score < 10).sort_by(path).all() + assert actual == [low, mid, high] + + actual = await Book.find(Book.metrics.score < 10).sort_by(f"-{path}").all() + assert actual == [high, mid, low] + + query = Book.find().sort_by("metrics.score") + assert query.resolve_redisearch_sort_fields() == ["SORTBY", "metrics_score", "asc"] + + with pytest.raises(QueryNotSupportedError): + # This embedded field does not exist. + Book.find().sort_by("metrics.nope") + + with pytest.raises(QueryNotSupportedError): + # This embedded field is not sortable or indexed. + Book.find().sort_by("metrics.unindexed") + + @py_test_mark_asyncio async def test_case_sensitive(members, m): member1, member2, member3 = members