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 { 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,15 @@ 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 => {
return {
[joinColumn.name]: data.id,
[idColumn.name]: id,
[typeColumn.name]: uid,
...(joinTable.on || {}),
...(data.__pivot || {}),
};
});
if (_.isEmpty(rows)) {
continue;
@ -379,12 +411,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 +470,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 +479,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 || {}),
};
});
@ -527,12 +555,12 @@ const createEntityManager = db => {
})
.execute();
const rows = toIds(data[attributeName] || []).map((dataID, idx) => ({
[joinColumn.name]: dataID,
const rows = toAssocs(data[attributeName]).map(data => ({
[joinColumn.name]: data.id,
[idColumn.name]: id,
[typeColumn.name]: uid,
...(joinTable.on || {}),
order: idx,
...(data.__pivot || {}),
}));
if (_.isEmpty(rows)) {
@ -566,12 +594,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 +652,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 +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
* 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
@ -816,11 +845,8 @@ const createEntityManager = db => {
// custom queries
// utilities
// -> format
// -> parse
// -> map result
// -> map input
// -> validation
// extra features
// -> virtuals

View File

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

View File

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

View File

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

View File

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

View File

@ -431,6 +431,7 @@ const applyJoin = (qb, join) => {
rootColumn,
rootTable = this.alias,
on,
orderBy,
} = join;
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));
@ -617,6 +625,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 +743,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 +822,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 })
@ -894,6 +905,7 @@ const applyPopulate = async (results, populate, ctx) => {
rootColumn: joinColumn.referencedColumn,
rootTable: qb.alias,
on: joinTable.on,
orderBy: joinTable.orderBy,
})
.addSelect([`${alias}.${idColumn.name}`, `${alias}.${typeColumn.name}`])
.where({
@ -1078,7 +1090,7 @@ const fromRow = (metadata, row) => {
// TODO: handle default value too
// 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
// field.validate(data[attributeName]);

View File

@ -14,31 +14,6 @@ const omitComponentData = (contentType, 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
const createComponents = async (uid, data) => {
const { attributes } = strapi.getModel(uid);
@ -48,7 +23,7 @@ const createComponents = async (uid, data) => {
for (const attributeName in attributes) {
const attribute = attributes[attributeName];
if (!has(attributeName, data)) {
if (!has(attributeName, data) || !contentTypesUtils.isComponentAttribute(attribute)) {
continue;
}
@ -71,10 +46,26 @@ const createComponents = async (uid, data) => {
);
// 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 {
const component = await createComponent(componentUID, componentValue);
componentBody[attributeName] = component.id;
componentBody[attributeName] = {
id: component.id,
__pivot: {
order: 1,
field: attributeName,
component_type: componentUID,
},
};
}
continue;
@ -88,9 +79,16 @@ const createComponents = async (uid, data) => {
}
componentBody[attributeName] = await Promise.all(
dynamiczoneValues.map(async value => {
dynamiczoneValues.map(async (value, idx) => {
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;
};
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
@ -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 { 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 = {
omitComponentData,
createComponents,

View File

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

View File

@ -154,10 +154,12 @@ const createContentType = (model, { modelName }, { apiName, pluginName } = {}) =
Object.assign(model.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(),
},
});