mirror of
https://github.com/strapi/strapi.git
synced 2025-12-27 07:03:38 +00:00
Merge branch 'v4/backend' into pluginAPI/loadPlugin
This commit is contained in:
commit
682e877644
@ -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
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
defaults: { mazdaz: { enabled: true } },
|
||||
load: {
|
||||
initialize() {},
|
||||
},
|
||||
};
|
||||
@ -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 = {
|
||||
|
||||
@ -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 })
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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
|
||||
|
||||
5
packages/core/database/lib/fields.d.ts
vendored
5
packages/core/database/lib/fields.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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]({});
|
||||
}
|
||||
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
10
packages/core/database/lib/query/helpers/index.js
Normal file
10
packages/core/database/lib/query/helpers/index.js
Normal file
@ -0,0 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
...require('./search'),
|
||||
...require('./order-by'),
|
||||
...require('./join'),
|
||||
...require('./populate'),
|
||||
...require('./where'),
|
||||
...require('./transform'),
|
||||
};
|
||||
97
packages/core/database/lib/query/helpers/join.js
Normal file
97
packages/core/database/lib/query/helpers/join.js
Normal 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,
|
||||
};
|
||||
60
packages/core/database/lib/query/helpers/order-by.js
Normal file
60
packages/core/database/lib/query/helpers/order-by.js
Normal 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,
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
70
packages/core/database/lib/query/helpers/search.js
Normal file
70
packages/core/database/lib/query/helpers/search.js
Normal 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,
|
||||
};
|
||||
56
packages/core/database/lib/query/helpers/transform.js
Normal file
56
packages/core/database/lib/query/helpers/transform.js
Normal 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,
|
||||
};
|
||||
328
packages/core/database/lib/query/helpers/where.js
Normal file
328
packages/core/database/lib/query/helpers/where.js
Normal 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,
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
"enabled": true,
|
||||
"multipart": true,
|
||||
"queryStringParser": {
|
||||
"strictNullHandling": true,
|
||||
"arrayLimit": 100,
|
||||
"depth": 20
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
340
packages/core/strapi/lib/services/entity-service/components.js
Normal file
340
packages/core/strapi/lib/services/entity-service/components.js
Normal 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,
|
||||
};
|
||||
240
packages/core/strapi/lib/services/entity-service/index.js
Normal file
240
packages/core/strapi/lib/services/entity-service/index.js
Normal 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);
|
||||
},
|
||||
});
|
||||
137
packages/core/strapi/lib/services/entity-service/params.js
Normal file
137
packages/core/strapi/lib/services/entity-service/params.js
Normal 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,
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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));
|
||||
});
|
||||
|
||||
|
||||
@ -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 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user