diff --git a/.claude/skills/api-guide/SKILL.md b/.claude/skills/api-guide/SKILL.md index 85784045f4d..3601031c58e 100644 --- a/.claude/skills/api-guide/SKILL.md +++ b/.claude/skills/api-guide/SKILL.md @@ -239,7 +239,37 @@ GraphQL Resolver → check_admin_only (if admin) → Processor → Service → R - NEVER put optional fields in Scope - use Filter instead - Scope fields must all be required (no default values, no Optional types) +### Cross-Entity Reference Resolvers + +When a GQL node references another entity node, use `strawberry.lazy()` to avoid circular imports. Strawberry requires runtime type resolution, so `TYPE_CHECKING` imports alone are insufficient. + +**Pattern:** +```python +# 1. TYPE_CHECKING: for static analysis (mypy) +if TYPE_CHECKING: + from ai.backend.manager.api.gql.domain_v2.types.node import DomainV2GQL + +# 2. Return type: Annotated with strawberry.lazy() for runtime resolution +# 3. Function body: runtime import + DataLoader +async def domain(self, info: Info[StrawberryGQLContext]) -> Annotated[ + DomainV2GQL, + strawberry.lazy("ai.backend.manager.api.gql.domain_v2.types.node"), +]: + from ai.backend.manager.api.gql.domain_v2.types.node import DomainV2GQL + data = await info.context.data_loaders.domain_loader.load(self.domain_name) + return DomainV2GQL.from_data(data) +``` + +**Optional return type:** `| None` must be **outside** `Annotated[]`: +```python +) -> Annotated[DomainV2GQL, strawberry.lazy("...")] | None: # ✅ +) -> Annotated[DomainV2GQL | None, strawberry.lazy("...")]: # ❌ lazy cannot resolve union +``` + +**DataLoaders** (`info.context.data_loaders`): Use DataLoaders instead of individual fetch functions to prevent N+1 queries. See `api/gql/data_loader/data_loaders.py` for available loaders. + **See examples:** +- `api/gql/fair_share/types/domain.py` - Cross-entity reference with DataLoader - `api/gql/domain_v2/types/node.py` - DomainV2GQL with fair_shares/usage_buckets - `api/gql/fair_share/types/*.py` - Scope and Filter patterns diff --git a/changes/8702.feature.md b/changes/8702.feature.md new file mode 100644 index 00000000000..776686c410f --- /dev/null +++ b/changes/8702.feature.md @@ -0,0 +1 @@ +Apply DataLoaders to fair share entity back-reference resolvers \ No newline at end of file diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index ad814057dbe..06bc29119d2 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -3341,7 +3341,7 @@ type DomainFairShare implements Node """ Added in 26.2.0. The domain entity associated with this fair share record. """ - domain: DomainV2! + domain: DomainV2 } """ @@ -7594,7 +7594,7 @@ type ProjectFairShare implements Node """ Added in 26.2.0. The project entity associated with this fair share record. """ - project: ProjectV2! + project: ProjectV2 } """ @@ -11412,7 +11412,7 @@ type UserFairShare implements Node """ Added in 26.2.0. The user entity associated with this fair share record. """ - user: UserV2! + user: UserV2 } """ diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index 5d8029307ab..52c269d2b7b 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -1731,7 +1731,7 @@ type DomainFairShare implements Node { """ Added in 26.2.0. The domain entity associated with this fair share record. """ - domain: DomainV2! + domain: DomainV2 } """ @@ -3975,7 +3975,7 @@ type ProjectFairShare implements Node { """ Added in 26.2.0. The project entity associated with this fair share record. """ - project: ProjectV2! + project: ProjectV2 } """ @@ -6526,7 +6526,7 @@ type UserFairShare implements Node { """ Added in 26.2.0. The user entity associated with this fair share record. """ - user: UserV2! + user: UserV2 } """ diff --git a/src/ai/backend/manager/api/gql/fair_share/types/domain.py b/src/ai/backend/manager/api/gql/fair_share/types/domain.py index e87e4775366..2cdc72b45c4 100644 --- a/src/ai/backend/manager/api/gql/fair_share/types/domain.py +++ b/src/ai/backend/manager/api/gql/fair_share/types/domain.py @@ -12,7 +12,7 @@ from strawberry.relay import Connection, Edge, Node, NodeID from ai.backend.manager.api.gql.base import OrderDirection, StringFilter -from ai.backend.manager.api.gql.types import GQLFilter, GQLOrderBy +from ai.backend.manager.api.gql.types import GQLFilter, GQLOrderBy, StrawberryGQLContext from ai.backend.manager.data.fair_share.types import DomainFairShareData from ai.backend.manager.repositories.base import ( QueryCondition, @@ -65,14 +65,20 @@ class DomainFairShareGQL(Node): ) async def domain( self, - info: Info, - ) -> Annotated[ - DomainV2GQL, - strawberry.lazy("ai.backend.manager.api.gql.domain_v2.types.node"), - ]: - from ai.backend.manager.api.gql.domain_v2.fetcher.domain import fetch_domain - - return await fetch_domain(info=info, domain_name=self.domain_name) + info: Info[StrawberryGQLContext], + ) -> ( + Annotated[ + DomainV2GQL, + strawberry.lazy("ai.backend.manager.api.gql.domain_v2.types.node"), + ] + | None + ): + from ai.backend.manager.api.gql.domain_v2.types.node import DomainV2GQL + + domain_data = await info.context.data_loaders.domain_loader.load(self.domain_name) + if domain_data is None: + return None + return DomainV2GQL.from_data(domain_data) @classmethod def from_dataclass(cls, data: DomainFairShareData) -> DomainFairShareGQL: diff --git a/src/ai/backend/manager/api/gql/fair_share/types/project.py b/src/ai/backend/manager/api/gql/fair_share/types/project.py index d4a864eae97..14c61867e3e 100644 --- a/src/ai/backend/manager/api/gql/fair_share/types/project.py +++ b/src/ai/backend/manager/api/gql/fair_share/types/project.py @@ -13,7 +13,7 @@ from strawberry.relay import Connection, Edge, Node, NodeID from ai.backend.manager.api.gql.base import OrderDirection, StringFilter, UUIDFilter -from ai.backend.manager.api.gql.types import GQLFilter, GQLOrderBy +from ai.backend.manager.api.gql.types import GQLFilter, GQLOrderBy, StrawberryGQLContext from ai.backend.manager.data.fair_share.types import ProjectFairShareData from ai.backend.manager.data.group.types import ProjectType from ai.backend.manager.repositories.base import ( @@ -68,14 +68,20 @@ class ProjectFairShareGQL(Node): ) async def project( self, - info: Info, - ) -> Annotated[ - ProjectV2GQL, - strawberry.lazy("ai.backend.manager.api.gql.project_v2.types.node"), - ]: - from ai.backend.manager.api.gql.project_v2.fetcher.project import fetch_project - - return await fetch_project(info=info, project_id=self.project_id) + info: Info[StrawberryGQLContext], + ) -> ( + Annotated[ + ProjectV2GQL, + strawberry.lazy("ai.backend.manager.api.gql.project_v2.types.node"), + ] + | None + ): + from ai.backend.manager.api.gql.project_v2.types.node import ProjectV2GQL + + project_data = await info.context.data_loaders.project_loader.load(self.project_id) + if project_data is None: + return None + return ProjectV2GQL.from_data(project_data) @classmethod def from_dataclass(cls, data: ProjectFairShareData) -> ProjectFairShareGQL: diff --git a/src/ai/backend/manager/api/gql/fair_share/types/user.py b/src/ai/backend/manager/api/gql/fair_share/types/user.py index 4f2fedbd1ef..217a76ecbda 100644 --- a/src/ai/backend/manager/api/gql/fair_share/types/user.py +++ b/src/ai/backend/manager/api/gql/fair_share/types/user.py @@ -13,7 +13,7 @@ from strawberry.relay import Connection, Edge, Node, NodeID from ai.backend.manager.api.gql.base import OrderDirection, StringFilter, UUIDFilter -from ai.backend.manager.api.gql.types import GQLFilter, GQLOrderBy +from ai.backend.manager.api.gql.types import GQLFilter, GQLOrderBy, StrawberryGQLContext from ai.backend.manager.data.fair_share.types import UserFairShareData from ai.backend.manager.repositories.base import ( QueryCondition, @@ -68,14 +68,20 @@ class UserFairShareGQL(Node): ) async def user( self, - info: Info, - ) -> Annotated[ - UserV2GQL, - strawberry.lazy("ai.backend.manager.api.gql.user_v2.types.node"), - ]: - from ai.backend.manager.api.gql.user_v2.fetcher.user import fetch_user - - return await fetch_user(info=info, user_uuid=self.user_uuid) + info: Info[StrawberryGQLContext], + ) -> ( + Annotated[ + UserV2GQL, + strawberry.lazy("ai.backend.manager.api.gql.user_v2.types.node"), + ] + | None + ): + from ai.backend.manager.api.gql.user_v2.types.node import UserV2GQL + + user_data = await info.context.data_loaders.user_loader.load(self.user_uuid) + if user_data is None: + return None + return UserV2GQL.from_data(user_data) @classmethod def from_dataclass(cls, data: UserFairShareData) -> UserFairShareGQL: