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.
This commit is contained in:
Marie
2025-07-17 14:59:41 +02:00
committed by GitHub
parent 2fb7390965
commit fca39d317f
19 changed files with 532 additions and 15 deletions

View File

@ -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<string, any>;
roleId?: string;
}): Promise<void> {
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<string, unknown>;
}): Promise<void> {
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,
})

View File

@ -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<string, any>;
roleId?: string;
}): Promise<void> {
return this.processNestedRelationsV2Helper.processNestedRelations({
@ -55,6 +58,7 @@ export class ProcessNestedRelationsHelper {
workspaceDataSource,
shouldBypassPermissionChecks,
roleId,
selectedFields,
});
}
}

View File

@ -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,
});
}

View File

@ -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,
});
}

View File

@ -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<ObjectRecord[]>(
@ -75,6 +82,7 @@ export class GraphqlQueryDeleteManyResolverService extends GraphqlQueryBaseResol
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}

View File

@ -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<ObjectRecord[]>(
@ -77,6 +84,7 @@ export class GraphqlQueryDeleteOneResolverService extends GraphqlQueryBaseResolv
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}

View File

@ -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<ObjectRecord[]>(
@ -73,6 +80,7 @@ export class GraphqlQueryDestroyManyResolverService extends GraphqlQueryBaseReso
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}

View File

@ -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,
});
}

View File

@ -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<ObjectRecord[]>(
nonFormattedDuplicates,

View File

@ -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,
});
}

View File

@ -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<ObjectRecord>(
nonFormattedObjectRecord,
@ -80,6 +91,7 @@ export class GraphqlQueryFindOneResolverService extends GraphqlQueryBaseResolver
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}

View File

@ -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<ObjectRecord[]>(
@ -75,6 +82,7 @@ export class GraphqlQueryRestoreManyResolverService extends GraphqlQueryBaseReso
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}

View File

@ -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<ObjectRecord[]>(
@ -77,6 +84,7 @@ export class GraphqlQueryRestoreOneResolverService extends GraphqlQueryBaseResol
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}

View File

@ -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<ObjectRecord[]>(
@ -114,6 +121,7 @@ export class GraphqlQueryUpdateManyResolverService extends GraphqlQueryBaseResol
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}

View File

@ -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<ObjectRecord[]>(
@ -106,6 +113,7 @@ export class GraphqlQueryUpdateOneResolverService extends GraphqlQueryBaseResolv
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}

View File

@ -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,
});
});
});

View File

@ -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<string, unknown>;
relations: Record<string, unknown>;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
}): string[] => {
return Object.entries(
buildColumnsToSelect({
select,
relations,
objectMetadataItemWithFieldMaps,
}),
)
.filter(([_columnName, value]) => value === true)
.map(([columnName]) => columnName);
};

View File

@ -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<string, unknown>;
relations: Record<string, unknown>;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
}) => {
const requiredRelationColumns = getRequiredRelationColumns(
relations,
objectMetadataItemWithFieldMaps,
);
const fieldsToSelect: Record<string, boolean> = 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<string, unknown>,
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;
};

View File

@ -32,6 +32,10 @@ export class WorkspaceSelectQueryBuilder<
this.shouldBypassPermissionChecks = shouldBypassPermissionChecks;
}
getFindOptions() {
return this.findOptions;
}
override clone(): this {
const clonedQueryBuilder = super.clone();