Init very simple filters system for the query layer to implement publication state filtering

This commit is contained in:
Alexandre Bodin 2021-10-13 14:06:16 +02:00
parent 435ce3bc88
commit 9dd2824824
7 changed files with 148 additions and 119 deletions

View File

@ -142,7 +142,7 @@ const createEntityManager = db => {
await db.lifecycles.run('beforeCount', uid, { params }); await db.lifecycles.run('beforeCount', uid, { params });
const res = await this.createQueryBuilder(uid) const res = await this.createQueryBuilder(uid)
.init(_.pick(['_q', 'where'], params)) .init(_.pick(['_q', 'where', 'filters'], params))
.count() .count()
.first() .first()
.execute(); .execute();
@ -172,7 +172,7 @@ const createEntityManager = db => {
await this.attachRelations(uid, id, data); 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 ? // TODO: do not trigger the findOne lifecycles ?
const result = await this.findOne(uid, { const result = await this.findOne(uid, {
where: { id }, where: { id },
@ -296,7 +296,7 @@ const createEntityManager = db => {
throw new Error('Delete requires a where parameter'); 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, { const entity = await this.findOne(uid, {
select: select && ['id'].concat(select), select: select && ['id'].concat(select),
where, where,
@ -469,7 +469,6 @@ const createEntityManager = db => {
const { joinTable } = attribute; const { joinTable } = attribute;
const { joinColumn, inverseJoinColumn } = joinTable; const { joinColumn, inverseJoinColumn } = joinTable;
// TODO: validate logic of delete
if (isOneToAny(attribute) && isBidirectional(attribute)) { if (isOneToAny(attribute) && isBidirectional(attribute)) {
await this.createQueryBuilder(joinTable.name) await this.createQueryBuilder(joinTable.name)
.delete() .delete()

View File

@ -122,7 +122,11 @@ const applyPopulate = async (results, populate, ctx) => {
const attribute = meta.attributes[key]; const attribute = meta.attributes[key];
const targetMeta = db.metadata.get(attribute.target); 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 isCount = populateValue.count === true;
const fromTargetRow = rowOrRows => fromRow(targetMeta, rowOrRows); const fromTargetRow = rowOrRows => fromRow(targetMeta, rowOrRows);

View File

@ -29,6 +29,7 @@ const createQueryBuilder = (uid, db) => {
return { return {
alias: getAlias(), alias: getAlias(),
getAlias, getAlias,
state,
select(args) { select(args) {
state.type = 'select'; state.type = 'select';
@ -115,7 +116,7 @@ const createQueryBuilder = (uid, db) => {
}, },
init(params = {}) { 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)) { if (!_.isNil(where)) {
this.where(where); this.where(where);
@ -151,9 +152,17 @@ const createQueryBuilder = (uid, db) => {
this.populate(populate); this.populate(populate);
} }
if (!_.isNil(filters)) {
this.filters(filters);
}
return this; return this;
}, },
filters(filters) {
state.filters = filters;
},
first() { first() {
state.first = true; state.first = true;
return this; return this;
@ -206,6 +215,19 @@ const createQueryBuilder = (uid, db) => {
processState() { processState() {
state.orderBy = helpers.processOrderBy(state.orderBy, { qb: this, uid, db }); 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.where = helpers.processWhere(state.where, { qb: this, uid, db });
state.populate = helpers.processPopulate(state.populate, { qb: this, uid, db }); state.populate = helpers.processPopulate(state.populate, { qb: this, uid, db });
state.data = helpers.toRow(meta, state.data); state.data = helpers.toRow(meta, state.data);

View File

@ -1,7 +1,6 @@
'use strict'; 'use strict';
const delegate = require('delegates'); const delegate = require('delegates');
const { pipe } = require('lodash/fp');
const { const {
sanitizeEntity, sanitizeEntity,
@ -16,12 +15,7 @@ const {
updateComponents, updateComponents,
deleteComponents, deleteComponents,
} = require('./components'); } = require('./components');
const { const { transformParamsToQuery, pickSelectionParams } = require('./params');
transformCommonParams,
transformPaginationParams,
transformParamsToQuery,
pickSelectionParams,
} = require('./params');
// TODO: those should be strapi events used by the webhooks not the other way arround // TODO: those should be strapi events used by the webhooks not the other way arround
const { ENTRY_CREATE, ENTRY_UPDATE, ENTRY_DELETE } = webhookUtils.webhookEvents; const { ENTRY_CREATE, ENTRY_UPDATE, ENTRY_DELETE } = webhookUtils.webhookEvents;
@ -231,13 +225,34 @@ const createDefaultImplementation = ({ strapi, db, eventHub, entityValidator })
const attribute = attributes[field]; const attribute = attributes[field];
const loadParams = const loadParams = {};
attribute.type === 'relation'
? transformParamsToQuery(attribute.target, params) switch (attribute.type) {
: pipe( case 'relation': {
transformCommonParams, Object.assign(loadParams, transformParamsToQuery(attribute.target, params));
transformPaginationParams break;
)(params); }
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); return db.query(uid).load(entity, field, loadParams);
}, },

View File

@ -1,6 +1,7 @@
'use strict'; 'use strict';
const { pick, pipe, isNil } = require('lodash/fp'); const assert = require('assert').strict;
const { pick, isNil, toNumber, isInteger } = require('lodash/fp');
const { const {
convertSortQueryParams, convertSortQueryParams,
@ -9,41 +10,39 @@ const {
convertPopulateQueryParams, convertPopulateQueryParams,
convertFiltersQueryParams, convertFiltersQueryParams,
convertFieldsQueryParams, convertFieldsQueryParams,
convertPublicationStateParams,
} = require('@strapi/utils/lib/convert-query-params'); } = 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 query = {};
const { _q, sort, filters, fields, populate, ...query } = params;
if (_q) { const { _q, sort, filters, fields, populate, page, pageSize, start, limit } = params;
if (!isNil(_q)) {
query._q = _q; query._q = _q;
} }
if (sort) { if (!isNil(sort)) {
query.orderBy = convertSortQueryParams(sort); query.orderBy = convertSortQueryParams(sort);
} }
if (filters) { if (!isNil(filters)) {
query.where = convertFiltersQueryParams(filters); query.where = convertFiltersQueryParams(filters);
} }
if (fields) { if (!isNil(fields)) {
query.select = convertFieldsQueryParams(fields); query.select = convertFieldsQueryParams(fields);
} }
if (populate) { if (!isNil(populate)) {
query.populate = convertPopulateQueryParams(populate); query.populate = convertPopulateQueryParams(populate);
} }
return query;
};
const transformPaginationParams = (params = {}) => {
const { page, pageSize, start, limit, ...query } = params;
const isPagePagination = !isNil(page) || !isNil(pageSize); const isPagePagination = !isNil(page) || !isNil(pageSize);
const isOffsetPagination = !isNil(start) || !isNil(limit); const isOffsetPagination = !isNil(start) || !isNil(limit);
@ -53,72 +52,38 @@ const transformPaginationParams = (params = {}) => {
); );
} }
if (page) { if (!isNil(page)) {
query.page = Number(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) { if (!isNil(pageSize)) {
query.pageSize = Number(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); query.offset = convertStartQueryParams(start);
} }
if (limit) { if (!isNil(limit)) {
query.limit = convertLimitQueryParams(limit); query.limit = convertLimitQueryParams(limit);
} }
return query; convertPublicationStateParams(type, params, 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
}
}
return 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 = { module.exports = {
transformCommonParams,
transformPublicationStateParams,
transformPaginationParams,
transformParamsToQuery, transformParamsToQuery,
pickSelectionParams, pickSelectionParams,
}; };

View File

@ -171,6 +171,7 @@ describe('Publication State', () => {
const res = await rq({ method: 'GET', url: `${baseUrl}${query}` }); const res = await rq({ method: 'GET', url: `${baseUrl}${query}` });
expect(res.body.data).toHaveLength(lengthFor(modelName, { mode })); expect(res.body.data).toHaveLength(lengthFor(modelName, { mode }));
expect(res.body.meta.pagination.total).toBe(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', () => { test('Payload integrity', () => {
@ -199,35 +200,29 @@ describe('Publication State', () => {
test('Root level', () => { test('Root level', () => {
products.forEach(product => { 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 }) => { test('Second level through component (countries) to be published only', () => {
// const length = getApiRef(id).categories.filter(c => c.publishedAt !== null).length; products.forEach(({ attributes }) => {
// expect(categories).toHaveLength(length); const countries = attributes.comp.countries.data;
// categories.forEach(category => { countries.forEach(country => {
// expect(category.publishedAt).toBeISODate(); expect(country.attributes.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();
// });
// });
// });
}); });
}); });
}); });

View File

@ -4,9 +4,12 @@
* Converts the standard Strapi REST query params to a more usable format for querying * 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 * 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 _ = require('lodash');
const parseType = require('./parse-type'); const parseType = require('./parse-type');
const contentTypesUtils = require('./content-types');
const { PUBLISHED_AT_ATTRIBUTE } = contentTypesUtils.constants;
class InvalidOrderError extends Error { class InvalidOrderError extends Error {
constructor() { constructor() {
@ -130,13 +133,15 @@ const convertPopulateQueryParams = (populate, depth = 0) => {
if (Array.isArray(populate)) { if (Array.isArray(populate)) {
// map convert // map convert
return populate.flatMap(value => { return _.uniq(
if (typeof value !== 'string') { populate.flatMap(value => {
throw new InvalidPopulateError(); if (typeof value !== 'string') {
} throw new InvalidPopulateError();
}
return value.split(',').map(value => _.trim(value)); return value.split(',').map(value => _.trim(value));
}); })
);
} }
if (_.isPlainObject(populate)) { if (_.isPlainObject(populate)) {
@ -144,6 +149,7 @@ const convertPopulateQueryParams = (populate, depth = 0) => {
for (const key in populate) { for (const key in populate) {
transformedPopulate[key] = convertNestedPopulate(populate[key]); transformedPopulate[key] = convertNestedPopulate(populate[key]);
} }
return transformedPopulate; 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'); 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 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 = { module.exports = {
convertSortQueryParams, convertSortQueryParams,
convertStartQueryParams, convertStartQueryParams,
@ -220,4 +248,5 @@ module.exports = {
convertPopulateQueryParams, convertPopulateQueryParams,
convertFiltersQueryParams, convertFiltersQueryParams,
convertFieldsQueryParams, convertFieldsQueryParams,
convertPublicationStateParams,
}; };