From fca39d317f9bfd8c94a242f95b2a8f7ecf012d8c Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Thu, 17 Jul 2025 14:59:41 +0200 Subject: [PATCH] Restrict queried columns to graphql-requested fields (#13246) Fixes https://github.com/twentyhq/core-team-issues/issues/255?issue=twentyhq%7Ccore-team-issues%7C1214. Until then, in the endpoints of our dynamic schema, we were querying all columns and then formatting the result by removing the non-requested fields (fields not mentioned in the graphql Query) from the result. This is not compatible with field-level permissions that we are about to introduce because users would see their request denied if they have restricted rights on any of the fields of the objects they are querying, even if they did not query it in the first place. To prepare for this change, we are restricting the list of queried columns to those made necessary by the graphql query. I only made the changes in the dynamic schema for now. We will potentially need to do updates to other part of the app that use createQueryBuilder directly or not (for instance, when calling repository methods such as .findOne()), but they mostly regard system objects that are not subject to permissions or are executed by entities that bypass permission such as jobs creating People and Companies from their email sync. No changes have been brought to existingRecords related logic in the dynamic schema because @Weiko is currently working on it, so I may need to adapt the new logic after he is done. No feature flag have been added so far as this should not change anything at the moment. --- .../process-nested-relations-v2.helper.ts | 30 +- .../process-nested-relations.helper.ts | 4 + ...phql-query-create-many-resolver.service.ts | 11 + ...aphql-query-create-one-resolver.service.ts | 11 + ...phql-query-delete-many-resolver.service.ts | 10 +- ...aphql-query-delete-one-resolver.service.ts | 12 +- ...hql-query-destroy-many-resolver.service.ts | 10 +- ...phql-query-destroy-one-resolver.service.ts | 10 +- ...-query-find-duplicates-resolver.service.ts | 14 +- ...raphql-query-find-many-resolver.service.ts | 11 + ...graphql-query-find-one-resolver.service.ts | 14 +- ...hql-query-restore-many-resolver.service.ts | 12 +- ...phql-query-restore-one-resolver.service.ts | 10 +- ...phql-query-update-many-resolver.service.ts | 12 +- ...aphql-query-update-one-resolver.service.ts | 10 +- .../__tests__/build-columns-to-select.spec.ts | 266 ++++++++++++++++++ .../utils/build-columns-to-return.ts | 22 ++ .../utils/build-columns-to-select.ts | 74 +++++ .../workspace-select-query-builder.ts | 4 + 19 files changed, 532 insertions(+), 15 deletions(-) create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/__tests__/build-columns-to-select.spec.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select.ts diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper.ts index 03bad6365c..1557ba668f 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper.ts @@ -11,6 +11,7 @@ import { GraphqlQueryRunnerExceptionCode, } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper'; +import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select'; import { getTargetObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-target-object-metadata.util'; import { AggregationField } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; @@ -40,6 +41,7 @@ export class ProcessNestedRelationsV2Helper { workspaceDataSource, roleId, shouldBypassPermissionChecks, + selectedFields, }: { objectMetadataMaps: ObjectMetadataMaps; parentObjectMetadataItem: ObjectMetadataItemWithFieldMaps; @@ -52,6 +54,8 @@ export class ProcessNestedRelationsV2Helper { authContext: AuthContext; workspaceDataSource: WorkspaceDataSource; shouldBypassPermissionChecks: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selectedFields: Record; roleId?: string; }): Promise { const processRelationTasks = Object.entries(relations).map( @@ -69,6 +73,10 @@ export class ProcessNestedRelationsV2Helper { workspaceDataSource, shouldBypassPermissionChecks, roleId, + selectedFields: + selectedFields[sourceFieldName] instanceof Object + ? selectedFields[sourceFieldName] + : undefined, }), ); @@ -88,6 +96,7 @@ export class ProcessNestedRelationsV2Helper { workspaceDataSource, shouldBypassPermissionChecks, roleId, + selectedFields, }: { objectMetadataMaps: ObjectMetadataMaps; parentObjectMetadataItem: ObjectMetadataItemWithFieldMaps; @@ -102,6 +111,7 @@ export class ProcessNestedRelationsV2Helper { workspaceDataSource: WorkspaceDataSource; shouldBypassPermissionChecks: boolean; roleId?: string; + selectedFields: Record; }): Promise { const sourceFieldMetadataId = parentObjectMetadataItem.fieldIdByName[sourceFieldName]; @@ -139,10 +149,20 @@ export class ProcessNestedRelationsV2Helper { roleId, ); - const targetObjectQueryBuilder = targetObjectRepository.createQueryBuilder( + let targetObjectQueryBuilder = targetObjectRepository.createQueryBuilder( targetObjectMetadata.nameSingular, ); + const columnsToSelect = buildColumnsToSelect({ + select: selectedFields, + relations: nestedRelations, + objectMetadataItemWithFieldMaps: targetObjectMetadata, + }); + + targetObjectQueryBuilder = targetObjectQueryBuilder.setFindOptions({ + select: columnsToSelect, + }); + const relationIds = this.getUniqueIds({ records: parentObjectRecords, idField: @@ -208,6 +228,7 @@ export class ProcessNestedRelationsV2Helper { workspaceDataSource, shouldBypassPermissionChecks, roleId, + selectedFields, }); } } @@ -324,7 +345,14 @@ export class ProcessNestedRelationsV2Helper { ); } + const queryBuilderOptions = referenceQueryBuilder.getFindOptions(); + const columnWithoutQuotes = column.replace(/["']/g, ''); + const result = await referenceQueryBuilder + .setFindOptions({ + ...queryBuilderOptions, + select: { ...queryBuilderOptions.select, [columnWithoutQuotes]: true }, + }) .where(`${column} IN (:...ids)`, { ids, }) diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts index 080630a60b..70e9958aee 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts @@ -29,6 +29,7 @@ export class ProcessNestedRelationsHelper { workspaceDataSource, shouldBypassPermissionChecks, roleId, + selectedFields, }: { objectMetadataMaps: ObjectMetadataMaps; parentObjectMetadataItem: ObjectMetadataItemWithFieldMaps; @@ -41,6 +42,8 @@ export class ProcessNestedRelationsHelper { authContext: AuthContext; workspaceDataSource: WorkspaceDataSource; shouldBypassPermissionChecks: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selectedFields: Record; roleId?: string; }): Promise { return this.processNestedRelationsV2Helper.processNestedRelations({ @@ -55,6 +58,7 @@ export class ProcessNestedRelationsHelper { workspaceDataSource, shouldBypassPermissionChecks, roleId, + selectedFields, }); } } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts index 71752b667b..115a9d9bf4 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts @@ -17,6 +17,7 @@ import { GraphqlQueryRunnerExceptionCode, } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; +import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select'; import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; @@ -397,7 +398,16 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol objectMetadataItemWithFieldMaps.nameSingular, ); + const columnsToSelect = buildColumnsToSelect({ + select: executionArgs.graphqlQuerySelectedFieldsResult.select, + relations: executionArgs.graphqlQuerySelectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + }); + const nonFormattedUpsertedRecords = await queryBuilder + .setFindOptions({ + select: columnsToSelect, + }) .where({ id: In(objectRecords.generatedMaps.map((record) => record.id)), }) @@ -433,6 +443,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol workspaceDataSource: executionArgs.workspaceDataSource, roleId, shouldBypassPermissionChecks, + selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service.ts index cec42860fa..25c41ab315 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service.ts @@ -12,6 +12,7 @@ import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-qu import { CreateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; +import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select'; import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util'; import { getObjectMetadataFromObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/utils/get-object-metadata-from-object-metadata-Item-with-field-maps'; @@ -41,7 +42,16 @@ export class GraphqlQueryCreateOneResolverService extends GraphqlQueryBaseResolv objectMetadataItemWithFieldMaps.nameSingular, ); + const columnsToSelect = buildColumnsToSelect({ + select: executionArgs.graphqlQuerySelectedFieldsResult.select, + relations: executionArgs.graphqlQuerySelectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + }); + const nonFormattedUpsertedRecords = await queryBuilder + .setFindOptions({ + select: columnsToSelect, + }) .where({ id: In(objectRecords.generatedMaps.map((record) => record.id)), }) @@ -73,6 +83,7 @@ export class GraphqlQueryCreateOneResolverService extends GraphqlQueryBaseResolv workspaceDataSource: executionArgs.workspaceDataSource, roleId, shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey, + selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-many-resolver.service.ts index 30032b374d..3d9d68889f 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-many-resolver.service.ts @@ -11,6 +11,7 @@ import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-qu import { DeleteManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; +import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return'; import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util'; import { getObjectMetadataFromObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/utils/get-object-metadata-from-object-metadata-Item-with-field-maps'; @@ -45,9 +46,15 @@ export class GraphqlQueryDeleteManyResolverService extends GraphqlQueryBaseResol executionArgs.args.filter, ); + const columnsToReturn = buildColumnsToReturn({ + select: executionArgs.graphqlQuerySelectedFieldsResult.select, + relations: executionArgs.graphqlQuerySelectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + }); + const nonFormattedDeletedObjectRecords = await queryBuilder .softDelete() - .returning('*') + .returning(columnsToReturn) .execute(); const formattedDeletedRecords = formatResult( @@ -75,6 +82,7 @@ export class GraphqlQueryDeleteManyResolverService extends GraphqlQueryBaseResol workspaceDataSource: executionArgs.workspaceDataSource, roleId, shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey, + selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-one-resolver.service.ts index 4b0ef3a9c3..1d395aaf10 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-one-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-one-resolver.service.ts @@ -15,10 +15,11 @@ import { GraphqlQueryRunnerExceptionCode, } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; +import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return'; import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util'; -import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; import { getObjectMetadataFromObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/utils/get-object-metadata-from-object-metadata-Item-with-field-maps'; +import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; @Injectable() export class GraphqlQueryDeleteOneResolverService extends GraphqlQueryBaseResolverService< @@ -37,10 +38,16 @@ export class GraphqlQueryDeleteOneResolverService extends GraphqlQueryBaseResolv objectMetadataItemWithFieldMaps.nameSingular, ); + const columnsToReturn = buildColumnsToReturn({ + select: executionArgs.graphqlQuerySelectedFieldsResult.select, + relations: executionArgs.graphqlQuerySelectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + }); + const nonFormattedDeletedObjectRecords = await queryBuilder .softDelete() .where({ id: executionArgs.args.id }) - .returning('*') + .returning(columnsToReturn) .execute(); const formattedDeletedRecords = formatResult( @@ -77,6 +84,7 @@ export class GraphqlQueryDeleteOneResolverService extends GraphqlQueryBaseResolv workspaceDataSource: executionArgs.workspaceDataSource, roleId, shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey, + selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-many-resolver.service.ts index ae23c6dff4..7e107673f3 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-many-resolver.service.ts @@ -11,6 +11,7 @@ import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-qu import { DestroyManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; +import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return'; import { getObjectMetadataFromObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/utils/get-object-metadata-from-object-metadata-Item-with-field-maps'; import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; import { computeTableName } from 'src/engine/utils/compute-table-name.util'; @@ -43,9 +44,15 @@ export class GraphqlQueryDestroyManyResolverService extends GraphqlQueryBaseReso executionArgs.args.filter, ); + const columnsToReturn = buildColumnsToReturn({ + select: executionArgs.graphqlQuerySelectedFieldsResult.select, + relations: executionArgs.graphqlQuerySelectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + }); + const nonFormattedDeletedObjectRecords = await queryBuilder .delete() - .returning('*') + .returning(columnsToReturn) .execute(); const deletedRecords = formatResult( @@ -73,6 +80,7 @@ export class GraphqlQueryDestroyManyResolverService extends GraphqlQueryBaseReso workspaceDataSource: executionArgs.workspaceDataSource, roleId, shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey, + selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service.ts index 6b032f4fa2..d218596c6b 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service.ts @@ -15,6 +15,7 @@ import { GraphqlQueryRunnerExceptionCode, } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; +import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return'; import { getObjectMetadataFromObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/utils/get-object-metadata-from-object-metadata-Item-with-field-maps'; import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; @@ -35,10 +36,16 @@ export class GraphqlQueryDestroyOneResolverService extends GraphqlQueryBaseResol objectMetadataItemWithFieldMaps.nameSingular, ); + const columnsToReturn = buildColumnsToReturn({ + select: executionArgs.graphqlQuerySelectedFieldsResult.select, + relations: executionArgs.graphqlQuerySelectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + }); + const nonFormattedDeletedObjectRecords = await queryBuilder .delete() .where({ id: executionArgs.args.id }) - .returning('*') + .returning(columnsToReturn) .execute(); if (!nonFormattedDeletedObjectRecords.affected) { @@ -73,6 +80,7 @@ export class GraphqlQueryDestroyOneResolverService extends GraphqlQueryBaseResol workspaceDataSource: executionArgs.workspaceDataSource, roleId, shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey, + selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service.ts index 0fea448082..e6207f9280 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service.ts @@ -21,6 +21,7 @@ import { } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; +import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select'; import { buildDuplicateConditions } from 'src/engine/api/utils/build-duplicate-conditions.utils'; import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util'; import { formatData } from 'src/engine/twenty-orm/utils/format-data.util'; @@ -102,6 +103,12 @@ export class GraphqlQueryFindDuplicatesResolverService extends GraphqlQueryBaseR }); } + const columnsToSelect = buildColumnsToSelect({ + select: executionArgs.graphqlQuerySelectedFieldsResult.select, + relations: executionArgs.graphqlQuerySelectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + }); + const duplicateRecordsQueryBuilder = executionArgs.repository.createQueryBuilder( objectMetadataItemWithFieldMaps.nameSingular, @@ -113,8 +120,11 @@ export class GraphqlQueryFindDuplicatesResolverService extends GraphqlQueryBaseR duplicateConditions, ); - const nonFormattedDuplicates = - (await duplicateRecordsQueryBuilder.getMany()) as ObjectRecord[]; + const nonFormattedDuplicates = (await duplicateRecordsQueryBuilder + .setFindOptions({ + select: columnsToSelect, + }) + .getMany()) as ObjectRecord[]; const duplicates = formatResult( nonFormattedDuplicates, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts index cf5173ce26..73592fcbf6 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts @@ -23,6 +23,7 @@ import { } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper'; +import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select'; import { getCursor, getPaginationInfo, @@ -120,7 +121,16 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve const limit = executionArgs.args.first ?? executionArgs.args.last ?? QUERY_MAX_RECORDS; + const columnsToSelect = buildColumnsToSelect({ + select: executionArgs.graphqlQuerySelectedFieldsResult.select, + relations: executionArgs.graphqlQuerySelectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + }); + const nonFormattedObjectRecords = await queryBuilder + .setFindOptions({ + select: columnsToSelect, + }) .take(limit + 1) .getMany(); @@ -156,6 +166,7 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve workspaceDataSource: executionArgs.workspaceDataSource, roleId, shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey, + selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts index 860148f380..1cbe5c0772 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts @@ -18,6 +18,7 @@ import { GraphqlQueryRunnerExceptionCode, } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; +import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select'; import { WorkspaceQueryRunnerException, WorkspaceQueryRunnerExceptionCode, @@ -52,7 +53,17 @@ export class GraphqlQueryFindOneResolverService extends GraphqlQueryBaseResolver executionArgs.args.filter ?? ({} as ObjectRecordFilter), ); - const nonFormattedObjectRecord = await queryBuilder.getOne(); + const columnsToSelect = buildColumnsToSelect({ + select: executionArgs.graphqlQuerySelectedFieldsResult.select, + relations: executionArgs.graphqlQuerySelectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + }); + + const nonFormattedObjectRecord = await queryBuilder + .setFindOptions({ + select: columnsToSelect, + }) + .getOne(); const objectRecord = formatResult( nonFormattedObjectRecord, @@ -80,6 +91,7 @@ export class GraphqlQueryFindOneResolverService extends GraphqlQueryBaseResolver workspaceDataSource: executionArgs.workspaceDataSource, roleId, shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey, + selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-many-resolver.service.ts index 501d33c2a8..a91057c45d 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-many-resolver.service.ts @@ -11,11 +11,12 @@ import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-qu import { RestoreManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; +import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return'; import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util'; +import { getObjectMetadataFromObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/utils/get-object-metadata-from-object-metadata-Item-with-field-maps'; import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; import { computeTableName } from 'src/engine/utils/compute-table-name.util'; -import { getObjectMetadataFromObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/utils/get-object-metadata-from-object-metadata-Item-with-field-maps'; @Injectable() export class GraphqlQueryRestoreManyResolverService extends GraphqlQueryBaseResolverService< @@ -45,9 +46,15 @@ export class GraphqlQueryRestoreManyResolverService extends GraphqlQueryBaseReso executionArgs.args.filter, ); + const columnsToReturn = buildColumnsToReturn({ + select: executionArgs.graphqlQuerySelectedFieldsResult.select, + relations: executionArgs.graphqlQuerySelectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + }); + const nonFormattedRestoredObjectRecords = await queryBuilder .restore() - .returning('*') + .returning(columnsToReturn) .execute(); const formattedRestoredRecords = formatResult( @@ -75,6 +82,7 @@ export class GraphqlQueryRestoreManyResolverService extends GraphqlQueryBaseReso workspaceDataSource: executionArgs.workspaceDataSource, roleId, shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey, + selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-one-resolver.service.ts index b8dbbde085..7381d486f6 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-one-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-one-resolver.service.ts @@ -15,6 +15,7 @@ import { GraphqlQueryRunnerExceptionCode, } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; +import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return'; import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util'; import { getObjectMetadataFromObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/utils/get-object-metadata-from-object-metadata-Item-with-field-maps'; @@ -37,10 +38,16 @@ export class GraphqlQueryRestoreOneResolverService extends GraphqlQueryBaseResol objectMetadataItemWithFieldMaps.nameSingular, ); + const columnsToReturn = buildColumnsToReturn({ + select: executionArgs.graphqlQuerySelectedFieldsResult.select, + relations: executionArgs.graphqlQuerySelectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + }); + const nonFormattedRestoredObjectRecords = await queryBuilder .restore() .where({ id: executionArgs.args.id }) - .returning('*') + .returning(columnsToReturn) .execute(); const formattedRestoredRecords = formatResult( @@ -77,6 +84,7 @@ export class GraphqlQueryRestoreOneResolverService extends GraphqlQueryBaseResol workspaceDataSource: executionArgs.workspaceDataSource, roleId, shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey, + selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts index f1e5e324b3..f32c8efe14 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts @@ -16,12 +16,13 @@ import { GraphqlQueryRunnerExceptionCode, } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; +import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return'; import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util'; +import { getObjectMetadataFromObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/utils/get-object-metadata-from-object-metadata-Item-with-field-maps'; import { formatData } from 'src/engine/twenty-orm/utils/format-data.util'; import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; import { computeTableName } from 'src/engine/utils/compute-table-name.util'; -import { getObjectMetadataFromObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/utils/get-object-metadata-from-object-metadata-Item-with-field-maps'; @Injectable() export class GraphqlQueryUpdateManyResolverService extends GraphqlQueryBaseResolverService< @@ -79,9 +80,15 @@ export class GraphqlQueryUpdateManyResolverService extends GraphqlQueryBaseResol objectMetadataItemWithFieldMaps, ); + const columnsToReturn = buildColumnsToReturn({ + select: executionArgs.graphqlQuerySelectedFieldsResult.select, + relations: executionArgs.graphqlQuerySelectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + }); + const nonFormattedUpdatedObjectRecords = await queryBuilder .update(data) - .returning('*') + .returning(columnsToReturn) .execute(); const formattedUpdatedRecords = formatResult( @@ -114,6 +121,7 @@ export class GraphqlQueryUpdateManyResolverService extends GraphqlQueryBaseResol workspaceDataSource: executionArgs.workspaceDataSource, roleId, shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey, + selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts index 3b5d2a40a4..b57c66cf76 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts @@ -16,6 +16,7 @@ import { GraphqlQueryRunnerExceptionCode, } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; +import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return'; import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util'; import { getObjectMetadataFromObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/utils/get-object-metadata-from-object-metadata-Item-with-field-maps'; @@ -63,10 +64,16 @@ export class GraphqlQueryUpdateOneResolverService extends GraphqlQueryBaseResolv ); } + const columnsToReturn = buildColumnsToReturn({ + select: executionArgs.graphqlQuerySelectedFieldsResult.select, + relations: executionArgs.graphqlQuerySelectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + }); + const nonFormattedUpdatedObjectRecords = await queryBuilder .update(data) .where({ id: executionArgs.args.id }) - .returning('*') + .returning(columnsToReturn) .execute(); const formattedUpdatedRecords = formatResult( @@ -106,6 +113,7 @@ export class GraphqlQueryUpdateOneResolverService extends GraphqlQueryBaseResolv workspaceDataSource: executionArgs.workspaceDataSource, roleId, shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey, + selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/__tests__/build-columns-to-select.spec.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/__tests__/build-columns-to-select.spec.ts new file mode 100644 index 0000000000..84578ea05b --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/__tests__/build-columns-to-select.spec.ts @@ -0,0 +1,266 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; + +import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select'; + +describe('buildColumnsToSelect', () => { + const mockObjectMetadataItemWithFieldMaps: any = { + id: 'a55d8cad-4c3d-4c8a-82b5-539a36de5605', + standardId: '20202020-e674-48e5-a542-72570eee7213', + dataSourceId: 'd161a12f-1daa-4a0b-887c-96a48a3d9ecf', + nameSingular: 'person', + namePlural: 'people', + labelSingular: 'Person', + labelPlural: 'People', + description: 'A person', + icon: 'IconUser', + standardOverrides: null, + targetTableName: 'DEPRECATED', + isCustom: false, + isRemote: false, + isActive: true, + isSystem: false, + isAuditLogged: true, + isSearchable: true, + duplicateCriteria: [ + ['nameFirstName', 'nameLastName'], + ['linkedinLinkPrimaryLinkUrl'], + ['emailsPrimaryEmail'], + ], + shortcut: 'P', + labelIdentifierFieldMetadataId: '92414583-c6a6-4c98-bae7-6ce318bd3423', + imageIdentifierFieldMetadataId: '6bfcecc4-1866-4254-ba6b-6f22246819bb', + isLabelSyncedWithName: false, + workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419', + createdAt: new Date('2025-07-10T10:58:54.536Z'), + updatedAt: new Date('2025-07-10T10:58:54.536Z'), + indexMetadatas: [], + fieldsById: { + '92414583-c6a6-4c98-bae7-6ce318bd3423': { + id: '92414583-c6a6-4c98-bae7-6ce318bd3423', + type: FieldMetadataType.FULL_NAME, + name: 'name', + label: 'Name', + defaultValue: { + lastName: "''", + firstName: "''", + }, + description: "Contact's name", + icon: 'IconUser', + standardOverrides: null, + options: undefined, + settings: undefined, + isCustom: false, + isActive: true, + isSystem: false, + isNullable: true, + isUnique: false, + workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419', + isLabelSyncedWithName: true, + relationTargetFieldMetadataId: undefined, + relationTargetObjectMetadataId: undefined, + objectMetadataId: 'a55d8cad-4c3d-4c8a-82b5-539a36de5605', + createdAt: new Date('2025-07-10T10:58:54.536Z'), + updatedAt: new Date('2025-07-10T10:58:54.536Z'), + }, + '30dc1370-bb1a-42e3-abbc-a40edf1c6796': { + id: '30dc1370-bb1a-42e3-abbc-a40edf1c6796', + type: FieldMetadataType.RELATION, + name: 'company', + label: 'Company', + defaultValue: null, + description: "Contact's company", + icon: 'IconBuildingSkyscraper', + standardOverrides: null, + options: undefined, + settings: { + relationType: RelationType.MANY_TO_ONE, + joinColumnName: 'companyId', + }, + isCustom: false, + isActive: true, + isSystem: false, + isNullable: true, + isUnique: false, + workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419', + isLabelSyncedWithName: true, + relationTargetFieldMetadataId: '83cd9e7f-dfbc-4b93-a0a7-4c04b76d2009', + relationTargetObjectMetadataId: '9af20778-2f2c-4e22-ae83-2e77e479b57c', + objectMetadataId: 'a55d8cad-4c3d-4c8a-82b5-539a36de5605', + createdAt: new Date('2025-07-10T10:58:54.536Z'), + updatedAt: new Date('2025-07-10T10:58:54.536Z'), + }, + }, + fieldIdByName: { + name: '92414583-c6a6-4c98-bae7-6ce318bd3423', + company: '30dc1370-bb1a-42e3-abbc-a40edf1c6796', + }, + fieldIdByJoinColumnName: { + companyId: '30dc1370-bb1a-42e3-abbc-a40edf1c6796', + }, + }; + + it('should build columns to select with relation fields', () => { + const select = { + nameFirstName: true, + company: { + id: true, + name: true, + }, + }; + + const relations = { + company: {}, + }; + + const result = buildColumnsToSelect({ + select, + relations, + objectMetadataItemWithFieldMaps: mockObjectMetadataItemWithFieldMaps, + }); + + expect(result).toEqual({ + nameFirstName: true, + companyId: true, + id: true, + }); + }); + + it('should build columns to select without relation fields', () => { + const select = { + nameFirstName: true, + nameLastName: true, + }; + + const relations = {}; + + const result = buildColumnsToSelect({ + select, + relations, + objectMetadataItemWithFieldMaps: mockObjectMetadataItemWithFieldMaps, + }); + + expect(result).toEqual({ + nameFirstName: true, + nameLastName: true, + id: true, + }); + }); + + it('should filter out non-boolean values from select', () => { + const select = { + nameFirstName: true, + nameLastName: false, + company: { id: true }, + }; + + const relations = {}; + + const result = buildColumnsToSelect({ + select, + relations, + objectMetadataItemWithFieldMaps: mockObjectMetadataItemWithFieldMaps, + }); + + expect(result).toEqual({ + nameFirstName: true, + id: true, + }); + }); + + it('should handle relation field that is not a relation type', () => { + const select = { + nameFirstName: true, + }; + + const relations = { + name: {}, // This is not a relation field + }; + + const result = buildColumnsToSelect({ + select, + relations, + objectMetadataItemWithFieldMaps: mockObjectMetadataItemWithFieldMaps, + }); + + expect(result).toEqual({ + nameFirstName: true, + id: true, + }); + }); + + it('should handle relation field that is not MANY_TO_ONE', () => { + const mockObjectMetadataWithOneToMany: any = { + ...mockObjectMetadataItemWithFieldMaps, + fieldsById: { + ...mockObjectMetadataItemWithFieldMaps.fieldsById, + '30dc1370-bb1a-42e3-abbc-a40edf1c6796': { + ...mockObjectMetadataItemWithFieldMaps.fieldsById[ + '30dc1370-bb1a-42e3-abbc-a40edf1c6796' + ], + settings: { + relationType: RelationType.ONE_TO_MANY, + joinColumnName: 'companyId', + }, + }, + }, + }; + + const select = { + nameFirstName: true, + }; + + const relations = { + company: {}, + }; + + const result = buildColumnsToSelect({ + select, + relations, + objectMetadataItemWithFieldMaps: mockObjectMetadataWithOneToMany, + }); + + expect(result).toEqual({ + nameFirstName: true, + id: true, + }); + }); + + it('should handle relation field without joinColumnName', () => { + const mockObjectMetadataWithoutJoinColumn: any = { + ...mockObjectMetadataItemWithFieldMaps, + fieldsById: { + ...mockObjectMetadataItemWithFieldMaps.fieldsById, + '30dc1370-bb1a-42e3-abbc-a40edf1c6796': { + ...mockObjectMetadataItemWithFieldMaps.fieldsById[ + '30dc1370-bb1a-42e3-abbc-a40edf1c6796' + ], + settings: { + relationType: RelationType.MANY_TO_ONE, + joinColumnName: null, + }, + }, + }, + }; + + const select = { + nameFirstName: true, + }; + + const relations = { + company: {}, + }; + + const result = buildColumnsToSelect({ + select, + relations, + objectMetadataItemWithFieldMaps: mockObjectMetadataWithoutJoinColumn, + }); + + expect(result).toEqual({ + nameFirstName: true, + id: true, + }); + }); +}); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return.ts new file mode 100644 index 0000000000..79826892d3 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return.ts @@ -0,0 +1,22 @@ +import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select'; +import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; + +export const buildColumnsToReturn = ({ + select, + relations, + objectMetadataItemWithFieldMaps, +}: { + select: Record; + relations: Record; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; +}): string[] => { + return Object.entries( + buildColumnsToSelect({ + select, + relations, + objectMetadataItemWithFieldMaps, + }), + ) + .filter(([_columnName, value]) => value === true) + .map(([columnName]) => columnName); +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select.ts new file mode 100644 index 0000000000..cf8e0f79f5 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select.ts @@ -0,0 +1,74 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; + +import { InternalServerError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; +import { isFieldMetadataInterfaceOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; + +export const buildColumnsToSelect = ({ + select, + relations, + objectMetadataItemWithFieldMaps, +}: { + select: Record; + relations: Record; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; +}) => { + const requiredRelationColumns = getRequiredRelationColumns( + relations, + objectMetadataItemWithFieldMaps, + ); + + const fieldsToSelect: Record = Object.entries(select) + .filter( + ([_columnName, value]) => value === true && typeof value !== 'object', + ) + .reduce((acc, [columnName]) => ({ ...acc, [columnName]: true }), {}); + + for (const columnName of requiredRelationColumns) { + fieldsToSelect[columnName] = true; + } + + return { ...fieldsToSelect, id: true }; +}; + +const getRequiredRelationColumns = ( + relations: Record, + objectMetadataItem: ObjectMetadataItemWithFieldMaps, +): string[] => { + const requiredColumns: string[] = []; + + for (const [relationFieldName, _] of Object.entries(relations)) { + const fieldMetadataId = objectMetadataItem.fieldIdByName[relationFieldName]; + + if (!fieldMetadataId) { + throw new InternalServerError( + `Field metadata not found for relation field name: ${relationFieldName}`, + ); + } + + const fieldMetadata = objectMetadataItem.fieldsById[fieldMetadataId]; + + if (!fieldMetadata) { + throw new InternalServerError( + `Field metadata not found for relation field name: ${relationFieldName}`, + ); + } + + if ( + !isFieldMetadataInterfaceOfType(fieldMetadata, FieldMetadataType.RELATION) + ) { + continue; + } + + if ( + fieldMetadata.settings?.relationType === RelationType.MANY_TO_ONE && + fieldMetadata.settings?.joinColumnName + ) { + requiredColumns.push(fieldMetadata.settings.joinColumnName); + } + } + + return requiredColumns; +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts index c67acd7be9..5273a1e3da 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts @@ -32,6 +32,10 @@ export class WorkspaceSelectQueryBuilder< this.shouldBypassPermissionChecks = shouldBypassPermissionChecks; } + getFindOptions() { + return this.findOptions; + } + override clone(): this { const clonedQueryBuilder = super.clone();