Connect - Update Gql schema generation (#13001)

To test : 

```
mutation testMutation {
  createPerson(
    data: {company: {connect: {where: {domainName: {primaryLinkUrl: "airbnb.com"}}}}}
  ) {
    id
  }
}
```

closes https://github.com/twentyhq/core-team-issues/issues/1167
This commit is contained in:
Etienne
2025-07-02 16:37:24 +02:00
committed by GitHub
parent 54e233d7b9
commit ba67e0d5f4
14 changed files with 604 additions and 98 deletions

View File

@ -4,6 +4,7 @@ import { CompositeInputTypeDefinitionFactory } from 'src/engine/api/graphql/work
import { CompositeObjectTypeDefinitionFactory } from 'src/engine/api/graphql/workspace-schema-builder/factories/composite-object-type-definition.factory';
import { EnumTypeDefinitionFactory } from 'src/engine/api/graphql/workspace-schema-builder/factories/enum-type-definition.factory';
import { ExtendObjectTypeDefinitionV2Factory } from 'src/engine/api/graphql/workspace-schema-builder/factories/extend-object-type-definition-v2.factory';
import { RelationConnectInputTypeDefinitionFactory } from 'src/engine/api/graphql/workspace-schema-builder/factories/relation-connect-input-type-definition.factory';
import { RelationTypeV2Factory } from 'src/engine/api/graphql/workspace-schema-builder/factories/relation-type-v2.factory';
import { ArgsFactory } from './args.factory';
@ -25,6 +26,7 @@ export const workspaceSchemaBuilderFactories = [
InputTypeFactory,
InputTypeDefinitionFactory,
CompositeInputTypeDefinitionFactory,
RelationConnectInputTypeDefinitionFactory,
OutputTypeFactory,
ObjectTypeDefinitionFactory,
CompositeObjectTypeDefinitionFactory,

View File

@ -32,11 +32,17 @@ export class InputTypeDefinitionFactory {
private readonly typeMapperService: TypeMapperService,
) {}
public create(
objectMetadata: ObjectMetadataInterface,
kind: InputTypeDefinitionKind,
options: WorkspaceBuildSchemaOptions,
): InputTypeDefinition {
public create({
objectMetadata,
kind,
options,
isRelationConnectEnabled = false,
}: {
objectMetadata: ObjectMetadataInterface;
kind: InputTypeDefinitionKind;
options: WorkspaceBuildSchemaOptions;
isRelationConnectEnabled?: boolean;
}): InputTypeDefinition {
// @ts-expect-error legacy noImplicitAny
const inputType = new GraphQLInputObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}${kind.toString()}Input`,
@ -55,12 +61,12 @@ export class InputTypeDefinitionFactory {
});
return {
...generateFields(
...generateFields({
objectMetadata,
kind,
options,
this.inputTypeFactory,
),
typeFactory: this.inputTypeFactory,
}),
and: {
type: andOrType,
},
@ -78,12 +84,13 @@ export class InputTypeDefinitionFactory {
* Other input types are generated with fields only
*/
default:
return generateFields(
return generateFields({
objectMetadata,
kind,
options,
this.inputTypeFactory,
);
typeFactory: this.inputTypeFactory,
isRelationConnectEnabled,
});
}
},
});

View File

@ -44,6 +44,9 @@ export class InputTypeFactory {
*/
case InputTypeDefinitionKind.Create:
case InputTypeDefinitionKind.Update:
//if it's a relation connect field, type is in storage
if (typeOptions.isRelationConnectField) break;
inputType = this.typeMapperService.mapToScalarType(
type,
typeOptions.settings,

View File

@ -37,12 +37,12 @@ export class ObjectTypeDefinitionFactory {
type: new GraphQLObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}${kind.toString()}`,
description: objectMetadata.description,
fields: generateFields(
fields: generateFields({
objectMetadata,
kind,
options,
this.outputTypeFactory,
),
typeFactory: this.outputTypeFactory,
}),
}),
};
}

View File

@ -0,0 +1,150 @@
import { Injectable } from '@nestjs/common';
import {
GraphQLInputFieldConfig,
GraphQLInputObjectType,
GraphQLInputType,
GraphQLString,
} from 'graphql';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import {
InputTypeDefinition,
InputTypeDefinitionKind,
} from 'src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory';
import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { getUniqueConstraintsFields } from 'src/engine/metadata-modules/index-metadata/utils/getUniqueConstraintsFields.util';
import { pascalCase } from 'src/utils/pascal-case';
export const formatRelationConnectInputTarget = (objectMetadataId: string) =>
`${objectMetadataId}-connect-input`;
@Injectable()
export class RelationConnectInputTypeDefinitionFactory {
constructor(private readonly typeMapperService: TypeMapperService) {}
public create(
objectMetadata: ObjectMetadataInterface,
): InputTypeDefinition[] {
const fields = this.generateRelationConnectInputType(objectMetadata);
const target = formatRelationConnectInputTarget(objectMetadata.id);
return [
{
target,
kind: InputTypeDefinitionKind.Create,
type: fields,
},
{
target,
kind: InputTypeDefinitionKind.Update,
type: fields,
},
];
}
private generateRelationConnectInputType(
objectMetadata: ObjectMetadataInterface,
): GraphQLInputObjectType {
return new GraphQLInputObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}RelationInput`,
fields: () => ({
connect: {
type: new GraphQLInputObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}ConnectInput`,
fields: this.generateRelationWhereInputType(objectMetadata),
}),
description: `Connect to a ${objectMetadata.nameSingular} record`,
},
}),
});
}
private generateRelationWhereInputType(
objectMetadata: ObjectMetadataInterface,
): Record<string, GraphQLInputFieldConfig> {
const uniqueConstraints = getUniqueConstraintsFields(objectMetadata);
const fields: Record<
string,
{ type: GraphQLInputType; description: string }
> = {};
uniqueConstraints.forEach((constraint) => {
constraint.forEach((field) => {
if (isCompositeFieldMetadataType(field.type)) {
const compositeType = compositeTypeDefinitions.get(field.type);
if (!compositeType) {
throw new Error(
`Composite type definition not found for field type ${field.type}`,
);
}
const uniqueProperties = compositeType.properties.filter(
(property) => property.isIncludedInUniqueConstraint,
);
if (uniqueProperties.length > 0) {
const compositeFields: Record<
string,
{ type: GraphQLInputType; description: string }
> = {};
uniqueProperties.forEach((property) => {
const scalarType = this.typeMapperService.mapToScalarType(
property.type,
);
compositeFields[property.name] = {
type: scalarType || GraphQLString,
description: `Connect by ${property.name}`,
};
});
const compositeInputType = new GraphQLInputObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}${pascalCase(field.name)}WhereInput`,
fields: () => compositeFields,
});
fields[field.name] = {
type: compositeInputType,
description: `Connect by ${field.label || field.name}`,
};
}
} else {
const scalarType = this.typeMapperService.mapToScalarType(
field.type,
field.settings,
field.name === 'id',
);
fields[field.name] = {
type: scalarType || GraphQLString,
description: `Connect by ${field.label || field.name}`,
};
}
});
});
return {
where: {
type: new GraphQLInputObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}WhereUniqueInput`,
fields: () => fields,
}),
description: `Find a ${objectMetadata.nameSingular} record based on its unique fields: ${this.formatConstraints(uniqueConstraints)}`,
},
};
}
private formatConstraints(constraints: FieldMetadataInterface[][]) {
return constraints
.map((constraint) => constraint.map((field) => field.name).join(' and '))
.join(' or ');
}
}

View File

@ -13,4 +13,10 @@ export interface WorkspaceBuildSchemaOptions {
* @default 'float'
*/
numberScalarMode?: NumberScalarMode;
/**
* Workspace ID - used to relation connect feature flag check
* TODO: remove once IS_RELATION_CONNECT_ENABLED is removed
*/
workspaceId?: string;
}

View File

@ -50,6 +50,7 @@ export interface TypeOptions<T = any> {
defaultValue?: T;
settings?: FieldMetadataSettings<FieldMetadataType>;
isIdField?: boolean;
isRelationConnectField?: boolean;
}
const StringArrayScalarType = new GraphQLList(GraphQLString);

View File

@ -1,5 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
@ -8,6 +10,9 @@ import { CompositeInputTypeDefinitionFactory } from 'src/engine/api/graphql/work
import { CompositeObjectTypeDefinitionFactory } from 'src/engine/api/graphql/workspace-schema-builder/factories/composite-object-type-definition.factory';
import { EnumTypeDefinitionFactory } from 'src/engine/api/graphql/workspace-schema-builder/factories/enum-type-definition.factory';
import { ExtendObjectTypeDefinitionV2Factory } from 'src/engine/api/graphql/workspace-schema-builder/factories/extend-object-type-definition-v2.factory';
import { RelationConnectInputTypeDefinitionFactory } from 'src/engine/api/graphql/workspace-schema-builder/factories/relation-connect-input-type-definition.factory';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { ConnectionTypeDefinitionFactory } from './factories/connection-type-definition.factory';
@ -39,6 +44,8 @@ export class TypeDefinitionsGenerator {
private readonly edgeTypeDefinitionFactory: EdgeTypeDefinitionFactory,
private readonly connectionTypeDefinitionFactory: ConnectionTypeDefinitionFactory,
private readonly extendObjectTypeDefinitionV2Factory: ExtendObjectTypeDefinitionV2Factory,
private readonly relationConnectInputTypeDefinitionFactory: RelationConnectInputTypeDefinitionFactory,
private readonly featureFlagService: FeatureFlagService,
) {}
async generate(
@ -49,6 +56,8 @@ export class TypeDefinitionsGenerator {
await this.generateCompositeTypeDefs(options);
// Generate metadata objects
await this.generateMetadataTypeDefs(objectMetadataCollection, options);
this.generateRelationConnectInputTypeDefs(objectMetadataCollection);
}
/**
@ -96,10 +105,10 @@ export class TypeDefinitionsGenerator {
}
private generateCompositeInputTypeDefs(
compisteTypes: CompositeType[],
compositeTypes: CompositeType[],
options: WorkspaceBuildSchemaOptions,
) {
const inputTypeDefs = compisteTypes
const inputTypeDefs = compositeTypes
.map((compositeType) => {
const optionalExtendedObjectMetadata = {
...compositeType,
@ -159,7 +168,7 @@ export class TypeDefinitionsGenerator {
this.generateEnumTypeDefs(dynamicObjectMetadataCollection, options);
this.generateObjectTypeDefs(dynamicObjectMetadataCollection, options);
this.generatePaginationTypeDefs(dynamicObjectMetadataCollection, options);
this.generateInputTypeDefs(dynamicObjectMetadataCollection, options);
await this.generateInputTypeDefs(dynamicObjectMetadataCollection, options);
await this.generateExtendedObjectTypeDefs(
dynamicObjectMetadataCollection,
options,
@ -200,10 +209,17 @@ export class TypeDefinitionsGenerator {
this.typeDefinitionsStorage.addObjectTypes(connectionTypeDefs);
}
private generateInputTypeDefs(
private async generateInputTypeDefs(
objectMetadataCollection: ObjectMetadataInterface[],
options: WorkspaceBuildSchemaOptions,
) {
const isRelationConnectEnabled = isDefined(options.workspaceId)
? await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_RELATION_CONNECT_ENABLED,
options.workspaceId,
)
: false;
const inputTypeDefs = objectMetadataCollection
.map((objectMetadata) => {
const optionalExtendedObjectMetadata = {
@ -216,29 +232,31 @@ export class TypeDefinitionsGenerator {
return [
// Input type for create
this.inputTypeDefinitionFactory.create(
this.inputTypeDefinitionFactory.create({
objectMetadata,
InputTypeDefinitionKind.Create,
kind: InputTypeDefinitionKind.Create,
options,
),
isRelationConnectEnabled,
}),
// Input type for update
this.inputTypeDefinitionFactory.create(
optionalExtendedObjectMetadata,
InputTypeDefinitionKind.Update,
this.inputTypeDefinitionFactory.create({
objectMetadata: optionalExtendedObjectMetadata,
kind: InputTypeDefinitionKind.Update,
isRelationConnectEnabled,
options,
),
}),
// Filter input type
this.inputTypeDefinitionFactory.create(
optionalExtendedObjectMetadata,
InputTypeDefinitionKind.Filter,
this.inputTypeDefinitionFactory.create({
objectMetadata: optionalExtendedObjectMetadata,
kind: InputTypeDefinitionKind.Filter,
options,
),
}),
// OrderBy input type
this.inputTypeDefinitionFactory.create(
optionalExtendedObjectMetadata,
InputTypeDefinitionKind.OrderBy,
this.inputTypeDefinitionFactory.create({
objectMetadata: optionalExtendedObjectMetadata,
kind: InputTypeDefinitionKind.OrderBy,
options,
),
}),
];
})
.flat();
@ -283,4 +301,16 @@ export class TypeDefinitionsGenerator {
this.typeDefinitionsStorage.addObjectTypes(objectTypeDefs);
}
private generateRelationConnectInputTypeDefs(
objectMetadataCollection: ObjectMetadataInterface[],
) {
const relationWhereInputTypeDefs = objectMetadataCollection
.map((objectMetadata) =>
this.relationConnectInputTypeDefinitionFactory.create(objectMetadata),
)
.flat();
this.typeDefinitionsStorage.addInputTypes(relationWhereInputTypeDefs);
}
}

View File

@ -5,13 +5,16 @@ import {
GraphQLOutputType,
} from 'graphql';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceBuildSchemaOptions } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { InputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory';
import { ObjectTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/factories/object-type-definition.factory';
import { formatRelationConnectInputTarget } from 'src/engine/api/graphql/workspace-schema-builder/factories/relation-connect-input-type-definition.factory';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { isFieldMetadataInterfaceOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
@ -30,6 +33,7 @@ type TypeFactory<T extends InputTypeDefinitionKind | ObjectTypeDefinitionKind> =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
settings: any;
isIdField: boolean;
isRelationConnectField?: boolean;
},
) => T extends InputTypeDefinitionKind
? GraphQLInputType
@ -38,87 +42,189 @@ type TypeFactory<T extends InputTypeDefinitionKind | ObjectTypeDefinitionKind> =
export const generateFields = <
T extends InputTypeDefinitionKind | ObjectTypeDefinitionKind,
>(
objectMetadata: ObjectMetadataInterface,
kind: T,
options: WorkspaceBuildSchemaOptions,
typeFactory: TypeFactory<T>,
): T extends InputTypeDefinitionKind
>({
objectMetadata,
kind,
options,
typeFactory,
isRelationConnectEnabled = false,
}: {
objectMetadata: ObjectMetadataInterface;
kind: T;
options: WorkspaceBuildSchemaOptions;
typeFactory: TypeFactory<T>;
isRelationConnectEnabled?: boolean;
}): T extends InputTypeDefinitionKind
? GraphQLInputFieldConfigMap
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
GraphQLFieldConfigMap<any, any> => {
const fields = {};
const allGeneratedFields = {};
for (const fieldMetadata of objectMetadata.fields) {
let generatedField;
if (
isFieldMetadataInterfaceOfType(
fieldMetadata,
FieldMetadataType.RELATION,
) &&
fieldMetadata.settings?.relationType !== RelationType.MANY_TO_ONE
isFieldMetadataInterfaceOfType(fieldMetadata, FieldMetadataType.RELATION)
) {
continue;
generatedField = generateRelationField({
fieldMetadata,
kind,
options,
typeFactory,
isRelationConnectEnabled,
});
} else {
generatedField = generateField({
fieldMetadata,
kind,
options,
typeFactory,
});
}
const target = isCompositeFieldMetadataType(fieldMetadata.type)
? fieldMetadata.type.toString()
: fieldMetadata.id;
Object.assign(allGeneratedFields, generatedField);
}
const typeFactoryOptions = isInputTypeDefinitionKind(kind)
? {
nullable: fieldMetadata.isNullable,
defaultValue: fieldMetadata.defaultValue,
isArray:
kind !== InputTypeDefinitionKind.Filter &&
fieldMetadata.type === FieldMetadataType.MULTI_SELECT,
settings: fieldMetadata.settings,
isIdField: fieldMetadata.name === 'id',
}
: {
nullable: fieldMetadata.isNullable,
isArray: fieldMetadata.type === FieldMetadataType.MULTI_SELECT,
settings: fieldMetadata.settings,
// Scalar type is already defined in the entity itself.
isIdField: false,
};
return allGeneratedFields;
};
const type = typeFactory.create(
target,
const getTarget = <T extends FieldMetadataType>(
fieldMetadata: FieldMetadataInterface<T>,
) => {
return isCompositeFieldMetadataType(fieldMetadata.type)
? fieldMetadata.type.toString()
: fieldMetadata.id;
};
const getTypeFactoryOptions = <T extends FieldMetadataType>(
fieldMetadata: FieldMetadataInterface<T>,
kind: InputTypeDefinitionKind | ObjectTypeDefinitionKind,
) => {
return isInputTypeDefinitionKind(kind)
? {
nullable: fieldMetadata.isNullable,
defaultValue: fieldMetadata.defaultValue,
isArray:
kind !== InputTypeDefinitionKind.Filter &&
fieldMetadata.type === FieldMetadataType.MULTI_SELECT,
settings: fieldMetadata.settings,
isIdField: fieldMetadata.name === 'id',
}
: {
nullable: fieldMetadata.isNullable,
isArray: fieldMetadata.type === FieldMetadataType.MULTI_SELECT,
settings: fieldMetadata.settings,
// Scalar type is already defined in the entity itself.
isIdField: false,
};
};
const generateField = <
T extends InputTypeDefinitionKind | ObjectTypeDefinitionKind,
>({
fieldMetadata,
kind,
options,
typeFactory,
}: {
fieldMetadata: FieldMetadataInterface;
kind: T;
options: WorkspaceBuildSchemaOptions;
typeFactory: TypeFactory<T>;
}) => {
const target = getTarget(fieldMetadata);
const typeFactoryOptions = getTypeFactoryOptions(fieldMetadata, kind);
const type = typeFactory.create(
target,
fieldMetadata.type,
kind,
options,
typeFactoryOptions,
);
return {
[fieldMetadata.name]: {
type,
description: fieldMetadata.description,
},
};
};
const generateRelationField = <
T extends InputTypeDefinitionKind | ObjectTypeDefinitionKind,
>({
fieldMetadata,
kind,
options,
typeFactory,
isRelationConnectEnabled,
}: {
fieldMetadata: FieldMetadataInterface<FieldMetadataType.RELATION>;
kind: T;
options: WorkspaceBuildSchemaOptions;
typeFactory: TypeFactory<T>;
isRelationConnectEnabled: boolean;
}) => {
const relationField = {};
if (fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY) {
return relationField;
}
const joinColumnName = fieldMetadata.settings?.joinColumnName;
if (!joinColumnName) {
throw new Error('Join column name is not defined');
}
const target = getTarget(fieldMetadata);
const typeFactoryOptions = getTypeFactoryOptions(fieldMetadata, kind);
let type = typeFactory.create(
target,
fieldMetadata.type,
kind,
options,
typeFactoryOptions,
);
// @ts-expect-error legacy noImplicitAny
relationField[joinColumnName] = {
type,
description: fieldMetadata.description,
};
if (
[InputTypeDefinitionKind.Create, InputTypeDefinitionKind.Update].includes(
kind as InputTypeDefinitionKind,
) &&
isDefined(fieldMetadata.relationTargetObjectMetadataId) &&
isRelationConnectEnabled
) {
type = typeFactory.create(
formatRelationConnectInputTarget(
fieldMetadata.relationTargetObjectMetadataId,
),
fieldMetadata.type,
kind,
options,
typeFactoryOptions,
{
...typeFactoryOptions,
isRelationConnectField: true,
},
);
if (
isFieldMetadataInterfaceOfType(
fieldMetadata,
FieldMetadataType.RELATION,
) &&
fieldMetadata.settings?.relationType === RelationType.MANY_TO_ONE
) {
const joinColumnName = fieldMetadata.settings?.joinColumnName;
if (!joinColumnName) {
throw new Error('Join column name is not defined');
}
// @ts-expect-error legacy noImplicitAny
fields[joinColumnName] = {
type,
description: fieldMetadata.description,
};
}
// @ts-expect-error legacy noImplicitAny
fields[fieldMetadata.name] = {
type,
description: fieldMetadata.description,
};
}
return fields;
// @ts-expect-error legacy noImplicitAny
relationField[fieldMetadata.name] = {
type: type,
description: fieldMetadata.description,
};
return relationField;
};
// Type guard

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { WorkspaceResolverBuilderModule } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { TypeDefinitionsGenerator } from './type-definitions.generator';
@ -11,7 +12,11 @@ import { TypeMapperService } from './services/type-mapper.service';
import { TypeDefinitionsStorage } from './storages/type-definitions.storage';
@Module({
imports: [ObjectMetadataModule, WorkspaceResolverBuilderModule],
imports: [
ObjectMetadataModule,
WorkspaceResolverBuilderModule,
FeatureFlagModule,
],
providers: [
TypeDefinitionsStorage,
TypeMapperService,

View File

@ -81,7 +81,9 @@ export class WorkspaceSchemaFactory {
await this.workspaceGraphQLSchemaFactory.create(
objectMetadataCollection,
workspaceResolverBuilderMethodNames,
{},
{
workspaceId: authContext.workspace.id,
},
);
usedScalarNames =

View File

@ -7,4 +7,5 @@ export enum FeatureFlagKey {
IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
}

View File

@ -0,0 +1,151 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { IndexFieldMetadataInterface } from 'src/engine/metadata-modules/index-metadata/interfaces/index-field-metadata.interface';
import { IndexMetadataInterface } from 'src/engine/metadata-modules/index-metadata/interfaces/index-metadata.interface';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { getUniqueConstraintsFields } from 'src/engine/metadata-modules/index-metadata/utils/getUniqueConstraintsFields.util';
describe('getUniqueConstraintsFields', () => {
const mockIdField: FieldMetadataInterface = {
id: 'field-id-1',
name: 'id',
label: 'ID',
type: FieldMetadataType.UUID,
objectMetadataId: 'object-id-1',
isNullable: false,
isUnique: false,
isCustom: false,
isSystem: true,
isActive: true,
isLabelSyncedWithName: false,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
};
const mockEmailField: FieldMetadataInterface = {
id: 'field-id-2',
name: 'email',
label: 'Email',
type: FieldMetadataType.EMAILS,
objectMetadataId: 'object-id-1',
isNullable: true,
isUnique: true,
isCustom: false,
isSystem: false,
isActive: true,
isLabelSyncedWithName: false,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
};
const mockNameField: FieldMetadataInterface = {
id: 'field-id-3',
name: 'name',
label: 'Name',
type: FieldMetadataType.TEXT,
objectMetadataId: 'object-id-1',
isNullable: true,
isUnique: false,
isCustom: false,
isSystem: false,
isActive: true,
isLabelSyncedWithName: false,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
};
const createMockIndexFieldMetadata = (
fieldMetadataId: string,
indexMetadataId: string,
order = 0,
): IndexFieldMetadataInterface =>
({
id: `index-field-${fieldMetadataId}-${indexMetadataId}`,
indexMetadataId,
fieldMetadataId,
order,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
}) as IndexFieldMetadataInterface;
const createMockIndexMetadata = (
id: string,
name: string,
isUnique: boolean,
indexFieldMetadatas: IndexFieldMetadataInterface[],
): IndexMetadataInterface => ({
id,
name,
isUnique,
indexFieldMetadatas,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
indexWhereClause: null,
indexType: IndexType.BTREE,
});
const createMockObjectMetadata = (
fields: FieldMetadataInterface[],
indexMetadatas: IndexMetadataInterface[] = [],
): ObjectMetadataInterface => ({
id: 'object-id-1',
workspaceId: 'workspace-id-1',
nameSingular: 'person',
namePlural: 'people',
labelSingular: 'Person',
labelPlural: 'People',
description: 'A person object',
icon: 'IconUser',
targetTableName: 'person',
fields,
indexMetadatas,
isSystem: false,
isCustom: false,
isActive: true,
isRemote: false,
isAuditLogged: true,
isSearchable: true,
});
it('should return the primary key constraint field if no unique indexes are present', () => {
const objectMetadata = createMockObjectMetadata([
mockIdField,
mockNameField,
]);
const result = getUniqueConstraintsFields(objectMetadata);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(1);
expect(result[0][0]).toEqual(mockIdField);
});
it('should return the primary key constraint field and the unique indexes fields if unique indexes are present', () => {
const emailIndexFieldMetadata = createMockIndexFieldMetadata(
'field-id-2',
'index-id-1',
);
const emailIndex = createMockIndexMetadata(
'index-id-1',
'unique_email_index',
true,
[emailIndexFieldMetadata],
);
const objectMetadata = createMockObjectMetadata(
[mockIdField, mockEmailField, mockNameField],
[emailIndex],
);
const result = getUniqueConstraintsFields(objectMetadata);
expect(result).toHaveLength(2);
expect(result[0]).toHaveLength(1);
expect(result[0][0]).toEqual(mockIdField);
expect(result[1]).toHaveLength(1);
expect(result[1][0]).toEqual(mockEmailField);
});
});

View File

@ -0,0 +1,42 @@
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
export const getUniqueConstraintsFields = (
objectMetadata: ObjectMetadataInterface,
): FieldMetadataInterface[][] => {
const uniqueIndexes = objectMetadata.indexMetadatas.filter(
(index) => index.isUnique,
);
const fieldsMapById = new Map(
objectMetadata.fields.map((field) => [field.id, field]),
);
const primaryKeyConstraintField = objectMetadata.fields.find(
(field) => field.name === 'id',
);
if (!isDefined(primaryKeyConstraintField)) {
throw new Error(
`Primary key constraint field not found for object metadata ${objectMetadata.id}`,
);
}
const otherUniqueConstraintsFields = uniqueIndexes.map((index) =>
index.indexFieldMetadatas.map((field) => {
const indexField = fieldsMapById.get(field.fieldMetadataId);
if (!isDefined(indexField)) {
throw new Error(
`Index field not found for field id ${field.fieldMetadataId} in index metadata ${index.id}`,
);
}
return indexField;
}),
);
return [[primaryKeyConstraintField], ...otherUniqueConstraintsFields];
};