From 97fef4cac91c86e4d33e9010705775fa9f160e96 Mon Sep 17 00:00:00 2001 From: Hagop Jamkojian Date: Sun, 17 May 2020 13:39:42 +0200 Subject: [PATCH] Add pagination mongoose plugin closes #13 --- src/controllers/user.controller.js | 7 +- src/models/plugins.js | 66 ++++++++++++++ src/models/token.model.js | 2 +- src/models/user.model.js | 3 +- src/models/utils.js | 36 -------- src/routes/v1/user.route.js | 23 ++++- src/services/user.service.js | 6 +- src/utils/query.utils.js | 17 ---- tests/integration/user.test.js | 86 +++++++++++++++---- .../models/{utils.test.js => plugins.test.js} | 2 +- 10 files changed, 165 insertions(+), 83 deletions(-) create mode 100644 src/models/plugins.js delete mode 100644 src/models/utils.js delete mode 100644 src/utils/query.utils.js rename tests/unit/models/{utils.test.js => plugins.test.js} (97%) diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js index dc185746..6649eaa8 100644 --- a/src/controllers/user.controller.js +++ b/src/controllers/user.controller.js @@ -3,7 +3,6 @@ const { pick } = require('lodash'); const ApiError = require('../utils/ApiError'); const catchAsync = require('../utils/catchAsync'); const { userService } = require('../services'); -const { getQueryOptions } = require('../utils/query.utils'); const createUser = catchAsync(async (req, res) => { const user = await userService.createUser(req.body); @@ -11,9 +10,9 @@ const createUser = catchAsync(async (req, res) => { }); const getUsers = catchAsync(async (req, res) => { - const filter = pick(req.query, ['name', 'role']); - const options = getQueryOptions(req.query); - const users = await userService.getUsers(filter, options); + const query = pick(req.query, ['name', 'role']); + const options = pick(req.query, ['sortBy', 'limit', 'page']); + const users = await userService.queryUsers(query, options); res.send(users); }); diff --git a/src/models/plugins.js b/src/models/plugins.js new file mode 100644 index 00000000..ee0fb4b6 --- /dev/null +++ b/src/models/plugins.js @@ -0,0 +1,66 @@ +/* eslint-disable no-param-reassign */ + +/** + * A mongoose schema plugin which applies the following in the toJSON transform call: + * - removes __v, createdAt, updatedAt, and any path that has private: true + * - replaces _id with id + */ +const toJSON = (schema) => { + let transform; + if (schema.options.toJSON && schema.options.toJSON.transform) { + transform = schema.options.toJSON.transform; + } + + schema.options.toJSON = Object.assign(schema.options.toJSON || {}, { + transform(doc, ret, options) { + Object.keys(schema.paths).forEach((path) => { + if (schema.paths[path].options && schema.paths[path].options.private) { + delete ret[path]; + } + }); + + ret.id = ret._id.toString(); + delete ret._id; + delete ret.__v; + delete ret.createdAt; + delete ret.updatedAt; + if (transform) { + return transform(doc, ret, options); + } + }, + }); +}; + +const paginate = (schema) => { + schema.statics.paginate = async function (query, options) { + const sort = {}; + if (options.sortBy) { + const parts = options.sortBy.split(':'); + sort[parts[0]] = parts[1] === 'desc' ? -1 : 1; + } + const limit = options.limit && parseInt(options.limit, 10) > 0 ? parseInt(options.limit, 10) : 10; + const page = options.page && parseInt(options.page, 10) > 0 ? parseInt(options.page, 10) : 1; + const skip = (page - 1) * limit; + + const countPromise = this.countDocuments(query).exec(); + const docsPromise = this.find(query).sort(sort).skip(skip).limit(limit).exec(); + + return Promise.all([countPromise, docsPromise]).then((values) => { + const [totalResults, results] = values; + const totalPages = Math.ceil(totalResults / limit); + const result = { + results, + page, + limit, + totalPages, + totalResults, + }; + return Promise.resolve(result); + }); + }; +}; + +module.exports = { + toJSON, + paginate, +}; diff --git a/src/models/token.model.js b/src/models/token.model.js index bfc634e5..ba9e9b37 100644 --- a/src/models/token.model.js +++ b/src/models/token.model.js @@ -1,5 +1,5 @@ const mongoose = require('mongoose'); -const { toJSON } = require('./utils'); +const { toJSON } = require('./plugins'); const tokenSchema = mongoose.Schema( { diff --git a/src/models/user.model.js b/src/models/user.model.js index e09a2df1..ec4b572d 100644 --- a/src/models/user.model.js +++ b/src/models/user.model.js @@ -1,7 +1,7 @@ const mongoose = require('mongoose'); const validator = require('validator'); const bcrypt = require('bcryptjs'); -const { toJSON } = require('./utils'); +const { toJSON, paginate } = require('./plugins'); const { roles } = require('../config/roles'); const userSchema = mongoose.Schema( @@ -48,6 +48,7 @@ const userSchema = mongoose.Schema( // add plugin that converts mongoose to json userSchema.plugin(toJSON); +userSchema.plugin(paginate); userSchema.statics.isEmailTaken = async function (email, excludeUserId) { const user = await this.findOne({ email, _id: { $ne: excludeUserId } }); diff --git a/src/models/utils.js b/src/models/utils.js deleted file mode 100644 index 9dd55e8c..00000000 --- a/src/models/utils.js +++ /dev/null @@ -1,36 +0,0 @@ -/* eslint-disable no-param-reassign */ - -/** - * A mongoose schema plugin which applies the following in the toJSON transform call: - * - removes __v, createdAt, updatedAt, and any path that has private: true - * - replaces _id with id - */ -const toJSON = (schema) => { - let transform; - if (schema.options.toJSON && schema.options.toJSON.transform) { - transform = schema.options.toJSON.transform; - } - - schema.options.toJSON = Object.assign(schema.options.toJSON || {}, { - transform(doc, ret, options) { - Object.keys(schema.paths).forEach((path) => { - if (schema.paths[path].options && schema.paths[path].options.private) { - delete ret[path]; - } - }); - - ret.id = ret._id.toString(); - delete ret._id; - delete ret.__v; - delete ret.createdAt; - delete ret.updatedAt; - if (transform) { - return transform(doc, ret, options); - } - }, - }); -}; - -module.exports = { - toJSON, -}; diff --git a/src/routes/v1/user.route.js b/src/routes/v1/user.route.js index 487ce6e0..c4cfee83 100644 --- a/src/routes/v1/user.route.js +++ b/src/routes/v1/user.route.js @@ -108,7 +108,7 @@ module.exports = router; * schema: * type: integer * minimum: 1 - * default: 100 + * default: 10 * description: Maximum number of users * - in: query * name: page @@ -123,9 +123,24 @@ module.exports = router; * content: * application/json: * schema: - * type: array - * items: - * $ref: '#/components/schemas/User' + * type: object + * properties: + * results: + * type: array + * items: + * $ref: '#/components/schemas/User' + * page: + * type: integer + * example: 1 + * limit: + * type: integer + * example: 10 + * totalPages: + * type: integer + * example: 1 + * totalResults: + * type: integer + * example: 1 * "401": * $ref: '#/components/responses/Unauthorized' * "403": diff --git a/src/services/user.service.js b/src/services/user.service.js index 44a08b5f..466ad125 100644 --- a/src/services/user.service.js +++ b/src/services/user.service.js @@ -10,8 +10,8 @@ const createUser = async (userBody) => { return user; }; -const getUsers = async (filter, options) => { - const users = await User.find(filter, null, options); +const queryUsers = async (query, options) => { + const users = await User.paginate(query, options); return users; }; @@ -47,7 +47,7 @@ const deleteUserById = async (userId) => { module.exports = { createUser, - getUsers, + queryUsers, getUserById, getUserByEmail, updateUserById, diff --git a/src/utils/query.utils.js b/src/utils/query.utils.js deleted file mode 100644 index 4874f63d..00000000 --- a/src/utils/query.utils.js +++ /dev/null @@ -1,17 +0,0 @@ -const getQueryOptions = (query) => { - const page = query.page * 1 || 1; - const limit = query.limit * 1 || 100; - const skip = (page - 1) * limit; - - const sort = {}; - if (query.sortBy) { - const parts = query.sortBy.split(':'); - sort[parts[0]] = parts[1] === 'desc' ? -1 : 1; - } - - return { limit, skip, sort }; -}; - -module.exports = { - getQueryOptions, -}; diff --git a/tests/integration/user.test.js b/tests/integration/user.test.js index fcb22271..6edfa07f 100644 --- a/tests/integration/user.test.js +++ b/tests/integration/user.test.js @@ -135,7 +135,7 @@ describe('User routes', () => { }); describe('GET /v1/users', () => { - test('should return 200 and all users', async () => { + test('should return 200 and apply the default query options', async () => { await insertUsers([userOne, userTwo, admin]); const res = await request(app) @@ -144,9 +144,15 @@ describe('User routes', () => { .send() .expect(httpStatus.OK); - expect(res.body).toBeInstanceOf(Array); - expect(res.body).toHaveLength(3); - expect(res.body[0]).toEqual({ + expect(res.body).toEqual({ + results: expect.any(Array), + page: 1, + limit: 10, + totalPages: 1, + totalResults: 3, + }); + expect(res.body.results).toHaveLength(3); + expect(res.body.results[0]).toEqual({ id: userOne._id.toHexString(), name: userOne.name, email: userOne.email, @@ -180,8 +186,15 @@ describe('User routes', () => { .send() .expect(httpStatus.OK); - expect(res.body).toHaveLength(1); - expect(res.body[0].id).toBe(userOne._id.toHexString()); + expect(res.body).toEqual({ + results: expect.any(Array), + page: 1, + limit: 10, + totalPages: 1, + totalResults: 1, + }); + expect(res.body.results).toHaveLength(1); + expect(res.body.results[0].id).toBe(userOne._id.toHexString()); }); test('should correctly apply filter on role field', async () => { @@ -194,9 +207,16 @@ describe('User routes', () => { .send() .expect(httpStatus.OK); - expect(res.body).toHaveLength(2); - expect(res.body[0].id).toBe(userOne._id.toHexString()); - expect(res.body[1].id).toBe(userTwo._id.toHexString()); + expect(res.body).toEqual({ + results: expect.any(Array), + page: 1, + limit: 10, + totalPages: 1, + totalResults: 2, + }); + expect(res.body.results).toHaveLength(2); + expect(res.body.results[0].id).toBe(userOne._id.toHexString()); + expect(res.body.results[1].id).toBe(userTwo._id.toHexString()); }); test('should correctly sort returned array if descending sort param is specified', async () => { @@ -209,8 +229,17 @@ describe('User routes', () => { .send() .expect(httpStatus.OK); - expect(res.body).toHaveLength(3); - expect(res.body[0].id).toBe(userOne._id.toHexString()); + expect(res.body).toEqual({ + results: expect.any(Array), + page: 1, + limit: 10, + totalPages: 1, + totalResults: 3, + }); + expect(res.body.results).toHaveLength(3); + expect(res.body.results[0].id).toBe(userOne._id.toHexString()); + expect(res.body.results[1].id).toBe(userTwo._id.toHexString()); + expect(res.body.results[2].id).toBe(admin._id.toHexString()); }); test('should correctly sort returned array if ascending sort param is specified', async () => { @@ -223,8 +252,17 @@ describe('User routes', () => { .send() .expect(httpStatus.OK); - expect(res.body).toHaveLength(3); - expect(res.body[0].id).toBe(admin._id.toHexString()); + expect(res.body).toEqual({ + results: expect.any(Array), + page: 1, + limit: 10, + totalPages: 1, + totalResults: 3, + }); + expect(res.body.results).toHaveLength(3); + expect(res.body.results[0].id).toBe(admin._id.toHexString()); + expect(res.body.results[1].id).toBe(userOne._id.toHexString()); + expect(res.body.results[2].id).toBe(userTwo._id.toHexString()); }); test('should limit returned array if limit param is specified', async () => { @@ -237,7 +275,16 @@ describe('User routes', () => { .send() .expect(httpStatus.OK); - expect(res.body).toHaveLength(2); + expect(res.body).toEqual({ + results: expect.any(Array), + page: 1, + limit: 2, + totalPages: 2, + totalResults: 3, + }); + expect(res.body.results).toHaveLength(2); + expect(res.body.results[0].id).toBe(userOne._id.toHexString()); + expect(res.body.results[1].id).toBe(userTwo._id.toHexString()); }); test('should return the correct page if page and limit params are specified', async () => { @@ -250,8 +297,15 @@ describe('User routes', () => { .send() .expect(httpStatus.OK); - expect(res.body).toHaveLength(1); - expect(res.body[0].id).toBe(admin._id.toHexString()); + expect(res.body).toEqual({ + results: expect.any(Array), + page: 2, + limit: 2, + totalPages: 2, + totalResults: 3, + }); + expect(res.body.results).toHaveLength(1); + expect(res.body.results[0].id).toBe(admin._id.toHexString()); }); }); diff --git a/tests/unit/models/utils.test.js b/tests/unit/models/plugins.test.js similarity index 97% rename from tests/unit/models/utils.test.js rename to tests/unit/models/plugins.test.js index b4ba9c5d..e8c41a55 100644 --- a/tests/unit/models/utils.test.js +++ b/tests/unit/models/plugins.test.js @@ -1,5 +1,5 @@ const mongoose = require('mongoose'); -const { toJSON } = require('../../../src/models/utils'); +const { toJSON } = require('../../../src/models/plugins'); describe('Model utils', () => { describe('toJSON plugin', () => {