/
githubmirror
/
strapi
Обзор
Документация
Войти
/
githubmirror
/
strapi
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
packages/core/database/src/entity-manager/index.ts
1 804 строки
60 KB
Nico André
chore(lint): add non-blocking oxlint setup (#26923)
31 июл 2026, 16:56
Не верифицирован
31 июл 2026, 16:56
6469af3
Код
Авторство
О чём код?
import { castArray, compact, difference, differenceWith, has, isArray, isEmpty, isEqual, isInteger, isNil, isNull, isNumber, isObject, isPlainObject, isString, isUndefined, map, pick, uniqBy, uniqWith, } from 'lodash/fp'; import * as types from '../utils/types'; import { createField } from '../fields'; import { createQueryBuilder } from '../query'; import { createRepository } from './entity-repository'; import { deleteRelatedMorphOneRelationsAfterMorphToManyUpdate, encodePolymorphicRelation, encodePolymorphicId, } from './morph-relations'; import { isBidirectional, isAnyToOne, isOneToAny, hasOrderColumn, hasInverseOrderColumn, } from '../metadata'; import { deletePreviousOneToAnyRelations, deletePreviousAnyToOneRelations, deleteRelations, cleanOrderColumns, } from './regular-relations'; import { relationsOrderer } from './relations-orderer'; import type { Database } from '..'; import type { Meta } from '../metadata'; import type { ID } from '../types'; import { EntityManager, Repository, Entity } from './types'; export * from './types'; /** * Batched join-table insert for SQLite etc. (GH#25198). Uses dialect.getBatchInsertSize(). * All batches run in the same transaction so the operation is atomic; no partial state on failure. * Caller must pass an active transaction (trx) — do not call without one. */ async function batchInsertJoinTable( db: Database, joinTableName: string, rows: Record<string, unknown>[], trx: any, options?: { onConflict?: string[]; merge?: string[]; ignore?: boolean } ): Promise<void> { if (rows.length === 0) return; if (trx == null) { throw new Error( 'batchInsertJoinTable requires a transaction so all batches commit or roll back atomically' ); } const batchSize = db.dialect.getBatchInsertSize(); for (let i = 0; i < rows.length; i += batchSize) { const chunk = rows.slice(i, i + batchSize); let qb = createQueryBuilder(joinTableName, db).insert(chunk).transacting(trx); if (options?.onConflict) { qb = qb.onConflict(options.onConflict); if (options.merge) qb.merge(options.merge); else if (options.ignore) qb.ignore(); } await qb.execute(); } } const isRecord = (value: unknown): value is Record<string, unknown> => isObject(value) && !isNil(value); const toId = (value: unknown | { id: unknown }): ID => { if (isRecord(value) && 'id' in value && isValidId(value.id)) { return value.id; } if (isValidId(value)) { return value; } throw new Error(`Invalid id, expected a string or integer, got ${JSON.stringify(value)}`); }; const toIds = (value: unknown): ID[] => castArray(value || []).map(toId); const isValidId = (value: unknown): value is ID => isString(value) || isInteger(value); const isValidObjectId = (value: unknown): value is Entity => isRecord(value) && 'id' in value && isValidId(value.id); const toIdArray = ( data: unknown ): { id: ID; __pivot?: { [key: string]: any }; [key: string]: any; }[] => { const array = 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 (!isValidObjectId(datum)) { throw new Error(`Invalid id, expected a string or integer, got ${datum}`); } return datum; }); return uniqWith(isEqual, array); }; type ScalarAssoc = string | number | null; type Assocs = | ScalarAssoc | { id: ScalarAssoc | Array<ScalarAssoc> } | Array<ScalarAssoc> | { set?: Array<ScalarAssoc> | null; options?: { strict?: boolean }; connect?: Array<{ id: ScalarAssoc; position?: { start?: boolean; end?: boolean; before?: ID; after?: ID }; __pivot?: any; __type?: any; }> | null; disconnect?: Array<ScalarAssoc> | null; }; const toAssocs = (data: Assocs) => { if ( isArray(data) || isString(data) || isNumber(data) || isNull(data) || (isRecord(data) && 'id' in data) ) { return { set: isNull(data) ? data : toIdArray(data), }; } if (data?.set) { return { set: isNull(data.set) ? data.set : toIdArray(data.set), }; } return { options: { strict: data?.options?.strict, }, connect: toIdArray(data?.connect).map((elm) => ({ id: elm.id, position: elm.position ? elm.position : { end: true }, __pivot: elm.__pivot ?? {}, __type: elm.__type, })), disconnect: toIdArray(data?.disconnect), }; }; const processData = ( metadata: Meta, data: Record<string, unknown> = {}, { withDefaults = false } = {} ) => { const { attributes } = metadata; const obj: Record<string, unknown> = {}; for (const attributeName of Object.keys(attributes)) { const attribute = attributes[attributeName]; if (types.isScalarAttribute(attribute)) { const field = createField(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; } if ( 'validate' in field && typeof field.validate === 'function' && data[attributeName] !== null ) { field.validate(data[attributeName]); } const val = data[attributeName] === null ? null : field.toDB(data[attributeName]); obj[attributeName] = val; } if (types.isRelationalAttribute(attribute)) { // oneToOne & manyToOne if ('joinColumn' in attribute && attribute.joinColumn && attribute.owner) { const joinColumnName = attribute.joinColumn.name; // allow setting to null let attrValue = !isUndefined(data[attributeName]) ? data[attributeName] : data[joinColumnName]; // Legacy single-column storage: only one id fits. Take the last // and warn — modern schemas use a join table that can hold both // the draft and published rows of the related entry. if ( isObject(attrValue) && !Array.isArray(attrValue) && 'set' in attrValue && Array.isArray(attrValue.set) ) { const setIds = attrValue.set; if (setIds.length > 1) { strapi?.log?.warn?.( `Multiple ids provided for xToOne relation "${attributeName}" stored in a single FK column; keeping only the last id. Consider using a join table (useJoinTable: true) to support multiple versions of a Draft-and-Publish target.` ); } attrValue = setIds.length > 0 ? setIds[setIds.length - 1] : null; } if (isNull(attrValue)) { obj[joinColumnName] = attrValue; } else if (!isUndefined(attrValue)) { obj[joinColumnName] = toId(attrValue); } continue; } if ('morphColumn' in attribute && attribute.morphColumn && attribute.owner) { const { idColumn, typeColumn, typeField = '__type' } = attribute.morphColumn; const value = data[attributeName] as Record<string, unknown>; if (value === null) { Object.assign(obj, { [idColumn.name]: null, [typeColumn.name]: null, }); continue; } if (!isUndefined(value)) { if (!has('id', value) || !has(typeField, value)) { throw new Error(`Expects properties ${typeField} an id to make a morph association`); } Object.assign(obj, { [idColumn.name]: value.id, [typeColumn.name]: value[typeField], }); } } } } return obj; }; export const createEntityManager = (db: Database): EntityManager => { const repoMap: Record<string, Repository> = {}; return { async findOne(uid, params) { const states = await db.lifecycles.run('beforeFindOne', uid, { params }); const result = await this.createQueryBuilder(uid) .init(params) .first() .execute<Entity | null>(); await db.lifecycles.run('afterFindOne', uid, { params, result }, states); return result; }, // should we name it findOne because people are used to it ? async findMany(uid, params) { const states = await db.lifecycles.run('beforeFindMany', uid, { params }); const result = await this.createQueryBuilder(uid).init(params).execute<any[]>(); await db.lifecycles.run('afterFindMany', uid, { params, result }, states); return result; }, async count(uid, params = {}) { const states = await db.lifecycles.run('beforeCount', uid, { params }); const res = await this.createQueryBuilder(uid) .init(pick(['_q', 'where', 'filters'], params)) .count() .first() .execute<{ count: number }>(); const result = Number(res.count); await db.lifecycles.run('afterCount', uid, { params, result }, states); return result; }, async create(uid, params = {}) { const states = await db.lifecycles.run('beforeCreate', uid, { params }); const metadata = db.metadata.get(uid); const { data } = params; if (!isPlainObject(data)) { throw new Error('Create expects a data object'); } const dataToInsert = processData(metadata, data, { withDefaults: true }); const res = await this.createQueryBuilder(uid) .insert(dataToInsert) .execute<Array<ID | { id: ID }>>(); const id = isRecord(res[0]) ? res[0].id : res[0]; const trx = await strapi.db.transaction(); try { await this.attachRelations(uid, id, data, { transaction: trx.get() }); await trx.commit(); } catch (e) { await trx.rollback(); await this.createQueryBuilder(uid).where({ id }).delete().execute(); throw e; } // TODO: in case there is no select or populate specified return the inserted data ? // TODO: do not trigger the findOne lifecycles ? const result = await this.findOne(uid, { where: { id }, select: params.select, populate: params.populate, filters: params.filters, }); await db.lifecycles.run('afterCreate', uid, { params, result }, states); return result; }, // TODO: where do we handle relation processing for many queries ? async createMany(uid, params = {}) { const states = await db.lifecycles.run('beforeCreateMany', uid, { params }); const metadata = db.metadata.get(uid); const { data } = params; if (!isArray(data)) { throw new Error('CreateMany expects data to be an array'); } const dataToInsert = data.map((datum) => processData(metadata, datum, { withDefaults: true }) ); if (isEmpty(dataToInsert)) { throw new Error('Nothing to insert'); } const batchSize = db.dialect.getBatchInsertSize(); const trx = await db.transaction(); let createdEntries: Array<ID | { id: ID }> = []; try { for (let i = 0; i < dataToInsert.length; i += batchSize) { const chunk = dataToInsert.slice(i, i + batchSize); const chunkResult = await this.createQueryBuilder(uid) .insert(chunk) .transacting(trx.get()) .execute<Array<ID | { id: ID }>>(); createdEntries = createdEntries.concat( Array.isArray(chunkResult) ? chunkResult : [chunkResult] ); } await trx.commit(); } catch (e) { await trx.rollback(); throw e; } const result = { count: data.length, ids: createdEntries.map((entry) => (typeof entry === 'object' ? entry?.id : entry)), }; await db.lifecycles.run('afterCreateMany', uid, { params, result }, states); return result; }, async update(uid, params = {}) { const states = await db.lifecycles.run('beforeUpdate', uid, { params }); const metadata = db.metadata.get(uid); const { where, data } = params; if (!isPlainObject(data)) { throw new Error('Update requires a data object'); } if (isEmpty(where)) { throw new Error('Update requires a where parameter'); } const entity = await this.createQueryBuilder(uid) .select('*') .where(where) .first() .execute<{ id: ID }>({ mapResults: false }); if (!entity) { return null; } const { id } = entity; const dataToUpdate = processData(metadata, data); if (!isEmpty(dataToUpdate)) { await this.createQueryBuilder(uid).where({ id }).update(dataToUpdate).execute(); } const trx = await strapi.db.transaction(); try { await this.updateRelations(uid, id, data, { transaction: trx.get() }); await trx.commit(); } catch (e) { await trx.rollback(); await this.createQueryBuilder(uid).where({ id }).update(entity).execute(); throw e; } // TODO: do not trigger the findOne lifecycles ? const result = await this.findOne(uid, { where: { id }, select: params.select, populate: params.populate, filters: params.filters, }); await db.lifecycles.run('afterUpdate', uid, { params, result }, states); return result; }, // TODO: where do we handle relation processing for many queries ? async updateMany(uid, params = {}) { const states = await db.lifecycles.run('beforeUpdateMany', uid, { params }); const metadata = db.metadata.get(uid); const { where, data } = params; const dataToUpdate = processData(metadata, data); if (isEmpty(dataToUpdate)) { throw new Error('Update requires data'); } const updatedRows = await this.createQueryBuilder(uid) .where(where) .update(dataToUpdate) .execute<number>(); const result = { count: updatedRows }; await db.lifecycles.run('afterUpdateMany', uid, { params, result }, states); return result; }, async delete(uid, params = {}) { const states = await db.lifecycles.run('beforeDelete', uid, { params }); const { where, select, populate } = params; if (isEmpty(where)) { throw new Error('Delete requires a where parameter'); } // TODO: do not trigger the findOne lifecycles ? const entity = await this.findOne(uid, { select: select && ['id'].concat(select), where, populate, }); if (!entity) { return null; } const { id } = entity; await this.createQueryBuilder(uid).where({ id }).delete().execute(); const trx = await strapi.db.transaction(); try { await this.deleteRelations(uid, id, { transaction: trx.get() }); await trx.commit(); } catch (e) { await trx.rollback(); throw e; } await db.lifecycles.run('afterDelete', uid, { params, result: entity }, states); return entity; }, // TODO: unlike delete(), deleteMany does not run deleteRelations() per removed row. async deleteMany(uid, params = {}) { const states = await db.lifecycles.run('beforeDeleteMany', uid, { params }); // Only apply filter criteria (_q / where / filters), same as count — not full findMany params. // limit, offset, orderBy, populate, etc. must be ignored: populate throws on delete results, // and pagination keys can make deleteMany diverge from findMany or delete an unexpected slice. const deletedRows = await this.createQueryBuilder(uid) .init(pick(['_q', 'where', 'filters'], params)) .delete() .execute<number>({ mapResults: false }); const result = { count: deletedRows }; await db.lifecycles.run('afterDeleteMany', uid, { params, result }, states); return result; }, /** * Attach relations to a new entity */ async attachRelations(uid, id, data, options) { const { attributes } = db.metadata.get(uid); const { transaction: trx } = options ?? {}; for (const attributeName of Object.keys(attributes)) { const attribute = attributes[attributeName]; const isValidLink = has(attributeName, data) && !isNil(data[attributeName]); if (attribute.type !== 'relation' || !isValidLink) { continue; } const cleanRelationData = toAssocs(data[attributeName]); if (attribute.relation === 'morphOne' || attribute.relation === 'morphMany') { /** * morphOne and morphMany relations */ const { target, morphBy } = attribute; const targetAttribute = db.metadata.get(target).attributes[morphBy]; if (targetAttribute.type !== 'relation') { throw new Error( `Expected target attribute ${target}.${morphBy} to be a relation attribute` ); } if (targetAttribute.relation === 'morphToOne') { // set columns const { idColumn, typeColumn } = targetAttribute.morphColumn; const relId = toId(cleanRelationData.set?.[0]); await this.createQueryBuilder(target) .update({ [idColumn.name]: id, [typeColumn.name]: uid }) .where({ id: relId }) .transacting(trx) .execute(); } else if (targetAttribute.relation === 'morphToMany') { const { joinTable } = targetAttribute; const { joinColumn, morphColumn } = joinTable; const { idColumn, typeColumn } = morphColumn; if (isEmpty(cleanRelationData.set)) { continue; } const rows = cleanRelationData.set?.map((data, idx) => { return { [joinColumn.name]: data.id, [idColumn.name]: id, [typeColumn.name]: uid, ...('on' in joinTable && joinTable.on), ...data.__pivot, order: idx + 1, field: attributeName, }; }) ?? []; await batchInsertJoinTable(db, joinTable.name, rows, trx); } continue; } else if (attribute.relation === 'morphToOne') { /** * morphToOne */ // handled on the entry itself continue; } else if (attribute.relation === 'morphToMany') { /** * morphToMany */ const { joinTable } = attribute; const { joinColumn, morphColumn } = joinTable; const { idColumn, typeColumn, typeField = '__type' } = morphColumn; if (isEmpty(cleanRelationData.set) && isEmpty(cleanRelationData.connect)) { continue; } // set happens before connect/disconnect const dataset = cleanRelationData.set || cleanRelationData.connect || []; const rows = dataset.map((data, idx) => ({ [joinColumn.name]: id, [idColumn.name]: data.id, [typeColumn.name]: data[typeField as '__type'], ...('on' in joinTable && joinTable.on), ...data.__pivot, order: idx + 1, })) satisfies Record<string, any>[]; const orderMap = relationsOrderer( [], morphColumn.idColumn.name, 'order', true // Always make a strict connect when inserting ) .connect( // Merge id & __type to get a single id key dataset.map(encodePolymorphicRelation({ idColumn: 'id', typeColumn: typeField })) ) .get() // set the order based on the order of the ids .reduce((acc, rel, idx) => ({ ...acc, [rel.id]: idx + 1 }), {} as Record<ID, number>); rows.forEach((row: Record<string, unknown>) => { const rowId = row[morphColumn.idColumn.name] as ID; const rowType = row[morphColumn.typeColumn.name] as string; const encodedId = encodePolymorphicId(rowId, rowType); row.order = orderMap[encodedId]; }); // delete previous relations await deleteRelatedMorphOneRelationsAfterMorphToManyUpdate(rows as any, { uid, attributeName, joinTable, db, transaction: trx, }); await batchInsertJoinTable(db, joinTable.name, rows, trx); continue; } if ('joinColumn' in attribute && attribute.joinColumn && attribute.owner) { const relIdsToAdd = toIds(cleanRelationData.set); if ( attribute.relation === 'oneToOne' && isBidirectional(attribute) && relIdsToAdd.length ) { await this.createQueryBuilder(uid) .where({ [attribute.joinColumn.name]: relIdsToAdd, id: { $ne: id } }) .update({ [attribute.joinColumn.name]: null }) .transacting(trx) .execute(); } continue; } // oneToOne oneToMany on the non owning side if ('joinColumn' in attribute && attribute.joinColumn && !attribute.owner) { // need to set the column on the target const { target } = attribute; // TODO: check it is an id & the entity exists (will throw due to FKs otherwise so not a big pbl in SQL) const relIdsToAdd = toIds(cleanRelationData.set); await this.createQueryBuilder(target) .where({ [attribute.joinColumn.referencedColumn]: id }) .update({ [attribute.joinColumn.referencedColumn]: null }) .transacting(trx) .execute(); await this.createQueryBuilder(target) .update({ [attribute.joinColumn.referencedColumn]: id }) // NOTE: works if it is an array or a single id .where({ id: relIdsToAdd }) .transacting(trx) .execute(); } if ('joinTable' in attribute && attribute.joinTable) { // need to set the column on the target const { joinTable } = attribute; const { joinColumn, inverseJoinColumn, orderColumnName, inverseOrderColumnName } = joinTable; const relsToAdd = (cleanRelationData.set || cleanRelationData.connect) ?? []; const relIdsToadd = toIds(relsToAdd); if (isBidirectional(attribute) && isOneToAny(attribute)) { await deletePreviousOneToAnyRelations({ id, attribute, relIdsToadd, db, transaction: trx, }); } // prepare new relations to insert const insert = uniqBy('id', relsToAdd).map((data) => { return { [joinColumn.name]: id, [inverseJoinColumn.name]: data.id, ...('on' in joinTable && joinTable.on), ...data.__pivot, }; }) satisfies Record<string, any>[]; // add order value if (cleanRelationData.set && hasOrderColumn(attribute)) { insert.forEach((data: Record<string, unknown>, idx) => { data[orderColumnName] = idx + 1; }); } else if (cleanRelationData.connect && hasOrderColumn(attribute)) { // use position attributes to calculate order const orderMap = relationsOrderer( [], inverseJoinColumn.name, joinTable.orderColumnName, true // Always make an strict connect when inserting ) .connect(relsToAdd) .get() // set the order based on the order of the ids .reduce((acc, rel, idx) => ({ ...acc, [rel.id]: idx }), {} as Record<ID, number>); insert.forEach((row: Record<string, unknown>) => { row[orderColumnName] = orderMap[row[inverseJoinColumn.name] as number]; }); } // add inv_order value if (hasInverseOrderColumn(attribute)) { const maxResults = await db .getConnection() .select(inverseJoinColumn.name) .max(inverseOrderColumnName, { as: 'max' }) .whereIn(inverseJoinColumn.name, relIdsToadd) .where(joinTable.on || {}) .groupBy(inverseJoinColumn.name) .from(joinTable.name) .transacting(trx); const maxMap = maxResults.reduce( (acc, res) => Object.assign(acc, { [res[inverseJoinColumn.name]]: res.max }), {} as Record<string, number> ); insert.forEach((rel) => { rel[inverseOrderColumnName] = (maxMap[rel[inverseJoinColumn.name]] || 0) + 1; }); } if (insert.length === 0) { continue; } // insert new relations await batchInsertJoinTable(db, joinTable.name, insert, trx); } } }, /** * Updates relations of an existing entity */ // TODO: check relation exists (handled by FKs except for polymorphics) async updateRelations(uid, id, data, options) { const { attributes } = db.metadata.get(uid); const { transaction: trx } = options ?? {}; for (const attributeName of Object.keys(attributes)) { const attribute = attributes[attributeName]; if (attribute.type !== 'relation' || !has(attributeName, data)) { continue; } const cleanRelationData = toAssocs(data[attributeName]); if (attribute.relation === 'morphOne' || attribute.relation === 'morphMany') { const { target, morphBy } = attribute; const targetAttribute = db.metadata.get(target).attributes[morphBy]; if (targetAttribute.type === 'relation' && targetAttribute.relation === 'morphToOne') { // set columns const { idColumn, typeColumn } = targetAttribute.morphColumn; // update instead of deleting because the relation is directly on the entity table // and not in a join table await this.createQueryBuilder(target) .update({ [idColumn.name]: null, [typeColumn.name]: null }) .where({ [idColumn.name]: id, [typeColumn.name]: uid }) .transacting(trx) .execute(); if (!isNull(cleanRelationData.set)) { const relId = toIds(cleanRelationData.set?.[0]); await this.createQueryBuilder(target) .update({ [idColumn.name]: id, [typeColumn.name]: uid }) .where({ id: relId }) .transacting(trx) .execute(); } } else if ( targetAttribute.type === 'relation' && targetAttribute.relation === 'morphToMany' ) { const { joinTable } = targetAttribute; const { joinColumn, morphColumn } = joinTable; const { idColumn, typeColumn } = morphColumn; const hasSet = !isEmpty(cleanRelationData.set); const hasConnect = !isEmpty(cleanRelationData.connect); const hasDisconnect = !isEmpty(cleanRelationData.disconnect); // for connect/disconnect without a set, only modify those relations if (!hasSet && (hasConnect || hasDisconnect)) { // delete disconnects and connects (to prevent duplicates when we add them later) const idsToDelete = [ ...(cleanRelationData.disconnect || []), ...(cleanRelationData.connect || []), ]; if (!isEmpty(idsToDelete)) { const where = { $or: idsToDelete.map((item: any) => { return { [idColumn.name]: id, [typeColumn.name]: uid, [joinColumn.name]: item.id, ...joinTable.on, field: attributeName, }; }), }; await this.createQueryBuilder(joinTable.name) .delete() .where(where) .transacting(trx) .execute(); } // connect relations if (hasConnect) { // Query database to find the order of the last relation const start = await this.createQueryBuilder(joinTable.name) .where({ [idColumn.name]: id, [typeColumn.name]: uid, ...joinTable.on, ...data.__pivot, }) .max('order') .first() .transacting(trx) .execute(); const startOrder = (start as any)?.max || 0; const rows = (cleanRelationData.connect ?? []).map((data, idx) => ({ [joinColumn.name]: data.id, [idColumn.name]: id, [typeColumn.name]: uid, ...joinTable.on, ...data.__pivot, order: startOrder + idx + 1, field: attributeName, })) satisfies Record<string, any>[]; await batchInsertJoinTable(db, joinTable.name, rows, trx); } continue; } // delete all relations await this.createQueryBuilder(joinTable.name) .delete() .where({ [idColumn.name]: id, [typeColumn.name]: uid, ...joinTable.on, field: attributeName, }) .transacting(trx) .execute(); if (hasSet) { const rows = (cleanRelationData.set ?? []).map((data, idx) => ({ [joinColumn.name]: data.id, [idColumn.name]: id, [typeColumn.name]: uid, ...joinTable.on, ...data.__pivot, order: idx + 1, field: attributeName, })) satisfies Record<string, any>[]; await batchInsertJoinTable(db, joinTable.name, rows, trx); } } continue; } if (attribute.relation === 'morphToOne') { // handled on the entry itself continue; } if (attribute.relation === 'morphToMany') { const { joinTable } = attribute; const { joinColumn, morphColumn } = joinTable; const { idColumn, typeColumn, typeField = '__type' } = morphColumn; const hasSet = !isEmpty(cleanRelationData.set); const hasConnect = !isEmpty(cleanRelationData.connect); const hasDisconnect = !isEmpty(cleanRelationData.disconnect); // for connect/disconnect without a set, only modify those relations if (!hasSet && (hasConnect || hasDisconnect)) { // delete disconnects and connects (to prevent duplicates when we add them later) const idsToDelete = [ ...(cleanRelationData.disconnect || []), ...(cleanRelationData.connect || []), ]; const rowsToDelete = [ ...(cleanRelationData.disconnect ?? []).map((data, idx) => ({ [joinColumn.name]: id, [idColumn.name]: data.id, [typeColumn.name]: data[typeField], ...('on' in joinTable && joinTable.on), ...data.__pivot, order: idx + 1, })), ...(cleanRelationData.connect ?? []).map((data, idx) => ({ [joinColumn.name]: id, [idColumn.name]: data.id, // @ts-expect-error TODO [typeColumn.name]: data[typeField], ...('on' in joinTable && joinTable.on), ...data.__pivot, order: idx + 1, })), ]; const adjacentRelations = await this.createQueryBuilder(joinTable.name) .where({ $or: [ { [joinColumn.name]: id, [idColumn.name]: { $in: compact( cleanRelationData.connect?.map( (r) => r.position?.after || r.position?.before ) ), }, }, { [joinColumn.name]: id, order: this.createQueryBuilder(joinTable.name) .min('order') .where({ [joinColumn.name]: id }) .where(joinTable.on || {}) .transacting(trx) .getKnexQuery(), }, { [joinColumn.name]: id, order: this.createQueryBuilder(joinTable.name) .max('order') .where({ [joinColumn.name]: id }) .where(joinTable.on || {}) .transacting(trx) .getKnexQuery(), }, ], }) .where(joinTable.on || {}) .orderBy('order') .transacting(trx) .execute<Array<Record<string, any>>>(); if (!isEmpty(idsToDelete)) { const where = { $or: idsToDelete.map((item: any) => { return { [idColumn.name]: item.id, [typeColumn.name]: item[typeField], [joinColumn.name]: id, ...joinTable.on, }; }), }; // delete previous relations await this.createQueryBuilder(joinTable.name) .delete() .where(where) .transacting(trx) .execute(); await deleteRelatedMorphOneRelationsAfterMorphToManyUpdate(rowsToDelete as any, { uid, attributeName, joinTable, db, transaction: trx, }); } // connect relations if (hasConnect) { const dataset = cleanRelationData.connect || []; const rows = dataset.map((data) => ({ [joinColumn.name]: id, [idColumn.name]: data.id, [typeColumn.name]: data[typeField as '__type'], ...joinTable.on, ...data.__pivot, field: attributeName, })) satisfies Record<string, any>[]; const orderMap = relationsOrderer( // Merge id & __type to get a single id key adjacentRelations.map( encodePolymorphicRelation({ idColumn: idColumn.name, typeColumn: typeColumn.name, }) ), idColumn.name, 'order', cleanRelationData.options?.strict ) .connect( // Merge id & __type to get a single id key dataset.map(encodePolymorphicRelation({ idColumn: 'id', typeColumn: '__type' })) ) .getOrderMap(); rows.forEach((row: Record<string, unknown>) => { const rowId = row[idColumn.name] as number; const rowType = row[typeColumn.name] as string; const encodedId = encodePolymorphicId(rowId, rowType); row.order = orderMap[encodedId]; }); await batchInsertJoinTable(db, joinTable.name, rows, trx); } continue; } if (hasSet) { // delete all relations for this entity await this.createQueryBuilder(joinTable.name) .delete() .where({ [joinColumn.name]: id, ...joinTable.on, }) .transacting(trx) .execute(); const rows = (cleanRelationData.set ?? []).map((data, idx) => ({ [joinColumn.name]: id, [idColumn.name]: data.id, [typeColumn.name]: data[typeField], field: attributeName, ...joinTable.on, ...data.__pivot, order: idx + 1, })) satisfies Record<string, any>[]; await deleteRelatedMorphOneRelationsAfterMorphToManyUpdate(rows, { uid, attributeName, joinTable, db, transaction: trx, }); await batchInsertJoinTable(db, joinTable.name, rows, trx); } continue; } if ('joinColumn' in attribute && attribute.joinColumn && attribute.owner) { // handled in the row itself continue; } // oneToOne oneToMany on the non owning side. // Since it is a join column no need to remove previous relations if ('joinColumn' in attribute && attribute.joinColumn && !attribute.owner) { // need to set the column on the target const { target } = attribute; await this.createQueryBuilder(target) .where({ [attribute.joinColumn.referencedColumn]: id }) .update({ [attribute.joinColumn.referencedColumn]: null }) .transacting(trx) .execute(); if (!isNull(cleanRelationData.set)) { const relIdsToAdd = toIds(cleanRelationData.set); await this.createQueryBuilder(target) .where({ id: relIdsToAdd }) .update({ [attribute.joinColumn.referencedColumn]: id }) .transacting(trx) .execute(); } } if (attribute.joinTable) { const { joinTable } = attribute; const { joinColumn, inverseJoinColumn, orderColumnName, inverseOrderColumnName } = joinTable; const select = [joinColumn.name, inverseJoinColumn.name]; if (hasOrderColumn(attribute)) { select.push(orderColumnName); } if (hasInverseOrderColumn(attribute)) { select.push(inverseOrderColumnName); } // only delete relations if (isNull(cleanRelationData.set)) { await deleteRelations({ id, attribute, db, relIdsToDelete: 'all', transaction: trx }); } else { const isPartialUpdate = !has('set', cleanRelationData); let relIdsToaddOrMove: ID[]; if (isPartialUpdate) { if (isAnyToOne(attribute)) { // TODO: V5 find a fix to connect multiple versions of a document at the same time on xToOne relations // cleanRelationData.connect = cleanRelationData.connect?.slice(-1); } relIdsToaddOrMove = toIds(cleanRelationData.connect); // Use id-only comparison so a disconnect item whose id also appears in // the connect array is correctly excluded from deletion (deep-equality // fails because connect items carry extra fields like `position`). const relIdsToDelete = toIds( differenceWith( (a: { id: ID }, b: { id: ID }) => a.id === b.id, cleanRelationData.disconnect, cleanRelationData.connect ?? [] ) ); // When a connect item's position.before/after references an id that is // about to be deleted (relIdsToDelete), the referenced row won't exist by // the time the adjacentRelations query runs below, causing sortConnectArray // to throw. Rewrite such a position to point at the nearest surviving // neighbor in the relation's current order, so the item lands where the // deleted relation used to be instead of always falling back to the end. const idKey = (value: ID) => String(value); const deletedIds = new Set(relIdsToDelete.map(idKey)); let resolvedConnect = cleanRelationData.connect ?? []; if ( hasOrderColumn(attribute) && !isEmpty(relIdsToDelete) && resolvedConnect.some((item) => { const adjacentId = item.position?.before ?? item.position?.after; return adjacentId != null && deletedIds.has(idKey(adjacentId)); }) ) { const currentOrder = await this.createQueryBuilder(joinTable.name) .select([inverseJoinColumn.name, orderColumnName]) .where({ [joinColumn.name]: id }) .where(joinTable.on || {}) .orderBy(orderColumnName) .transacting(trx) .execute<Array<Record<string, any>>>(); const orderedIds = currentOrder.map((rel) => rel[inverseJoinColumn.name]); const orderedIdKeys = orderedIds.map(idKey); const connectIds = new Set(resolvedConnect.map((item) => idKey(item.id))); const deletedIdsInCurrentOrder = new Set( orderedIds.map(idKey).filter((orderedId) => deletedIds.has(orderedId)) ); // A neighbor is only a valid fallback target if it survives the delete // and isn't being moved by this connect payload. const findSurvivingNeighbor = (targetId: ID, direction: 1 | -1) => { let index = orderedIdKeys.indexOf(idKey(targetId)); while (index !== -1) { index += direction; const candidate = orderedIds[index]; if (candidate === undefined) { return undefined; } const candidateKey = idKey(candidate); if (!deletedIds.has(candidateKey) && !connectIds.has(candidateKey)) { return candidate; } } return undefined; }; const positionByConnectId = new Map< ID, NonNullable<(typeof resolvedConnect)[number]['position']> >(); const connectGroups = new Map< string, { targetId: ID; before: typeof resolvedConnect; after: typeof resolvedConnect } >(); const getConnectGroup = (targetId: ID) => { const targetKey = idKey(targetId); let group = connectGroups.get(targetKey); if (!group) { group = { targetId, before: [], after: [] }; connectGroups.set(targetKey, group); } return group; }; resolvedConnect.forEach((item) => { const { before, after } = item.position ?? {}; const adjacentId = before ?? after; if ( adjacentId == null || !deletedIds.has(idKey(adjacentId)) || !deletedIdsInCurrentOrder.has(idKey(adjacentId)) ) { return; } const group = getConnectGroup(adjacentId); if (before) { group.before.push(item); } else { group.after.push(item); } }); connectGroups.forEach(({ targetId, before, after }) => { const previousNeighbor = findSurvivingNeighbor(targetId, -1); const nextNeighbor = findSurvivingNeighbor(targetId, 1); let previousPositionId = previousNeighbor; before.forEach((item) => { if (previousPositionId !== undefined) { positionByConnectId.set(item.id, { after: previousPositionId }); } else if (nextNeighbor !== undefined) { positionByConnectId.set(item.id, { before: nextNeighbor }); } else { positionByConnectId.set(item.id, { start: true }); } previousPositionId = item.id; }); after.forEach((item) => { if (previousPositionId !== undefined) { positionByConnectId.set(item.id, { after: previousPositionId }); } else if (nextNeighbor !== undefined) { positionByConnectId.set(item.id, { before: nextNeighbor }); } else { positionByConnectId.set(item.id, { start: true }); } previousPositionId = item.id; }); }); resolvedConnect = resolvedConnect.map((item) => { const position = positionByConnectId.get(item.id); if (position) { return { ...item, position }; } return item; }); } if (!isEmpty(relIdsToDelete)) { await deleteRelations({ id, attribute, db, relIdsToDelete, transaction: trx }); } if (isEmpty(cleanRelationData.connect)) { continue; } // Fetch current relations to handle ordering let currentMovingRels: Record<string, ID>[] = []; if (hasOrderColumn(attribute) || hasInverseOrderColumn(attribute)) { currentMovingRels = await this.createQueryBuilder(joinTable.name) .select(select) .where({ [joinColumn.name]: id, [inverseJoinColumn.name]: { $in: relIdsToaddOrMove }, }) .where(joinTable.on || {}) .transacting(trx) .execute(); } // prepare relations to insert const insert = uniqBy('id', cleanRelationData.connect).map((relToAdd) => ({ [joinColumn.name]: id, [inverseJoinColumn.name]: relToAdd.id, ...joinTable.on, ...relToAdd.__pivot, })); if (hasOrderColumn(attribute)) { // Get all adjacent relations and the one with the highest order const adjacentRelations = await this.createQueryBuilder(joinTable.name) .where({ $or: [ { [joinColumn.name]: id, [inverseJoinColumn.name]: { $in: compact( resolvedConnect.map((r) => r.position?.after || r.position?.before) ), }, }, { [joinColumn.name]: id, [orderColumnName]: this.createQueryBuilder(joinTable.name) .min(orderColumnName) .where({ [joinColumn.name]: id }) .where(joinTable.on || {}) .transacting(trx) .getKnexQuery(), }, { [joinColumn.name]: id, [orderColumnName]: this.createQueryBuilder(joinTable.name) .max(orderColumnName) .where({ [joinColumn.name]: id }) .where(joinTable.on || {}) .transacting(trx) .getKnexQuery(), }, ], }) .where(joinTable.on || {}) .orderBy(orderColumnName) .transacting(trx) .execute<Array<Record<string, any>>>(); const orderMap = relationsOrderer( adjacentRelations, inverseJoinColumn.name, joinTable.orderColumnName, cleanRelationData.options?.strict ) .connect(resolvedConnect) .getOrderMap(); insert.forEach((row) => { row[orderColumnName] = orderMap[row[inverseJoinColumn.name]]; }); } // add inv order value if (hasInverseOrderColumn(attribute)) { const nonExistingRelsIds: ID[] = difference( relIdsToaddOrMove, map(inverseJoinColumn.name, currentMovingRels) ); const maxResults = await db .getConnection() .select(inverseJoinColumn.name) .max(inverseOrderColumnName, { as: 'max' }) .whereIn(inverseJoinColumn.name, nonExistingRelsIds) .where(joinTable.on || {}) .groupBy(inverseJoinColumn.name) .from(joinTable.name) .transacting(trx); const maxMap = maxResults.reduce( (acc, res) => Object.assign(acc, { [res[inverseJoinColumn.name]]: res.max }), {} ); insert.forEach((row) => { row[inverseOrderColumnName] = (maxMap[row[inverseJoinColumn.name]] || 0) + 1; }); } // insert rows await batchInsertJoinTable(db, joinTable.name, insert, trx, { onConflict: joinTable.pivotColumns, merge: hasOrderColumn(attribute) ? [orderColumnName] : undefined, ignore: !hasOrderColumn(attribute), }); // remove gap between orders await cleanOrderColumns({ attribute, db, id, transaction: trx }); } else { // Keep every row. The payload was already collapsed to a // single related entry upstream; what's left here may still // be two rows for the same entry (its draft and published // sides) and both need to be linked, otherwise the entry // vanishes from the Edit View on save. // overwrite all relations relIdsToaddOrMove = toIds(cleanRelationData.set); await deleteRelations({ id, attribute, db, relIdsToDelete: 'all', relIdsToNotDelete: relIdsToaddOrMove, transaction: trx, }); if (isEmpty(cleanRelationData.set)) { continue; } const insert = uniqBy('id', cleanRelationData.set).map((relToAdd) => ({ [joinColumn.name]: id, [inverseJoinColumn.name]: relToAdd.id, ...joinTable.on, ...relToAdd.__pivot, })); // add order value if (hasOrderColumn(attribute)) { insert.forEach((row, idx) => { row[orderColumnName] = idx + 1; }); } // add inv order value if (hasInverseOrderColumn(attribute)) { const existingRels = await this.createQueryBuilder(joinTable.name) .select(inverseJoinColumn.name) .where({ [joinColumn.name]: id, [inverseJoinColumn.name]: { $in: relIdsToaddOrMove }, }) .where(joinTable.on || {}) .transacting(trx) .execute<Array<Record<string, ID>>>(); const inverseRelsIds = map(inverseJoinColumn.name, existingRels); const nonExistingRelsIds = difference(relIdsToaddOrMove, inverseRelsIds); const maxResults = await db .getConnection() .select(inverseJoinColumn.name) .max(inverseOrderColumnName, { as: 'max' }) .whereIn(inverseJoinColumn.name, nonExistingRelsIds) .where(joinTable.on || {}) .groupBy(inverseJoinColumn.name) .from(joinTable.name) .transacting(trx); const maxMap = maxResults.reduce( (acc, res) => Object.assign(acc, { [res[inverseJoinColumn.name]]: res.max }), {} ); insert.forEach((row: any) => { row[inverseOrderColumnName] = (maxMap[row[inverseJoinColumn.name]] || 0) + 1; }); } // insert rows await batchInsertJoinTable(db, joinTable.name, insert, trx, { onConflict: joinTable.pivotColumns, merge: hasOrderColumn(attribute) ? [orderColumnName] : undefined, ignore: !hasOrderColumn(attribute), }); } // Delete the previous relations for oneToAny relations if (isBidirectional(attribute) && isOneToAny(attribute)) { await deletePreviousOneToAnyRelations({ id, attribute, relIdsToadd: relIdsToaddOrMove, db, transaction: trx, }); } // Delete the previous relations for anyToOne relations if (isAnyToOne(attribute)) { await deletePreviousAnyToOneRelations({ id, attribute, relIdToadd: relIdsToaddOrMove[0], db, transaction: trx, }); } } } } }, /** * 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 dialects that have FKs * * @param {EntityManager} em - entity manager instance * @param {Metadata} metadata - model metadta * @param {ID} id - entity ID */ async deleteRelations(uid, id, options) { const { attributes } = db.metadata.get(uid); const { transaction: trx } = options ?? {}; for (const attributeName of Object.keys(attributes)) { const attribute = attributes[attributeName]; if (attribute.type !== 'relation') { continue; } /* if morphOne | morphMany if morphBy is morphToOne set null if morphBy is morphToOne delete links */ if (attribute.relation === 'morphOne' || attribute.relation === 'morphMany') { const { target, morphBy } = attribute; const targetAttribute = db.metadata.get(target).attributes[morphBy]; if (targetAttribute.type === 'relation' && targetAttribute.relation === 'morphToOne') { // set columns const { idColumn, typeColumn } = targetAttribute.morphColumn; await this.createQueryBuilder(target) .update({ [idColumn.name]: null, [typeColumn.name]: null }) .where({ [idColumn.name]: id, [typeColumn.name]: uid }) .transacting(trx) .execute(); } else if ( targetAttribute.type === 'relation' && targetAttribute.relation === 'morphToMany' ) { const { joinTable } = targetAttribute; const { morphColumn } = joinTable; const { idColumn, typeColumn } = morphColumn; await this.createQueryBuilder(joinTable.name) .delete() .where({ [idColumn.name]: id, [typeColumn.name]: uid, ...joinTable.on, field: attributeName, }) .transacting(trx) .execute(); } continue; } /* if morphToOne nothing to do */ if (attribute.relation === 'morphToOne') { // do nothing } /* if morphToMany delete links */ if (attribute.relation === 'morphToMany') { const { joinTable } = attribute; const { joinColumn } = joinTable; await this.createQueryBuilder(joinTable.name) .delete() .where({ [joinColumn.name]: id, ...joinTable.on, }) .transacting(trx) .execute(); continue; } // do not need to delete links when using foreign keys if (db.dialect.usesForeignKeys()) { continue; } // NOTE: we do not remove existing associations with the target as it should handled by unique FKs instead if ('joinColumn' in attribute && attribute.joinColumn && attribute.owner) { // nothing to do => relation already added on the table continue; } // oneToOne oneToMany on the non owning side. if ('joinColumn' in attribute && attribute.joinColumn && !attribute.owner) { // need to set the column on the target const { target } = attribute; await this.createQueryBuilder(target) .where({ [attribute.joinColumn.referencedColumn]: id }) .update({ [attribute.joinColumn.referencedColumn]: null }) .transacting(trx) .execute(); } if ('joinTable' in attribute && attribute.joinTable) { await deleteRelations({ id, attribute, db, relIdsToDelete: 'all', transaction: trx }); } } }, // TODO: add lifecycle events async populate(uid, entity, populate) { const entry = await this.findOne(uid, { select: ['id'], where: { id: entity.id }, populate, }); return { ...entity, ...entry }; }, // TODO: add lifecycle events async load(uid, entity, fields, populate) { const { attributes } = db.metadata.get(uid); const fieldsArr = castArray(fields); fieldsArr.forEach((field) => { const attribute = attributes[field]; if (!attribute || attribute.type !== 'relation') { throw new Error(`Invalid load. Expected ${field} to be a relational attribute`); } }); const entry = await this.findOne(uid, { select: ['id'], where: { id: entity.id }, populate: fieldsArr.reduce( (acc, field) => { acc[field] = populate || true; return acc; }, {} as Record<string, unknown> ), }); if (!entry) { return null; } if (Array.isArray(fields)) { return pick(fields, entry); } return entry[fields]; }, // cascading // aggregations // -> avg // -> min // -> max // -> grouping // formulas // custom queries // utilities // -> map result // -> map input // extra features // -> virtuals // -> private /** * Insert join-table rows in batches (GH#25198). * Uses dialect.getBatchInsertSize() so SQLite etc. can enforce a safe batch size. * All batches run in the same transaction; caller must pass an active transaction. */ async insertJoinTableRows( joinTableName: string, rows: Record<string, unknown>[], trx: any, options?: { onConflict?: string[]; merge?: string[]; ignore?: boolean } ) { if (rows.length === 0) return; if (trx == null) { throw new Error( 'insertJoinTableRows requires a transaction so all batches commit or roll back atomically' ); } const batchSize = db.dialect.getBatchInsertSize(); for (let i = 0; i < rows.length; i += batchSize) { const chunk = rows.slice(i, i + batchSize); let qb = this.createQueryBuilder(joinTableName).insert(chunk).transacting(trx); if (options?.onConflict) { qb = qb.onConflict(options.onConflict); if (options.merge) qb.merge(options.merge); else if (options.ignore) qb.ignore(); } await qb.execute(); } }, createQueryBuilder(uid) { return createQueryBuilder(uid, db); }, getRepository(uid) { if (!repoMap[uid]) { repoMap[uid] = createRepository(uid, db); } return repoMap[uid]; }, }; };