- 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
resolverFactory
for advanced cases of resolver creation - Mutations get a new
error: ErrorInterface
field in theirs payload for better error handling - Added a new
ValidationError
- Enhancements
- Performance improvements
- Breaking changes
- Misc
- Thanks
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 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.
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 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
&after
hooks, 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();
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' });
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
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'],
}],
}
- 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
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
},
],
},
},
},
}
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 thengraphql-compose-mongoose
will cast it toInt
GraphQL type. For other fields,Number
is casted toFloat
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.
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',
});
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).
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.
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.
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
_id
field was removed from the UpdateByIdRecord
input type, and added to the top level
- updateById(record: UpdateByIdRecord!)
+ updateById(_id: MongoID!, record: UpdateByIdRecord!)
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.
- type for
filter._operators
field. WasOperatorsXXXFilterInput
, which now becomesXXXFilterOperatorsInput
. This helps keep all generated types with the same prefix forXXX
entity. - in the
count
resolver, we changed thefilter
type name fromFilter
toFilterCount
. All other resolvers already hadFilterFindMany
,FilterFindOne
, etc. names; only thecount
resolver did not follow this pattern.
- Output type for the
findMany
resolver is now NonNull of List of NonNull of (WrappedType) - Used to be List of NonNull of (WrappedType)
- 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 availableargs
in resolvers
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 🙏
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