Merge pull request #4601 from strapi/dynamic-zone/mongoose-support

Dynamic zone/mongoose support
This commit is contained in:
Alexandre BODIN 2019-11-27 15:23:00 +01:00 committed by GitHub
commit afa0f50a23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 458 additions and 240 deletions

View File

@ -7,10 +7,7 @@
},
"options": {
"increments": true,
"timestamps": [
"created_at",
"updated_at"
],
"timestamps": ["created_at", "updated_at"],
"comment": ""
},
"attributes": {
@ -38,21 +35,12 @@
"collection": "category"
},
"price_range": {
"enum": [
"very_cheap",
"cheap",
"average",
"expensive",
"very_expensive"
],
"enum": ["very_cheap", "cheap", "average", "expensive", "very_expensive"],
"type": "enumeration"
},
"body": {
"type": "dynamiczone",
"components": [
"default.closingperiod",
"default.restaurantservice"
]
"components": ["default.closingperiod", "default.restaurantservice"]
},
"description": {
"type": "richtext",
@ -61,7 +49,6 @@
"opening_times": {
"component": "default.openingtimes",
"type": "component",
"required": true,
"repeatable": true,
"min": 1,
"max": 10
@ -72,7 +59,6 @@
},
"services": {
"component": "default.restaurantservice",
"required": true,
"repeatable": true,
"type": "component"
},
@ -83,4 +69,4 @@
"type": "text"
}
}
}
}

View File

@ -17,6 +17,11 @@
"type": "date",
"required": true
},
"media": {
"model": "file",
"via": "related",
"plugin": "upload"
},
"dish": {
"component": "default.dish",
"type": "component"

View File

@ -10,6 +10,11 @@
"type": "string",
"required": true
},
"media": {
"model": "file",
"via": "related",
"plugin": "upload"
},
"is_available": {
"type": "boolean",
"required": true,

View File

@ -6,6 +6,7 @@ const mongoose = require('mongoose');
const utilsModels = require('strapi-utils').models;
const utils = require('./utils');
const relations = require('./relations');
const { findComponentByGlobalId } = require('./utils/helpers');
module.exports = ({ models, target, plugin = false }, ctx) => {
const { instance } = ctx;
@ -27,13 +28,18 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
global[definition.globalName] = {};
}
const componentAttributes = Object.keys(definition.attributes).filter(
key => definition.attributes[key].type === 'component'
const componentAttributes = Object.keys(definition.attributes).filter(key =>
['component', 'dynamiczone'].includes(definition.attributes[key].type)
);
const scalarAttributes = Object.keys(definition.attributes).filter(key => {
const { type } = definition.attributes[key];
return type !== undefined && type !== null && type !== 'component';
return (
type !== undefined &&
type !== null &&
type !== 'component' &&
type !== 'dynamiczone'
);
});
const relationalAttributes = Object.keys(definition.attributes).filter(
@ -43,7 +49,7 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
}
);
// handle gorup attrs
// handle component and dynamic zone attrs
if (componentAttributes.length > 0) {
// create join morph collection thingy
componentAttributes.forEach(name => {
@ -129,8 +135,7 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
if (_.isFunction(target[model.toLowerCase()][fn])) {
schema.pre(key, function() {
return target[model.toLowerCase()]
[fn](this);
return target[model.toLowerCase()][fn](this);
});
}
});
@ -252,14 +257,28 @@ module.exports = ({ models, target, plugin = false }, ctx) => {
componentAttributes.forEach(name => {
const attribute = definition.attributes[name];
const { type } = attribute;
if (Array.isArray(returned[name])) {
const components = returned[name].map(el => el.ref);
// Reformat data by bypassing the many-to-many relationship.
returned[name] =
attribute.repeatable === true
? components
: _.first(components) || null;
if (type === 'component') {
if (Array.isArray(returned[name])) {
const components = returned[name].map(el => el.ref);
// Reformat data by bypassing the many-to-many relationship.
returned[name] =
attribute.repeatable === true
? components
: _.first(components) || null;
}
}
if (type === 'dynamiczone') {
const components = returned[name].map(el => {
return {
__component: findComponentByGlobalId(el.kind).uid,
...el.ref,
};
});
returned[name] = components;
}
});
},
@ -304,121 +323,38 @@ const createOnFetchPopulateFn = ({
componentAttributes,
definition,
}) => {
return function(next) {
return function() {
const populatedPaths = this.getPopulatedPaths();
morphAssociations.forEach(association => {
if (
this._mongooseOptions.populate &&
this._mongooseOptions.populate[association.alias]
) {
if (
association.nature === 'oneToManyMorph' ||
association.nature === 'manyToManyMorph'
) {
this._mongooseOptions.populate[association.alias].match = {
const { alias, nature } = association;
if (['oneToManyMorph', 'manyToManyMorph'].includes(nature)) {
this.populate({
path: alias,
match: {
[`${association.via}.${association.filter}`]: association.alias,
[`${association.via}.kind`]: definition.globalId,
};
// Select last related to an entity.
this._mongooseOptions.populate[association.alias].options = {
},
options: {
sort: '-createdAt',
};
} else {
this._mongooseOptions.populate[
association.alias
].path = `${association.alias}.ref`;
}
} else {
if (!this._mongooseOptions.populate) {
this._mongooseOptions.populate = {};
}
// Images are not displayed in populated data.
// We automatically populate morph relations.
if (
association.nature === 'oneToManyMorph' ||
association.nature === 'manyToManyMorph'
) {
this._mongooseOptions.populate[association.alias] = {
path: association.alias,
match: {
[`${association.via}.${association.filter}`]: association.alias,
[`${association.via}.kind`]: definition.globalId,
},
options: {
sort: '-createdAt',
},
select: undefined,
model: undefined,
_docs: {},
};
}
}
});
componentAttributes.forEach(name => {
const attr = definition.attributes[name];
const component = strapi.components[attr.component];
const assocs = (component.associations || []).filter(
assoc => assoc.autoPopulate === true
);
let subpopulates = [];
assocs.forEach(assoc => {
if (isPolymorphic({ assoc })) {
if (
assoc.nature === 'oneToManyMorph' ||
assoc.nature === 'manyToManyMorph'
) {
subpopulates.push({
path: assoc.alias,
match: {
[`${assoc.via}.${assoc.filter}`]: assoc.alias,
[`${assoc.via}.kind`]: definition.globalId,
},
options: {
sort: '-createdAt',
},
select: undefined,
model: undefined,
_docs: {},
});
} else {
subpopulates.push({ path: `${assoc.alias}.ref`, _docs: {} });
}
} else {
subpopulates.push({
path: assoc.alias,
_docs: {},
});
}
});
if (
this._mongooseOptions.populate &&
this._mongooseOptions.populate[name]
) {
this._mongooseOptions.populate[name].path = `${name}.ref`;
this._mongooseOptions.populate[name].populate = subpopulates;
} else {
_.set(this._mongooseOptions, ['populate', name], {
path: `${name}.ref`,
populate: subpopulates,
_docs: {},
},
});
return;
}
if (populatedPaths.includes(alias)) {
_.set(this._mongooseOptions.populate, [alias, 'path'], `${alias}.ref`);
}
});
next();
componentAttributes.forEach(key => {
this.populate({ path: `${key}.ref` });
});
};
};
const isPolymorphic = ({ assoc }) => {
return assoc.nature.toLowerCase().indexOf('morph') !== -1;
};
const buildRelation = ({ definition, model, instance, attribute, name }) => {
const { nature, verbose } =
utilsModels.getNature(attribute, name, undefined, model.toLowerCase()) ||

View File

@ -10,15 +10,18 @@ const {
models: modelUtils,
} = require('strapi-utils');
module.exports = ({ model, modelKey, strapi }) => {
const hasPK = obj => _.has(obj, model.primaryKey) || _.has(obj, 'id');
const getPK = obj =>
_.has(obj, model.primaryKey) ? obj[model.primaryKey] : obj.id;
const { findComponentByGlobalId } = require('./utils/helpers');
const hasPK = (obj, model) => _.has(obj, model.primaryKey) || _.has(obj, 'id');
const getPK = (obj, model) =>
_.has(obj, model.primaryKey) ? obj[model.primaryKey] : obj.id;
module.exports = ({ model, modelKey, strapi }) => {
const assocKeys = model.associations.map(ast => ast.alias);
const componentKeys = Object.keys(model.attributes).filter(key => {
return model.attributes[key].type === 'component';
});
const componentKeys = Object.keys(model.attributes).filter(key =>
['component', 'dynamiczone'].includes(model.attributes[key].type)
);
const excludedKeys = assocKeys.concat(componentKeys);
const defaultPopulate = model.associations
@ -38,49 +41,96 @@ module.exports = ({ model, modelKey, strapi }) => {
for (let key of componentKeys) {
const attr = model.attributes[key];
const { component, required = false, repeatable = false } = attr;
const { type } = attr;
const componentModel = strapi.components[component];
if (type === 'component') {
const { component, required = false, repeatable = false } = attr;
if (required === true && !_.has(values, key)) {
const err = new Error(`Component ${key} is required`);
err.status = 400;
throw err;
const componentModel = strapi.components[component];
if (required === true && !_.has(values, key)) {
const err = new Error(`Component ${key} is required`);
err.status = 400;
throw err;
}
if (!_.has(values, key)) continue;
const componentValue = values[key];
if (repeatable === true) {
validateRepeatableInput(componentValue, { key, ...attr });
const components = await Promise.all(
componentValue.map(value => {
return strapi.query(component).create(value);
})
);
const componentsArr = components.map(componentEntry => ({
kind: componentModel.globalId,
ref: componentEntry,
}));
entry[key] = componentsArr;
await entry.save();
} else {
validateNonRepeatableInput(componentValue, { key, ...attr });
if (componentValue === null) continue;
const componentEntry = await strapi
.query(component)
.create(componentValue);
entry[key] = [
{
kind: componentModel.globalId,
ref: componentEntry,
},
];
await entry.save();
}
}
if (!_.has(values, key)) continue;
if (type === 'dynamiczone') {
const { required = false } = attr;
const componentValue = values[key];
if (required === true && !_.has(values, key)) {
const err = new Error(`Dynamiczone ${key} is required`);
err.status = 400;
throw err;
}
if (repeatable === true) {
validateRepeatableInput(componentValue, { key, ...attr });
const components = await Promise.all(
componentValue.map(value => {
return strapi.query(component).create(value);
if (!_.has(values, key)) continue;
const dynamiczoneValues = values[key];
validateDynamiczoneInput(dynamiczoneValues, { key, ...attr });
const dynamiczones = await Promise.all(
dynamiczoneValues.map(value => {
const component = value.__component;
return strapi
.query(component)
.create(value)
.then(entity => {
return {
__component: value.__component,
entity,
};
});
})
);
const componentsArr = components.map(componentEntry => ({
kind: componentModel.globalId,
ref: componentEntry,
}));
const componentsArr = dynamiczones.map(({ __component, entity }) => {
const componentModel = strapi.components[__component];
return {
kind: componentModel.globalId,
ref: entity,
};
});
entry[key] = componentsArr;
await entry.save();
} else {
validateNonRepeatableInput(componentValue, { key, ...attr });
if (componentValue === null) continue;
const componentEntry = await strapi
.query(component)
.create(componentValue);
entry[key] = [
{
kind: componentModel.globalId,
ref: componentEntry,
},
];
await entry.save();
}
}
}
@ -88,70 +138,185 @@ module.exports = ({ model, modelKey, strapi }) => {
async function updateComponents(entry, values) {
if (componentKeys.length === 0) return;
const updateOrCreateComponent = async ({ componentUID, value }) => {
// check if value has an id then update else create
const query = strapi.query(componentUID);
if (hasPK(value, query.model)) {
return query.update(
{
[query.model.primaryKey]: getPK(value, query.model),
},
value
);
}
return query.create(value);
};
for (let key of componentKeys) {
// if key isn't present then don't change the current component data
if (!_.has(values, key)) continue;
const attr = model.attributes[key];
const { component, repeatable = false } = attr;
const { type } = attr;
const componentModel = strapi.components[component];
const componentValue = values[key];
if (type === 'component') {
const { component: componentUID, repeatable = false } = attr;
const updateOrCreateComponent = async value => {
// check if value has an id then update else create
if (hasPK(value)) {
return strapi.query(component).update(
{
[model.primaryKey]: getPK(value),
},
value
const componentModel = strapi.components[componentUID];
const componentValue = values[key];
if (repeatable === true) {
validateRepeatableInput(componentValue, { key, ...attr });
await deleteOldComponents(entry, componentValue, {
key,
componentModel,
});
const components = await Promise.all(
componentValue.map(value =>
updateOrCreateComponent({ componentUID, value })
)
);
}
return strapi.query(component).create(value);
};
if (repeatable === true) {
validateRepeatableInput(componentValue, { key, ...attr });
await deleteOldComponents(entry, componentValue, {
key,
componentModel,
});
const components = await Promise.all(
componentValue.map(updateOrCreateComponent)
);
const componentsArr = components.map(component => ({
kind: componentModel.globalId,
ref: component,
}));
entry[key] = componentsArr;
await entry.save();
} else {
validateNonRepeatableInput(componentValue, { key, ...attr });
await deleteOldComponents(entry, componentValue, {
key,
componentModel,
});
if (componentValue === null) continue;
const component = await updateOrCreateComponent(componentValue);
entry[key] = [
{
const componentsArr = components.map(component => ({
kind: componentModel.globalId,
ref: component,
},
];
}));
entry[key] = componentsArr;
await entry.save();
} else {
validateNonRepeatableInput(componentValue, { key, ...attr });
await deleteOldComponents(entry, componentValue, {
key,
componentModel,
});
if (componentValue === null) continue;
const component = await updateOrCreateComponent({
componentUID,
value: componentValue,
});
entry[key] = [
{
kind: componentModel.globalId,
ref: component,
},
];
await entry.save();
}
}
if (type === 'dynamiczone') {
const dynamiczoneValues = values[key];
validateDynamiczoneInput(dynamiczoneValues, { key, ...attr });
await deleteDynamicZoneOldComponents(entry, dynamiczoneValues, {
key,
});
const dynamiczones = await Promise.all(
dynamiczoneValues.map(value => {
const componentUID = value.__component;
return updateOrCreateComponent({ componentUID, value }).then(
entity => {
return {
componentUID,
entity,
};
}
);
})
);
const componentsArr = dynamiczones.map(({ componentUID, entity }) => {
const componentModel = strapi.components[componentUID];
return {
kind: componentModel.globalId,
ref: entity,
};
});
entry[key] = componentsArr;
await entry.save();
}
}
return;
}
async function deleteDynamicZoneOldComponents(entry, values, { key }) {
const idsToKeep = values.reduce((acc, value) => {
const component = value.__component;
const componentModel = strapi.components[component];
if (hasPK(value, componentModel)) {
acc.push({
id: getPK(value, componentModel).toString(),
componentUID: componentModel.uid,
});
}
return acc;
}, []);
const allIds = []
.concat(entry[key] || [])
.filter(el => el.ref)
.map(el => ({
id: el.ref._id.toString(),
componentUID: findComponentByGlobalId(el.kind).uid,
}));
// verify the provided ids are realted to this entity.
idsToKeep.forEach(({ id, componentUID }) => {
if (
!allIds.find(el => el.id === id && el.componentUID === componentUID)
) {
const err = new Error(
`Some of the provided components in ${key} are not related to the entity`
);
err.status = 400;
throw err;
}
});
const idsToDelete = allIds.reduce((acc, { id, componentUID }) => {
if (
!idsToKeep.find(el => el.id === id && el.componentUID === componentUID)
) {
acc.push({
id,
componentUID,
});
}
return acc;
}, []);
if (idsToDelete.length > 0) {
const deleteMap = idsToDelete.reduce((map, { id, componentUID }) => {
if (!_.has(map, componentUID)) {
map[componentUID] = [id];
return map;
}
map[componentUID].push(id);
return map;
}, {});
await Promise.all(
Object.keys(deleteMap).map(componentUID => {
return strapi
.query(componentUID)
.delete({ [`${model.primaryKey}_in`]: deleteMap[componentUID] });
})
);
}
}
async function deleteOldComponents(
entry,
componentValue,
@ -161,8 +326,12 @@ module.exports = ({ model, modelKey, strapi }) => {
? componentValue
: [componentValue];
const idsToKeep = componentArr.filter(hasPK).map(getPK);
const allIds = await (entry[key] || [])
const idsToKeep = componentArr
.filter(val => hasPK(val, componentModel))
.map(val => getPK(val, componentModel));
const allIds = []
.concat(entry[key] || [])
.filter(el => el.ref)
.map(el => el.ref._id);
@ -194,14 +363,45 @@ module.exports = ({ model, modelKey, strapi }) => {
for (let key of componentKeys) {
const attr = model.attributes[key];
const { component } = attr;
const componentModel = strapi.components[component];
const { type } = attr;
if (Array.isArray(entry[key]) && entry[key].length > 0) {
const idsToDelete = entry[key].map(el => el.ref);
await strapi
.query(componentModel.uid)
.delete({ [`${model.primaryKey}_in`]: idsToDelete });
if (type === 'component') {
const { component } = attr;
const componentModel = strapi.components[component];
if (Array.isArray(entry[key]) && entry[key].length > 0) {
const idsToDelete = entry[key].map(el => el.ref);
await strapi
.query(componentModel.uid)
.delete({ [`${model.primaryKey}_in`]: idsToDelete });
}
}
if (type === 'dynamiczone') {
if (Array.isArray(entry[key]) && entry[key].length > 0) {
const idsToDelete = entry[key].map(el => ({
componentUID: findComponentByGlobalId(el.kind).uid,
id: el.ref,
}));
const deleteMap = idsToDelete.reduce((map, { id, componentUID }) => {
if (!_.has(map, componentUID)) {
map[componentUID] = [id];
return map;
}
map[componentUID].push(id);
return map;
}, {});
await Promise.all(
Object.keys(deleteMap).map(componentUID => {
return strapi.query(componentUID).delete({
[`${model.primaryKey}_in`]: idsToDelete[componentUID],
});
})
);
}
}
}
}
@ -221,7 +421,7 @@ module.exports = ({ model, modelKey, strapi }) => {
}
async function findOne(params, populate) {
const primaryKey = getPK(params);
const primaryKey = getPK(params, model);
if (primaryKey) {
params = {
@ -257,13 +457,13 @@ module.exports = ({ model, modelKey, strapi }) => {
// Create relational data and return the entry.
return model.updateRelations({
[model.primaryKey]: getPK(entry),
[model.primaryKey]: getPK(entry, model),
values: relations,
});
}
async function update(params, values) {
const primaryKey = getPK(params);
const primaryKey = getPK(params, model);
if (primaryKey) {
params = {
@ -293,7 +493,7 @@ module.exports = ({ model, modelKey, strapi }) => {
}
async function deleteMany(params) {
const primaryKey = getPK(params);
const primaryKey = getPK(params, model);
if (primaryKey) return deleteOne(params);
@ -303,7 +503,7 @@ module.exports = ({ model, modelKey, strapi }) => {
async function deleteOne(params) {
const entry = await model
.findOneAndRemove({ [model.primaryKey]: getPK(params) })
.findOneAndRemove({ [model.primaryKey]: getPK(params, model) })
.populate(defaultPopulate);
if (!entry) {
@ -454,3 +654,56 @@ function validateNonRepeatableInput(value, { key, required }) {
throw err;
}
}
function validateDynamiczoneInput(
value,
{ key, min, max, components, required }
) {
if (!Array.isArray(value)) {
const err = new Error(`Dynamiczone ${key} is invalid. Expected an array`);
err.status = 400;
throw err;
}
value.forEach(val => {
if (typeof val !== 'object' || Array.isArray(val) || val === null) {
const err = new Error(
`Dynamiczone ${key} has invalid items. Expected each items to be objects`
);
err.status = 400;
throw err;
}
if (!_.has(val, '__component')) {
const err = new Error(
`Dynamiczone ${key} has invalid items. Expected each items to have a valid __component key`
);
err.status = 400;
throw err;
} else if (!components.includes(val.__component)) {
const err = new Error(
`Dynamiczone ${key} has invalid items. Each item must have a __component key that is present in the attribute definition`
);
err.status = 400;
throw err;
}
});
if (
(required === true || (required !== true && value.length > 0)) &&
(min && value.length < min)
) {
const err = new Error(
`Dynamiczone ${key} must contain at least ${min} items`
);
err.status = 400;
throw err;
}
if (max && value.length > max) {
const err = new Error(
`Dynamiczone ${key} must contain at most ${max} items`
);
err.status = 400;
throw err;
}
}

View File

@ -0,0 +1,11 @@
'use strict';
const findComponentByGlobalId = globalId => {
return Object.values(strapi.components).find(
compo => compo.globalId === globalId
);
};
module.exports = {
findComponentByGlobalId,
};

View File

@ -8,6 +8,10 @@ module.exports = async (ctx, next) => {
const ct = strapi.contentTypes[model];
if (!ct) {
return ctx.send({ error: 'contentType.notFound' }, 404);
}
const target =
ct.plugin === 'admin' ? strapi.admin : strapi.plugins[ct.plugin];

View File

@ -576,6 +576,7 @@ describe.each([
};
expect(updateRes.statusCode).toBe(200);
expect(updateRes.body).toMatchObject(expectedResult);
const getRes = await rq.get(`/${res.body.id}`);

View File

@ -16,6 +16,7 @@ module.exports = async (entry, files, { model, source }) => {
let tmpModel = entity;
let modelName = model;
let sourceName;
for (let i = 0; i < path.length; i++) {
if (!tmpModel) return {};
const part = path[i];
@ -35,7 +36,11 @@ module.exports = async (entry, files, { model, source }) => {
tmpModel = strapi.components[attr.component];
} else if (attr.type === 'dynamiczone') {
const entryIdx = path[i + 1]; // get component index
modelName = _.get(entry, [...currentPath, entryIdx]).__component; // get component type
const value = _.get(entry, [...currentPath, entryIdx]);
if (!value) return {};
modelName = value.__component; // get component type
tmpModel = strapi.components[modelName];
} else if (_.has(attr, 'model') || _.has(attr, 'collection')) {
sourceName = attr.plugin;
@ -57,6 +62,7 @@ module.exports = async (entry, files, { model, source }) => {
if (model) {
const id = _.get(entry, path.concat('id'));
return uploadService.uploadToEntity(
{ id, model },
{ [field]: files },

View File

@ -80,7 +80,7 @@ module.exports = {
delete file.buffer;
file.provider = provider.provider;
const res = await strapi.plugins['upload'].services.upload.add(file);
const res = await this.add(file);
// Remove temp file
if (file.tmpPath) {

View File

@ -8,7 +8,9 @@ const findModelByAssoc = assoc => {
};
const isAttribute = (model, field) =>
_.has(model.allAttributes, field) || model.primaryKey === field;
_.has(model.allAttributes, field) ||
model.primaryKey === field ||
field === 'id';
/**
* Returns the model, attribute name and association from a path of relation
@ -131,7 +133,11 @@ const buildQuery = ({ model, filters = {}, ...rest }) => {
? value.map(val => castValue({ type, operator, value: val }))
: castValue({ type, operator, value: value });
return { field, operator, value: castedValue };
return {
field: field === 'id' ? model.primaryKey : field,
operator,
value: castedValue,
};
});
}

View File

@ -16,6 +16,7 @@ module.exports = async (entry, files, { model, source }) => {
let tmpModel = entity;
let modelName = model;
let sourceName;
for (let i = 0; i < path.length; i++) {
if (!tmpModel) return {};
const part = path[i];
@ -35,7 +36,11 @@ module.exports = async (entry, files, { model, source }) => {
tmpModel = strapi.components[attr.component];
} else if (attr.type === 'dynamiczone') {
const entryIdx = path[i + 1]; // get component index
modelName = _.get(entry, [...currentPath, entryIdx]).__component; // get component type
const value = _.get(entry, [...currentPath, entryIdx]);
if (!value) return {};
modelName = value.__component; // get component type
tmpModel = strapi.components[modelName];
} else if (_.has(attr, 'model') || _.has(attr, 'collection')) {
sourceName = attr.plugin;