Release notes graphql-compose-mongoose v9.0.0
September 29, 2022 · View on GitHub
- What's new?
- A new way for resolver creation (via factory)
- An full example using TypeScript with mongoose, graphql-compose-mongoose, and a new DataLoader resolver.
- Expose
resolverFactoryfor advanced cases of resolver creation - Mutations get a new
error: ErrorInterfacefield in theirs payload for better error handling - Added a new
ValidationError
- Enhancements
- Performance improvements
- Breaking changes
- Misc
- Thanks
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
findByIdexists?) - 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-tosupport 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
mongooseResolversproperty with a factory for resolver creation on demand.
This will provide the following advantages:
- You may programmatically modify
TypeComposerand only after that generate resolvers with their sub-types based on already customized fields inTypeComposer. - 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.
mongooseResolversfactory 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 itssource: 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&afterhooks, ACL, additional filters, and sorts params.
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' });
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.
- First variant, as usual, via
errorsfield 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'],
}],
}
- And the second new variant of obtaining errors is – the
errorfield 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
},
],
},
},
},
}
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
Numberfor_idfield thengraphql-compose-mongoosewill cast it toIntGraphQL type. For other fields,Numberis casted toFloatby 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_idif you define your own_idpath. For automatic numeric id creation you can use the following plugins mongoose-plugin-autoinc or @typegoose/auto-increment.
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',
});
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).
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.
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: trueoption #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!)
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._operatorsfield. WasOperatorsXXXFilterInput, which now becomesXXXFilterOperatorsInput. This helps keep all generated types with the same prefix forXXXentity. - in the
countresolver, we changed thefiltertype name fromFiltertoFilterCount. All other resolvers already hadFilterFindMany,FilterFindOne, etc. names; only thecountresolver did not follow this pattern.
findMany and findByIds output type NonNull
- Output type for the
findManyresolver is now NonNull of List of NonNull of (WrappedType) - Used to be List of NonNull of (WrappedType)
Misc
- Refactor
pagination&connectionresolvers (now they are dependencies) #272 - Allow the ability to provide
suffixesfor resolvers configs #268 - Remove
getRecordIdFn()#262 - TypeScript definition improvements for resolvers:
sourceis now typed, and first level of availableargsin 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