Skip to content
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

Array-type resolvers: an intuitive solution to the n+1 problem #4024

Closed
Ustice opened this issue Mar 1, 2024 · 2 comments
Closed

Array-type resolvers: an intuitive solution to the n+1 problem #4024

Ustice opened this issue Mar 1, 2024 · 2 comments

Comments

@Ustice
Copy link

Ustice commented Mar 1, 2024

There have been a lot of solutions to the n+1 problem in GraphQL. Patterns like the DataLoader gather up the requests and allow the developer to make a single request for the group. These come with their own complications, and learning curve.

If we include the main idea of these solutions at the GraphQL level, we can get similar capabilities all with reduced complexity. To accomplish this we need to decouple the list type from the non-list type, which allows developers to make a single external request for a list of results.

List-field resolvers would work just a little differently from the typical resolver. When a request is being parsed, if the field type is a list type, then we should check the resolvers for a list of objects of that type. If such a resolver is found then we first resolve list, and call the list field resolver rather than mapping over the individual resolvers for every item in that field. The resolver would then call the resolver with a parent object that is a list, and one which must resolve to an array of the same length, where each element corresponds to the field for value for the parent of the same index. Returning an array of a different length would result in an error being thrown, as it would be impossible to field values back onto the parent object.

This is a lot more difficult to describe than to show an example. So let's consider this schema:

extend type Query {
  things: [Thing!]!
  thing(id: String!): Thing
}

type Thing {
  id: String!
  property: String!
  stuff: [Stuff!]!
}

type Stuff {
  id: String!
  description: String!
}

I'm going to use prisma for the example, and we can assume that the structure in the database matches the GraphQL objects. Given that, here

const resolvers = {
  Query: {
    thing: (__, { id }) => prisma.thing.findOne({ where: { id } })
    things: () => prisma.thing.findMany() 
  },
  Thing: {
    stuff: ({ id }) => prisma.stuff.findMany({ where: { thingId: id } }),
    
    // This is an approximation of the default field resolver,
    // used for comparison to the array-field defined below
    property: (thing) => thing.property
  },
  
  // When a field is of type `[Thing]`, these are the resolvers for the subfields. 
  "[Thing]": {
    stuff: async (things) => {
      const parentIds = things.map((n) => n.id)
      const allStuff = await prisma.stuff.findMany({ where: { thingId: { in: parentsIds } } })

      // Notice how we are not just returning an array, but instead an array of arrays,
      // where for _each_ parent element, we return the value of the corresponding field.
      // The easiest way to accomplish this is to simply map over the parent list (`things`)
      return things.map((thing) => allStuff.filter(({ thingId }) => thingId === thing.id ))
      
      // This would be an error as `allStuff` is of type `Stuff[]`, but the resolver expects a return
      //  value of type `Stuff[][]`
      return allStuff
    },
    
    // This is an approximation of what would become the default resolver for `[Things].property`
    property: async (things, args, context, info) => 
      things.map(
        // Don't actually do this, but this illustrates how list-fields are resolved by default.
        (thing) => resolvers.Thing.property(thing, args, context, info)
      )
  },
}

And finally the following query:

  Query ThingsWithStuff {
    things {
      id
      property
      stuff {
        id
        description
      }
    }
  }

As far as I am aware all GraphQL servers in the Javascript ecosystem, to resolve this query would call resolvers.Query.things, and then for each result would call, resolvers.Thing.stuff for as many things that are returned from the first query, ie the infamous n+1 problem.

My hypothetical GraphQL server would again start by calling resolvers.Query.things, but instead this time it calls resolvers["[Thing]"].stuff, resulting in just two database queries.

If a more complete example with data is necessary I can write that up later.

@yaacovCR
Copy link
Contributor

One complication is that a field can return a list of interfaces, where items may include different types of objects at runtime that fulfill the abstract type, with possibly different selection sets.

if you have a proposal for modifying the execution flow, of course, you can always submit a PR for a review. This repository tries to match the specification as much as possible, but an optimizations that do not obfuscate too much can always be considered.

It might make sense for the PR within this repository to simply allow for a more flexible architecture where the actual change of execution flow is a separate user space package.

@yaacovCR
Copy link
Contributor

We’re cleaning house, so I am going to close this issue, but feel free to reopen if you think there is more to discuss. I think the trickiness is within the implementation, so you might want to sketch out a pull request if you want to pursue this further. I would suggest a bare bones implementation to just see if the solution can handle interfaces, and we can continue the discussion from there.

I would also suggest considering a different approach such as grafast which is more comprehensive, but certainly can handle what you are describing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants