fix(api-key): batch role resolution with DataLoader to fix N+1 (#19590)

## Summary

The `role` @ResolveField on `ApiKeyResolver` calls `getRolesByApiKeys`
with a single-element array per API key. When a query returns N API
keys, this produces N separate DB queries to resolve their roles.

This adds an `apiKeyRoleLoader` to the existing DataLoader
infrastructure. All API key IDs in a single GraphQL request are
collected and resolved in one batched query.

- Before: N queries (one per API key)
- After: 1 query (batched via DataLoader)

## Changes

- `dataloader.service.ts` - new `createApiKeyRoleLoader` method,
delegates to `ApiKeyRoleService.getRolesByApiKeys`
- `dataloader.interface.ts` - `apiKeyRoleLoader` added to `IDataloaders`
- `dataloader.module.ts` - import `ApiKeyModule` so `ApiKeyRoleService`
is available
- `api-key.resolver.ts` - `role()` now uses
`context.loaders.apiKeyRoleLoader.load()` instead of calling the service
directly

## Test plan

- [ ] Verify `apiKeys { id role { label } }` query returns the same
results as before
- [ ] Confirm only 1 role_target query fires regardless of how many API
keys are returned

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
oniani1
2026-04-14 12:43:31 +04:00
committed by GitHub
parent 714f149b0c
commit d583984bf0
2 changed files with 42 additions and 14 deletions
@@ -5,6 +5,7 @@ import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialE
import { PermissionFlagType } from 'twenty-shared/constants';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { CreateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/create-api-key.input';
import { GetApiKeyInput } from 'src/engine/core-modules/api-key/dtos/get-api-key.input';
@@ -17,7 +18,6 @@ import {
import { apiKeyGraphqlApiExceptionHandler } from 'src/engine/core-modules/api-key/utils/api-key-graphql-api-exception-handler.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
@@ -126,20 +126,9 @@ export class ApiKeyResolver {
@Parent() apiKey: ApiKeyEntity,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<RoleDTO> {
const rolesMap = await this.apiKeyRoleService.getRolesByApiKeys({
apiKeyIds: [apiKey.id],
return this.apiKeyRoleService.getRoleDtoByApiKeyId({
apiKeyId: apiKey.id,
workspaceId: workspace.id,
});
const role = rolesMap.get(apiKey.id);
if (!role) {
throw new ApiKeyException(
`API key ${apiKey.id} has no role assigned`,
ApiKeyExceptionCode.API_KEY_NO_ROLE_ASSIGNED,
);
}
return role;
}
}
@@ -9,10 +9,12 @@ import {
ApiKeyException,
ApiKeyExceptionCode,
} from 'src/engine/core-modules/api-key/exceptions/api-key.exception';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
import { type RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { fromFlatRoleToRoleDto } from 'src/engine/metadata-modules/role/utils/fromFlatRoleToRoleDto.util';
import { fromRoleEntityToRoleDto } from 'src/engine/metadata-modules/role/utils/fromRoleEntityToRoleDto.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@@ -80,6 +82,43 @@ export class ApiKeyRoleService {
return roleId;
}
async getRoleDtoByApiKeyId({
apiKeyId,
workspaceId,
}: {
apiKeyId: string;
workspaceId: string;
}): Promise<RoleDTO> {
const { apiKeyRoleMap, flatRoleMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'apiKeyRoleMap',
'flatRoleMaps',
]);
const roleId = apiKeyRoleMap[apiKeyId];
if (!isDefined(roleId)) {
throw new ApiKeyException(
`API key ${apiKeyId} has no role assigned`,
ApiKeyExceptionCode.API_KEY_NO_ROLE_ASSIGNED,
);
}
const flatRole = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: roleId,
flatEntityMaps: flatRoleMaps,
});
if (!isDefined(flatRole)) {
throw new ApiKeyException(
`Role ${roleId} not found for API key ${apiKeyId}`,
ApiKeyExceptionCode.API_KEY_NO_ROLE_ASSIGNED,
);
}
return fromFlatRoleToRoleDto(flatRole);
}
private async validateAssignRoleInput({
apiKeyId,
workspaceId,