Skip to content
Merged
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
32 changes: 31 additions & 1 deletion src/ferro/query/builder.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Build fluent query objects that serialize filter definitions for the Rust core"""

import json
from typing import TYPE_CHECKING, Any, Generic, Type, TypeVar
from typing import TYPE_CHECKING, Any, Generic, Type, TypeVar, overload

from .._core import (
add_m2m_links,
Expand All @@ -18,6 +18,7 @@
from .nodes import QueryNode

T = TypeVar("T")
E = TypeVar("E")


def _query_def_to_json(query_def: dict[str, Any]) -> str:
Expand Down Expand Up @@ -404,6 +405,35 @@ class BackRef(Query[T]):
True
"""

# NOTE ON TYPING:
#
# Users commonly annotate reverse collections as BackRef[list[Model]] to encode
# cardinality (one-to-many / many-to-many). Since Query.all() is typed as list[T],
# that would naively become list[list[Model]] in IDEs.
#
# We fix hinting by overriding BackRef.{all,first} with overloads that interpret
# BackRef[T] as a query whose *rows* are model instances, regardless of whether
# T is written as Model or list[Model] in the field annotation.
if TYPE_CHECKING:

@overload
async def all(self: "BackRef[list[E]]") -> list[E]: ...

@overload
async def all(self: "BackRef[E]") -> list[E]: ...

@overload
async def first(self: "BackRef[list[E]]") -> E | None: ...

@overload
async def first(self: "BackRef[E]") -> E | None: ...

async def all(self): # type: ignore[override]
return await super().all()

async def first(self): # type: ignore[override]
return await super().first()

@classmethod
def __get_pydantic_core_schema__(cls, _source_type, _handler):
"""Allow pydantic-core to treat relationships as arbitrary runtime values"""
Expand Down
Loading