diff --git a/demo/Page/Page.model.js b/demo/Page/Page.model.js index e8a8dc9908..e57009d061 100644 --- a/demo/Page/Page.model.js +++ b/demo/Page/Page.model.js @@ -1,5 +1,6 @@ import mongoose from 'mongoose'; -import mongooseApiQuery from '../../src/utils/mongooseApiQuery'; +import buildQuery from '../../src/plugins/buildQuery'; +import paginate from '../../src/plugins/paginate'; import mongooseIntl from 'mongoose-intl'; import payloadConfig from '.././payload.config'; import { schemaBaseFields } from '../../src/helpers/mongoose/schemaBaseFields'; @@ -15,12 +16,13 @@ const PageSchema = new mongoose.Schema({ { timestamps: true } ); -PageSchema.plugin(mongooseApiQuery); +PageSchema.plugin(paginate); +PageSchema.plugin(buildQuery); const formattedIntl = { defaultLanguage: payloadConfig.localization.defaultLocale, languages: payloadConfig.localization.locales -} +}; PageSchema.plugin(mongooseIntl, formattedIntl); diff --git a/src/plugins/buildQuery.js b/src/plugins/buildQuery.js new file mode 100644 index 0000000000..198ce2a059 --- /dev/null +++ b/src/plugins/buildQuery.js @@ -0,0 +1,189 @@ +/* eslint-disable no-use-before-define */ + +export default function buildQuery(schema) { + + schema.statics.apiQuery = function (rawParams) { + const model = this; + const params = paramParser(this, rawParams); + + // Create the Mongoose Query object. + let query = model + .find(params.searchParams); + + if (params.sort) + query = query.sort(params.sort); + + return query; + }; +} + +function paramParser(model, rawParams) { + + let query = { + searchParams: {}, + sort: false + }; + + // Construct searchParams + for (const key in rawParams) { + const separatedParams = rawParams[key] + .match(/{\w+}(.[^{}]*)/g); + + if (separatedParams === null) { + query = parseParam(key, rawParams[key], model, query); + } else { + for (let i = 0; i < separatedParams.length; ++i) { + query = parseParam(key, separatedParams[i], model, query); + } + } + } + + return query; +} + +function convertToBoolean(str) { + return str.toLowerCase() === 'true' || + str.toLowerCase() === 't' || + str.toLowerCase() === 'yes' || + str.toLowerCase() === 'y' || + str === '1'; +} + +function addSearchParam(query, key, value) { + if (typeof query.searchParams[key] !== 'undefined') { + for (let i in value) { + query.searchParams[key][i] = value[i]; + } + } else { + query.searchParams[key] = value; + } + return query; +} + +function parseParam(key, val, model, query) { + const lcKey = key; + let operator = val.match(/\{(.*)\}/); + val = val.replace(/\{(.*)\}/, ''); + + if (operator) operator = operator[1]; + + if (val === '') { + return {}; + } else if (lcKey === 'sort_by' || lcKey === 'order_by') { + const parts = val.split(','); + query.sort = {}; + query.sort[parts[0]] = parts[1] === 'asc' || parts.length <= 1 ? 1 : parts[1]; + } else if (lcKey === 'include') { + if (val.match(',')) { + let orArray = []; + val.split(',').map(id => orArray.push({_id: id})); + query = addSearchParam(query, '$or', orArray); + } else + query.searchParams['_id'] = val; + } else if (lcKey === 'exclude') { + if (val.match(',')) { + let andArray = []; + val.split(',').map(id => andArray.push({_id: {$ne: id}})); + query = addSearchParam(query, '$and', andArray); + } else + query.searchParams['_id'] = {$ne: val}; + } else { + query = parseSchemaForKey(model.schema, query, '', lcKey, val, operator); + } + return query; +} + +function parseSchemaForKey(schema, query, keyPrefix, lcKey, val, operator) { + let paramType; + const key = keyPrefix + lcKey; + + let matches = lcKey.match(/(.+)\.(.+)/); + if (matches) { + // Parse SubSchema + if (schema.paths[matches[1]].constructor.name === 'DocumentArray' || + schema.paths[matches[1]].constructor.name === 'Mixed') { + parseSchemaForKey(schema.paths[matches[1]].schema, `${matches[1]}.`, matches[2], val, operator) + } + else if (schema.paths[matches[1]].constructor.name === 'SchemaType'){ + // This wasn't handled in the original package but seems to work + paramType = schema.paths[matches[1]].schema.paths.name.instance; + } + } else if (typeof schema === 'undefined') { + paramType = 'String'; + } else if (typeof schema.paths[lcKey] === 'undefined') { + // nada, not found + } else if (schema.paths[lcKey].constructor.name === 'SchemaBoolean') { + paramType = 'Boolean'; + } else if (schema.paths[lcKey].constructor.name === 'SchemaString') { + paramType = 'String'; + } else if (schema.paths[lcKey].constructor.name === 'SchemaNumber') { + paramType = 'Number'; + } else if (schema.paths[lcKey].constructor.name === 'ObjectId') { + paramType = 'ObjectId'; + } else if (schema.paths[lcKey].constructor.name === 'SchemaArray') { + paramType = 'Array'; + } + + if (paramType === 'Boolean') { + addSearchParam(query, key, convertToBoolean(val)); + } else if (paramType === 'Number') { + if (val.match(/([0-9]+,?)/) && val.match(',')) { + if (operator === 'all') { + addSearchParam(query, key, {$all: val.split(',')}); + } else if (operator === 'nin') { + addSearchParam(query, key, {$nin: val.split(',')}); + } else if (operator === 'mod') { + addSearchParam(query, key, {$mod: [val.split(',')[0], val.split(',')[1]]}); + } else { + addSearchParam(query, key, {$in: val.split(',')}); + } + } else if (val.match(/([0-9]+)/)) { + if (operator === 'gt' || + operator === 'gte' || + operator === 'lt' || + operator === 'lte' || + operator === 'ne') { + let newParam = {}; + newParam['$' + operator] = val; + addSearchParam(query, key, newParam); + } else { + addSearchParam(query, key, parseInt(val)); + } + } + } else if (paramType === 'String') { + if (val.match(',')) { + const options = val.split(',').map(str => new RegExp(str, 'i')); + + if (operator === 'all') { + addSearchParam(query, key, {$all: options}); + } else if (operator === 'nin') { + addSearchParam(query, key, {$nin: options}); + } else { + addSearchParam(query, key, {$in: options}); + } + } else if (val.match(/([0-9]+)/)) { + if (operator === 'gt' || + operator === 'gte' || + operator === 'lt' || + operator === 'lte') { + let newParam = {}; + newParam['$' + operator] = val; + addSearchParam(query, key, newParam); + } else { + addSearchParam(query, key, val); + } + } else if (operator === 'ne' || operator === 'not') { + const neregex = new RegExp(val, 'i'); + addSearchParam(query, key, {'$not': neregex}); + } else if (operator === 'like') { + addSearchParam(query, key, {$regex: val, $options: '-i'}); + } else { + addSearchParam(query, key, val); + } + } else if (paramType === 'ObjectId') { + addSearchParam(query, key, val); + } else if (paramType === 'Array') { + addSearchParam(query, key, val); + } + return query; +} diff --git a/src/plugins/paginate.js b/src/plugins/paginate.js new file mode 100644 index 0000000000..bb4a3deb9c --- /dev/null +++ b/src/plugins/paginate.js @@ -0,0 +1,155 @@ +/** + * @param {Object} [query={}] + * @param {Object} [options={}] + * @param {Object|String} [options.select] + * @param {Object|String} [options.sort] + * @param {Object|String} [options.customLabels] + * @param {Object} [options.collation] + * @param {Array|Object|String} [options.populate] + * @param {Boolean} [options.lean=false] + * @param {Boolean} [options.leanWithId=true] + * @param {Number} [options.offset=0] - Use offset or page to set skip position + * @param {Number} [options.page=1] + * @param {Number} [options.limit=10] + * @param {Function} [callback] + * + * @returns {Promise} + */ + +function paginate(query, options, callback) { + + query = query || {}; + options = Object.assign({}, paginate.options, options); + options.customLabels = options.customLabels ? options.customLabels : {}; + + var defaultLimit = 10; + + var select = options.select; + var sort = options.sort; + var collation = options.collation || {}; + var populate = options.populate; + var lean = options.lean || false; + var leanWithId = options.hasOwnProperty('leanWithId') ? options.leanWithId : true; + var limit = options.hasOwnProperty('limit') ? parseInt(options.limit) : defaultLimit; + var skip; + var offset; + var page; + + // Custom Labels + var labelTotal = options.customLabels.totalDocs ? options.customLabels.totalDocs : 'totalDocs'; + var labelLimit = options.customLabels.limit ? options.customLabels.limit : 'limit'; + var labelPage = options.customLabels.page ? options.customLabels.page : 'page'; + var labelTotalPages = options.customLabels.totalPages ? options.customLabels.totalPages : 'totalPages'; + var labelDocs = options.customLabels.docs ? options.customLabels.docs : 'docs'; + var labelNextPage = options.customLabels.nextPage ? options.customLabels.nextPage : 'nextPage'; + var labelPrevPage = options.customLabels.prevPage ? options.customLabels.prevPage : 'prevPage'; + var labelPagingCounter = options.customLabels.pagingCounter ? options.customLabels.pagingCounter : 'pagingCounter'; + + if (options.hasOwnProperty('offset')) { + offset = parseInt(options.offset); + skip = offset; + } else if (options.hasOwnProperty('page')) { + page = parseInt(options.page); + skip = (page - 1) * limit; + } else { + offset = 0; + page = 1; + skip = offset; + } + + const count = this.countDocuments(query).exec(); + + const model = this.find(query); + model.select(select); + model.sort(sort); + model.lean(lean); + + // Hack for mongo < v3.4 + if (Object.keys(collation).length > 0) { + model.collation(collation); + } + + if (limit) { + model.skip(skip); + model.limit(limit); + } + + if (populate) { + model.populate(populate); + } + + var docs = model.exec(); + + if (lean && leanWithId) { + docs = docs.then(function (docs) { + docs.forEach(function (doc) { + doc.id = String(doc._id); + }); + return docs; + }); + } + + return Promise.all([count, docs]) + .then(function (values) { + + var result = { + [labelDocs]: values[1], + [labelTotal]: values[0], + [labelLimit]: limit + }; + + if (offset !== undefined) { + result.offset = offset; + } + + if (page !== undefined) { + + const pages = Math.ceil(values[0] / limit) || 1; + + result.hasPrevPage = false; + result.hasNextPage = false; + + result[labelPage] = page; + result[labelTotalPages] = pages; + result[labelPagingCounter] = ((page - 1) * limit) + 1; + + // Set prev page + if (page > 1) { + result.hasPrevPage = true; + result[labelPrevPage] = (page - 1); + } else { + result[labelPrevPage] = null; + } + + // Set next page + if (page < pages) { + result.hasNextPage = true; + result[labelNextPage] = (page + 1); + } else { + result[labelNextPage] = null; + } + } + + // Adding support for callbacks if specified. + if (typeof callback === 'function') { + return callback(null, result); + } else { + return Promise.resolve(result); + } + }).catch(function (reject) { + if (typeof callback === 'function') { + return callback(reject); + } else { + return Promise.reject(reject); + } + }); +} + +/** + * @param {Schema} schema + */ +module.exports = function (schema) { + schema.statics.paginate = paginate; +}; + +module.exports.paginate = paginate; diff --git a/src/utils/paramParser.js b/src/plugins/paramParser.js similarity index 97% rename from src/utils/paramParser.js rename to src/plugins/paramParser.js index 1ba5d87ded..d7fe5edec8 100644 --- a/src/utils/paramParser.js +++ b/src/plugins/paramParser.js @@ -1,6 +1,5 @@ /* eslint-disable no-use-before-define,camelcase */ export function paramParser(model, rawParams) { - console.log('raw params', rawParams); let query = { searchParams: {}, @@ -23,7 +22,6 @@ export function paramParser(model, rawParams) { } } - console.log('query object', query); return query; } @@ -57,8 +55,8 @@ function parseParam(key, val, model, query) { return {}; } else if (lcKey === 'page') { query.page = val; - } else if (lcKey === 'per_page' || lcKey === 'limit') { - query.per_page = parseInt(val); + } else if (lcKey === 'limit') { + query.limit = parseInt(val); } else if (lcKey === 'sort_by' || lcKey === 'order_by') { const parts = val.split(','); query.sort = {}; diff --git a/src/requestHandlers/findOne.js b/src/requestHandlers/findOne.js index 49bb7124df..319507d62e 100644 --- a/src/requestHandlers/findOne.js +++ b/src/requestHandlers/findOne.js @@ -1,7 +1,7 @@ import httpStatus from 'http-status'; const findOne = (req, res) => { - req.model.setDefaultLanguage(req.locale); + // req.model.setDefaultLanguage(req.locale); req.model.findOne({ _id: req.params._id }, (err, doc) => { if (err) diff --git a/src/requestHandlers/query.js b/src/requestHandlers/query.js index 6e2ee3a3ab..802f73af6e 100644 --- a/src/requestHandlers/query.js +++ b/src/requestHandlers/query.js @@ -1,15 +1,20 @@ import httpStatus from 'http-status'; const query = (req, res) => { - if (req.query.locale) + if (req.query.locale) { req.model.setDefaultLanguage(req.query.locale); + } - req.model.apiQuery(req.query, (err, pages) => { - if (err) + req.model.paginate(req.model.apiQuery(req.query), req.query, (err, result) => { + if (err) { return res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ error: err }); + } - return res.json(pages.map(page => page.toJSON({ virtuals: !!req.locale }))); - }); + return res.json({ + ...result, + docs: result.docs.map(doc => doc.toJSON({ virtuals: !!req.locale })) + }); + }) }; export default query; diff --git a/src/tests/mongooseApiQuery.spec.js b/src/tests/mongooseApiQuery.spec.js index 5d7ae7e27e..be2f359380 100644 --- a/src/tests/mongooseApiQuery.spec.js +++ b/src/tests/mongooseApiQuery.spec.js @@ -1,6 +1,6 @@ import mongoose from 'mongoose'; const Schema = mongoose.Schema; -import mongooseApiQuery from '../utils/mongooseApiQuery'; +import mongooseApiQuery from '../plugins/buildQuery'; const TestUserSchema = new Schema({ name: {type: String}, diff --git a/src/tests/paramParser.spec.js b/src/tests/paramParser.spec.js index fc1b89240d..49a436d6bc 100644 --- a/src/tests/paramParser.spec.js +++ b/src/tests/paramParser.spec.js @@ -1,7 +1,7 @@ /* eslint-disable camelcase */ import mongoose from 'mongoose'; const Schema = mongoose.Schema; -import {paramParser} from '../utils/paramParser'; +import {paramParser} from '../plugins/paramParser'; const AuthorSchema = new Schema({ name: String, diff --git a/src/utils/mongooseApiQuery.js b/src/utils/mongooseApiQuery.js deleted file mode 100644 index 0e28d33f17..0000000000 --- a/src/utils/mongooseApiQuery.js +++ /dev/null @@ -1,24 +0,0 @@ -import {paramParser} from './paramParser'; - -export default function apiQueryPlugin(schema) { - - schema.statics.apiQuery = function (rawParams, cb) { - const model = this; - const params = paramParser(this, rawParams); - - // Create the Mongoose Query object. - let query = model - .find(params.searchParams) - .limit(params.per_page) - .skip((params.page - 1) * params.per_page); - - if (params.sort) - query = query.sort(params.sort); - - if (cb) { - query.exec(cb); - } else { - return query; - } - }; -}