Files v2 - Add new FILES field type (#17236)
In this PR : - New field type FieldMetadataType.FILES added (as jsonb in DB) - Backend: GraphQL types, validation, REST API schema, data processor handling - Frontend: field configuration in settings - Feature flag IS_FILES_FIELD_ENABLED to gate the feature - Integration tests for create/filter validation - Dev seed: Feature flag enabled + FILES field on survey result object To do in next PRs : - Backend : -- new workspaceFile controller (for download) & new workspaceFile upload resolver (for upload) -- pre-hook/post-hook to ensure fileId existence + listener to ensure cleaning - Frontend : FILES field display/edit in table view, record, ...
This commit is contained in:
+11
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isNull, isUndefined } from '@sniptt/guards';
|
||||
import {
|
||||
FieldMetadataFilesSettings,
|
||||
FieldMetadataRelationSettings,
|
||||
FieldMetadataType,
|
||||
ObjectRecord,
|
||||
@@ -29,6 +30,7 @@ import { validateBooleanFieldOrThrow } from 'src/engine/api/common/common-args-p
|
||||
import { validateCurrencyFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-currency-field-or-throw.util';
|
||||
import { validateDateAndDateTimeFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-date-and-date-time-field-or-throw.util';
|
||||
import { validateEmailsFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-field-or-throw.util';
|
||||
import { validateFilesFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util';
|
||||
import { validateFullNameFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-full-name-field-or-throw.util';
|
||||
import { validateLinksFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-links-field-or-throw.util';
|
||||
import { validateMultiSelectFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-multi-select-field-or-throw.util';
|
||||
@@ -246,6 +248,15 @@ export class DataArgProcessor {
|
||||
|
||||
return transformEmailsValue(validatedValue);
|
||||
}
|
||||
case FieldMetadataType.FILES: {
|
||||
const validatedValue = validateFilesFieldOrThrow(
|
||||
value,
|
||||
key,
|
||||
fieldMetadata.settings as FieldMetadataFilesSettings,
|
||||
);
|
||||
|
||||
return transformRawJsonField(validatedValue);
|
||||
}
|
||||
case FieldMetadataType.FULL_NAME: {
|
||||
const validatedValue = validateFullNameFieldOrThrow(value, key);
|
||||
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
import { validateFilesFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util';
|
||||
import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
|
||||
describe('validateFilesFieldOrThrow', () => {
|
||||
describe('valid inputs', () => {
|
||||
it('should return null when value is null', () => {
|
||||
const result = validateFilesFieldOrThrow(null, 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the files array when all fields are valid', () => {
|
||||
const filesValue = [
|
||||
{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'Document 1' },
|
||||
{ fileId: '660e8400-e29b-41d4-a716-446655440001', label: 'Document 2' },
|
||||
];
|
||||
const result = validateFilesFieldOrThrow(filesValue, 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
});
|
||||
|
||||
expect(result).toEqual(filesValue);
|
||||
});
|
||||
|
||||
it('should return an empty array when value is an empty array', () => {
|
||||
const result = validateFilesFieldOrThrow([], 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse and return valid stringified JSON array', () => {
|
||||
const filesValue = [
|
||||
{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'Document 1' },
|
||||
];
|
||||
const stringifiedValue = JSON.stringify(filesValue);
|
||||
const result = validateFilesFieldOrThrow(stringifiedValue, 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
});
|
||||
|
||||
expect(result).toEqual(filesValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid inputs', () => {
|
||||
it('should throw when value is an invalid JSON string', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow('not valid json', 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
}),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when value is not an array', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(
|
||||
{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'test' },
|
||||
'testField',
|
||||
{ maxNumberOfValues: 10 },
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when value is undefined', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(undefined, 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
}),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when array item is not an object', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(['not an object'], 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
}),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when array item is null', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow([null], 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
}),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when fileId key is missing', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow([{ label: 'test' }], 'testField', {
|
||||
maxNumberOfValues: 10,
|
||||
}),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when label key is missing', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(
|
||||
[{ fileId: '550e8400-e29b-41d4-a716-446655440000' }],
|
||||
'testField',
|
||||
{ maxNumberOfValues: 10 },
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when extra keys are present', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(
|
||||
[
|
||||
{
|
||||
fileId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
label: 'test',
|
||||
extraKey: 'invalid',
|
||||
},
|
||||
],
|
||||
'testField',
|
||||
{ maxNumberOfValues: 10 },
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when fileId is not a valid UUID', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(
|
||||
[{ fileId: 'not-a-uuid', label: 'test' }],
|
||||
'testField',
|
||||
{ maxNumberOfValues: 10 },
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when fileId is not a string', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(
|
||||
[{ fileId: 12345, label: 'test' }],
|
||||
'testField',
|
||||
{ maxNumberOfValues: 10 },
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should throw when label is not a string', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(
|
||||
[{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 12345 }],
|
||||
'testField',
|
||||
{ maxNumberOfValues: 10 },
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
it('should throw when max number of files is exceeded', () => {
|
||||
expect(() =>
|
||||
validateFilesFieldOrThrow(
|
||||
[
|
||||
{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'test' },
|
||||
{ fileId: '550e8400-e29b-41d4-a716-446655440001', label: 'test' },
|
||||
],
|
||||
'testField',
|
||||
{ maxNumberOfValues: 1 },
|
||||
),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
});
|
||||
});
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { inspect } from 'util';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isNull } from '@sniptt/guards';
|
||||
import { type FieldMetadataFilesSettings } from 'twenty-shared/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
|
||||
export const fileItemSchema = z
|
||||
.object({
|
||||
fileId: z.string().uuidv4(),
|
||||
label: z.string(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const filesFieldSchema = z.array(fileItemSchema);
|
||||
|
||||
export type FileItem = z.infer<typeof fileItemSchema>;
|
||||
|
||||
export const validateFilesFieldOrThrow = (
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
settings: FieldMetadataFilesSettings,
|
||||
): FileItem[] | null => {
|
||||
if (isNull(value)) return null;
|
||||
|
||||
let parsedValue: unknown = value;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
parsedValue = JSON.parse(value);
|
||||
} catch {
|
||||
const inspectedValue = inspect(value);
|
||||
|
||||
throw new CommonQueryRunnerException(
|
||||
`Invalid value "${inspectedValue}" for FILES field "${fieldName}" - It should be an array of objects with "fileId" and "label" properties.`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
|
||||
{
|
||||
userFriendlyMessage: msg`Invalid value "${inspectedValue}" for FILES field "${fieldName}" - It should be an array of objects with "fileId" and "label" properties.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = filesFieldSchema.safeParse(parsedValue);
|
||||
|
||||
if (!result.success) {
|
||||
const inspectedValue = inspect(parsedValue);
|
||||
const errorMessage = result.error.issues
|
||||
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
|
||||
.join(', ');
|
||||
|
||||
throw new CommonQueryRunnerException(
|
||||
`Invalid value "${inspectedValue}" for FILES field "${fieldName}" - ${errorMessage}`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
|
||||
{
|
||||
userFriendlyMessage: msg`Invalid value for FILES field "${fieldName}" - ${errorMessage}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (result.data.length > settings.maxNumberOfValues) {
|
||||
const maxNumberOfValues = settings.maxNumberOfValues;
|
||||
|
||||
throw new CommonQueryRunnerException(
|
||||
`Max number of files is ${maxNumberOfValues}`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
|
||||
{
|
||||
userFriendlyMessage: msg`Max number of files is ${maxNumberOfValues}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
};
|
||||
+5
-2
@@ -5,8 +5,8 @@ import {
|
||||
GraphQLInputObjectType,
|
||||
isObjectType,
|
||||
} from 'graphql';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { CompositeType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum';
|
||||
import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service';
|
||||
@@ -72,7 +72,10 @@ export class CompositeFieldMetadataCreateGqlInputTypeGenerator {
|
||||
|
||||
const type = isEnumFieldMetadataType(property.type)
|
||||
? this.gqlTypesStorage.getGqlTypeByKey(key)
|
||||
: this.typeMapperService.mapToScalarType(property.type, typeOptions);
|
||||
: this.typeMapperService.mapToPreBuiltGraphQLInputType({
|
||||
fieldMetadataType: property.type,
|
||||
typeOptions,
|
||||
});
|
||||
|
||||
if (!isDefined(type) || isObjectType(type)) {
|
||||
const message = `Could not find a GraphQL input type for ${compositeType.type} ${property.name}`;
|
||||
|
||||
+3
-3
@@ -187,10 +187,10 @@ export class ObjectMetadataCreateGqlInputTypeGenerator {
|
||||
fieldMetadata: FlatFieldMetadata,
|
||||
typeOptions: TypeOptions,
|
||||
) {
|
||||
const type = this.typeMapperService.mapToScalarType(
|
||||
fieldMetadata.type,
|
||||
const type = this.typeMapperService.mapToPreBuiltGraphQLInputType({
|
||||
fieldMetadataType: fieldMetadata.type,
|
||||
typeOptions,
|
||||
);
|
||||
});
|
||||
|
||||
if (!isDefined(type) || isObjectType(type)) {
|
||||
const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`;
|
||||
|
||||
+12
-7
@@ -123,9 +123,10 @@ export class RelationConnectGqlInputTypeGenerator {
|
||||
> = {};
|
||||
|
||||
uniqueProperties.forEach((property) => {
|
||||
const scalarType = this.typeMapperService.mapToScalarType(
|
||||
property.type,
|
||||
);
|
||||
const scalarType =
|
||||
this.typeMapperService.mapToPreBuiltGraphQLInputType({
|
||||
fieldMetadataType: property.type,
|
||||
});
|
||||
|
||||
compositeFields[property.name] = {
|
||||
type: scalarType || GraphQLString,
|
||||
@@ -144,10 +145,14 @@ export class RelationConnectGqlInputTypeGenerator {
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const scalarType = this.typeMapperService.mapToScalarType(
|
||||
field.type,
|
||||
{ settings: field.settings, isIdField: field.name === 'id' },
|
||||
);
|
||||
const scalarType =
|
||||
this.typeMapperService.mapToPreBuiltGraphQLInputType({
|
||||
fieldMetadataType: field.type,
|
||||
typeOptions: {
|
||||
settings: field.settings,
|
||||
isIdField: field.name === 'id',
|
||||
},
|
||||
});
|
||||
|
||||
inputFields[field.name] = {
|
||||
type: scalarType || GraphQLString,
|
||||
|
||||
+3
-3
@@ -45,10 +45,10 @@ export class RelationFieldMetadataGqlInputTypeGenerator {
|
||||
|
||||
const { joinColumnName } = extractGraphQLRelationFieldNames(fieldMetadata);
|
||||
|
||||
const type = this.typeMapperService.mapToScalarType(
|
||||
fieldMetadata.type,
|
||||
const type = this.typeMapperService.mapToPreBuiltGraphQLInputType({
|
||||
fieldMetadataType: fieldMetadata.type,
|
||||
typeOptions,
|
||||
);
|
||||
});
|
||||
|
||||
if (!isDefined(type)) {
|
||||
const message = `Could not find a GraphQL input type for ${type} field metadata`;
|
||||
|
||||
+5
-2
@@ -5,8 +5,8 @@ import {
|
||||
GraphQLInputObjectType,
|
||||
isObjectType,
|
||||
} from 'graphql';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { CompositeType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum';
|
||||
import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service';
|
||||
@@ -73,7 +73,10 @@ export class CompositeFieldMetadataUpdateGqlInputTypeGenerator {
|
||||
|
||||
const type = isEnumFieldMetadataType(property.type)
|
||||
? this.gqlTypesStorage.getGqlTypeByKey(key)
|
||||
: this.typeMapperService.mapToScalarType(property.type, typeOptions);
|
||||
: this.typeMapperService.mapToPreBuiltGraphQLInputType({
|
||||
fieldMetadataType: property.type,
|
||||
typeOptions,
|
||||
});
|
||||
|
||||
if (!isDefined(type) || isObjectType(type)) {
|
||||
const message = `Could not find a GraphQL input type for ${compositeType.type} ${property.name}`;
|
||||
|
||||
+6
-3
@@ -4,10 +4,12 @@ import {
|
||||
GraphQLEnumType,
|
||||
GraphQLInputFieldConfigMap,
|
||||
GraphQLInputObjectType,
|
||||
GraphQLList,
|
||||
GraphQLScalarType,
|
||||
isEnumType,
|
||||
isInputObjectType,
|
||||
isObjectType,
|
||||
type GraphQLInputType,
|
||||
} from 'graphql';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@@ -97,6 +99,7 @@ export class ObjectMetadataUpdateGqlInputTypeGenerator {
|
||||
| GraphQLInputObjectType
|
||||
| GraphQLEnumType
|
||||
| GraphQLScalarType
|
||||
| GraphQLList<GraphQLInputType>
|
||||
| undefined;
|
||||
|
||||
if (isEnumFieldMetadataType(fieldMetadata.type)) {
|
||||
@@ -138,10 +141,10 @@ export class ObjectMetadataUpdateGqlInputTypeGenerator {
|
||||
|
||||
type = compositeType;
|
||||
} else {
|
||||
type = this.typeMapperService.mapToScalarType(
|
||||
fieldMetadata.type,
|
||||
type = this.typeMapperService.mapToPreBuiltGraphQLInputType({
|
||||
fieldMetadataType: fieldMetadata.type,
|
||||
typeOptions,
|
||||
);
|
||||
});
|
||||
|
||||
if (!isDefined(type) || isObjectType(type)) {
|
||||
const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`;
|
||||
|
||||
+4
-1
@@ -71,7 +71,10 @@ export class CompositeFieldMetadataGqlObjectTypeGenerator {
|
||||
|
||||
const type = isEnumFieldMetadataType(property.type)
|
||||
? this.gqlTypesStorage.getGqlTypeByKey(key)
|
||||
: this.typeMapperService.mapToScalarType(property.type, typeOptions);
|
||||
: this.typeMapperService.mapToPreBuiltGraphQLOutputType({
|
||||
fieldMetadataType: property.type,
|
||||
typeOptions,
|
||||
});
|
||||
|
||||
if (!isDefined(type) || isInputObjectType(type)) {
|
||||
const message = `Could not find a GraphQL object type for ${compositeType.type} ${property.name}`;
|
||||
|
||||
+4
-4
@@ -134,10 +134,10 @@ export class ObjectMetadataGqlObjectTypeGenerator {
|
||||
|
||||
type = enumFieldEnumType;
|
||||
} else {
|
||||
type = this.typeMapperService.mapToScalarType(
|
||||
field.type,
|
||||
typeFactoryOptions,
|
||||
);
|
||||
type = this.typeMapperService.mapToPreBuiltGraphQLOutputType({
|
||||
fieldMetadataType: field.type,
|
||||
typeOptions: typeFactoryOptions,
|
||||
});
|
||||
|
||||
if (!isDefined(type)) {
|
||||
const message = `Could not find a GraphQL output type for ${field.name} scalar field metadata of object ${objectNameSingular}`;
|
||||
|
||||
+3
-3
@@ -32,10 +32,10 @@ export class RelationFieldMetadataGqlObjectTypeGenerator {
|
||||
|
||||
const { joinColumnName } = extractGraphQLRelationFieldNames(fieldMetadata);
|
||||
|
||||
const type = this.typeMapperService.mapToScalarType(
|
||||
fieldMetadata.type,
|
||||
const type = this.typeMapperService.mapToPreBuiltGraphQLOutputType({
|
||||
fieldMetadataType: fieldMetadata.type,
|
||||
typeOptions,
|
||||
);
|
||||
});
|
||||
|
||||
if (!isDefined(type)) {
|
||||
const message = `Could not find a GraphQL output type for ${type} field metadata`;
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
GraphQLInputObjectType,
|
||||
GraphQLList,
|
||||
GraphQLNonNull,
|
||||
GraphQLString,
|
||||
} from 'graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
const FileInputType = new GraphQLInputObjectType({
|
||||
name: 'FileInput',
|
||||
fields: {
|
||||
fileId: { type: new GraphQLNonNull(UUIDScalarType) },
|
||||
label: { type: new GraphQLNonNull(GraphQLString) },
|
||||
},
|
||||
});
|
||||
|
||||
export const FilesInputType = new GraphQLList(FileInputType);
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
GraphQLEnumType,
|
||||
GraphQLList,
|
||||
GraphQLNonNull,
|
||||
GraphQLObjectType,
|
||||
GraphQLString,
|
||||
} from 'graphql';
|
||||
import { FILE_CATEGORIES } from 'twenty-shared/types';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
const FileCategoryEnumType = new GraphQLEnumType({
|
||||
name: 'FileCategory',
|
||||
values: Object.fromEntries(
|
||||
Object.values(FILE_CATEGORIES).map((value) => [value, { value }]),
|
||||
),
|
||||
});
|
||||
|
||||
const FileObjectType = new GraphQLObjectType({
|
||||
name: 'FileObject',
|
||||
fields: {
|
||||
fileId: { type: new GraphQLNonNull(UUIDScalarType) },
|
||||
label: { type: new GraphQLNonNull(GraphQLString) },
|
||||
fileCategory: { type: FileCategoryEnumType },
|
||||
//TODO: Will be made non-nullable in a future PR
|
||||
// fileCategory: { type: new GraphQLNonNull(FileCategoryEnumType) },
|
||||
},
|
||||
});
|
||||
|
||||
export const FilesObjectType = new GraphQLList(FileObjectType);
|
||||
+83
-36
@@ -9,17 +9,18 @@ import {
|
||||
type GraphQLInputType,
|
||||
GraphQLList,
|
||||
GraphQLNonNull,
|
||||
type GraphQLOutputType,
|
||||
type GraphQLScalarType,
|
||||
GraphQLString,
|
||||
type GraphQLType,
|
||||
} from 'graphql';
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
type FieldMetadataSettings,
|
||||
FieldMetadataType,
|
||||
NumberDataType,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
|
||||
|
||||
@@ -34,11 +35,13 @@ import {
|
||||
RawJsonFilterType,
|
||||
StringFilterType,
|
||||
} from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input';
|
||||
import { FilesInputType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/files.input-type';
|
||||
import { MultiSelectFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/multi-select-filter.input-type';
|
||||
import { RichTextV2FilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/rich-text.input-type';
|
||||
import { SelectFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/select-filter.input-type';
|
||||
import { TSVectorFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/ts-vector-filter.input-type';
|
||||
import { UUIDFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/uuid-filter.input-type';
|
||||
import { FilesObjectType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/object/files.object-type';
|
||||
import {
|
||||
BigFloatScalarType,
|
||||
DateScalarType,
|
||||
@@ -63,43 +66,85 @@ const StringArrayScalarType = new GraphQLList(GraphQLString);
|
||||
|
||||
@Injectable()
|
||||
export class TypeMapperService {
|
||||
mapToScalarType(
|
||||
fieldMetadataType: FieldMetadataType,
|
||||
typeOptions?: TypeOptions,
|
||||
): GraphQLScalarType | undefined {
|
||||
if (
|
||||
typeOptions?.isIdField ||
|
||||
fieldMetadataType === FieldMetadataType.RELATION ||
|
||||
fieldMetadataType === FieldMetadataType.MORPH_RELATION
|
||||
) {
|
||||
private readonly baseTypeScalarMapping = new Map<
|
||||
FieldMetadataType,
|
||||
GraphQLScalarType | GraphQLList<GraphQLScalarType>
|
||||
>([
|
||||
[FieldMetadataType.UUID, UUIDScalarType],
|
||||
[FieldMetadataType.TEXT, GraphQLString],
|
||||
[FieldMetadataType.DATE_TIME, GraphQLISODateTime],
|
||||
[FieldMetadataType.DATE, DateScalarType],
|
||||
[FieldMetadataType.BOOLEAN, GraphQLBoolean],
|
||||
[FieldMetadataType.NUMERIC, BigFloatScalarType],
|
||||
[FieldMetadataType.POSITION, PositionScalarType],
|
||||
[FieldMetadataType.RAW_JSON, GraphQLJSON],
|
||||
[FieldMetadataType.ARRAY, StringArrayScalarType],
|
||||
[FieldMetadataType.RICH_TEXT, GraphQLString],
|
||||
[FieldMetadataType.TS_VECTOR, TSVectorScalarType],
|
||||
]);
|
||||
|
||||
mapToPreBuiltGraphQLOutputType({
|
||||
fieldMetadataType,
|
||||
typeOptions,
|
||||
}: {
|
||||
fieldMetadataType: FieldMetadataType;
|
||||
typeOptions?: TypeOptions;
|
||||
}): GraphQLScalarType | GraphQLList<GraphQLOutputType> | undefined {
|
||||
if (this.isIdOrRelationType(fieldMetadataType, typeOptions)) {
|
||||
return GraphQLID;
|
||||
}
|
||||
const typeScalarMapping = new Map<FieldMetadataType, GraphQLScalarType>([
|
||||
[FieldMetadataType.UUID, UUIDScalarType],
|
||||
[FieldMetadataType.TEXT, GraphQLString],
|
||||
[FieldMetadataType.DATE_TIME, GraphQLISODateTime],
|
||||
[FieldMetadataType.DATE, DateScalarType],
|
||||
[FieldMetadataType.BOOLEAN, GraphQLBoolean],
|
||||
[
|
||||
FieldMetadataType.NUMBER,
|
||||
getNumberScalarType(
|
||||
(
|
||||
typeOptions?.settings as FieldMetadataSettings<FieldMetadataType.NUMBER>
|
||||
)?.dataType ?? NumberDataType.FLOAT,
|
||||
),
|
||||
],
|
||||
[FieldMetadataType.NUMERIC, BigFloatScalarType],
|
||||
[FieldMetadataType.POSITION, PositionScalarType],
|
||||
[FieldMetadataType.RAW_JSON, GraphQLJSON],
|
||||
[
|
||||
FieldMetadataType.ARRAY,
|
||||
StringArrayScalarType as unknown as GraphQLScalarType,
|
||||
],
|
||||
[FieldMetadataType.RICH_TEXT, GraphQLString],
|
||||
[FieldMetadataType.TS_VECTOR, TSVectorScalarType],
|
||||
]);
|
||||
|
||||
return typeScalarMapping.get(fieldMetadataType);
|
||||
if (fieldMetadataType === FieldMetadataType.NUMBER) {
|
||||
return this.getNumberScalarTypeFromOptions(typeOptions);
|
||||
}
|
||||
|
||||
if (fieldMetadataType === FieldMetadataType.FILES) {
|
||||
return FilesObjectType;
|
||||
}
|
||||
|
||||
return this.baseTypeScalarMapping.get(fieldMetadataType);
|
||||
}
|
||||
|
||||
mapToPreBuiltGraphQLInputType({
|
||||
fieldMetadataType,
|
||||
typeOptions,
|
||||
}: {
|
||||
fieldMetadataType: FieldMetadataType;
|
||||
typeOptions?: TypeOptions;
|
||||
}): GraphQLScalarType | GraphQLList<GraphQLInputType> | undefined {
|
||||
if (this.isIdOrRelationType(fieldMetadataType, typeOptions)) {
|
||||
return GraphQLID;
|
||||
}
|
||||
|
||||
if (fieldMetadataType === FieldMetadataType.NUMBER) {
|
||||
return this.getNumberScalarTypeFromOptions(typeOptions);
|
||||
}
|
||||
|
||||
if (fieldMetadataType === FieldMetadataType.FILES) {
|
||||
return FilesInputType;
|
||||
}
|
||||
|
||||
return this.baseTypeScalarMapping.get(fieldMetadataType);
|
||||
}
|
||||
|
||||
private isIdOrRelationType(
|
||||
fieldMetadataType: FieldMetadataType,
|
||||
typeOptions?: TypeOptions,
|
||||
): boolean {
|
||||
return (
|
||||
typeOptions?.isIdField === true ||
|
||||
fieldMetadataType === FieldMetadataType.RELATION ||
|
||||
fieldMetadataType === FieldMetadataType.MORPH_RELATION
|
||||
);
|
||||
}
|
||||
|
||||
private getNumberScalarTypeFromOptions(
|
||||
typeOptions?: TypeOptions,
|
||||
): GraphQLScalarType {
|
||||
return getNumberScalarType(
|
||||
(typeOptions?.settings as FieldMetadataSettings<FieldMetadataType.NUMBER>)
|
||||
?.dataType ?? NumberDataType.FLOAT,
|
||||
);
|
||||
}
|
||||
|
||||
mapToFilterType(
|
||||
@@ -133,6 +178,7 @@ export class TypeMapperService {
|
||||
],
|
||||
[FieldMetadataType.NUMERIC, BigFloatFilterType],
|
||||
[FieldMetadataType.POSITION, FloatFilterType],
|
||||
[FieldMetadataType.FILES, RawJsonFilterType],
|
||||
[FieldMetadataType.RAW_JSON, RawJsonFilterType],
|
||||
[FieldMetadataType.RICH_TEXT, StringFilterType],
|
||||
[FieldMetadataType.RICH_TEXT_V2, RichTextV2FilterType],
|
||||
@@ -162,6 +208,7 @@ export class TypeMapperService {
|
||||
[FieldMetadataType.SELECT, OrderByDirectionType],
|
||||
[FieldMetadataType.MULTI_SELECT, OrderByDirectionType],
|
||||
[FieldMetadataType.POSITION, OrderByDirectionType],
|
||||
[FieldMetadataType.FILES, OrderByDirectionType],
|
||||
[FieldMetadataType.RAW_JSON, OrderByDirectionType],
|
||||
[FieldMetadataType.RICH_TEXT, OrderByDirectionType],
|
||||
[FieldMetadataType.ARRAY, OrderByDirectionType],
|
||||
|
||||
+1
@@ -13,4 +13,5 @@ export enum FeatureFlagKey {
|
||||
IS_IF_ELSE_ENABLED = 'IS_IF_ELSE_ENABLED',
|
||||
IS_SSE_DB_EVENTS_ENABLED = 'IS_SSE_DB_EVENTS_ENABLED',
|
||||
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
|
||||
IS_FILES_FIELD_ENABLED = 'IS_FILES_FIELD_ENABLED',
|
||||
}
|
||||
|
||||
+4
@@ -141,6 +141,10 @@ export const generateRandomFieldValue = ({
|
||||
return [];
|
||||
}
|
||||
|
||||
case FieldMetadataType.FILES: {
|
||||
return null;
|
||||
}
|
||||
|
||||
case FieldMetadataType.TS_VECTOR: {
|
||||
throw new Error(
|
||||
`We should not generate fake version for ${field.type} field`,
|
||||
|
||||
+5
@@ -9,6 +9,7 @@ import { z } from 'zod';
|
||||
|
||||
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
||||
|
||||
import { filesFieldSchema } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util';
|
||||
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
@@ -243,6 +244,10 @@ export const generateRecordPropertiesZodSchema = (
|
||||
});
|
||||
break;
|
||||
|
||||
case FieldMetadataType.FILES:
|
||||
fieldSchema = filesFieldSchema;
|
||||
break;
|
||||
|
||||
default:
|
||||
fieldSchema = getFieldZodType(field);
|
||||
break;
|
||||
|
||||
+2
@@ -9,6 +9,7 @@ import { FlatEntityPropertiesUpdates } from 'src/engine/metadata-modules/flat-en
|
||||
import { type FlatFieldMetadataTypeValidator } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type';
|
||||
import { FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
|
||||
import { validateEnumSelectFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-enum-flat-field-metadata.util';
|
||||
import { validateFilesFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-files-flat-field-metadata.util';
|
||||
import { validateMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-metadata.util';
|
||||
import { validateMorphRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-flat-field-metadata.util';
|
||||
import { FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-validation-args.type';
|
||||
@@ -60,6 +61,7 @@ export class FlatFieldMetadataTypeValidatorService {
|
||||
DATE: DEFAULT_NO_VALIDATION,
|
||||
DATE_TIME: DEFAULT_NO_VALIDATION,
|
||||
EMAILS: DEFAULT_NO_VALIDATION,
|
||||
FILES: validateFilesFlatFieldMetadata,
|
||||
FULL_NAME: DEFAULT_NO_VALIDATION,
|
||||
LINKS: DEFAULT_NO_VALIDATION,
|
||||
NUMBER: DEFAULT_NO_VALIDATION,
|
||||
|
||||
+1
@@ -172,6 +172,7 @@ export const fromCreateFieldInputToFlatFieldMetadatasToCreate = async ({
|
||||
case FieldMetadataType.NUMERIC:
|
||||
case FieldMetadataType.LINKS:
|
||||
case FieldMetadataType.CURRENCY:
|
||||
case FieldMetadataType.FILES:
|
||||
case FieldMetadataType.FULL_NAME:
|
||||
case FieldMetadataType.POSITION:
|
||||
case FieldMetadataType.ADDRESS:
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { validateFilesFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-files-flat-field-metadata.util';
|
||||
|
||||
const createFlatEntityToValidate = (
|
||||
overrides: Partial<FlatFieldMetadata<FieldMetadataType.FILES>> = {},
|
||||
): FlatFieldMetadata<FieldMetadataType.FILES> =>
|
||||
({
|
||||
type: FieldMetadataType.FILES,
|
||||
name: 'testFilesField',
|
||||
label: 'Test Files Field',
|
||||
settings: { maxNumberOfValues: 5 },
|
||||
isUnique: false,
|
||||
...overrides,
|
||||
}) as FlatFieldMetadata<FieldMetadataType.FILES>;
|
||||
|
||||
const callValidator = (
|
||||
flatEntityToValidate: FlatFieldMetadata<FieldMetadataType.FILES>,
|
||||
featureFlagEnabled = true,
|
||||
) =>
|
||||
validateFilesFlatFieldMetadata({
|
||||
flatEntityToValidate,
|
||||
additionalCacheDataMaps: {
|
||||
featureFlagsMap: {
|
||||
[FeatureFlagKey.IS_FILES_FIELD_ENABLED]: featureFlagEnabled,
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof validateFilesFlatFieldMetadata>[0]);
|
||||
|
||||
describe('validateFilesFlatFieldMetadata', () => {
|
||||
it('should return no errors for a valid files field', () => {
|
||||
const errors = callValidator(createFlatEntityToValidate());
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return error when feature flag is disabled', () => {
|
||||
const errors = callValidator(createFlatEntityToValidate(), false);
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].code).toBe(FieldMetadataExceptionCode.INVALID_FIELD_INPUT);
|
||||
expect(errors[0].message).toContain('Files field type is not supported');
|
||||
});
|
||||
|
||||
it('should return error when isUnique is true', () => {
|
||||
const errors = callValidator(
|
||||
createFlatEntityToValidate({ isUnique: true }),
|
||||
);
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].code).toBe(FieldMetadataExceptionCode.INVALID_FIELD_INPUT);
|
||||
expect(errors[0].message).toContain(
|
||||
'Files field is not supported for unique fields',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when settings is undefined', () => {
|
||||
const errors = callValidator(
|
||||
createFlatEntityToValidate({ settings: undefined }),
|
||||
);
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].code).toBe(FieldMetadataExceptionCode.INVALID_FIELD_INPUT);
|
||||
expect(errors[0].message).toContain(
|
||||
'maxNumberOfValues must be defined in settings',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when maxNumberOfValues is 0', () => {
|
||||
const errors = callValidator(
|
||||
createFlatEntityToValidate({ settings: { maxNumberOfValues: 0 } }),
|
||||
);
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].code).toBe(FieldMetadataExceptionCode.INVALID_FIELD_INPUT);
|
||||
});
|
||||
|
||||
it('should return error when maxNumberOfValues exceeds max (11)', () => {
|
||||
const errors = callValidator(
|
||||
createFlatEntityToValidate({ settings: { maxNumberOfValues: 11 } }),
|
||||
);
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].code).toBe(FieldMetadataExceptionCode.INVALID_FIELD_INPUT);
|
||||
});
|
||||
});
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { FILES_FIELD_MAX_NUMBER_OF_VALUES } from 'twenty-shared/constants';
|
||||
import { type FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
|
||||
import { type FlatFieldMetadataTypeValidationArgs } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type';
|
||||
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
|
||||
|
||||
export const validateFilesFlatFieldMetadata = ({
|
||||
flatEntityToValidate,
|
||||
additionalCacheDataMaps,
|
||||
}: FlatFieldMetadataTypeValidationArgs<FieldMetadataType.FILES>): FlatFieldMetadataValidationError[] => {
|
||||
const errors: FlatFieldMetadataValidationError[] = [];
|
||||
const { featureFlagsMap } = additionalCacheDataMaps;
|
||||
|
||||
if (featureFlagsMap[FeatureFlagKey.IS_FILES_FIELD_ENABLED] !== true) {
|
||||
errors.push({
|
||||
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
message: 'Files field type is not supported',
|
||||
userFriendlyMessage: msg`Files field type is not supported`,
|
||||
});
|
||||
}
|
||||
|
||||
if (flatEntityToValidate.isUnique === true) {
|
||||
errors.push({
|
||||
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
message: 'Files field is not supported for unique fields',
|
||||
userFriendlyMessage: msg`Files field is not supported for unique fields`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!isDefined(flatEntityToValidate?.settings?.maxNumberOfValues) ||
|
||||
flatEntityToValidate.settings.maxNumberOfValues < 1 ||
|
||||
flatEntityToValidate.settings.maxNumberOfValues >
|
||||
FILES_FIELD_MAX_NUMBER_OF_VALUES
|
||||
) {
|
||||
errors.push({
|
||||
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
message: `maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to ${FILES_FIELD_MAX_NUMBER_OF_VALUES}`,
|
||||
userFriendlyMessage: msg`maxNumberOfValues must be defined in settings and be a number greater than 0 and less than or equal to ${FILES_FIELD_MAX_NUMBER_OF_VALUES}`,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
+1
@@ -223,6 +223,7 @@ describe('WorkspaceEntityManager', () => {
|
||||
IS_IF_ELSE_ENABLED: false,
|
||||
IS_SSE_DB_EVENTS_ENABLED: false,
|
||||
IS_COMMAND_MENU_ITEM_ENABLED: false,
|
||||
IS_FILES_FIELD_ENABLED: false,
|
||||
},
|
||||
userWorkspaceRoleMap: {},
|
||||
eventEmitterService: {
|
||||
|
||||
+27
-1
@@ -1,7 +1,8 @@
|
||||
import {
|
||||
FieldMetadataType,
|
||||
type FieldMetadataSettings,
|
||||
FILE_CATEGORIES,
|
||||
NumberDataType,
|
||||
type FieldMetadataSettings,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@@ -330,6 +331,31 @@ export const convertObjectMetadataToSchemaProperties = ({
|
||||
},
|
||||
};
|
||||
break;
|
||||
case FieldMetadataType.FILES:
|
||||
itemProperty = {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
fileId: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
},
|
||||
label: {
|
||||
type: 'string',
|
||||
},
|
||||
...(forResponse
|
||||
? {
|
||||
fileCategory: {
|
||||
type: 'string',
|
||||
enum: Object.values(FILE_CATEGORIES),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
default:
|
||||
itemProperty = getFieldProperties(field);
|
||||
break;
|
||||
|
||||
+5
@@ -81,6 +81,11 @@ export const seedFeatureFlags = async ({
|
||||
workspaceId: workspaceId,
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKey.IS_FILES_FIELD_ENABLED,
|
||||
workspaceId: workspaceId,
|
||||
value: true,
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
};
|
||||
|
||||
+9
@@ -58,4 +58,13 @@ export const SURVEY_RESULT_CUSTOM_FIELD_SEEDS: FieldMetadataSeed[] = [
|
||||
displayedMaxRows: 1,
|
||||
},
|
||||
} as FieldMetadataDTO<FieldMetadataType.TEXT>,
|
||||
{
|
||||
type: FieldMetadataType.FILES,
|
||||
label: 'Files',
|
||||
name: 'files',
|
||||
icon: 'IconFiles',
|
||||
settings: {
|
||||
maxNumberOfValues: 5,
|
||||
},
|
||||
} as FieldMetadataDTO<FieldMetadataType.FILES>,
|
||||
];
|
||||
|
||||
+1
@@ -35,6 +35,7 @@ export const fieldMetadataTypeToColumnType = <Type extends FieldMetadataType>(
|
||||
case FieldMetadataType.SELECT:
|
||||
case FieldMetadataType.MULTI_SELECT:
|
||||
return 'enum';
|
||||
case FieldMetadataType.FILES:
|
||||
case FieldMetadataType.RAW_JSON:
|
||||
return 'jsonb';
|
||||
case FieldMetadataType.TS_VECTOR:
|
||||
|
||||
Reference in New Issue
Block a user