graphql-compose-mongoose
This is a plugin for graphql-compose, which derives GraphQLType from your mongoose model. Also derives bunch of internal GraphQL Types. Provide all CRUD resolvers, including graphql connection
, also provided basic search via operators ($lt, $gt and so on).
Release Notes for v9.0.0 contains a lot of improvements. It's strongly recommended for reading before upgrading from v8.
Installation
npm install graphql graphql-compose mongoose graphql-compose-mongoose --save
Modules graphql
, graphql-compose
, mongoose
are in peerDependencies
, so should be installed explicitly in your app. They have global objects and should not have ability to be installed as submodule.
Intro video
Viktor Kjartansson created a quite solid intro for graphql-compose-mongoose
in comparison with graphql-tools
:
https://www.youtube.com/watch?v=RXcY-OoGnQ8 (23 mins)
Example
Live demo: https://graphql-compose.herokuapp.com/
Source code: https://github.com/graphql-compose/graphql-compose-examples
Small explanation for variables naming:
UserSchema
- this is a mongoose schemaUser
- this is a mongoose modelUserTC
- this is a ObjectTypeComposer
instance for User. ObjectTypeComposer
has GraphQLObjectType
inside, available via method UserTC.getType()
.- Here and in all other places of code variables suffix
...TC
means that this is ObjectTypeComposer
instance, ...ITC
- InputTypeComposer
, ...ETC
- EnumTypeComposer
.
import mongoose from 'mongoose';
import { composeMongoose } from 'graphql-compose-mongoose';
import { schemaComposer } from 'graphql-compose';
const LanguagesSchema = new mongoose.Schema({
language: String,
skill: {
type: String,
enum: ['basic', 'fluent', 'native'],
},
});
const UserSchema = new mongoose.Schema({
name: String,
age: {
type: Number,
index: true,
},
ln: {
type: [LanguagesSchema],
default: [],
alias: 'languages',
},
contacts: {
email: String,
phones: [String],
},
gender: {
type: String,
enum: ['male', 'female'],
},
someMixed: {
type: mongoose.Schema.Types.Mixed,
description: 'Can be any mixed type, that will be treated as JSON GraphQL Scalar Type',
},
});
const User = mongoose.model('User', UserSchema);
const customizationOptions = {};
const UserTC = composeMongoose(User, customizationOptions);
schemaComposer.Query.addFields({
userById: UserTC.mongooseResolvers.findById(),
userByIds: UserTC.mongooseResolvers.findByIds(),
userOne: UserTC.mongooseResolvers.findOne(),
userMany: UserTC.mongooseResolvers.findMany(),
userDataLoader: UserTC.mongooseResolvers.dataLoader(),
userDataLoaderMany: UserTC.mongooseResolvers.dataLoaderMany(),
userByIdLean: UserTC.mongooseResolvers.findById({ lean: true }),
userByIdsLean: UserTC.mongooseResolvers.findByIds({ lean: true }),
userOneLean: UserTC.mongooseResolvers.findOne({ lean: true }),
userManyLean: UserTC.mongooseResolvers.findMany({ lean: true }),
userDataLoaderLean: UserTC.mongooseResolvers.dataLoader({ lean: true }),
userDataLoaderManyLean: UserTC.mongooseResolvers.dataLoaderMany({ lean: true }),
userCount: UserTC.mongooseResolvers.count(),
userConnection: UserTC.mongooseResolvers.connection(),
userPagination: UserTC.mongooseResolvers.pagination(),
});
schemaComposer.Mutation.addFields({
userCreateOne: UserTC.mongooseResolvers.createOne(),
userCreateMany: UserTC.mongooseResolvers.createMany(),
userUpdateById: UserTC.mongooseResolvers.updateById(),
userUpdateOne: UserTC.mongooseResolvers.updateOne(),
userUpdateMany: UserTC.mongooseResolvers.updateMany(),
userRemoveById: UserTC.mongooseResolvers.removeById(),
userRemoveOne: UserTC.mongooseResolvers.removeOne(),
userRemoveMany: UserTC.mongooseResolvers.removeMany(),
});
const schema = schemaComposer.buildSchema();
export default schema;
import { graphql } from 'graphql';
(async () => {
await mongoose.connect('mongodb://localhost:27017/test');
await mongoose.connection.dropDatabase();
await User.create({ name: 'alice', age: 29, gender: 'female' });
await User.create({ name: 'maria', age: 31, gender: 'female' });
const bob = await User.create({ name: 'bob', age: 30, gender: 'male' });
const response1 = await graphql({
schema,
source: 'query { userMany { _id name } }',
});
console.dir(response1, { depth: 5 });
const response2 = await graphql({
schema,
source: 'query($id: MongoID!) { userById(_id: $id) { _id name } }',
variableValues: { id: bob._id },
});
console.dir(response2, { depth: 5 });
const response3 = await graphql({
schema,
source: 'mutation($id: MongoID!, $name: String) { userUpdateOne(filter: {_id: $id}, record: { name: $name }) { record { _id name } } }',
variableValues: { id: bob._id, name: 'bill' },
});
console.dir(response3, { depth: 5 });
mongoose.disconnect();
})();
That's all!
You think that is to much code?
I don't think so, because by default internally was created about 55 graphql types (for input, sorting, filtering). So you will need much much more lines of code to implement all these CRUD operations by hands.
Working with Mongoose Collection Level Discriminators
Variable Namings
...DTC
- Suffix for a DiscriminatorTypeComposer
instance, which is also an instance of ObjectTypeComposer
. All fields and Relations manipulations on this instance affects all registered discriminators and the Discriminator Interface.
import mongoose from 'mongoose';
import { schemaComposer } from 'graphql-compose';
import { composeMongooseDiscriminators } from 'graphql-compose-mongoose';
const DKey = 'type';
const enumCharacterType = {
PERSON: 'Person',
DROID: 'Droid',
};
const CharacterSchema = new mongoose.Schema({
type: {
type: String,
required: true,
enum: (Object.keys(enumCharacterType): Array<string>),
description: 'Character type Droid or Person',
},
name: String,
height: Number,
mass: Number,
films: [String],
});
const DroidSchema = new mongoose.Schema({
makeDate: String,
primaryFunction: [String],
});
const PersonSchema = new mongoose.Schema({
gender: String,
hairColor: String,
starships: [String],
});
CharacterSchema.set('discriminatorKey', DKey);
const CharacterModel = mongoose.model('Character', CharacterSchema);
const DroidModel = CharacterModel.discriminator(enumCharacterType.DROID, DroidSchema);
const PersonModel = CharacterModel.discriminator(enumCharacterType.PERSON, PersonSchema);
const baseOptions = {
fields: {
remove: ['friends'],
}
}
const CharacterDTC = composeMongooseDiscriminators(CharacterModel, baseOptions);
const droidTypeConverterOptions = {
fields: {
remove: ['makeDate'],
}
};
const DroidTC = CharacterDTC.discriminator(DroidModel, droidTypeConverterOptions);
const PersonTC = CharacterDTC.discriminator(PersonModel);
schemaComposer.Mutation.addFields({
droidCreate: DroidTC.getResolver('createOne'),
personCreate: PersonTC.getResolver('createOne'),
});
const schema = schemaComposer.buildSchema();
describe('createOne', () => {
it('should create child document without specifying DKey', async () => {
const res = await graphql.graphql(
schema,
`mutation CreateCharacters {
droidCreate(record: {name: "Queue XL", modelNumber: 360 }) {
record {
__typename
type
name
modelNumber
}
}
personCreate(record: {name: "mernxl", dob: 57275272}) {
record {
__typename
type
name
dob
}
}
}`
);
expect(res).toEqual({
data: {
droidCreate: {
record: { __typename: 'Droid', type: 'Droid', name: 'Queue XL', modelNumber: 360 },
},
personCreate: {
record: { __typename: 'Person', type: 'Person', name: 'mernxl', dob: 57275272 },
},
},
});
});
});
Customization options
composeMongoose
customization options
When you converting mongoose model const UserTC = composeMongoose(User, opts: ComposeMongooseOpts);
you may tune every piece of future derived types – setup name and description for the main type, remove fields or leave only desired fields.
type ComposeMongooseOpts = {
schemaComposer?: SchemaComposer<TContext>;
name?: string;
description?: string;
onlyFields?: string[];
removeFields?: string[];
inputType?: TypeConverterInputTypeOpts;
defaultsAsNonNull?: boolean;
};
This is opts.inputType
options for default InputTypeObject which will be provided to all resolvers for filter
and input
args.
type TypeConverterInputTypeOpts = {
name?: string;
description?: string;
onlyFields?: string[];
removeFields?: string[];
requiredFields?: string[];
};
Resolvers' customization options
When you are creating resolvers from mongooseResolvers
factory, you may provide customizationOptions to it:
UserTC.mongooseResolvers.findMany(opts);
connection(opts?: ConnectionResolverOpts)
type ConnectionResolverOpts<TContext = any> = {
sort?: ConnectionSortMapOpts;
name?: string;
defaultLimit?: number | undefined;
edgeTypeName?: string;
edgeFields?: ObjectTypeComposerFieldConfigMap<any, TContext>;
countOpts?: CountResolverOpts;
findManyOpts?: FindManyResolverOpts;
}
The countOpts
and findManyOpts
props would be used to customize the internally created findMany
and count
resolver factories used by the connection resolver.
If not provided the default configuration for each of the resolver factories is assumed.
The sort
prop is optional. When provided it is used to customize the sorting behaviour of the connection. When not provided, the sorting configuration is derived from the existing indexes on the model.
Please refer to the documentation of the graphql-compose-connection plugin for more details on the sorting customization parameter.
count(opts?: CountResolverOpts)
interface CountResolverOpts {
suffix?: string;
filter?: FilterHelperArgsOpts | false;
}
createMany(opts?: CreateManyResolverOpts)
interface CreateManyResolverOpts {
suffix?: string;
records?: RecordHelperArgsOpts;
recordIds?: PayloadRecordIdsHelperOpts | false;
disableErrorField?: boolean;
}
createOne(opts?: CreateOneResolverOpts)
interface CreateOneResolverOpts {
suffix?: string;
record?: RecordHelperArgsOpts;
recordId?: PayloadRecordIdHelperOpts | false;
disableErrorField?: boolean;
}
dataLoader(opts?: DataLoaderResolverOpts)
interface DataLoaderResolverOpts {
lean?: boolean;
}
dataLoaderMany(opts?: DataLoaderManyResolverOpts)
interface DataLoaderManyResolverOpts {
lean?: boolean;
}
findById(opts?: FindByIdResolverOpts)
interface FindByIdResolverOpts {
lean?: boolean;
}
findByIds(opts?: FindByIdsResolverOpts)
interface FindByIdsResolverOpts {
lean?: boolean;
limit?: LimitHelperArgsOpts | false;
sort?: SortHelperArgsOpts | false;
}
findMany(opts?: FindManyResolverOpts)
interface FindManyResolverOpts {
lean?: boolean;
suffix?: string;
filter?: FilterHelperArgsOpts | false;
sort?: SortHelperArgsOpts | false;
limit?: LimitHelperArgsOpts | false;
skip?: false;
}
findOne(opts?: FindOneResolverOpts)
interface FindOneResolverOpts {
lean?: boolean;
suffix?: string;
filter?: FilterHelperArgsOpts | false;
sort?: SortHelperArgsOpts | false;
skip?: false;
}
interface PaginationResolverOpts {
name?: string;
perPage?: number;
countOpts?: CountResolverOpts;
findManyOpts?: FindManyResolverOpts;
}
removeById(opts?: RemoveByIdResolverOpts)
interface RemoveByIdResolverOpts {
suffix?: string;
recordId?: PayloadRecordIdHelperOpts | false;
disableErrorField?: boolean;
}
removeMany(opts?: RemoveManyResolverOpts)
interface RemoveManyResolverOpts {
suffix?: string;
filter?: FilterHelperArgsOpts | false;
limit?: LimitHelperArgsOpts | false;
disableErrorField?: boolean;
}
removeOne(opts?: RemoveOneResolverOpts)
interface RemoveOneResolverOpts {
suffix?: string;
filter?: FilterHelperArgsOpts | false;
sort?: SortHelperArgsOpts | false;
recordId?: PayloadRecordIdHelperOpts | false;
disableErrorField?: boolean;
}
updateById(opts?: UpdateByIdResolverOpts)
interface UpdateByIdResolverOpts {
suffix?: string;
record?: RecordHelperArgsOpts;
recordId?: PayloadRecordIdHelperOpts | false;
disableErrorField?: boolean;
}
updateMany(opts?: UpdateManyResolverOpts)
interface UpdateManyResolverOpts {
suffix?: string;
record?: RecordHelperArgsOpts;
filter?: FilterHelperArgsOpts | false;
sort?: SortHelperArgsOpts | false;
limit?: LimitHelperArgsOpts | false;
skip?: false;
disableErrorField?: boolean;
}
updateOne(opts?: UpdateOneResolverOpts)
interface UpdateOneResolverOpts {
suffix?: string;
record?: RecordHelperArgsOpts;
filter?: FilterHelperArgsOpts | false;
sort?: SortHelperArgsOpts | false;
skip?: false;
recordId?: PayloadRecordIdHelperOpts | false;
disableErrorField?: boolean;
}
Description of common resolvers' options
FilterHelperArgsOpts
type FilterHelperArgsOpts = {
onlyIndexed?: boolean;
removeFields?: string | string[];
requiredFields?: string | string[];
operators?: FieldsOperatorsConfig | false;
isRequired?: boolean;
baseTypeName?: string;
prefix?: string;
suffix?: string;
};
SortHelperArgsOpts
type SortHelperArgsOpts = {
multi?: boolean;
sortTypeName?: string;
};
RecordHelperArgsOpts
type RecordHelperArgsOpts = {
removeFields?: string[];
requiredFields?: string[];
allFieldsNullable?: boolean;
prefix?: string;
suffix?: string;
isRequired?: boolean;
};
LimitHelperArgsOpts
type LimitHelperArgsOpts = {
defaultValue?: number;
};
FAQ
Can I get generated vanilla GraphQL types?
const UserTC = composeMongoose(User);
UserTC.getType();
UserTC.getInputType();
UserTC.get('languages').getType();
UserTC.get('fieldWithNesting.subNesting').getType();
How to add custom fields?
UserTC.addFields({
lonLat: ObjectTypeComposer.create('type LonLat { lon: Float, lat: Float }'),
notice: 'String',
noticeList: {
type: '[String]',
description: 'Array of notices',
resolve: (source, args, context, info) => 'some value',
},
bio: {
type: GraphQLString,
description: 'Providing vanilla GraphQL type'
}
})
How to build nesting/relations?
Suppose you User
model has friendsIds
field with array of user ids. So let build some relations:
UserTC.addRelation(
'friends',
{
resolver: () => UserTC.mongooseResolvers.dataLoaderMany(),
prepareArgs: {
_ids: (source) => source.friendsIds,
},
projection: { friendsIds: 1 },
}
);
UserTC.addRelation(
'adultFriendsWithSameGender',
{
resolver: () => UserTC.mongooseResolvers.findMany(),
prepareArgs: {
filter: (source) => ({
_operators : {
_id : { in: source.friendsIds },
age: { gt: 21 }
},
gender: source.gender,
}),
limit: 10,
},
projection: { friendsIds: 1, gender: 1 },
}
);
Reusing the same mongoose Schema in embedded object fields
Suppose you have a common structure you use as embedded object in multiple Schemas.
Also suppose you want the structure to have the same GraphQL type across all parent types.
(For instance, to allow reuse of fragments for this type)
Here are Schemas to demonstrate:
import { Schema } from 'mongoose';
const ImageDataStructure = Schema({
url: String,
dimensions : {
width: Number,
height: Number
}
}, { _id: false });
const UserProfile = Schema({
fullName: String,
personalImage: ImageDataStructure
});
const Article = Schema({
title: String,
heroImage: ImageDataStructure
});
If you want the ImageDataStructure
to use the same GraphQL type in both Article
and UserProfile
you will need create it as a mongoose schema (not a standard javascript object) and to explicitly tell graphql-compose-mongoose
the name you want it to have. Otherwise, without the name, it would generate the name according to the first parent this type was embedded in.
Do the following:
import { schemaComposer } from 'graphql-compose';
import { convertSchemaToGraphQL } from 'graphql-compose-mongoose';
convertSchemaToGraphQL(ImageDataStructure, 'EmbeddedImage', schemaComposer);
Before continuing to convert your models to TypeComposers:
import mongoose from 'mongoose';
import { composeMongoose } from 'graphql-compose-mongoose';
const UserProfile = mongoose.model('UserProfile', UserProfile);
const Article = mongoose.model('Article', Article);
const UserProfileTC = composeMongoose(UserProfile);
const ArticleTC = composeMongoose(Article);
Then, you can use queries like this:
query {
topUser {
fullName
personalImage {
...fullImageData
}
}
topArticle {
title
heroImage {
...fullImageData
}
}
}
fragment fullImageData on EmbeddedImage {
url
dimensions {
width height
}
}
Access and modify mongoose doc before save
This library provides some amount of ready resolvers for fetch and update data which was mentioned above. And you can create your own resolver of course. However you can find that add some actions or light modifications of mongoose document directly before save at existing resolvers appears more simple than create new resolver. Some of resolvers accepts before save hook which can be provided in resolver params as param named beforeRecordMutate
. This hook allows to have access and modify mongoose document before save. The resolvers which supports this hook are:
- createOne
- createMany
- removeById
- removeOne
- updateById
- updateOne
The prototype of before save hook:
(doc: mixed, rp: ResolverResolveParams) => Promise<*>,
The typical implementation may be like this:
rp.beforeRecordMutate = async function(doc, rp) {
doc.userTouchedAt = new Date();
const canMakeUpdate = await performAsyncTask( ...provide data from doc... )
if (!canMakeUpdate) {
throw new Error('Forbidden!');
}
return doc;
}
You can provide your implementation directly in type composer:
UserTC.wrapResolverResolve('updateById', next => async rp => {
rp.beforeRecordMutate = async (doc, resolveParams) => { ... };
return next(rp);
});
or you can create wrappers for example to protect access:
function adminAccess(resolvers) {
Object.keys(resolvers).forEach((k) => {
resolvers[k] = resolvers[k].wrapResolve(next => async rp => {
rp.beforeRecordMutate = async function(doc, rp) { ... }
return next(rp)
})
})
return resolvers
}
schemaComposer.Mutation.addFields({
createResource: ResourceTC.mongooseResolvers.createOne(),
createResources: ResourceTC.mongooseResolvers.createMany(),
...adminAccess({
updateResource: ResourceTC.mongooseResolvers.updateById(),
removeResource: ResourceTC.mongooseResolvers.removeById(),
}),
});
How can I push/pop or add/remove values to arrays?
The default resolvers, by design, will replace (overwrite) any supplied array object when using e.g. updateById
. If you want to push or pop a value in an array you can use a custom resolver with a native MongoDB call.
For example (push):
schemaComposer.Mutation.addFields({
userPushToArray: {
type: UserTC,
args: { userId: 'MongoID!', valueToPush: 'String' },
resolve: async (source, args, context, info) => {
const user = await User.update(
{ _id: args.userId },
{ $push: { arrayToPushTo: args.valueToPush } }
);
if (!user) return null
return User.findOne({ _id: args.userId })
}
}
})
User
is the corresponding Mongoose model. If you do not wish to allow duplicates in the array then replace $push
with $addToSet
. Read the graphql-compose docs on custom resolvers for more info: https://graphql-compose.github.io/docs/en/basics-resolvers.html
NB if you set unique: true
on the array then using the update
$push
approach will not check for duplicates, this is due to a MongoDB bug: https://jira.mongodb.org/browse/SERVER-1068. For more usage examples with $push
and arrays see the MongoDB docs here https://docs.mongodb.com/manual/reference/operator/update/push/. Also note that $push
will preserve order in the array (append to end of array) whereas $addToSet
will not.
Is it possible to use several schemas?
By default composeMongoose
uses global schemaComposer
for generated types. If you need to create different GraphQL schemas you need create own schemaComposer
s and provide them to customizationOptions
:
import { SchemaComposer } from 'graphql-compose';
const schema1 = new SchemaComposer();
const schema2 = new SchemaComposer();
const UserTCForSchema1 = composeMongoose(User, { schemaComposer: schema1 });
const UserTCForSchema2 = composeMongoose(User, { schemaComposer: schema2 });
Embedded documents has _id
field and you don't need it?
Just turn them off in mongoose:
const UsersSchema = new Schema({
_id: { type: String }
emails: [{
_id: false,
address: { type: String },
verified: Boolean
}]
});
Can field name in schema have different name in database?
Yes, it can. This package understands mongoose alias
option for fields. Just provide alias: 'country'
for field c
and you get country
field name in GraphQL schema and Mongoose model but c
field in database:
const childSchema = new Schema({
c: {
type: String,
alias: 'country'
}
});
Backers
Thank you to all our backers! 🙏 [Become a backer]
Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [Become a sponsor]
License
MIT