Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)))
Expand All @@ -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<EntityDtoOut> 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<EntityGraphNode> 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<EntityGraphNode> 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<Entity> entities = entityService.getEntitiesByTemplateIdentifier(pageable,
templateIdentifier, filter);
return entityDtoOutMapper.fromEntitiesPageToDtoPageWithoutRelations(entities,
templateIdentifier);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,40 @@ private Map<String, EntitySummaryDto> 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<EntityDtoOut> fromEntitiesPageToDtoPageWithoutRelations(Page<Entity> 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<String, Object> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down
Loading