directus

Форк
0
/
relations.ts 
604 строки · 19.1 Кб
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';
20

21
export class RelationsService {
22
	knex: Knex;
23
	permissionsService: PermissionsService;
24
	schemaInspector: SchemaInspector;
25
	accountability: Accountability | null;
26
	schema: SchemaOverview;
27
	relationsItemService: ItemsService<RelationMeta>;
28
	systemCache: Keyv<any>;
29
	helpers: Helpers;
30

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;
37

38
		this.relationsItemService = new ItemsService('directus_relations', {
39
			knex: this.knex,
40
			schema: this.schema,
41
			// We don't set accountability here. If you have read access to certain fields, you are
42
			// allowed to extract the relations regardless of permissions to directus_relations. This
43
			// happens in `filterForbidden` down below
44
		});
45

46
		this.systemCache = getCache().systemCache;
47
		this.helpers = getHelpers(this.knex);
48
	}
49

50
	async readAll(collection?: string, opts?: QueryOptions): Promise<Relation[]> {
51
		if (this.accountability && this.accountability.admin !== true && this.hasReadAccess === false) {
52
			throw new ForbiddenError();
53
		}
54

55
		const metaReadQuery: Query = {
56
			limit: -1,
57
		};
58

59
		if (collection) {
60
			metaReadQuery.filter = {
61
				many_collection: {
62
					_eq: collection,
63
				},
64
			};
65
		}
66

67
		const metaRows = [
68
			...(await this.relationsItemService.readByQuery(metaReadQuery, opts)),
69
			...systemRelationRows,
70
		].filter((metaRow) => {
71
			if (!collection) return true;
72
			return metaRow.many_collection === collection;
73
		});
74

75
		const schemaRows = await this.schemaInspector.foreignKeys(collection);
76
		const results = this.stitchRelations(metaRows, schemaRows);
77
		return await this.filterForbidden(results);
78
	}
79

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();
84
			}
85

86
			const permissions = this.accountability.permissions?.find((permission) => {
87
				return permission.action === 'read' && permission.collection === collection;
88
			});
89

90
			if (!permissions || !permissions.fields) throw new ForbiddenError();
91

92
			if (permissions.fields.includes('*') === false) {
93
				const allowedFields = permissions.fields;
94
				if (allowedFields.includes(field) === false) throw new ForbiddenError();
95
			}
96
		}
97

98
		const metaRow = await this.relationsItemService.readByQuery({
99
			limit: 1,
100
			filter: {
101
				_and: [
102
					{
103
						many_collection: {
104
							_eq: collection,
105
						},
106
					},
107
					{
108
						many_field: {
109
							_eq: field,
110
						},
111
					},
112
				],
113
			},
114
		});
115

116
		const schemaRow = (await this.schemaInspector.foreignKeys(collection)).find(
117
			(foreignKey) => foreignKey.column === field,
118
		);
119

120
		const stitched = this.stitchRelations(metaRow, schemaRow ? [schemaRow] : []);
121
		const results = await this.filterForbidden(stitched);
122

123
		if (results.length === 0) {
124
			throw new ForbiddenError();
125
		}
126

127
		return results[0]!;
128
	}
129

130
	/**
131
	 * Create a new relationship / foreign key constraint
132
	 */
133
	async createOne(relation: Partial<Relation>, opts?: MutationOptions): Promise<void> {
134
		if (this.accountability && this.accountability.admin !== true) {
135
			throw new ForbiddenError();
136
		}
137

138
		if (!relation.collection) {
139
			throw new InvalidPayloadError({ reason: '"collection" is required' });
140
		}
141

142
		if (!relation.field) {
143
			throw new InvalidPayloadError({ reason: '"field" is required' });
144
		}
145

146
		const collectionSchema = this.schema.collections[relation.collection];
147

148
		if (!collectionSchema) {
149
			throw new InvalidPayloadError({ reason: `Collection "${relation.collection}" doesn't exist` });
150
		}
151

152
		const fieldSchema = collectionSchema.fields[relation.field];
153

154
		if (!fieldSchema) {
155
			throw new InvalidPayloadError({
156
				reason: `Field "${relation.field}" doesn't exist in collection "${relation.collection}"`,
157
			});
158
		}
159

160
		// A primary key should not be a foreign key
161
		if (collectionSchema.primary === relation.field) {
162
			throw new InvalidPayloadError({
163
				reason: `Field "${relation.field}" in collection "${relation.collection}" is a primary key`,
164
			});
165
		}
166

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` });
169
		}
170

171
		const existingRelation = this.schema.relations.find(
172
			(existingRelation) =>
173
				existingRelation.collection === relation.collection && existingRelation.field === relation.field,
174
		);
175

176
		if (existingRelation) {
177
			throw new InvalidPayloadError({
178
				reason: `Field "${relation.field}" in collection "${relation.collection}" already has an associated relationship`,
179
			});
180
		}
181

182
		const runPostColumnChange = await this.helpers.schema.preColumnChange();
183
		this.helpers.schema.preRelationChange(relation);
184

185
		const nestedActionEvents: ActionEventParams[] = [];
186

187
		try {
188
			const metaRow = {
189
				...(relation.meta || {}),
190
				many_collection: relation.collection,
191
				many_field: relation.field,
192
				one_collection: relation.related_collection || null,
193
			};
194

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);
199

200
						const constraintName: string = getDefaultIndexName('foreign', relation.collection!, relation.field!);
201

202
						const builder = table
203
							.foreign(relation.field!, constraintName)
204
							.references(
205
								`${relation.related_collection!}.${this.schema.collections[relation.related_collection!]!.primary}`,
206
							);
207

208
						if (relation.schema?.on_delete) {
209
							builder.onDelete(relation.schema.on_delete);
210
						}
211

212
						if (relation.schema?.on_update) {
213
							builder.onUpdate(relation.schema.on_update);
214
						}
215
					});
216
				}
217

218
				const relationsItemService = new ItemsService('directus_relations', {
219
					knex: trx,
220
					schema: this.schema,
221
					// We don't set accountability here. If you have read access to certain fields, you are
222
					// allowed to extract the relations regardless of permissions to directus_relations. This
223
					// happens in `filterForbidden` down below
224
				});
225

226
				await relationsItemService.createOne(metaRow, {
227
					bypassEmitAction: (params) =>
228
						opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params),
229
				});
230
			});
231
		} finally {
232
			if (runPostColumnChange) {
233
				await this.helpers.schema.postColumnChange();
234
			}
235

236
			if (opts?.autoPurgeSystemCache !== false) {
237
				await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache });
238
			}
239

240
			if (opts?.emitEvents !== false && nestedActionEvents.length > 0) {
241
				const updatedSchema = await getSchema();
242

243
				for (const nestedActionEvent of nestedActionEvents) {
244
					nestedActionEvent.context.schema = updatedSchema;
245
					emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
246
				}
247
			}
248
		}
249
	}
250

251
	/**
252
	 * Update an existing foreign key constraint
253
	 *
254
	 * Note: You can update anything under meta, but only the `on_delete` trigger under schema
255
	 */
256
	async updateOne(
257
		collection: string,
258
		field: string,
259
		relation: Partial<Relation>,
260
		opts?: MutationOptions,
261
	): Promise<void> {
262
		if (this.accountability && this.accountability.admin !== true) {
263
			throw new ForbiddenError();
264
		}
265

266
		const collectionSchema = this.schema.collections[collection];
267

268
		if (!collectionSchema) {
269
			throw new InvalidPayloadError({ reason: `Collection "${collection}" doesn't exist` });
270
		}
271

272
		const fieldSchema = collectionSchema.fields[field];
273

274
		if (!fieldSchema) {
275
			throw new InvalidPayloadError({ reason: `Field "${field}" doesn't exist in collection "${collection}"` });
276
		}
277

278
		const existingRelation = this.schema.relations.find(
279
			(existingRelation) => existingRelation.collection === collection && existingRelation.field === field,
280
		);
281

282
		if (!existingRelation) {
283
			throw new InvalidPayloadError({
284
				reason: `Field "${field}" in collection "${collection}" doesn't have a relationship.`,
285
			});
286
		}
287

288
		const runPostColumnChange = await this.helpers.schema.preColumnChange();
289
		this.helpers.schema.preRelationChange(relation);
290

291
		const nestedActionEvents: ActionEventParams[] = [];
292

293
		try {
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);
298

299
						// If the FK already exists in the DB, drop it first
300
						if (existingRelation?.schema) {
301
							constraintName = existingRelation.schema.constraint_name || constraintName;
302
							table.dropForeign(field, constraintName);
303

304
							constraintName = this.helpers.schema.constraintName(constraintName);
305
							existingRelation.schema.constraint_name = constraintName;
306
						}
307

308
						this.alterType(table, relation, fieldSchema.nullable);
309

310
						const builder = table
311
							.foreign(field, constraintName || undefined)
312
							.references(
313
								`${existingRelation.related_collection!}.${
314
									this.schema.collections[existingRelation.related_collection!]!.primary
315
								}`,
316
							);
317

318
						if (relation.schema?.on_delete) {
319
							builder.onDelete(relation.schema.on_delete);
320
						}
321

322
						if (relation.schema?.on_update) {
323
							builder.onUpdate(relation.schema.on_update);
324
						}
325
					});
326
				}
327

328
				const relationsItemService = new ItemsService('directus_relations', {
329
					knex: trx,
330
					schema: this.schema,
331
					// We don't set accountability here. If you have read access to certain fields, you are
332
					// allowed to extract the relations regardless of permissions to directus_relations. This
333
					// happens in `filterForbidden` down below
334
				});
335

336
				if (relation.meta) {
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),
341
						});
342
					} else {
343
						await relationsItemService.createOne(
344
							{
345
								...(relation.meta || {}),
346
								many_collection: relation.collection,
347
								many_field: relation.field,
348
								one_collection: existingRelation.related_collection || null,
349
							},
350
							{
351
								bypassEmitAction: (params) =>
352
									opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params),
353
							},
354
						);
355
					}
356
				}
357
			});
358
		} finally {
359
			if (runPostColumnChange) {
360
				await this.helpers.schema.postColumnChange();
361
			}
362

363
			if (opts?.autoPurgeSystemCache !== false) {
364
				await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache });
365
			}
366

367
			if (opts?.emitEvents !== false && nestedActionEvents.length > 0) {
368
				const updatedSchema = await getSchema();
369

370
				for (const nestedActionEvent of nestedActionEvents) {
371
					nestedActionEvent.context.schema = updatedSchema;
372
					emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
373
				}
374
			}
375
		}
376
	}
377

378
	/**
379
	 * Delete an existing relationship
380
	 */
381
	async deleteOne(collection: string, field: string, opts?: MutationOptions): Promise<void> {
382
		if (this.accountability && this.accountability.admin !== true) {
383
			throw new ForbiddenError();
384
		}
385

386
		if (collection in this.schema.collections === false) {
387
			throw new InvalidPayloadError({ reason: `Collection "${collection}" doesn't exist` });
388
		}
389

390
		if (field in this.schema.collections[collection]!.fields === false) {
391
			throw new InvalidPayloadError({ reason: `Field "${field}" doesn't exist in collection "${collection}"` });
392
		}
393

394
		const existingRelation = this.schema.relations.find(
395
			(existingRelation) => existingRelation.collection === collection && existingRelation.field === field,
396
		);
397

398
		if (!existingRelation) {
399
			throw new InvalidPayloadError({
400
				reason: `Field "${field}" in collection "${collection}" doesn't have a relationship.`,
401
			});
402
		}
403

404
		const runPostColumnChange = await this.helpers.schema.preColumnChange();
405
		const nestedActionEvents: ActionEventParams[] = [];
406

407
		try {
408
			await transaction(this.knex, async (trx) => {
409
				const existingConstraints = await this.schemaInspector.foreignKeys();
410
				const constraintNames = existingConstraints.map((key) => key.constraint_name);
411

412
				if (
413
					existingRelation.schema?.constraint_name &&
414
					constraintNames.includes(existingRelation.schema.constraint_name)
415
				) {
416
					await trx.schema.alterTable(existingRelation.collection, (table) => {
417
						table.dropForeign(existingRelation.field, existingRelation.schema!.constraint_name!);
418
					});
419
				}
420

421
				if (existingRelation.meta) {
422
					await trx('directus_relations').delete().where({ many_collection: collection, many_field: field });
423
				}
424

425
				const actionEvent = {
426
					event: 'relations.delete',
427
					meta: {
428
						payload: [field],
429
						collection: collection,
430
					},
431
					context: {
432
						database: this.knex,
433
						schema: this.schema,
434
						accountability: this.accountability,
435
					},
436
				};
437

438
				if (opts?.bypassEmitAction) {
439
					opts.bypassEmitAction(actionEvent);
440
				} else {
441
					nestedActionEvents.push(actionEvent);
442
				}
443
			});
444
		} finally {
445
			if (runPostColumnChange) {
446
				await this.helpers.schema.postColumnChange();
447
			}
448

449
			if (opts?.autoPurgeSystemCache !== false) {
450
				await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache });
451
			}
452

453
			if (opts?.emitEvents !== false && nestedActionEvents.length > 0) {
454
				const updatedSchema = await getSchema();
455

456
				for (const nestedActionEvent of nestedActionEvents) {
457
					nestedActionEvent.context.schema = updatedSchema;
458
					emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
459
				}
460
			}
461
		}
462
	}
463

464
	/**
465
	 * Whether or not the current user has read access to relations
466
	 */
467
	private get hasReadAccess() {
468
		return !!this.accountability?.permissions?.find((permission) => {
469
			return permission.collection === 'directus_relations' && permission.action === 'read';
470
		});
471
	}
472

473
	/**
474
	 * Combine raw schema foreign key information with Directus relations meta rows to form final
475
	 * Relation objects
476
	 */
477
	private stitchRelations(metaRows: RelationMeta[], schemaRows: ForeignKey[]) {
478
		const results = schemaRows.map((foreignKey): Relation => {
479
			return {
480
				collection: foreignKey.table,
481
				field: foreignKey.column,
482
				related_collection: foreignKey.foreign_key_table,
483
				schema: foreignKey,
484
				meta:
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;
489
						return true;
490
					}) || null,
491
			};
492
		});
493

494
		/**
495
		 * Meta rows that don't have a corresponding schema foreign key
496
		 */
497
		const remainingMetaRows = metaRows
498
			.filter((meta) => {
499
				return !results.find((relation) => relation.meta === meta);
500
			})
501
			.map((meta): Relation => {
502
				return {
503
					collection: meta.many_collection,
504
					field: meta.many_field,
505
					related_collection: meta.one_collection ?? null,
506
					schema: null,
507
					meta: meta,
508
				};
509
			});
510

511
		results.push(...remainingMetaRows);
512

513
		return results;
514
	}
515

516
	/**
517
	 * Loop over all relations and filter out the ones that contain collections/fields you don't have
518
	 * permissions to
519
	 */
520
	private async filterForbidden(relations: Relation[]): Promise<Relation[]> {
521
		if (this.accountability === null || this.accountability?.admin === true) return relations;
522

523
		const allowedCollections =
524
			this.accountability.permissions
525
				?.filter((permission) => {
526
					return permission.action === 'read';
527
				})
528
				.map(({ collection }) => collection) ?? [];
529

530
		const allowedFields = this.permissionsService.getAllowedFields('read');
531

532
		relations = toArray(relations);
533

534
		return relations.filter((relation) => {
535
			let collectionsAllowed = true;
536
			let fieldsAllowed = true;
537

538
			if (allowedCollections.includes(relation.collection) === false) {
539
				collectionsAllowed = false;
540
			}
541

542
			if (relation.related_collection && allowedCollections.includes(relation.related_collection) === false) {
543
				collectionsAllowed = false;
544
			}
545

546
			if (
547
				relation.meta?.one_allowed_collections &&
548
				relation.meta?.one_allowed_collections.every((collection) => allowedCollections.includes(collection)) === false
549
			) {
550
				collectionsAllowed = false;
551
			}
552

553
			if (
554
				!allowedFields[relation.collection] ||
555
				(allowedFields[relation.collection]?.includes('*') === false &&
556
					allowedFields[relation.collection]?.includes(relation.field) === false)
557
			) {
558
				fieldsAllowed = false;
559
			}
560

561
			if (
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))
567
			) {
568
				fieldsAllowed = false;
569
			}
570

571
			return collectionsAllowed && fieldsAllowed;
572
		});
573
	}
574

575
	/**
576
	 * MySQL Specific
577
	 *
578
	 * MySQL doesn't accept FKs from `int` to `int unsigned`. `knex` defaults `.increments()` to
579
	 * `unsigned`, but defaults regular `int` to `int`. This means that created m2o fields have the
580
	 * wrong type. This step will force the m2o `int` field into `unsigned`, but only if both types are
581
	 * integers, and only if we go from `int` to `int unsigned`.
582
	 *
583
	 * @TODO This is a bit of a hack, and might be better of abstracted elsewhere
584
	 */
585
	private alterType(table: Knex.TableBuilder, relation: Partial<Relation>, nullable: boolean) {
586
		const m2oFieldDBType = this.schema.collections[relation.collection!]!.fields[relation.field!]!.dbType;
587

588
		const relatedFieldDBType =
589
			this.schema.collections[relation.related_collection!]!.fields[
590
				this.schema.collections[relation.related_collection!]!.primary
591
			]!.dbType;
592

593
		if (m2oFieldDBType !== relatedFieldDBType && m2oFieldDBType === 'int' && relatedFieldDBType === 'int unsigned') {
594
			const alterField = table.specificType(relation.field!, 'int unsigned');
595

596
			// Maintains the non-nullable state
597
			if (!nullable) {
598
				alterField.notNullable();
599
			}
600

601
			alterField.alter();
602
		}
603
	}
604
}
605

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.