@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
496 lines (495 loc) • 23.6 kB
JavaScript
import { useEnv } from '@directus/env';
import { ForbiddenError, InvalidPayloadError } from '@directus/errors';
import { createInspector } from '@directus/schema';
import { systemRelationRows } from '@directus/system-data';
import { toArray } from '@directus/utils';
import { clearSystemCache, getCache, getCacheValue, setCacheValue } from '../cache.js';
import { getHelpers } from '../database/helpers/index.js';
import getDatabase, { getSchemaInspector } from '../database/index.js';
import emitter from '../emitter.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 { validateAccess } from '../permissions/modules/validate-access/validate-access.js';
import { getDefaultIndexName } from '../utils/get-default-index-name.js';
import { getSchema } from '../utils/get-schema.js';
import { transaction } from '../utils/transaction.js';
import { ItemsService } from './items.js';
const env = useEnv();
export class RelationsService {
knex;
schemaInspector;
accountability;
schema;
relationsItemService;
systemCache;
schemaCache;
helpers;
constructor(options) {
this.knex = options.knex || getDatabase();
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,
// We don't set accountability here. If you have read access to certain fields, you are
// allowed to extract the relations regardless of permissions to directus_relations. This
// happens in `filterForbidden` down below
});
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}"`,
});
}
// A primary key should not be a foreign key
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` });
}
const existingRelation = this.schema.relations.find((existingRelation) => existingRelation.collection === relation.collection && existingRelation.field === relation.field);
if (existingRelation) {
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);
}
});
}
const relationsItemService = new ItemsService('directus_relations', {
knex: trx,
schema: this.schema,
// We don't set accountability here. If you have read access to certain fields, you are
// allowed to extract the relations regardless of permissions to directus_relations. This
// happens in `filterForbidden` down below
});
await relationsItemService.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.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) => existingRelation.collection === collection && existingRelation.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 the FK already exists in the DB, drop it first
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 || undefined)
.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,
// We don't set accountability here. If you have read access to certain fields, you are
// allowed to extract the relations regardless of permissions to directus_relations. This
// happens in `filterForbidden` down below
});
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.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) => existingRelation.collection === collection && existingRelation.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 existingConstraints = await this.foreignKeys();
const constraintNames = existingConstraints.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: 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.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: 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');
// Maintains the non-nullable state
if (!nullable) {
alterField.notNullable();
}
alterField.alter();
}
}
}