graphql-compose / graphql-compose-mongoose

Mongoose model converter to GraphQL types with resolvers for graphql-compose https://github.com/nodkz/graphql-compose
MIT License
708 stars 94 forks source link

How do you filter on a relationship field? #96

Open danielmahon opened 6 years ago

danielmahon commented 6 years ago

OK. Ive been trying for hours now to get the right combination of addRelation, addFields and addFilterArg functions and I cannot seem to figure out how to allow for recursive(deep) filtering that will carry through to a populated field. I've had a hard time making sense of the previous github issues and notes/code that refer to similar methods.

Id basically like to pass a query like the following and retrieve only those images whose services contain the specified slug. I can make it work fine if I pass the service's _id but Id like to acheive the same results by matching the slug or any other attribute.

client query

query getImagesByServiceSlug {
  images(filter: { services: { slug : "my-service-slug" }}) {
    _id
    name
    services {
      _id
      slug
      name
    }
  }
}

Working findMany and findOne relationships (this also works with findByIds/findById) This is a snippet of how I am mapping my mongoose models (via KeystoneJS) using graphl-compose-mongoose. This gets run for each model (list).

...

    const { model } = list;
    const name = keystone.utils.camelcase(model.modelName, true);

    const customizationOptions = {
      fields: {
        // Remove potentially sensitive data from GraphQL Schema
        remove: ['password'],
      },
    };

    types[key] = composeWithMongoose(list.model, customizationOptions);

    // Queries
    const queries = {};
    // model: find one
    queries[name] = types[key].getResolver('findOne');
    // models: find many
    queries[utils.plural(name)] = types[key].getResolver('findMany');
    schemaComposer.rootQuery().addFields({ ...adminAccess(queries) });

    // Mutations
    const mutations = {};
    // createModel: create new
    mutations[`create${utils.upcase(name)}`] = types[key].getResolver('createOne');
    // updateModel(record): update one
    mutations[`update${utils.upcase(name)}`] = types[key].getResolver('updateById');
    // deleteModel(): delete one
    mutations[`delete${utils.upcase(name)}`] = types[key].getResolver('removeOne');
    schemaComposer.rootMutation().addFields({ ...adminAccess(mutations) });

...

  // Define relationships
  _.each(lists, (list, key) => {
    _.each(list.relationshipFields, (field) => {
      const resolverName = pluralize.isPlural(field.path) ? 'findMany' : 'findOne';
      const resolver = types[field.options.ref].getResolver(resolverName);
      types[key].addRelation(field.path, {
        resolver,
        prepareArgs: {
          filter: source => ({
            [pluralize.isPlural(field.path) ? '_ids' : '_id']: source[field.path],
          }),
        },
        projection: { [field.path]: true },
      });
    });
  });

...

I've removed all my "attempts" to make it work because it was a mess so if you can help me from this point that would be great! Thank you!

danielmahon commented 6 years ago

Here is a full gist of the integration: https://gist.github.com/2efafb579b7ab551d28e720701731510

danielmahon commented 6 years ago

I've tried variations on the following as well but I can't seem to get the query and resolver functions to run... so I must be doing something wrong.

...

  // Define relationships
  _.each(lists, (list, key) => {
    _.each(list.relationshipFields, (field) => {
      const resolverName = pluralize.isPlural(field.path) ? 'findMany' : 'findOne';
      const resolver = types[field.options.ref].getResolver(resolverName);
      types[key].setResolver(
        // providing same name for replacing standard resolver `connection`,
        // or you may set another name for keepeng standard resolver untoched
        'connection',
        types[key].getResolver('connection').addFilterArg({
          name: field.path,
          type: resolver.get('@filter'),
          description: 'Filter based on slug.',
          query: (rawQuery, value) => {
            console.log(rawQuery, value);
            // if (value.length === 1) {
            //   rawQuery['location.name'] = value[0];
            // } else {
            //   rawQuery['location.name'] = { $in: value };
            // }
          },
        }),
      );
      types[key].addRelation(field.path, {
        resolver: () =>
          resolver.wrapResolve(next => (_rp) => {
            const rp = _rp;
            console.log(rp);
            // With object-path package set filter by one line
            // objectPath.set(rp, 'args.filter._ids', rp.source.eventIds);
            // or
            // if (!rp.args.filter) rp.args.filter = {}; // ensure that `filter` exists
            // rp.args.filter._ids = rp.source[field.path]; // set `_ids` from current User doc

            // call standard `findMany` resolver with extended filter
            return next(rp);
          }),
        prepareArgs: {
          filter: (source, args) => {
            console.log(source, args);
            return { [pluralize.isPlural(field.path) ? '_ids' : '_id']: source[field.path] };
          },
        },
        projection: { [field.path]: true },
      });
    });
  });

...
nodkz commented 6 years ago

... and retrieve only those images whose services contain the specified slug.

query getImagesByServiceSlug {
images(filter: { services: { slug : "my-service-slug" }}) {
_id
name
services {
_id
slug
name
}
}
}

Wow, you made hard work and your understanding of graphql-compose is quite strong.

How I understand you have two different collections Images and Services. And trying to make complex filtering on sub-requests - get all images where service has desired slug. If you have 2 collections, then you cannot make such query with graphql, cause it breaks nature of graphql: Firstly, will be made images request and only after its execution for every image record will be made a sub-request to services collection.

No graphql, no graphql-compose cannot filter data from the underlying resolver (services) for the already obtained list by the top level resolver (images).

You should reassemble your schema or write some custom resolver or filtering.

1st solution - get needed services, and then request related images

query getImagesByServiceSlug {
  services(filter: { slug : "my-service-slug" }) {
    _id
    name
    slug
    images {
      _id
      name
    }
  }
}

2nd solution - denormalize data in Image collection

You need to keep serviceSlugs field for every record in the image collection. In such case top level resolver image.findMany will have ability to properly filter data

query getImagesByServiceSlug {
  images(filter: { serviceSlugs: "my-service-slug" }) {
    _id
    name
    serviceSlugs
  }
}

3rd solution - write a custom resolver

You will need to use aggregations or maybe several requests which firstly obtain required service ids by slug, and then make sub-request for obtaining images filtered by serviceId. Made it inside one resolver.

danielmahon commented 6 years ago

Thanks @nodkz. I'll take a look at your solution options and let you know how I end up proceeding. I'm still getting comfortable with GraphQL but this library has saved me a TON of time, so thank you for maintaining it so well! If I can get "virtuals and/or getters" working right (right now they arent being seen by graphql-compose-mongoose) then the 2nd solution should work well as I can just add a virtual serviceSlugs to my Image model that updates whenever the record does.

nodkz commented 6 years ago

Virtuals should be added manually, cause mongoose Models does not provide types for them. And virtual field may depend on other fields which should be requested from db in projection. So they cannot be added automatically.

Simple virtual field (assume your model has myVirtualField getter), may be added in such way:

ImageTC.addFields({
  myVirtualField: 'String', // valid types: Int, Json, Date, Float, Boolean; with wrappers [String], String!
});

If your virtual field is required data from another model's fields, then you need to add a projection:

ImageTC.addFields({
  fullName: {
    type: 'String',
    projection: { firstName: 1, lastName: 1 },
  }
});

When you request fullName in the query then graphql-compose-mongoose will automatically request firstName and lastName from database. Otherwise (without projection) you will need to request them explicitly in your query for proper work of your virtual field.

nodkz commented 6 years ago

PS. By virtual fields you cannot filter data via basic resolvers. You may do it via your own custom resolvers, but it will be a bad solution. Cause you will filter records on the backend side in the node process. Better to do filtering on database level.

danielmahon commented 6 years ago

OK, that makes sense. I guess I was forgetting the filtering was happening on the MongoDB level. For those watching, see https://stackoverflow.com/questions/27536383/querying-on-a-virtual-property-in-mongoose. I think I'll just add an actual serviceSlugs field to the Image model and filter based on that. I'd like to keep as many of the fields defined in the model itself and not GraphQL. I can still have that field automatically populated upon record create/update, I'll just have to do a minor DB migration to catch everything up. Thanks again!

danielmahon commented 6 years ago

@nodkz So, I've messed with adding support for running schema methods through GraphQL calls... Not sure exactly how I'm going to use them yet but might be helpful. How do I get the MongoID type set here instead of String? (https://github.com/graphql-compose/graphql-compose-mongoose/blob/master/src/types/mongoid.js) Also, any reason why I shouldn't be attempting this?

example mutation

mutation ExampleSchemaMethod {
  userGetFullName(record: {_id: "1234"}) {
    payload
    recordId
    record {
      name {
        first
        last
      }
    }
  }
}

result

{
  "data": {
    "userGetFullName": {
      "payload": "Daniel Mahon",
      "recordId": "1234",
      "record": {
        "name": {
          "first": "Daniel",
          "last": "Mahon"
        }
      }
    }
  }
}

setup

...

    types[`${utils.upcase(name)}SchemaMethodPayload`] = TypeComposer.create(`
      type ${utils.upcase(name)}SchemaMethodPayload {
        recordId: String! <!-- HOW DO I GET "MongoID!" HERE? -->
        record: ${queries[name].getType()}
        payload: Json
      }
    `);
    _.each(list.model.schema.methods, (method, methodName) => {
      // modelMethodName(_id): run method function by _id
      mutations[name + utils.upcase(methodName)] = types[key]
        .addResolver({
          name: 'runSchemaMethod',
          kind: 'mutation',
          type: types[`${utils.upcase(name)}SchemaMethodPayload`],
          args: types[key].getResolver('updateById').getArgs(),
          resolve: async ({ args }) => {
            // First find the record
            const record = await model.findOne(args.record).exec();
            // Run the methodName on the record,
            // always returns the modified record and/or payload
            const result = await record[methodName]();
            return {
              payload: result.payload,
              record: result.record || record,
              recordId: types[key].getRecordIdFn()(record),
            };
          },
        })
        .getResolver('runSchemaMethod');
    });

...
nodkz commented 6 years ago

Try to add MongoID to schemaComposer, then it should become available for using by name in SDL format definitions:

import { schemaComposer } from 'graphql-compose';
import { GraphQLMongoID } from 'graphql-compose-mongoose';

schemaComposer.set('MongoID', GraphQLMongoID);

...
    types[`${utils.upcase(name)}SchemaMethodPayload`] = TypeComposer.create(`
      type ${utils.upcase(name)}SchemaMethodPayload {
        recordId: MongoID!
        record: ${queries[name].getTypeName()}
        payload: Json
      }
    `);
....