UNPKG

@directus/api

Version:

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

513 lines (511 loc) 23.6 kB
import { ALIAS_TYPES } from "../constants.js"; import { clearSystemCache, getCache } from "../cache.js"; import { getHelpers } from "../database/helpers/index.js"; import database_default, { getSchemaInspector } from "../database/index.js"; import emitter_default from "../emitter.js"; import { validateAccess } from "../permissions/modules/validate-access/validate-access.js"; import { transaction } from "../utils/transaction.js"; import { shouldClearCache } from "../utils/should-clear-cache.js"; import { ItemsService } from "./items.js"; import { getSchema } from "../utils/get-schema.js"; import { getEntitlementManager } from "../license/entitlements/manager.js"; import { buildCollectionAndFieldRelations } from "./fields/build-collection-and-field-relations.js"; import { getCollectionMetaUpdates } from "./fields/get-collection-meta-updates.js"; import { getCollectionRelationList } from "./fields/get-collection-relation-list.js"; import { FieldsService } from "./fields.js"; import "../license/index.js"; import { fetchAllowedCollections } from "../permissions/modules/fetch-allowed-collections/fetch-allowed-collections.js"; import { useEnv } from "@directus/env"; import { ForbiddenError, InvalidPayloadError } from "@directus/errors"; import { addFieldFlag } from "@directus/utils"; import { chunk, groupBy, merge, omit } from "lodash-es"; import { createInspector } from "@directus/schema"; import { systemCollectionRows } from "@directus/system-data"; //#region src/services/collections.ts var CollectionsService = class CollectionsService { knex; helpers; accountability; schemaInspector; schema; cache; systemCache; constructor(options) { this.knex = options.knex || database_default(); this.helpers = getHelpers(this.knex); this.accountability = options.accountability || null; this.schemaInspector = options.knex ? createInspector(options.knex) : getSchemaInspector(); this.schema = options.schema; const { cache, systemCache } = getCache(); this.cache = cache; this.systemCache = systemCache; } /** * Create a single new collection */ async createOne(payload, opts) { if (this.accountability && this.accountability.admin !== true) throw new ForbiddenError(); if (!("collection" in payload)) throw new InvalidPayloadError({ reason: `"collection" is required` }); if (typeof payload.collection !== "string" || payload.collection.trim() === "") throw new InvalidPayloadError({ reason: `"collection" must be a non-empty string` }); if (payload.collection !== payload.collection.trim()) throw new InvalidPayloadError({ reason: `"collection" can't start or end with whitespace` }); if (payload.collection.startsWith("directus_")) throw new InvalidPayloadError({ reason: `"collection" can't start with "directus_"` }); if (payload.collection.includes("/")) throw new InvalidPayloadError({ reason: `"collection" can't contain "/"` }); if (payload.schema && payload.meta && (!("status" in payload.meta) || payload.meta.status === "active")) await getEntitlementManager().assert("collections", { adding: 1, knex: this.knex }); payload.collection = await this.helpers.schema.parseCollectionName(payload.collection); const nestedActionEvents = []; try { if ([...(await this.knex.select("collection").from("directus_collections"))?.map(({ collection }) => collection) ?? [], ...Object.keys(this.schema.collections)].includes(payload.collection)) throw new InvalidPayloadError({ reason: `Collection "${payload.collection}" already exists` }); const attemptConcurrentIndex = Boolean(opts?.attemptConcurrentIndex); await transaction(this.knex, async (trx) => { if (payload.schema) { if ("fields" in payload && !Array.isArray(payload.fields)) throw new InvalidPayloadError({ reason: `"fields" must be an array` }); /** * Directus heavily relies on the primary key of a collection, so we have to make sure that * every collection that is created has a primary key. If no primary key field is created * while making the collection, we default to an auto incremented id named `id` */ const injectedPrimaryKeyField = { field: "id", type: "integer", meta: { hidden: true, interface: "numeric", readonly: true }, schema: { is_primary_key: true, has_auto_increment: true } }; if (!payload.fields || payload.fields.length === 0) payload.fields = [injectedPrimaryKeyField]; else if (!payload.fields.some((f) => f.schema?.is_primary_key === true || f.schema?.has_auto_increment === true)) payload.fields = [injectedPrimaryKeyField, ...payload.fields]; payload.fields = payload.fields.map((field) => { if (field.meta) field.meta = { ...field.meta, field: field.field, collection: payload.collection }; const flagToAdd = this.helpers.date.fieldFlagForField(field.type); if (flagToAdd) addFieldFlag(field, flagToAdd); return field; }); const fieldsService = new FieldsService({ knex: trx, schema: this.schema }); await trx.schema.createTable(payload.collection, (table) => { for (const field of payload.fields) if (field.type && ALIAS_TYPES.includes(field.type) === false) fieldsService.addColumnToTable(table, payload.collection, field, { attemptConcurrentIndex }); }); const fieldItemsService = new ItemsService("directus_fields", { knex: trx, accountability: this.accountability, schema: this.schema }); const fieldPayloads = payload.fields.filter((field) => field.meta).map((field) => field.meta); let sortedFieldPayloads = fieldPayloads.filter((field) => field?.group === void 0 || field?.group === null).map((field, index) => merge({ sort: index + 1 }, field)); if (sortedFieldPayloads.length < fieldPayloads.length) { const fieldsWithGroups = groupBy(fieldPayloads.filter((field) => field?.group), (field) => field?.group); for (const [_group, fields] of Object.entries(fieldsWithGroups)) sortedFieldPayloads = sortedFieldPayloads.concat(fields.map((field, index) => merge({ sort: index + 1 }, field))); } await fieldItemsService.createMany(sortedFieldPayloads, { bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params), bypassLimits: true }); } if (payload.meta) await new ItemsService("directus_collections", { knex: trx, accountability: this.accountability, schema: this.schema }).createOne({ ...payload.meta, collection: payload.collection }, { bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params) }); return payload.collection; }); if (attemptConcurrentIndex && payload.schema && Array.isArray(payload.fields)) { const fieldsService = new FieldsService({ schema: this.schema }); for (const field of payload.fields) if (field.type && ALIAS_TYPES.includes(field.type) === false) await fieldsService.addColumnIndex(payload.collection, field, { attemptConcurrentIndex }); } return payload.collection; } finally { if (shouldClearCache(this.cache, opts)) await this.cache.clear(); if (opts?.autoPurgeSystemCache !== false) { await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache }); await getEntitlementManager().clearCache("collections"); } if (opts?.emitEvents !== false && nestedActionEvents.length > 0) { const updatedSchema = await getSchema(); for (const nestedActionEvent of nestedActionEvents) { nestedActionEvent.context.schema = updatedSchema; emitter_default.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context); } } } } /** * Create multiple new collections */ async createMany(payloads, opts) { const nestedActionEvents = []; try { return await transaction(this.knex, async (trx) => { const service = new CollectionsService({ schema: this.schema, accountability: this.accountability, knex: trx }); const collectionNames = []; for (const payload of payloads) { const name = await service.createOne(payload, { autoPurgeCache: false, autoPurgeSystemCache: false, bypassEmitAction: (params) => nestedActionEvents.push(params), attemptConcurrentIndex: Boolean(opts?.attemptConcurrentIndex) }); collectionNames.push(name); } return collectionNames; }); } finally { if (shouldClearCache(this.cache, opts)) await this.cache.clear(); if (opts?.autoPurgeSystemCache !== false) { await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache }); await getEntitlementManager().clearCache("collections"); } if (opts?.emitEvents !== false && nestedActionEvents.length > 0) { const updatedSchema = await getSchema(); for (const nestedActionEvent of nestedActionEvents) { nestedActionEvent.context.schema = updatedSchema; emitter_default.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context); } } } } /** * Read all collections. Currently doesn't support any query. */ async readByQuery() { const env = useEnv(); const collectionsItemsService = new ItemsService("directus_collections", { knex: this.knex, schema: this.schema, accountability: this.accountability }); let tablesInDatabase = await this.schemaInspector.tableInfo(); let meta = await collectionsItemsService.readByQuery({ limit: -1 }); meta.push(...systemCollectionRows); if (this.accountability && this.accountability.admin !== true) { const collectionsGroups = meta.reduce((meta$1, item) => ({ ...meta$1, [item.collection]: item.group }), {}); let collectionsYouHavePermissionToRead = await fetchAllowedCollections({ accountability: this.accountability, action: "read" }, { knex: this.knex, schema: this.schema }); for (const collection of collectionsYouHavePermissionToRead) { const group = collectionsGroups[collection]; if (group) collectionsYouHavePermissionToRead.push(group); delete collectionsGroups[collection]; } collectionsYouHavePermissionToRead = [...new Set([...collectionsYouHavePermissionToRead])]; tablesInDatabase = tablesInDatabase.filter((table) => { return collectionsYouHavePermissionToRead.includes(table.name); }); meta = meta.filter((collectionMeta) => { return collectionsYouHavePermissionToRead.includes(collectionMeta.collection); }); } const collections = []; for (const collectionMeta of meta) { const collection = { collection: collectionMeta.collection, meta: collectionMeta, schema: tablesInDatabase.find((table) => table.name === collectionMeta.collection) ?? null }; collections.push(collection); } for (const table of tablesInDatabase) if (!!!collections.find(({ collection }) => collection === table.name)) collections.push({ collection: table.name, schema: table, meta: null }); if (env["DB_EXCLUDE_TABLES"]) return collections.filter((collection) => env["DB_EXCLUDE_TABLES"].includes(collection.collection) === false); return collections; } /** * Get a single collection by name */ async readOne(collectionKey) { const result = await this.readMany([collectionKey]); if (result.length === 0) throw new ForbiddenError(); return result[0]; } /** * Read many collections by name */ async readMany(collectionKeys) { if (this.accountability) await Promise.all(collectionKeys.map((collection) => validateAccess({ accountability: this.accountability, action: "read", collection, skipCollectionExistsCheck: true }, { schema: this.schema, knex: this.knex }))); return (await this.readByQuery()).filter(({ collection }) => collectionKeys.includes(collection)); } /** * Update a single collection by name */ async updateOne(collectionKey, payload, opts) { if (this.accountability && this.accountability.admin !== true) throw new ForbiddenError(); const nestedActionEvents = []; try { const collectionsItemsService = new ItemsService("directus_collections", { knex: this.knex, accountability: this.accountability, schema: this.schema }); if (!payload.meta) return collectionKey; if (payload.meta?.status === "active") await getEntitlementManager().assert("collections", { adding: 1, knex: this.knex }); if (!!await this.knex.select("collection").from("directus_collections").where({ collection: collectionKey }).first()) await collectionsItemsService.updateOne(collectionKey, payload.meta, { ...opts, bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params) }); else await collectionsItemsService.createOne({ ...payload.meta, collection: collectionKey }, { ...opts, bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params) }); return collectionKey; } finally { if (shouldClearCache(this.cache, opts)) await this.cache.clear(); if (opts?.autoPurgeSystemCache !== false) { await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache }); await getEntitlementManager().clearCache("collections"); } if (opts?.emitEvents !== false && nestedActionEvents.length > 0) { const updatedSchema = await getSchema(); for (const nestedActionEvent of nestedActionEvents) { nestedActionEvent.context.schema = updatedSchema; emitter_default.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context); } } } } /** * Update multiple collections in a single transaction */ async updateBatch(data, opts) { if (this.accountability && this.accountability.admin !== true) throw new ForbiddenError(); if (!Array.isArray(data)) throw new InvalidPayloadError({ reason: "Input should be an array of collection changes" }); const collectionKey = "collection"; const collectionKeys = []; const nestedActionEvents = []; try { await transaction(this.knex, async (trx) => { const collectionItemsService = new CollectionsService({ knex: trx, accountability: this.accountability, schema: this.schema }); for (const payload of data) { if (!payload[collectionKey]) throw new InvalidPayloadError({ reason: `Collection in update misses collection key` }); await collectionItemsService.updateOne(payload[collectionKey], omit(payload, collectionKey), { autoPurgeCache: false, autoPurgeSystemCache: false, bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params) }); collectionKeys.push(payload[collectionKey]); } }); } finally { if (shouldClearCache(this.cache, opts)) await this.cache.clear(); if (opts?.autoPurgeSystemCache !== false) { await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache }); await getEntitlementManager().clearCache("collections"); } if (opts?.emitEvents !== false && nestedActionEvents.length > 0) { const updatedSchema = await getSchema(); for (const nestedActionEvent of nestedActionEvents) { nestedActionEvent.context.schema = updatedSchema; emitter_default.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context); } } } return collectionKeys; } /** * Update multiple collections by name */ async updateMany(collectionKeys, data, opts) { if (this.accountability && this.accountability.admin !== true) throw new ForbiddenError(); const nestedActionEvents = []; try { await transaction(this.knex, async (trx) => { const service = new CollectionsService({ schema: this.schema, accountability: this.accountability, knex: trx }); for (const collectionKey of collectionKeys) await service.updateOne(collectionKey, data, { autoPurgeCache: false, autoPurgeSystemCache: false, bypassEmitAction: (params) => nestedActionEvents.push(params) }); }); return collectionKeys; } finally { if (shouldClearCache(this.cache, opts)) await this.cache.clear(); if (opts?.autoPurgeSystemCache !== false) { await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache }); await getEntitlementManager().clearCache("collections"); } if (opts?.emitEvents !== false && nestedActionEvents.length > 0) { const updatedSchema = await getSchema(); for (const nestedActionEvent of nestedActionEvents) { nestedActionEvent.context.schema = updatedSchema; emitter_default.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context); } } } } /** * Delete a single collection This will delete the table and all records within. It'll also * delete any fields, presets, activity, revisions, and permissions relating to this collection */ async deleteOne(collectionKey, opts) { if (this.accountability && this.accountability.admin !== true) throw new ForbiddenError(); const nestedActionEvents = []; try { const collectionToBeDeleted = (await this.readByQuery()).find((collection) => collection.collection === collectionKey); if (!!collectionToBeDeleted === false) throw new ForbiddenError(); await transaction(this.knex, async (trx) => { if (collectionToBeDeleted.schema) await trx.schema.dropTable(collectionKey); await trx("directus_collections").update({ group: null }).where({ group: collectionKey }); if (collectionToBeDeleted.meta) await new ItemsService("directus_collections", { knex: trx, accountability: this.accountability, schema: this.schema }).deleteOne(collectionKey, { bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params) }); if (collectionToBeDeleted.schema) { const fieldsService = new FieldsService({ knex: trx, accountability: this.accountability, schema: this.schema }); await new ItemsService("directus_fields", { knex: trx, accountability: this.accountability, schema: this.schema }).deleteByQuery({ filter: { collection: { _eq: collectionKey } } }, { bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params) }); await trx("directus_presets").delete().where("collection", "=", collectionKey); const revisionsToDelete = await trx.select("id").from("directus_revisions").where({ collection: collectionKey }); if (revisionsToDelete.length > 0) { const chunks = chunk(revisionsToDelete.map((record) => record.id), 1e4); for (const keys of chunks) await trx("directus_revisions").update({ parent: null }).whereIn("parent", keys); } await trx("directus_revisions").delete().where("collection", "=", collectionKey); await trx("directus_activity").delete().where("collection", "=", collectionKey); await trx("directus_permissions").delete().where("collection", "=", collectionKey); await trx("directus_relations").delete().where({ many_collection: collectionKey }); const { collectionRelationTree, fieldToCollectionList } = await buildCollectionAndFieldRelations(this.schema.relations); const collectionRelationList = getCollectionRelationList(collectionKey, collectionRelationTree); if (collectionRelationList.size !== 0) { const collectionMetas = await trx.select("collection", "archive_field", "sort_field", "item_duplication_fields").from("directus_collections").whereIn("collection", Array.from(collectionRelationList)).whereNotNull("item_duplication_fields"); await Promise.all(Object.keys(this.schema.collections[collectionKey]?.fields ?? {}).map(async (fieldKey) => { const collectionMetaUpdates = getCollectionMetaUpdates(collectionKey, fieldKey, collectionMetas, this.schema.collections, fieldToCollectionList); for (const meta of collectionMetaUpdates) await trx("directus_collections").update(meta.updates).where({ collection: meta.collection }); })); } const relations = this.schema.relations.filter((relation) => { return relation.collection === collectionKey || relation.related_collection === collectionKey; }); for (const relation of relations) { if (relation.related_collection && relation.meta?.one_field) await fieldsService.deleteField(relation.related_collection, relation.meta.one_field, { autoPurgeCache: false, autoPurgeSystemCache: false, bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params) }); if (relation.related_collection === collectionKey) await fieldsService.deleteField(relation.collection, relation.field, { autoPurgeCache: false, autoPurgeSystemCache: false, bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params) }); } const a2oRelationsThatIncludeThisCollection = this.schema.relations.filter((relation) => { return relation.meta?.one_allowed_collections?.includes(collectionKey); }); for (const relation of a2oRelationsThatIncludeThisCollection) { const newAllowedCollections = relation.meta.one_allowed_collections.filter((collection) => collectionKey !== collection).join(","); await trx("directus_relations").update({ one_allowed_collections: newAllowedCollections }).where({ id: relation.meta.id }); } } }); return collectionKey; } finally { if (shouldClearCache(this.cache, opts)) await this.cache.clear(); if (opts?.autoPurgeSystemCache !== false) { await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache }); await getEntitlementManager().clearCache("collections"); } if (opts?.emitEvents !== false && nestedActionEvents.length > 0) { const updatedSchema = await getSchema(); for (const nestedActionEvent of nestedActionEvents) { nestedActionEvent.context.schema = updatedSchema; emitter_default.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context); } } } } /** * Delete multiple collections by key */ async deleteMany(collectionKeys, opts) { if (this.accountability && this.accountability.admin !== true) throw new ForbiddenError(); const nestedActionEvents = []; try { await transaction(this.knex, async (trx) => { const service = new CollectionsService({ schema: this.schema, accountability: this.accountability, knex: trx }); for (const collectionKey of collectionKeys) await service.deleteOne(collectionKey, { autoPurgeCache: false, autoPurgeSystemCache: false, bypassEmitAction: (params) => nestedActionEvents.push(params) }); }); return collectionKeys; } finally { if (shouldClearCache(this.cache, opts)) await this.cache.clear(); if (opts?.autoPurgeSystemCache !== false) { await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache }); await getEntitlementManager().clearCache("collections"); } if (opts?.emitEvents !== false && nestedActionEvents.length > 0) { const updatedSchema = await getSchema(); for (const nestedActionEvent of nestedActionEvents) { nestedActionEvent.context.schema = updatedSchema; emitter_default.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context); } } } } }; //#endregion export { CollectionsService };