Skip to content

Latest commit

 

History

History
647 lines (509 loc) · 25.1 KB

File metadata and controls

647 lines (509 loc) · 25.1 KB

Release notes graphql-compose-mongoose v9.0.0

What's new?

A new way for resolver creation (via factory)

Before 9.0.0, graphql-compose-mongoose generated types & resolvers together on a composeWithMongoose call. This approach has the following disadvantages:

  • no static analysis for resolver names (are you sure that findById exists?)
  • under the hood generated around 15 resolvers and many graphql types for them (do you really use all of these resolvers?)
  • no static analysis for available args when you are creating relations
  • quite awkward mechanics of creating new resolvers, cloning, or wrapping them (what if you need to change type before resolvers creation? What if I need several copies of one resolver with a different set of args?)
  • no go-to support in IDE via ctrl+click for opening resolver definition.

To overcome all these problems a new composeMongoose method was created. And I strongly recommend to migrate your codebase from the old composeWithMongoose to the new composeMongoose; making your code more stable and faster:

- import { composeWithMongoose } from 'graphql-compose-mongoose';
+ import { composeMongoose } from 'graphql-compose-mongoose';

// generate GraphQL types & resolvers from mongoose model
- const UserTC = composeWithMongoose(UserModel);
+ const UserTC = composeMongoose(UserModel);

// getting generated resolver by name `findById`
- const findByIdResolver = UserTC.getResolver('findById');
+ const findByIdResolver = UserTC.mongooseResolvers.findById();

Under the hood, when the new composeMongoose method is called, it:

  • will create just two types: ObjectTypeComposer & InputTypeComposer
  • will not generate resolvers automatically, but instead of this, it adds mongooseResolvers property with a factory for resolver creation on demand.

This will provide the following advantages:

  • You may programmatically modify TypeComposer and only after that generate resolvers with their sub-types based on already customized fields in TypeComposer.
  • Safe memory and time on the bootstrap server phase, because it won't generate unused resolvers.
  • You may call one resolver generator several times with different configurations.
  • mongooseResolvers factory has improved typescript definitions. And you will get not only a list of available resolvers by names but also their customization options. Moreover, all generated Resolvers will know its source: Model<IDoc>.
  • According to the customization options for each resolver, you may control how your resolvers will be generated. Also, it unlocks the ability in future releases to add more options like before & after hooks, ACL, additional filters, and sorts params.

Issue #263

An full example using TypeScript with mongoose, graphql-compose-mongoose, and a new DataLoader resolver.

An example of TypeScript usage with a lot of type checks and autosuggestions:

import { SchemaComposer } from 'graphql-compose';
import { composeMongoose } from 'graphql-compose-mongoose';
import { mongoose } from 'mongoose';

// type for context which is provided by graphql-server
interface TContext {
  req: Request;
}

// creating a schema with TContext
const schemaComposer = new SchemaComposer<TContext>();

// describe mongoose schema for User
const UserSchema = new mongoose.Schema({
  name: { type: String, required: true },
});

// define interface for your mongoose model IUser
interface IUser extends mongoose.Document {
  name: string;
}

// describe mongoose schema for Post
const PostSchema = new mongoose.Schema({
  title: { type: String, required: true },
  authorId: { type: mongoose.Types.ObjectId },
  reviewerIds: { type: [mongoose.Types.ObjectId] },
});

// define interface for your mongoose model IPost
interface IPost extends mongoose.Document {
  title: string;
  authorId?: mongoose.Types.ObjectId;
  reviewerIds?: [mongoose.Types.ObjectId];
}

// Create mongoose models with typescript definitions
// Bored with describing fields two times for Schema and for Interface –
//   try typegoose: https://typegoose.github.io/typegoose/
const UserModel = mongoose.model<IUser>('User', UserSchema);
const PostModel = mongoose.model<IPost>('Post', PostSchema);

// create ObjectTypeComposers via the new method
const UserTC = composeMongoose(UserModel);
const PostTC = composeMongoose(PostModel);

// make relations between Post <--> Author
// via adding a new field `author` and using DataLoader
PostTC.addRelation('author', {
  resolver: UserTC.mongooseResolvers.dataLoader({ lean: true }),
  //                                ^^^         ^^^
  //                     autosuggestion for available resolvers and available options  
  prepareArgs: {
    // here will be allowed only the `_id` key, because only this arg is available on the `dataLoader` resolver
    _id: (source) => source.authorId,
    //                   ^^^ source is typed to IPost interface
  },
  projection: { authorId: true },
});

// Describe another relation via `dataLoaderMany`
PostTC.addRelation('reviewers', {
  resolver: UserTC.mongooseResolvers.dataLoaderMany({ lean: true }),
  prepareArgs: {
    _ids: (s) => s.reviewerIds,
  },
  projection: { reviewerIds: true },
});

// defining schema entrypoints for Query
schemaComposer.Query.addFields({
  post: PostTC.mongooseResolvers.findById(),
  posts: PostTC.mongooseResolvers.findMany(),
  user: UserTC.mongooseResolvers.findById(),
  users: UserTC.mongooseResolvers.findMany({ sort: false }),
});

// defining schema entrypoints for Mutation
schemaComposer.Query.addFields({
  postCreate: PostTC.mongooseResolvers.createOne(),
  userCreate: UserTC.mongooseResolvers.createOne(),
});

// generating GraphQL schema instance
const schema = schemaComposer.buildSchema();

Expose resolverFactory for advanced cases of resolver creation

graphql-compose-mongoose exposes the resolverFactory variable which contains all available resolver generators, eg. resolverFactory.createOne(someMongooseModel, someTC, customizationOpts). This way of resolver generation unlocks the following scenarios:

  • you have several mongoose models with different databases connection and one TypeComposer, so you can reuse one GraphQL type for using it with different MongoDBs.
  • you have one mongoose model and want to use different GraphQL types (for admins with a full set of fields, and for clients with a reduced set of fields).

The following example demonstrates how to use the second scenario with ClonedPostTC type for the reduced set of fields and existing mongoose PostModel for making requests to DB:

import { resolverFactory } from 'graphql-compose-mongoose';
import { PostModel, PostTC } from './the-example-above';

const ClonedPostTC = PostTC.clone('ReducedPost');
ClonedPostTC.getInputTypeComposer().removeField('authorId');

const createPostWithoutAuthor = resolverFactory.createOne(PostModel, ClonedPostTC, { suffix: 'CustomCreate' });

Issue #274

Mutations get a new error: ErrorInterface field in theirs payload for better error handling

All mutation resolvers get an error field in their payloads. And now clients may choose between two variants of how they may receive a runtime resolver error, if it happens.

  1. First variant, as usual, via errors field in the response payload. Assume the following mutation produce runtime error:
mutation {
  userCreate(...) {
    recordId
  }
}

Therefore, you would receive such a response from the GraphQL server:

{
  data: { userCreate: null },
  errors: [{
    message: 'E11000 duplicate key error collection: test.users index: email_1',
    extensions: { ... },
    path: ['userCreate'],
  }],
}
  1. And the second new variant of obtaining errors is – the error field in mutation payload:
mutation {
  userCreate(...) {
    recordId
    error {
      message
    }
  }
}

In such case, you will get the error in the userCreate.error field and the top-level errors field will be undefined:

{
  data: {
    userCreate: {
      error: {
        message: 'E11000 duplicate key error collection: test.users index: email_1',
      }
    }
  }
}

Moreover, userCreate.error field is typed and may provide additional information for you. Let's take a look at the implementation of error field via SDL, which has some essential comments with technical explanations:

type UserCreatePayload {
  recordId: Int
  # First of all the `error` field is described by Interface
  error: ErrorInterface
}

# Describing `UserCreatePayload.error` field by interface
# provides the following advantages:
# - you may return different types of errors with additional fields
# - no matter what type the error is, you may request the `message` field
interface ErrorInterface {
  message: String
}

# Currently in graphql-compose-mongoose there exist 3 error types -
# MongoError, ValidationError & RuntimeError

# MongoError is used if the error was thrown from the Database
# and contains an additional `code` field
# https://github.com/mongodb/mongo/blob/master/src/mongo/base/error_codes.yml
type MongoError implements ErrorInterface {
  message: String
  code: Int
}

# ValidationError is used if error was thrown by Mongoose
# when you create or update some documents.
type ValidationError implements ErrorInterface {
  message: String
  errors: ValidatorError
}

# RuntimeError is used as a fallback type if no one of the previous error was met.
type RuntimeError implements ErrorInterface {
  message: String
}

So if clients need more details about mutation errors they able to write the following query:

mutation {
  userCreate(...) {
    recordId
    error {
      message
      __typename
      ... on MongoError {
        code
      }
      ... on ValidationError {
        errors {
          message
          path
          value
        }
      }
    }
  }
}

Quite a long discussion about error implementation can be found in issue #248

Added a new ValidationError

Resolvers createOne, createMany, updateOne, updateById now returns validator errors in the following shape:

type ValidationError implements ErrorInterface {
  message: String
  errors: ValidatorError
}

type ValidatorError {
  message: String
  path: String
  value: JSON
  idx: Int!
}

So for such a query:

mutation {
  createMany(
    records: [
      { name: "Ok" },
      { name: "John", someStrangeField: "Test" }
    ]
  ) {
    records {
      name
    }
    error {
      __typename
      message
      ... on ValidationError {
        errors {
          message
          path
          value
          idx
        }
      }
    }
  }
}

You will receive the following response:

{
  data: {
    createMany: {
      records: null,
      error: {
        __typename: 'ValidationError',
        message: 'Nothing has been saved. Some documents contain validation errors',
        errors: [
          {
            message: 'this is a validate message',
            path: 'someStrangeField',
            value: 'Test',
            idx: 1, // <-- points that the second document, which has the error
          },
        ],
      },
    },
  },
}

Issue #248

Enhancements

Now _id field can be of any type (Int, String, Object)

Before v9.0.0, only the MongoID type was supported for the _id field. Now, it can be of any type: Int, String, Object. To use this feature, you need to add the _id field to your mongoose schema with the desired type, and graphql-compose-mongoose will do the rest:

const BookSchema = new mongoose.Schema({
  _id: { type: Number },
  title: { type: String },
});

interface IBook extends Document {
  _id: number;
  title?: string;
}

const BookModel = mongoose.model<IBook>('Book', BookSchema);
const BookTC = composeMongoose(BookModel);

Notes:

  • If you choose type Number for _id field then graphql-compose-mongoose will cast it to Int GraphQL type. For other fields, Number is casted to Float by default. However, you have the ability to change the type manually – BookTC.extendField('_id', { type: 'Float!' }).
  • Be careful: Mongoose will refuse to save a document that doesn't have an _id. So you're responsible for setting _id if you define your own _id path. For automatic numeric id creation you can use the following plugins mongoose-plugin-autoinc or @typegoose/auto-increment.

Issue #141

Add nested fields support, new operators regex, exists for filter._operators

Resolvers which have filter arg have an _operators field, which allows you to write complex filtering logic with AND, OR, gt, gte, lt, lte, ne, in, nin operators. Now in v9.0.0, the following were added: exists & regex. Also we have added support for nested fields like in contacts.email and contacts.skype:

query {
  findUsers(
    filter: {
      _operators: {
        age: { gt: 10, lt: 20 },
        address: { country: { in: ["US"] } },
        contacts: {
          email: { regex: "/3.COM/i" },
          skype: { exists: true },
        }
      }
    }
  ) {
    _id
    name
    age
  }
}

By default, for performance reason, graphql-compose-mongoose generates operators only for indexed fields. BUT you may enable operators for all fields when creating resolver in the following way:

const userFindMany = UserTC.mongooseResolvers.findMany({
  filter: {
    // enables all operators for all fields
    operators: true,
  }
});

OR provide a more granular operators configuration to suit your needs:

const userFindMany2 = UserTC.mongooseResolvers.findMany({
  filter: {
    // more granular operators configuration
    operators: {
      // for `age` field add just 3 operators
      age: ['in', 'gt', 'lt'],
      // for non-indexed `amount` field add all operators
      amount: true,
      // don't add this field to operators
      indexedField: false,
    },
  },
  // add suffix for avoiding type names collision with resolver above
  suffix: 'AnotherFindMany',
});

Issue #250

Better alias support for nested embedded fields

Mongoose supports aliases for fields. You may have short field names in DB t, a but they will be present in your models and graphql types under the full names – title, author:

const BookSchema = new mongoose.Schema({
  _id: { type: Number },
  t: { type: String, alias: 'title' },
  a: { type: AuthorSchema, alias: 'author' },
  meta: {
    v: { type: Number, alias: 'votes' },
    f: { type: Number, alias: 'favs' },
  }
});

From the example above, you will notice that aliases can be used for embedded fields like votes & favs.

Moreover, graphql-compose-mongoose re-implements alias logic to make alias support in resolvers with the lean: true option (when graphql gets raw documents from the database).

Issue #273

Performance improvements

Added projection for nested embedded documents

Before v9.0.0, we only supported top-level fields projections. But now graphql-compose-mongoose supports projection for embedded (nested) fields. It helps reduce data transfer between MongoDB and GraphQL server.

Issue #273

Added new dataLoader & dataLoaderMany resolvers

These resolvers are helpful for relations construction between Entities for avoiding the N+1 Problem via DataLoader. This problem occurs when a client requests an array of records with some relation data:

  • a GraphQL call will first resolve the query for the array of records,
  • then, for every record, will call nested resolve methods which make separate DB requests

As you can expect, doing N+1 queries will flood your database with queries, which is something we can and should avoid. So dataLoader, dataLoaderMany resolvers make one batch request for getting all related records by _id.

import { schemaComposer } from 'graphql-compose';
import { composeMongoose } from 'graphql-compose-mongoose';
import { mongoose, Document } from 'mongoose';

mongoose.set('debug', true); // <-- show mongoose queries in console

const UserSchema = new mongoose.Schema({
  name: { type: String, required: true },
});
const PostSchema = new mongoose.Schema({
  title: { type: String, required: true },
  authorId: { type: mongoose.Types.ObjectId },
  reviewerIds: { type: [mongoose.Types.ObjectId] },
});

interface IUser extends Document {
  name: string;
}

interface IPost extends Document {
  title: string;
  authorId?: mongoose.Types.ObjectId;
  reviewerIds?: [mongoose.Types.ObjectId];
}

const UserModel = mongoose.model<IUser>('User', UserSchema);
const PostModel = mongoose.model<IPost>('Post', PostSchema);

const UserTC = composeMongoose(UserModel);
const PostTC = composeMongoose(PostModel);

PostTC.addRelation('author', {
  // resolver: () => UserTC.mongooseResolvers.findById({ lean: true }),
  resolver: () => UserTC.mongooseResolvers.dataLoader({ lean: true }),
  prepareArgs: {
    _id: (s) => s.authorId,
  },
  projection: { authorId: true },
});

PostTC.addRelation('reviewers', {
  // resolver: () => UserTC.mongooseResolvers.findByIds({ lean: true }),
  resolver: () => UserTC.mongooseResolvers.dataLoaderMany({ lean: true }),
  prepareArgs: {
    _ids: (s) => s.reviewerIds,
  },
  projection: { reviewerIds: true },
});

schemaComposer.Query.addFields({
  posts: PostTC.mongooseResolvers.findMany(),
});

// console.log(schemaComposer.toSDL());
export const schema = schemaComposer.buildSchema();

Test suite for this example can be found here.

Add lean: boolean option to query resolvers

Resolvers with lean: true are significantly faster than without it (anywhere from 3 - 10 times faster), Mongoose Docs on Lean. If you need just raw data from DB then use this option. By default queries return fully instantiated Mongoose documents for supporting mongoose's virtuals fields, plugins and model methods (but it consumes much more CPU & RAM).

The lean option is available in the following resolvers findById, findByIds, findMany, findOne, dataLoader, dataLoaderMany.

BTW mongoose aliases are supported with lean: true option. graphql-compose-mongoose takes care of their proper conversion in filters, projection and output results:

// With aliases in MongoDB you will have such records
//   { _id: '...', n: 'John', a: 26 }
const AuthorSchema = new mongoose.Schema({
  name: { type: String, alias: 'n' },
  score: { type: Number, alias: 's' },
});
const AuthorModel = mongoose.model<IAuthor>('Author', AuthorSchema);

// A graphql type with full field names will be generated
//   type Author { name: String, score: Float }
const AuthorTC = composeMongoose(AuthorModel, { schemaComposer });

// Resolver will send queries something like this:
//   db.author.find({ n: 'John' })
// And convert shortened raw records to full form
//   { _id: '...', n: 'John', s: 26 }
const userFindManyResolver = AuthorTC.mongooseResolvers.findMany({ lean: true });
  • feat add lean: true option #259, #266 commit

Breaking changes

Changed Resolver updateById input args

_id field was removed from the UpdateByIdRecord input type, and added to the top level

- updateById(record: UpdateByIdRecord!)
+ updateById(_id: MongoID!, record: UpdateByIdRecord!)

Issue #257

createMany resolver now validates all records before save

Before 9.0.0, graphql-compose-mongoose would save some records provided to createMany even if another had failed with a validation error. At first it will check that all records are valid before saving; and if some records contain errors, then no one document will be saved.

Some generated types were renamed

  • type for filter._operators field. Was OperatorsXXXFilterInput, which now becomes XXXFilterOperatorsInput. This helps keep all generated types with the same prefix for XXX entity.
  • in the count resolver, we changed the filter type name from Filter to FilterCount. All other resolvers already had FilterFindMany, FilterFindOne, etc. names; only the count resolver did not follow this pattern.

findMany and findByIds output type NonNull

  • Output type for the findMany resolver is now NonNull of List of NonNull of (WrappedType)
  • Used to be List of NonNull of (WrappedType)

Misc

  • Refactor pagination & connection resolvers (now they are dependencies) #272
  • Allow the ability to provide suffixes for resolvers configs #268
  • Remove getRecordIdFn() #262
  • TypeScript definition improvements for resolvers: source is now typed, and first level of available args in resolvers

Thanks

Thanks to contributors

It will not be possible to provide such great improvements in v9.0.0 without the following amazing peoples:

  • Robert Lowe – new improved error payload for Mutations and better validation Errors on document creating/updating.
  • Sean Campbell – nested projection for reducing the amount of transmitted data from DB.
  • Morgan Touverey Quilling – non-nullability for fields with default values, help in lean resolvers.

Thank you very much for your help 🙏

Thanks to sponsors

Special thanks to our sponsors who have joined recently:

  • Bruce agency ($250) – Investing in JAMstack, headless and touchless experiences since 2007, with over 250+ projects built. https://bruce.agency/
  • Robert Lowe ($200) – freelancer with great experience in Realtime web, mobile and desktop apps http://robertlowe.ca

And thanks a lot to our regular backers – ScrapeHero $5, Woorke $2, 420 Coupon Codes $2,ScrapingBee $2, Adapt.js $2.

Your donations inspire me to improve the graphql-compose packages. And allow to spend more time on it. Thank you very much for your support!

You can consider sponsoring graphql-compose and all its plugins via OpenCollective – https://opencollective.com/graphql-compose