Avoid repeated query parsing.

This commit makes sure to avoid unnecessary parsing of queries when sorting must not be appended.
Additionally the parsed result is cached on a per query string with sort expression basis to avoid parsing repeating occurrences of the same expressions eg. while paging through results.

Closes: #3310
Original pull request: #3321
This commit is contained in:
Christoph Strobl
2024-01-11 08:52:10 +01:00
committed by Mark Paluch
parent 005c507eaa
commit 804e65cf5d
2 changed files with 315 additions and 4 deletions

View File

@@ -18,6 +18,8 @@ package org.springframework.data.jpa.repository.query;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import java.util.Objects;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.QueryRewriter;
@@ -28,6 +30,7 @@ import org.springframework.data.util.Lazy;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ConcurrentLruCache;
import org.springframework.util.StringUtils;
/**
@@ -41,6 +44,7 @@ import org.springframework.util.StringUtils;
* @author Mark Paluch
* @author Diego Krupitza
* @author Greg Turnquist
* @author Christoph Strobl
*/
abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery {
@@ -50,6 +54,7 @@ abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery {
private final SpelExpressionParser parser;
private final QueryParameterSetter.QueryMetadataCache metadataCache = new QueryParameterSetter.QueryMetadataCache();
private final QueryRewriter queryRewriter;
private ConcurrentLruCache<CachableQuery, String> queryCache = new ConcurrentLruCache<>(16, this::applySorting);
private final Lazy<ParameterBinder> countParameterBinder;
/**
@@ -104,12 +109,12 @@ abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery {
@Override
public Query doCreateQuery(JpaParametersParameterAccessor accessor) {
String sortedQueryString = QueryEnhancerFactory.forQuery(query) //
.applySorting(accessor.getSort(), query.getAlias());
Sort sort = accessor.getSort();
String sortedQueryString = applySortingIfNecessary(query, sort);
ResultProcessor processor = getQueryMethod().getResultProcessor().withDynamicProjection(accessor);
Query query = createJpaQuery(sortedQueryString, accessor.getSort(), accessor.getPageable(),
processor.getReturnedType());
Query query = createJpaQuery(sortedQueryString, sort, accessor.getPageable(), processor.getReturnedType());
QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata(sortedQueryString, query);
@@ -118,6 +123,10 @@ abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery {
return parameterBinder.get().bindAndPrepare(query, metadata, accessor);
}
protected String applySorting(DeclaredQuery query, Sort sort) {
return queryCache.get(new CachableQuery(query, sort));
}
@Override
protected ParameterBinder createBinder() {
return createBinder(query);
@@ -194,4 +203,77 @@ abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery {
? queryRewriter.rewrite(originalQuery, pageable) //
: queryRewriter.rewrite(originalQuery, sort);
}
String applySorting(CachableQuery cachableQuery) {
return QueryEnhancerFactory.forQuery(cachableQuery.getDeclaredQuery()).applySorting(cachableQuery.getSort(),
cachableQuery.getAlias());
}
private String applySortingIfNecessary(DeclaredQuery query, Sort sort) {
if (sort.isUnsorted()) {
return query.getQueryString();
}
return applySorting(query, sort);
}
/**
* Value object with optimized {@link Object#equals(Object)} to cache a query based on its query string and
* {@link Sort sorting}.
*
* @since 3.2.3
* @author Christoph Strobl
*/
static class CachableQuery {
private DeclaredQuery declaredQuery;
private final String queryString;
private final Sort sort;
CachableQuery(DeclaredQuery query, Sort sort) {
this.declaredQuery = query;
this.queryString = query.getQueryString();
this.sort = sort;
}
DeclaredQuery getDeclaredQuery() {
return declaredQuery;
}
Sort getSort() {
return sort;
}
String getAlias() {
return declaredQuery.getAlias();
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
CachableQuery that = (CachableQuery) o;
if (!Objects.equals(queryString, that.queryString)) {
return false;
}
return Objects.equals(sort, that.sort);
}
@Override
public int hashCode() {
int result = queryString != null ? queryString.hashCode() : 0;
result = 31 * result + (sort != null ? sort.hashCode() : 0);
return result;
}
}
}

View File

@@ -0,0 +1,229 @@
/*
* 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
*
* https://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.springframework.data.jpa.repository.query;
import static org.mockito.Mockito.*;
import jakarta.persistence.EntityManager;
import jakarta.persistence.metamodel.Metamodel;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
import org.assertj.core.api.Assertions;
import org.assertj.core.util.Arrays;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.provider.QueryExtractor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.QueryRewriter;
import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.core.RepositoryMetadata;
import org.springframework.data.repository.core.support.DefaultRepositoryMetadata;
import org.springframework.data.repository.query.ParametersSource;
import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
import org.springframework.data.repository.query.ReturnedType;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.lang.Nullable;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.ReflectionUtils;
/**
* @author Christoph Strobl
*/
public class AbstractStringBasedJpaQueryUnitTests {
@Test // GH-3310
void shouldNotAttemptToAppendSortIfNoSortArgumentPresent() {
InvocationCapturingStringQueryStub stringQuery = forMethod(TestRepo.class, "find");
stringQuery.createQueryWithArguments();
stringQuery.neverCalled("applySorting");
}
@Test // GH-3310
void shouldNotAttemptToAppendSortIfSortIndicatesUnsorted() {
InvocationCapturingStringQueryStub stringQuery = forMethod(TestRepo.class, "find", Sort.class);
stringQuery.createQueryWithArguments(Sort.unsorted());
stringQuery.neverCalled("applySorting");
}
@Test // GH-3310
void shouldAppendSortIfSortPresent() {
InvocationCapturingStringQueryStub stringQuery = forMethod(TestRepo.class, "find", Sort.class);
stringQuery.createQueryWithArguments(Sort.by("name"));
stringQuery.called("applySorting").times(1);
}
@Test // GH-3311
void cachesInvocationBasedOnSortArgument() {
InvocationCapturingStringQueryStub stringQuery = forMethod(TestRepo.class, "find", Sort.class);
stringQuery.createQueryWithArguments(Sort.by("name"));
stringQuery.called("applySorting").times(1);
stringQuery.createQueryWithArguments(Sort.by("name"));
stringQuery.called("applySorting").times(1);
stringQuery.createQueryWithArguments(Sort.by("age"));
stringQuery.called("applySorting").times(2);
}
interface TestRepo extends Repository<Object, Object> {
@Query("SELECT e FROM Employee e")
Object find();
@Query("SELECT e FROM Employee e")
Object find(Sort sort);
}
static InvocationCapturingStringQueryStub forMethod(Class<?> repository, String method, Class<?>... args) {
Method respositoryMethod = ReflectionUtils.findMethod(repository, method, args);
RepositoryMetadata repositoryMetadata = new DefaultRepositoryMetadata(repository);
SpelAwareProxyProjectionFactory projectionFactory = Mockito.mock(SpelAwareProxyProjectionFactory.class);
QueryExtractor queryExtractor = Mockito.mock(QueryExtractor.class);
JpaQueryMethod queryMethod = new JpaQueryMethod(respositoryMethod, repositoryMetadata, projectionFactory,
queryExtractor);
Query query = AnnotatedElementUtils.getMergedAnnotation(respositoryMethod, Query.class);
return new InvocationCapturingStringQueryStub(respositoryMethod, queryMethod, query.value(), query.countQuery(),
new SpelExpressionParser());
}
static class InvocationCapturingStringQueryStub extends AbstractStringBasedJpaQuery {
private final Method targetMethod;
private final MultiValueMap<String, Arguments> capturedArguments = new LinkedMultiValueMap<>(3);
InvocationCapturingStringQueryStub(Method targetMethod, JpaQueryMethod queryMethod, String queryString,
@Nullable String countQueryString, SpelExpressionParser parser) {
super(queryMethod, new Supplier<EntityManager>() {
@Override
public EntityManager get() {
EntityManager em = Mockito.mock(EntityManager.class);
Metamodel meta = mock(Metamodel.class);
when(em.getMetamodel()).thenReturn(meta);
when(em.getDelegate()).thenReturn(new Object()); // some generic jpa
return em;
}
}.get(), queryString, countQueryString, Mockito.mock(QueryRewriter.class),
Mockito.mock(QueryMethodEvaluationContextProvider.class), parser);
this.targetMethod = targetMethod;
}
@Override
protected String applySorting(CachableQuery query) {
captureInvocation("applySorting", query);
return super.applySorting(query);
}
@Override
protected jakarta.persistence.Query createJpaQuery(String queryString, Sort sort, @Nullable Pageable pageable,
ReturnedType returnedType) {
captureInvocation("createJpaQuery", queryString, sort, pageable, returnedType);
jakarta.persistence.Query jpaQuery = super.createJpaQuery(queryString, sort, pageable, returnedType);
return jpaQuery == null ? Mockito.mock(jakarta.persistence.Query.class) : jpaQuery;
}
// --> convenience for tests
JpaParameters getParameters() {
return new JpaParameters(ParametersSource.of(targetMethod));
}
JpaParametersParameterAccessor getParameterAccessor(Object... args) {
return new JpaParametersParameterAccessor(getParameters(), args);
}
jakarta.persistence.Query createQueryWithArguments(Object... args) {
return doCreateQuery(getParameterAccessor(args));
}
// --> capturing methods
private void captureInvocation(String key, Object... args) {
capturedArguments.add(key, new Arguments(args));
}
// --> verification methdos
int getInvocationCount(String method) {
List<Arguments> invocations = capturedArguments.get(method);
return invocations != null ? invocations.size() : 0;
}
public void neverCalled(String method) {
called(method).never();
}
public Times called(String method) {
return (invocationCount -> {
int actualCount = getInvocationCount(method);
Assertions.assertThat(actualCount)
.withFailMessage(
() -> "Expected %d invocations for %s, but recorded %d".formatted(invocationCount, method, actualCount))
.isEqualTo(invocationCount);
});
}
static class Arguments {
List<Object> values = new ArrayList<>(3);
public Arguments(Object... values) {
this.values = Arrays.asList(values);
}
}
interface Times {
void times(int invocationCount);
default void never() {
times(0);
}
}
}
}