Skip to content

Commit

Permalink
fix(#255): remove in-memory pagination on one-to-many resource endpoints
Browse files Browse the repository at this point in the history
Signed-off-by: Timon Borter <[email protected]>
  • Loading branch information
bbortt committed Apr 12, 2024
1 parent d024379 commit 9feb5a3
Show file tree
Hide file tree
Showing 14 changed files with 973 additions and 66 deletions.
1 change: 1 addition & 0 deletions simulator-spring-boot/lombok.config
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
lombok.log.fieldName = logger
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

package org.citrusframework.simulator.repository;

import java.util.List;
import java.util.Optional;
import org.citrusframework.simulator.model.Message;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Expand All @@ -27,9 +29,6 @@
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

/**
* Spring Data JPA repository for the {@link Message} entity.
*/
Expand Down Expand Up @@ -62,4 +61,8 @@ default List<Message> findAllForScenarioExecution(Long scenarioExecutionId, Stri
}

List<Message> findAllByScenarioExecutionExecutionIdEqualsAndCitrusMessageIdEqualsIgnoreCaseAndDirectionEquals(@Param("scenarioExecutionId") Long scenarioExecutionId, @Param("citrusMessageId") String citrusMessageId, @Param("direction") Integer direction);

@Query("FROM Message WHERE messageId IN :messageIds")
@EntityGraph(attributePaths = {"headers", "scenarioExecution"})
Page<Message> findAllWhereIdIn(@Param("messageIds") List<Long> messageIds, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,19 @@

package org.citrusframework.simulator.repository;

import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.citrusframework.simulator.model.ScenarioExecution;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.Optional;

/**
* Spring Data JPA repository for the {@link ScenarioExecution} entity.
*/
Expand All @@ -38,10 +39,10 @@ public interface ScenarioExecutionRepository extends JpaRepository<ScenarioExecu
@EntityGraph(attributePaths = {"scenarioParameters", "scenarioActions", "scenarioMessages"})
Page<ScenarioExecution> findAll(Pageable pageable);

@Override
@EntityGraph(attributePaths = {"scenarioParameters", "scenarioActions", "scenarioMessages"})
Page<ScenarioExecution> findAll(Specification<ScenarioExecution> spec, Pageable pageable);
Optional<ScenarioExecution> findOneByExecutionId(@Param("executionId") Long executionId);

@Query("FROM ScenarioExecution WHERE executionId IN :scenarioExecutionIds")
@EntityGraph(attributePaths = {"scenarioParameters", "scenarioActions", "scenarioMessages"})
Optional<ScenarioExecution> findOneByExecutionId(@Param("executionId") Long executionId);
Page<ScenarioExecution> findAllWhereIdIn(@Param("scenarioExecutionIds") List<Long> scenarioExecutionIds, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,19 @@

package org.citrusframework.simulator.repository;

import java.util.List;
import java.util.Optional;
import org.citrusframework.simulator.model.TestResult;
import org.citrusframework.simulator.service.dto.TestResultByStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.Optional;

/**
* Spring Data JPA repository for the {@link TestResult} entity.
*/
Expand All @@ -47,9 +47,9 @@ public interface TestResultRepository extends JpaRepository<TestResult, Long>, J

@Override
@EntityGraph(attributePaths = {"testParameters"})
Page<TestResult> findAll(Specification<TestResult> spec, Pageable pageable);
Optional<TestResult> findById(Long id);

@Override
@Query("FROM TestResult WHERE id IN :testResultIds")
@EntityGraph(attributePaths = {"testParameters"})
Optional<TestResult> findById(Long id);
Page<TestResult> findAllWhereIdIn(@Param("testResultIds") List<Long> testResultIds, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
* Copyright 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.citrusframework.simulator.service;

import static java.util.Objects.nonNull;

import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Order;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.metamodel.SingularAttribute;
import java.util.ArrayList;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;

final class CriteriaQueryUtils {

private CriteriaQueryUtils() {
// Static utility class
}

/**
* There is an issue within Hibernate when trying to apply {@link Pageable} in combination with filtering through
* a {@link Specification}. Hibernate cannot create a query (although SQL would allow it) that returns the paginated
* result. We must therefore manually execute two (or rather three) queries:
* <ul>
* <li>The first query to fetch a <b>paginated</b> list of all entity ID's</li>
* <li>The second query, to fetch all entities that belong to those ID's</li>
* <li>And a third "count"-query, in order to retrieve the missing piece of pagination information</li>
* </ul>
* <p>
* The warning is being seen in the log:
* <pre>
* [TIMESTAMP]: HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
* </pre>
* <p>
* The query created and returned by this function is the first of the mentioned queries, constructed with the
* restrictions from a given {@link Specification}.
*
* @see <a href="https://github.com/citrusframework/citrus-simulator/issues/255">problem with in-memory pagination when having high volume of data</a>
*/
static <R> TypedQuery<Long> newSelectIdBySpecificationQuery(Class<R> entityClass, SingularAttribute<R, Long> idAttribute, Specification<R> specification, Pageable page, EntityManager entityManager) {
CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
CriteriaQuery<Long> criteriaQuery = criteriaBuilder.createQuery(Long.class);
Root<R> root = criteriaQuery.from(entityClass);

criteriaQuery = selectIdField(idAttribute, criteriaQuery, root);

criteriaQuery = whereSpecificationApplies(specification, root, criteriaQuery, criteriaBuilder);

criteriaQuery = orderByPageSort(page, root, criteriaBuilder, criteriaQuery);

TypedQuery<Long> query = entityManager.createQuery(criteriaQuery);
query = selectPage(page, query);

return query;
}

/**
* Restrict the query to the ID of the entity.
*
* @return the modified {@link CriteriaQuery<Long>}.
*/
private static <R> CriteriaQuery<Long> selectIdField(SingularAttribute<R, Long> idAttribute, CriteriaQuery<Long> criteriaQuery, Root<R> root) {
return criteriaQuery.select(root.get(idAttribute));
}

/**
* Apply the specifications (criteria) to the query, if any restrictions exist.
*
* @return the modified {@link CriteriaQuery<Long>}.
*/
private static <R> CriteriaQuery<Long> whereSpecificationApplies(Specification<R> specification, Root<R> root, CriteriaQuery<Long> criteriaQuery, CriteriaBuilder criteriaBuilder) {
Predicate predicate = specification.toPredicate(root, criteriaQuery, criteriaBuilder);
if (nonNull(predicate)) {
return criteriaQuery.where(predicate);
}
return criteriaQuery;
}

/**
* Handle sorting, according to the definition of the {@link Pageable}.
*
* @return the modified {@link CriteriaQuery<Long>}.
*/
private static <R> CriteriaQuery<Long> orderByPageSort(Pageable page, Root<R> root, CriteriaBuilder criteriaBuilder, CriteriaQuery<Long> criteriaQuery) {
var orders = new ArrayList<Order>();
for (var sortOrder : page.getSort()) {
var path = root.get(sortOrder.getProperty());
orders.add(sortOrder.isAscending() ? criteriaBuilder.asc(path) : criteriaBuilder.desc(path));
}
if (!orders.isEmpty()) {
return criteriaQuery.orderBy(orders);
}
return criteriaQuery;
}

/**
* Apply pagination.
*
* @return The same {@link TypedQuery<Long>}.
*/
private static TypedQuery<Long> selectPage(Pageable page, TypedQuery<Long> query) {
// Calculate the first result index based on the page number and page size
var pageSize = page.getPageSize();
var firstResult = page.getPageNumber() * pageSize;

query = query.setFirstResult(firstResult);
query = query.setMaxResults(pageSize);

return query;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023 the original author or authors.
* Copyright 2023-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -16,38 +16,41 @@

package org.citrusframework.simulator.service;

import static org.citrusframework.simulator.service.CriteriaQueryUtils.newSelectIdBySpecificationQuery;

import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.JoinType;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.citrusframework.simulator.model.Message;
import org.citrusframework.simulator.model.MessageHeader_;
import org.citrusframework.simulator.model.Message_;
import org.citrusframework.simulator.model.ScenarioExecution_;
import org.citrusframework.simulator.repository.MessageRepository;
import org.citrusframework.simulator.service.criteria.MessageCriteria;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

/**
* Service for executing complex queries for {@link Message} entities in the database.
* The main input is a {@link MessageCriteria} which gets converted to {@link Specification},
* in a way that all the filters must apply.
* It returns a {@link List} of {@link Message} or a {@link Page} of {@link Message} which fulfills the criteria.
*/
@Slf4j
@Service
@Transactional(readOnly = true)
public class MessageQueryService extends QueryService<Message> {

private static final Logger logger = LoggerFactory.getLogger(MessageQueryService.class);

private final EntityManager entityManager;
private final MessageRepository messageRepository;

public MessageQueryService(MessageRepository messageRepository) {
public MessageQueryService(EntityManager entityManager, MessageRepository messageRepository) {
this.entityManager = entityManager;
this.messageRepository = messageRepository;
}

Expand All @@ -60,8 +63,19 @@ public MessageQueryService(MessageRepository messageRepository) {
@Transactional(readOnly = true)
public Page<Message> findByCriteria(MessageCriteria criteria, Pageable page) {
logger.debug("find by criteria : {}, page: {}", criteria, page);
final Specification<Message> specification = createSpecification(criteria);
return messageRepository.findAll(specification, page);

var specification = createSpecification(criteria);
var messageIds = newSelectIdBySpecificationQuery(
Message.class,
Message_.messageId,
specification,
page,
entityManager
)
.getResultList();

var messages = messageRepository.findAllWhereIdIn(messageIds, page);
return new PageImpl<>(messages.getContent(), page, messageRepository.count(specification));
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023 the original author or authors.
* Copyright 2023-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -16,39 +16,42 @@

package org.citrusframework.simulator.service;

import static org.citrusframework.simulator.service.CriteriaQueryUtils.newSelectIdBySpecificationQuery;

import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.JoinType;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.citrusframework.simulator.model.Message_;
import org.citrusframework.simulator.model.ScenarioAction_;
import org.citrusframework.simulator.model.ScenarioExecution;
import org.citrusframework.simulator.model.ScenarioExecution_;
import org.citrusframework.simulator.model.ScenarioParameter_;
import org.citrusframework.simulator.repository.ScenarioExecutionRepository;
import org.citrusframework.simulator.service.criteria.ScenarioExecutionCriteria;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

/**
* Service for executing complex queries for {@link ScenarioExecution} entities in the database.
* The main input is a {@link ScenarioExecutionCriteria} which gets converted to {@link Specification},
* in a way that all the filters must apply.
* It returns a {@link List} of {@link ScenarioExecution} or a {@link Page} of {@link ScenarioExecution} which fulfills the criteria.
*/
@Slf4j
@Service
@Transactional(readOnly = true)
public class ScenarioExecutionQueryService extends QueryService<ScenarioExecution> {

private static final Logger logger = LoggerFactory.getLogger(ScenarioExecutionQueryService.class);

private final EntityManager entityManager;
private final ScenarioExecutionRepository scenarioExecutionRepository;

public ScenarioExecutionQueryService(ScenarioExecutionRepository scenarioExecutionRepository) {
public ScenarioExecutionQueryService(EntityManager entityManager, ScenarioExecutionRepository scenarioExecutionRepository) {
this.entityManager = entityManager;
this.scenarioExecutionRepository = scenarioExecutionRepository;
}

Expand All @@ -75,8 +78,19 @@ public List<ScenarioExecution> findByCriteria(ScenarioExecutionCriteria criteria
@Transactional(readOnly = true)
public Page<ScenarioExecution> findByCriteria(ScenarioExecutionCriteria criteria, Pageable page) {
logger.debug("find by criteria : {}, page: {}", criteria, page);
final Specification<ScenarioExecution> specification = createSpecification(criteria);
return scenarioExecutionRepository.findAll(specification, page);

var specification = createSpecification(criteria);
var scenarioExecutionIds = newSelectIdBySpecificationQuery(
ScenarioExecution.class,
ScenarioExecution_.executionId,
specification,
page,
entityManager
)
.getResultList();

var scenarioExecutions = scenarioExecutionRepository.findAllWhereIdIn(scenarioExecutionIds, page);
return new PageImpl<>(scenarioExecutions.getContent(), page, scenarioExecutionRepository.count(specification));
}

/**
Expand Down
Loading

0 comments on commit 9feb5a3

Please sign in to comment.