Merge branch 'v4/backend' into pluginAPI/loadPlugin

This commit is contained in:
Pierre Noël 2021-08-18 12:13:33 +02:00
commit 682e877644
33 changed files with 1656 additions and 1384 deletions

View File

@ -33,8 +33,8 @@
Strapi is a free and open-source headless CMS delivering your content anywhere you need.
- **Keep control over your data**. With Strapi, you know where your data is stored, and you keep full control at all times.
- **Self-hosted**. You can host and scale Strapi projects the way you want. You can choose any hosting platform you want: AWS, Render, Netlify, Heroku, a VPS, or a dedicated server. You can scale as you grow, 100% independent.
- **Database agnostic**. Strapi works with SQL databases. You can choose the database you prefer: PostgreSQL, MySQL, MariaDB, and SQLite.
- **Self-hosted**. You can host and scale Strapi projects the way you want. You can choose any hosting platform you want: AWS, Render, Heroku, a VPS, or a dedicated server. You can scale as you grow, 100% independent.
- **Database agnostic**. You can choose the database you prefer. Strapi works with SQL & NoSQL databases: MongoDB, PostgreSQL, MySQL, MariaDB, and SQLite.
- **Customizable**. You can quickly build your logic by fully customizing APIs, routes, or plugins to fit your needs perfectly.
## Getting Started

View File

@ -1,6 +0,0 @@
module.exports = {
defaults: { mazdaz: { enabled: true } },
load: {
initialize() {},
},
};

View File

@ -46,7 +46,8 @@ module.exports = {
const roleService = getService('role');
const roles = await roleService.deleteByIds([id]);
const sanitizedRole = roles.map(roleService.sanitizeRole)[0] || null;
const sanitizedRole = roles.map(role => roleService.sanitizeRole(role))[0] || null;
return ctx.deleted({
data: sanitizedRole,
@ -107,6 +108,7 @@ module.exports = {
}
const permissions = await roleService.assignPermissions(role.id, input.permissions);
const sanitizedPermissions = permissions.map(permissionService.sanitizePermission);
ctx.body = {

View File

@ -26,12 +26,16 @@ const permissionDomain = require('../../domain/permission/index');
* @returns {Promise<array>}
*/
const deleteByRolesIds = async rolesIds => {
// FIXME: need to delete associations in delete many
await strapi.query('strapi::permission').deleteMany({
const permissionsToDelete = await strapi.query('strapi::permission').findMany({
select: ['id'],
where: {
role: { id: rolesIds },
},
});
if (permissionsToDelete.length > 0) {
await deleteByIds(permissionsToDelete.map(prop('id')));
}
};
/**
@ -43,9 +47,6 @@ const deleteByIds = async ids => {
for (const id of ids) {
await strapi.query('strapi::permission').delete({ where: { id } });
}
// FIXME: find a way to do delete many with auto association deletes (FKs should do the job)$
// await strapi.query('strapi::permission').deleteMany({ where: { id: ids } });
};
/**
@ -61,9 +62,6 @@ const createMany = async permissions => {
}
return permissionDomain.toPermission(createdPermissions);
// FIXME:
// await strapi.query('strapi::permission').createMany({ data: permissions })
};
/**

View File

@ -187,7 +187,10 @@ const deleteByIds = async (ids = []) => {
const deletedRoles = [];
for (const id of ids) {
const deletedRole = await strapi.query('strapi::role').delete({ where: { id } });
deletedRoles.push(deletedRole);
if (deletedRole) {
deletedRoles.push(deletedRole);
}
}
return deletedRoles;
@ -342,8 +345,8 @@ const assignPermissions = async (roleId, permissions = []) => {
}
if (permissionsToAdd.length > 0) {
await addPermissions(roleId, permissionsToAdd);
permissionsToReturn.push(...permissionsToAdd);
const newPermissions = await addPermissions(roleId, permissionsToAdd);
permissionsToReturn.push(...newPermissions);
}
if (!isSuperAdmin && (permissionsToAdd.length || permissionsToDelete.length)) {

View File

@ -5,14 +5,32 @@ const types = require('./types');
const { createField } = require('./fields');
const { createQueryBuilder } = require('./query');
const { createRepository } = require('./entity-repository');
const { isBidirectional } = require('./metadata/relations');
const { isBidirectional, isOneToAny } = require('./metadata/relations');
const toId = value => value.id || value;
const toIds = value => _.castArray(value || []).map(toId);
// TODO: move to query layer
const isValidId = value => _.isString(value) || _.isInteger(value);
const toAssocs = data => {
return _.castArray(data)
.filter(datum => !_.isNil(datum))
.map(datum => {
// if it is a string or an integer return an obj with id = to datum
if (isValidId(datum)) {
return { id: datum, __pivot: {} };
}
// if it is an object check it has at least a valid id
if (!_.has('id', datum) || !isValidId(datum.id)) {
throw new Error(`Invalid id, expected a string or integer, got ${datum}`);
}
return datum;
});
};
// TODO: handle programmatic defaults
const toRow = (metadata, data = {}) => {
const toRow = (metadata, data = {}, { withDefaults = false } = {}) => {
const { attributes } = metadata;
const obj = {};
@ -20,14 +38,24 @@ const toRow = (metadata, data = {}) => {
for (const attributeName in attributes) {
const attribute = attributes[attributeName];
if (types.isScalar(attribute.type) && _.has(attributeName, data)) {
// TODO: we convert to column name
// TODO: handle default value
// TODO: convert to column name
if (types.isScalar(attribute.type)) {
const field = createField(attribute);
const field = createField(attribute.type, attribute);
if (_.isUndefined(data[attributeName])) {
if (!_.isUndefined(attribute.default) && withDefaults) {
if (typeof attribute.default === 'function') {
obj[attributeName] = attribute.default();
} else {
obj[attributeName] = attribute.default;
}
}
continue;
}
// TODO: validate data on creation
// field.validate(data[attributeName]);
if (typeof field.validate === 'function' && data[attributeName] !== null) {
field.validate(data[attributeName]);
}
const val = data[attributeName] === null ? null : field.toDB(data[attributeName]);
@ -139,7 +167,7 @@ const createEntityManager = db => {
throw new Error('Create expects a data object');
}
const dataToInsert = toRow(metadata, data);
const dataToInsert = toRow(metadata, data, { withDefaults: true });
const [id] = await this.createQueryBuilder(uid)
.insert(dataToInsert)
@ -160,6 +188,7 @@ const createEntityManager = db => {
return result;
},
// TODO: where do we handle relation processing for many queries ?
async createMany(uid, params = {}) {
await db.lifecycles.run('beforeCreateMany', uid, { params });
@ -170,7 +199,7 @@ const createEntityManager = db => {
throw new Error('CreateMany expects data to be an array');
}
const dataToInsert = data.map(datum => toRow(metadata, datum));
const dataToInsert = data.map(datum => toRow(metadata, datum, { withDefaults: true }));
if (_.isEmpty(dataToInsert)) {
throw new Error('Nothing to insert');
@ -236,6 +265,7 @@ const createEntityManager = db => {
return result;
},
// TODO: where do we handle relation processing for many queries ?
async updateMany(uid, params = {}) {
await db.lifecycles.run('beforeUpdateMany', uid, { params });
@ -327,7 +357,7 @@ const createEntityManager = db => {
for (const attributeName in attributes) {
const attribute = attributes[attributeName];
const isValidLink = _.has(attributeName, data) && !_.isNull(data[attributeName]);
const isValidLink = _.has(attributeName, data) && !_.isNil(data[attributeName]);
if (attribute.type !== 'relation' || !isValidLink) {
continue;
@ -352,13 +382,17 @@ const createEntityManager = db => {
const { idColumn, typeColumn } = morphColumn;
const rows = toIds(data[attributeName]).map((dataID, idx) => ({
[joinColumn.name]: dataID,
[idColumn.name]: id,
[typeColumn.name]: uid,
...(joinTable.on || {}),
order: idx,
}));
const rows = toAssocs(data[attributeName]).map((data, idx) => {
return {
[joinColumn.name]: data.id,
[idColumn.name]: id,
[typeColumn.name]: uid,
...(joinTable.on || {}),
...(data.__pivot || {}),
order: idx + 1,
field: attributeName,
};
});
if (_.isEmpty(rows)) {
continue;
@ -379,12 +413,12 @@ const createEntityManager = db => {
const { idColumn, typeColumn, typeField = '__type' } = morphColumn;
const rows = _.castArray(data[attributeName] || []).map((data, idx) => ({
const rows = toAssocs(data[attributeName]).map(data => ({
[joinColumn.name]: id,
[idColumn.name]: data.id,
[typeColumn.name]: data[typeField],
...(joinTable.on || {}),
order: idx,
...(data.__pivot || {}),
}));
if (_.isEmpty(rows)) {
@ -438,13 +472,8 @@ const createEntityManager = db => {
const { joinTable } = attribute;
const { joinColumn, inverseJoinColumn } = joinTable;
// TODO: redefine
// TODO: check it is an id & the entity exists (will throw due to FKs otherwise so not a big pbl in SQL)
if (
['oneToOne', 'oneToMany'].includes(attribute.relation) &&
isBidirectional(attribute)
) {
// TODO: validate logic of delete
if (isOneToAny(attribute) && isBidirectional(attribute)) {
await this.createQueryBuilder(joinTable.name)
.delete()
.where({ [inverseJoinColumn.name]: _.castArray(data[attributeName]) })
@ -452,11 +481,12 @@ const createEntityManager = db => {
.execute();
}
const insert = _.castArray(data[attributeName]).map(datum => {
const insert = toAssocs(data[attributeName]).map(data => {
return {
[joinColumn.name]: id,
[inverseJoinColumn.name]: datum,
[inverseJoinColumn.name]: data.id,
...(joinTable.on || {}),
...(data.__pivot || {}),
};
});
@ -524,15 +554,18 @@ const createEntityManager = db => {
[idColumn.name]: id,
[typeColumn.name]: uid,
...(joinTable.on || {}),
field: attributeName,
})
.execute();
const rows = toIds(data[attributeName] || []).map((dataID, idx) => ({
[joinColumn.name]: dataID,
const rows = toAssocs(data[attributeName]).map((data, idx) => ({
[joinColumn.name]: data.id,
[idColumn.name]: id,
[typeColumn.name]: uid,
...(joinTable.on || {}),
order: idx,
...(data.__pivot || {}),
order: idx + 1,
field: attributeName,
}));
if (_.isEmpty(rows)) {
@ -566,12 +599,12 @@ const createEntityManager = db => {
})
.execute();
const rows = _.castArray(data[attributeName] || []).map((data, idx) => ({
const rows = toAssocs(data[attributeName]).map(data => ({
[joinColumn.name]: id,
[idColumn.name]: data.id,
[typeColumn.name]: data[typeField],
...(joinTable.on || {}),
order: idx,
...(data.__pivot || {}),
}));
if (_.isEmpty(rows)) {
@ -624,17 +657,18 @@ const createEntityManager = db => {
if (['oneToOne', 'oneToMany'].includes(attribute.relation)) {
await this.createQueryBuilder(joinTable.name)
.delete()
.where({ [inverseJoinColumn.name]: _.castArray(data[attributeName] || []) })
.where({ [inverseJoinColumn.name]: toIds(data[attributeName]) })
.where(joinTable.on || {})
.execute();
}
if (!_.isNull(data[attributeName])) {
const insert = _.castArray(data[attributeName] || []).map(datum => {
const insert = toAssocs(data[attributeName]).map(data => {
return {
[joinColumn.name]: id,
[inverseJoinColumn.name]: datum,
[inverseJoinColumn.name]: data.id,
...(joinTable.on || {}),
...(data.__pivot || {}),
};
});
@ -652,9 +686,9 @@ const createEntityManager = db => {
},
/**
* Delete relations of an existing entity
* Delete relational associations of an existing entity
* This removes associations but doesn't do cascade deletions for components for example. This will be handled on the entity service layer instead
* NOTE: Most of the deletion should be handled by ON DELETE CASCADE for dialect that have FKs
* NOTE: Most of the deletion should be handled by ON DELETE CASCADE for dialects that have FKs
*
* @param {EntityManager} em - entity manager instance
* @param {Metadata} metadata - model metadta
@ -703,6 +737,7 @@ const createEntityManager = db => {
[idColumn.name]: id,
[typeColumn.name]: uid,
...(joinTable.on || {}),
field: attributeName,
})
.execute();
}
@ -816,11 +851,8 @@ const createEntityManager = db => {
// custom queries
// utilities
// -> format
// -> parse
// -> map result
// -> map input
// -> validation
// extra features
// -> virtuals

View File

@ -4,4 +4,7 @@ interface Field {
fromDB(value: any): any;
}
export function createField(type: string): Field;
interface Attribute {
type: string
}
export function createField(attribute: Attribute): Field;

View File

@ -217,7 +217,9 @@ const typeToFieldMap = {
boolean: BooleanField,
};
const createField = (type /*attribute*/) => {
const createField = attribute => {
const { type } = attribute;
if (_.has(type, typeToFieldMap)) {
return new typeToFieldMap[type]({});
}

View File

@ -102,6 +102,9 @@ const createMetadata = (models = []) => {
on: {
field: attributeName,
},
orderBy: {
order: 'asc',
},
},
});
@ -135,6 +138,9 @@ const createMetadata = (models = []) => {
on: {
field: attributeName,
},
orderBy: {
order: 'asc',
},
},
});

View File

@ -9,6 +9,7 @@ const _ = require('lodash/fp');
const hasInversedBy = _.has('inversedBy');
const hasMappedBy = _.has('mappedBy');
const isOneToAny = attribute => ['oneToOne', 'oneToMany'].includes(attribute.relation);
const isBidirectional = attribute => hasInversedBy(attribute) || hasMappedBy(attribute);
const isOwner = attribute => !isBidirectional(attribute) || hasInversedBy(attribute);
const shouldUseJoinTable = attribute => attribute.useJoinTable !== false;
@ -224,7 +225,9 @@ const createMorphToMany = (attributeName, attribute, meta, metadata) => {
[typeColumnName]: {
type: 'string',
},
// TODO: add field
field: {
type: 'string',
},
order: {
type: 'integer',
column: {
@ -258,6 +261,9 @@ const createMorphToMany = (attributeName, attribute, meta, metadata) => {
referencedColumn: 'id',
},
},
orderBy: {
order: 'asc',
},
};
attribute.joinTable = joinTable;
@ -303,17 +309,6 @@ const createMorphMany = (attributeName, attribute, meta, metadata) => {
}
};
const relationFactoryMap = {
oneToOne: createOneToOne,
oneToMany: createOneToMany,
manyToOne: createManyToOne,
manyToMany: createManyToMany,
morphToOne: createMorphToOne,
morphToMany: createMorphToMany,
morphOne: createMorphOne,
morphMany: createMorphMany,
};
/**
* Creates a relation metadata
*
@ -323,13 +318,36 @@ const relationFactoryMap = {
* @param {Metadata} metadata
*/
const createRelation = (attributeName, attribute, meta, metadata) => {
if (_.has(attribute.relation, relationFactoryMap)) {
return relationFactoryMap[attribute.relation](attributeName, attribute, meta, metadata);
switch (attribute.relation) {
case 'oneToOne':
return createOneToOne(attributeName, attribute, meta, metadata);
case 'oneToMany':
return createOneToMany(attributeName, attribute, meta, metadata);
case 'manyToOne':
return createManyToOne(attributeName, attribute, meta, metadata);
case 'manyToMany':
return createManyToMany(attributeName, attribute, meta, metadata);
case 'morphToOne':
return createMorphToOne(attributeName, attribute, meta, metadata);
case 'morphToMany':
return createMorphToMany(attributeName, attribute, meta, metadata);
case 'morphOne':
return createMorphOne(attributeName, attribute, meta, metadata);
case 'morphMany':
return createMorphMany(attributeName, attribute, meta, metadata);
}
throw new Error(`Unknown relation ${attribute.relation}`);
};
/**
* Creates a join column info and add them to the attribute meta
* @param {Object} metadata metadata registry
* @param {Object} param
* @param {Object} param.attribute associated attribute
* @param {string} param.attributeName name of the associated attribute
* @param {Object} param.meta model metadata
*/
const createJoinColum = (metadata, { attribute, attributeName /*meta */ }) => {
const targetMeta = metadata.get(attribute.target);
@ -354,6 +372,14 @@ const createJoinColum = (metadata, { attribute, attributeName /*meta */ }) => {
}
};
/**
* Creates a join table and add it to the attribute meta
* @param {Object} metadata metadata registry
* @param {Object} param
* @param {Object} param.attribute associated attribute
* @param {string} param.attributeName name of the associated attribute
* @param {Object} param.meta model metadata
*/
const createJoinTable = (metadata, { attributeName, attribute, meta }) => {
const targetMeta = metadata.get(attribute.target);
@ -442,4 +468,5 @@ module.exports = {
createRelation,
isBidirectional,
isOneToAny,
};

View File

@ -0,0 +1,10 @@
'use strict';
module.exports = {
...require('./search'),
...require('./order-by'),
...require('./join'),
...require('./populate'),
...require('./where'),
...require('./transform'),
};

View File

@ -0,0 +1,97 @@
'use strict';
const createPivotJoin = (qb, joinTable, alias, tragetMeta) => {
const joinAlias = qb.getAlias();
qb.join({
alias: joinAlias,
referencedTable: joinTable.name,
referencedColumn: joinTable.joinColumn.name,
rootColumn: joinTable.joinColumn.referencedColumn,
rootTable: alias,
on: joinTable.on,
});
const subAlias = qb.getAlias();
qb.join({
alias: subAlias,
referencedTable: tragetMeta.tableName,
referencedColumn: joinTable.inverseJoinColumn.referencedColumn,
rootColumn: joinTable.inverseJoinColumn.name,
rootTable: joinAlias,
});
return subAlias;
};
const createJoin = (ctx, { alias, attributeName, attribute }) => {
const { db, qb } = ctx;
if (attribute.type !== 'relation') {
throw new Error(`Cannot join on non relational field ${attributeName}`);
}
const tragetMeta = db.metadata.get(attribute.target);
const joinColumn = attribute.joinColumn;
if (joinColumn) {
const subAlias = qb.getAlias();
qb.join({
alias: subAlias,
referencedTable: tragetMeta.tableName,
referencedColumn: joinColumn.referencedColumn,
rootColumn: joinColumn.name,
rootTable: alias,
});
return subAlias;
}
const joinTable = attribute.joinTable;
if (joinTable) {
return createPivotJoin(qb, joinTable, alias, tragetMeta);
}
// TODO: polymorphic relations
return alias;
};
// TODO: allow for more conditions
const applyJoin = (qb, join) => {
const {
method = 'leftJoin',
alias,
referencedTable,
referencedColumn,
rootColumn,
rootTable = this.alias,
on,
orderBy,
} = join;
qb[method]({ [alias]: referencedTable }, inner => {
inner.on(`${rootTable}.${rootColumn}`, `${alias}.${referencedColumn}`);
if (on) {
for (const key in on) {
inner.onVal(`${alias}.${key}`, on[key]);
}
}
});
if (orderBy) {
Object.keys(orderBy).forEach(column => {
const direction = orderBy[column];
qb.orderBy(`${alias}.${column}`, direction);
});
}
};
const applyJoins = (qb, joins) => joins.forEach(join => applyJoin(qb, join));
module.exports = {
createJoin,
createPivotJoin,
applyJoins,
applyJoin,
};

View File

@ -0,0 +1,60 @@
'use strict';
const _ = require('lodash/fp');
const types = require('../../types');
const { createJoin } = require('./join');
// TODO: convert field names to columns names
const processOrderBy = (orderBy, ctx) => {
const { db, uid, qb, alias = qb.alias } = ctx;
if (typeof orderBy === 'string') {
const attribute = db.metadata.get(uid).attributes[orderBy];
if (!attribute) {
throw new Error(`Attribute ${orderBy} not found on model ${uid}`);
}
return [{ column: `${alias}.${orderBy}` }];
}
if (Array.isArray(orderBy)) {
return orderBy.flatMap(value => processOrderBy(value, ctx));
}
if (_.isPlainObject(orderBy)) {
return Object.entries(orderBy).flatMap(([key, direction]) => {
const value = orderBy[key];
const attribute = db.metadata.get(uid).attributes[key];
if (!attribute) {
throw new Error(`Attribute ${key} not found on model ${uid}`);
}
if (attribute.type === 'relation') {
// TODO: pass down some filters (e.g published at)
const subAlias = createJoin(ctx, { alias, uid, attributeName: key, attribute });
return processOrderBy(value, {
db,
qb,
alias: subAlias,
uid: attribute.target,
});
}
if (types.isScalar(attribute.type)) {
return { column: `${alias}.${key}`, order: direction };
}
throw new Error(`You cannot order on ${attribute.type} types`);
});
}
throw new Error('Invalid orderBy syntax');
};
module.exports = {
processOrderBy,
};

View File

@ -2,450 +2,22 @@
const _ = require('lodash/fp');
const types = require('../types');
const { createField } = require('../fields');
const types = require('../../types');
const { fromRow } = require('./transform');
const GROUP_OPERATORS = ['$and', '$or'];
const OPERATORS = [
'$not',
'$in',
'$notIn',
'$eq',
'$ne',
'$gt',
'$gte',
'$lt',
'$lte',
'$null',
'$notNull',
'$between',
// '$like',
// '$regexp',
'$startsWith',
'$endsWith',
'$contains',
'$notContains',
];
const getRootLevelPopulate = meta => {
const populate = {};
const ARRAY_OPERATORS = ['$in', '$notIn', '$between'];
const createPivotJoin = (qb, joinTable, alias, tragetMeta) => {
const joinAlias = qb.getAlias();
qb.join({
alias: joinAlias,
referencedTable: joinTable.name,
referencedColumn: joinTable.joinColumn.name,
rootColumn: joinTable.joinColumn.referencedColumn,
rootTable: alias,
on: joinTable.on,
});
const subAlias = qb.getAlias();
qb.join({
alias: subAlias,
referencedTable: tragetMeta.tableName,
referencedColumn: joinTable.inverseJoinColumn.referencedColumn,
rootColumn: joinTable.inverseJoinColumn.name,
rootTable: joinAlias,
});
return subAlias;
};
const createJoin = (ctx, { alias, attributeName, attribute }) => {
const { db, qb } = ctx;
if (attribute.type !== 'relation') {
throw new Error(`Cannot join on non relational field ${attributeName}`);
}
const tragetMeta = db.metadata.get(attribute.target);
const joinColumn = attribute.joinColumn;
if (joinColumn) {
const subAlias = qb.getAlias();
qb.join({
alias: subAlias,
referencedTable: tragetMeta.tableName,
referencedColumn: joinColumn.referencedColumn,
rootColumn: joinColumn.name,
rootTable: alias,
});
return subAlias;
}
const joinTable = attribute.joinTable;
if (joinTable) {
return createPivotJoin(qb, joinTable, alias, tragetMeta);
}
// NOTE: using the joinColumn / joinTable syntax we don't really care about the relation type here
switch (attribute.relation) {
case 'oneToOne': {
break;
}
case 'oneToMany': {
break;
}
case 'manyToOne': {
break;
}
case 'manyToMany': {
break;
}
// TODO: polymorphic relations
// TODO: components -> they are converted to relation so not needed either
}
return alias;
};
// TODO: convert field names to columns names
const processOrderBy = (orderBy, ctx) => {
const { db, uid, qb, alias = qb.alias } = ctx;
if (typeof orderBy === 'string') {
const attribute = db.metadata.get(uid).attributes[orderBy];
if (!attribute) {
throw new Error(`Attribute ${orderBy} not found on model ${uid}`);
}
return [{ column: `${alias}.${orderBy}` }];
}
if (Array.isArray(orderBy)) {
return orderBy.flatMap(value => processOrderBy(value, ctx));
}
if (_.isPlainObject(orderBy)) {
return Object.entries(orderBy).flatMap(([key, direction]) => {
const value = orderBy[key];
const attribute = db.metadata.get(uid).attributes[key];
if (!attribute) {
throw new Error(`Attribute ${key} not found on model ${uid}`);
}
if (attribute.type === 'relation') {
// TODO: pass down some filters (e.g published at)
const subAlias = createJoin(ctx, { alias, uid, attributeName: key, attribute });
return processOrderBy(value, {
db,
qb,
alias: subAlias,
uid: attribute.target,
});
}
if (types.isScalar(attribute.type)) {
return { column: `${alias}.${key}`, order: direction };
}
throw new Error(`You cannot order on ${attribute.type} types`);
});
}
throw new Error('Invalid orderBy syntax');
};
const isOperator = key => OPERATORS.includes(key);
const processWhere = (where, ctx, depth = 0) => {
if (depth === 0 && !_.isPlainObject(where)) {
throw new Error('Where must be an object');
}
const processNested = (where, ctx) => {
if (!_.isPlainObject(where)) {
return where;
}
return processWhere(where, ctx, depth + 1);
};
const { db, uid, qb, alias = qb.alias } = ctx;
const filters = {};
// for each key in where
for (const key in where) {
const value = where[key];
const attribute = db.metadata.get(uid).attributes[key];
// if operator $and $or then loop over them
if (GROUP_OPERATORS.includes(key)) {
filters[key] = value.map(sub => processNested(sub, ctx));
continue;
}
if (key === '$not') {
filters[key] = processNested(value, ctx);
continue;
}
if (isOperator(key)) {
if (depth == 0) {
throw new Error(
`Only $and, $or and $not can by used as root level operators. Found ${key}.`
);
}
filters[key] = processNested(value, ctx);
continue;
}
if (!attribute) {
// TODO: if targeting a column name instead of an attribute
// if key as an alias don't add one
if (key.indexOf('.') >= 0) {
filters[key] = processNested(value, ctx);
} else {
filters[`${alias || qb.alias}.${key}`] = processNested(value, ctx);
}
continue;
// throw new Error(`Attribute ${key} not found on model ${uid}`);
}
// move to if else to check for scalar / relation / components & throw for other types
for (const attributeName in meta.attributes) {
const attribute = meta.attributes[attributeName];
if (attribute.type === 'relation') {
// TODO: pass down some filters (e.g published at)
// attribute
const subAlias = createJoin(ctx, { alias, uid, attributeName: key, attribute });
let nestedWhere = processNested(value, {
db,
qb,
alias: subAlias,
uid: attribute.target,
});
if (!_.isPlainObject(nestedWhere) || isOperator(_.keys(nestedWhere)[0])) {
nestedWhere = { [`${subAlias}.id`]: nestedWhere };
}
// TODO: use a better merge logic (push to $and when collisions)
Object.assign(filters, nestedWhere);
continue;
populate[attributeName] = true;
}
if (types.isScalar(attribute.type)) {
// TODO: convert attribute name to column name
// TODO: cast to DB type
filters[`${alias || qb.alias}.${key}`] = processNested(value, ctx);
continue;
}
throw new Error(`You cannot filter on ${attribute.type} types`);
}
return filters;
return populate;
};
const applyOperator = (qb, column, operator, value) => {
if (Array.isArray(value) && !ARRAY_OPERATORS.includes(operator)) {
return qb.where(subQB => {
value.forEach(subValue =>
subQB.orWhere(innerQB => {
applyOperator(innerQB, column, operator, subValue);
})
);
});
}
switch (operator) {
case '$not': {
qb.whereNot(qb => applyWhereToColumn(qb, column, value));
break;
}
case '$in': {
qb.whereIn(column, _.castArray(value));
break;
}
case '$notIn': {
qb.whereNotIn(column, _.castArray(value));
break;
}
case '$eq': {
if (value === null) {
qb.whereNull(column);
break;
}
qb.where(column, value);
break;
}
case '$ne': {
if (value === null) {
qb.whereNotNull(column);
break;
}
qb.where(column, '<>', value);
break;
}
case '$gt': {
qb.where(column, '>', value);
break;
}
case '$gte': {
qb.where(column, '>=', value);
break;
}
case '$lt': {
qb.where(column, '<', value);
break;
}
case '$lte': {
qb.where(column, '<=', value);
break;
}
case '$null': {
// TODO: make this better
if (value) {
qb.whereNull(column);
}
break;
}
case '$notNull': {
if (value) {
qb.whereNotNull(column);
}
break;
}
case '$between': {
qb.whereBetween(column, value);
break;
}
// case '$regexp': {
// // TODO:
//
// break;
// }
// // string
// // TODO: use $case to make it case insensitive
// case '$like': {
// qb.where(column, 'like', value);
// break;
// }
// TODO: add casting logic
case '$startsWith': {
qb.where(column, 'like', `${value}%`);
break;
}
case '$endsWith': {
qb.where(column, 'like', `%${value}`);
break;
}
case '$contains': {
// TODO: handle insensitive
qb.where(column, 'like', `%${value}%`);
break;
}
case '$notContains': {
// TODO: handle insensitive
qb.whereNot(column, 'like', `%${value}%`);
break;
}
// TODO: json operators
// TODO: relational operators every/some/exists/size ...
default: {
throw new Error(`Undefined operator ${operator}`);
}
}
};
const applyWhereToColumn = (qb, column, columnWhere) => {
if (!_.isPlainObject(columnWhere)) {
if (Array.isArray(columnWhere)) {
return qb.whereIn(column, columnWhere);
}
return qb.where(column, columnWhere);
}
// TODO: Transform into if has($in, value) then to handle cases with two keys doing one thing (like $contains with $case)
Object.keys(columnWhere).forEach(operator => {
const value = columnWhere[operator];
applyOperator(qb, column, operator, value);
});
};
const applyWhere = (qb, where) => {
if (Array.isArray(where)) {
return qb.where(subQB => where.forEach(subWhere => applyWhere(subQB, subWhere)));
}
if (!_.isPlainObject(where)) {
throw new Error('Where must be an object');
}
Object.keys(where).forEach(key => {
const value = where[key];
if (key === '$and') {
return qb.where(subQB => {
value.forEach(v => applyWhere(subQB, v));
});
}
if (key === '$or') {
return qb.where(subQB => {
value.forEach(v => subQB.orWhere(inner => applyWhere(inner, v)));
});
}
if (key === '$not') {
return qb.whereNot(qb => applyWhere(qb, value));
}
applyWhereToColumn(qb, key, value);
});
};
// TODO: allow for more conditions
const applyJoin = (qb, join) => {
const {
method = 'leftJoin',
alias,
referencedTable,
referencedColumn,
rootColumn,
rootTable = this.alias,
on,
} = join;
qb[method]({ [alias]: referencedTable }, inner => {
inner.on(`${rootTable}.${rootColumn}`, `${alias}.${referencedColumn}`);
if (on) {
for (const key in on) {
inner.onVal(`${alias}.${key}`, on[key]);
}
}
});
};
const applyJoins = (qb, joins) => joins.forEach(join => applyJoin(qb, join));
/**
* Converts and prepares the query for populate
*
@ -465,7 +37,9 @@ const processPopulate = (populate, ctx) => {
return null;
}
if (Array.isArray(populate)) {
if (populate === true) {
populateMap = getRootLevelPopulate(meta);
} else if (Array.isArray(populate)) {
for (const key of populate) {
const [root, ...rest] = key.split('.');
@ -502,17 +76,17 @@ const processPopulate = (populate, ctx) => {
const attribute = meta.attributes[key];
if (!attribute) {
// NOTE: we could continue to allow having different populate depending on the type (polymorphic)
continue;
// throw new Error(`Cannot populate unknown field ${key}`);
}
if (!types.isRelation(attribute.type)) {
throw new Error(`Invalid populate field. Expected a relation, got ${attribute.type}`);
}
// TODO: make sure necessary columns are present for future populate queries
qb.addSelect('id');
// make sure id is present for future populate queries
if (_.has('id', meta.attributes)) {
qb.addSelect('id');
}
finalPopulate[key] = populateMap[key];
}
@ -520,11 +94,15 @@ const processPopulate = (populate, ctx) => {
return finalPopulate;
};
const applyPopulate = async (results, populate, ctx) => {
// TODO: cleanup code
// TODO: create aliases for pivot columns
// TODO: optimize depth to avoid overfetching
// Omit limit & offset to avoid needing a query per result to avoid making too many queries
const pickPopulateParams = _.pick(['select', 'count', 'where', 'populate', 'orderBy']);
// TODO: cleanup code
// TODO: create aliases for pivot columns
// TODO: optimize depth to avoid overfetching
// TODO: handle count for join columns
// TODO: cleanup count
const applyPopulate = async (results, populate, ctx) => {
const { db, uid, qb } = ctx;
const meta = db.metadata.get(uid);
@ -533,20 +111,12 @@ const applyPopulate = async (results, populate, ctx) => {
}
for (const key in populate) {
// NOTE: Omit limit & offset to avoid needing a query per result to avoid making too many queries
const populateValue = _.pick(
['select', 'count', 'where', 'populate', 'orderBy'],
populate[key]
);
// TODO: handle count for join columns
// TODO: cleanup count
const isCount = populateValue.count === true;
const attribute = meta.attributes[key];
const targetMeta = db.metadata.get(attribute.target);
const populateValue = pickPopulateParams(populate[key]);
const isCount = populateValue.count === true;
const fromTargetRow = rowOrRows => fromRow(targetMeta, rowOrRows);
if (attribute.relation === 'oneToOne' || attribute.relation === 'manyToOne') {
@ -617,6 +187,7 @@ const applyPopulate = async (results, populate, ctx) => {
rootColumn: joinTable.inverseJoinColumn.referencedColumn,
rootTable: qb.alias,
on: joinTable.on,
orderBy: joinTable.orderBy,
})
.addSelect(joinColAlias)
.where({ [joinColAlias]: referencedValues })
@ -734,6 +305,7 @@ const applyPopulate = async (results, populate, ctx) => {
rootColumn: joinTable.inverseJoinColumn.referencedColumn,
rootTable: qb.alias,
on: joinTable.on,
orderBy: joinTable.orderBy,
})
.addSelect(joinColAlias)
.where({ [joinColAlias]: referencedValues })
@ -812,6 +384,7 @@ const applyPopulate = async (results, populate, ctx) => {
rootColumn: joinTable.inverseJoinColumn.referencedColumn,
rootTable: qb.alias,
on: joinTable.on,
orderBy: joinTable.orderBy,
})
.addSelect(joinColAlias)
.where({ [joinColAlias]: referencedValues })
@ -893,7 +466,11 @@ const applyPopulate = async (results, populate, ctx) => {
referencedColumn: joinColumn.name,
rootColumn: joinColumn.referencedColumn,
rootTable: qb.alias,
on: joinTable.on,
on: {
...(joinTable.on || {}),
field: key,
},
orderBy: joinTable.orderBy,
})
.addSelect([`${alias}.${idColumn.name}`, `${alias}.${typeColumn.name}`])
.where({
@ -1049,121 +626,7 @@ const applyPopulate = async (results, populate, ctx) => {
}
};
const fromRow = (metadata, row) => {
if (Array.isArray(row)) {
return row.map(singleRow => fromRow(metadata, singleRow));
}
const { attributes } = metadata;
if (_.isNil(row)) {
return null;
}
const obj = {};
for (const column in row) {
// to field Name
const attributeName = column;
if (!attributes[attributeName]) {
// ignore value that are not related to an attribute (join columns ...)
continue;
}
const attribute = attributes[attributeName];
if (types.isScalar(attribute.type)) {
// TODO: we convert to column name
// TODO: handle default value too
// TODO: format data & use dialect to know which type they support (json particularly)
const field = createField(attribute.type, attribute);
// TODO: validate data on creation
// field.validate(data[attributeName]);
const val = row[column] === null ? null : field.fromDB(row[column]);
obj[attributeName] = val;
}
if (types.isRelation(attribute.type)) {
obj[attributeName] = row[column];
}
}
return obj;
};
const applySearch = (qb, query, ctx) => {
const { alias, uid, db } = ctx;
const { attributes } = db.metadata.get(uid);
const searchColumns = ['id'];
const stringColumns = Object.keys(attributes).filter(attributeName => {
const attribute = attributes[attributeName];
return types.isString(attribute.type) && attribute.searchable !== false;
});
searchColumns.push(...stringColumns);
if (!_.isNaN(_.toNumber(query))) {
const numberColumns = Object.keys(attributes).filter(attributeName => {
const attribute = attributes[attributeName];
return types.isNumber(attribute.type) && attribute.searchable !== false;
});
searchColumns.push(...numberColumns);
}
switch (db.dialect.client) {
case 'postgres': {
searchColumns.forEach(attr =>
qb.orWhereRaw(`"${alias}"."${attr}"::text ILIKE ?`, `%${escapeQuery(query, '*%\\')}%`)
);
break;
}
case 'sqlite': {
searchColumns.forEach(attr =>
qb.orWhereRaw(`"${alias}"."${attr}" LIKE ? ESCAPE '\\'`, `%${escapeQuery(query, '*%\\')}%`)
);
break;
}
case 'mysql': {
searchColumns.forEach(attr =>
qb.orWhereRaw(`\`${alias}\`.\`${attr}\` LIKE ?`, `%${escapeQuery(query, '*%\\')}%`)
);
break;
}
default: {
// do nothing
}
}
};
const escapeQuery = (query, charsToEscape, escapeChar = '\\') => {
return query
.split('')
.reduce(
(escapedQuery, char) =>
charsToEscape.includes(char)
? `${escapedQuery}${escapeChar}${char}`
: `${escapedQuery}${char}`,
''
);
};
module.exports = {
applyWhere,
processWhere,
applyJoins,
applyJoin,
processOrderBy,
processPopulate,
applySearch,
applyPopulate,
fromRow,
};

View File

@ -0,0 +1,70 @@
'use strict';
const _ = require('lodash/fp');
const types = require('../../types');
const applySearch = (qb, query, ctx) => {
const { alias, uid, db } = ctx;
const { attributes } = db.metadata.get(uid);
const searchColumns = ['id'];
const stringColumns = Object.keys(attributes).filter(attributeName => {
const attribute = attributes[attributeName];
return types.isString(attribute.type) && attribute.searchable !== false;
});
searchColumns.push(...stringColumns);
if (!_.isNaN(_.toNumber(query))) {
const numberColumns = Object.keys(attributes).filter(attributeName => {
const attribute = attributes[attributeName];
return types.isNumber(attribute.type) && attribute.searchable !== false;
});
searchColumns.push(...numberColumns);
}
switch (db.dialect.client) {
case 'postgres': {
searchColumns.forEach(attr =>
qb.orWhereRaw(`"${alias}"."${attr}"::text ILIKE ?`, `%${escapeQuery(query, '*%\\')}%`)
);
break;
}
case 'sqlite': {
searchColumns.forEach(attr =>
qb.orWhereRaw(`"${alias}"."${attr}" LIKE ? ESCAPE '\\'`, `%${escapeQuery(query, '*%\\')}%`)
);
break;
}
case 'mysql': {
searchColumns.forEach(attr =>
qb.orWhereRaw(`\`${alias}\`.\`${attr}\` LIKE ?`, `%${escapeQuery(query, '*%\\')}%`)
);
break;
}
default: {
// do nothing
}
}
};
const escapeQuery = (query, charsToEscape, escapeChar = '\\') => {
return query
.split('')
.reduce(
(escapedQuery, char) =>
charsToEscape.includes(char)
? `${escapedQuery}${escapeChar}${char}`
: `${escapedQuery}${char}`,
''
);
};
module.exports = {
applySearch,
};

View File

@ -0,0 +1,56 @@
'use strict';
const _ = require('lodash/fp');
const types = require('../../types');
const { createField } = require('../../fields');
const fromRow = (metadata, row) => {
if (Array.isArray(row)) {
return row.map(singleRow => fromRow(metadata, singleRow));
}
const { attributes } = metadata;
if (_.isNil(row)) {
return null;
}
const obj = {};
for (const column in row) {
// to field Name
const attributeName = column;
if (!attributes[attributeName]) {
// ignore value that are not related to an attribute (join columns ...)
continue;
}
const attribute = attributes[attributeName];
if (types.isScalar(attribute.type)) {
// TODO: we convert to column name
// TODO: handle default value too
// TODO: format data & use dialect to know which type they support (json particularly)
const field = createField(attribute);
// TODO: validate data on creation
// field.validate(data[attributeName]);
const val = row[column] === null ? null : field.fromDB(row[column]);
obj[attributeName] = val;
}
if (types.isRelation(attribute.type)) {
obj[attributeName] = row[column];
}
}
return obj;
};
module.exports = {
fromRow,
};

View File

@ -0,0 +1,328 @@
'use strict';
const _ = require('lodash/fp');
const types = require('../../types');
const { createJoin } = require('./join');
const GROUP_OPERATORS = ['$and', '$or'];
const OPERATORS = [
'$not',
'$in',
'$notIn',
'$eq',
'$ne',
'$gt',
'$gte',
'$lt',
'$lte',
'$null',
'$notNull',
'$between',
// '$like',
// '$regexp',
'$startsWith',
'$endsWith',
'$contains',
'$notContains',
'$containsi',
'$notContainsi',
];
const ARRAY_OPERATORS = ['$in', '$notIn', '$between'];
const isOperator = key => OPERATORS.includes(key);
/**
* Process where parameter
* @param {Object} where
* @param {Object} ctx
* @param {number} depth
* @returns {Object}
*/
const processWhere = (where, ctx, depth = 0) => {
if (depth === 0 && !_.isPlainObject(where)) {
throw new Error('Where must be an object');
}
const processNested = (where, ctx) => {
if (!_.isPlainObject(where)) {
return where;
}
return processWhere(where, ctx, depth + 1);
};
const { db, uid, qb, alias = qb.alias } = ctx;
const filters = {};
// for each key in where
for (const key in where) {
const value = where[key];
const attribute = db.metadata.get(uid).attributes[key];
// if operator $and $or then loop over them
if (GROUP_OPERATORS.includes(key)) {
filters[key] = value.map(sub => processNested(sub, ctx));
continue;
}
if (key === '$not') {
filters[key] = processNested(value, ctx);
continue;
}
if (isOperator(key)) {
if (depth == 0) {
throw new Error(
`Only $and, $or and $not can by used as root level operators. Found ${key}.`
);
}
filters[key] = processNested(value, ctx);
continue;
}
if (!attribute) {
// TODO: if targeting a column name instead of an attribute
// if key as an alias don't add one
if (key.indexOf('.') >= 0) {
filters[key] = processNested(value, ctx);
} else {
filters[`${alias || qb.alias}.${key}`] = processNested(value, ctx);
}
continue;
// throw new Error(`Attribute ${key} not found on model ${uid}`);
}
// move to if else to check for scalar / relation / components & throw for other types
if (attribute.type === 'relation') {
// TODO: pass down some filters (e.g published at)
// attribute
const subAlias = createJoin(ctx, { alias, uid, attributeName: key, attribute });
let nestedWhere = processNested(value, {
db,
qb,
alias: subAlias,
uid: attribute.target,
});
if (!_.isPlainObject(nestedWhere) || isOperator(_.keys(nestedWhere)[0])) {
nestedWhere = { [`${subAlias}.id`]: nestedWhere };
}
// TODO: use a better merge logic (push to $and when collisions)
Object.assign(filters, nestedWhere);
continue;
}
if (types.isScalar(attribute.type)) {
// TODO: convert attribute name to column name
// TODO: cast to DB type
filters[`${alias || qb.alias}.${key}`] = processNested(value, ctx);
continue;
}
throw new Error(`You cannot filter on ${attribute.type} types`);
}
return filters;
};
const applyOperator = (qb, column, operator, value) => {
if (Array.isArray(value) && !ARRAY_OPERATORS.includes(operator)) {
return qb.where(subQB => {
value.forEach(subValue =>
subQB.orWhere(innerQB => {
applyOperator(innerQB, column, operator, subValue);
})
);
});
}
switch (operator) {
case '$not': {
qb.whereNot(qb => applyWhereToColumn(qb, column, value));
break;
}
case '$in': {
qb.whereIn(column, _.castArray(value));
break;
}
case '$notIn': {
qb.whereNotIn(column, _.castArray(value));
break;
}
case '$eq': {
if (value === null) {
qb.whereNull(column);
break;
}
qb.where(column, value);
break;
}
case '$ne': {
if (value === null) {
qb.whereNotNull(column);
break;
}
qb.where(column, '<>', value);
break;
}
case '$gt': {
qb.where(column, '>', value);
break;
}
case '$gte': {
qb.where(column, '>=', value);
break;
}
case '$lt': {
qb.where(column, '<', value);
break;
}
case '$lte': {
qb.where(column, '<=', value);
break;
}
case '$null': {
// TODO: make this better
if (value) {
qb.whereNull(column);
}
break;
}
case '$notNull': {
if (value) {
qb.whereNotNull(column);
}
break;
}
case '$between': {
qb.whereBetween(column, value);
break;
}
// case '$regexp': {
// // TODO:
//
// break;
// }
// // string
// // TODO: use $case to make it case insensitive
// case '$like': {
// qb.where(column, 'like', value);
// break;
// }
// TODO: add casting logic
case '$startsWith': {
qb.where(column, 'like', `${value}%`);
break;
}
case '$endsWith': {
qb.where(column, 'like', `%${value}`);
break;
}
case '$contains': {
qb.where(column, 'like', `%${value}%`);
break;
}
case '$notContains': {
qb.whereNot(column, 'like', `%${value}%`);
break;
}
case '$containsi': {
qb.whereRaw(`${fieldLowerFn(qb)} LIKE LOWER(?)`, [column, `%${value}%`]);
break;
}
case '$notContainsi': {
qb.whereRaw(`${fieldLowerFn(qb)} NOT LIKE LOWER(?)`, [column, `%${value}%`]);
break;
}
// TODO: json operators
// TODO: relational operators every/some/exists/size ...
default: {
throw new Error(`Undefined operator ${operator}`);
}
}
};
const applyWhereToColumn = (qb, column, columnWhere) => {
if (!_.isPlainObject(columnWhere)) {
if (Array.isArray(columnWhere)) {
return qb.whereIn(column, columnWhere);
}
return qb.where(column, columnWhere);
}
// TODO: handle casing
Object.keys(columnWhere).forEach(operator => {
const value = columnWhere[operator];
applyOperator(qb, column, operator, value);
});
};
const applyWhere = (qb, where) => {
if (Array.isArray(where)) {
return qb.where(subQB => where.forEach(subWhere => applyWhere(subQB, subWhere)));
}
if (!_.isPlainObject(where)) {
throw new Error('Where must be an object');
}
Object.keys(where).forEach(key => {
const value = where[key];
if (key === '$and') {
return qb.where(subQB => {
value.forEach(v => applyWhere(subQB, v));
});
}
if (key === '$or') {
return qb.where(subQB => {
value.forEach(v => subQB.orWhere(inner => applyWhere(inner, v)));
});
}
if (key === '$not') {
return qb.whereNot(qb => applyWhere(qb, value));
}
applyWhereToColumn(qb, key, value);
});
};
const fieldLowerFn = qb => {
// Postgres requires string to be passed
if (qb.client.config.client === 'pg') {
return 'LOWER(CAST(?? AS VARCHAR))';
}
return 'LOWER(??)';
};
module.exports = {
applyWhere,
processWhere,
};

View File

@ -82,7 +82,7 @@ describe('Default Service', () => {
await service.createOrUpdate(input);
expect(strapi.entityService.find).toHaveBeenCalledWith('testModel', {
params: { publicationState: 'live', limit: defaultLimit },
params: { publicationState: 'live', pagination: { limit: defaultLimit } },
});
expect(strapi.entityService.create).toHaveBeenCalledWith('testModel', { data: input });
@ -111,7 +111,7 @@ describe('Default Service', () => {
expect(strapi.entityService.find).toHaveBeenCalledWith('testModel', {
populate: undefined,
params: { publicationState: 'live', limit: defaultLimit },
params: { publicationState: 'live', pagination: { limit: defaultLimit } },
});
expect(strapi.entityService.update).toHaveBeenCalledWith('testModel', 1, {
@ -138,7 +138,7 @@ describe('Default Service', () => {
expect(strapi.entityService.find).toHaveBeenCalledWith('testModel', {
populate: undefined,
params: { publicationState: 'live', limit: defaultLimit },
params: { publicationState: 'live', pagination: { limit: defaultLimit } },
});
expect(strapi.entityService.delete).toHaveBeenCalledWith('testModel', 1);
@ -172,8 +172,10 @@ describe('getFetchParams', () => {
['1000 if limit=1000 and no max allowed limit is set', { limit: 1000 }, 1000],
])('Sets limit parameter to %s', (description, input, expected) => {
strapi.config.api.rest.maxLimit = input.maxLimit;
expect(getFetchParams({ limit: input.limit })).toMatchObject({
limit: expected,
expect(getFetchParams({ pagination: { limit: input.limit } })).toMatchObject({
pagination: {
limit: expected,
},
});
});
});

View File

@ -35,15 +35,13 @@ const getLimitConfigDefaults = () => ({
maxLimit: _.toNumber(strapi.config.get('api.rest.maxLimit')) || null,
});
const getLimitParam = params => {
const { defaultLimit, maxLimit } = getLimitConfigDefaults();
if (params.limit === undefined) {
return defaultLimit;
}
const limit = _.toNumber(params.limit);
// if there is max limit set and params.limit exceeds this number, return configured max limit
/**
* if there is max limit set and limit exceeds this number, return configured max limit
* @param {number} limit - limit you want to cap
* @param {number?} maxLimit - maxlimit used has capping
* @returns {number}
*/
const applyMaxLimit = (limit, maxLimit) => {
if (maxLimit && (limit === -1 || limit > maxLimit)) {
return maxLimit;
}
@ -51,6 +49,31 @@ const getLimitParam = params => {
return limit;
};
const applyDefaultPagination = params => {
const { defaultLimit, maxLimit } = getLimitConfigDefaults();
if (_.isUndefined(params.pagination) || !_.isPlainObject(params.pagination)) {
return {
limit: defaultLimit,
};
}
const { pagination } = params;
if (!_.isUndefined(pagination.pageSize)) {
return {
page: pagination.page,
pageSize: applyMaxLimit(_.toNumber(pagination.pageSize), maxLimit),
};
}
const limit = _.isUndefined(pagination.limit) ? defaultLimit : _.toNumber(pagination.limit);
return {
start: pagination.start,
limit: applyMaxLimit(limit, maxLimit),
};
};
/**
* Create default fetch params
* @param {*} params
@ -60,7 +83,7 @@ const getFetchParams = (params = {}) => {
return {
publicationState: DP_PUB_STATE_LIVE,
...params,
limit: getLimitParam(params),
pagination: applyDefaultPagination(params),
};
};

View File

@ -71,10 +71,12 @@ const createContentType = (uid, definition) => {
Object.assign(createdContentType.schema.attributes, {
[CREATED_AT_ATTRIBUTE]: {
type: 'datetime',
// default: () => new Date(),
default: () => new Date(),
},
// TODO: handle on edit set to new date
[UPDATED_AT_ATTRIBUTE]: {
type: 'datetime',
default: () => new Date(),
},
});

View File

@ -3,6 +3,7 @@
"enabled": true,
"multipart": true,
"queryStringParser": {
"strictNullHandling": true,
"arrayLimit": 100,
"depth": 20
}

View File

@ -15,32 +15,27 @@ describe('Entity service', () => {
};
describe('Decorator', () => {
test.each([
'create',
'update',
'find',
'findOne',
'delete',
'count',
'findPage',
])('Can decorate', async method => {
const instance = createEntityService({
strapi: {},
db: {},
eventHub: new EventEmitter(),
});
test.each(['create', 'update', 'find', 'findOne', 'delete', 'count', 'findPage'])(
'Can decorate',
async method => {
const instance = createEntityService({
strapi: {},
db: {},
eventHub: new EventEmitter(),
});
const methodFn = jest.fn();
const decorator = () => ({
[method]: methodFn,
});
const methodFn = jest.fn();
const decorator = () => ({
[method]: methodFn,
});
instance.decorate(decorator);
instance.decorate(decorator);
const args = [{}, {}];
await instance[method](...args);
expect(methodFn).toHaveBeenCalled();
});
const args = [{}, {}];
await instance[method](...args);
expect(methodFn).toHaveBeenCalled();
}
);
});
describe('Find', () => {

View File

@ -1,651 +0,0 @@
'use strict';
const _ = require('lodash');
const { has, pick, omit, prop } = require('lodash/fp');
const delegate = require('delegates');
const {
convertSortQueryParams,
convertLimitQueryParams,
convertStartQueryParams,
} = require('@strapi/utils/lib/convert-rest-query-params');
const {
sanitizeEntity,
webhook: webhookUtils,
contentTypes: contentTypesUtils,
relations: relationsUtils,
} = require('@strapi/utils');
const { MANY_RELATIONS } = relationsUtils.constants;
const { PUBLISHED_AT_ATTRIBUTE } = contentTypesUtils.constants;
const uploadFiles = require('./utils/upload-files');
// 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 omitComponentData = (contentType, data) => {
const { attributes } = contentType;
const componentAttributes = Object.keys(attributes).filter(attributeName =>
contentTypesUtils.isComponentAttribute(attributes[attributeName])
);
return omit(componentAttributes, data);
};
module.exports = ctx => {
const implementation = createDefaultImplementation(ctx);
const service = {
implementation,
decorate(decorator) {
if (typeof decorator !== 'function') {
throw new Error(`Decorator must be a function, received ${typeof decorator}`);
}
this.implementation = Object.assign({}, this.implementation, decorator(this.implementation));
return this;
},
};
const delegator = delegate(service, 'implementation');
// delegate every method in implementation
Object.keys(service.implementation).forEach(key => delegator.method(key));
return service;
};
// TODO: remove once the front is migrated
const convertOldQuery = params => {
const obj = {};
Object.keys(params).forEach(key => {
if (key.startsWith('_')) {
obj[key.slice(1)] = params[key];
} else {
obj[key] = params[key];
}
});
return obj;
};
// TODO: move to Controller ?
const transformParamsToQuery = (uid, params = {}) => {
const model = strapi.getModel(uid);
const query = {};
// TODO: check invalid values add defaults ....
const {
start,
page,
pageSize,
limit,
sort,
filters,
fields,
populate,
publicationState,
_q,
_where,
...rest
} = params;
if (_q) {
query._q = _q;
}
if (page) {
query.page = Number(page);
}
if (pageSize) {
query.pageSize = Number(pageSize);
}
if (start) {
query.offset = convertStartQueryParams(start);
}
if (limit) {
query.limit = convertLimitQueryParams(limit);
}
if (sort) {
query.orderBy = convertSortQueryParams(sort);
}
if (filters) {
query.where = filters;
}
if (_where) {
query.where = {
$and: [_where].concat(query.where || []),
};
}
if (fields) {
query.select = _.castArray(fields);
}
if (populate) {
const { populate } = params;
query.populate = typeof populate === 'object' ? populate : _.castArray(populate);
}
// TODO: move to layer above ?
if (publicationState && contentTypesUtils.hasDraftAndPublish(model)) {
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
}
}
const finalQuery = {
...convertOldQuery(rest),
...query,
};
return finalQuery;
};
const pickSelectionParams = pick(['fields', 'populate']);
const createDefaultImplementation = ({ strapi, db, eventHub, entityValidator }) => ({
uploadFiles,
async wrapOptions(options = {}) {
return options;
},
emitEvent(uid, event, entity) {
const model = strapi.getModel(uid);
eventHub.emit(event, {
model: model.modelName,
entry: sanitizeEntity(entity, { model }),
});
},
async find(uid, opts) {
const { kind } = strapi.getModel(uid);
const { params } = await this.wrapOptions(opts, { uid, action: 'find' });
const query = transformParamsToQuery(uid, params);
// return first element and ignore filters
if (kind === 'singleType') {
return db.query(uid).findOne({});
}
return db.query(uid).findMany(query);
},
async findPage(uid, opts) {
const { params } = await this.wrapOptions(opts, { uid, action: 'findPage' });
const query = transformParamsToQuery(uid, params);
return db.query(uid).findPage(query);
},
async findWithRelationCounts(uid, opts) {
const model = strapi.getModel(uid);
const { params } = await this.wrapOptions(opts, { uid, action: 'findWithRelationCounts' });
const query = transformParamsToQuery(uid, params);
const { attributes } = model;
const populate = (query.populate || []).reduce((populate, attributeName) => {
const attribute = attributes[attributeName];
if (
MANY_RELATIONS.includes(attribute.relation) &&
contentTypesUtils.isVisibleAttribute(model, attributeName)
) {
populate[attributeName] = { count: true };
} else {
populate[attributeName] = true;
}
return populate;
}, {});
const { results, pagination } = await db.query(uid).findPage({
...query,
populate,
});
return {
results,
pagination,
};
},
async findOne(uid, entityId, opts) {
const { params } = await this.wrapOptions(opts, { uid, action: 'findOne' });
const query = transformParamsToQuery(uid, pickSelectionParams(params));
return db.query(uid).findOne({ ...query, where: { id: entityId } });
},
async count(uid, opts) {
const { params } = await this.wrapOptions(opts, { uid, action: 'count' });
const query = transformParamsToQuery(uid, params);
return db.query(uid).count(query);
},
async create(uid, opts) {
const { params, data, files } = await this.wrapOptions(opts, { uid, action: 'create' });
const model = strapi.getModel(uid);
const isDraft = contentTypesUtils.isDraft(data, model);
const validData = await entityValidator.validateEntityCreation(model, data, { isDraft });
// select / populate
const query = transformParamsToQuery(uid, pickSelectionParams(params));
// TODO: wrap into transaction
const componentData = await createComponents(uid, validData);
let entity = await db.query(uid).create({
...query,
data: Object.assign(omitComponentData(model, validData), componentData),
});
// TODO: upload the files then set the links in the entity like with compo to avoid making too many queries
if (files && Object.keys(files).length > 0) {
await this.uploadFiles(uid, entity, files);
entity = await this.findOne(uid, entity.id, { params });
}
this.emitEvent(uid, ENTRY_CREATE, entity);
return entity;
},
async update(uid, entityId, opts) {
const { params, data, files } = await this.wrapOptions(opts, { uid, action: 'update' });
const model = strapi.getModel(uid);
const entityToUpdate = await db.query(uid).findOne({ where: { id: entityId } });
if (!entityToUpdate) {
return null;
}
const isDraft = contentTypesUtils.isDraft(entityToUpdate, model);
const validData = await entityValidator.validateEntityUpdate(model, data, {
isDraft,
});
const query = transformParamsToQuery(uid, pickSelectionParams(params));
// TODO: wrap in transaction
const componentData = await updateComponents(uid, entityToUpdate, validData);
let entity = await db.query(uid).update({
...query,
where: { id: entityId },
data: Object.assign(omitComponentData(model, validData), componentData),
});
// TODO: upload the files then set the links in the entity like with compo to avoid making too many queries
if (files && Object.keys(files).length > 0) {
await this.uploadFiles(uid, entity, files);
entity = await this.findOne(uid, entity.id, { params });
}
this.emitEvent(uid, ENTRY_UPDATE, entity);
return entity;
},
async delete(uid, entityId, opts) {
const { params } = await this.wrapOptions(opts, { uid, action: 'delete' });
// select / populate
const query = transformParamsToQuery(uid, pickSelectionParams(params));
const entityToDelete = await db.query(uid).findOne({
...query,
where: { id: entityId },
});
if (!entityToDelete) {
return null;
}
await deleteComponents(uid, entityToDelete);
await db.query(uid).delete({ where: { id: entityToDelete.id } });
this.emitEvent(uid, ENTRY_DELETE, entityToDelete);
return entityToDelete;
},
async deleteMany(uid, opts) {
const { params } = await this.wrapOptions(opts, { uid, action: 'delete' });
// select / populate
const query = transformParamsToQuery(uid, params);
return db.query(uid).deleteMany(query);
},
});
// components can have nested compos so this must be recursive
const createComponent = async (uid, data) => {
const model = strapi.getModel(uid);
const componentData = await createComponents(uid, data);
return await strapi.query(uid).create({
data: Object.assign(omitComponentData(model, data), componentData),
});
};
// components can have nested compos so this must be recursive
const updateComponent = async (uid, componentToUpdate, data) => {
const model = strapi.getModel(uid);
const componentData = await updateComponents(uid, componentToUpdate, data);
return await strapi.query(uid).update({
where: {
id: componentToUpdate.id,
},
data: Object.assign(omitComponentData(model, data), componentData),
});
};
// NOTE: we could generalize the logic to allow CRUD of relation directly in the DB layer
const createComponents = async (uid, data) => {
const { attributes } = strapi.getModel(uid);
const componentBody = {};
for (const attributeName in attributes) {
const attribute = attributes[attributeName];
if (!has(attributeName, data)) {
continue;
}
if (attribute.type === 'component') {
const { component: componentUID, repeatable = false } = attribute;
const componentValue = data[attributeName];
if (componentValue === null) {
continue;
}
if (repeatable === true) {
if (!Array.isArray(componentValue)) {
throw new Error('Expected an array to create repeatable component');
}
const components = await Promise.all(
componentValue.map(value => createComponent(componentUID, value))
);
// TODO: add order
componentBody[attributeName] = components.map(({ id }) => id);
} else {
const component = await createComponent(componentUID, componentValue);
componentBody[attributeName] = component.id;
}
continue;
}
if (attribute.type === 'dynamiczone') {
const dynamiczoneValues = data[attributeName];
if (!Array.isArray(dynamiczoneValues)) {
throw new Error('Expected an array to create repeatable component');
}
componentBody[attributeName] = await Promise.all(
dynamiczoneValues.map(async value => {
const { id } = await createComponent(value.__component, value);
return { id, __component: value.__component };
})
);
continue;
}
}
return componentBody;
};
const updateOrCreateComponent = (componentUID, value) => {
if (value === null) {
return null;
}
// update
if (has('id', value)) {
// TODO: verify the compo is associated with the entity
return updateComponent(componentUID, { id: value.id }, value);
}
// create
return createComponent(componentUID, value);
};
/*
delete old components
create or update
*/
const updateComponents = async (uid, entityToUpdate, data) => {
const { attributes } = strapi.getModel(uid);
const componentBody = {};
for (const attributeName in attributes) {
const attribute = attributes[attributeName];
if (!has(attributeName, data)) {
continue;
}
if (attribute.type === 'component') {
const { component: componentUID, repeatable = false } = attribute;
const componentValue = data[attributeName];
await deleteOldComponents(uid, componentUID, entityToUpdate, attributeName, componentValue);
if (repeatable === true) {
if (!Array.isArray(componentValue)) {
throw new Error('Expected an array to create repeatable component');
}
const components = await Promise.all(
componentValue.map(value => updateOrCreateComponent(componentUID, value))
);
// TODO: add order
componentBody[attributeName] = components.filter(_.negate(_.isNil)).map(({ id }) => id);
} else {
const component = await updateOrCreateComponent(componentUID, componentValue);
componentBody[attributeName] = component && component.id;
}
continue;
}
if (attribute.type === 'dynamiczone') {
const dynamiczoneValues = data[attributeName];
await deleteOldDZComponents(uid, entityToUpdate, attributeName, dynamiczoneValues);
if (!Array.isArray(dynamiczoneValues)) {
throw new Error('Expected an array to create repeatable component');
}
componentBody[attributeName] = await Promise.all(
dynamiczoneValues.map(async value => {
const { id } = await updateOrCreateComponent(value.__component, value);
return { id, __component: value.__component };
})
);
continue;
}
}
return componentBody;
};
const deleteOldComponents = async (
uid,
componentUID,
entityToUpdate,
attributeName,
componentValue
) => {
const previousValue = await strapi.query(uid).load(entityToUpdate, attributeName);
const idsToKeep = _.castArray(componentValue)
.filter(has('id'))
.map(prop('id'));
const allIds = _.castArray(previousValue)
.filter(has('id'))
.map(prop('id'));
idsToKeep.forEach(id => {
if (!allIds.includes(id)) {
const err = new Error(
`Some of the provided components in ${attributeName} are not related to the entity`
);
err.status = 400;
throw err;
}
});
const idsToDelete = _.difference(allIds, idsToKeep);
if (idsToDelete.length > 0) {
for (const idToDelete of idsToDelete) {
await deleteComponent(componentUID, { id: idToDelete });
}
}
};
const deleteOldDZComponents = async (uid, entityToUpdate, attributeName, dynamiczoneValues) => {
const previousValue = await strapi.query(uid).load(entityToUpdate, attributeName);
const idsToKeep = _.castArray(dynamiczoneValues)
.filter(has('id'))
.map(({ id, __component }) => ({
id,
__component,
}));
const allIds = _.castArray(previousValue)
.filter(has('id'))
.map(({ id, __component }) => ({
id,
__component,
}));
idsToKeep.forEach(({ id, __component }) => {
if (!allIds.find(el => el.id === id && el.__component === __component)) {
const err = new Error(
`Some of the provided components in ${attributeName} are not related to the entity`
);
err.status = 400;
throw err;
}
});
const idsToDelete = allIds.reduce((acc, { id, __component }) => {
if (!idsToKeep.find(el => el.id === id && el.__component === __component)) {
acc.push({ id, __component });
}
return acc;
}, []);
if (idsToDelete.length > 0) {
for (const idToDelete of idsToDelete) {
const { id, __component } = idToDelete;
await deleteComponent(__component, { id });
}
}
};
const deleteComponent = async (uid, componentToDelete) => {
await deleteComponents(uid, componentToDelete);
await strapi.query(uid).delete({ where: { id: componentToDelete.id } });
};
const deleteComponents = async (uid, entityToDelete) => {
const { attributes } = strapi.getModel(uid);
for (const attributeName in attributes) {
const attribute = attributes[attributeName];
if (attribute.type === 'component') {
const { component: componentUID } = attribute;
const value = await strapi.query(uid).load(entityToDelete, attributeName);
if (!value) {
continue;
}
if (Array.isArray(value)) {
await Promise.all(value.map(subValue => deleteComponent(componentUID, subValue)));
} else {
await deleteComponent(componentUID, value);
}
continue;
}
if (attribute.type === 'dynamiczone') {
const value = await strapi.query(uid).load(entityToDelete, attributeName);
if (!value) {
continue;
}
if (Array.isArray(value)) {
await Promise.all(value.map(subValue => deleteComponent(subValue.__component, subValue)));
}
continue;
}
}
};

View File

@ -0,0 +1,340 @@
'use strict';
const _ = require('lodash');
const { has, prop, omit } = require('lodash/fp');
const { contentTypes: contentTypesUtils } = require('@strapi/utils');
const omitComponentData = (contentType, data) => {
const { attributes } = contentType;
const componentAttributes = Object.keys(attributes).filter(attributeName =>
contentTypesUtils.isComponentAttribute(attributes[attributeName])
);
return omit(componentAttributes, data);
};
// NOTE: we could generalize the logic to allow CRUD of relation directly in the DB layer
const createComponents = async (uid, data) => {
const { attributes } = strapi.getModel(uid);
const componentBody = {};
for (const attributeName in attributes) {
const attribute = attributes[attributeName];
if (!has(attributeName, data) || !contentTypesUtils.isComponentAttribute(attribute)) {
continue;
}
if (attribute.type === 'component') {
const { component: componentUID, repeatable = false } = attribute;
const componentValue = data[attributeName];
if (componentValue === null) {
continue;
}
if (repeatable === true) {
if (!Array.isArray(componentValue)) {
throw new Error('Expected an array to create repeatable component');
}
const components = await Promise.all(
componentValue.map(value => createComponent(componentUID, value))
);
// TODO: add order
componentBody[attributeName] = components.map(({ id }, idx) => {
return {
id,
__pivot: {
order: idx + 1,
field: attributeName,
component_type: componentUID,
},
};
});
} else {
const component = await createComponent(componentUID, componentValue);
componentBody[attributeName] = {
id: component.id,
__pivot: {
order: 1,
field: attributeName,
component_type: componentUID,
},
};
}
continue;
}
if (attribute.type === 'dynamiczone') {
const dynamiczoneValues = data[attributeName];
if (!Array.isArray(dynamiczoneValues)) {
throw new Error('Expected an array to create repeatable component');
}
componentBody[attributeName] = await Promise.all(
dynamiczoneValues.map(async (value, idx) => {
const { id } = await createComponent(value.__component, value);
return {
id,
__component: value.__component,
__pivot: {
order: idx + 1,
field: attributeName,
},
};
})
);
continue;
}
}
return componentBody;
};
/*
delete old components
create or update
*/
const updateComponents = async (uid, entityToUpdate, data) => {
const { attributes } = strapi.getModel(uid);
const componentBody = {};
for (const attributeName in attributes) {
const attribute = attributes[attributeName];
if (!has(attributeName, data)) {
continue;
}
if (attribute.type === 'component') {
const { component: componentUID, repeatable = false } = attribute;
const componentValue = data[attributeName];
await deleteOldComponents(uid, componentUID, entityToUpdate, attributeName, componentValue);
if (repeatable === true) {
if (!Array.isArray(componentValue)) {
throw new Error('Expected an array to create repeatable component');
}
const components = await Promise.all(
componentValue.map(value => updateOrCreateComponent(componentUID, value))
);
// TODO: add order
componentBody[attributeName] = components.filter(_.negate(_.isNil)).map(({ id }) => id);
} else {
const component = await updateOrCreateComponent(componentUID, componentValue);
componentBody[attributeName] = component && component.id;
}
continue;
}
if (attribute.type === 'dynamiczone') {
const dynamiczoneValues = data[attributeName];
await deleteOldDZComponents(uid, entityToUpdate, attributeName, dynamiczoneValues);
if (!Array.isArray(dynamiczoneValues)) {
throw new Error('Expected an array to create repeatable component');
}
componentBody[attributeName] = await Promise.all(
dynamiczoneValues.map(async value => {
const { id } = await updateOrCreateComponent(value.__component, value);
return { id, __component: value.__component };
})
);
continue;
}
}
return componentBody;
};
const deleteOldComponents = async (
uid,
componentUID,
entityToUpdate,
attributeName,
componentValue
) => {
const previousValue = await strapi.query(uid).load(entityToUpdate, attributeName);
const idsToKeep = _.castArray(componentValue)
.filter(has('id'))
.map(prop('id'));
const allIds = _.castArray(previousValue)
.filter(has('id'))
.map(prop('id'));
idsToKeep.forEach(id => {
if (!allIds.includes(id)) {
const err = new Error(
`Some of the provided components in ${attributeName} are not related to the entity`
);
err.status = 400;
throw err;
}
});
const idsToDelete = _.difference(allIds, idsToKeep);
if (idsToDelete.length > 0) {
for (const idToDelete of idsToDelete) {
await deleteComponent(componentUID, { id: idToDelete });
}
}
};
const deleteOldDZComponents = async (uid, entityToUpdate, attributeName, dynamiczoneValues) => {
const previousValue = await strapi.query(uid).load(entityToUpdate, attributeName);
const idsToKeep = _.castArray(dynamiczoneValues)
.filter(has('id'))
.map(({ id, __component }) => ({
id,
__component,
}));
const allIds = _.castArray(previousValue)
.filter(has('id'))
.map(({ id, __component }) => ({
id,
__component,
}));
idsToKeep.forEach(({ id, __component }) => {
if (!allIds.find(el => el.id === id && el.__component === __component)) {
const err = new Error(
`Some of the provided components in ${attributeName} are not related to the entity`
);
err.status = 400;
throw err;
}
});
const idsToDelete = allIds.reduce((acc, { id, __component }) => {
if (!idsToKeep.find(el => el.id === id && el.__component === __component)) {
acc.push({ id, __component });
}
return acc;
}, []);
if (idsToDelete.length > 0) {
for (const idToDelete of idsToDelete) {
const { id, __component } = idToDelete;
await deleteComponent(__component, { id });
}
}
};
const deleteComponents = async (uid, entityToDelete) => {
const { attributes } = strapi.getModel(uid);
for (const attributeName in attributes) {
const attribute = attributes[attributeName];
if (attribute.type === 'component') {
const { component: componentUID } = attribute;
const value = await strapi.query(uid).load(entityToDelete, attributeName);
if (!value) {
continue;
}
if (Array.isArray(value)) {
await Promise.all(value.map(subValue => deleteComponent(componentUID, subValue)));
} else {
await deleteComponent(componentUID, value);
}
continue;
}
if (attribute.type === 'dynamiczone') {
const value = await strapi.query(uid).load(entityToDelete, attributeName);
if (!value) {
continue;
}
if (Array.isArray(value)) {
await Promise.all(value.map(subValue => deleteComponent(subValue.__component, subValue)));
}
continue;
}
}
};
/***************************
Component queries
***************************/
// components can have nested compos so this must be recursive
const createComponent = async (uid, data) => {
const model = strapi.getModel(uid);
const componentData = await createComponents(uid, data);
return await strapi.query(uid).create({
data: Object.assign(omitComponentData(model, data), componentData),
});
};
// components can have nested compos so this must be recursive
const updateComponent = async (uid, componentToUpdate, data) => {
const model = strapi.getModel(uid);
const componentData = await updateComponents(uid, componentToUpdate, data);
return await strapi.query(uid).update({
where: {
id: componentToUpdate.id,
},
data: Object.assign(omitComponentData(model, data), componentData),
});
};
const updateOrCreateComponent = (componentUID, value) => {
if (value === null) {
return null;
}
// update
if (has('id', value)) {
// TODO: verify the compo is associated with the entity
return updateComponent(componentUID, { id: value.id }, value);
}
// create
return createComponent(componentUID, value);
};
const deleteComponent = async (uid, componentToDelete) => {
await deleteComponents(uid, componentToDelete);
await strapi.query(uid).delete({ where: { id: componentToDelete.id } });
};
module.exports = {
omitComponentData,
createComponents,
updateComponents,
deleteComponents,
};

View File

@ -0,0 +1,240 @@
'use strict';
const delegate = require('delegates');
const {
sanitizeEntity,
webhook: webhookUtils,
contentTypes: contentTypesUtils,
relations: relationsUtils,
} = require('@strapi/utils');
const uploadFiles = require('../utils/upload-files');
const {
omitComponentData,
createComponents,
updateComponents,
deleteComponents,
} = require('./components');
const { transformParamsToQuery, pickSelectionParams } = require('./params');
const { MANY_RELATIONS } = relationsUtils.constants;
// TODO: those should be strapi events used by the webhooks not the other way arround
const { ENTRY_CREATE, ENTRY_UPDATE, ENTRY_DELETE } = webhookUtils.webhookEvents;
module.exports = ctx => {
const implementation = createDefaultImplementation(ctx);
const service = {
implementation,
decorate(decorator) {
if (typeof decorator !== 'function') {
throw new Error(`Decorator must be a function, received ${typeof decorator}`);
}
this.implementation = Object.assign({}, this.implementation, decorator(this.implementation));
return this;
},
};
const delegator = delegate(service, 'implementation');
// delegate every method in implementation
Object.keys(service.implementation).forEach(key => delegator.method(key));
return service;
};
const createDefaultImplementation = ({ strapi, db, eventHub, entityValidator }) => ({
uploadFiles,
async wrapOptions(options = {}) {
return options;
},
emitEvent(uid, event, entity) {
const model = strapi.getModel(uid);
eventHub.emit(event, {
model: model.modelName,
entry: sanitizeEntity(entity, { model }),
});
},
async find(uid, opts) {
const { kind } = strapi.getModel(uid);
const { params } = await this.wrapOptions(opts, { uid, action: 'find' });
const query = transformParamsToQuery(uid, params);
// return first element and ignore filters
if (kind === 'singleType') {
return db.query(uid).findOne({});
}
return db.query(uid).findMany(query);
},
async findPage(uid, opts) {
const { params } = await this.wrapOptions(opts, { uid, action: 'findPage' });
const query = transformParamsToQuery(uid, params);
return db.query(uid).findPage(query);
},
async findWithRelationCounts(uid, opts) {
const model = strapi.getModel(uid);
const { params } = await this.wrapOptions(opts, { uid, action: 'findWithRelationCounts' });
const query = transformParamsToQuery(uid, params);
const { attributes } = model;
const populate = (query.populate || []).reduce((populate, attributeName) => {
const attribute = attributes[attributeName];
if (
MANY_RELATIONS.includes(attribute.relation) &&
contentTypesUtils.isVisibleAttribute(model, attributeName)
) {
populate[attributeName] = { count: true };
} else {
populate[attributeName] = true;
}
return populate;
}, {});
const { results, pagination } = await db.query(uid).findPage({
...query,
populate,
});
return {
results,
pagination,
};
},
async findOne(uid, entityId, opts) {
const { params } = await this.wrapOptions(opts, { uid, action: 'findOne' });
const query = transformParamsToQuery(uid, pickSelectionParams(params));
return db.query(uid).findOne({ ...query, where: { id: entityId } });
},
async count(uid, opts) {
const { params } = await this.wrapOptions(opts, { uid, action: 'count' });
const query = transformParamsToQuery(uid, params);
return db.query(uid).count(query);
},
async create(uid, opts) {
const { params, data, files } = await this.wrapOptions(opts, { uid, action: 'create' });
const model = strapi.getModel(uid);
const isDraft = contentTypesUtils.isDraft(data, model);
const validData = await entityValidator.validateEntityCreation(model, data, { isDraft });
// select / populate
const query = transformParamsToQuery(uid, pickSelectionParams(params));
// TODO: wrap into transaction
const componentData = await createComponents(uid, validData);
let entity = await db.query(uid).create({
...query,
data: Object.assign(omitComponentData(model, validData), componentData),
});
// TODO: upload the files then set the links in the entity like with compo to avoid making too many queries
// FIXME: upload in components
if (files && Object.keys(files).length > 0) {
await this.uploadFiles(uid, entity, files);
entity = await this.findOne(uid, entity.id, { params });
}
this.emitEvent(uid, ENTRY_CREATE, entity);
return entity;
},
async update(uid, entityId, opts) {
const { params, data, files } = await this.wrapOptions(opts, { uid, action: 'update' });
const model = strapi.getModel(uid);
const entityToUpdate = await db.query(uid).findOne({ where: { id: entityId } });
if (!entityToUpdate) {
return null;
}
const isDraft = contentTypesUtils.isDraft(entityToUpdate, model);
const validData = await entityValidator.validateEntityUpdate(model, data, {
isDraft,
});
const query = transformParamsToQuery(uid, pickSelectionParams(params));
// TODO: wrap in transaction
const componentData = await updateComponents(uid, entityToUpdate, validData);
let entity = await db.query(uid).update({
...query,
where: { id: entityId },
data: Object.assign(omitComponentData(model, validData), componentData),
});
// TODO: upload the files then set the links in the entity like with compo to avoid making too many queries
// FIXME: upload in components
if (files && Object.keys(files).length > 0) {
await this.uploadFiles(uid, entity, files);
entity = await this.findOne(uid, entity.id, { params });
}
this.emitEvent(uid, ENTRY_UPDATE, entity);
return entity;
},
async delete(uid, entityId, opts) {
const { params } = await this.wrapOptions(opts, { uid, action: 'delete' });
// select / populate
const query = transformParamsToQuery(uid, pickSelectionParams(params));
const entityToDelete = await db.query(uid).findOne({
...query,
where: { id: entityId },
});
if (!entityToDelete) {
return null;
}
await deleteComponents(uid, entityToDelete);
await db.query(uid).delete({ where: { id: entityToDelete.id } });
this.emitEvent(uid, ENTRY_DELETE, entityToDelete);
return entityToDelete;
},
async deleteMany(uid, opts) {
const { params } = await this.wrapOptions(opts, { uid, action: 'delete' });
// select / populate
const query = transformParamsToQuery(uid, params);
return db.query(uid).deleteMany(query);
},
});

View File

@ -0,0 +1,137 @@
'use strict';
const _ = require('lodash');
const { pick } = require('lodash/fp');
const {
convertSortQueryParams,
convertLimitQueryParams,
convertStartQueryParams,
} = require('@strapi/utils/lib/convert-rest-query-params');
const { contentTypes: contentTypesUtils } = require('@strapi/utils');
const { PUBLISHED_AT_ATTRIBUTE } = contentTypesUtils.constants;
const transformParamsToQuery = (uid, params = {}) => {
const model = strapi.getModel(uid);
const query = {};
// TODO: check invalid values add defaults ....
const {
start,
page,
pageSize,
limit,
sort,
filters,
fields,
populate,
publicationState,
_q,
_where,
...rest
} = params;
if (_q) {
query._q = _q;
}
if (page) {
query.page = Number(page);
}
if (pageSize) {
query.pageSize = Number(pageSize);
}
if (start) {
query.offset = convertStartQueryParams(start);
}
if (limit) {
query.limit = convertLimitQueryParams(limit);
}
if (sort) {
query.orderBy = convertSortQueryParams(sort);
}
if (filters) {
query.where = filters;
}
if (_where) {
query.where = {
$and: [_where].concat(query.where || []),
};
}
if (fields) {
// TODO: handle *.* syntax
query.select = _.castArray(fields);
}
if (populate) {
// TODO: handle *.* syntax
const { populate } = params;
if (populate === '*') {
query.populate = true;
} else if (typeof populate === 'object') {
query.populate = populate;
} else {
query.populate = _.castArray(populate);
}
}
// TODO: move to layer above ?
if (publicationState && contentTypesUtils.hasDraftAndPublish(model)) {
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
}
}
const finalQuery = {
...convertOldQuery(rest),
...query,
};
return finalQuery;
};
// TODO: to remove once the front is migrated
const convertOldQuery = params => {
const obj = {};
Object.keys(params).forEach(key => {
if (key.startsWith('_')) {
obj[key.slice(1)] = params[key];
} else {
obj[key] = params[key];
}
});
return obj;
};
const pickSelectionParams = pick(['fields', 'populate']);
module.exports = {
transformParamsToQuery,
pickSelectionParams,
};

View File

@ -23,7 +23,7 @@ const uploadImg = () => {
const components = {
singleMedia: {
name: 'single-media',
name: 'one-media',
attributes: {
media: {
type: 'media',
@ -31,7 +31,7 @@ const components = {
},
},
multipleMedia: {
name: 'multiple-media',
name: 'many-media',
attributes: {
media: {
type: 'media',
@ -44,11 +44,11 @@ const components = {
attributes: {
singleMedia: {
type: 'component',
component: 'default.single-media',
component: 'default.one-media',
},
multipleMedia: {
type: 'component',
component: 'default.multiple-media',
component: 'default.many-media',
},
},
},
@ -59,7 +59,7 @@ const ct = {
attributes: {
field: {
type: 'dynamiczone',
components: ['default.single-media', 'default.multiple-media', 'default.with-nested'],
components: ['default.one-media', 'default.many-media', 'default.with-nested'],
},
},
};
@ -101,11 +101,11 @@ describe('Not required dynamiczone', () => {
body: {
field: [
{
__component: 'default.single-media',
__component: 'default.one-media',
media: mediaId,
},
{
__component: 'default.multiple-media',
__component: 'default.many-media',
media: [mediaId, mediaId],
},
],
@ -122,7 +122,7 @@ describe('Not required dynamiczone', () => {
field: [
{
id: expect.anything(),
__component: 'default.single-media',
__component: 'default.one-media',
media: {
id: mediaId,
url: expect.any(String),
@ -130,7 +130,7 @@ describe('Not required dynamiczone', () => {
},
{
id: expect.anything(),
__component: 'default.multiple-media',
__component: 'default.many-media',
media: expect.arrayContaining([
expect.objectContaining({
id: mediaId,
@ -155,11 +155,11 @@ describe('Not required dynamiczone', () => {
body: {
field: [
{
__component: 'default.single-media',
__component: 'default.one-media',
media: mediaId,
},
{
__component: 'default.multiple-media',
__component: 'default.many-media',
media: [mediaId, mediaId],
},
],
@ -182,11 +182,11 @@ describe('Not required dynamiczone', () => {
body: {
field: [
{
__component: 'default.single-media',
__component: 'default.one-media',
media: newMediaId,
},
{
__component: 'default.multiple-media',
__component: 'default.many-media',
media: [newMediaId, newMediaId],
},
],
@ -201,7 +201,7 @@ describe('Not required dynamiczone', () => {
field: [
{
id: expect.anything(),
__component: 'default.single-media',
__component: 'default.one-media',
media: {
id: newMediaId,
url: expect.any(String),
@ -209,7 +209,7 @@ describe('Not required dynamiczone', () => {
},
{
id: expect.anything(),
__component: 'default.multiple-media',
__component: 'default.many-media',
media: expect.arrayContaining([
expect.objectContaining({
id: newMediaId,
@ -234,11 +234,11 @@ describe('Not required dynamiczone', () => {
body: {
field: [
{
__component: 'default.single-media',
__component: 'default.one-media',
media: mediaId,
},
{
__component: 'default.multiple-media',
__component: 'default.many-media',
media: [mediaId, mediaId],
},
],
@ -263,7 +263,7 @@ describe('Not required dynamiczone', () => {
field: [
{
id: expect.anything(),
__component: 'default.single-media',
__component: 'default.one-media',
media: {
id: mediaId,
url: expect.any(String),
@ -271,7 +271,7 @@ describe('Not required dynamiczone', () => {
},
{
id: expect.anything(),
__component: 'default.multiple-media',
__component: 'default.many-media',
media: expect.arrayContaining([
expect.objectContaining({
id: mediaId,

View File

@ -971,14 +971,14 @@ describe('Filtering API', () => {
expect(res.body.data).toEqual(expect.arrayContaining([data.product[0]]));
});
test.skip('Filter contains insensitive', async () => {
test('Filter contains insensitive', async () => {
const res = await rq({
method: 'GET',
url: '/products',
qs: {
filters: {
name: {
$contains: ['Product', '1'],
$containsi: ['Product', '1'],
},
},
},
@ -987,14 +987,14 @@ describe('Filtering API', () => {
expect(res.body.data).toEqual(expect.arrayContaining([data.product[0]]));
});
test.skip('Filter not contains insensitive', async () => {
test('Filter not contains insensitive', async () => {
const res = await rq({
method: 'GET',
url: '/products',
qs: {
filters: {
name: {
$notContains: ['Product', 'Non existent'],
$notContainsi: ['Product', 'Non existent'],
},
},
},

View File

@ -154,6 +154,8 @@ describe('Publication State', () => {
strapi = await createStrapiInstance();
rq = await createAuthRequest({ strapi });
console.log(JSON.stringify(builder.sanitizedFixtures(strapi), null, 2));
Object.assign(data, builder.sanitizedFixtures(strapi));
});

View File

@ -77,12 +77,9 @@ module.exports = ({ strapi }) => ({
if (refId && ref && field) {
entity.related = [
{
__type: ref,
id: refId,
// refId,
// ref,
// source,
// field,
__type: ref,
__pivot: { field },
},
];
}

View File

@ -1,6 +1,12 @@
'use strict';
const { escapeQuery, stringIncludes, stringEquals } = require('../string-formatting');
const {
escapeQuery,
stringIncludes,
stringEquals,
getCommonBeginning,
getCommonPath,
} = require('../string-formatting');
describe('string-formatting', () => {
describe('Escape Query', () => {
@ -64,4 +70,31 @@ describe('string-formatting', () => {
expect(result).toBe(expectedResult);
});
});
describe('getCommonBeginning', () => {
const tests = [
[['abcd', 'abc', 'ab'], 'ab'],
[['abcd', 'abc'], 'abc'],
[['ab/cd', 'ab/c'], 'ab/c'],
[['abc', 'abc'], 'abc'],
];
test.each(tests)('%p has common beginning: %p', (a, expectedResult) => {
const result = getCommonBeginning(...a);
expect(result).toBe(expectedResult);
});
});
describe('getCommonPath', () => {
const tests = [
[['abc', 'ab'], ''],
[['http://ab.com/cd', 'http://ab.com/c'], 'http://ab.com'],
[['http://ab.com/admin', 'http://ab.com/api'], 'http://ab.com'],
[['http://ab.com/admin', 'http://ab.com/admin/'], 'http://ab.com/admin'],
[['http://ab.com/admin', 'http://ab.com/admin'], 'http://ab.com/admin'],
];
test.each(tests)('%p has common path: %p', (a, expectedResult) => {
const result = getCommonPath(...a);
expect(result).toBe(expectedResult);
});
});
});

View File

@ -1,7 +1,7 @@
'use strict';
const _ = require('lodash');
const { getCommonBeginning } = require('./string-formatting');
const { getCommonPath } = require('./string-formatting');
const getConfigUrls = (serverConfig, forAdminBuild = false) => {
// Defines serverUrl value
@ -46,7 +46,7 @@ const getConfigUrls = (serverConfig, forAdminBuild = false) => {
new URL(adminUrl).origin === new URL(serverUrl).origin &&
!forAdminBuild
) {
adminPath = adminUrl.replace(getCommonBeginning(serverUrl, adminUrl), '');
adminPath = adminUrl.replace(getCommonPath(serverUrl, adminUrl), '');
adminPath = `/${_.trim(adminPath, '/')}`;
} else if (adminUrl.startsWith('http')) {
adminPath = new URL(adminUrl).pathname;

View File

@ -1,23 +1,22 @@
'use strict';
const _ = require('lodash');
const slugify = require('@sindresorhus/slugify');
const nameToSlug = (name, options = { separator: '-' }) => slugify(name, options);
const nameToCollectionName = name => slugify(name, { separator: '_' });
const getCommonBeginning = (str1 = '', str2 = '') => {
let common = '';
let index = 0;
while (index < str1.length && index < str2.length) {
if (str1[index] === str2[index]) {
common += str1[index];
index += 1;
} else {
break;
}
}
return common;
const getCommonBeginning = (...strings) =>
_.takeWhile(strings[0], (char, index) => strings.every(string => string[index] === char)).join(
''
);
const getCommonPath = (...paths) => {
const [segments, ...otherSegments] = paths.map(it => _.split(it, '/'));
return _.join(
_.takeWhile(segments, (str, index) => otherSegments.every(it => it[index] === str)),
'/'
);
};
const escapeQuery = (query, charsToEscape, escapeChar = '\\') => {
@ -39,6 +38,7 @@ module.exports = {
nameToSlug,
nameToCollectionName,
getCommonBeginning,
getCommonPath,
escapeQuery,
stringIncludes,
stringEquals,