Handle join table pivot associations and ordering

This commit is contained in:
Alexandre Bodin 2021-08-12 16:12:40 +02:00
parent 2009ebf129
commit 3c2040011a
9 changed files with 179 additions and 97 deletions

View File

@ -5,14 +5,32 @@ const types = require('./types');
const { createField } = require('./fields'); const { createField } = require('./fields');
const { createQueryBuilder } = require('./query'); const { createQueryBuilder } = require('./query');
const { createRepository } = require('./entity-repository'); const { createRepository } = require('./entity-repository');
const { isBidirectional } = require('./metadata/relations'); const { isBidirectional, isOneToAny } = require('./metadata/relations');
const toId = value => value.id || value; const toId = value => value.id || value;
const toIds = value => _.castArray(value || []).map(toId); 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 // TODO: handle programmatic defaults
const toRow = (metadata, data = {}) => { const toRow = (metadata, data = {}, { withDefaults = false } = {}) => {
const { attributes } = metadata; const { attributes } = metadata;
const obj = {}; const obj = {};
@ -20,14 +38,24 @@ const toRow = (metadata, data = {}) => {
for (const attributeName in attributes) { for (const attributeName in attributes) {
const attribute = attributes[attributeName]; const attribute = attributes[attributeName];
if (types.isScalar(attribute.type) && _.has(attributeName, data)) { // TODO: convert to column name
// TODO: we convert to column name if (types.isScalar(attribute.type)) {
// TODO: handle default value 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 if (typeof field.validate === 'function' && data[attributeName] !== null) {
// field.validate(data[attributeName]); field.validate(data[attributeName]);
}
const val = data[attributeName] === null ? null : field.toDB(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'); 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) const [id] = await this.createQueryBuilder(uid)
.insert(dataToInsert) .insert(dataToInsert)
@ -160,6 +188,7 @@ const createEntityManager = db => {
return result; return result;
}, },
// TODO: where do we handle relation processing for many queries ?
async createMany(uid, params = {}) { async createMany(uid, params = {}) {
await db.lifecycles.run('beforeCreateMany', 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'); 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)) { if (_.isEmpty(dataToInsert)) {
throw new Error('Nothing to insert'); throw new Error('Nothing to insert');
@ -236,6 +265,7 @@ const createEntityManager = db => {
return result; return result;
}, },
// TODO: where do we handle relation processing for many queries ?
async updateMany(uid, params = {}) { async updateMany(uid, params = {}) {
await db.lifecycles.run('beforeUpdateMany', uid, { params }); await db.lifecycles.run('beforeUpdateMany', uid, { params });
@ -327,7 +357,7 @@ const createEntityManager = db => {
for (const attributeName in attributes) { for (const attributeName in attributes) {
const attribute = attributes[attributeName]; 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) { if (attribute.type !== 'relation' || !isValidLink) {
continue; continue;
@ -352,13 +382,15 @@ const createEntityManager = db => {
const { idColumn, typeColumn } = morphColumn; const { idColumn, typeColumn } = morphColumn;
const rows = toIds(data[attributeName]).map((dataID, idx) => ({ const rows = toAssocs(data[attributeName]).map(data => {
[joinColumn.name]: dataID, return {
[idColumn.name]: id, [joinColumn.name]: data.id,
[typeColumn.name]: uid, [idColumn.name]: id,
...(joinTable.on || {}), [typeColumn.name]: uid,
order: idx, ...(joinTable.on || {}),
})); ...(data.__pivot || {}),
};
});
if (_.isEmpty(rows)) { if (_.isEmpty(rows)) {
continue; continue;
@ -379,12 +411,12 @@ const createEntityManager = db => {
const { idColumn, typeColumn, typeField = '__type' } = morphColumn; const { idColumn, typeColumn, typeField = '__type' } = morphColumn;
const rows = _.castArray(data[attributeName] || []).map((data, idx) => ({ const rows = toAssocs(data[attributeName]).map(data => ({
[joinColumn.name]: id, [joinColumn.name]: id,
[idColumn.name]: data.id, [idColumn.name]: data.id,
[typeColumn.name]: data[typeField], [typeColumn.name]: data[typeField],
...(joinTable.on || {}), ...(joinTable.on || {}),
order: idx, ...(data.__pivot || {}),
})); }));
if (_.isEmpty(rows)) { if (_.isEmpty(rows)) {
@ -438,13 +470,8 @@ const createEntityManager = db => {
const { joinTable } = attribute; const { joinTable } = attribute;
const { joinColumn, inverseJoinColumn } = joinTable; const { joinColumn, inverseJoinColumn } = joinTable;
// TODO: redefine // TODO: validate logic of delete
// TODO: check it is an id & the entity exists (will throw due to FKs otherwise so not a big pbl in SQL) if (isOneToAny(attribute) && isBidirectional(attribute)) {
if (
['oneToOne', 'oneToMany'].includes(attribute.relation) &&
isBidirectional(attribute)
) {
await this.createQueryBuilder(joinTable.name) await this.createQueryBuilder(joinTable.name)
.delete() .delete()
.where({ [inverseJoinColumn.name]: _.castArray(data[attributeName]) }) .where({ [inverseJoinColumn.name]: _.castArray(data[attributeName]) })
@ -452,11 +479,12 @@ const createEntityManager = db => {
.execute(); .execute();
} }
const insert = _.castArray(data[attributeName]).map(datum => { const insert = toAssocs(data[attributeName]).map(data => {
return { return {
[joinColumn.name]: id, [joinColumn.name]: id,
[inverseJoinColumn.name]: datum, [inverseJoinColumn.name]: data.id,
...(joinTable.on || {}), ...(joinTable.on || {}),
...(data.__pivot || {}),
}; };
}); });
@ -527,12 +555,12 @@ const createEntityManager = db => {
}) })
.execute(); .execute();
const rows = toIds(data[attributeName] || []).map((dataID, idx) => ({ const rows = toAssocs(data[attributeName]).map(data => ({
[joinColumn.name]: dataID, [joinColumn.name]: data.id,
[idColumn.name]: id, [idColumn.name]: id,
[typeColumn.name]: uid, [typeColumn.name]: uid,
...(joinTable.on || {}), ...(joinTable.on || {}),
order: idx, ...(data.__pivot || {}),
})); }));
if (_.isEmpty(rows)) { if (_.isEmpty(rows)) {
@ -566,12 +594,12 @@ const createEntityManager = db => {
}) })
.execute(); .execute();
const rows = _.castArray(data[attributeName] || []).map((data, idx) => ({ const rows = toAssocs(data[attributeName]).map(data => ({
[joinColumn.name]: id, [joinColumn.name]: id,
[idColumn.name]: data.id, [idColumn.name]: data.id,
[typeColumn.name]: data[typeField], [typeColumn.name]: data[typeField],
...(joinTable.on || {}), ...(joinTable.on || {}),
order: idx, ...(data.__pivot || {}),
})); }));
if (_.isEmpty(rows)) { if (_.isEmpty(rows)) {
@ -624,17 +652,18 @@ const createEntityManager = db => {
if (['oneToOne', 'oneToMany'].includes(attribute.relation)) { if (['oneToOne', 'oneToMany'].includes(attribute.relation)) {
await this.createQueryBuilder(joinTable.name) await this.createQueryBuilder(joinTable.name)
.delete() .delete()
.where({ [inverseJoinColumn.name]: _.castArray(data[attributeName] || []) }) .where({ [inverseJoinColumn.name]: toIds(data[attributeName]) })
.where(joinTable.on || {}) .where(joinTable.on || {})
.execute(); .execute();
} }
if (!_.isNull(data[attributeName])) { if (!_.isNull(data[attributeName])) {
const insert = _.castArray(data[attributeName] || []).map(datum => { const insert = toAssocs(data[attributeName]).map(data => {
return { return {
[joinColumn.name]: id, [joinColumn.name]: id,
[inverseJoinColumn.name]: datum, [inverseJoinColumn.name]: data.id,
...(joinTable.on || {}), ...(joinTable.on || {}),
...(data.__pivot || {}),
}; };
}); });
@ -652,9 +681,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 * 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 {EntityManager} em - entity manager instance
* @param {Metadata} metadata - model metadta * @param {Metadata} metadata - model metadta
@ -816,11 +845,8 @@ const createEntityManager = db => {
// custom queries // custom queries
// utilities // utilities
// -> format
// -> parse
// -> map result // -> map result
// -> map input // -> map input
// -> validation
// extra features // extra features
// -> virtuals // -> virtuals

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ const _ = require('lodash/fp');
const hasInversedBy = _.has('inversedBy'); const hasInversedBy = _.has('inversedBy');
const hasMappedBy = _.has('mappedBy'); const hasMappedBy = _.has('mappedBy');
const isOneToAny = attribute => ['oneToOne', 'oneToMany'].includes(attribute.relation);
const isBidirectional = attribute => hasInversedBy(attribute) || hasMappedBy(attribute); const isBidirectional = attribute => hasInversedBy(attribute) || hasMappedBy(attribute);
const isOwner = attribute => !isBidirectional(attribute) || hasInversedBy(attribute); const isOwner = attribute => !isBidirectional(attribute) || hasInversedBy(attribute);
const shouldUseJoinTable = attribute => attribute.useJoinTable !== false; const shouldUseJoinTable = attribute => attribute.useJoinTable !== false;
@ -442,4 +443,5 @@ module.exports = {
createRelation, createRelation,
isBidirectional, isBidirectional,
isOneToAny,
}; };

View File

@ -431,6 +431,7 @@ const applyJoin = (qb, join) => {
rootColumn, rootColumn,
rootTable = this.alias, rootTable = this.alias,
on, on,
orderBy,
} = join; } = join;
qb[method]({ [alias]: referencedTable }, inner => { qb[method]({ [alias]: referencedTable }, inner => {
@ -442,6 +443,13 @@ const applyJoin = (qb, join) => {
} }
} }
}); });
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)); const applyJoins = (qb, joins) => joins.forEach(join => applyJoin(qb, join));
@ -617,6 +625,7 @@ const applyPopulate = async (results, populate, ctx) => {
rootColumn: joinTable.inverseJoinColumn.referencedColumn, rootColumn: joinTable.inverseJoinColumn.referencedColumn,
rootTable: qb.alias, rootTable: qb.alias,
on: joinTable.on, on: joinTable.on,
orderBy: joinTable.orderBy,
}) })
.addSelect(joinColAlias) .addSelect(joinColAlias)
.where({ [joinColAlias]: referencedValues }) .where({ [joinColAlias]: referencedValues })
@ -734,6 +743,7 @@ const applyPopulate = async (results, populate, ctx) => {
rootColumn: joinTable.inverseJoinColumn.referencedColumn, rootColumn: joinTable.inverseJoinColumn.referencedColumn,
rootTable: qb.alias, rootTable: qb.alias,
on: joinTable.on, on: joinTable.on,
orderBy: joinTable.orderBy,
}) })
.addSelect(joinColAlias) .addSelect(joinColAlias)
.where({ [joinColAlias]: referencedValues }) .where({ [joinColAlias]: referencedValues })
@ -812,6 +822,7 @@ const applyPopulate = async (results, populate, ctx) => {
rootColumn: joinTable.inverseJoinColumn.referencedColumn, rootColumn: joinTable.inverseJoinColumn.referencedColumn,
rootTable: qb.alias, rootTable: qb.alias,
on: joinTable.on, on: joinTable.on,
orderBy: joinTable.orderBy,
}) })
.addSelect(joinColAlias) .addSelect(joinColAlias)
.where({ [joinColAlias]: referencedValues }) .where({ [joinColAlias]: referencedValues })
@ -894,6 +905,7 @@ const applyPopulate = async (results, populate, ctx) => {
rootColumn: joinColumn.referencedColumn, rootColumn: joinColumn.referencedColumn,
rootTable: qb.alias, rootTable: qb.alias,
on: joinTable.on, on: joinTable.on,
orderBy: joinTable.orderBy,
}) })
.addSelect([`${alias}.${idColumn.name}`, `${alias}.${typeColumn.name}`]) .addSelect([`${alias}.${idColumn.name}`, `${alias}.${typeColumn.name}`])
.where({ .where({
@ -1078,7 +1090,7 @@ const fromRow = (metadata, row) => {
// TODO: handle default value too // TODO: handle default value too
// TODO: format data & use dialect to know which type they support (json particularly) // TODO: format data & use dialect to know which type they support (json particularly)
const field = createField(attribute.type, attribute); const field = createField(attribute);
// TODO: validate data on creation // TODO: validate data on creation
// field.validate(data[attributeName]); // field.validate(data[attributeName]);

View File

@ -14,31 +14,6 @@ const omitComponentData = (contentType, data) => {
return omit(componentAttributes, data); return omit(componentAttributes, data);
}; };
// 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 // NOTE: we could generalize the logic to allow CRUD of relation directly in the DB layer
const createComponents = async (uid, data) => { const createComponents = async (uid, data) => {
const { attributes } = strapi.getModel(uid); const { attributes } = strapi.getModel(uid);
@ -48,7 +23,7 @@ const createComponents = async (uid, data) => {
for (const attributeName in attributes) { for (const attributeName in attributes) {
const attribute = attributes[attributeName]; const attribute = attributes[attributeName];
if (!has(attributeName, data)) { if (!has(attributeName, data) || !contentTypesUtils.isComponentAttribute(attribute)) {
continue; continue;
} }
@ -71,10 +46,26 @@ const createComponents = async (uid, data) => {
); );
// TODO: add order // TODO: add order
componentBody[attributeName] = components.map(({ id }) => id); componentBody[attributeName] = components.map(({ id }, idx) => {
return {
id,
__pivot: {
order: idx + 1,
field: attributeName,
component_type: componentUID,
},
};
});
} else { } else {
const component = await createComponent(componentUID, componentValue); const component = await createComponent(componentUID, componentValue);
componentBody[attributeName] = component.id; componentBody[attributeName] = {
id: component.id,
__pivot: {
order: 1,
field: attributeName,
component_type: componentUID,
},
};
} }
continue; continue;
@ -88,9 +79,16 @@ const createComponents = async (uid, data) => {
} }
componentBody[attributeName] = await Promise.all( componentBody[attributeName] = await Promise.all(
dynamiczoneValues.map(async value => { dynamiczoneValues.map(async (value, idx) => {
const { id } = await createComponent(value.__component, value); const { id } = await createComponent(value.__component, value);
return { id, __component: value.__component }; return {
id,
__component: value.__component,
__pivot: {
order: idx + 1,
field: attributeName,
},
};
}) })
); );
@ -101,21 +99,6 @@ const createComponents = async (uid, data) => {
return componentBody; 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 delete old components
create or update create or update
@ -260,11 +243,6 @@ const deleteOldDZComponents = async (uid, entityToUpdate, attributeName, dynamic
} }
}; };
const deleteComponent = async (uid, componentToDelete) => {
await deleteComponents(uid, componentToDelete);
await strapi.query(uid).delete({ where: { id: componentToDelete.id } });
};
const deleteComponents = async (uid, entityToDelete) => { const deleteComponents = async (uid, entityToDelete) => {
const { attributes } = strapi.getModel(uid); const { attributes } = strapi.getModel(uid);
@ -305,6 +283,55 @@ const deleteComponents = async (uid, entityToDelete) => {
} }
}; };
/***************************
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 = { module.exports = {
omitComponentData, omitComponentData,
createComponents, createComponents,

View File

@ -70,10 +70,12 @@ const transformParamsToQuery = (uid, params = {}) => {
} }
if (fields) { if (fields) {
// TODO: handle *.* syntax
query.select = _.castArray(fields); query.select = _.castArray(fields);
} }
if (populate) { if (populate) {
// TODO: handle *.* syntax
const { populate } = params; const { populate } = params;
query.populate = typeof populate === 'object' ? populate : _.castArray(populate); query.populate = typeof populate === 'object' ? populate : _.castArray(populate);
} }

View File

@ -154,10 +154,12 @@ const createContentType = (model, { modelName }, { apiName, pluginName } = {}) =
Object.assign(model.attributes, { Object.assign(model.attributes, {
[CREATED_AT_ATTRIBUTE]: { [CREATED_AT_ATTRIBUTE]: {
type: 'datetime', type: 'datetime',
// default: () => new Date(), default: () => new Date(),
}, },
// TODO: handle on edit set to new date
[UPDATED_AT_ATTRIBUTE]: { [UPDATED_AT_ATTRIBUTE]: {
type: 'datetime', type: 'datetime',
default: () => new Date(),
}, },
}); });