From d33a363c88afecc7e8ee97e3466cc11dea9898dc Mon Sep 17 00:00:00 2001 From: Alexandre Bodin Date: Fri, 25 Jun 2021 12:07:32 +0200 Subject: [PATCH] Crud + relations Associations v1 --- .../core/admin/config/functions/bootstrap.js | 8 +- packages/core/admin/services/user.js | 65 +-- packages/core/database/examples/index.js | 26 -- packages/core/database/examples/models.js | 9 +- packages/core/database/lib/entity-manager.js | 370 +++++++++++++----- packages/core/database/lib/metadata/index.js | 2 +- packages/core/database/lib/query/helpers.js | 28 +- .../core/database/lib/query/query-builder.js | 7 +- 8 files changed, 303 insertions(+), 212 deletions(-) diff --git a/packages/core/admin/config/functions/bootstrap.js b/packages/core/admin/config/functions/bootstrap.js index c9867dec89..ee3757cac7 100644 --- a/packages/core/admin/config/functions/bootstrap.js +++ b/packages/core/admin/config/functions/bootstrap.js @@ -41,17 +41,17 @@ module.exports = async () => { registerAdminConditions(); registerPermissionActions(); - const permissionService = getService('permission'); + // const permissionService = getService('permission'); const userService = getService('user'); const roleService = getService('role'); await roleService.createRolesIfNoneExist(); - await roleService.resetSuperAdminPermissions(); + // await roleService.resetSuperAdminPermissions(); await roleService.displayWarningIfNoSuperAdmin(); - await permissionService.ensureBoundPermissionsInDatabase(); - await permissionService.cleanPermissionsInDatabase(); + // await permissionService.ensureBoundPermissionsInDatabase(); + // await permissionService.cleanPermissionsInDatabase(); await userService.displayWarningIfUsersDontHaveRole(); diff --git a/packages/core/admin/services/user.js b/packages/core/admin/services/user.js index 7121ff6563..b56307dc1f 100644 --- a/packages/core/admin/services/user.js +++ b/packages/core/admin/services/user.js @@ -256,27 +256,14 @@ const deleteByIds = async ids => { /** Count the users that don't have any associated roles * @returns {Promise} */ -// FIXME: test / cleanup const countUsersWithoutRole = async () => { - return strapi.query('strapi::user').count({ where: { roles: { id: { $null: true } } } }); - - // const userModel = strapi.query('strapi::user').model; - // let count; - - // if (userModel.orm === 'bookshelf') { - // count = await strapi.query('strapi::user').count({ roles_null: true }); - // } else if (userModel.orm === 'mongoose') { - // count = await strapi.query('strapi::user').model.countDocuments({ - // $or: [{ roles: { $exists: false } }, { roles: { $size: 0 } }], - // }); - // } else { - // const allRoles = await strapi.query('role', 'admin').find({ _limit: -1 }); - // count = await strapi.query('strapi::user').count({ - // roles_nin: allRoles.map(r => r.id), - // }); - // } - - // return count; + return strapi.query('strapi::user').count({ + where: { + roles: { + id: { $null: true }, + }, + }, + }); }; /** @@ -291,13 +278,7 @@ const count = async (where = {}) => { /** Assign some roles to several users * @returns {undefined} */ -// TODO: impl with updateMany const assignARoleToAll = async roleId => { - // await strapi.query('strapi::user').updateMany({ - // where: { roles: { id: { $null: true } } }, - // data: { roles: [roleId] }, - // }); - const users = await strapi.query('strapi::user').findMany({ select: ['id'], where: { @@ -305,42 +286,12 @@ const assignARoleToAll = async roleId => { }, }); - console.log(users); - - await strapi.query('strapi;:role').update({ + await strapi.query('strapi::role').update({ where: { id: roleId }, data: { users: users.map(u => u.id), }, }); - - // for (const user of users) { - // await strapi - // .query('strapi::user') - // .update({ where: { id: user.id }, data: { roles: [roleId] } }); - - // // or - - // } - - // const userModel = strapi.query('strapi::user').model; - // if (userModel.orm === 'bookshelf') { - // const assocTable = userModel.associations.find(a => a.alias === 'roles').tableCollectionName; - // const userTable = userModel.collectionName; - // const knex = strapi.connections[userModel.connection]; - // const usersIds = await knex - // .select(`${userTable}.id`) - // .from(userTable) - // .leftJoin(assocTable, `${userTable}.id`, `${assocTable}.user_id`) - // .where(`${assocTable}.role_id`, null) - // .pluck(`${userTable}.id`); - // if (usersIds.length > 0) { - // const newRelations = usersIds.map(userId => ({ user_id: userId, role_id: roleId })); - // await knex.insert(newRelations).into(assocTable); - // } - // } else if (userModel.orm === 'mongoose') { - // await strapi.query('strapi::user').model.updateMany({}, { roles: [roleId] }); - // } }; /** Display a warning if some users don't have at least one role diff --git a/packages/core/database/examples/index.js b/packages/core/database/examples/index.js index cde5ae0b9e..d34f1c52db 100644 --- a/packages/core/database/examples/index.js +++ b/packages/core/database/examples/index.js @@ -19,32 +19,6 @@ async function main() { await orm.schema.reset(); // await orm.schema.sync(); - - // const article = await orm.query('article').create({ - // data: { - // title: 'Test', - // }, - // }); - - // // console.log(article); - - // await orm.query('article').findOne({ - // where: { - // id: 2, - // }, - // }); - - // await orm.query('article').update({ - // data: { - // title: 'Test', - // }, - // }); - - // await orm.query('article').delete({ - // where: { - // id: 2, - // }, - // }); } finally { orm.destroy(); } diff --git a/packages/core/database/examples/models.js b/packages/core/database/examples/models.js index 0292c9fb90..b7df261cc9 100644 --- a/packages/core/database/examples/models.js +++ b/packages/core/database/examples/models.js @@ -22,11 +22,6 @@ const category = { relation: 'oneToMany', target: 'article', mappedBy: 'category', - // useJoinTable: false, - }, - compo: { - type: 'component', - component: 'compo', }, }, }; @@ -52,7 +47,7 @@ const article = { target: 'tag', inversedBy: 'articles', }, - compo: { + compos: { type: 'component', component: 'compo', repeatable: true, @@ -94,7 +89,7 @@ const compo = { type: 'string', }, value: { - type: 'integer', + type: 'string', }, }, }; diff --git a/packages/core/database/lib/entity-manager.js b/packages/core/database/lib/entity-manager.js index 780af21322..4b0176b478 100644 --- a/packages/core/database/lib/entity-manager.js +++ b/packages/core/database/lib/entity-manager.js @@ -26,7 +26,10 @@ const pickRowAttributes = (metadata, data = {}) => { // TODO: ensure joinColumn name respect convention ? const joinColumnName = attribute.joinColumn.name; - const attrValue = data[attributeName] || data[joinColumnName]; + // allow setting to null + const attrValue = !_.isUndefined(data[attributeName]) + ? data[attributeName] + : data[joinColumnName]; if (!_.isUndefined(attrValue)) { obj[joinColumnName] = attrValue; @@ -38,6 +41,45 @@ const pickRowAttributes = (metadata, data = {}) => { return obj; }; +/** + * Attach relations to a new entity + * oneToOne + * if owner + * if joinColumn + * -> Id should have been added in the column of the model table beforehand to avoid extra updates + * if joinTable + * -> add relation + * + * if not owner + * if joinColumn + * -> add relation + * if joinTable + * -> add relation in join table + * + * oneToMany + * owner -> cannot be owner + * not owner + * joinColumn + * -> add relations in target + * joinTable + * -> add relations in join table + * + * manyToOne + * not owner -> must be owner + * owner + * join Column + * -> Id should have been added in the column of the model table beforehand to avoid extra updates + * joinTable + * -> add relation in join table + * + * manyToMany + * -> add relation in join table + * + * @param {EntityManager} em - entity manager instance + * @param {Metadata} metadata - model metadta + * @param {ID} id - entity ID + * @param {object} data - data received for creation + */ const attachRelations = async (em, metadata, id, data) => { const { attributes } = metadata; @@ -80,11 +122,13 @@ const attachRelations = async (em, metadata, id, data) => { return { [joinColumn.name]: id, [inverseJoinColumn.name]: datum, + ...(joinTable.on || {}), }; }) : { [joinColumn.name]: id, [inverseJoinColumn.name]: data[attributeName], + ...(joinTable.on || {}), }; await em @@ -93,49 +137,167 @@ const attachRelations = async (em, metadata, id, data) => { .execute(); } } - - /* - oneToOne - if owner - if joinColumn - TODO: We might actually want to make the column unique and throw -> doing this makes the code generic and doesn't require specific logic - removing existing relation - -> Id should have been added in the column of the model table beforehand to avoid extra updates - if joinTable - -> clear join Table assoc - -> add relation - - if not owner - if joinColumn - remove existing relation - -> add relation - if joinTable - -> clear join Table assoc - -> add relation in join table - - oneToMany - owner -> cannot be owner - not owner - joinColumn - -> add relations in target - joinTable - -> add relations in join table - - manyToOne - not owner -> must be owner - owner - join Column - -> Id should have been added in the column of the model table beforehand to avoid extra updates - joinTable - -> add relation in join table - - manyToMany - -> add relation in join table - - */ } }; +/** + * Updates relations of an existing entity + * oneToOne + * if owner + * if joinColumn + * -> handled in the DB row + * if joinTable + * -> clear join Table assoc + * -> add relation in join table + * + * if not owner + * if joinColumn + * -> set join column on the target + * if joinTable + * -> clear join Table assoc + * -> add relation in join table + * + * oneToMany + * owner -> cannot be owner + * not owner + * joinColumn + * -> set join column on the target + * joinTable + * -> add relations in join table + * + * manyToOne + * not owner -> must be owner + * owner + * join Column + * -> handled in the DB row + * joinTable + * -> add relation in join table + * + * manyToMany + * -> clear join Table assoc + * -> add relation in join table + * + * @param {EntityManager} em - entity manager instance + * @param {Metadata} metadata - model metadta + * @param {ID} id - entity ID + * @param {object} data - data received for creation + */ +// TODO: check relation exists (handled by FKs except for polymorphics) +const updateRelations = async (em, metadata, id, data) => { + const { attributes } = metadata; + + for (const attributeName in attributes) { + const attribute = attributes[attributeName]; + + // NOTE: we do not remove existing associations with the target as it should handled by unique FKs instead + if (attribute.joinColumn && attribute.owner) { + // nothing to do => relation already added on the table + continue; + } + + // oneToOne oneToMany on the non owning side. + // Since it is a join column no need to remove previous relations + if (attribute.joinColumn && !attribute.owner) { + // need to set the column on the target + const { target } = attribute; + + if (data[attributeName]) { + await em + .createQueryBuilder(target) + .update({ [attribute.joinColumn.referencedColumn]: id }) + // NOTE: works if it is an array or a single id + .where({ id: data[attributeName] }) + .execute(); + } + } + + if (attribute.joinTable) { + const { joinTable } = attribute; + const { joinColumn, inverseJoinColumn } = joinTable; + + if (data[attributeName]) { + // clear previous associations in the joinTable + await em + .createQueryBuilder(joinTable.name) + .delete() + .where({ + [joinColumn.name]: id, + }) + // TODO: add join.on filters to only clear the valid info + .where(joinTable.on ? joinTable.on : {}) + .execute(); + + // TODO: add pivot informations too + const insert = Array.isArray(data[attributeName]) + ? data[attributeName].map(datum => { + return { + [joinColumn.name]: id, + [inverseJoinColumn.name]: datum, + ...(joinTable.on || {}), + }; + }) + : { + [joinColumn.name]: id, + [inverseJoinColumn.name]: data[attributeName], + ...(joinTable.on || {}), + }; + + console.log(insert); + + await em + .createQueryBuilder(joinTable.name) + .insert(insert) + .execute(); + } + } + } +}; + +/** + * Delete relations 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 + * + * oneToOne + * if owner + * if joinColumn + * -> handled in the DB row + * if joinTable + * -> clear join Table assoc + * + * if not owner + * if joinColumn + * -> set join column on the target // CASCADING should do the job + * if joinTable + * -> clear join Table assoc // CASCADING + * + * oneToMany + * owner -> cannot be owner + * not owner + * joinColumn + * -> set join column on the target + * joinTable + * -> add relations in join table + * + * manyToOne + * not owner -> must be owner + * owner + * join Column + * -> handled in the DB row + * joinTable + * -> add relation in join table + * + * manyToMany + * -> clear join Table assoc + * -> add relation in join table + * + * @param {EntityManager} em - entity manager instance + * @param {Metadata} metadata - model metadta + * @param {ID} id - entity ID + */ +// noop as cascade FKs does the job +const deleteRelations = () => {}; + const createEntityManager = db => { const repoMap = {}; @@ -161,7 +323,6 @@ const createEntityManager = db => { return await Promise.all([this.findMany(uid, params), this.count(uid, params)]); }, - // TODO: define api async count(uid, params = {}) { const qb = this.createQueryBuilder(uid).where(params.where); @@ -173,23 +334,24 @@ const createEntityManager = db => { return Number(res.count); }, - // TODO: make it create one somehow - async create(uid, params) { + async create(uid, params = {}) { // create entry in DB - const metadata = db.metadata.get(uid); const { data } = params; + if (!_.isPlainObject(data)) { + throw new Error('Create expects a data object'); + } + // transform value to storage value // apply programatic defaults if any -> I think this should be handled outside of this layer as we might have some applicative rules in the entity service - // TODO: in query builder ? const dataToInsert = pickRowAttributes(metadata, data); - if (_.isEmpty(dataToInsert)) { - throw new Error('Create requires data'); - } + // if (_.isEmpty(dataToInsert)) { + // throw new Error('Create requires data'); + // } const [id] = await this.createQueryBuilder(uid) .insert(dataToInsert) @@ -198,19 +360,25 @@ const createEntityManager = db => { // create relation associations or move this to the entity service & call attach on the repo instead await attachRelations(this, metadata, id, data); + // TODO: in case there is not select or populate specified return the inserted data ? + return this.findOne(uid, { where: { id }, select: params.select, populate: params.populate }); }, - async createMany(uid, params) { + // TODO: where do we handle relation processing for many queries ? + async createMany(uid, params = {}) { const { data } = params; + if (!_.isArray(data)) { + throw new Error('CreateMany expecets data to be an array'); + } + const metadata = db.metadata.get(uid); - // Add defaults / transform to storage type const dataToInsert = data.map(datum => pickRowAttributes(metadata, datum)); if (_.isEmpty(dataToInsert)) { - throw new Error('Create requires data'); + throw new Error('Nothing to insert'); } await this.createQueryBuilder(uid) @@ -220,9 +388,43 @@ const createEntityManager = db => { return { count: data.length }; }, - // TODO: make it update one somehow - // findOne + update with a return - async update(uid, params) { + async update(uid, params = {}) { + const { where, data } = params; + const metadata = db.metadata.get(uid); + + if (_.isEmpty(where)) { + throw new Error('Update requires a where parameter'); + } + + const entity = await this.createQueryBuilder(uid) + .select('id') + .where(where) + .first() + .execute(); + + if (!entity) { + // TODO: or throw ? + return null; + } + + const { id } = entity; + + const dataToUpdate = pickRowAttributes(metadata, data); + + if (!_.isEmpty(dataToUpdate)) { + await this.createQueryBuilder(uid) + .where({ id }) + .update(dataToUpdate) + .execute(); + } + + await updateRelations(this, metadata, id, data); + + return this.findOne(uid, { where: { id }, select: params.select, populate: params.populate }); + }, + + // TODO: where do we handle relation processing for many queries ? + async updateMany(uid, params = {}) { const { where, data } = params; const metadata = db.metadata.get(uid); @@ -232,66 +434,54 @@ const createEntityManager = db => { throw new Error('Update requires data'); } - const res = await this.createQueryBuilder(uid) + const updatedRows = await this.createQueryBuilder(uid) .where(where) .update(dataToUpdate) .execute(); - // TODO: update relations - console.log({ res }); - - // TODO: return obj - return {}; + return { count: updatedRows }; }, - // only returns the number of affected rows - async updateMany(uid, params) { - const { where, data } = params; - + async delete(uid, params = {}) { + const { where, select, populate } = params; const metadata = db.metadata.get(uid); - const dataToUpdate = pickRowAttributes(metadata, data); - if (_.isEmpty(dataToUpdate)) { - throw new Error('Update requires data'); + if (_.isEmpty(where)) { + throw new Error('Delete requires a where parameter'); } - const res = await this.createQueryBuilder(uid) - .where(where) - .update(dataToUpdate) - .execute(); + const entity = await this.findOne(uid, { + where, + select: select && ['id'].concat(select), + populate, + }); - console.log({ res }); + if (!entity) { + return null; + } - // TODO: update relations + const { id } = entity; - // TODO: Return count on updateMany - }, - - // TODO: make it deleteOne somehow - // findOne + delete with a return -> should go in the entity service - async delete(uid, params) { - const res = await this.createQueryBuilder(uid) - .init(params) + await this.createQueryBuilder(uid) + .where({ id }) .delete() .execute(); - console.log({ res }); - // TODO: delete relations + await deleteRelations(this, metadata, id); - return res; + return entity; }, - async deleteMany(uid, params) { + // TODO: where do we handle relation processing for many queries ? + async deleteMany(uid, params = {}) { const { where } = params; - const res = await this.createQueryBuilder(uid) + const deletedRows = await this.createQueryBuilder(uid) .where(where) .delete() .execute(); - // TODO: delete relations - - return res; + return { count: deletedRows }; }, // populate already loaded entry diff --git a/packages/core/database/lib/metadata/index.js b/packages/core/database/lib/metadata/index.js index f61f882bd9..97e38b18dc 100644 --- a/packages/core/database/lib/metadata/index.js +++ b/packages/core/database/lib/metadata/index.js @@ -389,7 +389,7 @@ const createCompoLinkModelMeta = baseModelMeta => { return { // TODO: make sure there can't be any conflicts with a prefix // singularName: 'compo', - uid: `${baseModelMeta.uid}_components`, + uid: `${baseModelMeta.tableName}_components`, tableName: `${baseModelMeta.tableName}_components`, attributes: { id: { diff --git a/packages/core/database/lib/query/helpers.js b/packages/core/database/lib/query/helpers.js index 8e948b6f4e..691d3f6a71 100644 --- a/packages/core/database/lib/query/helpers.js +++ b/packages/core/database/lib/query/helpers.js @@ -280,7 +280,6 @@ const applyWhereToColumn = (qb, column, columnWhere) => { return qb.where(column, '<>', value); } - case '$gt': { return qb.where(column, '>', value); } @@ -293,20 +292,16 @@ const applyWhereToColumn = (qb, column, columnWhere) => { case '$lte': { return qb.where(column, '<=', value); } - case '$null': { - return value === true ? qb.whereNull() : qb.whereNotNull(); + return value === true ? qb.whereNull(column) : qb.whereNotNull(column); } - case '$between': { return qb.whereBetween(column, value); } - case '$regexp': { // TODO: return; } - // string // TODO: use $case to make it case insensitive case '$like': { @@ -447,22 +442,20 @@ const processPopulate = (populate, ctx) => { const applyPopulate = async (results, populate, ctx) => { // TODO: cleanup code + // TODO: create aliases for pivot columns + // TODO: remove joinColumn + // TODO: optimize depth to avoid overfetching // TODO: ⚠️ on join tables we might want to make one query to find all the xxx_id then one query instead of a join to avoid returning multiple times the same object const { db, uid, qb } = ctx; const meta = db.metadata.get(uid); - // TODO: support deep populates for (const key in populate) { const populateValue = populate[key]; const attribute = meta.attributes[key]; const targetMeta = db.metadata.get(attribute.target); - // TODO: use query builder directly ? - - // will need some specific code per relation - if (attribute.relation === 'oneToOne' || attribute.relation === 'manyToOne') { if (attribute.joinColumn) { const { @@ -497,7 +490,6 @@ const applyPopulate = async (results, populate, ctx) => { const alias = qb.getAlias(); const rr = await qb .init(populateValue) - .select('*') .join({ alias: alias, referencedTable: joinTable.name, @@ -550,16 +542,12 @@ const applyPopulate = async (results, populate, ctx) => { if (attribute.joinTable) { const { joinTable } = attribute; - // query the target through the join table const qb = db.entityManager.createQueryBuilder(targetMeta.uid); - // TODO: create aliases for the columns - const alias = qb.getAlias(); const rr = await qb .init(populateValue) - .select('*') .join({ alias: alias, referencedTable: joinTable.name, @@ -568,7 +556,6 @@ const applyPopulate = async (results, populate, ctx) => { rootTable: qb.alias, on: joinTable.on, }) - //TODO: select join column .addSelect(`${alias}.${joinTable.joinColumn.name}`) .where({ [`${alias}.${joinTable.joinColumn.name}`]: results.map( @@ -590,16 +577,12 @@ const applyPopulate = async (results, populate, ctx) => { continue; } else if (attribute.relation === 'manyToMany') { const { joinTable } = attribute; - // query the target through the join table const qb = db.entityManager.createQueryBuilder(targetMeta.uid); - // TODO: create aliases for the columns - const alias = qb.getAlias(); const rr = await qb .init(populateValue) - .select('*') .join({ alias: alias, referencedTable: joinTable.name, @@ -618,12 +601,13 @@ const applyPopulate = async (results, populate, ctx) => { const rrMap = _.groupBy(joinTable.joinColumn.name, rr); - // TODO: remove joinColumn results.forEach(r => { Object.assign(r, { [key]: rrMap[r[joinTable.joinColumn.referencedColumn]] || [], }); }); + + continue; } } }; diff --git a/packages/core/database/lib/query/query-builder.js b/packages/core/database/lib/query/query-builder.js index a6c14c8693..3287de2d08 100644 --- a/packages/core/database/lib/query/query-builder.js +++ b/packages/core/database/lib/query/query-builder.js @@ -244,17 +244,14 @@ const createQueryBuilder = (uid, db) => { helpers.applyJoins(qb, state.joins); } - // TODO: hanlde populate - - console.log('Running query: ', qb.toSQL()); + // console.log('Running query: ', qb.toQuery()); const queryResult = await qb; const results = db.dialect.processResult(queryResult, state.type); - // if query response should be process (in case of custom queries we shouldn't for example) - if (state.populate) { + // TODO: hanlde populate await helpers.applyPopulate(_.castArray(results), state.populate, { qb: this, uid, db }); }