-
Notifications
You must be signed in to change notification settings - Fork 34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature request: ariadne server codegen #122
Comments
This is something I've been thinking about some recently, but I am always finding those propositions missing some important decisions that would have to be made immediately as work on generator would star. For example, considering I have this schema: type Query {
issue(id: ID!): Issue
}
type Issue {
id: ID!
text: String!
parent: Issue
} What function signatures would you expect to be generated for |
Based on the example given above I'd expect the following skeletons: # generated/ariadne_schema.py
from ariadne import ObjectType
# import input types and enum types
from .scalars import ID # Or sth. similar, e.g. ID = str
# _ - Prefix to prevent name conficts
_Query = ObjectType("Query") # Or ariadne.QueryType
_Issue = ObjectType("Issue")
# create resolver skeletons
@_Query.field("issue")
async def resolve__query__issue(parent: Any, info: GraphQLResolveInfo, *, id: ID) -> Any:
return await mypackage.resolvers.resolve__query__issue(parent, info, id=id)
@_Issue.field("parent")
async def resolve__issue__parent(parent: Any, info: GraphQLResolveInfo) -> Any:
return await mypackage.resolvers.resolve__issue__parent(parent, info)
# Resolver for scalar valued fields might be opt-in via config In this specific example, the codegen ensures that:
Support for typing of parent and return types should be handled as a separate feature. @rafalp Am I overlooking something essential? |
But correct me if I am wrong but isn't major draw to typing resolvers that their return types are something else than |
With I fear that it will be difficult to completely type all resolvers (including parent and return types) due to the fact, that resolvers can be arbitrary functions and the graphql-core The problem with more concrete types is that one can only type the return type of leaf-resolvers (scalars or last custom resolver before scalars) with certainty. Everything else depends on how the leaf-resolvers produce their return value out of their parent value. In addition, those leaf-resolvers don't need to depend on their parent objects at all (which might be odd but possible). That's why typing the return type of a resolver with a concrete type is only meaningful if all downstream resolvers are simple getters (aka. the
Annotating the return type for the last custom resolver before the "all scalar values" shouldn't be too complicated, if "the last custom resolver" would to be defined in the config. Unfortunately, those resolvers are likely to be the minority in large and/or highly connected schemas. For the other resolvers, it is more complex. The only thing that we now here, is that the return type of a resolver must be compatible with all parent types of its immediate child resolvers. I tried to implement a poc (see below) based on @rafalp Maybe you have an idea on how to do this. Exampleschema.graphqltype Some {
strange: Strange!
}
type Strange {
behaviour: String!
otherBehaviour: [String!]!
}
mypackage.resolvers.pyimport typing as t
from graphql import GraphQLResolveInfo
class Strange(t.TypedDict):
this: str
class StrangeButAsAttribute(t.NamedTuple):
this: str
def resolve__some__strange(parent: t.Any, info: GraphQLResolveInfo) -> Strange:
return {"this": "can be anything and still resolve"}
def resolve__strange__behaviour(parent: Strange, info: GraphQLResolveInfo) -> str:
return parent["this"]
def resolve__strange__behaviour_wrong_parent(
parent: StrangeButAsAttribute, info: GraphQLResolveInfo
) -> str:
return parent.this
def resolve__strange__other_behaviour(
parent: Strange,
info: GraphQLResolveInfo,
) -> t.List[str]:
return "Something completely different".split()
example.pyimport typing as t
from ariadne import ObjectType
from graphql import GraphQLResolveInfo
import mypackage.resolvers
# === Type base
T = t.TypeVar("T", bound=t.Any, contravariant=True)
K = t.TypeVar("K", bound=t.Any, covariant=True)
Resolvable = t.Union[
K,
t.Callable[[], K],
t.Awaitable[K],
t.Callable[[], t.Awaitable[K]],
]
AnyIterable = t.Iterable[K] | t.AsyncIterable[K]
# === Resolver protocols
class SomeStrangeResolver(t.Protocol[T, K]):
def __call__(self, parent: T, info: GraphQLResolveInfo) -> Resolvable[K]: ...
class StrangeBehaviourResolver(t.Protocol[T]):
def __call__(self, parent: T, info: GraphQLResolveInfo) -> Resolvable[str]: ...
class StrangeOtherBehaviourResolver(t.Protocol[T]):
def __call__(self, parent: T, info: GraphQLResolveInfo) -> Resolvable[AnyIterable[str]]: ...
# === Ariadne types with additional typing
class SomeObjectType(ObjectType):
def __init__(self):
super().__init__("Some")
def set_field(
self,
name: t.Literal["strange"],
resolver: SomeStrangeResolver,
) -> SomeStrangeResolver:
return super().set_field(name, resolver)
class StrangeObjectType(ObjectType):
def __init__(self):
super().__init__("Strange")
@t.overload
def set_field(
self, name: t.Literal["behaviour"], resolver: StrangeBehaviourResolver
) -> StrangeBehaviourResolver: ...
@t.overload
def set_field(
self, name: t.Literal["otherBehaviour"], resolver: StrangeOtherBehaviourResolver
) -> StrangeOtherBehaviourResolver: ...
def set_field(self, name, resolver):
return super().set_field(name, resolver)
Some = SomeObjectType()
Strange = StrangeObjectType()
# === Assignment of resolver functions
# The return type of ...resolve__some__strange must be a super-type of the first arg in the methods below
Some.set_field("strange", mypackage.resolvers.resolve__some__strange)
Strange.set_field("behaviour", mypackage.resolvers.resolve__strange__behaviour)
Strange.set_field("otherBehaviour", mypackage.resolvers.resolve__strange__other_behaviour)
# this should be flagged as wrong but it isn't
Strange.set_field("behaviour", mypackage.resolvers.resolve__strange__behaviour_wrong_parent) |
@rafalp I've got a working PoC for the typed resolvers up and running. You can check it out here. It's based on your initial example with
|
Hey, I am sorry. I was going to reply to this after coming back to office after May holidays, but I've got distracted by other work. The reason why I've asked about generating return types, is because there's separate discussion on same subject happening on Ariadne's discussions page, and people in that discussion were focused on expectation that return types for resolvers will be generated, and considered them highly important. I agree with your point about generating types for leaf resolvers only. In fact, I have witnessed experiments in past where people implemented 1:1 types for their schema using Python dataclasses, and then used mypy to validate that resolvers return correct values for those types. But unfortunate side effect of this approach was that resolvers on But because some types were returned by multiple resolvers, this resulted in people creating utility factory functions "convert my model to But as I've said before, people kind of still expect it. 🤔 |
Sorry for the even later reply, I was thinking about this topic but didn't came to write a reply. I think the typed-resolvers problem boils down to 2 essential core-problems:
1. Type compatibilityA type returned by one resolver must be compatible with all other resolvers that utilize it either as a parent or return type. I got this covered in the PoC mentioned above (tested against mypy, pyright and pyCharm's language server). At the core, it requires to create one function per Given your challenge schema: type Query {
issue(id: ID!): Issue
}
type Issue {
id: ID!
text: String!
parent: Issue
} only a single function is required, to validate return types and parent types match (incl. recursion). import typing as t
from graphql import GraphQLResolveInfo
from mypackage import resolvers
from .scalars import ID
from .resolver_type_base import Resolvable # see detail box below
T = t.TypeVar("T")
def validate_issue(
# Issue as return type
query__issue__resolver: t.Callable[[t.Any, GraphQLResolveInfo, ID], Resolvable[T | None]],
# Issue as parent type
issue__id__resolver: t.Callable[[T, GraphQLResolveInfo], Resolvable[ID]],
issue__text__resolver: t.Callable[[T, GraphQLResolveInfo], Resolvable[str]],
# Issue as parent and return type (recursion)
issue__parent__resolver: t.Callable[[T, GraphQLResolveInfo], Resolvable[T | None]],
) -> None:
"""A body is not required"""
if t.TYPE_CHECKING:
# By calling the function, the type checker can validate
# if all of the resolvers can agree on one type without specifying it explicitly
validate_issue(
query__issue__resolver=resolvers.resolve__query__issue,
issue__id__resolver=resolvers.resolve__issue__id,
issue__text__resolver=resolvers.resolve__issue__text,
issue__parent__resolver=resolvers.resolve__issue__parent,
) Definition of `Resolvable` alias
TL;DR: Anything that can be handled by the default resolver. import typing as t
_T = t.TypeVar("_T")
SimpleResolvable = t.Union[_T, t.Awaitable[_T]] # mypy doesn't get this when using pipe (|) syntax
CallableResolvable = t.Callable[[], SimpleResolvable[_T]] | t.Awaitable[t.Callable[[], SimpleResolvable[_T]]]
Resolvable = SimpleResolvable[_T] | CallableResolvable[_T]
Iterable = t.Iterable[_T] | t.AsyncIterable[_T] # for lists -> Iterable[Resolvable[<type>]] This works, but the downside of this approach is, that now you need to define all resolvers, even the trivial ones ( To further improve this, it is necessary to
which is where things get ugly. 2. Default resolverPoint 1 is fairly easy to archive by trying to import the resolvers at generation time ( Point 2 for simple scalars has the problem that e.g.
Just the single Issue.text default resolver
import typing as t
class IssueTextFromTypedDict(t.TypedDict, total=False):
text: str
class IssueTextFromGetItem(t.Protocol):
def __getitem__(self, item: t.Literal["text"]) -> str: ...
class IssueTextFromField(t.Protocol):
text: str
class IssueTextFromProperty(t.Protocol):
@property
def text(self) -> str: ...
class IssueTextFromCallable(t.Protocol):
def text(self) -> str: ...
DefaultResolvableIssueText = t.Union[
IssueTextFromTypedDict,
IssueTextFromGetItem,
IssueTextFromField,
IssueTextFromProperty,
IssueTextFromCallable,
]
def resolve__issue__text(obj: DefaultResolvableIssueText) -> str:
... It can then be used like this in the resolver type check from mypackage import resolvers
from . import default_resolvers
...
validate_issue(
query__issue__resolver=resolvers.resolve__query__issue,
issue__id__resolver=resolvers.resolve__issue__id,
issue__text__resolver=default_resolvers.resolve__issue__text, # <--
issue__parent__resolver=resolvers.resolve__issue__parent,
) Now point 3 additionally requires to make default resolver return other default resolvers. At this point I'm not sure if current type checkers can handle this - so this will require another PoC (which I will be working on). Right now, I'm optimistic that it should be possible, or at least with some minor restrictions. |
Feature Request
Are there any plans (or existing plugins) to add support for server-side codegen?
E.g. generating a schema (through
make_executable_schema
) with:This would make it possible to implement the ariadne resolvers with static typing based on a python version of the graphql schema (like strawberry but schema first) and reduced amount of boilerplate code.
The
graphqlschema
-Strategy might go into this direction, but that's not it yet as it produces a pure graphql-core schema.Example
This request would require something like an
ariadneschema
-Strategy:Source
Generated
Custom implementation
While all code above can be inferred from the graphql schema (boilerplate), the only purely server-specific implementation left would be:
If the schema for input/enum/return types changes in a way incompatible with the resolver implementation or new resolvers are missing, this can now be recognized by static code analysis tools.
Note: Typing parent types might lead to inconsitent typing, as the parent resolvers may still accept anything deemed valid by ariadne and pass it through to the child resolver - despite static typing.
The text was updated successfully, but these errors were encountered: