1
import { ForbiddenError, InvalidPayloadError } from '@directus/errors';
2
import type { ForeignKey, SchemaInspector } from '@directus/schema';
3
import { createInspector } from '@directus/schema';
4
import { systemRelationRows } from '@directus/system-data';
5
import type { Accountability, Query, Relation, RelationMeta, SchemaOverview } from '@directus/types';
6
import { toArray } from '@directus/utils';
7
import type Keyv from 'keyv';
8
import type { Knex } from 'knex';
9
import { clearSystemCache, getCache } from '../cache.js';
10
import type { Helpers } from '../database/helpers/index.js';
11
import { getHelpers } from '../database/helpers/index.js';
12
import getDatabase, { getSchemaInspector } from '../database/index.js';
13
import emitter from '../emitter.js';
14
import type { AbstractServiceOptions, ActionEventParams, MutationOptions } from '../types/index.js';
15
import { getDefaultIndexName } from '../utils/get-default-index-name.js';
16
import { getSchema } from '../utils/get-schema.js';
17
import { transaction } from '../utils/transaction.js';
18
import { ItemsService, type QueryOptions } from './items.js';
19
import { PermissionsService } from './permissions/index.js';
21
export class RelationsService {
23
permissionsService: PermissionsService;
24
schemaInspector: SchemaInspector;
25
accountability: Accountability | null;
26
schema: SchemaOverview;
27
relationsItemService: ItemsService<RelationMeta>;
28
systemCache: Keyv<any>;
31
constructor(options: AbstractServiceOptions) {
32
this.knex = options.knex || getDatabase();
33
this.permissionsService = new PermissionsService(options);
34
this.schemaInspector = options.knex ? createInspector(options.knex) : getSchemaInspector();
35
this.schema = options.schema;
36
this.accountability = options.accountability || null;
38
this.relationsItemService = new ItemsService('directus_relations', {
46
this.systemCache = getCache().systemCache;
47
this.helpers = getHelpers(this.knex);
50
async readAll(collection?: string, opts?: QueryOptions): Promise<Relation[]> {
51
if (this.accountability && this.accountability.admin !== true && this.hasReadAccess === false) {
52
throw new ForbiddenError();
55
const metaReadQuery: Query = {
60
metaReadQuery.filter = {
68
...(await this.relationsItemService.readByQuery(metaReadQuery, opts)),
69
...systemRelationRows,
70
].filter((metaRow) => {
71
if (!collection) return true;
72
return metaRow.many_collection === collection;
75
const schemaRows = await this.schemaInspector.foreignKeys(collection);
76
const results = this.stitchRelations(metaRows, schemaRows);
77
return await this.filterForbidden(results);
80
async readOne(collection: string, field: string): Promise<Relation> {
81
if (this.accountability && this.accountability.admin !== true) {
82
if (this.hasReadAccess === false) {
83
throw new ForbiddenError();
86
const permissions = this.accountability.permissions?.find((permission) => {
87
return permission.action === 'read' && permission.collection === collection;
90
if (!permissions || !permissions.fields) throw new ForbiddenError();
92
if (permissions.fields.includes('*') === false) {
93
const allowedFields = permissions.fields;
94
if (allowedFields.includes(field) === false) throw new ForbiddenError();
98
const metaRow = await this.relationsItemService.readByQuery({
116
const schemaRow = (await this.schemaInspector.foreignKeys(collection)).find(
117
(foreignKey) => foreignKey.column === field,
120
const stitched = this.stitchRelations(metaRow, schemaRow ? [schemaRow] : []);
121
const results = await this.filterForbidden(stitched);
123
if (results.length === 0) {
124
throw new ForbiddenError();
133
async createOne(relation: Partial<Relation>, opts?: MutationOptions): Promise<void> {
134
if (this.accountability && this.accountability.admin !== true) {
135
throw new ForbiddenError();
138
if (!relation.collection) {
139
throw new InvalidPayloadError({ reason: '"collection" is required' });
142
if (!relation.field) {
143
throw new InvalidPayloadError({ reason: '"field" is required' });
146
const collectionSchema = this.schema.collections[relation.collection];
148
if (!collectionSchema) {
149
throw new InvalidPayloadError({ reason: `Collection "${relation.collection}" doesn't exist` });
152
const fieldSchema = collectionSchema.fields[relation.field];
155
throw new InvalidPayloadError({
156
reason: `Field "${relation.field}" doesn't exist in collection "${relation.collection}"`,
161
if (collectionSchema.primary === relation.field) {
162
throw new InvalidPayloadError({
163
reason: `Field "${relation.field}" in collection "${relation.collection}" is a primary key`,
167
if (relation.related_collection && relation.related_collection in this.schema.collections === false) {
168
throw new InvalidPayloadError({ reason: `Collection "${relation.related_collection}" doesn't exist` });
171
const existingRelation = this.schema.relations.find(
172
(existingRelation) =>
173
existingRelation.collection === relation.collection && existingRelation.field === relation.field,
176
if (existingRelation) {
177
throw new InvalidPayloadError({
178
reason: `Field "${relation.field}" in collection "${relation.collection}" already has an associated relationship`,
182
const runPostColumnChange = await this.helpers.schema.preColumnChange();
183
this.helpers.schema.preRelationChange(relation);
185
const nestedActionEvents: ActionEventParams[] = [];
189
...(relation.meta || {}),
190
many_collection: relation.collection,
191
many_field: relation.field,
192
one_collection: relation.related_collection || null,
195
await transaction(this.knex, async (trx) => {
196
if (relation.related_collection) {
197
await trx.schema.alterTable(relation.collection!, async (table) => {
198
this.alterType(table, relation, fieldSchema.nullable);
200
const constraintName: string = getDefaultIndexName('foreign', relation.collection!, relation.field!);
202
const builder = table
203
.foreign(relation.field!, constraintName)
205
`${relation.related_collection!}.${this.schema.collections[relation.related_collection!]!.primary}`,
208
if (relation.schema?.on_delete) {
209
builder.onDelete(relation.schema.on_delete);
212
if (relation.schema?.on_update) {
213
builder.onUpdate(relation.schema.on_update);
218
const relationsItemService = new ItemsService('directus_relations', {
226
await relationsItemService.createOne(metaRow, {
227
bypassEmitAction: (params) =>
228
opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params),
232
if (runPostColumnChange) {
233
await this.helpers.schema.postColumnChange();
236
if (opts?.autoPurgeSystemCache !== false) {
237
await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache });
240
if (opts?.emitEvents !== false && nestedActionEvents.length > 0) {
241
const updatedSchema = await getSchema();
243
for (const nestedActionEvent of nestedActionEvents) {
244
nestedActionEvent.context.schema = updatedSchema;
245
emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
259
relation: Partial<Relation>,
260
opts?: MutationOptions,
262
if (this.accountability && this.accountability.admin !== true) {
263
throw new ForbiddenError();
266
const collectionSchema = this.schema.collections[collection];
268
if (!collectionSchema) {
269
throw new InvalidPayloadError({ reason: `Collection "${collection}" doesn't exist` });
272
const fieldSchema = collectionSchema.fields[field];
275
throw new InvalidPayloadError({ reason: `Field "${field}" doesn't exist in collection "${collection}"` });
278
const existingRelation = this.schema.relations.find(
279
(existingRelation) => existingRelation.collection === collection && existingRelation.field === field,
282
if (!existingRelation) {
283
throw new InvalidPayloadError({
284
reason: `Field "${field}" in collection "${collection}" doesn't have a relationship.`,
288
const runPostColumnChange = await this.helpers.schema.preColumnChange();
289
this.helpers.schema.preRelationChange(relation);
291
const nestedActionEvents: ActionEventParams[] = [];
294
await transaction(this.knex, async (trx) => {
295
if (existingRelation.related_collection) {
296
await trx.schema.alterTable(collection, async (table) => {
297
let constraintName: string = getDefaultIndexName('foreign', collection, field);
300
if (existingRelation?.schema) {
301
constraintName = existingRelation.schema.constraint_name || constraintName;
302
table.dropForeign(field, constraintName);
304
constraintName = this.helpers.schema.constraintName(constraintName);
305
existingRelation.schema.constraint_name = constraintName;
308
this.alterType(table, relation, fieldSchema.nullable);
310
const builder = table
311
.foreign(field, constraintName || undefined)
313
`${existingRelation.related_collection!}.${
314
this.schema.collections[existingRelation.related_collection!]!.primary
318
if (relation.schema?.on_delete) {
319
builder.onDelete(relation.schema.on_delete);
322
if (relation.schema?.on_update) {
323
builder.onUpdate(relation.schema.on_update);
328
const relationsItemService = new ItemsService('directus_relations', {
337
if (existingRelation?.meta) {
338
await relationsItemService.updateOne(existingRelation.meta.id, relation.meta, {
339
bypassEmitAction: (params) =>
340
opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params),
343
await relationsItemService.createOne(
345
...(relation.meta || {}),
346
many_collection: relation.collection,
347
many_field: relation.field,
348
one_collection: existingRelation.related_collection || null,
351
bypassEmitAction: (params) =>
352
opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params),
359
if (runPostColumnChange) {
360
await this.helpers.schema.postColumnChange();
363
if (opts?.autoPurgeSystemCache !== false) {
364
await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache });
367
if (opts?.emitEvents !== false && nestedActionEvents.length > 0) {
368
const updatedSchema = await getSchema();
370
for (const nestedActionEvent of nestedActionEvents) {
371
nestedActionEvent.context.schema = updatedSchema;
372
emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
381
async deleteOne(collection: string, field: string, opts?: MutationOptions): Promise<void> {
382
if (this.accountability && this.accountability.admin !== true) {
383
throw new ForbiddenError();
386
if (collection in this.schema.collections === false) {
387
throw new InvalidPayloadError({ reason: `Collection "${collection}" doesn't exist` });
390
if (field in this.schema.collections[collection]!.fields === false) {
391
throw new InvalidPayloadError({ reason: `Field "${field}" doesn't exist in collection "${collection}"` });
394
const existingRelation = this.schema.relations.find(
395
(existingRelation) => existingRelation.collection === collection && existingRelation.field === field,
398
if (!existingRelation) {
399
throw new InvalidPayloadError({
400
reason: `Field "${field}" in collection "${collection}" doesn't have a relationship.`,
404
const runPostColumnChange = await this.helpers.schema.preColumnChange();
405
const nestedActionEvents: ActionEventParams[] = [];
408
await transaction(this.knex, async (trx) => {
409
const existingConstraints = await this.schemaInspector.foreignKeys();
410
const constraintNames = existingConstraints.map((key) => key.constraint_name);
413
existingRelation.schema?.constraint_name &&
414
constraintNames.includes(existingRelation.schema.constraint_name)
416
await trx.schema.alterTable(existingRelation.collection, (table) => {
417
table.dropForeign(existingRelation.field, existingRelation.schema!.constraint_name!);
421
if (existingRelation.meta) {
422
await trx('directus_relations').delete().where({ many_collection: collection, many_field: field });
425
const actionEvent = {
426
event: 'relations.delete',
429
collection: collection,
434
accountability: this.accountability,
438
if (opts?.bypassEmitAction) {
439
opts.bypassEmitAction(actionEvent);
441
nestedActionEvents.push(actionEvent);
445
if (runPostColumnChange) {
446
await this.helpers.schema.postColumnChange();
449
if (opts?.autoPurgeSystemCache !== false) {
450
await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache });
453
if (opts?.emitEvents !== false && nestedActionEvents.length > 0) {
454
const updatedSchema = await getSchema();
456
for (const nestedActionEvent of nestedActionEvents) {
457
nestedActionEvent.context.schema = updatedSchema;
458
emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
467
private get hasReadAccess() {
468
return !!this.accountability?.permissions?.find((permission) => {
469
return permission.collection === 'directus_relations' && permission.action === 'read';
477
private stitchRelations(metaRows: RelationMeta[], schemaRows: ForeignKey[]) {
478
const results = schemaRows.map((foreignKey): Relation => {
480
collection: foreignKey.table,
481
field: foreignKey.column,
482
related_collection: foreignKey.foreign_key_table,
485
metaRows.find((meta) => {
486
if (meta.many_collection !== foreignKey.table) return false;
487
if (meta.many_field !== foreignKey.column) return false;
488
if (meta.one_collection && meta.one_collection !== foreignKey.foreign_key_table) return false;
497
const remainingMetaRows = metaRows
499
return !results.find((relation) => relation.meta === meta);
501
.map((meta): Relation => {
503
collection: meta.many_collection,
504
field: meta.many_field,
505
related_collection: meta.one_collection ?? null,
511
results.push(...remainingMetaRows);
520
private async filterForbidden(relations: Relation[]): Promise<Relation[]> {
521
if (this.accountability === null || this.accountability?.admin === true) return relations;
523
const allowedCollections =
524
this.accountability.permissions
525
?.filter((permission) => {
526
return permission.action === 'read';
528
.map(({ collection }) => collection) ?? [];
530
const allowedFields = this.permissionsService.getAllowedFields('read');
532
relations = toArray(relations);
534
return relations.filter((relation) => {
535
let collectionsAllowed = true;
536
let fieldsAllowed = true;
538
if (allowedCollections.includes(relation.collection) === false) {
539
collectionsAllowed = false;
542
if (relation.related_collection && allowedCollections.includes(relation.related_collection) === false) {
543
collectionsAllowed = false;
547
relation.meta?.one_allowed_collections &&
548
relation.meta?.one_allowed_collections.every((collection) => allowedCollections.includes(collection)) === false
550
collectionsAllowed = false;
554
!allowedFields[relation.collection] ||
555
(allowedFields[relation.collection]?.includes('*') === false &&
556
allowedFields[relation.collection]?.includes(relation.field) === false)
558
fieldsAllowed = false;
562
relation.related_collection &&
563
relation.meta?.one_field &&
564
(!allowedFields[relation.related_collection] ||
565
(allowedFields[relation.related_collection]?.includes('*') === false &&
566
allowedFields[relation.related_collection]?.includes(relation.meta.one_field) === false))
568
fieldsAllowed = false;
571
return collectionsAllowed && fieldsAllowed;
585
private alterType(table: Knex.TableBuilder, relation: Partial<Relation>, nullable: boolean) {
586
const m2oFieldDBType = this.schema.collections[relation.collection!]!.fields[relation.field!]!.dbType;
588
const relatedFieldDBType =
589
this.schema.collections[relation.related_collection!]!.fields[
590
this.schema.collections[relation.related_collection!]!.primary
593
if (m2oFieldDBType !== relatedFieldDBType && m2oFieldDBType === 'int' && relatedFieldDBType === 'int unsigned') {
594
const alterField = table.specificType(relation.field!, 'int unsigned');
598
alterField.notNullable();