From 9d158303ff1e137818784488d4d470f86d89b5d1 Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Fri, 14 Sep 2018 00:10:53 +0200 Subject: [PATCH 01/44] Add pluralize lib to strapi-utils --- packages/strapi-utils/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/strapi-utils/package.json b/packages/strapi-utils/package.json index efed5b83ba..2743267f7a 100644 --- a/packages/strapi-utils/package.json +++ b/packages/strapi-utils/package.json @@ -23,6 +23,7 @@ "knex": "^0.13.0", "lodash": "^4.17.5", "pino": "^4.7.1", + "pluralize": "^7.0.0", "shelljs": "^0.7.7" }, "author": { From de878dac4ddc4a3baba294804c653fe82a13d245 Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Fri, 14 Sep 2018 00:19:20 +0200 Subject: [PATCH 02/44] Add a utility function to get the table name for a many-to-many relation --- packages/strapi-utils/lib/models.js | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/strapi-utils/lib/models.js b/packages/strapi-utils/lib/models.js index f690a032b1..440f0a3a17 100644 --- a/packages/strapi-utils/lib/models.js +++ b/packages/strapi-utils/lib/models.js @@ -9,6 +9,7 @@ const path = require('path'); // Public node modules. const _ = require('lodash'); +const pluralize = require('pluralize'); // Following this discussion https://stackoverflow.com/questions/18082/validate-decimal-numbers-in-javascript-isnumeric this function is the best implem to determine if a value is a valid number candidate const isNumeric = (value) => { @@ -311,6 +312,16 @@ module.exports = { return _.get(strapi.models, collectionIdentity.toLowerCase() + '.orm'); }, + /** + * Return table name for a collection many-to-many + */ + getCollectionName: (associationA, associationB) => { + return [associationA, associationB] + .sort((a, b) => a.collection < b.collection ? -1 : 1) + .map(table => _.snakeCase(`${pluralize.plural(table.collection)} ${pluralize.plural(table.via)}`)) + .join('__'); + }, + /** * Define associations key to models */ @@ -340,7 +351,7 @@ module.exports = { // Build associations object if (association.hasOwnProperty('collection') && association.collection !== '*') { - definition.associations.push({ + const ast = { alias: key, type: 'collection', collection: association.collection, @@ -350,7 +361,13 @@ module.exports = { dominant: details.dominant !== true, plugin: association.plugin || undefined, filter: details.filter, - }); + }; + + if (infos.nature === 'manyToMany' && !association.plugin && definition.orm === 'bookshelf') { + ast.tableCollectionName = this.getCollectionName(association, details); + } + + definition.associations.push(ast); } else if (association.hasOwnProperty('model') && association.model !== '*') { definition.associations.push({ alias: key, From 5506aef71042ea3c34a472846ba1c4d8b0fcff6f Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Fri, 14 Sep 2018 00:19:55 +0200 Subject: [PATCH 03/44] feat(filters): add logic to parse relations filter if exist --- packages/strapi-utils/lib/models.js | 43 +++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/strapi-utils/lib/models.js b/packages/strapi-utils/lib/models.js index 440f0a3a17..74c28b6ef6 100644 --- a/packages/strapi-utils/lib/models.js +++ b/packages/strapi-utils/lib/models.js @@ -466,6 +466,7 @@ module.exports = { const convertor = strapi.hook[connector].load().getQueryParams; const convertParams = { where: {}, + relations: {}, sort: '', start: 0, limit: 100 @@ -503,26 +504,44 @@ module.exports = { const [attr, order = 'ASC'] = formattedValue.split(':'); result = convertor(order, key, attr); } else { - const suffix = key.split('_'); + let type = '='; - // Mysql stores boolean as 1 or 0 - if (client === 'mysql' && _.get(models, [model, 'attributes', suffix, 'type']) === 'boolean') { - formattedValue = value === 'true' ? '1' : '0'; + if (key.match(/_{1}(?:ne|lte?|gte?|containss?|in)/)) { + type = key.match(/_{1}(?:ne|lte?|gte?|containss?|in)/)[0]; + key = key.replace(type, ''); } - let type; + if (key.includes('.')) { + // Check if it's a valid relation + const [relationName, relationKey] = key.split('.'); + const relationAttribute = models[model] && models[model].attributes[relationName]; - if (_.includes(['ne', 'lt', 'gt', 'lte', 'gte', 'contains', 'containss', 'in'], _.last(suffix))) { - type = `_${_.last(suffix)}`; - key = _.dropRight(suffix).join('_'); + if (relationAttribute && ( + relationAttribute.hasOwnProperty('collection') || + relationAttribute.hasOwnProperty('model') + )) { + // Mysql stores boolean as 1 or 0 + const field = models[relationAttribute.collection ? relationAttribute.collection : relationAttribute.model].attributes[relationKey]; + if (client === 'mysql' && field.type && field.type === 'boolean') { + formattedValue = value === 'true' ? '1' : '0'; + } + + result = convertor(formattedValue, type, relationKey); + result.key = result.key.replace('where.', `relations.${relationName}.`); + } } else { - type = '='; - } + // Mysql stores boolean as 1 or 0 + if (client === 'mysql' && _.get(models, [model, 'attributes', key, 'type']) === 'boolean') { + formattedValue = value === 'true' ? '1' : '0'; + } - result = convertor(formattedValue, type, key); + result = convertor(formattedValue, type, key); + } } - _.set(convertParams, result.key, result.value); + if (result) { + _.set(convertParams, result.key, result.value); + } }); return convertParams; From ff0a87df7a21a97f45377087356c5ff9d920fdfa Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Fri, 14 Sep 2018 00:22:57 +0200 Subject: [PATCH 04/44] Update Bookshelf/Mongoose service template to support relation attribute filtering --- .../templates/bookshelf/service.template | 49 ++++++++++- .../templates/mongoose/service.template | 88 ++++++++++++++++--- 2 files changed, 124 insertions(+), 13 deletions(-) diff --git a/packages/strapi-generate-api/templates/bookshelf/service.template b/packages/strapi-generate-api/templates/bookshelf/service.template index 90657eb78c..4b67cac521 100644 --- a/packages/strapi-generate-api/templates/bookshelf/service.template +++ b/packages/strapi-generate-api/templates/bookshelf/service.template @@ -1,4 +1,5 @@ 'use strict'; +/* global <%= globalID %> */ /** * <%= filename %> service @@ -32,7 +33,7 @@ module.exports = { _.forEach(filters.where, (where, key) => { if (_.isArray(where.value) && where.symbol !== 'IN') { for (const value in where.value) { - qb[value ? 'where' : 'orWhere'](key, where.symbol, where.value[value]) + qb[value ? 'where' : 'orWhere'](key, where.symbol, where.value[value]); } } else { qb.where(key, where.symbol, where.value); @@ -43,6 +44,48 @@ module.exports = { qb.orderBy(filters.sort.key, filters.sort.order); } + Object.keys(filters.relations).forEach( + (relationName) => { + const ast = <%= globalID.toLowerCase() %>.associations.find(a => a.alias === relationName); + if (ast) { + const model = ast.plugin ? + strapi.plugins[ast.plugin].models[ast.model ? ast.model : ast.collection] : + strapi.models[ast.model ? ast.model : ast.collection]; + + qb.distinct(); + + if (ast.tableCollectionName) { + qb.innerJoin( + ast.tableCollectionName, + `${ast.tableCollectionName}.${<%= globalID.toLowerCase() %>.info.name}_${<%= globalID.toLowerCase() %>.primaryKey}`, + `${<%= globalID.toLowerCase() %>.collectionName}.${<%= globalID.toLowerCase() %>.primaryKey}`, + ); + qb.innerJoin( + `${relationName}`, + `${relationName}.${<%= globalID.toLowerCase() %>.attributes[relationName].column}`, + `${ast.tableCollectionName}.${<%= globalID.toLowerCase() %>.attributes[relationName].attribute}_${<%= globalID.toLowerCase() %>.attributes[relationName].column}`, + ); + } else { + const relationTable = model.collectionName; + const externalKey = ast.type === 'collection' ? + `${model.collectionName}.${ast.via}` : + `${model.collectionName}.${model.primaryKey}`; + const internalKey = !ast.dominant ? `${<%= globalID.toLowerCase() %>.collectionName}.${<%= globalID.toLowerCase() %>.primaryKey}` : + ast.via === <%= globalID.toLowerCase() %>.collectionName ? `${<%= globalID.toLowerCase() %>.collectionName}.${ast.alias}` : `${<%= globalID.toLowerCase() %>.collectionName}.${<%= globalID.toLowerCase() %>.primaryKey}`; + + qb.innerJoin(relationTable, externalKey, internalKey); + } + + const relation = filters.relations[relationName]; + Object.keys(relation).forEach( + (filter) => { + qb.where(`${model.collectionName}.${filter}`, `${relation[filter].symbol}`, `${relation[filter].value}`); + } + ); + } + } + ); + qb.offset(filters.start); qb.limit(filters.limit); }).fetchAll({ @@ -81,7 +124,7 @@ module.exports = { _.forEach(filters.where, (where, key) => { if (_.isArray(where.value)) { for (const value in where.value) { - qb[value ? 'where' : 'orWhere'](key, where.symbol, where.value[value]) + qb[value ? 'where' : 'orWhere'](key, where.symbol, where.value[value]); } } else { qb.where(key, where.symbol, where.value); @@ -154,7 +197,7 @@ module.exports = { await <%= globalID %>.updateRelations(params); return <%= globalID %>.forge(params).destroy(); - }, + }, /** * Promise to search a/an <%= id %>. diff --git a/packages/strapi-generate-api/templates/mongoose/service.template b/packages/strapi-generate-api/templates/mongoose/service.template index f5de552e9d..b3fc91db8e 100644 --- a/packages/strapi-generate-api/templates/mongoose/service.template +++ b/packages/strapi-generate-api/templates/mongoose/service.template @@ -1,4 +1,5 @@ 'use strict'; +/* global <%= globalID %> */ /** * <%= filename %> service @@ -20,19 +21,86 @@ module.exports = { fetchAll: (params) => { // Convert `params` object to filters compatible with Mongo. const filters = strapi.utils.models.convertParams('<%= globalID.toLowerCase() %>', params); + // Select field to populate. const populate = <%= globalID %>.associations - .filter(ast => ast.autoPopulate !== false) - .map(ast => ast.alias) - .join(' '); + .filter(ast => ast.autoPopulate) + .reduce((acc, ast) => { + const from = ast.plugin ? `${ast.plugin}_${ast.model}` : ast.collection ? ast.collection : ast.model; + const as = ast.alias; + const localField = !ast.dominant ? '_id' : ast.via === <%= globalID %>.collectionName || ast.via === 'related' ? '_id' : ast.alias; + const foreignField = ast.filter ? `${ast.via}.ref` : + ast.dominant ? + (ast.via === <%= globalID %>.collectionName ? ast.via : '_id') : + (ast.via === <%= globalID %>.collectionName ? '_id' : ast.via); - return <%= globalID %> - .find() - .where(filters.where) - .sort(filters.sort) + // Add the juncture like the `.populate()` function + acc.push({ + $lookup: { + from, + localField, + foreignField, + as, + } + }); + + // Unwind the relation's result if only one is expected + if (ast.type === 'model') { + acc.push({ + $unwind: { + path: `$${ast.alias}`, + preserveNullAndEmptyArrays: true + } + }); + } + + // Preserve relation field if it is empty + acc.push({ + $addFields: { + [ast.alias]: { + $ifNull: [`$${ast.alias}`, null] + } + } + }); + + // Filtrate the result depending of params + if (filters.relations) { + Object.keys(filters.relations).forEach( + (relationName) => { + if (ast.alias === relationName) { + const association = <%= globalID %>.associations.find(a => a.alias === relationName); + if (association) { + const relation = filters.relations[relationName]; + + Object.keys(relation).forEach( + (filter) => { + acc.push({ + $match: { [`${relationName}.${filter}`]: relation[filter] } + }); + } + ); + } + } + } + ); + } + + return acc; + }, []); + + const result = <%= globalID %> + .aggregate([ + { + $match: filters.where + }, + ...populate, + ]) .skip(filters.start) - .limit(filters.limit) - .populate(populate); + .limit(filters.limit); + + if (filters.sort) result.sort(filters.sort); + + return result; }, /** @@ -146,7 +214,7 @@ module.exports = { ); return data; - }, + }, /** * Promise to search a/an <%= id %>. From f8d844a48acd13c371fa471d078b528aa68a14a8 Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Fri, 14 Sep 2018 00:23:27 +0200 Subject: [PATCH 05/44] Update user-permissions query logic for both bookshelf/mongoose --- .../config/queries/bookshelf.js | 52 +++++++++++- .../config/queries/mongoose.js | 80 +++++++++++++++++-- 2 files changed, 120 insertions(+), 12 deletions(-) diff --git a/packages/strapi-plugin-users-permissions/config/queries/bookshelf.js b/packages/strapi-plugin-users-permissions/config/queries/bookshelf.js index e2382a70dd..e2e2f98313 100644 --- a/packages/strapi-plugin-users-permissions/config/queries/bookshelf.js +++ b/packages/strapi-plugin-users-permissions/config/queries/bookshelf.js @@ -2,7 +2,7 @@ const _ = require('lodash'); module.exports = { find: async function (params = {}, populate) { - const records = await this.query(function(qb) { + const records = await this.query((qb) => { _.forEach(params.where, (where, key) => { if (_.isArray(where.value)) { for (const value in where.value) { @@ -28,10 +28,54 @@ module.exports = { qb.orderBy(params.sort); } } + + if (params.relations) { + Object.keys(params.relations).forEach( + (relationName) => { + const ast = this.associations.find(a => a.alias === relationName); + if (ast) { + const model = ast.plugin ? + strapi.plugins[ast.plugin].models[ast.model ? ast.model : ast.collection] : + strapi.models[ast.model ? ast.model : ast.collection]; + + qb.distinct(); + + if (ast.tableCollectionName) { + qb.innerJoin( + ast.tableCollectionName, + `${ast.tableCollectionName}.${this.info.name}_${this.primaryKey}`, + `${this.collectionName}.${this.primaryKey}`, + ); + qb.innerJoin( + `${relationName}`, + `${relationName}.${this.attributes[relationName].column}`, + `${ast.tableCollectionName}.${this.attributes[relationName].attribute}_${this.attributes[relationName].column}`, + ); + } else { + const relationTable = model.collectionName; + const externalKey = ast.type === 'collection' ? + `${model.collectionName}.${ast.via}` : + `${model.collectionName}.${model.primaryKey}`; + const internalKey = !ast.dominant ? `${this.collectionName}.${this.primaryKey}` : + ast.via === this.collectionName ? `${this.collectionName}.${ast.alias}` : `${this.collectionName}.${this.primaryKey}`; + + qb.innerJoin(relationTable, externalKey, internalKey); + } + + const relation = params.relations[relationName]; + Object.keys(params.relations[relationName]).forEach( + (filter) => { + qb.where(`${model.collectionName}.${filter}`, `${relation[filter].symbol}`, `${relation[filter].value}`); + } + ); + } + } + ); + } }) - .fetchAll({ - withRelated: populate || _.keys(_.groupBy(_.reject(this.associations, { autoPopulate: false }), 'alias')) - }); + .fetchAll({ + withRelated: populate || _.keys(_.groupBy(_.reject(this.associations, { autoPopulate: false }), 'alias')) + }); return records ? records.toJSON() : records; diff --git a/packages/strapi-plugin-users-permissions/config/queries/mongoose.js b/packages/strapi-plugin-users-permissions/config/queries/mongoose.js index 53ce2cd17a..cbf9eb52f1 100644 --- a/packages/strapi-plugin-users-permissions/config/queries/mongoose.js +++ b/packages/strapi-plugin-users-permissions/config/queries/mongoose.js @@ -1,14 +1,78 @@ const _ = require('lodash'); module.exports = { - find: async function (params = {}, populate) { - return this - .find(params.where) - .limit(Number(params.limit)) - .sort(params.sort) - .skip(Number(params.skip)) - .populate(populate || this.associations.map(x => x.alias).join(' ')) - .lean(); + find: async function (params = {}) { + let collectionName = this.collectionName; + if (this.collectionName.split('_')) { + collectionName = this.collectionName.split('_')[this.collectionName.split('_').length - 1]; + } + + const populate = this.associations + .filter(ast => ast.autoPopulate) + .reduce((acc, ast) => { + const from = ast.plugin ? `${ast.plugin}_${ast.model}` : ast.collection ? ast.collection : ast.model; + const as = ast.alias; + const localField = !ast.dominant ? '_id' : ast.via === collectionName || ast.via === 'related' ? '_id' : ast.alias; + const foreignField = ast.filter ? `${ast.via}.ref` : + ast.dominant ? + (ast.via === collectionName ? ast.via : '_id') : + (ast.via === collectionName ? '_id' : ast.via); + + acc.push({ + $lookup: { + from, + localField, + foreignField, + as, + } + }); + + if (ast.type === 'model') { + acc.push({ + $unwind: { + path: `$${ast.alias}`, + preserveNullAndEmptyArrays: true + } + }); + } + + if (params.relations) { + Object.keys(params.relations).forEach( + (relationName) => { + if (ast.alias === relationName) { + const association = this.associations.find(a => a.alias === relationName); + if (association) { + const relation = params.relations[relationName]; + + Object.keys(relation).forEach( + (filter) => { + acc.push({ + $match: { [`${relationName}.${filter}`]: relation[filter] } + }); + } + ); + } + } + } + ); + } + + return acc; + }, []); + + const result = this + .aggregate([ + { + $match: params.where ? params.where : {} + }, + ...populate, + ]); + + if (params.start) result.skip(params.start); + if (params.limit) result.limit(params.limit); + if (params.sort) result.sort(params.sort); + + return result; }, count: async function (params = {}) { From 3593046c41b6dc55d3973c6abefe74f2943ab58b Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Fri, 14 Sep 2018 00:23:40 +0200 Subject: [PATCH 06/44] Add some docs --- docs/3.x.x/guides/filters.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/3.x.x/guides/filters.md b/docs/3.x.x/guides/filters.md index 03f431d2dc..bafc26acdb 100644 --- a/docs/3.x.x/guides/filters.md +++ b/docs/3.x.x/guides/filters.md @@ -37,6 +37,16 @@ Find products having a price equal or greater than `3`. `GET /products?price_gte=3` +#### Relations + You can also use filters into a relation attribute which will be applied to the first level of the request. + Find users having written a post named `Title`. + `GET /user?posts.name=Title` + Find posts written by a user having more than 12 years old. + `GET /post?author.age_gt=12` + > Note: You can't use filter to have specific results inside relation, like "Find users and only their posts older than yesterday" as example. If you need it, you can modify or create your own service ou use [GraphQL](./graphql.md#query-api). + + > Warning: this filter isn't available for `upload` plugin + ### Sort Sort according to a specific field. From e5ce8c1b42da808ed303ae32f7806422d92b09ee Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Fri, 14 Sep 2018 00:24:07 +0200 Subject: [PATCH 07/44] use the new method to generate the collection name --- packages/strapi-hook-bookshelf/lib/index.js | 48 +++++---------------- 1 file changed, 11 insertions(+), 37 deletions(-) diff --git a/packages/strapi-hook-bookshelf/lib/index.js b/packages/strapi-hook-bookshelf/lib/index.js index efd8b33b75..8688d498a0 100644 --- a/packages/strapi-hook-bookshelf/lib/index.js +++ b/packages/strapi-hook-bookshelf/lib/index.js @@ -502,7 +502,7 @@ module.exports = function(strapi) { console.log(e); } - strapi.log.warn(`The SQL database indexes haven't been generated successfully. Please enable the debug mode for more details.`); + strapi.log.warn('The SQL database indexes haven\'t been generated successfully. Please enable the debug mode for more details.'); } } }; @@ -677,24 +677,11 @@ module.exports = function(strapi) { } }; - const table = _.get(manyRelations, 'collectionName') || - _.map( - _.sortBy( - [ - collection.attributes[ - manyRelations.via - ], - manyRelations - ], - 'collection' - ), - table => { - return _.snakeCase( - // eslint-disable-next-line prefer-template - pluralize.plural(table.collection) + ' ' + pluralize.plural(table.via) - ); - } - ).join('__'); + const table = _.get(manyRelations, 'collectionName') + || utilsModels.getCollectionName( + collection.attributes[manyRelations.via], + manyRelations + ); await handler(table, attributes); } @@ -813,24 +800,11 @@ module.exports = function(strapi) { strapi.plugins[details.plugin].models[details.collection]: strapi.models[details.collection]; - const collectionName = _.get(details, 'collectionName') || - _.map( - _.sortBy( - [ - collection.attributes[ - details.via - ], - details - ], - 'collection' - ), - table => { - return _.snakeCase( - // eslint-disable-next-line prefer-template - pluralize.plural(table.collection) + ' ' + pluralize.plural(table.via) - ); - } - ).join('__'); + const collectionName = _.get(details, 'collectionName') + || utilsModels.getCollectionName( + collection.attributes[details.via], + details, + ); const relationship = _.clone( collection.attributes[details.via] From 4c3d5da8eee853ced1174d004e7a4c7139ea0fc2 Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Fri, 14 Sep 2018 00:26:07 +0200 Subject: [PATCH 08/44] Cast candidate ObjectID to Mongoose ObjectID --- packages/strapi-hook-mongoose/lib/utils/index.js | 12 +++++++++--- packages/strapi-utils/lib/models.js | 9 +++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/strapi-hook-mongoose/lib/utils/index.js b/packages/strapi-hook-mongoose/lib/utils/index.js index 8906ed5613..94b198b2fc 100644 --- a/packages/strapi-hook-mongoose/lib/utils/index.js +++ b/packages/strapi-hook-mongoose/lib/utils/index.js @@ -4,9 +4,13 @@ * Module dependencies */ -module.exports = mongoose => { - var Decimal = require('mongoose-float').loadType(mongoose, 2); - var Float = require('mongoose-float').loadType(mongoose, 20); +// Public node modules. +const mongoose = require('mongoose'); +const Mongoose = mongoose.Mongoose; + +module.exports = (mongoose = new Mongoose()) => { + const Decimal = require('mongoose-float').loadType(mongoose, 2); + const Float = require('mongoose-float').loadType(mongoose, 20); return { convertType: mongooseType => { @@ -42,5 +46,7 @@ module.exports = mongoose => { default: } }, + isObjectId: v => mongoose.Types.ObjectId.isValid(v), + toObjectId: v => mongoose.Types.ObjectId(v), }; }; diff --git a/packages/strapi-utils/lib/models.js b/packages/strapi-utils/lib/models.js index 74c28b6ef6..3403d80501 100644 --- a/packages/strapi-utils/lib/models.js +++ b/packages/strapi-utils/lib/models.js @@ -464,6 +464,8 @@ module.exports = { } const convertor = strapi.hook[connector].load().getQueryParams; + const _utils = require(path.resolve(strapi.config.appPath, 'node_modules', 'strapi-' + connector, 'lib', 'utils')); + const utils = _utils(); const convertParams = { where: {}, relations: {}, @@ -484,7 +486,6 @@ module.exports = { // Remove the filter keyword at the end let splitKey = key.split('_').slice(0,-1); splitKey = splitKey.join('_'); - if (modelAttributes[splitKey]) { fieldType = modelAttributes[splitKey]['type']; } @@ -495,7 +496,11 @@ module.exports = { ? _.toNumber(value) : value; } else { - formattedValue = value; + formattedValue = connector === 'mongoose' ? + utils.isObjectId(value) + ? utils.toObjectId(value) // This is required in order to be used inside of aggregate $match metakey + : value + : value; } if (_.includes(['_start', '_limit'], key)) { From 382f52a2e8e673d100781c7f67c6b41060ac823a Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Fri, 14 Sep 2018 00:33:18 +0200 Subject: [PATCH 09/44] Convert GraphQL params to one level-deep object --- packages/strapi-plugin-graphql/services/Resolvers.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/strapi-plugin-graphql/services/Resolvers.js b/packages/strapi-plugin-graphql/services/Resolvers.js index 45328cc731..76c9f9683e 100644 --- a/packages/strapi-plugin-graphql/services/Resolvers.js +++ b/packages/strapi-plugin-graphql/services/Resolvers.js @@ -398,7 +398,7 @@ module.exports = { queryOpts.skip = convertedParams.start; switch (association.nature) { - case 'manyToMany': { + case 'manyToMany': if (association.dominant) { const arrayOfIds = (obj[association.alias] || []).map( related => { @@ -413,10 +413,9 @@ module.exports = { [ref.primaryKey]: arrayOfIds, ...where.where, }).where; + break; } - break; // falls through - } default: // Where. queryOpts.query = strapi.utils.models.convertParams(name, { From 83da9fa38a87d82f18de5723bd16da6c399b709e Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Sat, 22 Sep 2018 21:45:22 +0200 Subject: [PATCH 10/44] [Bugfix] take the correct localField when having a manyToOne RS --- .../templates/bookshelf/service.template | 19 +++++++++++-------- .../templates/mongoose/service.template | 9 +++++++-- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/strapi-generate-api/templates/bookshelf/service.template b/packages/strapi-generate-api/templates/bookshelf/service.template index 4b67cac521..aac20bb6cd 100644 --- a/packages/strapi-generate-api/templates/bookshelf/service.template +++ b/packages/strapi-generate-api/templates/bookshelf/service.template @@ -40,17 +40,13 @@ module.exports = { } }); - if (filters.sort) { - qb.orderBy(filters.sort.key, filters.sort.order); - } - Object.keys(filters.relations).forEach( (relationName) => { const ast = <%= globalID.toLowerCase() %>.associations.find(a => a.alias === relationName); if (ast) { const model = ast.plugin ? - strapi.plugins[ast.plugin].models[ast.model ? ast.model : ast.collection] : - strapi.models[ast.model ? ast.model : ast.collection]; + strapi.plugins[ast.plugin].models[ast.model || ast.collection] : + strapi.models[ast.model || ast.collection]; qb.distinct(); @@ -70,8 +66,11 @@ module.exports = { const externalKey = ast.type === 'collection' ? `${model.collectionName}.${ast.via}` : `${model.collectionName}.${model.primaryKey}`; - const internalKey = !ast.dominant ? `${<%= globalID.toLowerCase() %>.collectionName}.${<%= globalID.toLowerCase() %>.primaryKey}` : - ast.via === <%= globalID.toLowerCase() %>.collectionName ? `${<%= globalID.toLowerCase() %>.collectionName}.${ast.alias}` : `${<%= globalID.toLowerCase() %>.collectionName}.${<%= globalID.toLowerCase() %>.primaryKey}`; + const internalKey = !ast.dominant + ? `${<%= globalID.toLowerCase() %>.collectionName}.${<%= globalID.toLowerCase() %>.primaryKey}` + : ast.via === <%= globalID.toLowerCase() %>.collectionName + ? `${<%= globalID.toLowerCase() %>.collectionName}.${ast.alias}` + : `${<%= globalID.toLowerCase() %>.collectionName}.${<%= globalID.toLowerCase() %>.primaryKey}`; qb.innerJoin(relationTable, externalKey, internalKey); } @@ -88,6 +87,10 @@ module.exports = { qb.offset(filters.start); qb.limit(filters.limit); + + if (filters.sort) { + qb.orderBy(filters.sort.key, filters.sort.order); + } }).fetchAll({ withRelated: populate }); diff --git a/packages/strapi-generate-api/templates/mongoose/service.template b/packages/strapi-generate-api/templates/mongoose/service.template index b3fc91db8e..b96c0fd9c0 100644 --- a/packages/strapi-generate-api/templates/mongoose/service.template +++ b/packages/strapi-generate-api/templates/mongoose/service.template @@ -26,9 +26,14 @@ module.exports = { const populate = <%= globalID %>.associations .filter(ast => ast.autoPopulate) .reduce((acc, ast) => { - const from = ast.plugin ? `${ast.plugin}_${ast.model}` : ast.collection ? ast.collection : ast.model; + // Strapi Model + const model = ast.plugin + ? strapi.plugins[ast.plugin].models[ast.collection || ast.model] + : strapi.models[ast.collection || ast.model]; + + const from = model.collectionName; const as = ast.alias; - const localField = !ast.dominant ? '_id' : ast.via === <%= globalID %>.collectionName || ast.via === 'related' ? '_id' : ast.alias; + const localField = ast.dominant ? '_id' : ast.via === <%= globalID %>.collectionName || ast.via === 'related' ? '_id' : ast.alias; const foreignField = ast.filter ? `${ast.via}.ref` : ast.dominant ? (ast.via === <%= globalID %>.collectionName ? ast.via : '_id') : From d018e29a42e142315408c9ce56303f9f1d7ed1ca Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Sat, 22 Sep 2018 21:45:56 +0200 Subject: [PATCH 11/44] Use the correct hook folder path --- packages/strapi-utils/lib/models.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/strapi-utils/lib/models.js b/packages/strapi-utils/lib/models.js index 3403d80501..735d37121c 100644 --- a/packages/strapi-utils/lib/models.js +++ b/packages/strapi-utils/lib/models.js @@ -38,7 +38,6 @@ module.exports = { getPK: function (collectionIdentity, collection, models) { if (_.isString(collectionIdentity)) { const ORM = this.getORM(collectionIdentity); - try { const GraphQLFunctions = require(path.resolve(strapi.config.appPath, 'node_modules', 'strapi-' + ORM, 'lib', 'utils')); @@ -464,7 +463,7 @@ module.exports = { } const convertor = strapi.hook[connector].load().getQueryParams; - const _utils = require(path.resolve(strapi.config.appPath, 'node_modules', 'strapi-' + connector, 'lib', 'utils')); + const _utils = require(path.resolve(strapi.config.appPath, 'node_modules', 'strapi-hook-' + connector, 'lib', 'utils')); const utils = _utils(); const convertParams = { where: {}, From a84b6409945bb2149cb52d690a6dac4669ec4f9e Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Sat, 22 Sep 2018 22:01:15 +0200 Subject: [PATCH 12/44] Make converQuery works also with array values --- .../strapi-plugin-graphql/services/GraphQL.js | 1341 +++++++++++++++++ 1 file changed, 1341 insertions(+) create mode 100644 packages/strapi-plugin-graphql/services/GraphQL.js diff --git a/packages/strapi-plugin-graphql/services/GraphQL.js b/packages/strapi-plugin-graphql/services/GraphQL.js new file mode 100644 index 0000000000..05db0e565d --- /dev/null +++ b/packages/strapi-plugin-graphql/services/GraphQL.js @@ -0,0 +1,1341 @@ +'use strict'; + +/** + * GraphQL.js service + * + * @description: A set of functions similar to controller's actions to avoid code duplication. + */ + + +const fs = require('fs'); +const path = require('path'); +const _ = require('lodash'); +const pluralize = require('pluralize'); +const graphql = require('graphql'); +const { makeExecutableSchema } = require('graphql-tools'); +const GraphQLJSON = require('graphql-type-json'); +const GraphQLDateTime = require('graphql-type-datetime'); +const policyUtils = require('strapi-utils').policy; + +module.exports = { + /** + * Returns all fields of type primitive + * + * @returns {Boolean} + */ + isPrimitiveType: (_type) => { + const type = _type.replace('!', ''); + return ( + type === 'Int' || + type === 'Float' || + type === 'String' || + type === 'Boolean' || + type === 'DateTime' || + type === 'JSON' + ); + }, + + /** + * Checks if the field is of type enum + * + * @returns {Boolean} + */ + isEnumType: (type) => { + return type === 'enumeration'; + }, + + /** + * Returns all fields that are not of type array + * + * @returns {Boolean} + * + * @example + * + * isNotOfTypeArray([String]) + * // => false + * isNotOfTypeArray(String!) + * // => true + */ + isNotOfTypeArray: (type) => { + return !/(\[\w+!?\])/.test(type); + }, + + /** + * Returns all fields of type Integer or float + */ + isNumberType: (type) => { + return type === 'Int' || type === 'Float'; + }, + + /** + * Convert non-primitive type to string (non-primitive types corresponds to a reference to an other model) + * + * @returns {String} + * + * @example + * + * extractType(String!) + * // => String + * + * extractType(user) + * // => ID + * + * extractType(ENUM_TEST_FIELD, enumeration) + * // => String + * + */ + extractType: function (_type, attributeType) { + return this.isPrimitiveType(_type) + ? _type.replace('!', '') + : this.isEnumType(attributeType) + ? 'String' + : 'ID'; + }, + + /** + * Returns a list of fields that have type included in fieldTypes. + */ + getFieldsByTypes: (fields, typeCheck, returnType) => { + return _.reduce(fields, (acc, fieldType, fieldName) => { + if (typeCheck(fieldType)) { + acc[fieldName] = returnType(fieldType, fieldName); + } + return acc; + }, {}); + }, + + /** + * Use the field resolver otherwise fall through the field value + * + * @returns {function} + */ + fieldResolver: (field, key) => { + return (object) => { + const resolver = field.resolve || function resolver(obj, options, context) { // eslint-disable-line no-unused-vars + return obj[key]; + }; + return resolver(object); + }; + }, + + /** + * Create fields resolvers + * + * @return {Object} + */ + createFieldsResolver: function(fields, resolver, typeCheck) { + return Object.keys(fields).reduce((acc, fieldKey) => { + const field = fields[fieldKey]; + // Check if the field is of the correct type + if (typeCheck(field)) { + return _.set(acc, fieldKey, (obj, options, context) => { + return resolver(obj, options, context, this.fieldResolver(field, fieldKey), fieldKey, obj, field); + }); + } + return acc; + }, {}); + }, + + /** + * Build the mongoose aggregator by applying the filters + */ + getModelAggregator: function (model, filters = {}) { + const aggregation = model.aggregate(); + if (!_.isEmpty(filters.where)) { + aggregation.match(filters.where); + } + if (filters.limit) { + aggregation.limit(filters.limit); + } + return aggregation; + }, + + /** + * Create the resolvers for each aggregation field + * + * @return {Object} + * + * @example + * + * const model = // Strapi model + * + * const fields = { + * username: String, + * age: Int, + * } + * + * const typeCheck = (type) => type === 'Int' || type === 'Float', + * + * const fieldsResoler = createAggregationFieldsResolver(model, fields, 'sum', typeCheck); + * + * // => { + * age: function ageResolver() { .... } + * } + */ + createAggregationFieldsResolver: function (model, fields, operation, typeCheck) { + return this.createFieldsResolver(fields, async (obj, options, context, fieldResolver, fieldKey) => { // eslint-disable-line no-unused-vars + const result = await this.getModelAggregator(model, obj).group({ + _id: null, + [fieldKey]: { [`$${operation}`]: `$${fieldKey}` } + }); + return _.get(result, `0.${fieldKey}`); + }, typeCheck); + }, + + /** + * Correctly format the data returned by the group by + */ + preProcessGroupByData: function ({ result, fieldKey, filters, modelName }) { + const _result = _.toArray(result); + return _.map(_result, (value) => { + const params = Object.assign( + {}, + this.convertToParams(_.omit(filters, 'where')), + filters.where, + { + [fieldKey]: value._id, + } + ); + + return { + key: value._id, + connection: strapi.utils.models.convertParams(modelName, params), + }; + }); + }, + + /** + * Create the resolvers for each group by field + * + * @return {Object} + * + * @example + * + * const model = // Strapi model + * const fields = { + * username: [UserConnectionUsername], + * email: [UserConnectionEmail], + * } + * const fieldsResoler = createGroupByFieldsResolver(model, fields); + * + * // => { + * username: function usernameResolver() { .... } + * email: function emailResolver() { .... } + * } + */ + createGroupByFieldsResolver: function (model, fields, name) { + return this.createFieldsResolver(fields, async (obj, options, context, fieldResolver, fieldKey) => { + const result = await this.getModelAggregator(model, obj).group({ + _id: `$${fieldKey}`, + }); + + return this.preProcessGroupByData({ + result, + fieldKey, + filters: obj, + modelName: name, + }); + }, () => true); + }, + + /** + * This method is the entry point to the GraphQL's Aggregation. + * It takes as param the model and its fields and it'll create the aggregation types and resolver to it + * Example: + * type User { + * username: String, + * age: Int, + * } + * + * It'll create + * type UserConnection { + * values: [User], + * groupBy: UserGroupBy, + * aggreate: UserAggregate + * } + * + * type UserAggregate { + * count: Int + * sum: UserAggregateSum + * avg: UserAggregateAvg + * } + * + * type UserAggregateSum { + * age: Float + * } + * + * type UserAggregateAvg { + * age: Float + * } + * + * type UserGroupBy { + * username: [UserConnectionUsername] + * age: [UserConnectionAge] + * } + * + * type UserConnectionUsername { + * key: String + * connection: UserConnection + * } + * + * type UserConnectionAge { + * key: Int + * connection: UserConnection + * } + * + */ + formatModelConnectionsGQL: function(fields, model, name, modelResolver) { + const { globalId } = model; + + const connectionGlobalId = `${globalId}Connection`; + const aggregatorFormat = this.formatConnectionAggregator(fields, model, name); + const groupByFormat = this.formatConnectionGroupBy(fields, model, name); + const connectionFields = { + values: `[${globalId}]`, + groupBy: `${globalId}GroupBy`, + aggregate: `${globalId}Aggregator`, + }; + + let modelConnectionTypes = `type ${connectionGlobalId} {${this.formatGQL(connectionFields)}}\n\n`; + if (aggregatorFormat) { + modelConnectionTypes += aggregatorFormat.type; + } + modelConnectionTypes += groupByFormat.type; + + return { + globalId: connectionGlobalId, + type: modelConnectionTypes, + query: { + [`${pluralize.plural(name)}Connection(sort: String, limit: Int, start: Int, where: JSON)`]: connectionGlobalId, + }, + resolver: { + Query: { + [`${pluralize.plural(name)}Connection`]: (obj, options, context) => { // eslint-disable-line no-unused-vars + const params = Object.assign( + {}, + this.convertToParams(_.omit(options, 'where')), + options.where + ); + return strapi.utils.models.convertParams(name, params); + } + }, + [connectionGlobalId]: { + values: (obj, option, context) => { + // Object here contains the key/value of the field that has been grouped-by + // for instance obj = { where: { country: 'USA' } } so the values here needs to be filtered according to the parent value + return modelResolver(obj, obj, context); + }, + groupBy: (obj, option, context) => { // eslint-disable-line no-unused-vars + // There is noting to resolve here, it's the aggregation resolver that will take care of it + return obj; + }, + aggregate: (obj, option, context) => { // eslint-disable-line no-unused-vars + // There is noting to resolve here, it's the aggregation resolver that will take care of it + return obj; + }, + }, + ...aggregatorFormat.resolver, + ...groupByFormat.resolver, + }, + }; + }, + + /** + * Generate the connection type of each non-array field of the model + * + * @return {String} + */ + generateConnectionFieldsTypes: function (fields, model) { + const { globalId, attributes } = model; + const primitiveFields = this.getFieldsByTypes( + fields, + this.isNotOfTypeArray, + (type, name) => this.extractType(type, (attributes[name] || {}).type), + ); + + const connectionFields = _.mapValues(primitiveFields, (fieldType) => ({ + key: fieldType, + connection: `${globalId}Connection`, + })); + + return Object.keys(primitiveFields).map((fieldKey) => + `type ${globalId}Connection${_.upperFirst(fieldKey)} {${this.formatGQL(connectionFields[fieldKey])}}` + ).join('\n\n'); + }, + + formatConnectionGroupBy: function(fields, model, name) { + const { globalId } = model; + const groupByGlobalId = `${globalId}GroupBy`; + + // Extract all primitive fields and change their types + const groupByFields = this.getFieldsByTypes( + fields, + this.isNotOfTypeArray, + (fieldType, fieldName) => `[${globalId}Connection${_.upperFirst(fieldName)}]`, + ); + + // Get the generated field types + let groupByTypes = `type ${groupByGlobalId} {${this.formatGQL(groupByFields)}}\n\n`; + groupByTypes += this.generateConnectionFieldsTypes(fields, model); + + return { + globalId: groupByGlobalId, + type: groupByTypes, + resolver: { + [groupByGlobalId]: this.createGroupByFieldsResolver(model, groupByFields, name), + } + }; + }, + + formatConnectionAggregator: function(fields, model) { + const { globalId } = model; + + // Extract all fields of type Integer and Float and change their type to Float + const numericFields = this.getFieldsByTypes(fields, this.isNumberType, () => 'Float'); + + // Don't create an aggregator field if the model has not number fields + const aggregatorGlobalId = `${globalId}Aggregator`; + const initialFields = { + count: 'Int', + }; + + // Only add the aggregator's operations if there are some numeric fields + if (!_.isEmpty(numericFields)) { + ['sum', 'avg', 'min', 'max'].forEach((agg) => { + initialFields[agg] = `${aggregatorGlobalId}${_.startCase(agg)}`; + }); + } + + const gqlNumberFormat = this.formatGQL(numericFields); + let aggregatorTypes = `type ${aggregatorGlobalId} {${this.formatGQL(initialFields)}}\n\n`; + + let resolvers = { + [aggregatorGlobalId]: { + count: async (obj, options, context) => { // eslint-disable-line no-unused-vars + // Object here corresponds to the filter that needs to be applied to the aggregation + const result = await this.getModelAggregator(model, obj).group({ + _id: null, + count: { $sum: 1 } + }); + + return _.get(result, '0.count'); + }, + } + }; + + // Only add the aggregator's operations types and resolver if there are some numeric fields + if (!_.isEmpty(numericFields)) { + // Returns the actual object and handle aggregation in the query resolvers + const defaultAggregatorFunc = (obj, options, context) => { // eslint-disable-line no-unused-vars + return obj; + }; + + aggregatorTypes += `type ${aggregatorGlobalId}Sum {${gqlNumberFormat}}\n\n`; + aggregatorTypes += `type ${aggregatorGlobalId}Avg {${gqlNumberFormat}}\n\n`; + aggregatorTypes += `type ${aggregatorGlobalId}Min {${gqlNumberFormat}}\n\n`; + aggregatorTypes += `type ${aggregatorGlobalId}Max {${gqlNumberFormat}}\n\n`; + + _.merge(resolvers[aggregatorGlobalId], { + sum: defaultAggregatorFunc, + avg: defaultAggregatorFunc, + min: defaultAggregatorFunc, + max: defaultAggregatorFunc, + }); + + resolvers = { + ...resolvers, + [`${aggregatorGlobalId}Sum`]: this.createAggregationFieldsResolver(model, fields, 'sum', this.isNumberType), + [`${aggregatorGlobalId}Avg`]: this.createAggregationFieldsResolver(model, fields, 'avg', this.isNumberType), + [`${aggregatorGlobalId}Min`]: this.createAggregationFieldsResolver(model, fields, 'min', this.isNumberType), + [`${aggregatorGlobalId}Max`]: this.createAggregationFieldsResolver(model, fields, 'max', this.isNumberType) + }; + } + + return { + globalId: aggregatorGlobalId, + type: aggregatorTypes, + resolver: resolvers, + }; + }, + + /** + * Receive an Object and return a string which is following the GraphQL specs. + * + * @return String + */ + + formatGQL: function (fields, description = {}, model = {}, type = 'field') { + const typeFields = JSON.stringify(fields, null, 2).replace(/['",]+/g, ''); + const lines = typeFields.split('\n'); + + // Try to add description for field. + if (type === 'field') { + return lines + .map(line => { + if (['{', '}'].includes(line)) { + return ''; + } + + const split = line.split(':'); + const attribute = _.trim(split[0]); + const info = (_.isString(description[attribute]) ? description[attribute] : _.get(description[attribute], 'description')) || _.get(model, `attributes.${attribute}.description`); + const deprecated = _.get(description[attribute], 'deprecated') || _.get(model, `attributes.${attribute}.deprecated`); + + // Snakecase an attribute when we find a dash. + if (attribute.indexOf('-') !== -1) { + line = ` ${_.snakeCase(attribute)}: ${_.trim(split[1])}`; + } + + if (info) { + line = ` """\n ${info}\n """\n${line}`; + } + + if (deprecated) { + line = `${line} @deprecated(reason: "${deprecated}")`; + } + + return line; + }) + .join('\n'); + } else if (type === 'query') { + return lines + .map((line, index) => { + if (['{', '}'].includes(line)) { + return ''; + } + + const split = Object.keys(fields)[index - 1].split('('); + const attribute = _.trim(split[0]); + const info = _.get(description[attribute], 'description'); + const deprecated = _.get(description[attribute], 'deprecated'); + + // Snakecase an attribute when we find a dash. + if (attribute.indexOf('-') !== -1) { + line = ` ${_.snakeCase(attribute)}(${_.trim(split[1])}`; + } + + if (info) { + line = ` """\n ${info}\n """\n${line}`; + } + + if (deprecated) { + line = `${line} @deprecated(reason: "${deprecated}")`; + } + + return line; + }) + .join('\n'); + } + + return lines + .map((line, index) => { + if ([0, lines.length - 1].includes(index)) { + return ''; + } + + return line; + }) + .join('\n'); + }, + + /** + * Retrieve description from variable and return a string which follow the GraphQL specs. + * + * @return String + */ + + getDescription: (description, model = {}) => { + const format = '"""\n'; + + const str = _.get(description, '_description') || + _.isString(description) ? description : undefined || + _.get(model, 'info.description'); + + if (str) { + return `${format}${str}\n${format}`; + } + + return ''; + }, + + convertToParams: (params) => { + return Object.keys(params).reduce((acc, current) => { + return Object.assign(acc, { + [`_${current}`]: params[current] + }); + }, {}); + }, + + /* + * It converts GraphQL object params to a dotted query strings + * @example + * + * params = { where: { posts: { isPublished: true } } } + * + * @result + * + * params = { where: { 'posts.isPublished': true } } + * + * NB: this only works with one level deep. + */ + convertToQuery: (params) => { + return _.reduce(params, (acc, value, parentKey) => { + if (!_.isObject(value)) { + acc[parentKey] = value; + return acc; + } + + return { + ...acc, + ..._.reduce(value, (_acc, _value, _key) => ({ + ..._acc, + [`${parentKey}.${_key}`]: _value + }), {}) + }; + }, {}); + }, + + /** + * Security to avoid infinite limit. + * + * @return String + */ + + amountLimiting: (params) => { + if (params.limit && params.limit < 0) { + params.limit = 0; + } else if (params.limit && params.limit > 100) { + params.limit = 100; + } + + return params; + }, + + /** + * Convert Strapi type to GraphQL type. + * @param {Object} attribute Information about the attribute. + * @param {Object} attribute.definition Definition of the attribute. + * @param {String} attribute.modelName Name of the model which owns the attribute. + * @param {String} attribute.attributeName Name of the attribute. + * @return String + */ + + convertType: function ({ definition = {}, modelName = '', attributeName = '' }) { + // Type + if (definition.type) { + let type = 'String'; + + switch (definition.type) { + // TODO: Handle fields of type Array, Perhaps default to [Int] or [String] ... + case 'boolean': + type = 'Boolean'; + break; + case 'integer': + type = 'Int'; + break; + case 'float': + type = 'Float'; + break; + case 'json': + type = 'JSON'; + break; + case 'time': + case 'date': + case 'datetime': + case 'timestamp': + type = 'DateTime'; + break; + case 'enumeration': + type = this.convertEnumType(definition, modelName, attributeName); + break; + } + + if (definition.required) { + type += '!'; + } + + return type; + } + + const ref = definition.model || definition.collection; + + // Association + if (ref && ref !== '*') { + // Add bracket or not + const globalId = definition.plugin ? + strapi.plugins[definition.plugin].models[ref].globalId: + strapi.models[ref].globalId; + const plural = !_.isEmpty(definition.collection); + + if (plural) { + return `[${globalId}]`; + } + + return globalId; + } + + return definition.model ? 'Morph' : '[Morph]'; + }, + + /** + * Convert Strapi enumeration to GraphQL Enum. + * @param {Object} definition Definition of the attribute. + * @param {String} model Name of the model which owns the attribute. + * @param {String} field Name of the attribute. + * @return String + */ + + convertEnumType: (definition, model, field) => definition.enumName ? definition.enumName : `ENUM_${model.toUpperCase()}_${field.toUpperCase()}`, + + /** + * Execute policies before the specified resolver. + * + * @return Promise or Error. + */ + + composeResolver: function (_schema, plugin, name, isSingular) { + const params = { + model: name + }; + + const queryOpts = plugin ? { source: plugin } : {}; // eslint-disable-line no-unused-vars + + const model = plugin ? + strapi.plugins[plugin].models[name]: + strapi.models[name]; + + // Retrieve generic service from the Content Manager plugin. + const resolvers = strapi.plugins['content-manager'].services['contentmanager']; // eslint-disable-line no-unused-vars + + // Extract custom resolver or type description. + const { resolver: handler = {} } = _schema; + + let queryName; + + if (isSingular === 'force') { + queryName = name; + } else { + queryName = isSingular ? + pluralize.singular(name): + pluralize.plural(name); + } + + // Retrieve policies. + const policies = _.get(handler, `Query.${queryName}.policies`, []); + + // Retrieve resolverOf. + const resolverOf = _.get(handler, `Query.${queryName}.resolverOf`, ''); + + const policiesFn = []; + + // Boolean to define if the resolver is going to be a resolver or not. + let isController = false; + + // Retrieve resolver. It could be the custom resolver of the user + // or the shadow CRUD resolver (aka Content-Manager). + const resolver = (() => { + // Try to retrieve custom resolver. + const resolver = _.get(handler, `Query.${queryName}.resolver`); + + if (_.isString(resolver) || _.isPlainObject(resolver)) { + const { handler = resolver } = _.isPlainObject(resolver) ? resolver : {}; + + // Retrieve the controller's action to be executed. + const [ name, action ] = handler.split('.'); + + const controller = plugin ? + _.get(strapi.plugins, `${plugin}.controllers.${_.toLower(name)}.${action}`): + _.get(strapi.controllers, `${_.toLower(name)}.${action}`); + + if (!controller) { + return new Error(`Cannot find the controller's action ${name}.${action}`); + } + + // We're going to return a controller instead. + isController = true; + + // Push global policy to make sure the permissions will work as expected. + policiesFn.push( + policyUtils.globalPolicy(undefined, { + handler: `${name}.${action}` + }, undefined, plugin) + ); + + // Return the controller. + return controller; + } else if (resolver) { + // Function. + return resolver; + } + + // We're going to return a controller instead. + isController = true; + + const controllers = plugin ? strapi.plugins[plugin].controllers : strapi.controllers; + + // Try to find the controller that should be related to this model. + const controller = isSingular ? + _.get(controllers, `${name}.findOne`): + _.get(controllers, `${name}.find`); + + if (!controller) { + return new Error(`Cannot find the controller's action ${name}.${isSingular ? 'findOne' : 'find'}`); + } + + // Push global policy to make sure the permissions will work as expected. + // We're trying to detect the controller name. + policiesFn.push( + policyUtils.globalPolicy(undefined, { + handler: `${name}.${isSingular ? 'findOne' : 'find'}` + }, undefined, plugin) + ); + + // Make the query compatible with our controller by + // setting in the context the parameters. + if (isSingular) { + return async (ctx, next) => { + ctx.params = { + ...params, + [model.primaryKey]: ctx.params.id + }; + + // Return the controller. + return controller(ctx, next); + }; + } + + // Plural. + return async (ctx, next) => { + const queryOpts = {}; + queryOpts.params = this.amountLimiting(ctx.params); + // Avoid using ctx.query = ... because it converts the object values to string + queryOpts.query = Object.assign( + {}, + this.convertToParams(_.omit(ctx.params, 'where')), + this.convertToQuery(ctx.params.where) + ); + + return controller(Object.assign({}, ctx, queryOpts, { send: ctx.send }), next); // send method doesn't get copied when using object.assign + }; + })(); + + // The controller hasn't been found. + if (_.isError(resolver)) { + return resolver; + } + + // Force policies of another action on a custom resolver. + if (_.isString(resolverOf) && !_.isEmpty(resolverOf)) { + // Retrieve the controller's action to be executed. + const [ name, action ] = resolverOf.split('.'); + + const controller = plugin ? + _.get(strapi.plugins, `${plugin}.controllers.${_.toLower(name)}.${action}`): + _.get(strapi.controllers, `${_.toLower(name)}.${action}`); + + if (!controller) { + return new Error(`Cannot find the controller's action ${name}.${action}`); + } + + policiesFn[0] = policyUtils.globalPolicy(undefined, { + handler: `${name}.${action}` + }, undefined, plugin); + } + + if (strapi.plugins['users-permissions']) { + policies.push('plugins.users-permissions.permissions'); + } + + // Populate policies. + policies.forEach(policy => policyUtils.get(policy, plugin, policiesFn, `GraphQL query "${queryName}"`, name)); + + return async (obj, options, context) => { + // Hack to be able to handle permissions for each query. + const ctx = Object.assign(_.clone(context), { + request: Object.assign(_.clone(context.request), { + graphql: null + }) + }); + + // Execute policies stack. + const policy = await strapi.koaMiddlewares.compose(policiesFn)(ctx); + + // Policy doesn't always return errors but they update the current context. + if (_.isError(ctx.request.graphql) || _.get(ctx.request.graphql, 'isBoom')) { + return ctx.request.graphql; + } + + // Something went wrong in the policy. + if (policy) { + return policy; + } + + // Resolver can be a function. Be also a native resolver or a controller's action. + if (_.isFunction(resolver)) { + context.query = this.convertToParams(options); + context.params = this.amountLimiting(options); + + if (isController) { + const values = await resolver.call(null, context); + + if (ctx.body) { + return ctx.body; + } + + return values && values.toJSON ? values.toJSON() : values; + } + + + return resolver.call(null, obj, options, context); + } + + // Resolver can be a promise. + return resolver; + }; + }, + + /** + * Construct the GraphQL query & definition and apply the right resolvers. + * + * @return Object + */ + + shadowCRUD: function (models, plugin) { + // Retrieve generic service from the Content Manager plugin. + const resolvers = strapi.plugins['content-manager'].services['contentmanager']; + + const initialState = { definition: '', query: {}, resolver: { Query : {} } }; + + if (_.isEmpty(models)) { + return initialState; + } + + return models.reduce((acc, name) => { + const model = plugin ? + strapi.plugins[plugin].models[name]: + strapi.models[name]; + + // Setup initial state with default attribute that should be displayed + // but these attributes are not properly defined in the models. + const initialState = { + [model.primaryKey]: 'ID!' + }; + + const globalId = model.globalId; + const _schema = _.cloneDeep(_.get(strapi.plugins, 'graphql.config._schema.graphql', {})); + + if (!acc.resolver[globalId]) { + acc.resolver[globalId] = {}; + } + + // Add timestamps attributes. + if (_.get(model, 'options.timestamps') === true) { + Object.assign(initialState, { + createdAt: 'DateTime!', + updatedAt: 'DateTime!' + }); + + Object.assign(acc.resolver[globalId], { + createdAt: (obj, options, context) => { // eslint-disable-line no-unused-vars + return obj.createdAt || obj.created_at; + }, + updatedAt: (obj, options, context) => { // eslint-disable-line no-unused-vars + return obj.updatedAt || obj.updated_at; + } + }); + } + + // Retrieve user customisation. + const { type = {}, resolver = {} } = _schema; + + // Convert our layer Model to the GraphQL DL. + const attributes = Object.keys(model.attributes) + .filter(attribute => model.attributes[attribute].private !== true) + .reduce((acc, attribute) => { + // Convert our type to the GraphQL type. + acc[attribute] = this.convertType({ + definition: model.attributes[attribute], + modelName: globalId, + attributeName: attribute, + }); + + return acc; + }, initialState); + + // Detect enum and generate it for the schema definition + const enums = Object.keys(model.attributes) + .filter(attribute => model.attributes[attribute].type === 'enumeration') + .map((attribute) => { + const definition = model.attributes[attribute]; + + return `enum ${this.convertEnumType(definition, globalId, attribute)} { ${definition.enum.join(' \n ')} }`; + }).join(' '); + + acc.definition += enums; + + // Add parameters to optimize association query. + (model.associations || []) + .filter(association => association.type === 'collection') + .forEach(association => { + attributes[`${association.alias}(sort: String, limit: Int, start: Int, where: JSON)`] = attributes[association.alias]; + + delete attributes[association.alias]; + }); + + acc.definition += `${this.getDescription(type[globalId], model)}type ${globalId} {${this.formatGQL(attributes, type[globalId], model)}}\n\n`; + + // Add definition to the schema but this type won't be "queriable". + if (type[model.globalId] === false || _.get(type, `${model.globalId}.enabled`) === false) { + return acc; + } + + // Build resolvers. + const queries = { + singular: _.get(resolver, `Query.${pluralize.singular(name)}`) !== false ? this.composeResolver( + _schema, + plugin, + name, + true + ) : null, + plural: _.get(resolver, `Query.${pluralize.plural(name)}`) !== false ? this.composeResolver( + _schema, + plugin, + name, + false + ) : null + }; + + // TODO: + // - Handle mutations. + Object.keys(queries).forEach(type => { + // The query cannot be built. + if (_.isError(queries[type])) { + console.error(queries[type]); + strapi.stop(); + } + + // Only create query if the function is available. + if (_.isFunction(queries[type])) { + if (type === 'singular') { + Object.assign(acc.query, { + [`${pluralize.singular(name)}(id: ID!)`]: model.globalId + }); + } else { + Object.assign(acc.query, { + [`${pluralize.plural(name)}(sort: String, limit: Int, start: Int, where: JSON)`]: `[${model.globalId}]` + }); + } + + _.merge(acc.resolver.Query, { + [type === 'singular' ? pluralize.singular(name) : pluralize.plural(name)]: queries[type] + }); + } + }); + + // TODO: + // - Add support for Graphql Aggregation in Bookshelf ORM + if (model.orm === 'mongoose') { + // Generation the aggregation for the given model + const modelAggregator = this.formatModelConnectionsGQL(attributes, model, name, queries.plural); + if (modelAggregator) { + acc.definition += modelAggregator.type; + if (!acc.resolver[modelAggregator.globalId]) { + acc.resolver[modelAggregator.globalId] = {}; + } + + _.merge(acc.resolver, modelAggregator.resolver); + _.merge(acc.query, modelAggregator.query); + } + } + + // Build associations queries. + (model.associations || []).forEach(association => { + switch (association.nature) { + case 'oneToManyMorph': + return _.merge(acc.resolver[globalId], { + [association.alias]: async (obj) => { + const withRelated = await resolvers.fetch({ + id: obj[model.primaryKey], + model: name + }, plugin, [association.alias], false); + + const entry = withRelated && withRelated.toJSON ? withRelated.toJSON() : withRelated; + + // Set the _type only when the value is defined + if (entry[association.alias]) { + entry[association.alias]._type = _.upperFirst(association.model); + } + + return entry[association.alias]; + } + }); + case 'manyMorphToOne': + case 'manyMorphToMany': + case 'manyToManyMorph': + return _.merge(acc.resolver[globalId], { + [association.alias]: async (obj, options, context) => { // eslint-disable-line no-unused-vars + const [ withRelated, withoutRelated ] = await Promise.all([ + resolvers.fetch({ + id: obj[model.primaryKey], + model: name + }, plugin, [association.alias], false), + resolvers.fetch({ + id: obj[model.primaryKey], + model: name + }, plugin, []) + ]); + + const entry = withRelated && withRelated.toJSON ? withRelated.toJSON() : withRelated; + + // TODO: + // - Handle sort, limit and start (lodash or inside the query) + entry[association.alias].map((entry, index) => { + const type = _.get(withoutRelated, `${association.alias}.${index}.kind`) || + _.upperFirst(_.camelCase(_.get(withoutRelated, `${association.alias}.${index}.${association.alias}_type`))) || + _.upperFirst(_.camelCase(association[association.type])); + + entry._type = type; + + return entry; + }); + + return entry[association.alias]; + } + }); + default: + } + + _.merge(acc.resolver[globalId], { + [association.alias]: async (obj, options, context) => { // eslint-disable-line no-unused-vars + // Construct parameters object to retrieve the correct related entries. + const params = { + model: association.model || association.collection, + }; + + const queryOpts = { + source: association.plugin + }; + + if (association.type === 'model') { + const rel = obj[association.alias]; + params.id = typeof rel === 'object' && 'id' in rel ? rel.id : rel; + } else { + // Get refering model. + const ref = association.plugin ? + strapi.plugins[association.plugin].models[params.model]: + strapi.models[params.model]; + + // Apply optional arguments to make more precise nested request. + const convertedParams = strapi.utils.models.convertParams(name, this.convertToParams(this.amountLimiting(options))); + const where = strapi.utils.models.convertParams(name, options.where || {}); + + // Limit, order, etc. + Object.assign(queryOpts, convertedParams); + + // Skip. + queryOpts.skip = convertedParams.start; + + switch (association.nature) { + case 'manyToMany': + if (association.dominant) { + const arrayOfIds = (obj[association.alias] || []).map(related => { + return related[ref.primaryKey] || related; + }); + + // Where. + queryOpts.query = strapi.utils.models.convertParams(name, { + // Construct the "where" query to only retrieve entries which are + // related to this entry. + [ref.primaryKey]: arrayOfIds, + ...where.where + }).where; + } + // falls through + default: + // Where. + queryOpts.query = strapi.utils.models.convertParams(name, { + // Construct the "where" query to only retrieve entries which are + // related to this entry. + [association.via]: obj[ref.primaryKey], + ...where.where + }).where; + } + } + + const value = await (association.model ? + resolvers.fetch(params, association.plugin, []): + resolvers.fetchAll(params, { ...queryOpts, populate: [] })); + + return value && value.toJSON ? value.toJSON() : value; + } + }); + }); + + return acc; + }, initialState); + }, + + /** + * Generate GraphQL schema. + * + * @return Schema + */ + + generateSchema: function () { + // Generate type definition and query/mutation for models. + const shadowCRUD = strapi.plugins.graphql.config.shadowCRUD !== false ? (() => { + // Exclude core models. + const models = Object.keys(strapi.models).filter(model => model !== 'core_store'); + + // Reproduce the same pattern for each plugin. + return Object.keys(strapi.plugins).reduce((acc, plugin) => { + const { definition, query, resolver } = this.shadowCRUD(Object.keys(strapi.plugins[plugin].models), plugin); + + // We cannot put this in the merge because it's a string. + acc.definition += definition || ''; + + return _.merge(acc, { + query, + resolver + }); + }, this.shadowCRUD(models)); + })() : { definition: '', query: '', resolver: '' }; + + // Extract custom definition, query or resolver. + const { definition, query, resolver = {} } = strapi.plugins.graphql.config._schema.graphql; + + // Polymorphic. + const { polymorphicDef, polymorphicResolver } = this.addPolymorphicUnionType(definition, shadowCRUD.definition); + + // Build resolvers. + const resolvers = _.omitBy(_.merge(shadowCRUD.resolver, resolver, polymorphicResolver), _.isEmpty) || {}; + + // Transform object to only contain function. + Object.keys(resolvers).reduce((acc, type) => { + return Object.keys(acc[type]).reduce((acc, resolver) => { + // Disabled this query. + if (acc[type][resolver] === false) { + delete acc[type][resolver]; + + return acc; + } + + if (!_.isFunction(acc[type][resolver])) { + acc[type][resolver] = acc[type][resolver].resolver; + } + + if (_.isString(acc[type][resolver]) || _.isPlainObject(acc[type][resolver])) { + const { plugin = '' } = _.isPlainObject(acc[type][resolver]) ? acc[type][resolver] : {}; + + acc[type][resolver] = this.composeResolver( + strapi.plugins.graphql.config._schema.graphql, + plugin, + resolver, + 'force' // Avoid singular/pluralize and force query name. + ); + } + + return acc; + }, acc); + }, resolvers); + + // Return empty schema when there is no model. + if (_.isEmpty(shadowCRUD.definition) && _.isEmpty(definition)) { + return {}; + } + + // Concatenate. + const typeDefs = ` + ${definition} + ${shadowCRUD.definition} + type Query {${shadowCRUD.query && this.formatGQL(shadowCRUD.query, resolver.Query, null, 'query')}${query}} + ${this.addCustomScalar(resolvers)} + ${polymorphicDef} + `; + + // Build schema. + const schema = makeExecutableSchema({ + typeDefs, + resolvers, + }); + + // Write schema. + this.writeGenerateSchema(graphql.printSchema(schema)); + + return schema; + }, + + /** + * Add custom scalar type such as JSON. + * + * @return void + */ + + addCustomScalar: (resolvers) => { + Object.assign(resolvers, { + JSON: GraphQLJSON, + DateTime: GraphQLDateTime, + }); + + return 'scalar JSON \n scalar DateTime'; + }, + + /** + * Add Union Type that contains the types defined by the user. + * + * @return string + */ + + addPolymorphicUnionType: (customDefs, defs) => { + const types = graphql.parse(customDefs + defs).definitions + .filter(def => def.kind === 'ObjectTypeDefinition' && def.name.value !== 'Query') + .map(def => def.name.value); + + if (types.length > 0) { + return { + polymorphicDef: `union Morph = ${types.join(' | ')}`, + polymorphicResolver: { + Morph: { + __resolveType(obj, context, info) { // eslint-disable-line no-unused-vars + return obj.kind || obj._type; + } + } + } + }; + } + + return { + polymorphicDef: '', + polymorphicResolver: {} + }; + }, + + /** + * Save into a file the readable GraphQL schema. + * + * @return void + */ + + writeGenerateSchema(schema) { + // Disable auto-reload. + strapi.reload.isWatching = false; + + const generatedFolder = path.resolve(strapi.config.appPath, 'plugins', 'graphql', 'config', 'generated'); + + // Create folder if necessary. + try { + fs.accessSync(generatedFolder, fs.constants.R_OK | fs.constants.W_OK); + } catch (err) { + if (err && err.code === 'ENOENT') { + fs.mkdirSync(generatedFolder); + } else { + console.error(err); + } + } + + fs.writeFileSync(path.join(generatedFolder, 'schema.graphql'), schema); + + strapi.reload.isWatching = true; + } + +}; From 2d1cf3591e5a840ab3d4404905a8cd442b063898 Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Mon, 24 Sep 2018 21:20:30 +0200 Subject: [PATCH 13/44] Split convertParams to multiple stage steps --- packages/strapi-hook-mongoose/lib/index.js | 25 +- .../strapi-plugin-graphql/services/GraphQL.js | 1341 ----------------- packages/strapi-utils/lib/models.js | 300 +++- 3 files changed, 241 insertions(+), 1425 deletions(-) delete mode 100644 packages/strapi-plugin-graphql/services/GraphQL.js diff --git a/packages/strapi-hook-mongoose/lib/index.js b/packages/strapi-hook-mongoose/lib/index.js index b2cc899a6a..3364a4026d 100644 --- a/packages/strapi-hook-mongoose/lib/index.js +++ b/packages/strapi-hook-mongoose/lib/index.js @@ -488,16 +488,16 @@ module.exports = function (strapi) { result.value = value; break; case '_sort': - result.key = `sort`; + result.key = 'sort'; result.value = (_.toLower(value) === 'desc') ? '-' : ''; result.value += key; break; case '_start': - result.key = `start`; + result.key = 'start'; result.value = parseFloat(value); break; case '_limit': - result.key = `limit`; + result.key = 'limit'; result.value = parseFloat(value); break; case '_contains': @@ -520,8 +520,27 @@ module.exports = function (strapi) { } return result; + }, + + postProcessValue: (value) => { + if (_.isArray(value)) { + return value.map(valueToId); + } + return valueToId(value); } }, relations); return hook; }; + +const valueToId = value => { + return isMongoId(value) + ? mongoose.Types.ObjectId(value) + : value; +}; + +const isMongoId = (value) => { + const hexadecimal = /^[0-9A-F]+$/i; + + return hexadecimal.test(value) && value.length === 24; +}; diff --git a/packages/strapi-plugin-graphql/services/GraphQL.js b/packages/strapi-plugin-graphql/services/GraphQL.js deleted file mode 100644 index 05db0e565d..0000000000 --- a/packages/strapi-plugin-graphql/services/GraphQL.js +++ /dev/null @@ -1,1341 +0,0 @@ -'use strict'; - -/** - * GraphQL.js service - * - * @description: A set of functions similar to controller's actions to avoid code duplication. - */ - - -const fs = require('fs'); -const path = require('path'); -const _ = require('lodash'); -const pluralize = require('pluralize'); -const graphql = require('graphql'); -const { makeExecutableSchema } = require('graphql-tools'); -const GraphQLJSON = require('graphql-type-json'); -const GraphQLDateTime = require('graphql-type-datetime'); -const policyUtils = require('strapi-utils').policy; - -module.exports = { - /** - * Returns all fields of type primitive - * - * @returns {Boolean} - */ - isPrimitiveType: (_type) => { - const type = _type.replace('!', ''); - return ( - type === 'Int' || - type === 'Float' || - type === 'String' || - type === 'Boolean' || - type === 'DateTime' || - type === 'JSON' - ); - }, - - /** - * Checks if the field is of type enum - * - * @returns {Boolean} - */ - isEnumType: (type) => { - return type === 'enumeration'; - }, - - /** - * Returns all fields that are not of type array - * - * @returns {Boolean} - * - * @example - * - * isNotOfTypeArray([String]) - * // => false - * isNotOfTypeArray(String!) - * // => true - */ - isNotOfTypeArray: (type) => { - return !/(\[\w+!?\])/.test(type); - }, - - /** - * Returns all fields of type Integer or float - */ - isNumberType: (type) => { - return type === 'Int' || type === 'Float'; - }, - - /** - * Convert non-primitive type to string (non-primitive types corresponds to a reference to an other model) - * - * @returns {String} - * - * @example - * - * extractType(String!) - * // => String - * - * extractType(user) - * // => ID - * - * extractType(ENUM_TEST_FIELD, enumeration) - * // => String - * - */ - extractType: function (_type, attributeType) { - return this.isPrimitiveType(_type) - ? _type.replace('!', '') - : this.isEnumType(attributeType) - ? 'String' - : 'ID'; - }, - - /** - * Returns a list of fields that have type included in fieldTypes. - */ - getFieldsByTypes: (fields, typeCheck, returnType) => { - return _.reduce(fields, (acc, fieldType, fieldName) => { - if (typeCheck(fieldType)) { - acc[fieldName] = returnType(fieldType, fieldName); - } - return acc; - }, {}); - }, - - /** - * Use the field resolver otherwise fall through the field value - * - * @returns {function} - */ - fieldResolver: (field, key) => { - return (object) => { - const resolver = field.resolve || function resolver(obj, options, context) { // eslint-disable-line no-unused-vars - return obj[key]; - }; - return resolver(object); - }; - }, - - /** - * Create fields resolvers - * - * @return {Object} - */ - createFieldsResolver: function(fields, resolver, typeCheck) { - return Object.keys(fields).reduce((acc, fieldKey) => { - const field = fields[fieldKey]; - // Check if the field is of the correct type - if (typeCheck(field)) { - return _.set(acc, fieldKey, (obj, options, context) => { - return resolver(obj, options, context, this.fieldResolver(field, fieldKey), fieldKey, obj, field); - }); - } - return acc; - }, {}); - }, - - /** - * Build the mongoose aggregator by applying the filters - */ - getModelAggregator: function (model, filters = {}) { - const aggregation = model.aggregate(); - if (!_.isEmpty(filters.where)) { - aggregation.match(filters.where); - } - if (filters.limit) { - aggregation.limit(filters.limit); - } - return aggregation; - }, - - /** - * Create the resolvers for each aggregation field - * - * @return {Object} - * - * @example - * - * const model = // Strapi model - * - * const fields = { - * username: String, - * age: Int, - * } - * - * const typeCheck = (type) => type === 'Int' || type === 'Float', - * - * const fieldsResoler = createAggregationFieldsResolver(model, fields, 'sum', typeCheck); - * - * // => { - * age: function ageResolver() { .... } - * } - */ - createAggregationFieldsResolver: function (model, fields, operation, typeCheck) { - return this.createFieldsResolver(fields, async (obj, options, context, fieldResolver, fieldKey) => { // eslint-disable-line no-unused-vars - const result = await this.getModelAggregator(model, obj).group({ - _id: null, - [fieldKey]: { [`$${operation}`]: `$${fieldKey}` } - }); - return _.get(result, `0.${fieldKey}`); - }, typeCheck); - }, - - /** - * Correctly format the data returned by the group by - */ - preProcessGroupByData: function ({ result, fieldKey, filters, modelName }) { - const _result = _.toArray(result); - return _.map(_result, (value) => { - const params = Object.assign( - {}, - this.convertToParams(_.omit(filters, 'where')), - filters.where, - { - [fieldKey]: value._id, - } - ); - - return { - key: value._id, - connection: strapi.utils.models.convertParams(modelName, params), - }; - }); - }, - - /** - * Create the resolvers for each group by field - * - * @return {Object} - * - * @example - * - * const model = // Strapi model - * const fields = { - * username: [UserConnectionUsername], - * email: [UserConnectionEmail], - * } - * const fieldsResoler = createGroupByFieldsResolver(model, fields); - * - * // => { - * username: function usernameResolver() { .... } - * email: function emailResolver() { .... } - * } - */ - createGroupByFieldsResolver: function (model, fields, name) { - return this.createFieldsResolver(fields, async (obj, options, context, fieldResolver, fieldKey) => { - const result = await this.getModelAggregator(model, obj).group({ - _id: `$${fieldKey}`, - }); - - return this.preProcessGroupByData({ - result, - fieldKey, - filters: obj, - modelName: name, - }); - }, () => true); - }, - - /** - * This method is the entry point to the GraphQL's Aggregation. - * It takes as param the model and its fields and it'll create the aggregation types and resolver to it - * Example: - * type User { - * username: String, - * age: Int, - * } - * - * It'll create - * type UserConnection { - * values: [User], - * groupBy: UserGroupBy, - * aggreate: UserAggregate - * } - * - * type UserAggregate { - * count: Int - * sum: UserAggregateSum - * avg: UserAggregateAvg - * } - * - * type UserAggregateSum { - * age: Float - * } - * - * type UserAggregateAvg { - * age: Float - * } - * - * type UserGroupBy { - * username: [UserConnectionUsername] - * age: [UserConnectionAge] - * } - * - * type UserConnectionUsername { - * key: String - * connection: UserConnection - * } - * - * type UserConnectionAge { - * key: Int - * connection: UserConnection - * } - * - */ - formatModelConnectionsGQL: function(fields, model, name, modelResolver) { - const { globalId } = model; - - const connectionGlobalId = `${globalId}Connection`; - const aggregatorFormat = this.formatConnectionAggregator(fields, model, name); - const groupByFormat = this.formatConnectionGroupBy(fields, model, name); - const connectionFields = { - values: `[${globalId}]`, - groupBy: `${globalId}GroupBy`, - aggregate: `${globalId}Aggregator`, - }; - - let modelConnectionTypes = `type ${connectionGlobalId} {${this.formatGQL(connectionFields)}}\n\n`; - if (aggregatorFormat) { - modelConnectionTypes += aggregatorFormat.type; - } - modelConnectionTypes += groupByFormat.type; - - return { - globalId: connectionGlobalId, - type: modelConnectionTypes, - query: { - [`${pluralize.plural(name)}Connection(sort: String, limit: Int, start: Int, where: JSON)`]: connectionGlobalId, - }, - resolver: { - Query: { - [`${pluralize.plural(name)}Connection`]: (obj, options, context) => { // eslint-disable-line no-unused-vars - const params = Object.assign( - {}, - this.convertToParams(_.omit(options, 'where')), - options.where - ); - return strapi.utils.models.convertParams(name, params); - } - }, - [connectionGlobalId]: { - values: (obj, option, context) => { - // Object here contains the key/value of the field that has been grouped-by - // for instance obj = { where: { country: 'USA' } } so the values here needs to be filtered according to the parent value - return modelResolver(obj, obj, context); - }, - groupBy: (obj, option, context) => { // eslint-disable-line no-unused-vars - // There is noting to resolve here, it's the aggregation resolver that will take care of it - return obj; - }, - aggregate: (obj, option, context) => { // eslint-disable-line no-unused-vars - // There is noting to resolve here, it's the aggregation resolver that will take care of it - return obj; - }, - }, - ...aggregatorFormat.resolver, - ...groupByFormat.resolver, - }, - }; - }, - - /** - * Generate the connection type of each non-array field of the model - * - * @return {String} - */ - generateConnectionFieldsTypes: function (fields, model) { - const { globalId, attributes } = model; - const primitiveFields = this.getFieldsByTypes( - fields, - this.isNotOfTypeArray, - (type, name) => this.extractType(type, (attributes[name] || {}).type), - ); - - const connectionFields = _.mapValues(primitiveFields, (fieldType) => ({ - key: fieldType, - connection: `${globalId}Connection`, - })); - - return Object.keys(primitiveFields).map((fieldKey) => - `type ${globalId}Connection${_.upperFirst(fieldKey)} {${this.formatGQL(connectionFields[fieldKey])}}` - ).join('\n\n'); - }, - - formatConnectionGroupBy: function(fields, model, name) { - const { globalId } = model; - const groupByGlobalId = `${globalId}GroupBy`; - - // Extract all primitive fields and change their types - const groupByFields = this.getFieldsByTypes( - fields, - this.isNotOfTypeArray, - (fieldType, fieldName) => `[${globalId}Connection${_.upperFirst(fieldName)}]`, - ); - - // Get the generated field types - let groupByTypes = `type ${groupByGlobalId} {${this.formatGQL(groupByFields)}}\n\n`; - groupByTypes += this.generateConnectionFieldsTypes(fields, model); - - return { - globalId: groupByGlobalId, - type: groupByTypes, - resolver: { - [groupByGlobalId]: this.createGroupByFieldsResolver(model, groupByFields, name), - } - }; - }, - - formatConnectionAggregator: function(fields, model) { - const { globalId } = model; - - // Extract all fields of type Integer and Float and change their type to Float - const numericFields = this.getFieldsByTypes(fields, this.isNumberType, () => 'Float'); - - // Don't create an aggregator field if the model has not number fields - const aggregatorGlobalId = `${globalId}Aggregator`; - const initialFields = { - count: 'Int', - }; - - // Only add the aggregator's operations if there are some numeric fields - if (!_.isEmpty(numericFields)) { - ['sum', 'avg', 'min', 'max'].forEach((agg) => { - initialFields[agg] = `${aggregatorGlobalId}${_.startCase(agg)}`; - }); - } - - const gqlNumberFormat = this.formatGQL(numericFields); - let aggregatorTypes = `type ${aggregatorGlobalId} {${this.formatGQL(initialFields)}}\n\n`; - - let resolvers = { - [aggregatorGlobalId]: { - count: async (obj, options, context) => { // eslint-disable-line no-unused-vars - // Object here corresponds to the filter that needs to be applied to the aggregation - const result = await this.getModelAggregator(model, obj).group({ - _id: null, - count: { $sum: 1 } - }); - - return _.get(result, '0.count'); - }, - } - }; - - // Only add the aggregator's operations types and resolver if there are some numeric fields - if (!_.isEmpty(numericFields)) { - // Returns the actual object and handle aggregation in the query resolvers - const defaultAggregatorFunc = (obj, options, context) => { // eslint-disable-line no-unused-vars - return obj; - }; - - aggregatorTypes += `type ${aggregatorGlobalId}Sum {${gqlNumberFormat}}\n\n`; - aggregatorTypes += `type ${aggregatorGlobalId}Avg {${gqlNumberFormat}}\n\n`; - aggregatorTypes += `type ${aggregatorGlobalId}Min {${gqlNumberFormat}}\n\n`; - aggregatorTypes += `type ${aggregatorGlobalId}Max {${gqlNumberFormat}}\n\n`; - - _.merge(resolvers[aggregatorGlobalId], { - sum: defaultAggregatorFunc, - avg: defaultAggregatorFunc, - min: defaultAggregatorFunc, - max: defaultAggregatorFunc, - }); - - resolvers = { - ...resolvers, - [`${aggregatorGlobalId}Sum`]: this.createAggregationFieldsResolver(model, fields, 'sum', this.isNumberType), - [`${aggregatorGlobalId}Avg`]: this.createAggregationFieldsResolver(model, fields, 'avg', this.isNumberType), - [`${aggregatorGlobalId}Min`]: this.createAggregationFieldsResolver(model, fields, 'min', this.isNumberType), - [`${aggregatorGlobalId}Max`]: this.createAggregationFieldsResolver(model, fields, 'max', this.isNumberType) - }; - } - - return { - globalId: aggregatorGlobalId, - type: aggregatorTypes, - resolver: resolvers, - }; - }, - - /** - * Receive an Object and return a string which is following the GraphQL specs. - * - * @return String - */ - - formatGQL: function (fields, description = {}, model = {}, type = 'field') { - const typeFields = JSON.stringify(fields, null, 2).replace(/['",]+/g, ''); - const lines = typeFields.split('\n'); - - // Try to add description for field. - if (type === 'field') { - return lines - .map(line => { - if (['{', '}'].includes(line)) { - return ''; - } - - const split = line.split(':'); - const attribute = _.trim(split[0]); - const info = (_.isString(description[attribute]) ? description[attribute] : _.get(description[attribute], 'description')) || _.get(model, `attributes.${attribute}.description`); - const deprecated = _.get(description[attribute], 'deprecated') || _.get(model, `attributes.${attribute}.deprecated`); - - // Snakecase an attribute when we find a dash. - if (attribute.indexOf('-') !== -1) { - line = ` ${_.snakeCase(attribute)}: ${_.trim(split[1])}`; - } - - if (info) { - line = ` """\n ${info}\n """\n${line}`; - } - - if (deprecated) { - line = `${line} @deprecated(reason: "${deprecated}")`; - } - - return line; - }) - .join('\n'); - } else if (type === 'query') { - return lines - .map((line, index) => { - if (['{', '}'].includes(line)) { - return ''; - } - - const split = Object.keys(fields)[index - 1].split('('); - const attribute = _.trim(split[0]); - const info = _.get(description[attribute], 'description'); - const deprecated = _.get(description[attribute], 'deprecated'); - - // Snakecase an attribute when we find a dash. - if (attribute.indexOf('-') !== -1) { - line = ` ${_.snakeCase(attribute)}(${_.trim(split[1])}`; - } - - if (info) { - line = ` """\n ${info}\n """\n${line}`; - } - - if (deprecated) { - line = `${line} @deprecated(reason: "${deprecated}")`; - } - - return line; - }) - .join('\n'); - } - - return lines - .map((line, index) => { - if ([0, lines.length - 1].includes(index)) { - return ''; - } - - return line; - }) - .join('\n'); - }, - - /** - * Retrieve description from variable and return a string which follow the GraphQL specs. - * - * @return String - */ - - getDescription: (description, model = {}) => { - const format = '"""\n'; - - const str = _.get(description, '_description') || - _.isString(description) ? description : undefined || - _.get(model, 'info.description'); - - if (str) { - return `${format}${str}\n${format}`; - } - - return ''; - }, - - convertToParams: (params) => { - return Object.keys(params).reduce((acc, current) => { - return Object.assign(acc, { - [`_${current}`]: params[current] - }); - }, {}); - }, - - /* - * It converts GraphQL object params to a dotted query strings - * @example - * - * params = { where: { posts: { isPublished: true } } } - * - * @result - * - * params = { where: { 'posts.isPublished': true } } - * - * NB: this only works with one level deep. - */ - convertToQuery: (params) => { - return _.reduce(params, (acc, value, parentKey) => { - if (!_.isObject(value)) { - acc[parentKey] = value; - return acc; - } - - return { - ...acc, - ..._.reduce(value, (_acc, _value, _key) => ({ - ..._acc, - [`${parentKey}.${_key}`]: _value - }), {}) - }; - }, {}); - }, - - /** - * Security to avoid infinite limit. - * - * @return String - */ - - amountLimiting: (params) => { - if (params.limit && params.limit < 0) { - params.limit = 0; - } else if (params.limit && params.limit > 100) { - params.limit = 100; - } - - return params; - }, - - /** - * Convert Strapi type to GraphQL type. - * @param {Object} attribute Information about the attribute. - * @param {Object} attribute.definition Definition of the attribute. - * @param {String} attribute.modelName Name of the model which owns the attribute. - * @param {String} attribute.attributeName Name of the attribute. - * @return String - */ - - convertType: function ({ definition = {}, modelName = '', attributeName = '' }) { - // Type - if (definition.type) { - let type = 'String'; - - switch (definition.type) { - // TODO: Handle fields of type Array, Perhaps default to [Int] or [String] ... - case 'boolean': - type = 'Boolean'; - break; - case 'integer': - type = 'Int'; - break; - case 'float': - type = 'Float'; - break; - case 'json': - type = 'JSON'; - break; - case 'time': - case 'date': - case 'datetime': - case 'timestamp': - type = 'DateTime'; - break; - case 'enumeration': - type = this.convertEnumType(definition, modelName, attributeName); - break; - } - - if (definition.required) { - type += '!'; - } - - return type; - } - - const ref = definition.model || definition.collection; - - // Association - if (ref && ref !== '*') { - // Add bracket or not - const globalId = definition.plugin ? - strapi.plugins[definition.plugin].models[ref].globalId: - strapi.models[ref].globalId; - const plural = !_.isEmpty(definition.collection); - - if (plural) { - return `[${globalId}]`; - } - - return globalId; - } - - return definition.model ? 'Morph' : '[Morph]'; - }, - - /** - * Convert Strapi enumeration to GraphQL Enum. - * @param {Object} definition Definition of the attribute. - * @param {String} model Name of the model which owns the attribute. - * @param {String} field Name of the attribute. - * @return String - */ - - convertEnumType: (definition, model, field) => definition.enumName ? definition.enumName : `ENUM_${model.toUpperCase()}_${field.toUpperCase()}`, - - /** - * Execute policies before the specified resolver. - * - * @return Promise or Error. - */ - - composeResolver: function (_schema, plugin, name, isSingular) { - const params = { - model: name - }; - - const queryOpts = plugin ? { source: plugin } : {}; // eslint-disable-line no-unused-vars - - const model = plugin ? - strapi.plugins[plugin].models[name]: - strapi.models[name]; - - // Retrieve generic service from the Content Manager plugin. - const resolvers = strapi.plugins['content-manager'].services['contentmanager']; // eslint-disable-line no-unused-vars - - // Extract custom resolver or type description. - const { resolver: handler = {} } = _schema; - - let queryName; - - if (isSingular === 'force') { - queryName = name; - } else { - queryName = isSingular ? - pluralize.singular(name): - pluralize.plural(name); - } - - // Retrieve policies. - const policies = _.get(handler, `Query.${queryName}.policies`, []); - - // Retrieve resolverOf. - const resolverOf = _.get(handler, `Query.${queryName}.resolverOf`, ''); - - const policiesFn = []; - - // Boolean to define if the resolver is going to be a resolver or not. - let isController = false; - - // Retrieve resolver. It could be the custom resolver of the user - // or the shadow CRUD resolver (aka Content-Manager). - const resolver = (() => { - // Try to retrieve custom resolver. - const resolver = _.get(handler, `Query.${queryName}.resolver`); - - if (_.isString(resolver) || _.isPlainObject(resolver)) { - const { handler = resolver } = _.isPlainObject(resolver) ? resolver : {}; - - // Retrieve the controller's action to be executed. - const [ name, action ] = handler.split('.'); - - const controller = plugin ? - _.get(strapi.plugins, `${plugin}.controllers.${_.toLower(name)}.${action}`): - _.get(strapi.controllers, `${_.toLower(name)}.${action}`); - - if (!controller) { - return new Error(`Cannot find the controller's action ${name}.${action}`); - } - - // We're going to return a controller instead. - isController = true; - - // Push global policy to make sure the permissions will work as expected. - policiesFn.push( - policyUtils.globalPolicy(undefined, { - handler: `${name}.${action}` - }, undefined, plugin) - ); - - // Return the controller. - return controller; - } else if (resolver) { - // Function. - return resolver; - } - - // We're going to return a controller instead. - isController = true; - - const controllers = plugin ? strapi.plugins[plugin].controllers : strapi.controllers; - - // Try to find the controller that should be related to this model. - const controller = isSingular ? - _.get(controllers, `${name}.findOne`): - _.get(controllers, `${name}.find`); - - if (!controller) { - return new Error(`Cannot find the controller's action ${name}.${isSingular ? 'findOne' : 'find'}`); - } - - // Push global policy to make sure the permissions will work as expected. - // We're trying to detect the controller name. - policiesFn.push( - policyUtils.globalPolicy(undefined, { - handler: `${name}.${isSingular ? 'findOne' : 'find'}` - }, undefined, plugin) - ); - - // Make the query compatible with our controller by - // setting in the context the parameters. - if (isSingular) { - return async (ctx, next) => { - ctx.params = { - ...params, - [model.primaryKey]: ctx.params.id - }; - - // Return the controller. - return controller(ctx, next); - }; - } - - // Plural. - return async (ctx, next) => { - const queryOpts = {}; - queryOpts.params = this.amountLimiting(ctx.params); - // Avoid using ctx.query = ... because it converts the object values to string - queryOpts.query = Object.assign( - {}, - this.convertToParams(_.omit(ctx.params, 'where')), - this.convertToQuery(ctx.params.where) - ); - - return controller(Object.assign({}, ctx, queryOpts, { send: ctx.send }), next); // send method doesn't get copied when using object.assign - }; - })(); - - // The controller hasn't been found. - if (_.isError(resolver)) { - return resolver; - } - - // Force policies of another action on a custom resolver. - if (_.isString(resolverOf) && !_.isEmpty(resolverOf)) { - // Retrieve the controller's action to be executed. - const [ name, action ] = resolverOf.split('.'); - - const controller = plugin ? - _.get(strapi.plugins, `${plugin}.controllers.${_.toLower(name)}.${action}`): - _.get(strapi.controllers, `${_.toLower(name)}.${action}`); - - if (!controller) { - return new Error(`Cannot find the controller's action ${name}.${action}`); - } - - policiesFn[0] = policyUtils.globalPolicy(undefined, { - handler: `${name}.${action}` - }, undefined, plugin); - } - - if (strapi.plugins['users-permissions']) { - policies.push('plugins.users-permissions.permissions'); - } - - // Populate policies. - policies.forEach(policy => policyUtils.get(policy, plugin, policiesFn, `GraphQL query "${queryName}"`, name)); - - return async (obj, options, context) => { - // Hack to be able to handle permissions for each query. - const ctx = Object.assign(_.clone(context), { - request: Object.assign(_.clone(context.request), { - graphql: null - }) - }); - - // Execute policies stack. - const policy = await strapi.koaMiddlewares.compose(policiesFn)(ctx); - - // Policy doesn't always return errors but they update the current context. - if (_.isError(ctx.request.graphql) || _.get(ctx.request.graphql, 'isBoom')) { - return ctx.request.graphql; - } - - // Something went wrong in the policy. - if (policy) { - return policy; - } - - // Resolver can be a function. Be also a native resolver or a controller's action. - if (_.isFunction(resolver)) { - context.query = this.convertToParams(options); - context.params = this.amountLimiting(options); - - if (isController) { - const values = await resolver.call(null, context); - - if (ctx.body) { - return ctx.body; - } - - return values && values.toJSON ? values.toJSON() : values; - } - - - return resolver.call(null, obj, options, context); - } - - // Resolver can be a promise. - return resolver; - }; - }, - - /** - * Construct the GraphQL query & definition and apply the right resolvers. - * - * @return Object - */ - - shadowCRUD: function (models, plugin) { - // Retrieve generic service from the Content Manager plugin. - const resolvers = strapi.plugins['content-manager'].services['contentmanager']; - - const initialState = { definition: '', query: {}, resolver: { Query : {} } }; - - if (_.isEmpty(models)) { - return initialState; - } - - return models.reduce((acc, name) => { - const model = plugin ? - strapi.plugins[plugin].models[name]: - strapi.models[name]; - - // Setup initial state with default attribute that should be displayed - // but these attributes are not properly defined in the models. - const initialState = { - [model.primaryKey]: 'ID!' - }; - - const globalId = model.globalId; - const _schema = _.cloneDeep(_.get(strapi.plugins, 'graphql.config._schema.graphql', {})); - - if (!acc.resolver[globalId]) { - acc.resolver[globalId] = {}; - } - - // Add timestamps attributes. - if (_.get(model, 'options.timestamps') === true) { - Object.assign(initialState, { - createdAt: 'DateTime!', - updatedAt: 'DateTime!' - }); - - Object.assign(acc.resolver[globalId], { - createdAt: (obj, options, context) => { // eslint-disable-line no-unused-vars - return obj.createdAt || obj.created_at; - }, - updatedAt: (obj, options, context) => { // eslint-disable-line no-unused-vars - return obj.updatedAt || obj.updated_at; - } - }); - } - - // Retrieve user customisation. - const { type = {}, resolver = {} } = _schema; - - // Convert our layer Model to the GraphQL DL. - const attributes = Object.keys(model.attributes) - .filter(attribute => model.attributes[attribute].private !== true) - .reduce((acc, attribute) => { - // Convert our type to the GraphQL type. - acc[attribute] = this.convertType({ - definition: model.attributes[attribute], - modelName: globalId, - attributeName: attribute, - }); - - return acc; - }, initialState); - - // Detect enum and generate it for the schema definition - const enums = Object.keys(model.attributes) - .filter(attribute => model.attributes[attribute].type === 'enumeration') - .map((attribute) => { - const definition = model.attributes[attribute]; - - return `enum ${this.convertEnumType(definition, globalId, attribute)} { ${definition.enum.join(' \n ')} }`; - }).join(' '); - - acc.definition += enums; - - // Add parameters to optimize association query. - (model.associations || []) - .filter(association => association.type === 'collection') - .forEach(association => { - attributes[`${association.alias}(sort: String, limit: Int, start: Int, where: JSON)`] = attributes[association.alias]; - - delete attributes[association.alias]; - }); - - acc.definition += `${this.getDescription(type[globalId], model)}type ${globalId} {${this.formatGQL(attributes, type[globalId], model)}}\n\n`; - - // Add definition to the schema but this type won't be "queriable". - if (type[model.globalId] === false || _.get(type, `${model.globalId}.enabled`) === false) { - return acc; - } - - // Build resolvers. - const queries = { - singular: _.get(resolver, `Query.${pluralize.singular(name)}`) !== false ? this.composeResolver( - _schema, - plugin, - name, - true - ) : null, - plural: _.get(resolver, `Query.${pluralize.plural(name)}`) !== false ? this.composeResolver( - _schema, - plugin, - name, - false - ) : null - }; - - // TODO: - // - Handle mutations. - Object.keys(queries).forEach(type => { - // The query cannot be built. - if (_.isError(queries[type])) { - console.error(queries[type]); - strapi.stop(); - } - - // Only create query if the function is available. - if (_.isFunction(queries[type])) { - if (type === 'singular') { - Object.assign(acc.query, { - [`${pluralize.singular(name)}(id: ID!)`]: model.globalId - }); - } else { - Object.assign(acc.query, { - [`${pluralize.plural(name)}(sort: String, limit: Int, start: Int, where: JSON)`]: `[${model.globalId}]` - }); - } - - _.merge(acc.resolver.Query, { - [type === 'singular' ? pluralize.singular(name) : pluralize.plural(name)]: queries[type] - }); - } - }); - - // TODO: - // - Add support for Graphql Aggregation in Bookshelf ORM - if (model.orm === 'mongoose') { - // Generation the aggregation for the given model - const modelAggregator = this.formatModelConnectionsGQL(attributes, model, name, queries.plural); - if (modelAggregator) { - acc.definition += modelAggregator.type; - if (!acc.resolver[modelAggregator.globalId]) { - acc.resolver[modelAggregator.globalId] = {}; - } - - _.merge(acc.resolver, modelAggregator.resolver); - _.merge(acc.query, modelAggregator.query); - } - } - - // Build associations queries. - (model.associations || []).forEach(association => { - switch (association.nature) { - case 'oneToManyMorph': - return _.merge(acc.resolver[globalId], { - [association.alias]: async (obj) => { - const withRelated = await resolvers.fetch({ - id: obj[model.primaryKey], - model: name - }, plugin, [association.alias], false); - - const entry = withRelated && withRelated.toJSON ? withRelated.toJSON() : withRelated; - - // Set the _type only when the value is defined - if (entry[association.alias]) { - entry[association.alias]._type = _.upperFirst(association.model); - } - - return entry[association.alias]; - } - }); - case 'manyMorphToOne': - case 'manyMorphToMany': - case 'manyToManyMorph': - return _.merge(acc.resolver[globalId], { - [association.alias]: async (obj, options, context) => { // eslint-disable-line no-unused-vars - const [ withRelated, withoutRelated ] = await Promise.all([ - resolvers.fetch({ - id: obj[model.primaryKey], - model: name - }, plugin, [association.alias], false), - resolvers.fetch({ - id: obj[model.primaryKey], - model: name - }, plugin, []) - ]); - - const entry = withRelated && withRelated.toJSON ? withRelated.toJSON() : withRelated; - - // TODO: - // - Handle sort, limit and start (lodash or inside the query) - entry[association.alias].map((entry, index) => { - const type = _.get(withoutRelated, `${association.alias}.${index}.kind`) || - _.upperFirst(_.camelCase(_.get(withoutRelated, `${association.alias}.${index}.${association.alias}_type`))) || - _.upperFirst(_.camelCase(association[association.type])); - - entry._type = type; - - return entry; - }); - - return entry[association.alias]; - } - }); - default: - } - - _.merge(acc.resolver[globalId], { - [association.alias]: async (obj, options, context) => { // eslint-disable-line no-unused-vars - // Construct parameters object to retrieve the correct related entries. - const params = { - model: association.model || association.collection, - }; - - const queryOpts = { - source: association.plugin - }; - - if (association.type === 'model') { - const rel = obj[association.alias]; - params.id = typeof rel === 'object' && 'id' in rel ? rel.id : rel; - } else { - // Get refering model. - const ref = association.plugin ? - strapi.plugins[association.plugin].models[params.model]: - strapi.models[params.model]; - - // Apply optional arguments to make more precise nested request. - const convertedParams = strapi.utils.models.convertParams(name, this.convertToParams(this.amountLimiting(options))); - const where = strapi.utils.models.convertParams(name, options.where || {}); - - // Limit, order, etc. - Object.assign(queryOpts, convertedParams); - - // Skip. - queryOpts.skip = convertedParams.start; - - switch (association.nature) { - case 'manyToMany': - if (association.dominant) { - const arrayOfIds = (obj[association.alias] || []).map(related => { - return related[ref.primaryKey] || related; - }); - - // Where. - queryOpts.query = strapi.utils.models.convertParams(name, { - // Construct the "where" query to only retrieve entries which are - // related to this entry. - [ref.primaryKey]: arrayOfIds, - ...where.where - }).where; - } - // falls through - default: - // Where. - queryOpts.query = strapi.utils.models.convertParams(name, { - // Construct the "where" query to only retrieve entries which are - // related to this entry. - [association.via]: obj[ref.primaryKey], - ...where.where - }).where; - } - } - - const value = await (association.model ? - resolvers.fetch(params, association.plugin, []): - resolvers.fetchAll(params, { ...queryOpts, populate: [] })); - - return value && value.toJSON ? value.toJSON() : value; - } - }); - }); - - return acc; - }, initialState); - }, - - /** - * Generate GraphQL schema. - * - * @return Schema - */ - - generateSchema: function () { - // Generate type definition and query/mutation for models. - const shadowCRUD = strapi.plugins.graphql.config.shadowCRUD !== false ? (() => { - // Exclude core models. - const models = Object.keys(strapi.models).filter(model => model !== 'core_store'); - - // Reproduce the same pattern for each plugin. - return Object.keys(strapi.plugins).reduce((acc, plugin) => { - const { definition, query, resolver } = this.shadowCRUD(Object.keys(strapi.plugins[plugin].models), plugin); - - // We cannot put this in the merge because it's a string. - acc.definition += definition || ''; - - return _.merge(acc, { - query, - resolver - }); - }, this.shadowCRUD(models)); - })() : { definition: '', query: '', resolver: '' }; - - // Extract custom definition, query or resolver. - const { definition, query, resolver = {} } = strapi.plugins.graphql.config._schema.graphql; - - // Polymorphic. - const { polymorphicDef, polymorphicResolver } = this.addPolymorphicUnionType(definition, shadowCRUD.definition); - - // Build resolvers. - const resolvers = _.omitBy(_.merge(shadowCRUD.resolver, resolver, polymorphicResolver), _.isEmpty) || {}; - - // Transform object to only contain function. - Object.keys(resolvers).reduce((acc, type) => { - return Object.keys(acc[type]).reduce((acc, resolver) => { - // Disabled this query. - if (acc[type][resolver] === false) { - delete acc[type][resolver]; - - return acc; - } - - if (!_.isFunction(acc[type][resolver])) { - acc[type][resolver] = acc[type][resolver].resolver; - } - - if (_.isString(acc[type][resolver]) || _.isPlainObject(acc[type][resolver])) { - const { plugin = '' } = _.isPlainObject(acc[type][resolver]) ? acc[type][resolver] : {}; - - acc[type][resolver] = this.composeResolver( - strapi.plugins.graphql.config._schema.graphql, - plugin, - resolver, - 'force' // Avoid singular/pluralize and force query name. - ); - } - - return acc; - }, acc); - }, resolvers); - - // Return empty schema when there is no model. - if (_.isEmpty(shadowCRUD.definition) && _.isEmpty(definition)) { - return {}; - } - - // Concatenate. - const typeDefs = ` - ${definition} - ${shadowCRUD.definition} - type Query {${shadowCRUD.query && this.formatGQL(shadowCRUD.query, resolver.Query, null, 'query')}${query}} - ${this.addCustomScalar(resolvers)} - ${polymorphicDef} - `; - - // Build schema. - const schema = makeExecutableSchema({ - typeDefs, - resolvers, - }); - - // Write schema. - this.writeGenerateSchema(graphql.printSchema(schema)); - - return schema; - }, - - /** - * Add custom scalar type such as JSON. - * - * @return void - */ - - addCustomScalar: (resolvers) => { - Object.assign(resolvers, { - JSON: GraphQLJSON, - DateTime: GraphQLDateTime, - }); - - return 'scalar JSON \n scalar DateTime'; - }, - - /** - * Add Union Type that contains the types defined by the user. - * - * @return string - */ - - addPolymorphicUnionType: (customDefs, defs) => { - const types = graphql.parse(customDefs + defs).definitions - .filter(def => def.kind === 'ObjectTypeDefinition' && def.name.value !== 'Query') - .map(def => def.name.value); - - if (types.length > 0) { - return { - polymorphicDef: `union Morph = ${types.join(' | ')}`, - polymorphicResolver: { - Morph: { - __resolveType(obj, context, info) { // eslint-disable-line no-unused-vars - return obj.kind || obj._type; - } - } - } - }; - } - - return { - polymorphicDef: '', - polymorphicResolver: {} - }; - }, - - /** - * Save into a file the readable GraphQL schema. - * - * @return void - */ - - writeGenerateSchema(schema) { - // Disable auto-reload. - strapi.reload.isWatching = false; - - const generatedFolder = path.resolve(strapi.config.appPath, 'plugins', 'graphql', 'config', 'generated'); - - // Create folder if necessary. - try { - fs.accessSync(generatedFolder, fs.constants.R_OK | fs.constants.W_OK); - } catch (err) { - if (err && err.code === 'ENOENT') { - fs.mkdirSync(generatedFolder); - } else { - console.error(err); - } - } - - fs.writeFileSync(path.join(generatedFolder, 'schema.graphql'), schema); - - strapi.reload.isWatching = true; - } - -}; diff --git a/packages/strapi-utils/lib/models.js b/packages/strapi-utils/lib/models.js index 735d37121c..7aaad82546 100644 --- a/packages/strapi-utils/lib/models.js +++ b/packages/strapi-utils/lib/models.js @@ -16,11 +16,13 @@ const isNumeric = (value) => { return !_.isObject(value) && !isNaN(parseFloat(value)) && isFinite(value); }; +// Constants +const ORDERS = ['ASC', 'DESC']; + /* eslint-disable prefer-template */ /* * Set of utils for models */ - module.exports = { /** @@ -435,8 +437,26 @@ module.exports = { }, convertParams: (entity, params) => { + const { model, models, convertor, postProcessValue } = this.prepareStage( + entity, + params + ); + + const _filter = this.splitPrimitiveAndRelationValues(params); + + // Execute Steps in the given order + return _.flow([ + this.processValues({ model, models, convertor, postProcessValue }), + this.processPredicates({ model, models, convertor }), + this.processGeneratedResults(), + ])(_filter); + }, + + prepareStage: (entity, params) => { if (!entity) { - throw new Error('You can\'t call the convert params method without passing the model\'s name as a first argument.'); + throw new Error( + 'You can\'t call the convert params method without passing the model\'s name as a first argument.' + ); } // Remove the source params (that can be sent from the ctm plugin) since it is not a filter @@ -444,35 +464,47 @@ module.exports = { delete params.source; } - const model = entity.toLowerCase(); + const modelName = entity.toLowerCase(); + const models = this.getStrapiModels(); + const model = models[modelName]; - const models = _.assign(_.clone(strapi.models), Object.keys(strapi.plugins).reduce((acc, current) => { - _.assign(acc, _.get(strapi.plugins[current], ['models'], {})); - return acc; - }, {})); - - if (!models.hasOwnProperty(model)) { - return this.log.error(`The model ${model} can't be found.`); + if (!model) { + throw new Error(`The model ${modelName} can't be found.`); } - const client = models[model].client; - const connector = models[model].orm; - - if (!connector) { - throw new Error(`Impossible to determine the ORM used for the model ${model}.`); + if (!model.orm) { + throw new Error( + `Impossible to determine the ORM used for the model ${modelName}.` + ); } - const convertor = strapi.hook[connector].load().getQueryParams; - const _utils = require(path.resolve(strapi.config.appPath, 'node_modules', 'strapi-hook-' + connector, 'lib', 'utils')); - const utils = _utils(); - const convertParams = { - where: {}, - relations: {}, - sort: '', - start: 0, - limit: 100 + const hook = strapi.hook[model.orm]; + const convertor = hook.load().getQueryParams; + const postProcessValue = hook.load().postProcessValue || _.identity; + + return { + models, + model, + hook, + convertor, + postProcessValue, }; + }, + getStrapiModels: () => { + return { + ...strapi.models, + ...Object.keys(strapi.plugins).reduce( + (acc, pluginName) => ({ + ...acc, + ..._.get(strapi.plugins[pluginName], 'models', {}), + }), + {} + ), + }; + }, + +<<<<<<< HEAD _.forEach(params, (value, key) => { let result; let formattedValue; @@ -487,67 +519,173 @@ module.exports = { splitKey = splitKey.join('_'); if (modelAttributes[splitKey]) { fieldType = modelAttributes[splitKey]['type']; - } - } - // Check if the value is a valid candidate to be converted to a number value - if (fieldType !== 'string') { - formattedValue = isNumeric(value) - ? _.toNumber(value) - : value; - } else { - formattedValue = connector === 'mongoose' ? - utils.isObjectId(value) - ? utils.toObjectId(value) // This is required in order to be used inside of aggregate $match metakey - : value - : value; - } - - if (_.includes(['_start', '_limit'], key)) { - result = convertor(formattedValue, key); - } else if (key === '_sort') { - const [attr, order = 'ASC'] = formattedValue.split(':'); - result = convertor(order, key, attr); - } else { - let type = '='; - - if (key.match(/_{1}(?:ne|lte?|gte?|containss?|in)/)) { - type = key.match(/_{1}(?:ne|lte?|gte?|containss?|in)/)[0]; - key = key.replace(type, ''); - } - - if (key.includes('.')) { - // Check if it's a valid relation - const [relationName, relationKey] = key.split('.'); - const relationAttribute = models[model] && models[model].attributes[relationName]; - - if (relationAttribute && ( - relationAttribute.hasOwnProperty('collection') || - relationAttribute.hasOwnProperty('model') - )) { - // Mysql stores boolean as 1 or 0 - const field = models[relationAttribute.collection ? relationAttribute.collection : relationAttribute.model].attributes[relationKey]; - if (client === 'mysql' && field.type && field.type === 'boolean') { - formattedValue = value === 'true' ? '1' : '0'; - } - - result = convertor(formattedValue, type, relationKey); - result.key = result.key.replace('where.', `relations.${relationName}.`); - } +======= + splitPrimitiveAndRelationValues: _query => { + const result = _.reduce( + _query, + (acc, value, key) => { + if (_.startsWith(key, '_')) { + acc[key] = value; + } else if (!_.includes(key, '.')) { + acc.where[key] = value; } else { - // Mysql stores boolean as 1 or 0 - if (client === 'mysql' && _.get(models, [model, 'attributes', key, 'type']) === 'boolean') { - formattedValue = value === 'true' ? '1' : '0'; - } - - result = convertor(formattedValue, type, key); + _.set(acc.relations, this.injectRelationInKey(key), value); +>>>>>>> 93889db... Split convertParams to multiple stage steps } + return acc; + }, + { + where: {}, + relations: {}, + sort: '', + start: 0, + limit: 100, } + ); + return result; + }, - if (result) { - _.set(convertParams, result.key, result.value); - } + injectRelationInKey: key => { + const numberOfRelations = key.match(/\./gi).length - 1; + const relationStrings = _.times(numberOfRelations, _.constant('relations')); + return _.chain(key) + .split('.') + .zip(relationStrings) + .flatten() + .compact() + .join('.') + .value(); + }, + + transformFilter: (filter, iteratee) => { + if (!_.isArray(filter) && !_.isPlainObject(filter)) { + return filter; + } + + return _.transform(filter, (updatedFilter, value, key) => { + const updatedValue = iteratee(value, key); + updatedFilter[key] = this.transformFilter(updatedValue, iteratee); + return updatedFilter; }); + }, - return convertParams; - } + processValues: ({ model, models, convertor, postProcessValue }) => filter => { + let parentModel = model; + return this.transformFilter(filter, (value, key) => { + const field = this.getFieldFromKey(key, parentModel); + if (!field) { + return this.processMeta(value, key, { + field, + client: model.client, + model, + convertor, + }); + } + if (field.collection || field.model) { + parentModel = models[field.collection || field.model]; + } + return postProcessValue( + this.processValue(value, key, { field, client: model.client, model }) + ); + }); + }, + + getFieldFromKey: (key, model) => { + let field; + // Primary key is a unique case because it doesn't belong to the model's attributes + if (key === model.primaryKey) { + field = { + type: 'ID', // Just in case + }; + } else if (model.attributes[key]) { + field = model.attributes[key]; + } else { + // Remove the filter keyword at the end + let splitKey = key.split('_').slice(0, -1); + splitKey = splitKey.join('_'); + + if (model.attributes[splitKey]) { + field = model.attributes[splitKey]; + } + } + + return field; + }, + + processValue: (value, key, { field, client }) => { + if (field.type === 'boolean' && client === 'mysql') { + return value === 'true' ? '1' : '0'; + } + + return value; + }, + + processMeta: (value, key, { convertor, model }) => { + if (_.includes(['_start', '_limit'], key)) { + return convertor(value, key); + } else if (key === '_sort') { + return this.processSortMeta(value, key, { convertor, model }); + } + + return value; + }, + + processSortMeta: (value, key, { convertor, model }) => { + const [attr, order = 'ASC'] = value.split(':'); + if (!_.includes(ORDERS, order)) { + throw new Error( + `Unkown order value: "${order}", available values are: ${ORDERS.join( + ', ' + )}` + ); + } + + const field = this.getFieldFromKey(attr, model); + if (!field) { + throw new Error(`Unkown field: "${attr}"`); + } + + return convertor(order, key, attr); + }, + + processPredicates: ({ model, models, convertor }) => filter => { + let parentModel = model; + return this.transformFilter(filter, (value, key) => { + const field = this.getFieldFromKey(key, parentModel); + if (!field) { + return value; + } + if (field.collection || field.model) { + parentModel = models[field.collection || field.model]; + } + return this.processCriteriaMeta(value, key, { convertor }); + }); + }, + + processCriteriaMeta: (value, key, { convertor }) => { + let type = '='; + if (key.match(/_{1}(?:ne|lte?|gte?|containss?|in)/)) { + type = key.match(/_{1}(?:ne|lte?|gte?|containss?|in)/)[0]; + key = key.replace(type, ''); + } + return convertor(value, type, key); + }, + + processGeneratedResults: () => filter => { + if (!_.isArray(filter) && !_.isPlainObject(filter)) { + return filter; + } + + return _.transform(filter, (updatedFilter, value, key) => { + // Only set results for object of shape { value, key } + if (_.has(value, 'value') && _.has(value, 'key')) { + const cleanKey = _.replace(value.key, 'where.', ''); + _.set(updatedFilter, cleanKey, this.processGeneratedResults()(value.value)); + } else { + updatedFilter[key] = this.processGeneratedResults()(value); + } + + return updatedFilter; + }); + }, }; From 3c37c33d5889b82355ab4b96448801c2066281f9 Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Mon, 24 Sep 2018 21:28:24 +0200 Subject: [PATCH 14/44] Rewrite the logic of deep population & matching --- .../templates/mongoose/service.template | 225 ++++++++++++------ 1 file changed, 148 insertions(+), 77 deletions(-) diff --git a/packages/strapi-generate-api/templates/mongoose/service.template b/packages/strapi-generate-api/templates/mongoose/service.template index b96c0fd9c0..7d5e8ac232 100644 --- a/packages/strapi-generate-api/templates/mongoose/service.template +++ b/packages/strapi-generate-api/templates/mongoose/service.template @@ -10,6 +10,144 @@ // Public dependencies. const _ = require('lodash'); +const buildTempFieldPath = field => { + return `__${field}`; +}; + +const restoreRealFieldPath = (field, prefix) => { + return `${prefix}${field}`; +}; + +export const generateLookupStage = ( + strapiModel, + { whitelistedPopulate = null, prefixPath = '' } = {} +) => { + const result = strapiModel.associations + .filter(ast => { + if (whitelistedPopulate) { + return _.includes(whitelistedPopulate, ast.alias); + } + return ast.autoPopulate; + }) + .reduce((acc, ast) => { + const model = ast.plugin + ? strapi.plugins[ast.plugin].models[ast.collection || ast.model] + : strapi.models[ast.collection || ast.model]; + + const from = model.collectionName; + const isDominantAssociation = ast.dominant || !!ast.model; + + const _localField = !isDominantAssociation + ? '_id' + : ast.via === strapiModel.collectionName || ast.via === 'related' + ? '_id' + : ast.alias; + + const localField = `${prefixPath}${_localField}`; + + const foreignField = ast.filter + ? `${ast.via}.ref` + : isDominantAssociation + ? ast.via === strapiModel.collectionName + ? ast.via + : '_id' + : ast.via === strapiModel.collectionName + ? '_id' + : ast.via; + + // Add the juncture like the `.populate()` function + const asTempPath = buildTempFieldPath(ast.alias, prefixPath); + const asRealPath = restoreRealFieldPath(ast.alias, prefixPath); + acc.push({ + $lookup: { + from, + localField, + foreignField, + as: asTempPath, + }, + }); + + // Unwind the relation's result if only one is expected + if (ast.type === 'model') { + acc.push({ + $unwind: { + path: `$${asTempPath}`, + preserveNullAndEmptyArrays: true, + }, + }); + } + + // Preserve relation field if it is empty + acc.push({ + $addFields: { + [asRealPath]: { + $ifNull: [`$${asTempPath}`, null], + }, + }, + }); + + // Remove temp field + acc.push({ + $project: { + [asTempPath]: 0, + }, + }); + + return acc; + }, []); + + return result; +}; + +export const generateMatchStage = ( + strapiModel, + filters, + { prefixPath = '' } = {} +) => { + const result = _.chain(filters) + .get('relations') + .reduce((acc, relationFilters, relationName) => { + const association = strapiModel.associations.find( + a => a.alias === relationName + ); + + // Ignore association if it's not been found + if (!association) { + return acc; + } + + const model = association.plugin + ? strapi.plugins[association.plugin].models[ + association.collection || association.model + ] + : strapi.models[association.collection || association.model]; + + _.forEach(relationFilters, (value, key) => { + if (key !== 'relations') { + acc.push({ + $match: { [`${prefixPath}${relationName}.${key}`]: value }, + }); + } else { + const nextPrefixedPath = `${prefixPath}${relationName}.`; + acc.push( + ...generateLookupStage(model, { + whitelistedPopulate: _.keys(value), + prefixPath: nextPrefixedPath, + }), + ...generateMatchStage(model, relationFilters, { + prefixPath: nextPrefixedPath, + }) + ); + } + }); + return acc; + }, []) + .value(); + + return result; +}; + + module.exports = { /** @@ -22,84 +160,17 @@ module.exports = { // Convert `params` object to filters compatible with Mongo. const filters = strapi.utils.models.convertParams('<%= globalID.toLowerCase() %>', params); - // Select field to populate. - const populate = <%= globalID %>.associations - .filter(ast => ast.autoPopulate) - .reduce((acc, ast) => { - // Strapi Model - const model = ast.plugin - ? strapi.plugins[ast.plugin].models[ast.collection || ast.model] - : strapi.models[ast.collection || ast.model]; + // Generate stages. + const populate = generateLookupStage(<%= globalID %>); + const match = generateMatchStage(<%= globalID %>, filters); - const from = model.collectionName; - const as = ast.alias; - const localField = ast.dominant ? '_id' : ast.via === <%= globalID %>.collectionName || ast.via === 'related' ? '_id' : ast.alias; - const foreignField = ast.filter ? `${ast.via}.ref` : - ast.dominant ? - (ast.via === <%= globalID %>.collectionName ? ast.via : '_id') : - (ast.via === <%= globalID %>.collectionName ? '_id' : ast.via); - - // Add the juncture like the `.populate()` function - acc.push({ - $lookup: { - from, - localField, - foreignField, - as, - } - }); - - // Unwind the relation's result if only one is expected - if (ast.type === 'model') { - acc.push({ - $unwind: { - path: `$${ast.alias}`, - preserveNullAndEmptyArrays: true - } - }); - } - - // Preserve relation field if it is empty - acc.push({ - $addFields: { - [ast.alias]: { - $ifNull: [`$${ast.alias}`, null] - } - } - }); - - // Filtrate the result depending of params - if (filters.relations) { - Object.keys(filters.relations).forEach( - (relationName) => { - if (ast.alias === relationName) { - const association = <%= globalID %>.associations.find(a => a.alias === relationName); - if (association) { - const relation = filters.relations[relationName]; - - Object.keys(relation).forEach( - (filter) => { - acc.push({ - $match: { [`${relationName}.${filter}`]: relation[filter] } - }); - } - ); - } - } - } - ); - } - - return acc; - }, []); - - const result = <%= globalID %> - .aggregate([ - { - $match: filters.where - }, - ...populate, - ]) + const result = <%= globalID %>.aggregate([ + { + $match: filters.where, // Direct relation filter + }, + ...populate, // Nested-Population + ...match, // Nested relation filter + ]) .skip(filters.start) .limit(filters.limit); From 9862705f8fdf1bd966478b830afa50784ec47ae0 Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Mon, 24 Sep 2018 21:48:38 +0200 Subject: [PATCH 15/44] fix rebase issue --- .../strapi-plugin-graphql/services/Query.js | 29 +++++++++++++++---- packages/strapi-utils/lib/models.js | 17 ----------- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/strapi-plugin-graphql/services/Query.js b/packages/strapi-plugin-graphql/services/Query.js index d2825af948..775177c513 100644 --- a/packages/strapi-plugin-graphql/services/Query.js +++ b/packages/strapi-plugin-graphql/services/Query.js @@ -25,6 +25,23 @@ module.exports = { }, {}); }, + convertToQuery: function(params) { + const result = {}; + + _.forEach(params, (value, key) => { + if (_.isPlainObject(value)) { + const flatObject = this.convertToQuery(value); + _.forEach (flatObject, (_value, _key) => { + result[key + '.' + _key] = _value; + }); + } else { + result[key] = value; + } + }); + + return result; + }, + /** * Security to avoid infinite limit. * @@ -178,13 +195,15 @@ module.exports = { // Plural. return async (ctx, next) => { - ctx.params = this.amountLimiting(ctx.params); - ctx.query = Object.assign( - this.convertToParams(_.omit(ctx.params, 'where')), - ctx.params.where, + const queryOpts = {}; + queryOpts.params = this.amountLimiting(ctx.params); + queryOpts.query = Object.assign( + {}, + this.convertToParams(_.omit(queryOpts.params, 'where')), + this.convertToQuery(queryOpts.params.where) ); - return controller(ctx, next); + return controller(Object.assign({}, ctx, queryOpts, { send: ctx.send }), next); }; })(); diff --git a/packages/strapi-utils/lib/models.js b/packages/strapi-utils/lib/models.js index 7aaad82546..f8519f23df 100644 --- a/packages/strapi-utils/lib/models.js +++ b/packages/strapi-utils/lib/models.js @@ -504,22 +504,6 @@ module.exports = { }; }, -<<<<<<< HEAD - _.forEach(params, (value, key) => { - let result; - let formattedValue; - let modelAttributes = models[model]['attributes']; - let fieldType; - // Get the field type to later check if it's a string before number conversion - if (modelAttributes[key]) { - fieldType = modelAttributes[key]['type']; - } else { - // Remove the filter keyword at the end - let splitKey = key.split('_').slice(0,-1); - splitKey = splitKey.join('_'); - if (modelAttributes[splitKey]) { - fieldType = modelAttributes[splitKey]['type']; -======= splitPrimitiveAndRelationValues: _query => { const result = _.reduce( _query, @@ -530,7 +514,6 @@ module.exports = { acc.where[key] = value; } else { _.set(acc.relations, this.injectRelationInKey(key), value); ->>>>>>> 93889db... Split convertParams to multiple stage steps } return acc; }, From ce534cf568db719683752e3cb1df74da88a0bed3 Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Mon, 24 Sep 2018 22:30:12 +0200 Subject: [PATCH 16/44] Take into account user model --- .../config/queries/mongoose.js | 201 +++++++++++++----- 1 file changed, 142 insertions(+), 59 deletions(-) diff --git a/packages/strapi-plugin-users-permissions/config/queries/mongoose.js b/packages/strapi-plugin-users-permissions/config/queries/mongoose.js index cbf9eb52f1..3158f8ca9e 100644 --- a/packages/strapi-plugin-users-permissions/config/queries/mongoose.js +++ b/packages/strapi-plugin-users-permissions/config/queries/mongoose.js @@ -1,76 +1,159 @@ const _ = require('lodash'); -module.exports = { - find: async function (params = {}) { - let collectionName = this.collectionName; - if (this.collectionName.split('_')) { - collectionName = this.collectionName.split('_')[this.collectionName.split('_').length - 1]; - } +const buildTempFieldPath = field => { + return `__${field}`; +}; - const populate = this.associations - .filter(ast => ast.autoPopulate) - .reduce((acc, ast) => { - const from = ast.plugin ? `${ast.plugin}_${ast.model}` : ast.collection ? ast.collection : ast.model; - const as = ast.alias; - const localField = !ast.dominant ? '_id' : ast.via === collectionName || ast.via === 'related' ? '_id' : ast.alias; - const foreignField = ast.filter ? `${ast.via}.ref` : - ast.dominant ? - (ast.via === collectionName ? ast.via : '_id') : - (ast.via === collectionName ? '_id' : ast.via); +const restoreRealFieldPath = (field, prefix) => { + return `${prefix}${field}`; +}; +export const generateLookupStage = ( + strapiModel, + { whitelistedPopulate = null, prefixPath = '' } = {} +) => { + const result = strapiModel.associations + .filter(ast => { + if (whitelistedPopulate) { + return _.includes(whitelistedPopulate, ast.alias); + } + return ast.autoPopulate; + }) + .reduce((acc, ast) => { + const model = ast.plugin + ? strapi.plugins[ast.plugin].models[ast.collection || ast.model] + : strapi.models[ast.collection || ast.model]; + + const from = model.collectionName; + const isDominantAssociation = ast.dominant || !!ast.model; + + const _localField = !isDominantAssociation + ? '_id' + : ast.via === strapiModel.collectionName || ast.via === 'related' + ? '_id' + : ast.alias; + + const localField = `${prefixPath}${_localField}`; + + const foreignField = ast.filter + ? `${ast.via}.ref` + : isDominantAssociation + ? ast.via === strapiModel.collectionName + ? ast.via + : '_id' + : ast.via === strapiModel.collectionName + ? '_id' + : ast.via; + + // Add the juncture like the `.populate()` function + const asTempPath = buildTempFieldPath(ast.alias, prefixPath); + const asRealPath = restoreRealFieldPath(ast.alias, prefixPath); + acc.push({ + $lookup: { + from, + localField, + foreignField, + as: asTempPath, + }, + }); + + // Unwind the relation's result if only one is expected + if (ast.type === 'model') { acc.push({ - $lookup: { - from, - localField, - foreignField, - as, - } + $unwind: { + path: `$${asTempPath}`, + preserveNullAndEmptyArrays: true, + }, }); + } - if (ast.type === 'model') { + // Preserve relation field if it is empty + acc.push({ + $addFields: { + [asRealPath]: { + $ifNull: [`$${asTempPath}`, null], + }, + }, + }); + + // Remove temp field + acc.push({ + $project: { + [asTempPath]: 0, + }, + }); + + return acc; + }, []); + + return result; +}; + +export const generateMatchStage = ( + strapiModel, + filters, + { prefixPath = '' } = {} +) => { + const result = _.chain(filters) + .get('relations') + .reduce((acc, relationFilters, relationName) => { + const association = strapiModel.associations.find( + a => a.alias === relationName + ); + + // Ignore association if it's not been found + if (!association) { + return acc; + } + + const model = association.plugin + ? strapi.plugins[association.plugin].models[ + association.collection || association.model + ] + : strapi.models[association.collection || association.model]; + + _.forEach(relationFilters, (value, key) => { + if (key !== 'relations') { acc.push({ - $unwind: { - path: `$${ast.alias}`, - preserveNullAndEmptyArrays: true - } + $match: { [`${prefixPath}${relationName}.${key}`]: value }, }); - } - - if (params.relations) { - Object.keys(params.relations).forEach( - (relationName) => { - if (ast.alias === relationName) { - const association = this.associations.find(a => a.alias === relationName); - if (association) { - const relation = params.relations[relationName]; - - Object.keys(relation).forEach( - (filter) => { - acc.push({ - $match: { [`${relationName}.${filter}`]: relation[filter] } - }); - } - ); - } - } - } + } else { + const nextPrefixedPath = `${prefixPath}${relationName}.`; + acc.push( + ...generateLookupStage(model, { + whitelistedPopulate: _.keys(value), + prefixPath: nextPrefixedPath, + }), + ...generateMatchStage(model, relationFilters, { + prefixPath: nextPrefixedPath, + }) ); } + }); + return acc; + }, []) + .value(); - return acc; - }, []); + return result; +}; - const result = this - .aggregate([ - { - $match: params.where ? params.where : {} - }, - ...populate, - ]); +module.exports = { + find: async function (filters = {}, populate) { + // Generate stages. + const populateStage = generateLookupStage(this, { whitelistedPopulate: populate }); + const matchStage = generateMatchStage(this, filters); - if (params.start) result.skip(params.start); - if (params.limit) result.limit(params.limit); - if (params.sort) result.sort(params.sort); + const result = this.aggregate([ + { + $match: filters.where || {}, // Direct relation filter + }, + ...populateStage, // Nested-Population + ...matchStage, // Nested relation filter + ]); + + if (_.has(filters, 'start')) result.skip(filters.start); + if (_.has(filters, 'limit')) result.limit(filters.limit); + if (_.has(filters, 'sort')) result.sort(filters.sort); return result; }, From 4d3d4c8d552f7b709ceb58f99b6cd3b80ce976de Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Mon, 8 Oct 2018 22:19:31 +0200 Subject: [PATCH 17/44] move utility functions to their own file --- packages/strapi-hook-mongoose/lib/index.js | 18 ++++-------------- .../strapi-hook-mongoose/lib/utils/index.js | 13 +++++++++++-- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/strapi-hook-mongoose/lib/index.js b/packages/strapi-hook-mongoose/lib/index.js index 3364a4026d..cdfdc16569 100644 --- a/packages/strapi-hook-mongoose/lib/index.js +++ b/packages/strapi-hook-mongoose/lib/index.js @@ -17,6 +17,8 @@ const { models: utilsModels } = require('strapi-utils'); // Local helpers. const utils = require('./utils/'); +const _utils = utils(); + const relations = require('./relations'); /** @@ -524,23 +526,11 @@ module.exports = function (strapi) { postProcessValue: (value) => { if (_.isArray(value)) { - return value.map(valueToId); + return value.map(_utils.valueToId); } - return valueToId(value); + return _utils.valueToId(value); } }, relations); return hook; }; - -const valueToId = value => { - return isMongoId(value) - ? mongoose.Types.ObjectId(value) - : value; -}; - -const isMongoId = (value) => { - const hexadecimal = /^[0-9A-F]+$/i; - - return hexadecimal.test(value) && value.length === 24; -}; diff --git a/packages/strapi-hook-mongoose/lib/utils/index.js b/packages/strapi-hook-mongoose/lib/utils/index.js index 94b198b2fc..ac267c9a97 100644 --- a/packages/strapi-hook-mongoose/lib/utils/index.js +++ b/packages/strapi-hook-mongoose/lib/utils/index.js @@ -46,7 +46,16 @@ module.exports = (mongoose = new Mongoose()) => { default: } }, - isObjectId: v => mongoose.Types.ObjectId.isValid(v), - toObjectId: v => mongoose.Types.ObjectId(v), + valueToId: value => { + return this.isMongoId(value) + ? mongoose.Types.ObjectId(value) + : value; + }, + isMongoId: (value) => { + // Here we don't use mongoose.Types.ObjectId.isValid method because it's a weird check, + // it returns for instance true for any integer value ¯\_(ツ)_/¯ + const hexadecimal = /^[0-9A-F]+$/i; + return hexadecimal.test(value) && value.length === 24; + } }; }; From bbfb2418a580e7502f6eee500149382c9df81009 Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Mon, 8 Oct 2018 22:35:25 +0200 Subject: [PATCH 18/44] Move functions that deals with stages to the mongoose relation file --- .../templates/mongoose/service.template | 147 +----------------- .../strapi-hook-mongoose/lib/relations.js | 133 ++++++++++++++++ packages/strapi-utils/lib/models.js | 4 + 3 files changed, 143 insertions(+), 141 deletions(-) diff --git a/packages/strapi-generate-api/templates/mongoose/service.template b/packages/strapi-generate-api/templates/mongoose/service.template index 7d5e8ac232..92b77a6e6f 100644 --- a/packages/strapi-generate-api/templates/mongoose/service.template +++ b/packages/strapi-generate-api/templates/mongoose/service.template @@ -10,142 +10,7 @@ // Public dependencies. const _ = require('lodash'); -const buildTempFieldPath = field => { - return `__${field}`; -}; - -const restoreRealFieldPath = (field, prefix) => { - return `${prefix}${field}`; -}; - -export const generateLookupStage = ( - strapiModel, - { whitelistedPopulate = null, prefixPath = '' } = {} -) => { - const result = strapiModel.associations - .filter(ast => { - if (whitelistedPopulate) { - return _.includes(whitelistedPopulate, ast.alias); - } - return ast.autoPopulate; - }) - .reduce((acc, ast) => { - const model = ast.plugin - ? strapi.plugins[ast.plugin].models[ast.collection || ast.model] - : strapi.models[ast.collection || ast.model]; - - const from = model.collectionName; - const isDominantAssociation = ast.dominant || !!ast.model; - - const _localField = !isDominantAssociation - ? '_id' - : ast.via === strapiModel.collectionName || ast.via === 'related' - ? '_id' - : ast.alias; - - const localField = `${prefixPath}${_localField}`; - - const foreignField = ast.filter - ? `${ast.via}.ref` - : isDominantAssociation - ? ast.via === strapiModel.collectionName - ? ast.via - : '_id' - : ast.via === strapiModel.collectionName - ? '_id' - : ast.via; - - // Add the juncture like the `.populate()` function - const asTempPath = buildTempFieldPath(ast.alias, prefixPath); - const asRealPath = restoreRealFieldPath(ast.alias, prefixPath); - acc.push({ - $lookup: { - from, - localField, - foreignField, - as: asTempPath, - }, - }); - - // Unwind the relation's result if only one is expected - if (ast.type === 'model') { - acc.push({ - $unwind: { - path: `$${asTempPath}`, - preserveNullAndEmptyArrays: true, - }, - }); - } - - // Preserve relation field if it is empty - acc.push({ - $addFields: { - [asRealPath]: { - $ifNull: [`$${asTempPath}`, null], - }, - }, - }); - - // Remove temp field - acc.push({ - $project: { - [asTempPath]: 0, - }, - }); - - return acc; - }, []); - - return result; -}; - -export const generateMatchStage = ( - strapiModel, - filters, - { prefixPath = '' } = {} -) => { - const result = _.chain(filters) - .get('relations') - .reduce((acc, relationFilters, relationName) => { - const association = strapiModel.associations.find( - a => a.alias === relationName - ); - - // Ignore association if it's not been found - if (!association) { - return acc; - } - - const model = association.plugin - ? strapi.plugins[association.plugin].models[ - association.collection || association.model - ] - : strapi.models[association.collection || association.model]; - - _.forEach(relationFilters, (value, key) => { - if (key !== 'relations') { - acc.push({ - $match: { [`${prefixPath}${relationName}.${key}`]: value }, - }); - } else { - const nextPrefixedPath = `${prefixPath}${relationName}.`; - acc.push( - ...generateLookupStage(model, { - whitelistedPopulate: _.keys(value), - prefixPath: nextPrefixedPath, - }), - ...generateMatchStage(model, relationFilters, { - prefixPath: nextPrefixedPath, - }) - ); - } - }); - return acc; - }, []) - .value(); - - return result; -}; +const { models: { mergeStages } } = require('strapi-utils'); module.exports = { @@ -159,17 +24,17 @@ module.exports = { fetchAll: (params) => { // Convert `params` object to filters compatible with Mongo. const filters = strapi.utils.models.convertParams('<%= globalID.toLowerCase() %>', params); - + const hook = strapi.hook[<%= globalID %>.orm]; // Generate stages. - const populate = generateLookupStage(<%= globalID %>); - const match = generateMatchStage(<%= globalID %>, filters); + const populate = hook.load().generateLookupStage(<%= globalID %>); // Nested-Population + const match = hook.load().generateMatchStage(<%= globalID %>, filters); // Nested relation filter + const aggregateStages = mergeStages(populate, match); const result = <%= globalID %>.aggregate([ { $match: filters.where, // Direct relation filter }, - ...populate, // Nested-Population - ...match, // Nested relation filter + ...aggregateStages ]) .skip(filters.start) .limit(filters.limit); diff --git a/packages/strapi-hook-mongoose/lib/relations.js b/packages/strapi-hook-mongoose/lib/relations.js index 0dcf8d35b7..584772938f 100644 --- a/packages/strapi-hook-mongoose/lib/relations.js +++ b/packages/strapi-hook-mongoose/lib/relations.js @@ -10,11 +10,144 @@ const _ = require('lodash'); // Utils const { models: { getValuePrimaryKey } } = require('strapi-utils'); + +const buildTempFieldPath = field => { + return `__${field}`; +}; + +const restoreRealFieldPath = (field, prefix) => { + return `${prefix}${field}`; +}; + module.exports = { getModel: function (model, plugin) { return _.get(strapi.plugins, [plugin, 'models', model]) || _.get(strapi, ['models', model]) || undefined; }, + generateLookupStage: function (strapiModel, { whitelistedPopulate = null, prefixPath = '' } = {}) { + return strapiModel.associations + .filter(ast => { + if (whitelistedPopulate) { + return _.includes(whitelistedPopulate, ast.alias); + } + return ast.autoPopulate; + }) + .reduce((acc, ast) => { + const model = ast.plugin + ? strapi.plugins[ast.plugin].models[ast.collection || ast.model] + : strapi.models[ast.collection || ast.model]; + + const from = model.collectionName; + const isDominantAssociation = + (ast.dominant && ast.nature === 'manyToMany') || !!ast.model; + + const _localField = + !isDominantAssociation || ast.via === 'related' ? '_id' : ast.alias; + + const localField = `${prefixPath}${_localField}`; + + const foreignField = ast.filter + ? `${ast.via}.ref` + : isDominantAssociation + ? '_id' + : ast.via; + + // Add the juncture like the `.populate()` function + const asTempPath = buildTempFieldPath(ast.alias, prefixPath); + const asRealPath = restoreRealFieldPath(ast.alias, prefixPath); + acc.push({ + $lookup: { + from, + localField, + foreignField, + as: asTempPath, + }, + }); + + // Unwind the relation's result if only one is expected + if (ast.type === 'model') { + acc.push({ + $unwind: { + path: `$${asTempPath}`, + preserveNullAndEmptyArrays: true, + }, + }); + } + + // Preserve relation field if it is empty + acc.push({ + $addFields: { + [asRealPath]: { + $ifNull: [`$${asTempPath}`, null], + }, + }, + }); + + // Remove temp field + acc.push({ + $project: { + [asTempPath]: 0, + }, + }); + + return acc; + }, []); + }, + + generateMatchStage: function (strapiModel, filters, { prefixPath = '' } = {}) { + if (!filters) { + return undefined; + } + + let acc = []; + + _.forEach(filters.relations, (value, key) => { + if (key !== 'relations') { + const nextPrefixedPath = `${prefixPath}${key}.`; + const association = strapiModel.associations.find(a => a.alias === key); + + if (!association) { + acc.push({ + $match: { [`${prefixPath}${key}`]: value }, + }); + } else { + const model = association.plugin + ? strapi.plugins[association.plugin].models[ + association.collection || association.model + ] + : strapi.models[association.collection || association.model]; + + // Generate lookup for this relation + acc.push( + ...this.generateLookupStage(strapiModel, { + whitelistedPopulate: [key], + prefixPath, + }) + ); + + // If it's an object re-run the same function with this new value until having either a primitive value or an array. + if (_.isPlainObject(value)) { + acc.push( + ...this.generateMatchStage( + model, + { relations: value }, + { + prefixPath: nextPrefixedPath, + } + ) + ); + } + } + } else { + acc.push( + ...this.generateMatchStage(strapiModel, { relations: value }, { prefixPath }) + ); + } + }); + + return acc; + }, + update: async function (params) { const virtualFields = []; const response = await this diff --git a/packages/strapi-utils/lib/models.js b/packages/strapi-utils/lib/models.js index f8519f23df..5116719df2 100644 --- a/packages/strapi-utils/lib/models.js +++ b/packages/strapi-utils/lib/models.js @@ -436,6 +436,10 @@ module.exports = { return _.findKey(strapi.models[association.model || association.collection].attributes, {via: attribute}); }, + mergeStages: (...stages) => { + return _.unionWith(...stages, _.isEqual); + }, + convertParams: (entity, params) => { const { model, models, convertor, postProcessValue } = this.prepareStage( entity, From 15a41614fa7123e2ced0bd500316b60f43629225 Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Mon, 8 Oct 2018 22:38:58 +0200 Subject: [PATCH 19/44] Don't populate relations in Graphql context --- .../templates/mongoose/service.template | 4 ++-- packages/strapi-plugin-graphql/services/Query.js | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/strapi-generate-api/templates/mongoose/service.template b/packages/strapi-generate-api/templates/mongoose/service.template index 92b77a6e6f..37a5c856d6 100644 --- a/packages/strapi-generate-api/templates/mongoose/service.template +++ b/packages/strapi-generate-api/templates/mongoose/service.template @@ -21,12 +21,12 @@ module.exports = { * @return {Promise} */ - fetchAll: (params) => { + fetchAll: (params, next, { populate } = {}) => { // Convert `params` object to filters compatible with Mongo. const filters = strapi.utils.models.convertParams('<%= globalID.toLowerCase() %>', params); const hook = strapi.hook[<%= globalID %>.orm]; // Generate stages. - const populate = hook.load().generateLookupStage(<%= globalID %>); // Nested-Population + const populate = hook.load().generateLookupStage(<%= globalID %>, { whitelistedPopulate: populate }); // Nested-Population const match = hook.load().generateMatchStage(<%= globalID %>, filters); // Nested relation filter const aggregateStages = mergeStages(populate, match); diff --git a/packages/strapi-plugin-graphql/services/Query.js b/packages/strapi-plugin-graphql/services/Query.js index 775177c513..a0c0a2d550 100644 --- a/packages/strapi-plugin-graphql/services/Query.js +++ b/packages/strapi-plugin-graphql/services/Query.js @@ -203,7 +203,7 @@ module.exports = { this.convertToQuery(queryOpts.params.where) ); - return controller(Object.assign({}, ctx, queryOpts, { send: ctx.send }), next); + return controller(Object.assign({}, ctx, queryOpts, { send: ctx.send }), next, { populate: [] }); }; })(); @@ -281,8 +281,12 @@ module.exports = { // Resolver can be a function. Be also a native resolver or a controller's action. if (_.isFunction(resolver)) { - context.query = this.convertToParams(options); context.params = this.amountLimiting(options); + context.query = Object.assign( + {}, + this.convertToParams(_.omit(options, 'where')), + this.convertToQuery(options.where) + ); if (isController) { const values = await resolver.call(null, context); From 5bdb5c25e007f86d1aae36e4f15884178c925bdc Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Mon, 8 Oct 2018 22:46:43 +0200 Subject: [PATCH 20/44] use stage functions from the relation file --- .../templates/bookshelf/service.template | 11 +- .../templates/mongoose/service.template | 10 +- .../config/queries/mongoose.js | 146 +----------------- 3 files changed, 15 insertions(+), 152 deletions(-) diff --git a/packages/strapi-generate-api/templates/bookshelf/service.template b/packages/strapi-generate-api/templates/bookshelf/service.template index aac20bb6cd..c1fe4dbe38 100644 --- a/packages/strapi-generate-api/templates/bookshelf/service.template +++ b/packages/strapi-generate-api/templates/bookshelf/service.template @@ -10,9 +10,6 @@ // Public dependencies. const _ = require('lodash'); -// Strapi utilities. -const utils = require('strapi-hook-bookshelf/lib/utils/'); - module.exports = { /** @@ -85,12 +82,10 @@ module.exports = { } ); - qb.offset(filters.start); - qb.limit(filters.limit); + if (_.has(filters, 'start')) qb.offset(filters.start); + if (_.has(filters, 'limit')) qb.limit(filters.limit); + if (_.has(filters, 'sort')) qb.orderBy(filters.sort.key, filters.sort.order); - if (filters.sort) { - qb.orderBy(filters.sort.key, filters.sort.order); - } }).fetchAll({ withRelated: populate }); diff --git a/packages/strapi-generate-api/templates/mongoose/service.template b/packages/strapi-generate-api/templates/mongoose/service.template index 37a5c856d6..f9d4bcb4cf 100644 --- a/packages/strapi-generate-api/templates/mongoose/service.template +++ b/packages/strapi-generate-api/templates/mongoose/service.template @@ -26,9 +26,9 @@ module.exports = { const filters = strapi.utils.models.convertParams('<%= globalID.toLowerCase() %>', params); const hook = strapi.hook[<%= globalID %>.orm]; // Generate stages. - const populate = hook.load().generateLookupStage(<%= globalID %>, { whitelistedPopulate: populate }); // Nested-Population - const match = hook.load().generateMatchStage(<%= globalID %>, filters); // Nested relation filter - const aggregateStages = mergeStages(populate, match); + const populateStage = hook.load().generateLookupStage(<%= globalID %>, { whitelistedPopulate: populate }); // Nested-Population + const matchStage = hook.load().generateMatchStage(<%= globalID %>, filters); // Nested relation filter + const aggregateStages = mergeStages(populateStage, matchStage); const result = <%= globalID %>.aggregate([ { @@ -39,7 +39,9 @@ module.exports = { .skip(filters.start) .limit(filters.limit); - if (filters.sort) result.sort(filters.sort); + if (_.has(filters, 'start')) result.skip(filters.start); + if (_.has(filters, 'limit')) result.limit(filters.limit); + if (_.has(filters, 'sort')) result.sort(filters.sort); return result; }, diff --git a/packages/strapi-plugin-users-permissions/config/queries/mongoose.js b/packages/strapi-plugin-users-permissions/config/queries/mongoose.js index 3158f8ca9e..35a7adc2aa 100644 --- a/packages/strapi-plugin-users-permissions/config/queries/mongoose.js +++ b/packages/strapi-plugin-users-permissions/config/queries/mongoose.js @@ -1,154 +1,20 @@ const _ = require('lodash'); -const buildTempFieldPath = field => { - return `__${field}`; -}; - -const restoreRealFieldPath = (field, prefix) => { - return `${prefix}${field}`; -}; - -export const generateLookupStage = ( - strapiModel, - { whitelistedPopulate = null, prefixPath = '' } = {} -) => { - const result = strapiModel.associations - .filter(ast => { - if (whitelistedPopulate) { - return _.includes(whitelistedPopulate, ast.alias); - } - return ast.autoPopulate; - }) - .reduce((acc, ast) => { - const model = ast.plugin - ? strapi.plugins[ast.plugin].models[ast.collection || ast.model] - : strapi.models[ast.collection || ast.model]; - - const from = model.collectionName; - const isDominantAssociation = ast.dominant || !!ast.model; - - const _localField = !isDominantAssociation - ? '_id' - : ast.via === strapiModel.collectionName || ast.via === 'related' - ? '_id' - : ast.alias; - - const localField = `${prefixPath}${_localField}`; - - const foreignField = ast.filter - ? `${ast.via}.ref` - : isDominantAssociation - ? ast.via === strapiModel.collectionName - ? ast.via - : '_id' - : ast.via === strapiModel.collectionName - ? '_id' - : ast.via; - - // Add the juncture like the `.populate()` function - const asTempPath = buildTempFieldPath(ast.alias, prefixPath); - const asRealPath = restoreRealFieldPath(ast.alias, prefixPath); - acc.push({ - $lookup: { - from, - localField, - foreignField, - as: asTempPath, - }, - }); - - // Unwind the relation's result if only one is expected - if (ast.type === 'model') { - acc.push({ - $unwind: { - path: `$${asTempPath}`, - preserveNullAndEmptyArrays: true, - }, - }); - } - - // Preserve relation field if it is empty - acc.push({ - $addFields: { - [asRealPath]: { - $ifNull: [`$${asTempPath}`, null], - }, - }, - }); - - // Remove temp field - acc.push({ - $project: { - [asTempPath]: 0, - }, - }); - - return acc; - }, []); - - return result; -}; - -export const generateMatchStage = ( - strapiModel, - filters, - { prefixPath = '' } = {} -) => { - const result = _.chain(filters) - .get('relations') - .reduce((acc, relationFilters, relationName) => { - const association = strapiModel.associations.find( - a => a.alias === relationName - ); - - // Ignore association if it's not been found - if (!association) { - return acc; - } - - const model = association.plugin - ? strapi.plugins[association.plugin].models[ - association.collection || association.model - ] - : strapi.models[association.collection || association.model]; - - _.forEach(relationFilters, (value, key) => { - if (key !== 'relations') { - acc.push({ - $match: { [`${prefixPath}${relationName}.${key}`]: value }, - }); - } else { - const nextPrefixedPath = `${prefixPath}${relationName}.`; - acc.push( - ...generateLookupStage(model, { - whitelistedPopulate: _.keys(value), - prefixPath: nextPrefixedPath, - }), - ...generateMatchStage(model, relationFilters, { - prefixPath: nextPrefixedPath, - }) - ); - } - }); - return acc; - }, []) - .value(); - - return result; -}; +const { models: { mergeStages } } = require('strapi-utils'); module.exports = { find: async function (filters = {}, populate) { + const hook = strapi.hook[this.orm]; // Generate stages. - const populateStage = generateLookupStage(this, { whitelistedPopulate: populate }); - const matchStage = generateMatchStage(this, filters); + const populateStage = hook.load().generateLookupStage(this, { whitelistedPopulate: populate }); // Nested-Population + const matchStage = hook.load().generateMatchStage(this, filters); // Nested relation filter + const aggregateStages = mergeStages(populateStage, matchStage); const result = this.aggregate([ { $match: filters.where || {}, // Direct relation filter }, - ...populateStage, // Nested-Population - ...matchStage, // Nested relation filter + ...aggregateStages ]); if (_.has(filters, 'start')) result.skip(filters.start); From 7801aac50f837928efb8f6259f625b38078b3d74 Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Tue, 9 Oct 2018 01:10:15 +0200 Subject: [PATCH 21/44] Turn arrow functions to object functions to get to 'this' --- .../strapi-hook-mongoose/lib/utils/index.js | 4 +- packages/strapi-utils/lib/models.js | 120 +++++++++--------- 2 files changed, 65 insertions(+), 59 deletions(-) diff --git a/packages/strapi-hook-mongoose/lib/utils/index.js b/packages/strapi-hook-mongoose/lib/utils/index.js index ac267c9a97..bed5daabb3 100644 --- a/packages/strapi-hook-mongoose/lib/utils/index.js +++ b/packages/strapi-hook-mongoose/lib/utils/index.js @@ -46,12 +46,12 @@ module.exports = (mongoose = new Mongoose()) => { default: } }, - valueToId: value => { + valueToId: function (value) { return this.isMongoId(value) ? mongoose.Types.ObjectId(value) : value; }, - isMongoId: (value) => { + isMongoId: function (value) { // Here we don't use mongoose.Types.ObjectId.isValid method because it's a weird check, // it returns for instance true for any integer value ¯\_(ツ)_/¯ const hexadecimal = /^[0-9A-F]+$/i; diff --git a/packages/strapi-utils/lib/models.js b/packages/strapi-utils/lib/models.js index 5116719df2..5766febb69 100644 --- a/packages/strapi-utils/lib/models.js +++ b/packages/strapi-utils/lib/models.js @@ -440,7 +440,7 @@ module.exports = { return _.unionWith(...stages, _.isEqual); }, - convertParams: (entity, params) => { + convertParams: function (entity, params) { const { model, models, convertor, postProcessValue } = this.prepareStage( entity, params @@ -456,7 +456,7 @@ module.exports = { ])(_filter); }, - prepareStage: (entity, params) => { + prepareStage: function (entity, params) { if (!entity) { throw new Error( 'You can\'t call the convert params method without passing the model\'s name as a first argument.' @@ -495,7 +495,7 @@ module.exports = { }; }, - getStrapiModels: () => { + getStrapiModels: function() { return { ...strapi.models, ...Object.keys(strapi.plugins).reduce( @@ -508,7 +508,7 @@ module.exports = { }; }, - splitPrimitiveAndRelationValues: _query => { + splitPrimitiveAndRelationValues: function(_query) { const result = _.reduce( _query, (acc, value, key) => { @@ -532,7 +532,7 @@ module.exports = { return result; }, - injectRelationInKey: key => { + injectRelationInKey: function (key) { const numberOfRelations = key.match(/\./gi).length - 1; const relationStrings = _.times(numberOfRelations, _.constant('relations')); return _.chain(key) @@ -544,7 +544,7 @@ module.exports = { .value(); }, - transformFilter: (filter, iteratee) => { + transformFilter: function (filter, iteratee) { if (!_.isArray(filter) && !_.isPlainObject(filter)) { return filter; } @@ -556,28 +556,30 @@ module.exports = { }); }, - processValues: ({ model, models, convertor, postProcessValue }) => filter => { - let parentModel = model; - return this.transformFilter(filter, (value, key) => { - const field = this.getFieldFromKey(key, parentModel); - if (!field) { - return this.processMeta(value, key, { - field, - client: model.client, - model, - convertor, - }); - } - if (field.collection || field.model) { - parentModel = models[field.collection || field.model]; - } - return postProcessValue( - this.processValue(value, key, { field, client: model.client, model }) - ); - }); + processValues: function ({ model, models, convertor, postProcessValue }) { + return filter => { + let parentModel = model; + return this.transformFilter(filter, (value, key) => { + const field = this.getFieldFromKey(key, parentModel); + if (!field) { + return this.processMeta(value, key, { + field, + client: model.client, + model, + convertor, + }); + } + if (field.collection || field.model) { + parentModel = models[field.collection || field.model]; + } + return postProcessValue( + this.processValue(value, key, { field, client: model.client, model }) + ); + }); + }; }, - getFieldFromKey: (key, model) => { + getFieldFromKey: function (key, model) { let field; // Primary key is a unique case because it doesn't belong to the model's attributes if (key === model.primaryKey) { @@ -599,7 +601,7 @@ module.exports = { return field; }, - processValue: (value, key, { field, client }) => { + processValue: function (value, key, { field, client }) { if (field.type === 'boolean' && client === 'mysql') { return value === 'true' ? '1' : '0'; } @@ -607,7 +609,7 @@ module.exports = { return value; }, - processMeta: (value, key, { convertor, model }) => { + processMeta: function (value, key, { convertor, model }) { if (_.includes(['_start', '_limit'], key)) { return convertor(value, key); } else if (key === '_sort') { @@ -617,7 +619,7 @@ module.exports = { return value; }, - processSortMeta: (value, key, { convertor, model }) => { + processSortMeta: function (value, key, { convertor, model }) { const [attr, order = 'ASC'] = value.split(':'); if (!_.includes(ORDERS, order)) { throw new Error( @@ -635,21 +637,23 @@ module.exports = { return convertor(order, key, attr); }, - processPredicates: ({ model, models, convertor }) => filter => { - let parentModel = model; - return this.transformFilter(filter, (value, key) => { - const field = this.getFieldFromKey(key, parentModel); - if (!field) { - return value; - } - if (field.collection || field.model) { - parentModel = models[field.collection || field.model]; - } - return this.processCriteriaMeta(value, key, { convertor }); - }); + processPredicates: function ({ model, models, convertor }) { + return filter => { + let parentModel = model; + return this.transformFilter(filter, (value, key) => { + const field = this.getFieldFromKey(key, parentModel); + if (!field) { + return value; + } + if (field.collection || field.model) { + parentModel = models[field.collection || field.model]; + } + return this.processCriteriaMeta(value, key, { convertor }); + }); + }; }, - processCriteriaMeta: (value, key, { convertor }) => { + processCriteriaMeta: function (value, key, { convertor }) { let type = '='; if (key.match(/_{1}(?:ne|lte?|gte?|containss?|in)/)) { type = key.match(/_{1}(?:ne|lte?|gte?|containss?|in)/)[0]; @@ -658,21 +662,23 @@ module.exports = { return convertor(value, type, key); }, - processGeneratedResults: () => filter => { - if (!_.isArray(filter) && !_.isPlainObject(filter)) { - return filter; - } - - return _.transform(filter, (updatedFilter, value, key) => { - // Only set results for object of shape { value, key } - if (_.has(value, 'value') && _.has(value, 'key')) { - const cleanKey = _.replace(value.key, 'where.', ''); - _.set(updatedFilter, cleanKey, this.processGeneratedResults()(value.value)); - } else { - updatedFilter[key] = this.processGeneratedResults()(value); + processGeneratedResults: function() { + return filter => { + if (!_.isArray(filter) && !_.isPlainObject(filter)) { + return filter; } - return updatedFilter; - }); - }, + return _.transform(filter, (updatedFilter, value, key) => { + // Only set results for object of shape { value, key } + if (_.has(value, 'value') && _.has(value, 'key')) { + const cleanKey = _.replace(value.key, 'where.', ''); + _.set(updatedFilter, cleanKey, this.processGeneratedResults()(value.value)); + } else { + updatedFilter[key] = this.processGeneratedResults()(value); + } + + return updatedFilter; + }); + }; + } }; From 0ed9b7643a359083cfd00206970b965cec1c1df5 Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Wed, 10 Oct 2018 00:34:39 +0200 Subject: [PATCH 22/44] Merge where and relation payload --- .../templates/mongoose/service.template | 7 +------ .../config/queries/mongoose.js | 7 +------ packages/strapi-utils/lib/models.js | 15 ++++++++++++++- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/strapi-generate-api/templates/mongoose/service.template b/packages/strapi-generate-api/templates/mongoose/service.template index f9d4bcb4cf..62bb9045e3 100644 --- a/packages/strapi-generate-api/templates/mongoose/service.template +++ b/packages/strapi-generate-api/templates/mongoose/service.template @@ -30,12 +30,7 @@ module.exports = { const matchStage = hook.load().generateMatchStage(<%= globalID %>, filters); // Nested relation filter const aggregateStages = mergeStages(populateStage, matchStage); - const result = <%= globalID %>.aggregate([ - { - $match: filters.where, // Direct relation filter - }, - ...aggregateStages - ]) + const result = <%= globalID %>.aggregate(aggregateStages) .skip(filters.start) .limit(filters.limit); diff --git a/packages/strapi-plugin-users-permissions/config/queries/mongoose.js b/packages/strapi-plugin-users-permissions/config/queries/mongoose.js index 35a7adc2aa..88e988ed95 100644 --- a/packages/strapi-plugin-users-permissions/config/queries/mongoose.js +++ b/packages/strapi-plugin-users-permissions/config/queries/mongoose.js @@ -10,12 +10,7 @@ module.exports = { const matchStage = hook.load().generateMatchStage(this, filters); // Nested relation filter const aggregateStages = mergeStages(populateStage, matchStage); - const result = this.aggregate([ - { - $match: filters.where || {}, // Direct relation filter - }, - ...aggregateStages - ]); + const result = this.aggregate(aggregateStages); if (_.has(filters, 'start')) result.skip(filters.start); if (_.has(filters, 'limit')) result.limit(filters.limit); diff --git a/packages/strapi-utils/lib/models.js b/packages/strapi-utils/lib/models.js index 5766febb69..ec80dc1658 100644 --- a/packages/strapi-utils/lib/models.js +++ b/packages/strapi-utils/lib/models.js @@ -364,7 +364,7 @@ module.exports = { filter: details.filter, }; - if (infos.nature === 'manyToMany' && !association.plugin && definition.orm === 'bookshelf') { + if (infos.nature === 'manyToMany' && definition.orm === 'bookshelf') { ast.tableCollectionName = this.getCollectionName(association, details); } @@ -453,6 +453,7 @@ module.exports = { this.processValues({ model, models, convertor, postProcessValue }), this.processPredicates({ model, models, convertor }), this.processGeneratedResults(), + this.mergeWhereAndRelationPayloads() ])(_filter); }, @@ -680,5 +681,17 @@ module.exports = { return updatedFilter; }); }; + }, + + mergeWhereAndRelationPayloads: function() { + return filter => { + return { + ...filter, // Normally here we need to omit where key + relations: { + ...filter.where, + relations: filter.relations + } + }; + }; } }; From 957368b8b2175a563b93302f169f1044a8a5e5d0 Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Wed, 10 Oct 2018 00:36:29 +0200 Subject: [PATCH 23/44] Add a support of deep filter in bookshelf --- .../templates/bookshelf/service.template | 67 +++--------------- .../strapi-hook-bookshelf/lib/relations.js | 61 ++++++++++++++++ .../config/queries/bookshelf.js | 69 ++----------------- 3 files changed, 79 insertions(+), 118 deletions(-) diff --git a/packages/strapi-generate-api/templates/bookshelf/service.template b/packages/strapi-generate-api/templates/bookshelf/service.template index c1fe4dbe38..144bc6190f 100644 --- a/packages/strapi-generate-api/templates/bookshelf/service.template +++ b/packages/strapi-generate-api/templates/bookshelf/service.template @@ -19,6 +19,8 @@ module.exports = { */ fetchAll: (params) => { + // Get model hook + const hook = strapi.hook[<%= globalID %>.orm]; // Convert `params` object to filters compatible with Bookshelf. const filters = strapi.utils.models.convertParams('<%= globalID.toLowerCase() %>', params); // Select field to populate. @@ -27,65 +29,18 @@ module.exports = { .map(ast => ast.alias); return <%= globalID %>.query(function(qb) { - _.forEach(filters.where, (where, key) => { - if (_.isArray(where.value) && where.symbol !== 'IN') { - for (const value in where.value) { - qb[value ? 'where' : 'orWhere'](key, where.symbol, where.value[value]); - } - } else { - qb.where(key, where.symbol, where.value); - } - }); - - Object.keys(filters.relations).forEach( - (relationName) => { - const ast = <%= globalID.toLowerCase() %>.associations.find(a => a.alias === relationName); - if (ast) { - const model = ast.plugin ? - strapi.plugins[ast.plugin].models[ast.model || ast.collection] : - strapi.models[ast.model || ast.collection]; - - qb.distinct(); - - if (ast.tableCollectionName) { - qb.innerJoin( - ast.tableCollectionName, - `${ast.tableCollectionName}.${<%= globalID.toLowerCase() %>.info.name}_${<%= globalID.toLowerCase() %>.primaryKey}`, - `${<%= globalID.toLowerCase() %>.collectionName}.${<%= globalID.toLowerCase() %>.primaryKey}`, - ); - qb.innerJoin( - `${relationName}`, - `${relationName}.${<%= globalID.toLowerCase() %>.attributes[relationName].column}`, - `${ast.tableCollectionName}.${<%= globalID.toLowerCase() %>.attributes[relationName].attribute}_${<%= globalID.toLowerCase() %>.attributes[relationName].column}`, - ); - } else { - const relationTable = model.collectionName; - const externalKey = ast.type === 'collection' ? - `${model.collectionName}.${ast.via}` : - `${model.collectionName}.${model.primaryKey}`; - const internalKey = !ast.dominant - ? `${<%= globalID.toLowerCase() %>.collectionName}.${<%= globalID.toLowerCase() %>.primaryKey}` - : ast.via === <%= globalID.toLowerCase() %>.collectionName - ? `${<%= globalID.toLowerCase() %>.collectionName}.${ast.alias}` - : `${<%= globalID.toLowerCase() %>.collectionName}.${<%= globalID.toLowerCase() %>.primaryKey}`; - - qb.innerJoin(relationTable, externalKey, internalKey); - } - - const relation = filters.relations[relationName]; - Object.keys(relation).forEach( - (filter) => { - qb.where(`${model.collectionName}.${filter}`, `${relation[filter].symbol}`, `${relation[filter].value}`); - } - ); - } - } - ); + // Generate match stage. + hook.load().generateMatchStage(qb)(<%= globalID %>, filters); if (_.has(filters, 'start')) qb.offset(filters.start); if (_.has(filters, 'limit')) qb.limit(filters.limit); - if (_.has(filters, 'sort')) qb.orderBy(filters.sort.key, filters.sort.order); - + if (!_.isEmpty(filters.sort)) { + if (filters.sort.key) { + qb.orderBy(filters.sort.key, filters.sort.order); + } else { + qb.orderBy(filters.sort); + } + } }).fetchAll({ withRelated: populate }); diff --git a/packages/strapi-hook-bookshelf/lib/relations.js b/packages/strapi-hook-bookshelf/lib/relations.js index 550c928b23..55b51a0ce6 100644 --- a/packages/strapi-hook-bookshelf/lib/relations.js +++ b/packages/strapi-hook-bookshelf/lib/relations.js @@ -35,6 +35,67 @@ module.exports = { return _.get(strapi.plugins, [plugin, 'models', model]) || _.get(strapi, ['models', model]) || undefined; }, + generateMatchStage: function (qb) { + return (strapiModel, filters) => { + _.forEach(filters.relations, (value, key) => { + if (key !== 'relations') { + const association = strapiModel.associations.find(a => a.alias === key); + if (!association) { + const fieldKey = `${strapiModel.collectionName}.${key}`; + if (_.isArray(value.value) && value.symbol !== 'IN') { + for (const value in value.value) { + qb[value ? 'where' : 'orWhere'](fieldKey, value.symbol, value.value[value]); + } + } else { + qb.where(fieldKey, value.symbol, value.value); + } + } else { + const model = association.plugin ? + strapi.plugins[association.plugin].models[association.model || association.collection] : + strapi.models[association.model || association.collection]; + const relationTable = model.collectionName; + + qb.distinct(); + + if (association.nature === 'manyToMany') { + // Join on both ends + qb.innerJoin( + association.tableCollectionName, + `${association.tableCollectionName}.${strapiModel.info.name}_${strapiModel.primaryKey}`, + `${strapiModel.collectionName}.${strapiModel.primaryKey}`, + ); + + qb.innerJoin( + relationTable, + `${association.tableCollectionName}.${strapiModel.attributes[key].attribute}_${strapiModel.attributes[key].column}`, + `${relationTable}.${model.primaryKey}`, + ); + } else { + const externalKey = association.type === 'collection' + ? `${relationTable}.${association.via}` + : `${relationTable}.${model.primaryKey}`; + + const internalKey = association.type === 'collection' + ? `${strapiModel.collectionName}.${strapiModel.primaryKey}` + : `${strapiModel.collectionName}.${association.alias}`; + + qb.innerJoin(relationTable, externalKey, internalKey); + } + + if (_.isPlainObject(value)) { + this.generateMatchStage(qb)( + model, + { relations: value.value } + ); + } + } + } else { + this.generateMatchStage(qb)(strapiModel, { relations: value }); + } + }); + }; + }, + findOne: async function (params, populate) { const record = await this .forge({ diff --git a/packages/strapi-plugin-users-permissions/config/queries/bookshelf.js b/packages/strapi-plugin-users-permissions/config/queries/bookshelf.js index e2e2f98313..cf90842d72 100644 --- a/packages/strapi-plugin-users-permissions/config/queries/bookshelf.js +++ b/packages/strapi-plugin-users-permissions/config/queries/bookshelf.js @@ -1,77 +1,22 @@ const _ = require('lodash'); + module.exports = { find: async function (params = {}, populate) { + const hook = strapi.hook[this.orm]; const records = await this.query((qb) => { - _.forEach(params.where, (where, key) => { - if (_.isArray(where.value)) { - for (const value in where.value) { - qb[value ? 'where' : 'orWhere'](key, where.symbol, where.value[value]); - } - } else { - qb.where(key, where.symbol, where.value); - } - }); + // Generate match stage. + hook.load().generateMatchStage(qb)(this, params); - if (params.start) { - qb.offset(params.start); - } - - if (params.limit) { - qb.limit(params.limit); - } - - if (params.sort) { + if (_.has(params, 'start')) qb.offset(params.start); + if (_.has(params, 'limit')) qb.limit(params.limit); + if (!_.isEmpty(params.sort)) { if (params.sort.key) { qb.orderBy(params.sort.key, params.sort.order); } else { qb.orderBy(params.sort); } } - - if (params.relations) { - Object.keys(params.relations).forEach( - (relationName) => { - const ast = this.associations.find(a => a.alias === relationName); - if (ast) { - const model = ast.plugin ? - strapi.plugins[ast.plugin].models[ast.model ? ast.model : ast.collection] : - strapi.models[ast.model ? ast.model : ast.collection]; - - qb.distinct(); - - if (ast.tableCollectionName) { - qb.innerJoin( - ast.tableCollectionName, - `${ast.tableCollectionName}.${this.info.name}_${this.primaryKey}`, - `${this.collectionName}.${this.primaryKey}`, - ); - qb.innerJoin( - `${relationName}`, - `${relationName}.${this.attributes[relationName].column}`, - `${ast.tableCollectionName}.${this.attributes[relationName].attribute}_${this.attributes[relationName].column}`, - ); - } else { - const relationTable = model.collectionName; - const externalKey = ast.type === 'collection' ? - `${model.collectionName}.${ast.via}` : - `${model.collectionName}.${model.primaryKey}`; - const internalKey = !ast.dominant ? `${this.collectionName}.${this.primaryKey}` : - ast.via === this.collectionName ? `${this.collectionName}.${ast.alias}` : `${this.collectionName}.${this.primaryKey}`; - - qb.innerJoin(relationTable, externalKey, internalKey); - } - - const relation = params.relations[relationName]; - Object.keys(params.relations[relationName]).forEach( - (filter) => { - qb.where(`${model.collectionName}.${filter}`, `${relation[filter].symbol}`, `${relation[filter].value}`); - } - ); - } - } - ); - } }) .fetchAll({ withRelated: populate || _.keys(_.groupBy(_.reject(this.associations, { autoPopulate: false }), 'alias')) From b9ad12f920c710e294c4182c372b8c536ddefd86 Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Wed, 10 Oct 2018 00:39:47 +0200 Subject: [PATCH 24/44] Add missing strapi-utils in the user-permissions package --- packages/strapi-plugin-users-permissions/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/strapi-plugin-users-permissions/package.json b/packages/strapi-plugin-users-permissions/package.json index 60d84e9dd0..4df10f56f0 100644 --- a/packages/strapi-plugin-users-permissions/package.json +++ b/packages/strapi-plugin-users-permissions/package.json @@ -29,6 +29,7 @@ "koa2-ratelimit": "^0.6.1", "purest": "^2.0.1", "request": "^2.83.0", + "strapi-utils": "3.0.0-alpha.14.3", "uuid": "^3.1.0" }, "devDependencies": { From f168da8acca7126b05cf5c8107ea9e979e2fda9b Mon Sep 17 00:00:00 2001 From: dimitrinicolas Date: Sun, 4 Nov 2018 12:47:55 +0100 Subject: [PATCH 25/44] Fix responsive admin headers --- .../admin/src/containers/InstallPluginPage/styles.scss | 3 +-- .../admin/src/containers/SettingPage/styles.scss | 2 +- .../admin/src/containers/SettingsPage/styles.scss | 2 +- .../admin/src/containers/EditPage/styles.scss | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/strapi-admin/admin/src/containers/InstallPluginPage/styles.scss b/packages/strapi-admin/admin/src/containers/InstallPluginPage/styles.scss index 5d84dcb5c8..565a9bff42 100644 --- a/packages/strapi-admin/admin/src/containers/InstallPluginPage/styles.scss +++ b/packages/strapi-admin/admin/src/containers/InstallPluginPage/styles.scss @@ -1,8 +1,7 @@ .containerFluid { padding: 18px 30px !important; > div:first-child { - max-height: 33px; - margin-bottom: 48px; + margin-bottom: 12px; } } diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/SettingPage/styles.scss b/packages/strapi-plugin-content-manager/admin/src/containers/SettingPage/styles.scss index fc55593091..1a137fe54a 100644 --- a/packages/strapi-plugin-content-manager/admin/src/containers/SettingPage/styles.scss +++ b/packages/strapi-plugin-content-manager/admin/src/containers/SettingPage/styles.scss @@ -1,7 +1,7 @@ .containerFluid { padding: 18px 30px; > div:first-child { - max-height: 33px; + margin-bottom: 12px; } } diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/SettingsPage/styles.scss b/packages/strapi-plugin-content-manager/admin/src/containers/SettingsPage/styles.scss index cc3db06f18..d37e90933b 100644 --- a/packages/strapi-plugin-content-manager/admin/src/containers/SettingsPage/styles.scss +++ b/packages/strapi-plugin-content-manager/admin/src/containers/SettingsPage/styles.scss @@ -1,7 +1,7 @@ .containerFluid { padding: 18px 30px; > div:first-child { - max-height: 33px; + margin-bottom: 12px; } } diff --git a/packages/strapi-plugin-users-permissions/admin/src/containers/EditPage/styles.scss b/packages/strapi-plugin-users-permissions/admin/src/containers/EditPage/styles.scss index ade50dafc9..2a71cbac59 100644 --- a/packages/strapi-plugin-users-permissions/admin/src/containers/EditPage/styles.scss +++ b/packages/strapi-plugin-users-permissions/admin/src/containers/EditPage/styles.scss @@ -5,7 +5,7 @@ .containerFluid { padding: 18px 30px; > div:first-child { - max-height: 33px; + margin-bottom: 12px; } } From e18bf9198b8ab070bb8662ab7708417b6372da8f Mon Sep 17 00:00:00 2001 From: Dimitri Nicolas Date: Tue, 13 Nov 2018 17:21:31 +0100 Subject: [PATCH 26/44] Change containerFluid child margin for better grid alignement --- .../admin/src/containers/InstallPluginPage/styles.scss | 2 +- .../admin/src/containers/SettingPage/styles.scss | 4 ++-- .../admin/src/containers/SettingsPage/styles.scss | 4 ++-- .../admin/src/containers/EditPage/styles.scss | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/strapi-admin/admin/src/containers/InstallPluginPage/styles.scss b/packages/strapi-admin/admin/src/containers/InstallPluginPage/styles.scss index 565a9bff42..007c081812 100644 --- a/packages/strapi-admin/admin/src/containers/InstallPluginPage/styles.scss +++ b/packages/strapi-admin/admin/src/containers/InstallPluginPage/styles.scss @@ -1,7 +1,7 @@ .containerFluid { padding: 18px 30px !important; > div:first-child { - margin-bottom: 12px; + margin-bottom: 11px; } } diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/SettingPage/styles.scss b/packages/strapi-plugin-content-manager/admin/src/containers/SettingPage/styles.scss index 1a137fe54a..3de9ee6fe8 100644 --- a/packages/strapi-plugin-content-manager/admin/src/containers/SettingPage/styles.scss +++ b/packages/strapi-plugin-content-manager/admin/src/containers/SettingPage/styles.scss @@ -1,7 +1,7 @@ .containerFluid { padding: 18px 30px; > div:first-child { - margin-bottom: 12px; + margin-bottom: 11px; } } @@ -192,4 +192,4 @@ .padded { padding-bottom: 1px; -} \ No newline at end of file +} diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/SettingsPage/styles.scss b/packages/strapi-plugin-content-manager/admin/src/containers/SettingsPage/styles.scss index d37e90933b..5f859e82d4 100644 --- a/packages/strapi-plugin-content-manager/admin/src/containers/SettingsPage/styles.scss +++ b/packages/strapi-plugin-content-manager/admin/src/containers/SettingsPage/styles.scss @@ -1,7 +1,7 @@ .containerFluid { padding: 18px 30px; > div:first-child { - margin-bottom: 12px; + margin-bottom: 11px; } } @@ -35,4 +35,4 @@ border-bottom: none; } } -} \ No newline at end of file +} diff --git a/packages/strapi-plugin-users-permissions/admin/src/containers/EditPage/styles.scss b/packages/strapi-plugin-users-permissions/admin/src/containers/EditPage/styles.scss index 2a71cbac59..6232326bdb 100644 --- a/packages/strapi-plugin-users-permissions/admin/src/containers/EditPage/styles.scss +++ b/packages/strapi-plugin-users-permissions/admin/src/containers/EditPage/styles.scss @@ -5,7 +5,7 @@ .containerFluid { padding: 18px 30px; > div:first-child { - margin-bottom: 12px; + margin-bottom: 11px; } } @@ -37,4 +37,4 @@ justify-content: center; min-height: 260px; margin: auto; -} \ No newline at end of file +} From b0f86ba6aea9f8e5e9afed724ec1f38cfc30595f Mon Sep 17 00:00:00 2001 From: Jim LAURIE Date: Thu, 15 Nov 2018 21:16:49 +0100 Subject: [PATCH 27/44] Add message to auto close issue --- .github/PULL_REQUEST_TEMPLATE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b7a40dd5de..51f1167db9 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,7 +4,7 @@ My PR is a: - + @@ -15,3 +15,5 @@ Main update on the: + + From da5ca2a1252b0ced752ae0673d79668167171234 Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Sat, 17 Nov 2018 13:50:50 +0100 Subject: [PATCH 28/44] Link the strapi-utils dependecy to the users-permissions module in the setup step --- packages/strapi-plugin-email/package.json | 2 +- packages/strapi-plugin-upload/package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- .../strapi-provider-upload-local/package.json | 2 +- .../package.json | 2 +- scripts/setup.js | 92 +++++++++++++++---- 12 files changed, 83 insertions(+), 31 deletions(-) diff --git a/packages/strapi-plugin-email/package.json b/packages/strapi-plugin-email/package.json index 47c9f0f0ff..fdcd00416b 100644 --- a/packages/strapi-plugin-email/package.json +++ b/packages/strapi-plugin-email/package.json @@ -49,4 +49,4 @@ "npm": ">= 5.0.0" }, "license": "MIT" -} +} \ No newline at end of file diff --git a/packages/strapi-plugin-upload/package.json b/packages/strapi-plugin-upload/package.json index 76c576a467..849229487d 100644 --- a/packages/strapi-plugin-upload/package.json +++ b/packages/strapi-plugin-upload/package.json @@ -47,4 +47,4 @@ "npm": ">= 3.0.0" }, "license": "MIT" -} +} \ No newline at end of file diff --git a/packages/strapi-plugin-users-permissions/package.json b/packages/strapi-plugin-users-permissions/package.json index 9277dc9a75..9adfdea9d7 100644 --- a/packages/strapi-plugin-users-permissions/package.json +++ b/packages/strapi-plugin-users-permissions/package.json @@ -29,7 +29,7 @@ "koa2-ratelimit": "^0.6.1", "purest": "^2.0.1", "request": "^2.83.0", - "strapi-utils": "3.0.0-alpha.14.3", + "strapi-utils": "3.0.0-alpha.14.5", "uuid": "^3.1.0" }, "devDependencies": { diff --git a/packages/strapi-provider-email-amazon-ses/package.json b/packages/strapi-provider-email-amazon-ses/package.json index a28935b21b..bd0b308c13 100644 --- a/packages/strapi-provider-email-amazon-ses/package.json +++ b/packages/strapi-provider-email-amazon-ses/package.json @@ -39,4 +39,4 @@ "npm": ">= 5.3.0" }, "license": "MIT" -} +} \ No newline at end of file diff --git a/packages/strapi-provider-email-mailgun/package.json b/packages/strapi-provider-email-mailgun/package.json index 0df48e93bc..e3a5c23561 100644 --- a/packages/strapi-provider-email-mailgun/package.json +++ b/packages/strapi-provider-email-mailgun/package.json @@ -42,4 +42,4 @@ "npm": ">= 5.3.0" }, "license": "MIT" -} +} \ No newline at end of file diff --git a/packages/strapi-provider-email-sendgrid/package.json b/packages/strapi-provider-email-sendgrid/package.json index 37014367fa..bda2fe3145 100644 --- a/packages/strapi-provider-email-sendgrid/package.json +++ b/packages/strapi-provider-email-sendgrid/package.json @@ -42,4 +42,4 @@ "npm": ">= 5.3.0" }, "license": "MIT" -} +} \ No newline at end of file diff --git a/packages/strapi-provider-email-sendmail/package.json b/packages/strapi-provider-email-sendmail/package.json index 25fa7fb62f..f4fa0cdd19 100644 --- a/packages/strapi-provider-email-sendmail/package.json +++ b/packages/strapi-provider-email-sendmail/package.json @@ -41,4 +41,4 @@ "npm": ">= 5.3.0" }, "license": "MIT" -} +} \ No newline at end of file diff --git a/packages/strapi-provider-upload-aws-s3/package.json b/packages/strapi-provider-upload-aws-s3/package.json index c47840adb0..0b6b9a6a07 100644 --- a/packages/strapi-provider-upload-aws-s3/package.json +++ b/packages/strapi-provider-upload-aws-s3/package.json @@ -43,4 +43,4 @@ "npm": ">= 5.3.0" }, "license": "MIT" -} +} \ No newline at end of file diff --git a/packages/strapi-provider-upload-cloudinary/package.json b/packages/strapi-provider-upload-cloudinary/package.json index cdd2fb5aa7..8b448dd016 100644 --- a/packages/strapi-provider-upload-cloudinary/package.json +++ b/packages/strapi-provider-upload-cloudinary/package.json @@ -43,4 +43,4 @@ "npm": ">= 5.3.0" }, "license": "MIT" -} +} \ No newline at end of file diff --git a/packages/strapi-provider-upload-local/package.json b/packages/strapi-provider-upload-local/package.json index 873627d833..f20fbd8f83 100644 --- a/packages/strapi-provider-upload-local/package.json +++ b/packages/strapi-provider-upload-local/package.json @@ -39,4 +39,4 @@ "npm": ">= 5.3.0" }, "license": "MIT" -} +} \ No newline at end of file diff --git a/packages/strapi-provider-upload-rackspace/package.json b/packages/strapi-provider-upload-rackspace/package.json index ca74f7fa13..923773db82 100644 --- a/packages/strapi-provider-upload-rackspace/package.json +++ b/packages/strapi-provider-upload-rackspace/package.json @@ -13,4 +13,4 @@ "pkgcloud": "^1.5.0", "streamifier": "^0.1.1" } -} +} \ No newline at end of file diff --git a/scripts/setup.js b/scripts/setup.js index 2b04adc32e..7ad61bbb0f 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -34,7 +34,6 @@ const watcher = (label, cmd, withSuccess = true) => { shell.echo('✅ Success'); shell.echo(''); } - }; const asyncWatcher = (label, cmd, withSuccess = true, resolve) => { @@ -88,7 +87,6 @@ if (shell.test('-e', 'admin/src/config/plugins.json') === false) { shell.cd('../../../'); } - watcher('📦 Linking strapi-admin', 'npm link --no-optional', false); shell.cd('../strapi-generate-admin'); @@ -112,18 +110,33 @@ watcher('', 'npm install ../strapi-hook-knex'); watcher('📦 Linking strapi-hook-bookshelf...', 'npm link'); shell.cd('../strapi'); -watcher('', 'npm install ../strapi-generate ../strapi-generate-admin ../strapi-generate-api ../strapi-generate-new ../strapi-generate-plugin ../strapi-generate-policy ../strapi-generate-service ../strapi-utils'); +watcher( + '', + 'npm install ../strapi-generate ../strapi-generate-admin ../strapi-generate-api ../strapi-generate-new ../strapi-generate-plugin ../strapi-generate-policy ../strapi-generate-service ../strapi-utils' +); watcher('📦 Linking strapi...', 'npm link'); shell.cd('../strapi-plugin-graphql'); -watcher('📦 Linking strapi-plugin-graphql...', 'npm link --no-optional', false); +watcher( + '📦 Linking strapi-plugin-graphql...', + 'npm link --no-optional', + false +); // Plugin services shell.cd('../strapi-provider-upload-local'); -watcher('📦 Linking strapi-provider-upload-local...', 'npm link --no-optional', false); +watcher( + '📦 Linking strapi-provider-upload-local...', + 'npm link --no-optional', + false +); shell.cd('../strapi-provider-email-sendmail'); -watcher('📦 Linking strapi-provider-email-sendmail...', 'npm link --no-optional', false); +watcher( + '📦 Linking strapi-provider-email-sendmail...', + 'npm link --no-optional', + false +); // Plugins with admin shell.cd('../strapi-plugin-email'); @@ -134,19 +147,31 @@ watcher('📦 Linking strapi-plugin-email...', 'npm link --no-optional', false) shell.cd('../strapi-plugin-users-permissions'); watcher('', 'npm install ../strapi-helper-plugin --no-optional'); +watcher('', 'npm install ../strapi-utils --no-optional'); shell.rm('-f', 'package-lock.json'); -watcher('📦 Linking strapi-plugin-users-permissions...', 'npm link --no-optional', false); +watcher( + '📦 Linking strapi-plugin-users-permissions...', + 'npm link --no-optional', + false +); shell.cd('../strapi-plugin-content-manager'); watcher('', 'npm install ../strapi-helper-plugin --no-optional'); shell.rm('-f', 'package-lock.json'); -watcher('📦 Linking strapi-plugin-content-manager...', 'npm link --no-optional', false); +watcher( + '📦 Linking strapi-plugin-content-manager...', + 'npm link --no-optional', + false +); shell.cd('../strapi-plugin-settings-manager'); watcher('', 'npm install ../strapi-helper-plugin --no-optional'); shell.rm('-f', 'package-lock.json'); -watcher('📦 Linking strapi-plugin-settings-manager...', 'npm link --no-optional', false); - +watcher( + '📦 Linking strapi-plugin-settings-manager...', + 'npm link --no-optional', + false +); // Plugins with admin and other plugin's dependencies shell.cd('../strapi-plugin-upload'); @@ -160,16 +185,32 @@ watcher('', 'npm install ../strapi-helper-plugin --no-optional'); watcher('', 'npm install ../strapi-generate --no-optional'); watcher('', 'npm install ../strapi-generate-api --no-optional'); shell.rm('-f', 'package-lock.json'); -watcher('📦 Linking strapi-plugin-content-type-builder...', 'npm link --no-optional', false); +watcher( + '📦 Linking strapi-plugin-content-type-builder...', + 'npm link --no-optional', + false +); - -const pluginsToBuild = ['admin', 'content-manager', 'content-type-builder', 'upload', 'email', 'users-permissions', 'settings-manager']; +const pluginsToBuild = [ + 'admin', + 'content-manager', + 'content-type-builder', + 'upload', + 'email', + 'users-permissions', + 'settings-manager' +]; const buildPlugins = async () => { - const build = (pckgName) => { + const build = pckgName => { return new Promise(resolve => { - const name = pckgName === 'admin' ? pckgName: `plugin-${pckgName}`; - asyncWatcher(`🏗 Building ${name}...`, `cd ../strapi-${name} && IS_MONOREPO=true npm run build`, false, resolve); + const name = pckgName === 'admin' ? pckgName : `plugin-${pckgName}`; + asyncWatcher( + `🏗 Building ${name}...`, + `cd ../strapi-${name} && IS_MONOREPO=true npm run build`, + false, + resolve + ); }); }; @@ -178,23 +219,34 @@ const buildPlugins = async () => { const setup = async () => { if (process.env.npm_config_build) { - if (process.platform === 'darwin') { // Allow async build for darwin platform + if (process.platform === 'darwin') { + // Allow async build for darwin platform await buildPlugins(); } else { pluginsToBuild.map(name => { const pluginName = name === 'admin' ? name : `plugin-${name}`; shell.cd(`../strapi-${pluginName}`); - return watcher(`🏗 Building ${pluginName}...`, 'IS_MONOREPO=true npm run build'); + return watcher( + `🏗 Building ${pluginName}...`, + 'IS_MONOREPO=true npm run build' + ); }); } } // Log installation duration. const installationEndDate = new Date(); - const duration = (installationEndDate.getTime() - installationStartDate.getTime()) / 1000; + const duration = + (installationEndDate.getTime() - installationStartDate.getTime()) / 1000; shell.echo('✅ Strapi has been succesfully installed.'); - shell.echo(`⏳ The installation took ${Math.floor(duration / 60) > 0 ? `${Math.floor(duration / 60)} minutes and ` : ''}${Math.floor(duration % 60)} seconds.`); + shell.echo( + `⏳ The installation took ${ + Math.floor(duration / 60) > 0 + ? `${Math.floor(duration / 60)} minutes and ` + : '' + }${Math.floor(duration % 60)} seconds.` + ); }; setup(); From 85eeb4aef2e156dc0989f652eb75f83a86d79e76 Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Sun, 18 Nov 2018 23:56:02 +0100 Subject: [PATCH 29/44] Fix wrong rebase --- packages/strapi-hook-mongoose/lib/relations.js | 4 ++-- packages/strapi-plugin-graphql/services/Resolvers.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/strapi-hook-mongoose/lib/relations.js b/packages/strapi-hook-mongoose/lib/relations.js index 062a608760..682a7a5adc 100644 --- a/packages/strapi-hook-mongoose/lib/relations.js +++ b/packages/strapi-hook-mongoose/lib/relations.js @@ -113,8 +113,8 @@ module.exports = { } else { const model = association.plugin ? strapi.plugins[association.plugin].models[ - association.collection || association.model - ] + association.collection || association.model + ] : strapi.models[association.collection || association.model]; // Generate lookup for this relation diff --git a/packages/strapi-plugin-graphql/services/Resolvers.js b/packages/strapi-plugin-graphql/services/Resolvers.js index 0ad27599ac..dfbeb680e6 100644 --- a/packages/strapi-plugin-graphql/services/Resolvers.js +++ b/packages/strapi-plugin-graphql/services/Resolvers.js @@ -398,11 +398,11 @@ module.exports = { queryOpts.skip = convertedParams.start; switch (association.nature) { - case 'manyToMany': { + case "manyToMany": const arrayOfIds = (obj[association.alias] || []).map( related => { return related[ref.primaryKey] || related; - }, + } ); // Where. From 511ca55461e33d86661ef8477512a5b1e7f9cea3 Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Mon, 19 Nov 2018 00:10:48 +0100 Subject: [PATCH 30/44] fix 1st level deep filter --- packages/strapi-hook-bookshelf/lib/relations.js | 10 ++++++++++ packages/strapi-hook-mongoose/lib/relations.js | 12 ++++++++++++ packages/strapi-plugin-graphql/services/Query.js | 2 +- packages/strapi-plugin-graphql/services/Resolvers.js | 4 ++-- .../config/queries/bookshelf.js | 7 +++---- packages/strapi-utils/lib/models.js | 5 ----- 6 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/strapi-hook-bookshelf/lib/relations.js b/packages/strapi-hook-bookshelf/lib/relations.js index 55b51a0ce6..0b8695510c 100644 --- a/packages/strapi-hook-bookshelf/lib/relations.js +++ b/packages/strapi-hook-bookshelf/lib/relations.js @@ -37,6 +37,16 @@ module.exports = { generateMatchStage: function (qb) { return (strapiModel, filters) => { + if (!filters) { + return undefined; + } + + // 1st level deep filter + if (filters.where) { + this.generateMatchStage(qb)(strapiModel, { relations: filters.where }); + } + + // 2nd+ level deep filter _.forEach(filters.relations, (value, key) => { if (key !== 'relations') { const association = strapiModel.associations.find(a => a.alias === key); diff --git a/packages/strapi-hook-mongoose/lib/relations.js b/packages/strapi-hook-mongoose/lib/relations.js index 682a7a5adc..a6999233ce 100644 --- a/packages/strapi-hook-mongoose/lib/relations.js +++ b/packages/strapi-hook-mongoose/lib/relations.js @@ -101,6 +101,18 @@ module.exports = { let acc = []; + // 1st level deep filter + if (filters.where) { + acc.push( + ...generateMatchStage( + strapiModel, + { relations: filters.where }, + { prefixPath } + ) + ); + } + + // 2nd+ level deep filter _.forEach(filters.relations, (value, key) => { if (key !== 'relations') { const nextPrefixedPath = `${prefixPath}${key}.`; diff --git a/packages/strapi-plugin-graphql/services/Query.js b/packages/strapi-plugin-graphql/services/Query.js index 26c307c843..989e78e89d 100644 --- a/packages/strapi-plugin-graphql/services/Query.js +++ b/packages/strapi-plugin-graphql/services/Query.js @@ -32,7 +32,7 @@ module.exports = { if (_.isPlainObject(value)) { const flatObject = this.convertToQuery(value); _.forEach (flatObject, (_value, _key) => { - result[key + '.' + _key] = _value; + result[`${key}.${_key}`] = _value; }); } else { result[key] = value; diff --git a/packages/strapi-plugin-graphql/services/Resolvers.js b/packages/strapi-plugin-graphql/services/Resolvers.js index dfbeb680e6..51235dc7c2 100644 --- a/packages/strapi-plugin-graphql/services/Resolvers.js +++ b/packages/strapi-plugin-graphql/services/Resolvers.js @@ -398,7 +398,7 @@ module.exports = { queryOpts.skip = convertedParams.start; switch (association.nature) { - case "manyToMany": + case "manyToMany": { const arrayOfIds = (obj[association.alias] || []).map( related => { return related[ref.primaryKey] || related; @@ -413,7 +413,7 @@ module.exports = { ...where.where, }).where; break; - // falls through + } default: // Where. queryOpts.query = strapi.utils.models.convertParams(name, { diff --git a/packages/strapi-plugin-users-permissions/config/queries/bookshelf.js b/packages/strapi-plugin-users-permissions/config/queries/bookshelf.js index cf90842d72..860ac31e09 100644 --- a/packages/strapi-plugin-users-permissions/config/queries/bookshelf.js +++ b/packages/strapi-plugin-users-permissions/config/queries/bookshelf.js @@ -18,10 +18,9 @@ module.exports = { } } }) - .fetchAll({ - withRelated: populate || _.keys(_.groupBy(_.reject(this.associations, { autoPopulate: false }), 'alias')) - }); - + .fetchAll({ + withRelated: populate || _.keys(_.groupBy(_.reject(this.associations, { autoPopulate: false }), 'alias')) + }); return records ? records.toJSON() : records; }, diff --git a/packages/strapi-utils/lib/models.js b/packages/strapi-utils/lib/models.js index ec80dc1658..de1545ad24 100644 --- a/packages/strapi-utils/lib/models.js +++ b/packages/strapi-utils/lib/models.js @@ -11,11 +11,6 @@ const path = require('path'); const _ = require('lodash'); const pluralize = require('pluralize'); -// Following this discussion https://stackoverflow.com/questions/18082/validate-decimal-numbers-in-javascript-isnumeric this function is the best implem to determine if a value is a valid number candidate -const isNumeric = (value) => { - return !_.isObject(value) && !isNaN(parseFloat(value)) && isFinite(value); -}; - // Constants const ORDERS = ['ASC', 'DESC']; From 53849ff12c997dbc94bb7f06cc21fd35c2d6695c Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Mon, 19 Nov 2018 00:12:09 +0100 Subject: [PATCH 31/44] Fix sort --- .../strapi-generate-api/templates/mongoose/service.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/strapi-generate-api/templates/mongoose/service.template b/packages/strapi-generate-api/templates/mongoose/service.template index 62bb9045e3..a058e63fe3 100644 --- a/packages/strapi-generate-api/templates/mongoose/service.template +++ b/packages/strapi-generate-api/templates/mongoose/service.template @@ -36,7 +36,7 @@ module.exports = { if (_.has(filters, 'start')) result.skip(filters.start); if (_.has(filters, 'limit')) result.limit(filters.limit); - if (_.has(filters, 'sort')) result.sort(filters.sort); + if (!_.isEmpty(filters, 'sort')) result.sort(filters.sort); return result; }, From 850040329756d7c5306a41e815f4c7569263415b Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Mon, 19 Nov 2018 15:42:00 +0100 Subject: [PATCH 32/44] Add missing this* --- packages/strapi-hook-mongoose/lib/relations.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/strapi-hook-mongoose/lib/relations.js b/packages/strapi-hook-mongoose/lib/relations.js index a6999233ce..9eee2ee7b3 100644 --- a/packages/strapi-hook-mongoose/lib/relations.js +++ b/packages/strapi-hook-mongoose/lib/relations.js @@ -104,7 +104,7 @@ module.exports = { // 1st level deep filter if (filters.where) { acc.push( - ...generateMatchStage( + ...this.generateMatchStage( strapiModel, { relations: filters.where }, { prefixPath } From 4a7cae4ac0d4edadef5c135eb8e486ba3d35f5b5 Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Mon, 19 Nov 2018 21:47:48 +0100 Subject: [PATCH 33/44] Stringify ObjectID --- packages/strapi-hook-mongoose/lib/utils/index.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/strapi-hook-mongoose/lib/utils/index.js b/packages/strapi-hook-mongoose/lib/utils/index.js index bed5daabb3..2e0ff1412e 100644 --- a/packages/strapi-hook-mongoose/lib/utils/index.js +++ b/packages/strapi-hook-mongoose/lib/utils/index.js @@ -8,7 +8,17 @@ const mongoose = require('mongoose'); const Mongoose = mongoose.Mongoose; +/** + * Convert MongoDB ID to the stringify version as GraphQL throws an error if not. + * + * Refer to: https://github.com/graphql/graphql-js/commit/3521e1429eec7eabeee4da65c93306b51308727b#diff-87c5e74dd1f7d923143e0eee611f598eR183 + */ +mongoose.Types.ObjectId.prototype.valueOf = function () { + return this.toString(); +}; + module.exports = (mongoose = new Mongoose()) => { + const Decimal = require('mongoose-float').loadType(mongoose, 2); const Float = require('mongoose-float').loadType(mongoose, 20); From 17140fb4b067171cda80d771d1e4e880367b166c Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Mon, 19 Nov 2018 21:48:15 +0100 Subject: [PATCH 34/44] Add strapi-utils dependency at the root level of the project folder --- packages/strapi-generate-new/json/package.json.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/strapi-generate-new/json/package.json.js b/packages/strapi-generate-new/json/package.json.js index 1b1f2fad2f..bf0db4aa63 100644 --- a/packages/strapi-generate-new/json/package.json.js +++ b/packages/strapi-generate-new/json/package.json.js @@ -56,6 +56,7 @@ module.exports = scope => { 'dependencies': Object.assign({}, { 'lodash': '^4.17.5', 'strapi': getDependencyVersion(cliPkg, 'strapi'), + 'strapi-utils': getDependencyVersion(cliPkg, 'strapi'), [scope.client.connector]: getDependencyVersion(cliPkg, 'strapi'), }, additionalsDependencies, { [scope.client.module]: scope.client.version From 886e39d196b9e7e1940e54e4ae7c7611c2840a88 Mon Sep 17 00:00:00 2001 From: Kamal Bennani Date: Mon, 19 Nov 2018 21:48:54 +0100 Subject: [PATCH 35/44] I'm a newby --- .../strapi-generate-api/templates/mongoose/service.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/strapi-generate-api/templates/mongoose/service.template b/packages/strapi-generate-api/templates/mongoose/service.template index a058e63fe3..bb70229fd1 100644 --- a/packages/strapi-generate-api/templates/mongoose/service.template +++ b/packages/strapi-generate-api/templates/mongoose/service.template @@ -36,7 +36,7 @@ module.exports = { if (_.has(filters, 'start')) result.skip(filters.start); if (_.has(filters, 'limit')) result.limit(filters.limit); - if (!_.isEmpty(filters, 'sort')) result.sort(filters.sort); + if (!_.isEmpty(filters.sort)) result.sort(filters.sort); return result; }, From 6308c71d6ee4d1a992f6e3048361a4d8aa9aacba Mon Sep 17 00:00:00 2001 From: Aurelsicoko Date: Tue, 20 Nov 2018 15:02:48 +0100 Subject: [PATCH 36/44] Add FullStory component --- packages/strapi-admin/admin/src/containers/AdminPage/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/strapi-admin/admin/src/containers/AdminPage/index.js b/packages/strapi-admin/admin/src/containers/AdminPage/index.js index f556ed6cc2..44114e6f33 100644 --- a/packages/strapi-admin/admin/src/containers/AdminPage/index.js +++ b/packages/strapi-admin/admin/src/containers/AdminPage/index.js @@ -43,6 +43,7 @@ import Logout from 'components/Logout'; import NotFoundPage from 'containers/NotFoundPage/Loadable'; import OverlayBlocker from 'components/OverlayBlocker'; import PluginPage from 'containers/PluginPage'; +import FullStory from 'components/FullStory'; // Utils import auth from 'utils/auth'; import injectReducer from 'utils/injectReducer'; @@ -225,6 +226,7 @@ export class AdminPage extends React.Component { + ); } From 283ed29f47293bea754543a17a06cdfed4f8ba48 Mon Sep 17 00:00:00 2001 From: Aurelsicoko Date: Tue, 20 Nov 2018 16:04:59 +0100 Subject: [PATCH 37/44] Move component --- .../admin/src/components/FullStory/index.js | 95 +++++++++++++++++++ .../admin/src/containers/AdminPage/index.js | 2 - .../admin/src/containers/App/index.js | 2 + 3 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 packages/strapi-admin/admin/src/components/FullStory/index.js diff --git a/packages/strapi-admin/admin/src/components/FullStory/index.js b/packages/strapi-admin/admin/src/components/FullStory/index.js new file mode 100644 index 0000000000..590b70b26c --- /dev/null +++ b/packages/strapi-admin/admin/src/components/FullStory/index.js @@ -0,0 +1,95 @@ +/* + * Copyright@React-FullStory (https://github.com/cereallarceny/react-fullstory) + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +const canUseDOM = !!( + typeof window !== 'undefined' && + window.document && + window.document.createElement +); + +export const getWindowFullStory = () => window[window['_fs_namespace']]; + +class FullStory extends React.Component { + constructor(props) { + super(props); + + window['_fs_debug'] = false; + window['_fs_host'] = 'fullstory.com'; + window['_fs_org'] = props.org; + window['_fs_namespace'] = 'FS'; + (function(m,n,e,t,l,o,g,y) { + if (e in m) { + if(m.console && m.console.log) { + m.console.log('FullStory namespace conflict. Please set window["_fs_namespace"].'); + } + + return; + } + + g = m[e]= function(a,b,s) { + g.q ? g.q.push([a,b,s]) : g._api(a,b,s); + }; + g.q=[]; + o = n.createElement(t); + o.async = 1; + o.src = `https://${window._fs_host}/s/fs.js`; + y = n.getElementsByTagName(t)[0]; + y.parentNode.insertBefore(o,y); + g.identify = function(i,v,s) { + g(l,{ uid:i },s); + + if (v) { + g(l,v,s); + } + }; + g.setUserVars = function(v,s) { + g(l,v,s); + }; + g.event = function(i,v,s) { + g('event',{ n:i,p:v },s); + }; + g.shutdown = function() { + g("rec",!1); + }; + g.restart = function() { + g("rec",!0); + }; + g.consent = function(a) { + g("consent",!arguments.length||a); + }; + g.identifyAccount = function(i,v) { + o = 'account'; + v = v||{}; + v.acctId = i; + g(o,v); + }; + g.clearUserCookie = function() {}; + })(window, document, window['_fs_namespace'], 'script', 'user'); + } + + shouldComponentUpdate() { + return false; + } + + componentWillUnmount() { + if (!canUseDOM || !getWindowFullStory()) return false; + + getWindowFullStory().shutdown(); + + delete getWindowFullStory(); + } + + render() { + return false; + } +} + +FullStory.propTypes = { + org: PropTypes.string.isRequired, +}; + +export default FullStory; diff --git a/packages/strapi-admin/admin/src/containers/AdminPage/index.js b/packages/strapi-admin/admin/src/containers/AdminPage/index.js index 44114e6f33..f556ed6cc2 100644 --- a/packages/strapi-admin/admin/src/containers/AdminPage/index.js +++ b/packages/strapi-admin/admin/src/containers/AdminPage/index.js @@ -43,7 +43,6 @@ import Logout from 'components/Logout'; import NotFoundPage from 'containers/NotFoundPage/Loadable'; import OverlayBlocker from 'components/OverlayBlocker'; import PluginPage from 'containers/PluginPage'; -import FullStory from 'components/FullStory'; // Utils import auth from 'utils/auth'; import injectReducer from 'utils/injectReducer'; @@ -226,7 +225,6 @@ export class AdminPage extends React.Component { - ); } diff --git a/packages/strapi-admin/admin/src/containers/App/index.js b/packages/strapi-admin/admin/src/containers/App/index.js index 961d13049d..5b327fd01b 100644 --- a/packages/strapi-admin/admin/src/containers/App/index.js +++ b/packages/strapi-admin/admin/src/containers/App/index.js @@ -18,6 +18,7 @@ import AdminPage from 'containers/AdminPage'; import NotFoundPage from 'containers/NotFoundPage'; import NotificationProvider from 'containers/NotificationProvider'; import AppLoader from 'containers/AppLoader'; +import FullStory from 'components/FullStory'; import LoadingIndicatorPage from 'components/LoadingIndicatorPage'; import '../../styles/main.scss'; import styles from './styles.scss'; @@ -26,6 +27,7 @@ export class App extends React.Component { // eslint-disable-line react/prefer-s render() { return (
+ {({ shouldLoad }) => { From 4ad6417e6275d9516c7e7fa4e5e663d13f7b6c5d Mon Sep 17 00:00:00 2001 From: Aurelsicoko Date: Tue, 20 Nov 2018 16:20:52 +0100 Subject: [PATCH 38/44] Add an easy way to disable tracking by removing the uuid --- .../strapi-admin/admin/src/containers/AdminPage/index.js | 6 ++++-- .../strapi-admin/admin/src/containers/AdminPage/reducer.js | 4 ++-- .../strapi-admin/admin/src/containers/AdminPage/saga.js | 4 ++-- packages/strapi-admin/admin/src/containers/App/index.js | 2 -- packages/strapi-admin/controllers/Admin.js | 3 +-- packages/strapi-admin/doc/disable-tracking.md | 2 +- 6 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/strapi-admin/admin/src/containers/AdminPage/index.js b/packages/strapi-admin/admin/src/containers/AdminPage/index.js index f556ed6cc2..72c6a31ce9 100644 --- a/packages/strapi-admin/admin/src/containers/AdminPage/index.js +++ b/packages/strapi-admin/admin/src/containers/AdminPage/index.js @@ -43,6 +43,7 @@ import Logout from 'components/Logout'; import NotFoundPage from 'containers/NotFoundPage/Loadable'; import OverlayBlocker from 'components/OverlayBlocker'; import PluginPage from 'containers/PluginPage'; +import FullStory from 'components/FullStory'; // Utils import auth from 'utils/auth'; import injectReducer from 'utils/injectReducer'; @@ -73,12 +74,12 @@ export class AdminPage extends React.Component { } componentDidUpdate(prevProps) { - const { adminPage: { allowGa }, location: { pathname }, plugins } = this.props; + const { adminPage: { uuid }, location: { pathname }, plugins } = this.props; if (prevProps.location.pathname !== pathname) { this.checkLogin(this.props); - if (allowGa) { + if (uuid) { ReactGA.pageview(pathname); } } @@ -198,6 +199,7 @@ export class AdminPage extends React.Component { return (
+ {this.props.adminPage.uuid ? : ''} {this.showLeftMenu() && ( action.data.allowGa) + .update('uuid', () => action.data.uuid) .update('currentEnvironment', () => action.data.currentEnvironment) .update('layout', () => Map(action.data.layout)) .update('strapiVersion', () => action.data.strapiVersion) diff --git a/packages/strapi-admin/admin/src/containers/AdminPage/saga.js b/packages/strapi-admin/admin/src/containers/AdminPage/saga.js index b922a1705c..f06d28125e 100644 --- a/packages/strapi-admin/admin/src/containers/AdminPage/saga.js +++ b/packages/strapi-admin/admin/src/containers/AdminPage/saga.js @@ -16,13 +16,13 @@ function* getData() { yield call(request, `${strapi.backendURL}/users/me`, { method: 'GET' }); } - const [{ allowGa }, { strapiVersion }, { currentEnvironment }, { layout }] = yield all([ + const [{ uuid }, { strapiVersion }, { currentEnvironment }, { layout }] = yield all([ call(request, '/admin/gaConfig', { method: 'GET' }), call(request, '/admin/strapiVersion', { method: 'GET' }), call(request, '/admin/currentEnvironment', { method: 'GET' }), call(request, '/admin/layout', { method: 'GET' }), ]); - yield put(getAdminDataSucceeded({ allowGa, strapiVersion, currentEnvironment, layout })); + yield put(getAdminDataSucceeded({ uuid, strapiVersion, currentEnvironment, layout })); } catch(err) { console.log(err); // eslint-disable-line no-console diff --git a/packages/strapi-admin/admin/src/containers/App/index.js b/packages/strapi-admin/admin/src/containers/App/index.js index 5b327fd01b..961d13049d 100644 --- a/packages/strapi-admin/admin/src/containers/App/index.js +++ b/packages/strapi-admin/admin/src/containers/App/index.js @@ -18,7 +18,6 @@ import AdminPage from 'containers/AdminPage'; import NotFoundPage from 'containers/NotFoundPage'; import NotificationProvider from 'containers/NotificationProvider'; import AppLoader from 'containers/AppLoader'; -import FullStory from 'components/FullStory'; import LoadingIndicatorPage from 'components/LoadingIndicatorPage'; import '../../styles/main.scss'; import styles from './styles.scss'; @@ -27,7 +26,6 @@ export class App extends React.Component { // eslint-disable-line react/prefer-s render() { return (
- {({ shouldLoad }) => { diff --git a/packages/strapi-admin/controllers/Admin.js b/packages/strapi-admin/controllers/Admin.js index a239825e62..3b4ff00f41 100644 --- a/packages/strapi-admin/controllers/Admin.js +++ b/packages/strapi-admin/controllers/Admin.js @@ -28,8 +28,7 @@ module.exports = { getGaConfig: async ctx => { try { - const allowGa = _.get(strapi.config, 'info.customs.allowGa', true); - ctx.send({ allowGa }); + ctx.send({ uuid: _.get(strapi.config, 'uuid', false) }); } catch(err) { ctx.badRequest(null, [{ messages: [{ id: 'An error occurred' }] }]); } diff --git a/packages/strapi-admin/doc/disable-tracking.md b/packages/strapi-admin/doc/disable-tracking.md index e16bc9f537..93c0b5132f 100644 --- a/packages/strapi-admin/doc/disable-tracking.md +++ b/packages/strapi-admin/doc/disable-tracking.md @@ -10,7 +10,7 @@ If you don't want to share your data with us, you can simply modify the `strapi` ```json { "strapi": { - "allowGa": false + "uuid": false } } ``` From 7a9ed040e217b4a4174279a47621ac91ccb14fdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abd=C3=B3n=20Rodr=C3=ADguez=20Davila?= Date: Tue, 20 Nov 2018 16:53:49 +0100 Subject: [PATCH 39/44] Update grant-koa version --- packages/strapi-plugin-users-permissions/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/strapi-plugin-users-permissions/package.json b/packages/strapi-plugin-users-permissions/package.json index 513d5d0b1c..8f0cb0cda5 100644 --- a/packages/strapi-plugin-users-permissions/package.json +++ b/packages/strapi-plugin-users-permissions/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "bcryptjs": "^2.4.3", - "grant-koa": "^3.8.1", + "grant-koa": "^4.2.0", "jsonwebtoken": "^8.1.0", "koa": "^2.1.0", "koa2-ratelimit": "^0.6.1", @@ -55,4 +55,4 @@ "npm": ">= 5.0.0" }, "license": "MIT" -} \ No newline at end of file +} From 995ff5125995c206bd4e8662ec1a46dbfa1bb02a Mon Sep 17 00:00:00 2001 From: Jim LAURIE Date: Tue, 20 Nov 2018 17:53:21 +0100 Subject: [PATCH 40/44] Use checkbox to select PR type --- .github/PULL_REQUEST_TEMPLATE.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 51f1167db9..0b97e00b61 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,16 +3,16 @@ My PR is a: - - - - +- [ ] 💥 Breaking change +- [ ] 🐛 Bug fix #issueNumber +- [ ] 💅 Enhancement +- [ ] 🚀 New feature Main update on the: - - - - +- [ ] Admin +- [ ] Documentation +- [ ] Framework +- [ ] Plugin From f76ab9ea03113dc873aeb7c089f97451926afca4 Mon Sep 17 00:00:00 2001 From: Jim LAURIE Date: Wed, 21 Nov 2018 12:15:41 +0100 Subject: [PATCH 41/44] Update plural ressources --- docs/3.x.x/guides/filters.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/3.x.x/guides/filters.md b/docs/3.x.x/guides/filters.md index bafc26acdb..9f58b56029 100644 --- a/docs/3.x.x/guides/filters.md +++ b/docs/3.x.x/guides/filters.md @@ -40,9 +40,9 @@ Find products having a price equal or greater than `3`. #### Relations You can also use filters into a relation attribute which will be applied to the first level of the request. Find users having written a post named `Title`. - `GET /user?posts.name=Title` + `GET /users?posts.name=Title` Find posts written by a user having more than 12 years old. - `GET /post?author.age_gt=12` + `GET /posts?author.age_gt=12` > Note: You can't use filter to have specific results inside relation, like "Find users and only their posts older than yesterday" as example. If you need it, you can modify or create your own service ou use [GraphQL](./graphql.md#query-api). > Warning: this filter isn't available for `upload` plugin From 954a6d321a52aba5f35f037495ce18bb342ec5dc Mon Sep 17 00:00:00 2001 From: Jim LAURIE Date: Wed, 21 Nov 2018 16:47:01 +0100 Subject: [PATCH 42/44] Fix indent --- .../strapi-generate-api/templates/bookshelf/service.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/strapi-generate-api/templates/bookshelf/service.template b/packages/strapi-generate-api/templates/bookshelf/service.template index 144bc6190f..b74ac459f1 100644 --- a/packages/strapi-generate-api/templates/bookshelf/service.template +++ b/packages/strapi-generate-api/templates/bookshelf/service.template @@ -150,7 +150,7 @@ module.exports = { await <%= globalID %>.updateRelations(params); return <%= globalID %>.forge(params).destroy(); - }, + }, /** * Promise to search a/an <%= id %>. From 412efec32afb32d88ae8e0749ba65f6618d92d37 Mon Sep 17 00:00:00 2001 From: Jim LAURIE Date: Wed, 21 Nov 2018 16:47:28 +0100 Subject: [PATCH 43/44] Fix space --- packages/strapi-generate-api/templates/mongoose/service.template | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/strapi-generate-api/templates/mongoose/service.template b/packages/strapi-generate-api/templates/mongoose/service.template index bb70229fd1..ebb9cd04d8 100644 --- a/packages/strapi-generate-api/templates/mongoose/service.template +++ b/packages/strapi-generate-api/templates/mongoose/service.template @@ -12,7 +12,6 @@ const _ = require('lodash'); const { models: { mergeStages } } = require('strapi-utils'); - module.exports = { /** From afbdc9b2bb627961ec1610ed098bef7cb8a636e9 Mon Sep 17 00:00:00 2001 From: Jim LAURIE Date: Wed, 21 Nov 2018 16:48:23 +0100 Subject: [PATCH 44/44] Fix indent --- .../strapi-generate-api/templates/mongoose/service.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/strapi-generate-api/templates/mongoose/service.template b/packages/strapi-generate-api/templates/mongoose/service.template index ebb9cd04d8..f2295f4c33 100644 --- a/packages/strapi-generate-api/templates/mongoose/service.template +++ b/packages/strapi-generate-api/templates/mongoose/service.template @@ -151,7 +151,7 @@ module.exports = { ); return data; - }, + }, /** * Promise to search a/an <%= id %>.