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).
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.
If you want to add additional resolvers connection
and/or pagination
- just install following packages and graphql-compose-mongoose
will add them automatically.
npm install graphql-compose-connection graphql-compose-pagination --save
Different builds
This library contains different builds for any purposes:
import { composeWithMongoose } from 'graphql-compose-mongoose';
import { composeWithMongoose } from 'graphql-compose-mongoose/node8';
import { composeWithMongoose } from 'graphql-compose-mongoose/es';
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, avaliable 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 { composeWithMongoose } 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,
},
languages: {
type: [LanguagesSchema],
default: [],
},
contacts: {
email: String,
phones: [String],
},
gender: {
type: String,
enum: ['male', 'female', 'ladyboy'],
},
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 = composeWithMongoose(User, customizationOptions);
schemaComposer.Query.addFields({
userById: UserTC.getResolver('findById'),
userByIds: UserTC.getResolver('findByIds'),
userOne: UserTC.getResolver('findOne'),
userMany: UserTC.getResolver('findMany'),
userCount: UserTC.getResolver('count'),
userConnection: UserTC.getResolver('connection'),
userPagination: UserTC.getResolver('pagination'),
});
schemaComposer.Mutation.addFields({
userCreateOne: UserTC.getResolver('createOne'),
userCreateMany: UserTC.getResolver('createMany'),
userUpdateById: UserTC.getResolver('updateById'),
userUpdateOne: UserTC.getResolver('updateOne'),
userUpdateMany: UserTC.getResolver('updateMany'),
userRemoveById: UserTC.getResolver('removeById'),
userRemoveOne: UserTC.getResolver('removeOne'),
userRemoveMany: UserTC.getResolver('removeMany'),
});
const graphqlSchema = schemaComposer.buildSchema();
export default graphqlSchema;
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 { composeWithMongooseDiscriminators } from 'graphql-compose-mongoose';
const DKey = 'type';
const enumCharacterType = {
PERSON: 'Person',
DROID: 'Droid',
};
const CharacterSchema = new mongoose.Schema({
type: {
type: String,
require: 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 = composeWithMongooseDiscriminators(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 },
},
},
});
});
});
FAQ
Can I get generated vanilla GraphQL types?
const UserTC = composeWithMongoose(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.getResolver('findByIds'),
prepareArgs: {
_ids: (source) => source.friendsIds,
},
projection: { friendsIds: 1 },
}
);
UserTC.addRelation(
'adultFriendsWithSameGender',
{
resolver: () => UserTC.get('$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 { composeWithMongoose } from 'graphql-compose-mongoose';
const UserProfile = mongoose.model('UserProfile', UserProfile);
const Article = mongoose.model('Article', Article);
const UserProfileTC = composeWithMongoose(UserProfile);
const ArticleTC = composeWithMongoose(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.getResolver('createOne'),
createResources: ResourceTC.getResolver('createMany'),
...adminAccess({
updateResource: ResourceTC.getResolver('updateById'),
removeResource: ResourceTC.getResolver('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):-
UserTC.addResolver({
name: 'pushToArray',
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 })
}
})
schemaComposer.Mutation.addFields({userPushToArray: UserTC.getResolver('pushToArray')})
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.
Customization options
When we convert model const UserTC = composeWithMongoose(User, customizationOptions);
you may tune every piece of future derived types and resolvers.
Here is flow typed definition of this options:
The top level of customization options. Here you setup name and description for the main type, remove fields or leave only desired fields.
export type typeConverterOpts = {
name?: string,
description?: string,
fields?: {
only?: string[],
remove?: string[],
},
inputType?: typeConverterInputTypeOpts,
resolvers?: false | typeConverterResolversOpts,
};
This is opts.inputType
level of options for default InputTypeObject which will be provided to all resolvers for filter
and input
args.
export type typeConverterInputTypeOpts = {
name?: string,
description?: string,
fields?: {
only?: string[],
remove?: string[],
required?: string[]
},
};
This is opts.resolvers
level of options.
If you set the option to false
it will disable resolver or some of its input args.
Every resolver's arg has it own options. They described below.
export type typeConverterResolversOpts = {
findById?: false,
findByIds?: false | {
limit?: limitHelperArgsOpts | false,
sort?: sortHelperArgsOpts | false,
},
findOne?: false | {
filter?: filterHelperArgsOpts | false,
sort?: sortHelperArgsOpts | false,
skip?: false,
},
findMany?: false | {
filter?: filterHelperArgsOpts | false,
sort?: sortHelperArgsOpts | false,
limit?: limitHelperArgsOpts | false,
skip?: false,
},
updateById?: false | {
record?: recordHelperArgsOpts | false,
},
updateOne?: false | {
record?: recordHelperArgsOpts | false,
filter?: filterHelperArgsOpts | false,
sort?: sortHelperArgsOpts | false,
skip?: false,
},
updateMany?: false | {
record?: recordHelperArgsOpts | false,
filter?: filterHelperArgsOpts | false,
sort?: sortHelperArgsOpts | false,
limit?: limitHelperArgsOpts | false,
skip?: false,
},
removeById?: false,
removeOne?: false | {
filter?: filterHelperArgsOpts | false,
sort?: sortHelperArgsOpts | false,
},
removeMany?: false | {
filter?: filterHelperArgsOpts | false,
},
createOne?: false | {
record?: recordHelperArgsOpts | false,
},
createMany?: false | {
records?: recordHelperArgsOpts | false,
},
count?: false | {
filter?: filterHelperArgsOpts | false,
},
connection?: false | {
uniqueFields: string[],
sortValue: mixed,
directionFilter: (<T>(filterArg: T, cursorData: CursorDataType, isBefore: boolean) => T),
},
pagination?: false | {
perPage?: number,
},
};
This is opts.resolvers.[resolverName].[filter|sort|record|limit]
level of options.
You may tune every resolver's args independently as you wish.
Here you may setup every argument and override some fields from the default input object type, described above in opts.inputType
.
export type filterHelperArgsOpts = {
filterTypeName?: string,
isRequired?: boolean,
onlyIndexed?: boolean,
requiredFields?: string | string[],
operators?: filterOperatorsOpts | false,
};
export type filterOperatorNames = 'gt' | 'gte' | 'lt' | 'lte' | 'ne' | 'in[]' | 'nin[]';
export type filterOperatorsOpts = { [fieldName: string]: filterOperatorNames[] | false };
export type sortHelperArgsOpts = {
sortTypeName?: string,
};
export type recordHelperArgsOpts = {
recordTypeName?: string,
isRequired?: boolean,
removeFields?: string[],
requiredFields?: string[],
};
export type limitHelperArgsOpts = {
defaultValue?: number,
};
Used plugins
This plugin adds connection
resolver. Build in mechanism allows sort by any unique indexes (not only by id). Also supported compound sorting (by several fields).
Besides standard connection arguments first
, last
, before
and after
, also added great arguments:
filter
arg - for filtering recordssort
arg - for sorting records
This plugin completely follows to Relay Cursor Connections Specification.
This plugin adds pagination
resolver.
License
MIT