diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/factories.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/factories.ts index 730bcec50..3bb57aaa5 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/factories.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/factories.ts @@ -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, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory.ts index 769e98b1b..b2cd38fcc 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory.ts @@ -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, + }); } }, }); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type.factory.ts index 58c16e4be..49a75602c 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type.factory.ts @@ -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, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/object-type-definition.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/object-type-definition.factory.ts index cf2efd3a7..842737ea5 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/object-type-definition.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/object-type-definition.factory.ts @@ -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, + }), }), }; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/relation-connect-input-type-definition.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/relation-connect-input-type-definition.factory.ts new file mode 100644 index 000000000..f97449401 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/relation-connect-input-type-definition.factory.ts @@ -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 { + 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 '); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface.ts index 19bd21e27..81852e9ca 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface.ts @@ -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; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts index 53207aa1b..414e41642 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts @@ -50,6 +50,7 @@ export interface TypeOptions { defaultValue?: T; settings?: FieldMetadataSettings; isIdField?: boolean; + isRelationConnectField?: boolean; } const StringArrayScalarType = new GraphQLList(GraphQLString); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/type-definitions.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/type-definitions.generator.ts index e8cd73f6d..5a5c2a7a1 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/type-definitions.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/type-definitions.generator.ts @@ -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); + } } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts index caad7cf58..0e53e4c0e 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts @@ -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 = // 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 = export const generateFields = < T extends InputTypeDefinitionKind | ObjectTypeDefinitionKind, ->( - objectMetadata: ObjectMetadataInterface, - kind: T, - options: WorkspaceBuildSchemaOptions, - typeFactory: TypeFactory, -): T extends InputTypeDefinitionKind +>({ + objectMetadata, + kind, + options, + typeFactory, + isRelationConnectEnabled = false, +}: { + objectMetadata: ObjectMetadataInterface; + kind: T; + options: WorkspaceBuildSchemaOptions; + typeFactory: TypeFactory; + 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 => { - 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 = ( + fieldMetadata: FieldMetadataInterface, +) => { + return isCompositeFieldMetadataType(fieldMetadata.type) + ? fieldMetadata.type.toString() + : fieldMetadata.id; +}; + +const getTypeFactoryOptions = ( + fieldMetadata: FieldMetadataInterface, + 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; +}) => { + 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; + kind: T; + options: WorkspaceBuildSchemaOptions; + typeFactory: TypeFactory; + 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 diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/workspace-schema-builder.module.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/workspace-schema-builder.module.ts index d29d2ce4b..d56143e3c 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/workspace-schema-builder.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/workspace-schema-builder.module.ts @@ -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, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts index ce301ae30..46c32323f 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts @@ -81,7 +81,9 @@ export class WorkspaceSchemaFactory { await this.workspaceGraphQLSchemaFactory.create( objectMetadataCollection, workspaceResolverBuilderMethodNames, - {}, + { + workspaceId: authContext.workspace.id, + }, ); usedScalarNames = diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 1a7ce3cd8..b4ac0175a 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -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', } diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/getUniqueConstraintsFields.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/getUniqueConstraintsFields.util.spec.ts new file mode 100644 index 000000000..454f36f92 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/getUniqueConstraintsFields.util.spec.ts @@ -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); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/getUniqueConstraintsFields.util.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/getUniqueConstraintsFields.util.ts new file mode 100644 index 000000000..d4c7653fe --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/getUniqueConstraintsFields.util.ts @@ -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]; +};