diff --git a/README.md b/README.md index 67d73aa..3f27d9f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,6 @@ This package uses [DataLoader](https://github.com/graphql/dataloader) for batchi - [Basic](#basic) - [Batching](#batching) - [Caching](#caching) - - [Mongoose](#mongoose) - [API](#api) - [findOneById](#findonebyid) - [findManyByIds](#findmanybyids) @@ -33,7 +32,7 @@ This package uses [DataLoader](https://github.com/graphql/dataloader) for batchi ### Basic -The basic setup is subclassing `MongoDataSource`, passing your collection to the constructor, and using the [API methods](#API): +The basic setup is subclassing `MongoDataSource`, passing your collection or Mongoose model to the constructor, and using the [API methods](#API): ```js import { MongoDataSource } from 'apollo-datasource-mongodb' @@ -50,18 +49,18 @@ and: ```js import Users from './data-sources/Users.js' -const users = db.collection('users') - const server = new ApolloServer({ typeDefs, resolvers, dataSources: () => ({ - db: new Users({ users }) + users: new Users(db.collection('users')) + // OR + // users: new Users(UserModel) }) }) ``` -The collection is available at `this.users` (e.g. `this.users.update({_id: 'foo, { $set: { name: 'me' }}})`). The request's context is available at `this.context`. For example, if you put the logged-in user's ID on context as `context.currentUserId`: +Inside the data source, the collection is available at `this.collection` (e.g. `this.collection.update({_id: 'foo, { $set: { name: 'me' }}})`). The model (if applicable) is available at `this.model` (`new this.model({ name: 'Alice' })`). The request's context is available at `this.context`. For example, if you put the logged-in user's ID on context as `context.currentUserId`: ```js class Users extends MongoDataSource { @@ -88,26 +87,6 @@ class Users extends MongoDataSource { } ``` -### Mongoose - -You can use mongoose the same way as with the native mongodb client - -```js -import mongoose from 'mongoose' -import Users from './data-sources/Users.js' - -const userSchema = new mongoose.Schema({ name: 'string'}); -const UsersModel = mongoose.model('users', userSchema); - -const server = new ApolloServer({ - typeDefs, - resolvers, - dataSources: () => ({ - db: new Users({ users: UsersModel }) - }) -}) -``` - ### Batching This is the main feature, and is always enabled. Here's a full example: @@ -134,15 +113,12 @@ const resolvers = { } } -const users = db.collection('users') -const posts = db.collection('posts') - const server = new ApolloServer({ typeDefs, resolvers, dataSources: () => ({ - users: new Users({ users }), - posts: new Posts({ posts }) + users: new Users(db.collection('users')), + posts: new Posts(db.collection('posts')) }) }) ``` @@ -161,7 +137,7 @@ class Users extends MongoDataSource { updateUserName(userId, newName) { this.deleteFromCacheById(userId) - return this.users.updateOne({ + return this.collection.updateOne({ _id: userId }, { $set: { name: newName } diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..95495de --- /dev/null +++ b/jest.config.js @@ -0,0 +1,3 @@ +module.exports = { + testEnvironment: 'node' +} diff --git a/package-lock.json b/package-lock.json index f67ff93..ef08567 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "apollo-datasource-mongodb", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -3219,6 +3219,12 @@ "dev": true, "optional": true }, + "bluebird": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz", + "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==", + "dev": true + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -3290,6 +3296,12 @@ "node-int64": "0.4.0" } }, + "bson": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.1.tgz", + "integrity": "sha512-jCGVYLoYMHDkOsbwJZBCqwMHyH4c+wzgI9hG7Z6SZJRXWr+x58pdIbm2i9a/jFGCkRJqRUr8eoI7lDWa0hTkxg==", + "dev": true + }, "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", @@ -5788,6 +5800,12 @@ "verror": "1.10.0" } }, + "kareem": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.3.1.tgz", + "integrity": "sha512-l3hLhffs9zqoDe8zjmb/mAN4B8VT3L56EUvKNqLFVs9YlFA+zx7ke1DO8STAdDyYNkeSo1nKmjuvQeI12So8Xw==", + "dev": true + }, "kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -6030,6 +6048,80 @@ "minimist": "0.0.8" } }, + "mongodb": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.3.2.tgz", + "integrity": "sha512-fqJt3iywelk4yKu/lfwQg163Bjpo5zDKhXiohycvon4iQHbrfflSAz9AIlRE6496Pm/dQKQK5bMigdVo2s6gBg==", + "dev": true, + "requires": { + "bson": "1.1.1", + "require_optional": "1.0.1", + "safe-buffer": "5.1.2" + } + }, + "mongoose": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-5.7.4.tgz", + "integrity": "sha512-IgqQS5HIaZ8tG2cib6QllfIw2Wc/A0QVOsdKLsSqRolqJFWOjI0se3vsKXLNkbEcuJ1xziW3e/jPhBs65678Hg==", + "dev": true, + "requires": { + "bson": "1.1.1", + "kareem": "2.3.1", + "mongodb": "3.3.2", + "mongoose-legacy-pluralize": "1.0.2", + "mpath": "0.6.0", + "mquery": "3.2.2", + "ms": "2.1.2", + "regexp-clone": "1.0.0", + "safe-buffer": "5.1.2", + "sift": "7.0.1", + "sliced": "1.0.1" + }, + "dependencies": { + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "mongoose-legacy-pluralize": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.2.tgz", + "integrity": "sha512-Yo/7qQU4/EyIS8YDFSeenIvXxZN+ld7YdV9LqFVQJzTLye8unujAWPZ4NWKfFA+RNjh+wvTWKY9Z3E5XM6ZZiQ==", + "dev": true + }, + "mpath": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.6.0.tgz", + "integrity": "sha512-i75qh79MJ5Xo/sbhxrDrPSEG0H/mr1kcZXJ8dH6URU5jD/knFxCVqVC/gVSW7GIXL/9hHWlT9haLbCXWOll3qw==", + "dev": true + }, + "mquery": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-3.2.2.tgz", + "integrity": "sha512-XB52992COp0KP230I3qloVUbkLUxJIu328HBP2t2EsxSFtf4W1HPSOBWOXf1bqxK4Xbb66lfMJ+Bpfd9/yZE1Q==", + "dev": true, + "requires": { + "bluebird": "3.5.1", + "debug": "3.1.0", + "regexp-clone": "1.0.0", + "safe-buffer": "5.1.2", + "sliced": "1.0.1" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -6892,6 +6984,12 @@ "safe-regex": "1.1.0" } }, + "regexp-clone": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/regexp-clone/-/regexp-clone-1.0.0.tgz", + "integrity": "sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw==", + "dev": true + }, "regexp-tree": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.5.tgz", @@ -6994,6 +7092,24 @@ "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=", "dev": true }, + "require_optional": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.1.tgz", + "integrity": "sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==", + "dev": true, + "requires": { + "resolve-from": "2.0.0", + "semver": "5.7.0" + }, + "dependencies": { + "resolve-from": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz", + "integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c=", + "dev": true + } + } + }, "resolve": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.10.0.tgz", @@ -7153,6 +7269,12 @@ "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", "dev": true }, + "sift": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/sift/-/sift-7.0.1.tgz", + "integrity": "sha512-oqD7PMJ+uO6jV9EQCl0LrRw1OwsiPsiFQR5AR30heR+4Dl7jBBbDLnNvWiak20tzZlSE1H7RB30SX/1j/YYT7g==", + "dev": true + }, "signal-exit": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", @@ -7171,6 +7293,12 @@ "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", "dev": true }, + "sliced": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz", + "integrity": "sha1-CzpmK10Ewxd7GSa+qCsD+Dei70E=", + "dev": true + }, "snapdragon": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", diff --git a/package.json b/package.json index 052324b..c83d89b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apollo-datasource-mongodb", - "version": "0.1.0", + "version": "0.2.0", "description": "Apollo data source for MongoDB", "main": "dist/index.js", "scripts": { @@ -27,6 +27,8 @@ "babel-jest": "^24.7.1", "graphql": "^14.2.1", "jest": "^24.7.1", + "mongodb": "^3.3.2", + "mongoose": "^5.7.4", "prettier": "^1.16.4", "waait": "^1.0.4" }, diff --git a/src/__tests__/datasource.test.js b/src/__tests__/datasource.test.js index 0ecd32b..dcf3ef9 100644 --- a/src/__tests__/datasource.test.js +++ b/src/__tests__/datasource.test.js @@ -1,6 +1,10 @@ +import { MongoClient } from 'mongodb' +import mongoose, { Schema, model } from 'mongoose' + import { MongoDataSource } from '../datasource' +import { isModel, isCollectionOrModel, getCollection } from '../helpers' -const users = {} +mongoose.set('useFindAndModify', false) class Users extends MongoDataSource { initialize(config) { @@ -10,9 +14,78 @@ class Users extends MongoDataSource { describe('MongoDataSource', () => { it('sets up caching functions', () => { - const source = new Users({ users }) - source.initialize({}) + const users = {} + const source = new Users(users) + source.initialize() expect(source.findOneById).toBeDefined() - expect(source.users).toEqual(users) + expect(source.collection).toEqual(users) + }) +}) + +const URL = 'mongodb://localhost:27017/test' +const connectArgs = [ + URL, + { + useNewUrlParser: true, + useUnifiedTopology: true + } +] + +const connect = async () => { + const client = new MongoClient(...connectArgs) + await mongoose.connect(...connectArgs) + await client.connect() + return client.db() +} + +describe('Mongoose', () => { + let UserModel + let userCollection + let alice + + beforeAll(async () => { + const userSchema = new Schema({ name: 'string' }) + UserModel = model('User', userSchema) + + const db = await connect() + userCollection = db.collection('users') + alice = await UserModel.findOneAndUpdate( + { name: 'Alice' }, + { name: 'Alice' }, + { upsert: true } + ) + }) + + test('isCollectionOrModel', () => { + expect(isCollectionOrModel(userCollection)).toBe(true) + expect(isCollectionOrModel(UserModel)).toBe(true) + expect(isCollectionOrModel(Function.prototype)).toBe(false) + expect(isCollectionOrModel(undefined)).toBe(false) + }) + + test('isModel', () => { + expect(isModel(userCollection)).toBe(false) + expect(isModel(UserModel)).toBe(true) + expect(isCollectionOrModel(Function.prototype)).toBe(false) + expect(isCollectionOrModel(undefined)).toBe(false) + }) + + test('getCollectionName', () => { + expect(getCollection(userCollection).collectionName).toBe('users') + expect(getCollection(UserModel).collectionName).toBe('users') + }) + + test('data source', async () => { + const users = new Users(UserModel) + users.initialize() + const user = await users.findOneById(alice._id) + expect(user.name).toBe('Alice') + }) + + test('collection', async () => { + const users = new Users(userCollection) + users.initialize() + const user = await users.findOneById(alice._id) + expect(user.name).toBe('Alice') }) }) diff --git a/src/cache.js b/src/cache.js index 9045d79..d109857 100644 --- a/src/cache.js +++ b/src/cache.js @@ -1,6 +1,9 @@ import DataLoader from 'dataloader' -const remapDocs = (docs, ids) => { +import { getCollection } from './helpers' + +// https://github.com/graphql/dataloader#batch-function +const orderDocs = ids => docs => { const idMap = {} docs.forEach(doc => { idMap[doc._id] = doc @@ -9,21 +12,14 @@ const remapDocs = (docs, ids) => { } export const createCachingMethods = ({ collection, cache }) => { - const isMongoose = typeof collection === 'function' - const loader = new DataLoader(ids => - isMongoose - ? collection - .find({ _id: { $in: ids } }) - .lean() - .then(docs => remapDocs(docs, ids)) - : collection - .find({ _id: { $in: ids } }) - .toArray() - .then(docs => remapDocs(docs, ids)) + collection + .find({ _id: { $in: ids } }) + .toArray() + .then(orderDocs(ids)) ) - const cachePrefix = `mongo-${collection.collectionName}-` + const cachePrefix = `mongo-${getCollection(collection).collectionName}-` const methods = { findOneById: async (id, { ttl } = {}) => { diff --git a/src/datasource.js b/src/datasource.js index 01cd1cb..7100531 100644 --- a/src/datasource.js +++ b/src/datasource.js @@ -3,33 +3,35 @@ import { ApolloError } from 'apollo-server-errors' import { InMemoryLRUCache } from 'apollo-server-caching' import { createCachingMethods } from './cache' +import { isCollectionOrModel, isModel } from './helpers' class MongoDataSource extends DataSource { constructor(collection) { super() - const setUpCorrectly = - typeof collection === 'object' && Object.keys(collection).length === 1 - if (!setUpCorrectly) { + if (!isCollectionOrModel(collection)) { throw new ApolloError( - 'MongoDataSource constructor must be given an object with a single collection' + 'MongoDataSource constructor must be given a collection or Mongoose model' ) } - this.collectionName = Object.keys(collection)[0] - this[this.collectionName] = collection[this.collectionName] + if (isModel(collection)) { + this.model = collection + this.collection = this.model.collection + } else { + this.collection = collection + } } // https://github.com/apollographql/apollo-server/blob/master/packages/apollo-datasource/src/index.ts - initialize(config) { - this.context = config.context - - const cache = config.cache || new InMemoryLRUCache() + initialize({ context, cache } = {}) { + this.context = context const methods = createCachingMethods({ - collection: this[this.collectionName], - cache + collection: this.collection, + cache: cache || new InMemoryLRUCache() }) + Object.assign(this, methods) } } diff --git a/src/helpers.js b/src/helpers.js new file mode 100644 index 0000000..2652627 --- /dev/null +++ b/src/helpers.js @@ -0,0 +1,8 @@ +const TYPEOF_COLLECTION = 'object' + +export const isModel = x => Boolean(x && x.name === 'model') + +export const isCollectionOrModel = x => + Boolean(x && (typeof x === TYPEOF_COLLECTION || isModel(x))) + +export const getCollection = x => (isModel(x) ? x.collection : x)