UNPKG

@directus/api

Version:

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

340 lines (338 loc) 17.2 kB
import { clearSystemCache, getCache, getCacheValue, setCacheValue } from "../cache.js"; import { getDefaultIndexName } from "../utils/get-default-index-name.js"; import { getHelpers } from "../database/helpers/index.js"; import database_default, { getSchemaInspector } from "../database/index.js"; import { fetchAllowedFieldMap } from "../permissions/modules/fetch-allowed-field-map/fetch-allowed-field-map.js"; import { fetchAllowedFields } from "../permissions/modules/fetch-allowed-fields/fetch-allowed-fields.js"; import emitter_default from "../emitter.js"; import { validateAccess } from "../permissions/modules/validate-access/validate-access.js"; import { transaction } from "../utils/transaction.js"; import { ItemsService } from "./items.js"; import { getSchema } from "../utils/get-schema.js"; import { useEnv } from "@directus/env"; import { ForbiddenError, InvalidPayloadError } from "@directus/errors"; import { toArray } from "@directus/utils"; import { createInspector } from "@directus/schema"; import { systemRelationRows } from "@directus/system-data"; //#region src/services/relations.ts const env = useEnv(); var RelationsService = class { knex; schemaInspector; accountability; schema; relationsItemService; systemCache; schemaCache; helpers; constructor(options) { this.knex = options.knex || database_default(); this.schemaInspector = options.knex ? createInspector(options.knex) : getSchemaInspector(); this.schema = options.schema; this.accountability = options.accountability || null; this.relationsItemService = new ItemsService("directus_relations", { knex: this.knex, schema: this.schema }); const cache = getCache(); this.systemCache = cache.systemCache; this.schemaCache = cache.localSchemaCache; this.helpers = getHelpers(this.knex); } async foreignKeys(collection) { const schemaCacheIsEnabled = Boolean(env["CACHE_SCHEMA"]); let foreignKeys = null; if (schemaCacheIsEnabled) foreignKeys = await getCacheValue(this.schemaCache, "foreignKeys"); if (!foreignKeys) { foreignKeys = await this.schemaInspector.foreignKeys(); if (schemaCacheIsEnabled) setCacheValue(this.schemaCache, "foreignKeys", foreignKeys); } if (collection) return foreignKeys.filter((row) => row.table === collection); return foreignKeys; } async readAll(collection, opts, bypassCache) { if (this.accountability) await validateAccess({ accountability: this.accountability, action: "read", collection: "directus_relations" }, { knex: this.knex, schema: this.schema }); const metaReadQuery = { limit: -1 }; if (collection) metaReadQuery.filter = { many_collection: { _eq: collection } }; const metaRows = [...await this.relationsItemService.readByQuery(metaReadQuery, opts), ...systemRelationRows].filter((metaRow) => { if (!collection) return true; return metaRow.many_collection === collection; }); let schemaRows = bypassCache ? await this.schemaInspector.foreignKeys() : await this.foreignKeys(collection); if (collection && bypassCache) schemaRows = schemaRows.filter((row) => row.table === collection); const results = this.stitchRelations(metaRows, schemaRows); return await this.filterForbidden(results); } async readOne(collection, field) { if (this.accountability && this.accountability.admin !== true) { await validateAccess({ accountability: this.accountability, action: "read", collection: "directus_relations" }, { schema: this.schema, knex: this.knex }); const allowedFields = await fetchAllowedFields({ collection, action: "read", accountability: this.accountability }, { schema: this.schema, knex: this.knex }); if (allowedFields.includes("*") === false && allowedFields.includes(field) === false) throw new ForbiddenError(); } const metaRow = await this.relationsItemService.readByQuery({ limit: 1, filter: { _and: [{ many_collection: { _eq: collection } }, { many_field: { _eq: field } }] } }); const schemaRow = (await this.foreignKeys(collection)).find((foreignKey) => foreignKey.column === field); const stitched = this.stitchRelations(metaRow, schemaRow ? [schemaRow] : []); const results = await this.filterForbidden(stitched); if (results.length === 0) throw new ForbiddenError(); return results[0]; } /** * Create a new relationship / foreign key constraint */ async createOne(relation, opts) { if (this.accountability && this.accountability.admin !== true) throw new ForbiddenError(); if (!relation.collection) throw new InvalidPayloadError({ reason: "\"collection\" is required" }); if (!relation.field) throw new InvalidPayloadError({ reason: "\"field\" is required" }); const collectionSchema = this.schema.collections[relation.collection]; if (!collectionSchema) throw new InvalidPayloadError({ reason: `Collection "${relation.collection}" doesn't exist` }); const fieldSchema = collectionSchema.fields[relation.field]; if (!fieldSchema) throw new InvalidPayloadError({ reason: `Field "${relation.field}" doesn't exist in collection "${relation.collection}"` }); if (collectionSchema.primary === relation.field) throw new InvalidPayloadError({ reason: `Field "${relation.field}" in collection "${relation.collection}" is a primary key` }); if (relation.related_collection && relation.related_collection in this.schema.collections === false) throw new InvalidPayloadError({ reason: `Collection "${relation.related_collection}" doesn't exist` }); if (this.schema.relations.find((existingRelation) => existingRelation.collection === relation.collection && existingRelation.field === relation.field)) throw new InvalidPayloadError({ reason: `Field "${relation.field}" in collection "${relation.collection}" already has an associated relationship` }); const runPostColumnChange = await this.helpers.schema.preColumnChange(); this.helpers.schema.preRelationChange(relation); const nestedActionEvents = []; try { const metaRow = { ...relation.meta || {}, many_collection: relation.collection, many_field: relation.field, one_collection: relation.related_collection || null }; await transaction(this.knex, async (trx) => { if (relation.related_collection) await trx.schema.alterTable(relation.collection, async (table) => { this.alterType(table, relation, fieldSchema.nullable); const constraintName = getDefaultIndexName("foreign", relation.collection, relation.field); const builder = table.foreign(relation.field, constraintName).references(`${relation.related_collection}.${this.schema.collections[relation.related_collection].primary}`); if (relation.schema?.on_delete) builder.onDelete(relation.schema.on_delete); if (relation.schema?.on_update) builder.onUpdate(relation.schema.on_update); }); await new ItemsService("directus_relations", { knex: trx, schema: this.schema }).createOne(metaRow, { bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params) }); }); } finally { if (runPostColumnChange) await this.helpers.schema.postColumnChange(); if (opts?.autoPurgeSystemCache !== false) await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache }); 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 an existing foreign key constraint * * Note: You can update anything under meta, but only the `on_delete` trigger under schema */ async updateOne(collection, field, relation, opts) { if (this.accountability && this.accountability.admin !== true) throw new ForbiddenError(); const collectionSchema = this.schema.collections[collection]; if (!collectionSchema) throw new InvalidPayloadError({ reason: `Collection "${collection}" doesn't exist` }); const fieldSchema = collectionSchema.fields[field]; if (!fieldSchema) throw new InvalidPayloadError({ reason: `Field "${field}" doesn't exist in collection "${collection}"` }); const existingRelation = this.schema.relations.find((existingRelation$1) => existingRelation$1.collection === collection && existingRelation$1.field === field); if (!existingRelation) throw new InvalidPayloadError({ reason: `Field "${field}" in collection "${collection}" doesn't have a relationship.` }); const runPostColumnChange = await this.helpers.schema.preColumnChange(); this.helpers.schema.preRelationChange(relation); const nestedActionEvents = []; try { await transaction(this.knex, async (trx) => { if (existingRelation.related_collection) await trx.schema.alterTable(collection, async (table) => { let constraintName = getDefaultIndexName("foreign", collection, field); if (existingRelation?.schema) { constraintName = existingRelation.schema.constraint_name || constraintName; table.dropForeign(field, constraintName); constraintName = this.helpers.schema.constraintName(constraintName); existingRelation.schema.constraint_name = constraintName; } this.alterType(table, relation, fieldSchema.nullable); const builder = table.foreign(field, constraintName || void 0).references(`${existingRelation.related_collection}.${this.schema.collections[existingRelation.related_collection].primary}`); if (relation.schema?.on_delete) builder.onDelete(relation.schema.on_delete); if (relation.schema?.on_update) builder.onUpdate(relation.schema.on_update); }); const relationsItemService = new ItemsService("directus_relations", { knex: trx, schema: this.schema }); if (relation.meta) if (existingRelation?.meta) await relationsItemService.updateOne(existingRelation.meta.id, relation.meta, { bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params) }); else await relationsItemService.createOne({ ...relation.meta || {}, many_collection: relation.collection, many_field: relation.field, one_collection: existingRelation.related_collection || null }, { bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params) }); }); } finally { if (runPostColumnChange) await this.helpers.schema.postColumnChange(); if (opts?.autoPurgeSystemCache !== false) await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache }); 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 an existing relationship */ async deleteOne(collection, field, opts) { if (this.accountability && this.accountability.admin !== true) throw new ForbiddenError(); if (collection in this.schema.collections === false) throw new InvalidPayloadError({ reason: `Collection "${collection}" doesn't exist` }); if (field in this.schema.collections[collection].fields === false) throw new InvalidPayloadError({ reason: `Field "${field}" doesn't exist in collection "${collection}"` }); const existingRelation = this.schema.relations.find((existingRelation$1) => existingRelation$1.collection === collection && existingRelation$1.field === field); if (!existingRelation) throw new InvalidPayloadError({ reason: `Field "${field}" in collection "${collection}" doesn't have a relationship.` }); const runPostColumnChange = await this.helpers.schema.preColumnChange(); const nestedActionEvents = []; try { await transaction(this.knex, async (trx) => { const constraintNames = (await this.foreignKeys()).map((key) => key.constraint_name); if (existingRelation.schema?.constraint_name && constraintNames.includes(existingRelation.schema.constraint_name)) await trx.schema.alterTable(existingRelation.collection, (table) => { table.dropForeign(existingRelation.field, existingRelation.schema.constraint_name); }); if (existingRelation.meta) await trx("directus_relations").delete().where({ many_collection: collection, many_field: field }); const actionEvent = { event: "relations.delete", meta: { payload: [field], collection }, context: { database: this.knex, schema: this.schema, accountability: this.accountability } }; if (opts?.bypassEmitAction) opts.bypassEmitAction(actionEvent); else nestedActionEvents.push(actionEvent); }); } finally { if (runPostColumnChange) await this.helpers.schema.postColumnChange(); if (opts?.autoPurgeSystemCache !== false) await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache }); 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); } } } } /** * Combine raw schema foreign key information with Directus relations meta rows to form final * Relation objects */ stitchRelations(metaRows, schemaRows) { const results = schemaRows.map((foreignKey) => { return { collection: foreignKey.table, field: foreignKey.column, related_collection: foreignKey.foreign_key_table, schema: foreignKey, meta: metaRows.find((meta) => { if (meta.many_collection !== foreignKey.table) return false; if (meta.many_field !== foreignKey.column) return false; if (meta.one_collection && meta.one_collection !== foreignKey.foreign_key_table) return false; return true; }) || null }; }); /** * Meta rows that don't have a corresponding schema foreign key */ const remainingMetaRows = metaRows.filter((meta) => { return !results.find((relation) => relation.meta === meta); }).map((meta) => { return { collection: meta.many_collection, field: meta.many_field, related_collection: meta.one_collection ?? null, schema: null, meta }; }); results.push(...remainingMetaRows); return results; } /** * Loop over all relations and filter out the ones that contain collections/fields you don't have * permissions to */ async filterForbidden(relations) { if (this.accountability === null || this.accountability?.admin === true) return relations; const allowedFields = await fetchAllowedFieldMap({ accountability: this.accountability, action: "read" }, { schema: this.schema, knex: this.knex }); const allowedCollections = Object.keys(allowedFields); relations = toArray(relations); return relations.filter((relation) => { let collectionsAllowed = true; let fieldsAllowed = true; if (allowedCollections.includes(relation.collection) === false) collectionsAllowed = false; if (relation.related_collection && allowedCollections.includes(relation.related_collection) === false) collectionsAllowed = false; if (relation.meta?.one_allowed_collections && relation.meta?.one_allowed_collections.every((collection) => allowedCollections.includes(collection)) === false) collectionsAllowed = false; if (!allowedFields[relation.collection] || allowedFields[relation.collection]?.includes("*") === false && allowedFields[relation.collection]?.includes(relation.field) === false) fieldsAllowed = false; if (relation.related_collection && relation.meta?.one_field && (!allowedFields[relation.related_collection] || allowedFields[relation.related_collection]?.includes("*") === false && allowedFields[relation.related_collection]?.includes(relation.meta.one_field) === false)) fieldsAllowed = false; return collectionsAllowed && fieldsAllowed; }); } /** * MySQL Specific * * MySQL doesn't accept FKs from `int` to `int unsigned`. `knex` defaults `.increments()` to * `unsigned`, but defaults regular `int` to `int`. This means that created m2o fields have the * wrong type. This step will force the m2o `int` field into `unsigned`, but only if both types are * integers, and only if we go from `int` to `int unsigned`. * * @TODO This is a bit of a hack, and might be better of abstracted elsewhere */ alterType(table, relation, nullable) { const m2oFieldDBType = this.schema.collections[relation.collection].fields[relation.field].dbType; const relatedFieldDBType = this.schema.collections[relation.related_collection].fields[this.schema.collections[relation.related_collection].primary].dbType; if (m2oFieldDBType !== relatedFieldDBType && m2oFieldDBType === "int" && relatedFieldDBType === "int unsigned") { const alterField = table.specificType(relation.field, "int unsigned"); if (!nullable) alterField.notNullable(); alterField.alter(); } } }; //#endregion export { RelationsService };