Explanation for different implementations of resolver function in graphql
Asked Answered
D

1

6

I've been reading through the graphQL docs and found that they've explained the implementation of the graphql server in 2 ways: one using graphql-yoga which is a fully featured graphql server and another one is using graphql, express-graphql and express. In both cases, we pass the schema and resolver functions while creating the server instance.

But the implementation of resolver function differs. While using graphql-yoga, the resolver function is provided with 4 arguments which contains information about the parent object, arguments received, context, info. whereas in the other case (using graphql), the resolver function only gets the arguments object.

Why is that so ? If I want the info, context objects, how do I get it ?

Using graphql-yoga example: https://graphql.org/learn/execution/

Using graphql example: https://graphql.github.io/graphql-js/mutations-and-input-types/

// Code example using graphql

var express = require('express');
var graphqlHTTP = require('express-graphql');
var { buildSchema } = require('graphql');

var schema = buildSchema(`
type Query {
    rollDice(numDice: Int!, numSides: Int): [Int]
}
type Mutation {
    addDice(numDice: Int): String
}
`);

var root = {
    rollDice({numDice, numSides}) {
        return [1, 2];
    },
    addDice({numDice}) {
        console.log("Adding something");
        return "Added";
    }
};

var app = express();
app.use('/graphql', graphqlHTTP({
    schema: schema,
    rootValue: root,
    graphiql: true,
}));
app.listen(4000);
console.log('Running a GraphQL API server at localhost:4000/graphql');

// Code example using graphql-yoga

let graphqlServer = require("graphql-yoga");

const typeDefs = `
    type Query {
        rollDice(numDice: Int!, numSides: Int): [Int]
    }
    type Mutation {
        addDice(numDice: Int): String
    }
    `;

const resolvers = {
    Query: {
        rollDice(parent, args, context, info) {
            console.log(args.numDice);
            console.log(args.numSides);
            return [1, 2];
        }
    },
    Mutation: {
        addDice(parent, args, context, info) {
            console.log(args.numDice);
            return "Added";
        }
    }
};

const server = new graphqlServer.GraphQLServer({
    typeDefs,
    resolvers
});

server.start(() => {
    console.log("server started on localhost:4000");
});

Difference between these 2 code snippets:

The resolver functions are present inside appropriate types (i.e. Query, Mutation) in one case. In the other case, they are present inside one root object. This means that I can have methods with same name in Query and Mutation in the first case, whereas in the second case that's not possible since they are keys of a single object and keys should be unique.

Why is this so ? Am I basically missing something ? How can the implementation details differ from one package to another ?

Druci answered 25/4, 2019 at 17:7 Comment(0)
R
9

REAL TALK: the GraphQL.js docs are not that great. In my opinion, they never should have used examples with buildSchema in the first place because it understandably leads to this kind of confusion.

GraphQL.js (i.e. the graphql package) is the JavaScript implementation of GraphQL. Building a schema in GraphQL.js is done programmatically, by constructing an instance of the GraphQLSchema class:

const userType = new GraphQLObjectType({
  name: 'User',
  fields: {
    id: {
      type: GraphQLID,
    },
    email: {
      type: GraphQLString,
    },
  },
});
const queryType = new GraphQLObjectType({
  name: 'Query',
  fields: {
    user: {
      type: userType,
      resolve: () => ({ id: 1, email: '[email protected]' }),
    },
  },
});
const schema = new GraphQLSchema({
  query: queryType,
})

If we print this schema in Schema Definition Language (SDL), it looks like this:

type Query {
  user: User
}

type User {
  id: ID
  email: String
}

Working with SDL is much easier than having to write out all that code. However, GraphQL.js does not provide a way to build a fully-featured schema from SDL. It does provide a buildSchema function, but this utility constructs a schema without any resolvers (and a number of other features like union/interface type resolution).

The graphql-tools package provides a makeExecutableSchema function that lets you build a schema from SDL and a resolver map object. This is what's used under the hood by apollo-server and graphql-yoga. makeExecutableSchema constructs a schema from SDL using buildSchema and then mutates the resulting object, adding the resolvers in after the fact.

In GraphQL.js, the resolve function (or resolver) for a field takes four parameters -- the parent value, the field's arguments, the context and a GraphQLResolveInfo object. If we're creating a GraphQLObjectType like userType in the above example, this is the optional function we can provide for each of the fields in our object. This is the same function you define when you construct a resolver map to use with graphql-yoga. This is the only implementation of a field resolver.

So what's the deal with buildSchema??

The examples in the docs take advantage of GraphQL's default field resolver:

export const defaultFieldResolver: GraphQLFieldResolver<any, *> = function(
  source,
  args,
  contextValue,
  info,
) {
  if (typeof source === 'object' || typeof source === 'function') {
    const property = source[info.fieldName];
    if (typeof property === 'function') {
      return source[info.fieldName](args, contextValue, info);
    }
    return property;
  }
};

As you can see, the default resolution logic looks for a property with the same name as the field on the source (parent) value. In our example above, the user resolver returns {id: 1, email: '[email protected]'} -- this is the value the field resolves to. The field is of the type User. We do not have a resolver defined for our id field, so the default resolver does its thing. The id field resolves to 1 because that's the value of the property named id on the parent object the resolver receives.

However, the parent value can also be a function instead of an object. If it's a function, it gets called first and then the return value is used. What does the function get called with? Well, it can't pass it a parent value (because of infinite recursion), but it can pass it the remaining three parameters (args, context and info). So that's what it does.

Now for the magic trick 🎩🐇

In our example, I can omit the resolver for the user field and pass a function to the root value instead.

const root = {
  user: () => ({id: 1, email: '[email protected]'})
}

The root object is just an optional object that's passed down as the parent value to resolvers at the root level (like your Query or Mutation types). Otherwise, those resolvers would not have a parent value.

Query is an operational root type -- it serves as an "entry point" to the rest of your schema. Any fields on the Query type will be passed the root object as the parent value. If I omit a resolver for the user field, the default resolver will 1) examine the parent object for a property with the same name, 2) find a property and determine that it's a function, 3) call the function, 4) resolve the field to the return value of the function.

TADA!

However, because the function is called by the default resolver, and is not used as a resolver itself, it will only receive the three aforementioned parameters, instead of 4.

This is a neat way to work around not being able to actually provide custom resolvers for a schema, but it's very limited. It only works for root types, so we can't similarly provide fake resolvers for User fields or other types. We can't use interfaces or unions in our schema because we can't provide resolveType functions. And so on...

Hopefully that provides some clarity. And hopefully we can get the docs updated in the near future to avoid all this confusion in the first place.

Report answered 25/4, 2019 at 18:18 Comment(3)
Thanks @daniel-rearden That gave me some clarity. Hope they update the docs soon xDDruci
I have been looking for this exact information for over a full day now. This is an excellent summary that addresses all these points of confusion about default resolvers, buildSchema vs makeExecutableSchema, etc. Thank you very much Daniel. I completely agree that much of this should be in the docs.Carrico
If I read default resolver correctly the missing parent or source object is available as “this”. For example if you pass root object like { user:()=> ({ id:1, comments:()=>([..])}) it will use “commments” function to resolve comments. While resolving comments source object will be available as this, args, context and info come as arguments to the function.Relation

© 2022 - 2024 — McMap. All rights reserved.