From a3eb4ba7de0d2cc8a8969161ae1d5ea487d969d4 Mon Sep 17 00:00:00 2001 From: Marco Mornati Date: Tue, 18 Aug 2026 14:24:43 +0200 Subject: [PATCH] perf(api): make relation inclusion opt-in on entity list endpoint Fixes #130 feat(api)!: update entity output for having relations grouped (#90) caused GET /api/v1/entities/{templateIdentifier} to eagerly resolve and serialize the full relation graph for every item in a paginated/filtered list, regardless of whether the caller needed relations. This regressed list latency 1.5-2.3x and payload size +33% (see idp-v2-vs-app-referential-analysis.md, section 12). Add an include_relations query parameter (default false). The default path now returns properties only, skipping relation-graph resolution entirely via a new EntityDtoOutMapper#fromEntitiesPageToDtoPageWithoutRelations. include_relations=true preserves the previous eager-relations behavior exactly for callers that need it. Get-by-id and /search endpoints are unaffected. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../api/configuration/SwaggerDescription.java | 1 + .../api/controller/EntityController.java | 36 ++++++++++++++++--- .../api/mapper/entity/EntityDtoOutMapper.java | 34 ++++++++++++++++++ .../api/controller/EntityControllerTest.java | 8 ++--- 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index fdf17a82..b816eed1 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -229,6 +229,7 @@ public class SwaggerDescription { public static final String PARAM_DEPTH_DESCRIPTION = "Maximum traversal depth for relationship resolution. Clamped between 1 and 6."; public static final String RELATIONS_DEPTH_DESCRIPTION = "Maximum depth to traverse when collecting entity relations. Defaults to 1. Valid range: 1-6."; public static final String RELATIONS_TO_DISPLAY_DESCRIPTION = "Comma-separated list of relation names to filter the response. Only relations that form a connected chain from the root entity are included. Isolated relations (not reachable from the root) are excluded even if listed. When omitted, all reachable relations are included."; + public static final String INCLUDE_RELATIONS_DESCRIPTION = "When true, each returned entity includes its resolved relations (outbound and inbound), at the cost of an additional per-item relation lookup. Defaults to false, returning properties only for lower latency on large/paginated result sets."; public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY = "Get entity relationship graph as flat nodes and edges"; public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION = "Retrieves the entity relationship graph as a flat nodes-and-edges structure, suitable for frontend visualization tools such as React Flow, Vis.js, and Cytoscape."; public static final String RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS = "Flat entity graph successfully retrieved"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index 4c0aa137..c50a2be8 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java @@ -16,6 +16,7 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PUT_ENTITY_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PUT_ENTITY_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FORBIDDEN_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.INCLUDE_RELATIONS_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.INTERNAL_SERVER_ERROR_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NO_CONTENT_CODE; @@ -149,7 +150,19 @@ public class EntityController { /// @param templateIdentifier template filter for entity scope limitation /// @param q optional filter query string (e.g. /// `name:API;property.language=JAVA`) + /// @param includeRelations when true, resolves and includes each entity's + /// relations; defaults to false to avoid the extra per-item relation + /// lookup on list/search responses (see performance note below) /// @return paginated entity DTOs matching the template and optional filter + /// + /// **Performance note:** relations are opt-in (`include_relations=true`) on + /// this list endpoint. Eagerly resolving the full relation graph for every + /// item in a paginated/filtered list multiplies cost with result-set size + /// and was found to regress list latency by 1.5-2.3x and payload size by + /// ~33% when it was previously always-on. Callers that only need + /// identifiers/properties (the common case for listings, search-driven UI, + /// and LLM-driven callers) should omit the flag; callers that need the + /// relation graph per item can opt in explicitly. @SuppressWarnings("null") @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) @@ -161,17 +174,30 @@ public class EntityController { @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) @Parameter(name = "q", description = PARAM_QUERY_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string"))) + @Parameter(name = "include_relations", description = INCLUDE_RELATIONS_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "boolean", defaultValue = "false"))) @ResponseStatus(OK) @GetMapping("/{templateIdentifier}") public Page getEntities(@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @PathVariable String templateIdentifier, - @RequestParam(required = false) String q) { + @RequestParam(required = false) String q, + @RequestParam(name = "include_relations", defaultValue = "false") boolean includeRelations) { Pageable pageable = PageRequest.of(page, size); EntityFilter filter = entityFilterDslParser.parse(q); - // Single transaction: pagination + batch relation fetch in one DB round trip - Page graphNodes = entityGraphService.getEntityGraphPageByTemplate(pageable, - templateIdentifier, filter, 1); - return entityDtoOutFromEntityNodeMapper.toPageDto(graphNodes, templateIdentifier, 1); + + if (includeRelations) { + // Single transaction: pagination + batch relation fetch in one DB round trip + Page graphNodes = entityGraphService.getEntityGraphPageByTemplate(pageable, + templateIdentifier, filter, 1); + return entityDtoOutFromEntityNodeMapper.toPageDto(graphNodes, templateIdentifier, 1); + } + + // Default path: properties only, no relation graph resolution — avoids the + // per-item relation lookup that previously ran unconditionally for every + // list/search result regardless of whether the caller needed relations. + Page entities = entityService.getEntitiesByTemplateIdentifier(pageable, + templateIdentifier, filter); + return entityDtoOutMapper.fromEntitiesPageToDtoPageWithoutRelations(entities, + templateIdentifier); } /** diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java index 8594aa37..433e1ee1 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java @@ -333,6 +333,40 @@ private Map buildEntitiesSummariesMapByCompositeKeys( es -> new EntitySummaryDto(es.identifier(), es.name(), es.templateIdentifier()))); } + /// Maps a paginated, single-template list of entities to API DTOs without + /// resolving relations. + /// + /// **Performance optimization:** Skips relation-graph resolution entirely — + /// no per-item relation lookups, no entity summary batch queries. Intended + /// for the default (non-`include_relations`) list/pagination path, where the + /// per-item relation fetch previously ran unconditionally regardless of + /// whether the caller needed relations, multiplying cost with result-set + /// size. Use [#fromEntitiesSearchPageToDtoPage] or the entity-graph-based + /// paths when relations are actually required. + /// + /// @param entities paginated domain entities, all belonging to + /// `templateIdentifier` + /// @param templateIdentifier the template used to resolve property type + /// metadata once for the whole page + /// @return paginated API DTOs with properties only; `relations` is always + /// empty + public Page fromEntitiesPageToDtoPageWithoutRelations(Page entities, + String templateIdentifier) { + if (entities.isEmpty()) { + return entities.map(entity -> new EntityDtoOut(entity.identifier(), entity.name(), + entity.templateIdentifier(), Map.of(), Map.of())); + } + + EntityTemplate entityTemplate = entityTemplateService + .getEntityTemplateByIdentifier(templateIdentifier); + + return entities.map(entity -> { + Map props = mapPropertiesDto(entity, entityTemplate); + return new EntityDtoOut(entity.identifier(), entity.name(), entity.templateIdentifier(), + props, Map.of()); + }); + } + /// Maps paginated search results to API DTOs with optimized bulk operations. /// /// **Performance optimization:** Batches template resolution across all diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 8bcaad4c..741eb505 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -140,7 +140,7 @@ class GetEntitiesByTemplateIdentifierWithFilterTests { void getEntities_200_withFilter(String query) throws Exception { MvcResult mvcResult = mockMvc .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER).param("q", query) - .accept(APPLICATION_JSON).with(csrf())) + .param("include_relations", "true").accept(APPLICATION_JSON).with(csrf())) .andExpect(status().isOk()).andReturn(); JSONAssert.assertEquals( getJsonTestFileContent( @@ -165,7 +165,7 @@ void getEntities_200_relationsAsTargetIdentifier() throws Exception { MvcResult mvcResult = mockMvc .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "microservice") .param("q", "relations_as_target.api-link.identifier=web-api-1") - .accept(APPLICATION_JSON).with(csrf())) + .param("include_relations", "true").accept(APPLICATION_JSON).with(csrf())) .andExpect(status().isOk()).andReturn(); JSONAssert.assertEquals( getJsonTestFileContent( @@ -179,8 +179,8 @@ void getEntities_200_relationsAsTargetIdentifier() throws Exception { void getEntities_200_relationsAsTargetNameContains() throws Exception { MvcResult mvcResult = mockMvc .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "microservice") - .param("q", "relations_as_target.api-link.name:Web API").accept(APPLICATION_JSON) - .with(csrf())) + .param("q", "relations_as_target.api-link.name:Web API") + .param("include_relations", "true").accept(APPLICATION_JSON).with(csrf())) .andExpect(status().isOk()).andReturn(); JSONAssert.assertEquals( getJsonTestFileContent(