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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user