UNPKG

@directus/api

Version:

Directus is a real-time API and App dashboard for managing SQL database content

295 lines (294 loc) 15.3 kB
import { DiffKind } from '@directus/types'; import deepDiff from 'deep-diff'; import { cloneDeep, merge, set } from 'lodash-es'; import { flushCaches } from '../cache.js'; import { getHelpers } from '../database/helpers/index.js'; import getDatabase from '../database/index.js'; import emitter from '../emitter.js'; import { useLogger } from '../logger/index.js'; import { CollectionsService } from '../services/collections.js'; import { FieldsService } from '../services/fields.js'; import { RelationsService } from '../services/relations.js'; import { transaction } from '../utils/transaction.js'; import { getSchema } from './get-schema.js'; const logger = useLogger(); export async function applyDiff(currentSnapshot, snapshotDiff, options) { const database = options?.database ?? getDatabase(); const helpers = getHelpers(database); const schema = options?.schema ?? (await getSchema({ database, bypassCache: true })); const nestedActionEvents = []; const mutationOptions = { autoPurgeSystemCache: false, bypassEmitAction: (params) => nestedActionEvents.push(params), bypassLimits: true, }; const runPostColumnChange = await helpers.schema.preColumnChange(); await transaction(database, async (trx) => { const collectionsService = new CollectionsService({ knex: trx, schema }); const getNestedCollectionsToCreate = (currentLevelCollection) => snapshotDiff.collections.filter(({ diff }) => diff[0].rhs?.meta?.group === currentLevelCollection); const createCollections = async (collections) => { for (const { collection, diff } of collections) { if (diff?.[0]?.kind === DiffKind.NEW && diff[0].rhs) { // We'll nest the to-be-created fields in the same collection creation, to prevent // creating a collection without a primary key const fields = snapshotDiff.fields .filter((fieldDiff) => fieldDiff.collection === collection) .map((fieldDiff) => fieldDiff.diff[0].rhs) .map((fieldDiff) => { // Casts field type to UUID when applying non-PostgreSQL schema onto PostgreSQL database. // This is needed because they snapshots UUID fields as char/varchar with length 36. if (['char', 'varchar'].includes(String(fieldDiff.schema?.data_type).toLowerCase()) && fieldDiff.schema?.max_length === 36 && (fieldDiff.schema?.is_primary_key || (fieldDiff.schema?.foreign_key_table && fieldDiff.schema?.foreign_key_column))) { return merge(fieldDiff, { type: 'uuid', schema: { data_type: 'uuid', max_length: null } }); } else { return fieldDiff; } }); try { await collectionsService.createOne({ ...diff[0].rhs, fields, }, mutationOptions); } catch (err) { logger.error(`Failed to create collection "${collection}"`); throw err; } // Now that the fields are in for this collection, we can strip them from the field edits snapshotDiff.fields = snapshotDiff.fields.filter((fieldDiff) => fieldDiff.collection !== collection); await createCollections(getNestedCollectionsToCreate(collection)); } } }; const deleteCollections = async (collections) => { for (const { collection, diff } of collections) { if (diff?.[0]?.kind === DiffKind.DELETE) { const relations = schema.relations.filter((r) => r.related_collection === collection || r.collection === collection); if (relations.length > 0) { const relationsService = new RelationsService({ knex: trx, schema }); for (const relation of relations) { try { await relationsService.deleteOne(relation.collection, relation.field, mutationOptions); } catch (err) { logger.error(`Failed to delete collection "${collection}" due to relation "${relation.collection}.${relation.field}"`); throw err; } } // clean up deleted relations from existing schema schema.relations = schema.relations.filter((r) => r.related_collection !== collection && r.collection !== collection); } try { await collectionsService.deleteOne(collection, mutationOptions); } catch (err) { logger.error(`Failed to delete collection "${collection}"`); throw err; } } } }; // Finds all collections that need to be created const filterCollectionsForCreation = ({ diff }) => { // Check new collections only const isNewCollection = diff[0]?.kind === DiffKind.NEW; if (!isNewCollection) return false; // Create now if no group const groupName = diff[0].rhs.meta?.group; if (!groupName) return true; // Check if parent collection already exists in schema const parentExists = currentSnapshot.collections.find((c) => c.collection === groupName) !== undefined; // If this is a new collection and the parent collection doesn't exist in current schema -> // Check if the parent collection will be created as part of applying this snapshot -> // If yes -> this collection will be created recursively // If not -> create now // (ex.) // TopLevelCollection - I exist in current schema // NestedCollection - I exist in snapshotDiff as a new collection // TheCurrentCollectionInIteration - I exist in snapshotDiff as a new collection but will be created as part of NestedCollection const parentWillBeCreatedInThisApply = snapshotDiff.collections.filter(({ collection, diff }) => diff[0]?.kind === DiffKind.NEW && collection === groupName).length > 0; // Has group, but parent is not new, parent is also not being created in this snapshot apply if (parentExists && !parentWillBeCreatedInThisApply) return true; return false; }; // Create top level collections (no group, or highest level in existing group) first, // then continue with nested collections recursively await createCollections(snapshotDiff.collections.filter(filterCollectionsForCreation)); const collectionsToDelete = snapshotDiff.collections.filter(({ diff }) => { if (diff.length === 0 || diff[0] === undefined) return false; const collectionDiff = diff[0]; return collectionDiff.kind === DiffKind.DELETE; }); if (collectionsToDelete.length > 0) await deleteCollections(collectionsToDelete); for (const { collection, diff } of snapshotDiff.collections) { if (diff?.[0]?.kind === DiffKind.EDIT || diff?.[0]?.kind === DiffKind.ARRAY) { const currentCollection = currentSnapshot.collections.find((field) => { return field.collection === collection; }); if (currentCollection) { try { const newValues = diff.reduce((acc, currentDiff) => { deepDiff.applyChange(acc, undefined, currentDiff); return acc; }, cloneDeep(currentCollection)); await collectionsService.updateOne(collection, newValues, mutationOptions); } catch (err) { logger.error(`Failed to update collection "${collection}"`); throw err; } } } } let fieldsService = new FieldsService({ knex: trx, schema: await getSchema({ database: trx, bypassCache: true }), }); for (const { collection, field, diff } of snapshotDiff.fields) { if (diff?.[0]?.kind === DiffKind.NEW && !isNestedMetaUpdate(diff?.[0])) { try { await fieldsService.createField(collection, diff[0].rhs, undefined, mutationOptions); // Refresh the schema fieldsService = new FieldsService({ knex: trx, schema: await getSchema({ database: trx, bypassCache: true }), }); } catch (err) { logger.error(`Failed to create field "${collection}.${field}"`); throw err; } } if (diff?.[0]?.kind === DiffKind.EDIT || diff?.[0]?.kind === DiffKind.ARRAY || isNestedMetaUpdate(diff[0])) { const currentField = currentSnapshot.fields.find((snapshotField) => { return snapshotField.collection === collection && snapshotField.field === field; }); if (currentField) { try { const newValues = diff.reduce((acc, currentDiff) => { deepDiff.applyChange(acc, undefined, currentDiff); return acc; }, cloneDeep(currentField)); await fieldsService.updateField(collection, newValues, mutationOptions); } catch (err) { logger.error(`Failed to update field "${collection}.${field}"`); throw err; } } } if (diff?.[0]?.kind === DiffKind.DELETE && !isNestedMetaUpdate(diff?.[0])) { try { await fieldsService.deleteField(collection, field, mutationOptions); // Refresh the schema fieldsService = new FieldsService({ knex: trx, schema: await getSchema({ database: trx, bypassCache: true }), }); } catch (err) { logger.error(`Failed to delete field "${collection}.${field}"`); throw err; } // Field deletion also cleans up the relationship. We should ignore any relationship // changes attached to this now non-existing field except newly created relationship snapshotDiff.relations = snapshotDiff.relations.filter((relation) => (relation.collection === collection && relation.field === field && !relation.diff.some((diff) => diff.kind === DiffKind.NEW)) === false); } } for (const { collection, field, diff } of snapshotDiff.systemFields) { if (diff?.[0]?.kind === DiffKind.EDIT) { try { const newValues = diff.reduce((acc, currentDiff) => { deepDiff.applyChange(acc, undefined, currentDiff); return acc; }, { collection, field }); await fieldsService.updateField(collection, newValues, mutationOptions); } catch (err) { logger.error(`Failed to update field "${collection}.${field}"`); throw err; } } } const relationsService = new RelationsService({ knex: trx, schema: await getSchema({ database: trx, bypassCache: true }), }); for (const { collection, field, diff } of snapshotDiff.relations) { const structure = {}; for (const diffEdit of diff) { set(structure, diffEdit.path, undefined); } if (diff?.[0]?.kind === DiffKind.NEW) { try { await relationsService.createOne({ ...diff[0].rhs, collection, field, }, mutationOptions); } catch (err) { logger.error(`Failed to create relation "${collection}.${field}"`); throw err; } } if (diff?.[0]?.kind === DiffKind.EDIT || diff?.[0]?.kind === DiffKind.ARRAY) { const currentRelation = currentSnapshot.relations.find((relation) => { return relation.collection === collection && relation.field === field; }); if (currentRelation) { try { const newValues = diff.reduce((acc, currentDiff) => { deepDiff.applyChange(acc, undefined, currentDiff); return acc; }, cloneDeep(currentRelation)); await relationsService.updateOne(collection, field, newValues, mutationOptions); } catch (err) { logger.error(`Failed to update relation "${collection}.${field}"`); throw err; } } } if (diff?.[0]?.kind === DiffKind.DELETE) { try { await relationsService.deleteOne(collection, field, mutationOptions); } catch (err) { logger.error(`Failed to delete relation "${collection}.${field}"`); throw err; } } } }); if (runPostColumnChange) { await helpers.schema.postColumnChange(); } await flushCaches(); if (nestedActionEvents.length > 0) { const updatedSchema = await getSchema({ database, bypassCache: true }); for (const nestedActionEvent of nestedActionEvents) { nestedActionEvent.context.schema = updatedSchema; emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context); } } } export function isNestedMetaUpdate(diff) { if (!diff) return false; if (diff.kind !== DiffKind.NEW && diff.kind !== DiffKind.DELETE) return false; if (!diff.path || diff.path.length < 2 || diff.path[0] !== 'meta') return false; return true; }