diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/common-args-processors.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/common-args-processors.ts index 33ddd150c35..263886f619d 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/common-args-processors.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/common-args-processors.ts @@ -1,9 +1,15 @@ import { DataArgProcessorService } from 'src/engine/api/common/common-args-processors/data-arg-processor/data-arg-processor.service'; import { FilterArgProcessorService } from 'src/engine/api/common/common-args-processors/filter-arg-processor/filter-arg-processor.service'; +import { GroupByArgProcessorService } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/group-by-arg-processor.service'; +import { OrderByArgProcessorService } from 'src/engine/api/common/common-args-processors/order-by-arg-processor/order-by-arg-processor.service'; +import { OrderByWithGroupByArgProcessorService } from 'src/engine/api/common/common-args-processors/order-by-with-group-by-arg-processor/order-by-with-group-by-arg-processor.service'; import { QueryRunnerArgsFactory } from 'src/engine/api/common/common-args-processors/query-runner-args.factory'; export const CommonArgsProcessors = [ DataArgProcessorService, FilterArgProcessorService, + GroupByArgProcessorService, + OrderByArgProcessorService, + OrderByWithGroupByArgProcessorService, QueryRunnerArgsFactory, ]; // TODO: Refacto-common Remove QueryRunnerArgsFactory diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-array-field.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-array-field.util.ts index 6ada51b487d..5aba93fd30a 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-array-field.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-array-field.util.ts @@ -1,9 +1,10 @@ +import { isString } from '@sniptt/guards'; import { isNullEquivalentArrayFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-array-field-value.util'; export const transformArrayField = ( value: string | string[] | null, ): string[] | null => { - if (typeof value === 'string') return [value]; + if (isString(value)) return [value]; return isNullEquivalentArrayFieldValue(value) ? null : value; }; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/group-by-arg-processor/group-by-arg-processor.service.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/group-by-arg-processor/group-by-arg-processor.service.ts new file mode 100644 index 00000000000..cbbc26b57ee --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/group-by-arg-processor/group-by-arg-processor.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { + ObjectRecordGroupByForAtomicField, + ObjectRecordGroupByForCompositeField, + ObjectRecordGroupByForDateField, +} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +@Injectable() +export class GroupByArgProcessorService { + process({ + groupBy, + }: { + groupBy: + | ObjectRecordGroupByForAtomicField + | ObjectRecordGroupByForCompositeField + | ObjectRecordGroupByForDateField + | Array< + | ObjectRecordGroupByForAtomicField + | ObjectRecordGroupByForCompositeField + | ObjectRecordGroupByForDateField + >; + }): Array< + | ObjectRecordGroupByForAtomicField + | ObjectRecordGroupByForCompositeField + | ObjectRecordGroupByForDateField + > { + if (Array.isArray(groupBy)) { + return groupBy; + } + + return [groupBy]; + } +} diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/order-by-arg-processor/order-by-arg-processor.service.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/order-by-arg-processor/order-by-arg-processor.service.ts new file mode 100644 index 00000000000..31342a2c8c1 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/order-by-arg-processor/order-by-arg-processor.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; +import { + ObjectRecordOrderByForCompositeField, + ObjectRecordOrderByForScalarField, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +@Injectable() +export class OrderByArgProcessorService { + process({ + orderBy, + }: { + orderBy: + | undefined + | ObjectRecordOrderByForScalarField + | ObjectRecordOrderByForCompositeField + | ObjectRecordOrderBy; + }): ObjectRecordOrderBy | undefined { + if (Array.isArray(orderBy) || !isDefined(orderBy)) { + return orderBy; + } + + return [orderBy]; + } +} diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/order-by-with-group-by-arg-processor/order-by-with-group-by-arg-processor.service.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/order-by-with-group-by-arg-processor/order-by-with-group-by-arg-processor.service.ts new file mode 100644 index 00000000000..c98ca000554 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/order-by-with-group-by-arg-processor/order-by-with-group-by-arg-processor.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { + OrderByWithGroupBy, + type AggregateOrderByWithGroupByField, + type ObjectRecordOrderByForCompositeField, + type ObjectRecordOrderByForRelationField, + type ObjectRecordOrderByForScalarField, + type ObjectRecordOrderByWithGroupByDateField, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +@Injectable() +export class OrderByWithGroupByArgProcessorService { + process({ + orderBy, + }: { + orderBy: + | undefined + | ObjectRecordOrderByForScalarField + | ObjectRecordOrderByForCompositeField + | ObjectRecordOrderByWithGroupByDateField + | ObjectRecordOrderByForRelationField + | AggregateOrderByWithGroupByField + | OrderByWithGroupBy; + }): OrderByWithGroupBy | undefined { + if (Array.isArray(orderBy) || !isDefined(orderBy)) { + return orderBy; + } + + return [orderBy]; + } +} diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts index f0b071ad6d1..d2cc9d8b293 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-base-query-runner.service.ts @@ -6,6 +6,9 @@ import { QueryResultFieldValue } from 'src/engine/api/graphql/workspace-query-ru import { DataArgProcessorService } from 'src/engine/api/common/common-args-processors/data-arg-processor/data-arg-processor.service'; import { FilterArgProcessorService } from 'src/engine/api/common/common-args-processors/filter-arg-processor/filter-arg-processor.service'; +import { GroupByArgProcessorService } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/group-by-arg-processor.service'; +import { OrderByArgProcessorService } from 'src/engine/api/common/common-args-processors/order-by-arg-processor/order-by-arg-processor.service'; +import { OrderByWithGroupByArgProcessorService } from 'src/engine/api/common/common-args-processors/order-by-with-group-by-arg-processor/order-by-with-group-by-arg-processor.service'; import { QueryRunnerArgsFactory } from 'src/engine/api/common/common-args-processors/query-runner-args.factory'; import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper'; import { @@ -64,6 +67,12 @@ export abstract class CommonBaseQueryRunnerService< @Inject() protected readonly filterArgProcessor: FilterArgProcessorService; @Inject() + protected readonly groupByArgProcessor: GroupByArgProcessorService; + @Inject() + protected readonly orderByArgProcessor: OrderByArgProcessorService; + @Inject() + protected readonly orderByWithGroupByArgProcessor: OrderByWithGroupByArgProcessorService; + @Inject() protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager; @Inject() protected readonly processNestedRelationsHelper: ProcessNestedRelationsHelper; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts index 7a19b2bba17..c16a9026222 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts @@ -13,7 +13,6 @@ import { ObjectRecordOrderBy, } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; -import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service'; import { CommonQueryRunnerException, @@ -39,6 +38,7 @@ import { countRelationFieldsInOrderBy, hasRelationFieldInOrderBy, } from 'src/engine/api/utils/validate-and-get-order-by.utils'; +import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util'; @@ -235,6 +235,9 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi return { ...args, + orderBy: this.orderByArgProcessor.process({ + orderBy: args.orderBy, + }), filter: this.filterArgProcessor.process({ filter: args.filter, flatObjectMetadata, diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts index d4a8896bb2a..3cf04f10157 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts @@ -405,6 +405,15 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic return { ...args, + groupBy: this.groupByArgProcessor.process({ + groupBy: args.groupBy, + }), + orderBy: this.orderByWithGroupByArgProcessor.process({ + orderBy: args.orderBy, + }), + orderByForRecords: this.orderByArgProcessor.process({ + orderBy: args.orderByForRecords, + }), filter: this.filterArgProcessor.process({ filter: args.filter, flatObjectMetadata, diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.service.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.service.ts index f4c56d0a916..6de5a8cc68c 100644 --- a/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.service.ts @@ -36,10 +36,10 @@ import { assertUpdateOneArgs } from 'src/engine/api/graphql/direct-execution/uti import { type ResolverNameMapEntry } from 'src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util'; import { buildWorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util'; import { extractArgumentsFromAst } from 'src/engine/api/graphql/direct-execution/utils/extract-arguments-from-ast.util'; -import { graphQLBackfillNullsFromSelectedFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-backfill-nulls-from-selected-fields.util'; import { graphQLBuildFragmentMap } from 'src/engine/api/graphql/direct-execution/utils/graphql-build-fragment-map.util'; import { graphQLBuildPartialResolveInfo } from 'src/engine/api/graphql/direct-execution/utils/graphql-build-partial-resolve-info.util'; import { graphQLExtractTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util'; +import { graphQLFormatResultFromSelectedFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-format-result-from-selected-fields.util'; import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; import { CreateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory'; @@ -67,7 +67,7 @@ import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; type DirectExecutionResult = { - data: Record | null; + data?: Record; errors?: GraphQLFormattedError[]; }; @@ -214,12 +214,23 @@ export class DirectExecutionService { workspaceSchemaBuilderContext, }); - graphQLBackfillNullsFromSelectedFields( + const formattedResult = graphQLFormatResultFromSelectedFields( result, - graphqlFields(graphqlPartialResolveInfo as GraphQLResolveInfo), + graphqlFields( + graphqlPartialResolveInfo as GraphQLResolveInfo, + {}, + { excludedFields: [] }, + ), + workspaceSchemaBuilderContext.flatObjectMetadata.nameSingular, + { + flatObjectMetadataMaps, + flatFieldMetadataMaps, + objectIdByNameSingular, + method: entry.method, + }, ); - return { responseKey, result }; + return { responseKey, result: formattedResult }; }), ); @@ -239,7 +250,7 @@ export class DirectExecutionService { return { data }; } catch (error) { - return { data: null, errors: [this.formatError(error, req)] }; + return { errors: [this.formatError(error, req)] }; } } diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-group-by-args.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-group-by-args.util.ts index 40bff9504e7..220f474eb05 100644 --- a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-group-by-args.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/assert-group-by-args.util.ts @@ -8,11 +8,11 @@ import { import { isDefined } from 'twenty-shared/utils'; +import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; import { GraphqlDirectExecutionException, GraphqlDirectExecutionExceptionCode, } from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception'; -import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; import { type GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; export function assertGroupByArgs( @@ -49,9 +49,12 @@ export function assertGroupByArgs( } } - if (!('groupBy' in args) || !Array.isArray(args.groupBy)) { + if ( + !('groupBy' in args) || + (!Array.isArray(args.groupBy) && !isObject(args.groupBy)) + ) { throw new GraphqlDirectExecutionException( - 'Missing required argument: "groupBy" (array)', + 'Missing required argument: "groupBy" must be an array.', GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT, { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, ); diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-backfill-nulls-from-selected-fields.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-backfill-nulls-from-selected-fields.util.ts deleted file mode 100644 index a1106973e61..00000000000 --- a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-backfill-nulls-from-selected-fields.util.ts +++ /dev/null @@ -1,52 +0,0 @@ -// GraphQL schema execution automatically returns null for nullable fields -// that are missing from the resolved object. Since direct execution bypasses -// schema resolution, we need to explicitly set requested-but-missing fields -// to null so the response shape matches what GraphQL would produce. -export const graphQLBackfillNullsFromSelectedFields = ( - result: unknown, - selectedFields: Record, -): unknown => { - if (result === null || result === undefined || typeof result !== 'object') { - return result; - } - - if (Array.isArray(result)) { - return result.map((item) => - graphQLBackfillNullsFromSelectedFields(item, selectedFields), - ); - } - - const record = result as Record; - - for (const [key, subFields] of Object.entries(selectedFields)) { - if (!(key in record)) { - record[key] = null; - continue; - } - - const hasNestedFields = - subFields && - typeof subFields === 'object' && - Object.keys(subFields).length > 0; - - if (!hasNestedFields || record[key] === null || record[key] === undefined) { - continue; - } - - if (Array.isArray(record[key])) { - record[key] = (record[key] as unknown[]).map((item) => - graphQLBackfillNullsFromSelectedFields( - item, - subFields as Record, - ), - ); - } else if (typeof record[key] === 'object') { - graphQLBackfillNullsFromSelectedFields( - record[key], - subFields as Record, - ); - } - } - - return result; -}; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-format-result-from-selected-fields.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-format-result-from-selected-fields.util.ts new file mode 100644 index 00000000000..8e640dd532e --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/graphql-format-result-from-selected-fields.util.ts @@ -0,0 +1,320 @@ +import { isNull, isObject } from '@sniptt/guards'; +import { RelationType } from 'twenty-shared/types'; +import { isDefined, isEmptyObject, pascalCase } from 'twenty-shared/utils'; + +import { CONNECTION_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/connection-method-names'; +import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; + +type GraphQLFormatInput = { + flatObjectMetadataMaps: FlatEntityMaps; + flatFieldMetadataMaps: FlatEntityMaps; + objectIdByNameSingular: Record; + method: string; +}; + +type GraphQLFormatContext = GraphQLFormatInput & { + fieldMetadataByNameCache: Map>; +}; + +type GraphQLObjectTypeKind = + | 'connection' + | 'groupByConnection' + | 'edge' + | 'node' + | 'pageInfo'; + +export const graphQLFormatResultFromSelectedFields = ( + result: unknown, + selectedFields: Record, + objectNameSingular: string, + input: GraphQLFormatInput, +): unknown => { + const context: GraphQLFormatContext = { + ...input, + fieldMetadataByNameCache: new Map(), + }; + + const objectTypeKind = inferObjectTypeKind(context.method); + + return format( + result, + selectedFields, + objectNameSingular, + objectTypeKind, + context, + ); +}; + +const format = ( + value: unknown, + selectedFields: Record, + objectNameSingular: string, + objectTypeKind: GraphQLObjectTypeKind, + context: GraphQLFormatContext, +): unknown => { + if (value === null || value === undefined) { + return value; + } + + if (Array.isArray(value)) { + return value.map((item) => + format(item, selectedFields, objectNameSingular, objectTypeKind, context), + ); + } + + if (isObject(value)) { + return backfillNullValuesAndComputeTypeName( + value as Record, + selectedFields, + objectNameSingular, + objectTypeKind, + context, + ); + } + + return value; +}; + +const backfillNullValuesAndComputeTypeName = ( + record: Record, + selectedFields: Record, + objectNameSingular: string, + objectTypeKind: GraphQLObjectTypeKind, + context: GraphQLFormatContext, +): Record => { + const formatted: Record = {}; + + for (const [key, subFields] of Object.entries(selectedFields)) { + if (key === '__typename') { + formatted.__typename = deriveTypeName(objectNameSingular, objectTypeKind); + continue; + } + + if (!isDefined(record[key])) { + formatted[key] = null; + continue; + } + + const value = record[key]; + + const hasNestedFields = isDefined(subFields) && !isEmptyObject(subFields); + + if (!hasNestedFields || isNull(value)) { + formatted[key] = value; + continue; + } + + const childObjectTypeKind = CONNECTION_FIELD_TO_OBJECT_TYPE_KIND[key]; + + if (isDefined(childObjectTypeKind)) { + formatted[key] = format( + value, + subFields as Record, + objectNameSingular, + childObjectTypeKind, + context, + ); + continue; + } + + const relationInfo = findRelationInfo(objectNameSingular, key, context); + + if (isDefined(relationInfo)) { + formatted[key] = format( + value, + subFields as Record, + relationInfo.targetObjectNameSingular, + relationInfo.objectTypeKind, + context, + ); + continue; + } + + const fieldMetadata = findFieldMetadataByName( + objectNameSingular, + key, + context, + ); + + if ( + isDefined(fieldMetadata) && + isCompositeFieldMetadataType(fieldMetadata.type) + ) { + formatted[key] = backfillNullValuesAndComputeTypeNameForCompositeField( + value as Record, + subFields as Record, + fieldMetadata.type, + ); + continue; + } + + formatted[key] = value; + } + + return formatted; +}; + +const backfillNullValuesAndComputeTypeNameForCompositeField = ( + record: Record, + selectedFields: Record, + fieldMetadataType: string, +): Record => { + const formatted: Record = {}; + + for (const key of Object.keys(selectedFields)) { + if (key === '__typename') { + formatted.__typename = pascalCase(fieldMetadataType); + continue; + } + + formatted[key] = record[key] ?? null; + } + + return formatted; +}; + +const deriveTypeName = ( + objectNameSingular: string, + objectTypeKind: GraphQLObjectTypeKind, +): string => { + const pascal = pascalCase(objectNameSingular); + + switch (objectTypeKind) { + case 'connection': + return `${pascal}Connection`; + case 'groupByConnection': + return `${pascal}GroupByConnection`; + case 'edge': + return `${pascal}Edge`; + case 'node': + return pascal; + case 'pageInfo': + return 'PageInfo'; + } +}; + +const inferObjectTypeKind = (method: string): GraphQLObjectTypeKind => { + if (method === RESOLVER_METHOD_NAMES.GROUP_BY) { + return 'groupByConnection'; + } + + if (CONNECTION_METHOD_NAMES.has(method)) { + return 'connection'; + } + + return 'node'; +}; + +const CONNECTION_FIELD_TO_OBJECT_TYPE_KIND: Record< + string, + GraphQLObjectTypeKind +> = { + edges: 'edge', + node: 'node', + pageInfo: 'pageInfo', +}; + +const getOrBuildFieldMetadataByNameMap = ( + objectNameSingular: string, + context: GraphQLFormatContext, +): Map => { + const cached = context.fieldMetadataByNameCache.get(objectNameSingular); + + if (isDefined(cached)) { + return cached; + } + + const map = new Map(); + const objectId = context.objectIdByNameSingular[objectNameSingular]; + + if (!isDefined(objectId)) { + context.fieldMetadataByNameCache.set(objectNameSingular, map); + + return map; + } + + const flatObjectMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: objectId, + flatEntityMaps: context.flatObjectMetadataMaps, + }); + + if (!isDefined(flatObjectMetadata)) { + context.fieldMetadataByNameCache.set(objectNameSingular, map); + + return map; + } + + for (const fieldId of flatObjectMetadata.fieldIds) { + const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: fieldId, + flatEntityMaps: context.flatFieldMetadataMaps, + }); + + if (isDefined(fieldMetadata)) { + map.set(fieldMetadata.name, fieldMetadata); + } + } + + context.fieldMetadataByNameCache.set(objectNameSingular, map); + + return map; +}; + +const findFieldMetadataByName = ( + objectNameSingular: string, + fieldName: string, + context: GraphQLFormatContext, +): FlatFieldMetadata | undefined => { + return getOrBuildFieldMetadataByNameMap(objectNameSingular, context).get( + fieldName, + ); +}; + +type RelationInfo = { + targetObjectNameSingular: string; + objectTypeKind: GraphQLObjectTypeKind; +}; + +const findRelationInfo = ( + objectNameSingular: string, + fieldName: string, + context: GraphQLFormatContext, +): RelationInfo | undefined => { + const fieldMetadata = findFieldMetadataByName( + objectNameSingular, + fieldName, + context, + ); + + if ( + !isDefined(fieldMetadata) || + !isMorphOrRelationFlatFieldMetadata(fieldMetadata) + ) { + return undefined; + } + + const targetObjectMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: fieldMetadata.relationTargetObjectMetadataId, + flatEntityMaps: context.flatObjectMetadataMaps, + }); + + if (!isDefined(targetObjectMetadata)) { + return undefined; + } + + const objectTypeKind: GraphQLObjectTypeKind = + fieldMetadata.settings.relationType === RelationType.ONE_TO_MANY + ? 'connection' + : 'node'; + + return { + targetObjectNameSingular: targetObjectMetadata.nameSingular, + objectTypeKind, + }; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/constants/connection-method-names.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/constants/connection-method-names.ts new file mode 100644 index 00000000000..ce4d3fab831 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/constants/connection-method-names.ts @@ -0,0 +1,11 @@ +import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; + +export const CONNECTION_METHOD_NAMES = new Set([ + RESOLVER_METHOD_NAMES.FIND_MANY, + RESOLVER_METHOD_NAMES.FIND_DUPLICATES, + RESOLVER_METHOD_NAMES.CREATE_MANY, + RESOLVER_METHOD_NAMES.UPDATE_MANY, + RESOLVER_METHOD_NAMES.DELETE_MANY, + RESOLVER_METHOD_NAMES.DESTROY_MANY, + RESOLVER_METHOD_NAMES.RESTORE_MANY, +]); diff --git a/packages/twenty-server/test/integration/graphql/suites/direct-execution-typename.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/direct-execution-typename.integration-spec.ts new file mode 100644 index 00000000000..07493c08113 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/direct-execution-typename.integration-spec.ts @@ -0,0 +1,135 @@ +import { randomUUID } from 'crypto'; + +import request from 'supertest'; +import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; +import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util'; +import { FeatureFlagKey } from 'twenty-shared/types'; + +const FIND_MANY_COMPANIES_WITH_TYPENAME = ` + query Companies($filter: CompanyFilterInput, $orderBy: [CompanyOrderByInput]) { + companies(filter: $filter, orderBy: $orderBy) { + __typename + edges { + __typename + node { + __typename + id + name + domainName { + __typename + primaryLinkLabel + primaryLinkUrl + } + } + cursor + } + pageInfo { + __typename + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } +`; + +describe('direct execution __typename filling (integration)', () => { + const testCompanyId1 = randomUUID(); + const testCompanyId2 = randomUUID(); + + beforeAll(async () => { + await updateFeatureFlag({ + featureFlag: FeatureFlagKey.IS_DIRECT_GRAPHQL_EXECUTION_ENABLED, + value: false, + expectToFail: false, + }); + + const gqlFields = 'id name'; + + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields, + data: { + id: testCompanyId1, + name: 'TypeName Test Company A', + }, + }), + ); + + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields, + data: { + id: testCompanyId2, + name: 'TypeName Test Company B', + }, + }), + ); + }); + + afterAll(async () => { + await updateFeatureFlag({ + featureFlag: FeatureFlagKey.IS_DIRECT_GRAPHQL_EXECUTION_ENABLED, + value: false, + expectToFail: false, + }); + + for (const id of [testCompanyId1, testCompanyId2]) { + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: 'id', + recordId: id, + }), + ); + } + }); + + it('should produce identical __typename values with and without direct execution', async () => { + const client = request(`http://localhost:${APP_PORT}`); + + const variables = { + filter: { + id: { in: [testCompanyId1, testCompanyId2] }, + }, + orderBy: [{ name: 'AscNullsLast' }], + }; + + // Run through standard GraphQL Yoga schema execution + const yogaResponse = await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ query: FIND_MANY_COMPANIES_WITH_TYPENAME, variables }) + .expect(200); + + expect(yogaResponse.body.errors).toBeUndefined(); + expect(yogaResponse.body.data).toBeDefined(); + + const yogaResult = yogaResponse.body.data.companies; + + await updateFeatureFlag({ + featureFlag: FeatureFlagKey.IS_DIRECT_GRAPHQL_EXECUTION_ENABLED, + value: true, + expectToFail: false, + }); + + // Run through direct execution path + const directResponse = await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ query: FIND_MANY_COMPANIES_WITH_TYPENAME, variables }) + .expect(200); + + expect(directResponse.body.errors).toBeUndefined(); + expect(directResponse.body.data).toBeDefined(); + + const directResult = directResponse.body.data.companies; + + expect(directResult).toStrictEqual(yogaResult); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts index efcdc440ef6..9f7191a5302 100644 --- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts +++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts @@ -414,6 +414,7 @@ export const successfulCreateInputByFieldMetadataType: { }, { input: { + name: 'position', position: undefined, }, validateInput: (record: Record) => {