diff --git a/packages/core/database/lib/entity-manager.js b/packages/core/database/lib/entity-manager.js index d0d79dab5c..c614e5a4e7 100644 --- a/packages/core/database/lib/entity-manager.js +++ b/packages/core/database/lib/entity-manager.js @@ -142,7 +142,7 @@ const createEntityManager = db => { await db.lifecycles.run('beforeCount', uid, { params }); const res = await this.createQueryBuilder(uid) - .init(_.pick(['_q', 'where'], params)) + .init(_.pick(['_q', 'where', 'filters'], params)) .count() .first() .execute(); @@ -172,7 +172,7 @@ const createEntityManager = db => { await this.attachRelations(uid, id, data); - // TODO: in case there is not select or populate specified return the inserted data ? + // TODO: in case there is no select or populate specified return the inserted data ? // TODO: do not trigger the findOne lifecycles ? const result = await this.findOne(uid, { where: { id }, @@ -296,7 +296,7 @@ const createEntityManager = db => { throw new Error('Delete requires a where parameter'); } - // TODO: avoid trigger the findOne lifecycles in the case ? + // TODO: do not trigger the findOne lifecycles ? const entity = await this.findOne(uid, { select: select && ['id'].concat(select), where, @@ -469,7 +469,6 @@ const createEntityManager = db => { const { joinTable } = attribute; const { joinColumn, inverseJoinColumn } = joinTable; - // TODO: validate logic of delete if (isOneToAny(attribute) && isBidirectional(attribute)) { await this.createQueryBuilder(joinTable.name) .delete() diff --git a/packages/core/database/lib/query/helpers/populate.js b/packages/core/database/lib/query/helpers/populate.js index eca5d12b97..b8740e2458 100644 --- a/packages/core/database/lib/query/helpers/populate.js +++ b/packages/core/database/lib/query/helpers/populate.js @@ -122,7 +122,11 @@ const applyPopulate = async (results, populate, ctx) => { const attribute = meta.attributes[key]; const targetMeta = db.metadata.get(attribute.target); - const populateValue = pickPopulateParams(populate[key]); + const populateValue = { + ...pickPopulateParams(populate[key]), + filters: qb.state.filters, + }; + const isCount = populateValue.count === true; const fromTargetRow = rowOrRows => fromRow(targetMeta, rowOrRows); diff --git a/packages/core/database/lib/query/query-builder.js b/packages/core/database/lib/query/query-builder.js index 715ec8f493..b559a0a378 100644 --- a/packages/core/database/lib/query/query-builder.js +++ b/packages/core/database/lib/query/query-builder.js @@ -29,6 +29,7 @@ const createQueryBuilder = (uid, db) => { return { alias: getAlias(), getAlias, + state, select(args) { state.type = 'select'; @@ -115,7 +116,7 @@ const createQueryBuilder = (uid, db) => { }, init(params = {}) { - const { _q, where, select, limit, offset, orderBy, groupBy, populate } = params; + const { _q, filters, where, select, limit, offset, orderBy, groupBy, populate } = params; if (!_.isNil(where)) { this.where(where); @@ -151,9 +152,17 @@ const createQueryBuilder = (uid, db) => { this.populate(populate); } + if (!_.isNil(filters)) { + this.filters(filters); + } + return this; }, + filters(filters) { + state.filters = filters; + }, + first() { state.first = true; return this; @@ -206,6 +215,19 @@ const createQueryBuilder = (uid, db) => { processState() { state.orderBy = helpers.processOrderBy(state.orderBy, { qb: this, uid, db }); + + if (!_.isNil(state.filters)) { + if (_.isFunction(state.filters)) { + const filters = state.filters({ qb: this, uid, meta, db }); + + if (!_.isNil(filters)) { + state.where.push(filters); + } + } else { + state.where.push(state.filters); + } + } + state.where = helpers.processWhere(state.where, { qb: this, uid, db }); state.populate = helpers.processPopulate(state.populate, { qb: this, uid, db }); state.data = helpers.toRow(meta, state.data); diff --git a/packages/core/strapi/lib/services/entity-service/index.js b/packages/core/strapi/lib/services/entity-service/index.js index 581a5b49f0..1f2d59273e 100644 --- a/packages/core/strapi/lib/services/entity-service/index.js +++ b/packages/core/strapi/lib/services/entity-service/index.js @@ -1,7 +1,6 @@ 'use strict'; const delegate = require('delegates'); -const { pipe } = require('lodash/fp'); const { sanitizeEntity, @@ -16,12 +15,7 @@ const { updateComponents, deleteComponents, } = require('./components'); -const { - transformCommonParams, - transformPaginationParams, - transformParamsToQuery, - pickSelectionParams, -} = require('./params'); +const { transformParamsToQuery, pickSelectionParams } = require('./params'); // TODO: those should be strapi events used by the webhooks not the other way arround const { ENTRY_CREATE, ENTRY_UPDATE, ENTRY_DELETE } = webhookUtils.webhookEvents; @@ -231,13 +225,34 @@ const createDefaultImplementation = ({ strapi, db, eventHub, entityValidator }) const attribute = attributes[field]; - const loadParams = - attribute.type === 'relation' - ? transformParamsToQuery(attribute.target, params) - : pipe( - transformCommonParams, - transformPaginationParams - )(params); + const loadParams = {}; + + switch (attribute.type) { + case 'relation': { + Object.assign(loadParams, transformParamsToQuery(attribute.target, params)); + break; + } + case 'component': { + Object.assign(loadParams, transformParamsToQuery(attribute.component, params)); + break; + } + case 'dynamiczone': { + Object.assign(loadParams, transformParamsToQuery(null, params)); + break; + } + case 'media': { + Object.assign(loadParams, transformParamsToQuery('plugin::upload.file', params)); + break; + } + } + + // const loadParams = + // attribute.type === 'relation' + // ? transformParamsToQuery(attribute.target, params) + // : pipe( + // transformCommonParams, + // transformPaginationParams + // )(params); return db.query(uid).load(entity, field, loadParams); }, diff --git a/packages/core/strapi/lib/services/entity-service/params.js b/packages/core/strapi/lib/services/entity-service/params.js index ce48c57deb..877d6a5f13 100644 --- a/packages/core/strapi/lib/services/entity-service/params.js +++ b/packages/core/strapi/lib/services/entity-service/params.js @@ -1,6 +1,7 @@ 'use strict'; -const { pick, pipe, isNil } = require('lodash/fp'); +const assert = require('assert').strict; +const { pick, isNil, toNumber, isInteger } = require('lodash/fp'); const { convertSortQueryParams, @@ -9,41 +10,39 @@ const { convertPopulateQueryParams, convertFiltersQueryParams, convertFieldsQueryParams, + convertPublicationStateParams, } = require('@strapi/utils/lib/convert-query-params'); -const { contentTypes: contentTypesUtils } = require('@strapi/utils'); +const pickSelectionParams = pick(['fields', 'populate']); -const { PUBLISHED_AT_ATTRIBUTE } = contentTypesUtils.constants; +const transformParamsToQuery = (uid, params) => { + // NOTE: can be a CT, a Compo or nothing in the case of polymorphism (DZ & morph relations) + const type = strapi.getModel(uid); -const transformCommonParams = (params = {}) => { - const { _q, sort, filters, fields, populate, ...query } = params; + const query = {}; - if (_q) { + const { _q, sort, filters, fields, populate, page, pageSize, start, limit } = params; + + if (!isNil(_q)) { query._q = _q; } - if (sort) { + if (!isNil(sort)) { query.orderBy = convertSortQueryParams(sort); } - if (filters) { + if (!isNil(filters)) { query.where = convertFiltersQueryParams(filters); } - if (fields) { + if (!isNil(fields)) { query.select = convertFieldsQueryParams(fields); } - if (populate) { + if (!isNil(populate)) { query.populate = convertPopulateQueryParams(populate); } - return query; -}; - -const transformPaginationParams = (params = {}) => { - const { page, pageSize, start, limit, ...query } = params; - const isPagePagination = !isNil(page) || !isNil(pageSize); const isOffsetPagination = !isNil(start) || !isNil(limit); @@ -53,72 +52,38 @@ const transformPaginationParams = (params = {}) => { ); } - if (page) { - query.page = Number(page); + if (!isNil(page)) { + const pageVal = toNumber(page); + const isValid = isInteger(pageVal) && pageVal > 0; + + assert(isValid, `Invalid 'page' parameter. Expected an integer > 0, received: ${page}`); + + query.page = pageVal; } - if (pageSize) { - query.pageSize = Number(pageSize); + if (!isNil(pageSize)) { + const pageSizeVal = toNumber(pageSize); + const isValid = isInteger(pageSizeVal) && pageSizeVal > 0; + + assert(isValid, `Invalid 'pageSize' parameter. Expected an integer > 0, received: ${page}`); + + query.pageSize = pageSizeVal; } - if (start) { + if (!isNil(start)) { query.offset = convertStartQueryParams(start); } - if (limit) { + if (!isNil(limit)) { query.limit = convertLimitQueryParams(limit); } - return query; -}; - -const transformPublicationStateParams = uid => (params = {}) => { - const contentType = strapi.getModel(uid); - - if (!contentType) { - return params; - } - - const { publicationState, ...query } = params; - - if (publicationState && contentTypesUtils.hasDraftAndPublish(contentType)) { - const { publicationState = 'live' } = params; - - const liveClause = { - [PUBLISHED_AT_ATTRIBUTE]: { - $notNull: true, - }, - }; - - if (publicationState === 'live') { - query.where = { - $and: [liveClause].concat(query.where || []), - }; - - // TODO: propagate nested publicationState filter somehow - } - } + convertPublicationStateParams(type, params, query); return query; }; -const pickSelectionParams = pick(['fields', 'populate']); - -const transformParamsToQuery = (uid, params) => { - return pipe( - // _q, filters, etc... - transformCommonParams, - // page, pageSize, start, limit - transformPaginationParams, - // publicationState - transformPublicationStateParams(uid) - )(params); -}; - module.exports = { - transformCommonParams, - transformPublicationStateParams, - transformPaginationParams, transformParamsToQuery, pickSelectionParams, }; diff --git a/packages/core/strapi/tests/publication-state.test.e2e.js b/packages/core/strapi/tests/publication-state.test.e2e.js index 999da335b6..80fcf9f548 100644 --- a/packages/core/strapi/tests/publication-state.test.e2e.js +++ b/packages/core/strapi/tests/publication-state.test.e2e.js @@ -171,6 +171,7 @@ describe('Publication State', () => { const res = await rq({ method: 'GET', url: `${baseUrl}${query}` }); expect(res.body.data).toHaveLength(lengthFor(modelName, { mode })); + expect(res.body.meta.pagination.total).toBe(lengthFor(modelName, { mode })); }); }); @@ -190,7 +191,7 @@ describe('Publication State', () => { }, }); - products = res.body.data.map(res => ({ id: res.id, ...res.attributes })); + products = res.body.data; }); test('Payload integrity', () => { @@ -199,35 +200,29 @@ describe('Publication State', () => { test('Root level', () => { products.forEach(product => { - expect(product.publishedAt).toBeISODate(); + expect(product.attributes.publishedAt).toBeISODate(); }); }); - // const getApiRef = id => data.product.find(product => product.id === id); + test('First level (categories) to be published only', () => { + products.forEach(({ attributes }) => { + const categories = attributes.categories.data; - test.todo('First level (categories)'); + categories.forEach(category => { + expect(category.attributes.publishedAt).toBeISODate(); + }); + }); + }); - // products.forEach(({ id, categories }) => { - // const length = getApiRef(id).categories.filter(c => c.publishedAt !== null).length; - // expect(categories).toHaveLength(length); + test('Second level through component (countries) to be published only', () => { + products.forEach(({ attributes }) => { + const countries = attributes.comp.countries.data; - // categories.forEach(category => { - // expect(category.publishedAt).toBeISODate(); - // }); - // }); - // }); - - test.todo('Second level through component (countries)'); - - // products.forEach(({ id, comp: { countries } }) => { - // const length = getApiRef(id).comp.countries.filter(c => c.publishedAt !== null).length; - // expect(countries).toHaveLength(length); - - // countries.forEach(country => { - // expect(country.publishedAt).toBeISODate(); - // }); - // }); - // }); + countries.forEach(country => { + expect(country.attributes.publishedAt).toBeISODate(); + }); + }); + }); }); }); }); diff --git a/packages/core/utils/lib/convert-query-params.js b/packages/core/utils/lib/convert-query-params.js index 6e70762ee3..e4c9f94763 100644 --- a/packages/core/utils/lib/convert-query-params.js +++ b/packages/core/utils/lib/convert-query-params.js @@ -4,9 +4,12 @@ * Converts the standard Strapi REST query params to a more usable format for querying * You can read more here: https://strapi.io/documentation/developer-docs/latest/developer-resources/content-api/content-api.html#filters */ - +const { has } = require('lodash/fp'); const _ = require('lodash'); const parseType = require('./parse-type'); +const contentTypesUtils = require('./content-types'); + +const { PUBLISHED_AT_ATTRIBUTE } = contentTypesUtils.constants; class InvalidOrderError extends Error { constructor() { @@ -130,13 +133,15 @@ const convertPopulateQueryParams = (populate, depth = 0) => { if (Array.isArray(populate)) { // map convert - return populate.flatMap(value => { - if (typeof value !== 'string') { - throw new InvalidPopulateError(); - } + return _.uniq( + populate.flatMap(value => { + if (typeof value !== 'string') { + throw new InvalidPopulateError(); + } - return value.split(',').map(value => _.trim(value)); - }); + return value.split(',').map(value => _.trim(value)); + }) + ); } if (_.isPlainObject(populate)) { @@ -144,6 +149,7 @@ const convertPopulateQueryParams = (populate, depth = 0) => { for (const key in populate) { transformedPopulate[key] = convertNestedPopulate(populate[key]); } + return transformedPopulate; } @@ -210,9 +216,31 @@ const convertFieldsQueryParams = (fields, depth = 0) => { throw new Error('Invalid fields parameter. Expected a string or an array of strings'); }; -// NOTE: We could validate the parameters are on existing / non private attributes const convertFiltersQueryParams = filters => filters; +const convertPublicationStateParams = (type, params = {}, query = {}) => { + if (!type) { + return; + } + + const { publicationState } = params; + + if (!_.isNil(publicationState)) { + if (!contentTypesUtils.constants.DP_PUB_STATES.includes(publicationState)) { + throw new Error( + `Invalid publicationState. Expected one of 'preview','live' received: ${publicationState}.` + ); + } + + // NOTE: this is the query layer filters not the entity service filters + query.filters = ({ meta }) => { + if (publicationState === 'live' && has(PUBLISHED_AT_ATTRIBUTE, meta.attributes)) { + return { [PUBLISHED_AT_ATTRIBUTE]: { $notNull: true } }; + } + }; + } +}; + module.exports = { convertSortQueryParams, convertStartQueryParams, @@ -220,4 +248,5 @@ module.exports = { convertPopulateQueryParams, convertFiltersQueryParams, convertFieldsQueryParams, + convertPublicationStateParams, };