4
DEFAULT_NUMERIC_PRECISION,
6
} from '@directus/constants';
7
import { ForbiddenError, InvalidPayloadError } from '@directus/errors';
8
import type { Column, SchemaInspector } from '@directus/schema';
9
import { createInspector } from '@directus/schema';
10
import type { Accountability, Field, FieldMeta, RawField, SchemaOverview, Type } from '@directus/types';
11
import { addFieldFlag, toArray } from '@directus/utils';
12
import type Keyv from 'keyv';
13
import type { Knex } from 'knex';
14
import { isEqual, isNil, merge } from 'lodash-es';
15
import { clearSystemCache, getCache } from '../cache.js';
16
import { ALIAS_TYPES } from '../constants.js';
17
import { translateDatabaseError } from '../database/errors/translate.js';
18
import type { Helpers } from '../database/helpers/index.js';
19
import { getHelpers } from '../database/helpers/index.js';
20
import getDatabase, { getSchemaInspector } from '../database/index.js';
21
import emitter from '../emitter.js';
22
import type { AbstractServiceOptions, ActionEventParams, MutationOptions } from '../types/index.js';
23
import getDefaultValue from '../utils/get-default-value.js';
24
import { getSystemFieldRowsWithAuthProviders } from '../utils/get-field-system-rows.js';
25
import getLocalType from '../utils/get-local-type.js';
26
import { getSchema } from '../utils/get-schema.js';
27
import { sanitizeColumn } from '../utils/sanitize-schema.js';
28
import { shouldClearCache } from '../utils/should-clear-cache.js';
29
import { transaction } from '../utils/transaction.js';
30
import { ItemsService } from './items.js';
31
import { PayloadService } from './payload.js';
32
import { RelationsService } from './relations.js';
34
const systemFieldRows = getSystemFieldRowsWithAuthProviders();
36
export class FieldsService {
39
accountability: Accountability | null;
40
itemsService: ItemsService;
41
payloadService: PayloadService;
42
schemaInspector: SchemaInspector;
43
schema: SchemaOverview;
44
cache: Keyv<any> | null;
45
systemCache: Keyv<any>;
47
constructor(options: AbstractServiceOptions) {
48
this.knex = options.knex || getDatabase();
49
this.helpers = getHelpers(this.knex);
50
this.schemaInspector = options.knex ? createInspector(options.knex) : getSchemaInspector();
51
this.accountability = options.accountability || null;
52
this.itemsService = new ItemsService('directus_fields', options);
53
this.payloadService = new PayloadService('directus_fields', options);
54
this.schema = options.schema;
56
const { cache, systemCache } = getCache();
59
this.systemCache = systemCache;
62
private get hasReadAccess() {
63
return !!this.accountability?.permissions?.find((permission) => {
64
return permission.collection === 'directus_fields' && permission.action === 'read';
68
async readAll(collection?: string): Promise<Field[]> {
69
let fields: FieldMeta[];
71
if (this.accountability && this.accountability.admin !== true && this.hasReadAccess === false) {
72
throw new ForbiddenError();
75
const nonAuthorizedItemsService = new ItemsService('directus_fields', {
81
fields = (await nonAuthorizedItemsService.readByQuery({
82
filter: { collection: { _eq: collection } },
86
fields.push(...systemFieldRows.filter((fieldMeta) => fieldMeta.collection === collection));
88
fields = (await nonAuthorizedItemsService.readByQuery({ limit: -1 })) as FieldMeta[];
89
fields.push(...systemFieldRows);
92
const columns = (await this.schemaInspector.columnInfo(collection)).map((column) => ({
94
default_value: getDefaultValue(
96
fields.find((field) => field.collection === column.table && field.field === column.name),
100
const columnsWithSystem = columns.map((column) => {
101
const field = fields.find((field) => {
102
return field.field === column.name && field.collection === column.table;
105
const type = getLocalType(column, field);
108
collection: column.table,
115
return data as Field;
118
const aliasQuery = this.knex.select<any[]>('*').from('directus_fields');
121
aliasQuery.andWhere('collection', collection);
124
let aliasFields = [...((await this.payloadService.processValues('read', await aliasQuery)) as FieldMeta[])];
127
aliasFields.push(...systemFieldRows.filter((fieldMeta) => fieldMeta.collection === collection));
129
aliasFields.push(...systemFieldRows);
132
aliasFields = aliasFields.filter((field) => {
133
const specials = toArray(field.special);
135
for (const type of ALIAS_TYPES) {
136
if (specials.includes(type)) return true;
142
const aliasFieldsAsField = aliasFields.map((field) => {
143
const type = getLocalType(undefined, field);
146
collection: field.collection,
156
const knownCollections = Object.keys(this.schema.collections);
158
const result = [...columnsWithSystem, ...aliasFieldsAsField].filter((field) =>
159
knownCollections.includes(field.collection),
162
// Filter the result so we only return the fields you have read access to
163
if (this.accountability && this.accountability.admin !== true) {
164
const permissions = this.accountability.permissions!.filter((permission) => {
165
return permission.action === 'read';
168
const allowedFieldsInCollection: Record<string, string[]> = {};
170
permissions.forEach((permission) => {
171
allowedFieldsInCollection[permission.collection] = permission.fields ?? [];
174
if (collection && collection in allowedFieldsInCollection === false) {
175
throw new ForbiddenError();
178
return result.filter((field) => {
179
if (field.collection in allowedFieldsInCollection === false) return false;
180
const allowedFields = allowedFieldsInCollection[field.collection]!;
181
if (allowedFields[0] === '*') return true;
182
return allowedFields.includes(field.field);
186
// Update specific database type overrides
187
for (const field of result) {
188
if (field.meta?.special?.includes('cast-timestamp')) {
189
field.type = 'timestamp';
190
} else if (field.meta?.special?.includes('cast-datetime')) {
191
field.type = 'dateTime';
194
field.type = this.helpers.schema.processFieldType(field);
200
async readOne(collection: string, field: string): Promise<Record<string, any>> {
201
if (this.accountability && this.accountability.admin !== true) {
202
if (this.hasReadAccess === false) {
203
throw new ForbiddenError();
206
const permissions = this.accountability.permissions!.find((permission) => {
207
return permission.action === 'read' && permission.collection === collection;
210
if (!permissions || !permissions.fields) throw new ForbiddenError();
212
if (permissions.fields.includes('*') === false) {
213
const allowedFields = permissions.fields;
214
if (allowedFields.includes(field) === false) throw new ForbiddenError();
218
let column = undefined;
219
let fieldInfo = await this.knex.select('*').from('directus_fields').where({ collection, field }).first();
222
fieldInfo = (await this.payloadService.processValues('read', fieldInfo)) as FieldMeta[];
227
systemFieldRows.find((fieldMeta) => fieldMeta.collection === collection && fieldMeta.field === field);
230
column = await this.schemaInspector.columnInfo(collection, field);
235
if (!column && !fieldInfo) throw new ForbiddenError();
237
const type = getLocalType(column, fieldInfo);
239
const columnWithCastDefaultValue = column
242
default_value: getDefaultValue(column, fieldInfo),
250
meta: fieldInfo || null,
251
schema: type === 'alias' ? null : columnWithCastDefaultValue,
259
field: Partial<Field> & { field: string; type: Type | null },
260
table?: Knex.CreateTableBuilder, // allows collection creation to
261
opts?: MutationOptions,
263
if (this.accountability && this.accountability.admin !== true) {
264
throw new ForbiddenError();
267
const runPostColumnChange = await this.helpers.schema.preColumnChange();
268
const nestedActionEvents: ActionEventParams[] = [];
272
field.field in this.schema.collections[collection]!.fields ||
274
await this.knex.select('id').from('directus_fields').where({ collection, field: field.field }).first(),
277
// Check if field already exists, either as a column, or as a row in directus_fields
279
throw new InvalidPayloadError({
280
reason: `Field "${field.field}" already exists in collection "${collection}"`,
284
// Add flag for specific database type overrides
285
const flagToAdd = this.helpers.date.fieldFlagForField(field.type);
288
addFieldFlag(field, flagToAdd);
291
await transaction(this.knex, async (trx) => {
292
const itemsService = new ItemsService('directus_fields', {
294
accountability: this.accountability,
298
const hookAdjustedField =
299
opts?.emitEvents !== false
300
? await emitter.emitFilter(
304
collection: collection,
309
accountability: this.accountability,
314
if (hookAdjustedField.type && ALIAS_TYPES.includes(hookAdjustedField.type) === false) {
316
this.addColumnToTable(table, hookAdjustedField as Field);
318
await trx.schema.alterTable(collection, (table) => {
319
this.addColumnToTable(table, hookAdjustedField as Field);
324
if (hookAdjustedField.meta) {
325
const existingSortRecord: Record<'max', number | null> | undefined = await trx
326
.from('directus_fields')
327
.where(hookAdjustedField.meta?.group ? { collection, group: hookAdjustedField.meta.group } : { collection })
328
.max('sort', { as: 'max' })
331
const newSortValue: number = existingSortRecord?.max ? existingSortRecord.max + 1 : 1;
333
await itemsService.createOne(
335
...merge({ sort: newSortValue }, hookAdjustedField.meta),
336
collection: collection,
337
field: hookAdjustedField.field,
339
{ emitEvents: false },
343
const actionEvent = {
344
event: 'fields.create',
346
payload: hookAdjustedField,
347
key: hookAdjustedField.field,
348
collection: collection,
351
database: getDatabase(),
353
accountability: this.accountability,
357
if (opts?.bypassEmitAction) {
358
opts.bypassEmitAction(actionEvent);
360
nestedActionEvents.push(actionEvent);
364
if (runPostColumnChange) {
365
await this.helpers.schema.postColumnChange();
368
if (shouldClearCache(this.cache, opts)) {
369
await this.cache.clear();
372
if (opts?.autoPurgeSystemCache !== false) {
373
await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache });
376
if (opts?.emitEvents !== false && nestedActionEvents.length > 0) {
377
const updatedSchema = await getSchema();
379
for (const nestedActionEvent of nestedActionEvents) {
380
nestedActionEvent.context.schema = updatedSchema;
381
emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
387
async updateField(collection: string, field: RawField, opts?: MutationOptions): Promise<string> {
388
if (this.accountability && this.accountability.admin !== true) {
389
throw new ForbiddenError();
392
const runPostColumnChange = await this.helpers.schema.preColumnChange();
393
const nestedActionEvents: ActionEventParams[] = [];
395
// 'type' is required for further checks on schema update
396
if (field.schema && !field.type) {
397
const existingType = this.schema.collections[collection]?.fields[field.field]?.type;
398
if (existingType) field.type = existingType;
402
const hookAdjustedField =
403
opts?.emitEvents !== false
404
? await emitter.emitFilter(
409
collection: collection,
414
accountability: this.accountability,
419
const record = field.meta
420
? await this.knex.select('id').from('directus_fields').where({ collection, field: field.field }).first()
424
hookAdjustedField.type &&
425
(hookAdjustedField.type === 'alias' ||
426
this.schema.collections[collection]!.fields[field.field]?.type === 'alias') &&
427
hookAdjustedField.type !== (this.schema.collections[collection]!.fields[field.field]?.type ?? 'alias')
429
throw new InvalidPayloadError({ reason: 'Alias type cannot be changed' });
432
if (hookAdjustedField.schema) {
433
const existingColumn = await this.schemaInspector.columnInfo(collection, hookAdjustedField.field);
435
if (hookAdjustedField.schema?.is_nullable === true && existingColumn.is_primary_key) {
436
throw new InvalidPayloadError({ reason: 'Primary key cannot be null' });
439
// Sanitize column only when applying snapshot diff as opts is only passed from /utils/apply-diff.ts
440
const columnToCompare =
441
opts?.bypassLimits && opts.autoPurgeSystemCache === false ? sanitizeColumn(existingColumn) : existingColumn;
443
if (!isEqual(columnToCompare, hookAdjustedField.schema)) {
445
await transaction(this.knex, async (trx) => {
446
await trx.schema.alterTable(collection, async (table) => {
447
if (!hookAdjustedField.schema) return;
448
this.addColumnToTable(table, field, existingColumn);
452
throw await translateDatabaseError(err);
457
if (hookAdjustedField.meta) {
459
await this.itemsService.updateOne(
462
...hookAdjustedField.meta,
463
collection: collection,
464
field: hookAdjustedField.field,
466
{ emitEvents: false },
469
await this.itemsService.createOne(
471
...hookAdjustedField.meta,
472
collection: collection,
473
field: hookAdjustedField.field,
475
{ emitEvents: false },
480
const actionEvent = {
481
event: 'fields.update',
483
payload: hookAdjustedField,
484
keys: [hookAdjustedField.field],
485
collection: collection,
488
database: getDatabase(),
490
accountability: this.accountability,
494
if (opts?.bypassEmitAction) {
495
opts.bypassEmitAction(actionEvent);
497
nestedActionEvents.push(actionEvent);
502
if (runPostColumnChange) {
503
await this.helpers.schema.postColumnChange();
506
if (shouldClearCache(this.cache, opts)) {
507
await this.cache.clear();
510
if (opts?.autoPurgeSystemCache !== false) {
511
await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache });
514
if (opts?.emitEvents !== false && nestedActionEvents.length > 0) {
515
const updatedSchema = await getSchema();
517
for (const nestedActionEvent of nestedActionEvents) {
518
nestedActionEvent.context.schema = updatedSchema;
519
emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
525
async updateFields(collection: string, fields: RawField[], opts?: MutationOptions): Promise<string[]> {
526
const nestedActionEvents: ActionEventParams[] = [];
529
const fieldNames = [];
531
for (const field of fields) {
533
await this.updateField(collection, field, {
534
autoPurgeCache: false,
535
autoPurgeSystemCache: false,
536
bypassEmitAction: (params) => nestedActionEvents.push(params),
543
if (shouldClearCache(this.cache, opts)) {
544
await this.cache.clear();
547
if (opts?.autoPurgeSystemCache !== false) {
548
await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache });
551
if (opts?.emitEvents !== false && nestedActionEvents.length > 0) {
552
const updatedSchema = await getSchema();
554
for (const nestedActionEvent of nestedActionEvents) {
555
nestedActionEvent.context.schema = updatedSchema;
556
emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
562
async deleteField(collection: string, field: string, opts?: MutationOptions): Promise<void> {
563
if (this.accountability && this.accountability.admin !== true) {
564
throw new ForbiddenError();
567
const runPostColumnChange = await this.helpers.schema.preColumnChange();
568
const nestedActionEvents: ActionEventParams[] = [];
571
if (opts?.emitEvents !== false) {
572
await emitter.emitFilter(
576
collection: collection,
581
accountability: this.accountability,
586
await transaction(this.knex, async (trx) => {
587
const relations = this.schema.relations.filter((relation) => {
589
(relation.collection === collection && relation.field === field) ||
590
(relation.related_collection === collection && relation.meta?.one_field === field)
594
const relationsService = new RelationsService({
596
accountability: this.accountability,
600
const fieldsService = new FieldsService({
602
accountability: this.accountability,
606
for (const relation of relations) {
607
const isM2O = relation.collection === collection && relation.field === field;
609
// If the current field is a m2o, delete the related o2m if it exists and remove the relationship
611
await relationsService.deleteOne(collection, field, {
612
autoPurgeSystemCache: false,
613
bypassEmitAction: (params) =>
614
opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params),
618
relation.related_collection &&
619
relation.meta?.one_field &&
620
relation.related_collection !== collection &&
621
relation.meta.one_field !== field
623
await fieldsService.deleteField(relation.related_collection, relation.meta.one_field, {
624
autoPurgeCache: false,
625
autoPurgeSystemCache: false,
626
bypassEmitAction: (params) =>
627
opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params),
632
// If the current field is a o2m, just delete the one field config from the relation
633
if (!isM2O && relation.meta?.one_field) {
634
await trx('directus_relations')
635
.update({ one_field: null })
636
.where({ many_collection: relation.collection, many_field: relation.field });
640
// Delete field only after foreign key constraints are removed
642
this.schema.collections[collection] &&
643
field in this.schema.collections[collection]!.fields &&
644
this.schema.collections[collection]!.fields[field]!.alias === false
646
await trx.schema.table(collection, (table) => {
647
table.dropColumn(field);
651
const collectionMeta = await trx
652
.select('archive_field', 'sort_field')
653
.from('directus_collections')
654
.where({ collection })
657
const collectionMetaUpdates: Record<string, null> = {};
659
if (collectionMeta?.archive_field === field) {
660
collectionMetaUpdates['archive_field'] = null;
663
if (collectionMeta?.sort_field === field) {
664
collectionMetaUpdates['sort_field'] = null;
667
if (Object.keys(collectionMetaUpdates).length > 0) {
668
await trx('directus_collections').update(collectionMetaUpdates).where({ collection });
671
// Cleanup directus_fields
672
const metaRow = await trx
673
.select('collection', 'field')
674
.from('directus_fields')
675
.where({ collection, field })
679
// Handle recursive FK constraints
680
await trx('directus_fields')
681
.update({ group: null })
682
.where({ group: metaRow.field, collection: metaRow.collection });
685
await trx('directus_fields').delete().where({ collection, field });
688
const actionEvent = {
689
event: 'fields.delete',
692
collection: collection,
697
accountability: this.accountability,
701
if (opts?.bypassEmitAction) {
702
opts.bypassEmitAction(actionEvent);
704
nestedActionEvents.push(actionEvent);
707
if (runPostColumnChange) {
708
await this.helpers.schema.postColumnChange();
711
if (shouldClearCache(this.cache, opts)) {
712
await this.cache.clear();
715
if (opts?.autoPurgeSystemCache !== false) {
716
await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache });
719
if (opts?.emitEvents !== false && nestedActionEvents.length > 0) {
720
const updatedSchema = await getSchema();
722
for (const nestedActionEvent of nestedActionEvents) {
723
nestedActionEvent.context.schema = updatedSchema;
724
emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
730
public addColumnToTable(table: Knex.CreateTableBuilder, field: RawField | Field, alter: Column | null = null): void {
731
let column: Knex.ColumnBuilder;
733
// Don't attempt to add a DB column for alias / corrupt fields
734
if (field.type === 'alias' || field.type === 'unknown') return;
736
if (field.schema?.has_auto_increment) {
737
if (field.type === 'bigInteger') {
738
column = table.bigIncrements(field.field);
740
column = table.increments(field.field);
742
} else if (field.type === 'string') {
743
column = table.string(field.field, field.schema?.max_length ?? undefined);
744
} else if (['float', 'decimal'].includes(field.type)) {
745
const type = field.type as 'float' | 'decimal';
747
column = table[type](
749
field.schema?.numeric_precision ?? DEFAULT_NUMERIC_PRECISION,
750
field.schema?.numeric_scale ?? DEFAULT_NUMERIC_SCALE,
752
} else if (field.type === 'csv') {
753
column = table.text(field.field);
754
} else if (field.type === 'hash') {
755
column = table.string(field.field, 255);
756
} else if (field.type === 'dateTime') {
757
column = table.dateTime(field.field, { useTz: false });
758
} else if (field.type === 'timestamp') {
759
column = table.timestamp(field.field, { useTz: true });
760
} else if (field.type.startsWith('geometry')) {
761
column = this.helpers.st.createColumn(table, field);
762
} else if (KNEX_TYPES.includes(field.type as (typeof KNEX_TYPES)[number])) {
763
column = table[field.type as (typeof KNEX_TYPES)[number]](field.field);
765
throw new InvalidPayloadError({ reason: `Illegal type passed: "${field.type}"` });
768
if (field.schema?.default_value !== undefined) {
770
typeof field.schema.default_value === 'string' &&
771
(field.schema.default_value.toLowerCase() === 'now()' || field.schema.default_value === 'CURRENT_TIMESTAMP')
773
column.defaultTo(this.knex.fn.now());
775
typeof field.schema.default_value === 'string' &&
776
field.schema.default_value.includes('CURRENT_TIMESTAMP(') &&
777
field.schema.default_value.includes(')')
779
const precision = field.schema.default_value.match(REGEX_BETWEEN_PARENS)![1];
780
column.defaultTo(this.knex.fn.now(Number(precision)));
782
column.defaultTo(field.schema.default_value);
786
if (field.schema?.is_nullable === false) {
787
if (!alter || alter.is_nullable === true) {
788
column.notNullable();
791
if (!alter || alter.is_nullable === false) {
796
if (field.schema?.is_primary_key) {
797
column.primary().notNullable();
798
} else if (field.schema?.is_unique === true) {
799
if (!alter || alter.is_unique === false) {
802
} else if (field.schema?.is_unique === false) {
803
if (alter && alter.is_unique === true) {
804
table.dropUnique([field.field]);