13

I am trying to figure out a clean way to work with queries and mongdb projections so I don't have to retrieve excessive information from the database. So assuming I have:

// the query
type Query {
  getUserByEmail(email: String!): User
}

And I have a User with an email and a username, to keep things simple. If I send a query and I only want to retrieve the email, I can do the following:

query { getUserByEmail(email: "test@test.com") { email } }

But in the resolver, my DB query still retrieves both username and email, but only one of those is passed back by apollo server as the query result.

I only want the DB to retrieve what the query asks for:

// the resolver
getUserByEmail(root, args, context, info) {
  // check what fields the query requested
  // create a projection to only request those fields
  return db.collection('users').findOne({ email: args.email }, { /* projection */ });
}

Of course the problem is, getting information on what the client is requesting isn't so straightforward.

Assuming I pass in request as context - I considered using context.payload (hapi.js), which has the query string, and searching it through various .split()s, but that feels kind of dirty. As far as I can tell, info.fieldASTs[0].selectionSet.selections has the list of fields, and I could check for it's existence in there. I'm not sure how reliable this is. Especially when I start using more complex queries.

Is there a simpler way?

In case you don't use mongDB, a projection is an additional argument you pass in telling it explicitly what to retrieve:

// telling mongoDB to not retrieve _id
db.collection('users').findOne({ email: 'test@test.com' }, { _id: 0 })

As always, thanks to the amazing community.

GreenAsJade
  • 14,459
  • 11
  • 63
  • 98
Vikk
  • 617
  • 7
  • 17
  • OK. So now it's still not clear what you are asking. That query says "please return an `email`". What do you mean by "But in the resolver, my DB query still retrieves both, but only passes back one. I only want the DB to retrieve what the query asks for"? You should share the resolver code for this query. – GreenAsJade Nov 18 '16 at 23:07
  • That is also my fault. I should have been a bit more clear. I am trying to find out what fields the query is looking for so I can make my database queries only request the information that the query requested. I'll edit my question to better reflect this. – Vikk Nov 18 '16 at 23:10
  • Sorry to be dense. It is still not clear what you mean by "the fields that the query requested". What are these fields? How did the query request them? Is your question actually "How do I make a query that contains information about a projection I want to perform"? Reading this question it sound like you think the query is already telling the resolver what "fields to project". You said that "getting information on what the client requested isn't straightforwards". Actually it is. Everything the client requested is in the query. If you want to request more, put it in the query. – GreenAsJade Nov 19 '16 at 00:43
  • In order use projections, I need to know which fields the query asked for: `getUserByEmail(email: "someemail") { field }`. The same query could also be made: `getUserByEmail(email: "someemail") { field1 field2 field3 }`. If I run the first query, I need to do `db.collection('test').findOne({ args }, { field: 1 })` but for the second query I need to do `db.collection('test').findOne({ args }, { field1: 1, field2: 1, field3: 1 })`. My issue is how to get that list of fields from the resolver. – Vikk Nov 19 '16 at 01:30
  • At last I understand the question :) I don't think you can do it. It is probably implementation dependent, but with `apollo-server`, you've defined the query schema. Your `getUserByEmail` returns a `User`: that's all there is to it. It seems that asking the DB for less information than that is premature optimisation. Why not just fetch the user and be done with it. On the client side `apollo-client` will cache the results so next time if you has for just the email, it will give it to you. – GreenAsJade Nov 19 '16 at 10:51

5 Answers5

7

2020-Jan answer

The current answer to getting the fields requested in a GraphQL query, is to use the graphql-parse-resolve-info library for parsing the info parameter.

The library is "a pretty complete solution and is actually used under the hood by postgraphile", and is recommended going forward by the author of the other top library for parsing the info field, graphql-fields.

Dan Dascalescu
  • 143,271
  • 52
  • 317
  • 404
1

Use graphql-fields

Apollo server example

const rootSchema = [`

    type Person {
        id: String!
        name: String!
        email: String!
        picture: String!
        type: Int!
        status: Int!
        createdAt: Float
        updatedAt: Float
    }

    schema {
    query: Query
    mutation: Mutation
    }

`];

const rootResolvers = {


    Query: {

        users(root, args, context, info) {
            const topLevelFields = Object.keys(graphqlFields(info));
            return fetch(`/api/user?fields=${topLevelFields.join(',')}`);
        }
    }
};

const schema = [...rootSchema];
const resolvers = Object.assign({}, rootResolvers);

// Create schema
const executableSchema = makeExecutableSchema({
    typeDefs: schema,
    resolvers,
});
Learner
  • 2,459
  • 4
  • 23
  • 39
  • Note that the author of [graphql-fields](https://github.com/robrichard/graphql-fields/issues/27#issuecomment-577412938) has just recommended using `graphql-parse-resolve-info` going forward. – Dan Dascalescu Jan 23 '20 at 03:20
0

Sure you can. This is actually the same functionality that is implemented on join-monster package for SQL based db's. There's a talk by their creator: https://www.youtube.com/watch?v=Y7AdMIuXOgs

Take a look on their info analysing code to get you started - https://github.com/stems/join-monster/blob/master/src/queryASTToSqlAST.js#L6-L30

Would love to see a projection-monster package for us mongo users :)

UPDATE: There is a package that creates a projection object from info on npm: https://www.npmjs.com/package/graphql-mongodb-projection

davidyaha
  • 1,940
  • 1
  • 12
  • 5
0

You can generate MongoDB projection from info argument. Here is the sample code that you can follow

 /**
 * @description - Gets MongoDB projection from graphql query
 *
 * @return { object }
 * @param { object } info
 * @param { model } model - MongoDB model for referencing
 */

function getDBProjection(info, model) {
  const {
    schema: { obj }
  } = model;
  const keys = Object.keys(obj);
  const projection = {};

  const { selections } = info.fieldNodes[0].selectionSet;

  for (let i = 0; i < keys.length; i++) {
    const key = keys[i];
    const isSelected = selections.some(
      selection => selection.name.value === key
    );

    projection[key] = isSelected;
  }

  console.log(projection);
}

module.exports = getDBProjection;

Yusufbek
  • 2,180
  • 1
  • 17
  • 23
0

With a few helper functions you can use it like this (typescript version):

import { parceGqlInfo, query } from "@backend";
import { GraphQLResolveInfo } from "graphql";

export const user = async (parent: unknown, args: unknown, ctx: unknown, info: GraphQLResolveInfo): Promise<User | null> => {
  const { dbQueryStr } = parceGqlInfo(info, userFields, "id");

  const [user] = await query(`SELECT ${dbQueryStr} FROM users WHERE id=$1;`, [1]);

  return user;
};

Helper functions.

Few points:

  • gql_uid used as ID! string type from primary key to not change db types

  • required option is used for dataloaders (if field was not requested by user)

  • allowedFields used to filter additional fields from info like '__typename'

  • queryPrefix is used if you need to prefix selected fields like select u.id from users u

    const userFields = [
           "gql_uid",
           "id",
           "email"
         ]
    
     // merge arrays and delete duplicates
     export const mergeDedupe = <T>(arr: any[][]): T => {
       // @ts-ignore
       return ([...new Set([].concat(...arr))] as unknown) as T;
     };
    
     import { parse, simplify, ResolveTree } from "graphql-parse-resolve-info";
     import { GraphQLResolveInfo } from "graphql";
    
     export const getQueryFieldsFromInfo = <Required = string>(info: GraphQLResolveInfo, options: { required?: Required[] } = {}): string[] => {
       const { fields } = simplify(parse(info) as ResolveTree, info.returnType) as { fields: { [key: string]: { name: string } } };
    
       let astFields = Object.entries(fields).map(([, v]) => v.name);
    
       if (options.required) {
         astFields = mergeDedupe([astFields, options.required]);
       }
    
       return astFields;
     };
    
     export const onlyAllowedFields = <T extends string | number>(raw: T[] | readonly T[], allowed: T[] | readonly T[]): T[] => {
       return allowed.filter((f) => raw.includes(f));
     };
    
     export const parceGqlInfo = (
       info: GraphQLResolveInfo,
       allowedFields: string[] | readonly string[],
       gqlUidDbAlliasField: string,
       options: { required?: string[]; queryPrefix?: string } = {}
     ): { pureDbFields: string[]; gqlUidRequested: boolean; dbQueryStr: string } => {
       const fieldsWithGqlUid = onlyAllowedFields(getQueryFieldsFromInfo(info, options), allowedFields);
    
       return {
         pureDbFields: fieldsWithGqlUid.filter((i) => i !== "gql_uid"),
         gqlUidRequested: fieldsWithGqlUid.includes("gql_uid"),
         dbQueryStr: fieldsWithGqlUid
           .map((f) => {
             const dbQueryStrField = f === "gql_uid" ? `${gqlUidDbAlliasField}::Text AS gql_uid` : f;
    
             return options.queryPrefix ? `${options.queryPrefix}.${dbQueryStrField}` : dbQueryStrField;
           })
           .join(),
       };
    

    };

ZiiMakc
  • 31,187
  • 24
  • 65
  • 105