Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Adding contrib for GraphQL Relay #1214

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft

Adding contrib for GraphQL Relay #1214

wants to merge 4 commits into from

Conversation

pkucmus
Copy link

@pkucmus pkucmus commented Jan 24, 2025

Related to #1213.

Important

The schema used for this example comes from https://relay.dev/docs/guides/graphql-server-specification/#schema

Doc strings with examples are yet to come so here's a brief manual on how to use this:

from ariadne.contrib.relay import (
    ConnectionArgumentsUnion,
    RelayConnection,
    RelayObjectType,
    RelayQueryType,
)

RelayQueryType

Use the QueryType that enforces the node GQL logic while providing some utility:

query = RelayQueryType()


@query.field("node")
async def resolve_node(_, info, bid: str):
    ships = [{"__typename": "Ship", **ship} for ship in SHIPS if ship["id"] == bid]
    return ships[0]


@query.node.type_resolver
def resolve_node_type(obj, *_):
    return obj["__typename"]

or if you have the node resolvers already:

query = RelayQueryType(resolve_node_type, resolve_node)

You can map to a different id field if your Node interface specifies something else than id, like bid:

@query.node.field("bid")
def resolve_id(obj, *_):
    return obj["id"]

RelayObjectType and RelayConnection

There's the RelayObjectType which is similar to the standard ObjectType but has the relay "pagination" logic encapsulated in the connection decorator. The decorator takes a resolver that will later be invoked with an additional connection_arguments argument that holds your first, last, after, before. The resolver is expected to fetch a slide of data from the resource's storage and return it, encapsulated in a RelayConnection instance with additional pagination data - in gist - the resolver is responsible for the page calculations.

faction = RelayObjectType("Faction")


@faction.connection("ships")
async def resolve_ships(
    faction_obj,
    info,
    connection_arguments: ConnectionArguments,
    **kwargs,
):
	# This is a poor man's implementation of a storage - the ships are stored in a 
	# list of dicts here. I'm leaving it here for the example to have more sense, 
 	# but it's not critical for the overall feature.
	# This would normally be a call to a remote resource, Django queries, SQLAlchemy queries, 
    # Dataloader calls, etc
    ships = [ship for ship in SHIPS if ship["factionId"] == faction_obj["id"]]
    total = len(ships)
    if connection_arguments.after:
        after_index = (
            ships.index(
                next(ship for ship in ships if ship["id"] == connection_arguments.after)
            )
            + 1
        )
    else:
        after_index = 0
    ships_slice = ships[after_index : after_index + connection_arguments.first]

	# This return is the important part.
    return RelayConnection(
        edges=ships_slice,
        total=total,
        has_next_page=after_index + connection_arguments.first < total,
        has_previous_page=after_index > 0,
    )

RelayConnection is something one would want to overload to provide some repeatable utility, maybe you'd like a DjangoRelayConnection to operate on a Django ORM QuerySet and maybe calculate the paging there to reduce the work that's needed to be done in the resolver. Overloading get_cursor, get_page_info, get_edges on RelayConnection should enable one to achieve a lot but here it's important to listen to the use cases people might have.

I'm open to feedback, thanks :)

@pkucmus
Copy link
Author

pkucmus commented Jan 28, 2025

Based on the request from #1213 (comment) I introduced the following way to provide utility for Query.node object resolving:

By default the RelayQueryType object will instantiate a new RelayNodeInterfaceType object with a node_resolver decorator capable of registering node object resolvers.

query = RelayQueryType()

...

@query.node.node_resolver("Ship")   # special node_resolver decorator
async def resolve_ship(_, info, bid: str):
    ships = [{"__typename": "Ship", **ship} for ship in SHIPS if ship["id"] == bid]
    return ships[0]

The above would be the minimal one need to do to achieve a query.node(id: ID) resolution - assuming a few defaults which seem to be healthy in terms of the Relay Spec like having a Base64 encoded {type_name}:{id} global ID scheme with the IDs incoming in the id kwarg.

Others will have the ability to provide means for a more customized behavior, like having a custom global ID field:

def decode_global_id(kwargs) -> GlobalIDTuple:
    return GlobalIDTuple(*b64decode(kwargs["bid"]).decode().split(":"))


node = RelayNodeInterfaceType(
    global_id_decoder=decode_global_id,
)
query = RelayQueryType(
    node=node,
)

Finally one can just reset the node field resolver to get rid of all and any logic delivered by this module, allowing full control:

query = RelayQueryType()

@query.field("node")     # standard Aridane field decorator
async def resolve_node(_, info, bid: str):
    ships = [{"__typename": "Ship", **ship} for ship in SHIPS if ship["id"] == bid]
    return ships[0]

…the option to use connections, add unit tests
temporary skip of tests until we figure out how to aproach the cahnges in dependencies
# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant