From c905402f7b566549818023384fcdc683421fcf2e Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Fri, 23 Feb 2024 11:17:18 +0100 Subject: [PATCH 1/6] Prepare issue branch. --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c6dc5a59a0..d769596164 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-commons - 3.4.0-SNAPSHOT + 3.4.0-GH-3049-SNAPSHOT Spring Data Core Core Spring concepts underpinning every Spring Data module. From 83a7ee5b618d336cfabd5b60d097ccf4fb7268ae Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Fri, 23 Feb 2024 12:25:14 +0100 Subject: [PATCH 2/6] Add `ValueExpression` infrastructure for query methods. Introduce ValueExpressionQueryRewriter as replacement for SpelQueryContext and QueryMethodValueEvaluationContextAccessor to encapsulate common ValueExpression functionality for Spring Data modules wanting to resolve Value Expressions in query methods. Reduce dependencies in RepositoryFactoryBeanSupport and RepositoryFactorySupport to EvaluationContextProvider instead of QueryMethodEvaluationContextProvider to simplify dependencies. Deprecate QueryMethodEvaluationContextProvider and its reactive variant for future removal. Closes #3049 --- .../DefaultValueEvaluationContext.java | 3 +- .../DefaultValueExpressionParser.java | 3 + ...eactiveValueEvaluationContextProvider.java | 54 +++ .../expression/ValueEvaluationContext.java | 22 +- .../ValueEvaluationContextProvider.java | 7 +- .../expression/ValueExpressionParser.java | 10 + ...achingValueExpressionEvaluatorFactory.java | 23 +- .../ReactiveRepositoryFactorySupport.java | 28 ++ .../support/RepositoryFactoryBeanSupport.java | 74 ++- .../support/RepositoryFactorySupport.java | 128 ++++-- ...sactionalRepositoryFactoryBeanSupport.java | 1 + .../query/CachingValueExpressionDelegate.java | 66 +++ ...eQueryMethodEvaluationContextProvider.java | 86 ++-- .../QueryMethodEvaluationContextProvider.java | 8 + ...yMethodValueEvaluationContextAccessor.java | 269 +++++++++++ ...eQueryMethodEvaluationContextProvider.java | 76 ++-- ...eQueryMethodEvaluationContextProvider.java | 2 + .../data/repository/query/SpelEvaluator.java | 2 + .../repository/query/SpelQueryContext.java | 2 + .../query/ValueExpressionDelegate.java | 87 ++++ .../query/ValueExpressionQueryRewriter.java | 423 ++++++++++++++++++ .../data/spel/EvaluationContextProvider.java | 5 +- ...tensionAwareEvaluationContextProvider.java | 8 +- .../ReactiveEvaluationContextProvider.java | 5 +- ...tensionAwareEvaluationContextProvider.java | 5 +- .../expression/ValueEvaluationUnitTests.java | 8 +- ...ValueExpressionQueryRewriterUnitTests.java | 158 +++++++ 27 files changed, 1406 insertions(+), 157 deletions(-) create mode 100644 src/main/java/org/springframework/data/expression/ReactiveValueEvaluationContextProvider.java create mode 100644 src/main/java/org/springframework/data/repository/query/CachingValueExpressionDelegate.java create mode 100644 src/main/java/org/springframework/data/repository/query/QueryMethodValueEvaluationContextAccessor.java create mode 100644 src/main/java/org/springframework/data/repository/query/ValueExpressionDelegate.java create mode 100644 src/main/java/org/springframework/data/repository/query/ValueExpressionQueryRewriter.java create mode 100644 src/test/java/org/springframework/data/repository/query/ValueExpressionQueryRewriterUnitTests.java diff --git a/src/main/java/org/springframework/data/expression/DefaultValueEvaluationContext.java b/src/main/java/org/springframework/data/expression/DefaultValueEvaluationContext.java index d498560135..c0f72a1000 100644 --- a/src/main/java/org/springframework/data/expression/DefaultValueEvaluationContext.java +++ b/src/main/java/org/springframework/data/expression/DefaultValueEvaluationContext.java @@ -17,6 +17,7 @@ import org.springframework.core.env.Environment; import org.springframework.expression.EvaluationContext; +import org.springframework.lang.Nullable; /** * Default {@link ValueEvaluationContext}. @@ -24,7 +25,7 @@ * @author Mark Paluch * @since 3.3 */ -record DefaultValueEvaluationContext(Environment environment, +record DefaultValueEvaluationContext(@Nullable Environment environment, EvaluationContext evaluationContext) implements ValueEvaluationContext { @Override diff --git a/src/main/java/org/springframework/data/expression/DefaultValueExpressionParser.java b/src/main/java/org/springframework/data/expression/DefaultValueExpressionParser.java index ceea642641..5fd7ad192d 100644 --- a/src/main/java/org/springframework/data/expression/DefaultValueExpressionParser.java +++ b/src/main/java/org/springframework/data/expression/DefaultValueExpressionParser.java @@ -22,6 +22,7 @@ import org.springframework.expression.Expression; import org.springframework.expression.ParseException; import org.springframework.expression.ParserContext; +import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.util.Assert; import org.springframework.util.SystemPropertyUtils; @@ -39,6 +40,8 @@ class DefaultValueExpressionParser implements ValueExpressionParser { public static final int PLACEHOLDER_PREFIX_LENGTH = PLACEHOLDER_PREFIX.length(); public static final char[] QUOTE_CHARS = { '\'', '"' }; + public static final ValueExpressionParser DEFAULT = new DefaultValueExpressionParser(SpelExpressionParser::new); + private final ValueParserConfiguration configuration; public DefaultValueExpressionParser(ValueParserConfiguration configuration) { diff --git a/src/main/java/org/springframework/data/expression/ReactiveValueEvaluationContextProvider.java b/src/main/java/org/springframework/data/expression/ReactiveValueEvaluationContextProvider.java new file mode 100644 index 0000000000..a280ddf625 --- /dev/null +++ b/src/main/java/org/springframework/data/expression/ReactiveValueEvaluationContextProvider.java @@ -0,0 +1,54 @@ +/* + * 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.expression; + +import reactor.core.publisher.Mono; + +import org.springframework.data.spel.ExpressionDependencies; +import org.springframework.lang.Nullable; + +/** + * Reactive extension to {@link ValueEvaluationContext} for obtaining a {@link ValueEvaluationContext} that participates + * in the reactive flow. + * + * @author Mark Paluch + * @since 3.4 + */ +public interface ReactiveValueEvaluationContextProvider extends ValueEvaluationContextProvider { + + /** + * Return a {@link ValueEvaluationContext} built using the given parameter values. + * + * @param rootObject the root object to set in the {@link ValueEvaluationContext}. + * @return a mono that emits exactly one {@link ValueEvaluationContext}. + */ + Mono getEvaluationContextLater(@Nullable Object rootObject); + + /** + * Return a tailored {@link ValueEvaluationContext} built using the given parameter values and considering + * {@link ExpressionDependencies expression dependencies}. The returned {@link ValueEvaluationContext} may contain a + * reduced visibility of methods and properties/fields according to the required {@link ExpressionDependencies + * expression dependencies}. + * + * @param rootObject the root object to set in the {@link ValueEvaluationContext}. + * @param dependencies the requested expression dependencies to be available. + * @return a mono that emits exactly one {@link ValueEvaluationContext}. + */ + default Mono getEvaluationContextLater(@Nullable Object rootObject, + ExpressionDependencies dependencies) { + return getEvaluationContextLater(rootObject); + } +} diff --git a/src/main/java/org/springframework/data/expression/ValueEvaluationContext.java b/src/main/java/org/springframework/data/expression/ValueEvaluationContext.java index 5be091d8ca..42bc6a289e 100644 --- a/src/main/java/org/springframework/data/expression/ValueEvaluationContext.java +++ b/src/main/java/org/springframework/data/expression/ValueEvaluationContext.java @@ -36,7 +36,7 @@ public interface ValueEvaluationContext { * @param evaluationContext * @return a new {@link ValueEvaluationContext} for the given environment and evaluation context. */ - static ValueEvaluationContext of(Environment environment, EvaluationContext evaluationContext) { + static ValueEvaluationContext of(@Nullable Environment environment, EvaluationContext evaluationContext) { return new DefaultValueEvaluationContext(environment, evaluationContext); } @@ -51,8 +51,26 @@ static ValueEvaluationContext of(Environment environment, EvaluationContext eval /** * Returns the {@link EvaluationContext} if provided. * - * @return the {@link EvaluationContext} or {@literal null}. + * @return the {@link EvaluationContext} or {@literal null} if not set. */ @Nullable EvaluationContext getEvaluationContext(); + + /** + * Returns the required {@link EvaluationContext} or throws {@link IllegalStateException} if there is no evaluation + * context available. + * + * @return the {@link EvaluationContext}. + * @since 3.4 + */ + default EvaluationContext getRequiredEvaluationContext() { + + EvaluationContext evaluationContext = getEvaluationContext(); + + if (evaluationContext == null) { + throw new IllegalStateException("No evaluation context available"); + } + + return evaluationContext; + } } diff --git a/src/main/java/org/springframework/data/expression/ValueEvaluationContextProvider.java b/src/main/java/org/springframework/data/expression/ValueEvaluationContextProvider.java index 05d7d4de2f..ec7bbd10a0 100644 --- a/src/main/java/org/springframework/data/expression/ValueEvaluationContextProvider.java +++ b/src/main/java/org/springframework/data/expression/ValueEvaluationContextProvider.java @@ -17,6 +17,7 @@ import org.springframework.data.spel.ExpressionDependencies; import org.springframework.expression.EvaluationContext; +import org.springframework.lang.Nullable; /** * SPI to provide to access a centrally defined potentially shared {@link ValueEvaluationContext}. @@ -24,6 +25,7 @@ * @author Mark Paluch * @since 3.3 */ +@FunctionalInterface public interface ValueEvaluationContextProvider { /** @@ -32,7 +34,7 @@ public interface ValueEvaluationContextProvider { * @param rootObject the root object to set in the {@link EvaluationContext}. * @return */ - ValueEvaluationContext getEvaluationContext(Object rootObject); + ValueEvaluationContext getEvaluationContext(@Nullable Object rootObject); /** * Return a tailored {@link EvaluationContext} built using the given parameter values and considering @@ -44,7 +46,8 @@ public interface ValueEvaluationContextProvider { * @param dependencies the requested expression dependencies to be available. * @return */ - default ValueEvaluationContext getEvaluationContext(Object rootObject, ExpressionDependencies dependencies) { + default ValueEvaluationContext getEvaluationContext(@Nullable Object rootObject, + ExpressionDependencies dependencies) { return getEvaluationContext(rootObject); } } diff --git a/src/main/java/org/springframework/data/expression/ValueExpressionParser.java b/src/main/java/org/springframework/data/expression/ValueExpressionParser.java index 89f9f65cdc..b5e1645899 100644 --- a/src/main/java/org/springframework/data/expression/ValueExpressionParser.java +++ b/src/main/java/org/springframework/data/expression/ValueExpressionParser.java @@ -27,6 +27,16 @@ */ public interface ValueExpressionParser { + /** + * Creates a default parser to parse expression strings. + * + * @return the parser instance. + * @since 3.4 + */ + static ValueExpressionParser create() { + return DefaultValueExpressionParser.DEFAULT; + } + /** * Creates a new parser to parse expression strings. * diff --git a/src/main/java/org/springframework/data/mapping/model/CachingValueExpressionEvaluatorFactory.java b/src/main/java/org/springframework/data/mapping/model/CachingValueExpressionEvaluatorFactory.java index 91be052dc2..8f4b075f20 100644 --- a/src/main/java/org/springframework/data/mapping/model/CachingValueExpressionEvaluatorFactory.java +++ b/src/main/java/org/springframework/data/mapping/model/CachingValueExpressionEvaluatorFactory.java @@ -23,6 +23,7 @@ import org.springframework.data.spel.EvaluationContextProvider; import org.springframework.data.spel.ExpressionDependencies; import org.springframework.expression.ExpressionParser; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ConcurrentLruCache; @@ -38,11 +39,29 @@ public class CachingValueExpressionEvaluatorFactory implements ValueEvaluationCo private final EnvironmentCapable environmentProvider; private final EvaluationContextProvider evaluationContextProvider; + /** + * Creates a new {@link CachingValueExpressionEvaluatorFactory} for the given {@link ExpressionParser}, + * {@link EnvironmentCapable Environment provider} and {@link EvaluationContextProvider} with a cache size of 256. + * + * @param expressionParser + * @param environmentProvider + * @param evaluationContextProvider + */ public CachingValueExpressionEvaluatorFactory(ExpressionParser expressionParser, EnvironmentCapable environmentProvider, EvaluationContextProvider evaluationContextProvider) { this(expressionParser, environmentProvider, evaluationContextProvider, 256); } + /** + * Creates a new {@link CachingValueExpressionEvaluatorFactory} for the given {@link ExpressionParser}, + * {@link EnvironmentCapable Environment provider} and {@link EvaluationContextProvider} with a specific + * {@code cacheSize}. + * + * @param expressionParser + * @param environmentProvider + * @param evaluationContextProvider + * @param cacheSize + */ public CachingValueExpressionEvaluatorFactory(ExpressionParser expressionParser, EnvironmentCapable environmentProvider, EvaluationContextProvider evaluationContextProvider, int cacheSize) { @@ -55,13 +74,13 @@ public CachingValueExpressionEvaluatorFactory(ExpressionParser expressionParser, } @Override - public ValueEvaluationContext getEvaluationContext(Object rootObject) { + public ValueEvaluationContext getEvaluationContext(@Nullable Object rootObject) { return ValueEvaluationContext.of(environmentProvider.getEnvironment(), evaluationContextProvider.getEvaluationContext(rootObject)); } @Override - public ValueEvaluationContext getEvaluationContext(Object rootObject, ExpressionDependencies dependencies) { + public ValueEvaluationContext getEvaluationContext(@Nullable Object rootObject, ExpressionDependencies dependencies) { return ValueEvaluationContext.of(environmentProvider.getEnvironment(), evaluationContextProvider.getEvaluationContext(rootObject, dependencies)); } diff --git a/src/main/java/org/springframework/data/repository/core/support/ReactiveRepositoryFactorySupport.java b/src/main/java/org/springframework/data/repository/core/support/ReactiveRepositoryFactorySupport.java index b0a6ed332d..45e3ba34ea 100644 --- a/src/main/java/org/springframework/data/repository/core/support/ReactiveRepositoryFactorySupport.java +++ b/src/main/java/org/springframework/data/repository/core/support/ReactiveRepositoryFactorySupport.java @@ -17,14 +17,20 @@ import java.lang.reflect.Method; import java.util.Arrays; +import java.util.Optional; import org.reactivestreams.Publisher; + import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.query.QueryLookupStrategy; import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.data.repository.query.ReactiveExtensionAwareQueryMethodEvaluationContextProvider; import org.springframework.data.repository.query.ReactiveQueryMethodEvaluationContextProvider; +import org.springframework.data.repository.query.ValueExpressionDelegate; import org.springframework.data.repository.util.ReactiveWrapperConverters; import org.springframework.data.util.ReactiveWrappers; +import org.springframework.lang.Nullable; import org.springframework.util.ClassUtils; /** @@ -69,6 +75,28 @@ public void setEvaluationContextProvider(QueryMethodEvaluationContextProvider ev : evaluationContextProvider); } + /** + * Returns the {@link QueryLookupStrategy} for the given {@link QueryLookupStrategy.Key} and + * {@link ValueExpressionDelegate}. Favor implementing this method over + * {@link #getQueryLookupStrategy(QueryLookupStrategy.Key, QueryMethodEvaluationContextProvider)} for extended + * {@link org.springframework.data.expression.ValueExpression} support. + *

+ * This method delegates to + * {@link #getQueryLookupStrategy(QueryLookupStrategy.Key, QueryMethodEvaluationContextProvider)} unless overridden. + *

+ * + * @param key can be {@literal null}. + * @param valueExpressionDelegate will never be {@literal null}. + * @return the {@link QueryLookupStrategy} to use or {@literal null} if no queries should be looked up. + * @since 3.4 + */ + @Override + protected Optional getQueryLookupStrategy(@Nullable QueryLookupStrategy.Key key, + ValueExpressionDelegate valueExpressionDelegate) { + return getQueryLookupStrategy(key, + new ReactiveExtensionAwareQueryMethodEvaluationContextProvider(getEvaluationContextProvider())); + } + /** * We need to make sure that the necessary conversion libraries are in place if the repository interface uses RxJava 1 * types. diff --git a/src/main/java/org/springframework/data/repository/core/support/RepositoryFactoryBeanSupport.java b/src/main/java/org/springframework/data/repository/core/support/RepositoryFactoryBeanSupport.java index b1a8f155ff..967d02d158 100644 --- a/src/main/java/org/springframework/data/repository/core/support/RepositoryFactoryBeanSupport.java +++ b/src/main/java/org/springframework/data/repository/core/support/RepositoryFactoryBeanSupport.java @@ -28,6 +28,8 @@ import org.springframework.beans.factory.ListableBeanFactory; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisherAware; +import org.springframework.context.EnvironmentAware; +import org.springframework.core.env.Environment; import org.springframework.data.mapping.PersistentEntity; import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.repository.Repository; @@ -41,6 +43,8 @@ import org.springframework.data.repository.query.QueryLookupStrategy.Key; import org.springframework.data.repository.query.QueryMethod; import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.data.repository.query.QueryMethodValueEvaluationContextAccessor; +import org.springframework.data.spel.EvaluationContextProvider; import org.springframework.data.util.Lazy; import org.springframework.lang.NonNull; import org.springframework.util.Assert; @@ -63,8 +67,8 @@ * @author Johannes Englmeier */ public abstract class RepositoryFactoryBeanSupport, S, ID> - implements InitializingBean, RepositoryFactoryInformation, FactoryBean, BeanClassLoaderAware, - BeanFactoryAware, ApplicationEventPublisherAware { + implements InitializingBean, RepositoryFactoryInformation, FactoryBean, ApplicationEventPublisherAware, + BeanClassLoaderAware, BeanFactoryAware, EnvironmentAware { private final Class repositoryInterface; @@ -74,14 +78,15 @@ public abstract class RepositoryFactoryBeanSupport, private Optional> repositoryBaseClass = Optional.empty(); private Optional customImplementation = Optional.empty(); private Optional repositoryFragments = Optional.empty(); - private NamedQueries namedQueries; + private NamedQueries namedQueries = PropertiesBasedNamedQueries.EMPTY; private Optional> mappingContext = Optional.empty(); private ClassLoader classLoader; + private ApplicationEventPublisher publisher; private BeanFactory beanFactory; + private Environment environment; private boolean lazyInit = false; - private Optional evaluationContextProvider = Optional.empty(); - private List repositoryFactoryCustomizers = new ArrayList<>(); - private ApplicationEventPublisher publisher; + private Optional evaluationContextProvider = Optional.empty(); + private final List repositoryFactoryCustomizers = new ArrayList<>(); private Lazy repository; @@ -115,7 +120,7 @@ public void setRepositoryBaseClass(Class repositoryBaseClass) { *

* Default is "false", in order to avoid unnecessary extra interception. This means that no guarantees are provided * that {@code RepositoryMethodContext} access will work consistently within any method of the advised object. - * + * * @since 3.4.0 */ public void setExposeMetadata(boolean exposeMetadata) { @@ -168,14 +173,26 @@ protected void setMappingContext(MappingContext mappingContext) { this.mappingContext = Optional.of(mappingContext); } + /** + * Sets the {@link EvaluationContextProvider} to be used to evaluate SpEL expressions in manually defined queries. + * + * @param evaluationContextProvider must not be {@literal null}. + * @since 3.4 + */ + public void setEvaluationContextProvider(EvaluationContextProvider evaluationContextProvider) { + this.evaluationContextProvider = Optional.of(evaluationContextProvider); + } + /** * Sets the {@link QueryMethodEvaluationContextProvider} to be used to evaluate SpEL expressions in manually defined * queries. * * @param evaluationContextProvider must not be {@literal null}. + * @deprecated since 3.4, use {@link #setEvaluationContextProvider(EvaluationContextProvider)} instead. */ + @Deprecated(since = "3.4") public void setEvaluationContextProvider(QueryMethodEvaluationContextProvider evaluationContextProvider) { - this.evaluationContextProvider = Optional.of(evaluationContextProvider); + setEvaluationContextProvider(evaluationContextProvider.getEvaluationContextProvider()); } /** @@ -210,19 +227,38 @@ public void setBeanFactory(BeanFactory beanFactory) throws BeansException { this.beanFactory = beanFactory; - if (!this.evaluationContextProvider.isPresent() && ListableBeanFactory.class.isInstance(beanFactory)) { - this.evaluationContextProvider = createDefaultQueryMethodEvaluationContextProvider( - (ListableBeanFactory) beanFactory); + if (this.evaluationContextProvider.isEmpty() && beanFactory instanceof ListableBeanFactory lbf) { + this.evaluationContextProvider = createDefaultEvaluationContextProvider(lbf); } } + @Override + public void setEnvironment(Environment environment) { + this.environment = environment; + } + + /** + * Create a default {@link EvaluationContextProvider} (or subclass) from {@link ListableBeanFactory}. + * + * @param beanFactory the bean factory to use. + * @return the default instance. May be {@link Optional#empty()}. + * @since 3.4 + */ + protected Optional createDefaultEvaluationContextProvider( + ListableBeanFactory beanFactory) { + return createDefaultQueryMethodEvaluationContextProvider(beanFactory) + .map(QueryMethodEvaluationContextProvider::getEvaluationContextProvider); + } + /** * Create a default {@link QueryMethodEvaluationContextProvider} (or subclass) from {@link ListableBeanFactory}. * * @param beanFactory the bean factory to use. * @return the default instance. May be {@link Optional#empty()}. * @since 2.4 + * @deprecated since 3.4, use {@link #createDefaultEvaluationContextProvider(ListableBeanFactory)} instead. */ + @Deprecated(since = "3.4") protected Optional createDefaultQueryMethodEvaluationContextProvider( ListableBeanFactory beanFactory) { return Optional.of(new ExtensionAwareQueryMethodEvaluationContextProvider(beanFactory)); @@ -233,11 +269,13 @@ public void setApplicationEventPublisher(ApplicationEventPublisher publisher) { this.publisher = publisher; } + @Override @SuppressWarnings("unchecked") public EntityInformation getEntityInformation() { return (EntityInformation) factory.getEntityInformation(repositoryMetadata.getDomainType()); } + @Override public RepositoryInformation getRepositoryInformation() { RepositoryFragments fragments = customImplementation.map(RepositoryFragments::just)// @@ -246,30 +284,36 @@ public RepositoryInformation getRepositoryInformation() { return factory.getRepositoryInformation(repositoryMetadata, fragments); } + @Override public PersistentEntity getPersistentEntity() { return mappingContext.orElseThrow(() -> new IllegalStateException("No MappingContext available")) .getRequiredPersistentEntity(repositoryMetadata.getDomainType()); } + @Override public List getQueryMethods() { return factory.getQueryMethods(); } + @Override @NonNull public T getObject() { return this.repository.get(); } + @Override @NonNull public Class getObjectType() { return repositoryInterface; } + @Override public boolean isSingleton() { return true; } + @Override public void afterPropertiesSet() { this.factory = createRepositoryFactory(); @@ -277,14 +321,18 @@ public void afterPropertiesSet() { this.factory.setQueryLookupStrategyKey(queryLookupStrategyKey); this.factory.setNamedQueries(namedQueries); this.factory.setEvaluationContextProvider( - evaluationContextProvider.orElseGet(() -> QueryMethodEvaluationContextProvider.DEFAULT)); + evaluationContextProvider.orElse(QueryMethodValueEvaluationContextAccessor.DEFAULT_CONTEXT_PROVIDER)); this.factory.setBeanClassLoader(classLoader); this.factory.setBeanFactory(beanFactory); - if (publisher != null) { + if (this.publisher != null) { this.factory.addRepositoryProxyPostProcessor(new EventPublishingRepositoryProxyPostProcessor(publisher)); } + if (this.environment != null) { + this.factory.setEnvironment(this.environment); + } + repositoryBaseClass.ifPresent(this.factory::setRepositoryBaseClass); this.repositoryFactoryCustomizers.forEach(customizer -> customizer.customize(this.factory)); diff --git a/src/main/java/org/springframework/data/repository/core/support/RepositoryFactorySupport.java b/src/main/java/org/springframework/data/repository/core/support/RepositoryFactorySupport.java index 0b73d66a0f..1c34a90b75 100644 --- a/src/main/java/org/springframework/data/repository/core/support/RepositoryFactorySupport.java +++ b/src/main/java/org/springframework/data/repository/core/support/RepositoryFactorySupport.java @@ -38,11 +38,16 @@ import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.BeanFactoryAware; import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.context.EnvironmentAware; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.core.env.Environment; +import org.springframework.core.env.EnvironmentCapable; +import org.springframework.core.env.StandardEnvironment; import org.springframework.core.log.LogMessage; import org.springframework.core.metrics.ApplicationStartup; import org.springframework.core.metrics.StartupStep; +import org.springframework.data.expression.ValueExpressionParser; import org.springframework.data.projection.DefaultMethodInvokingMethodInterceptor; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.projection.SpelAwareProxyProjectionFactory; @@ -54,14 +59,20 @@ import org.springframework.data.repository.core.support.RepositoryComposition.RepositoryFragments; import org.springframework.data.repository.core.support.RepositoryInvocationMulticaster.DefaultRepositoryInvocationMulticaster; import org.springframework.data.repository.core.support.RepositoryInvocationMulticaster.NoOpRepositoryInvocationMulticaster; +import org.springframework.data.repository.query.ExtensionAwareQueryMethodEvaluationContextProvider; import org.springframework.data.repository.query.QueryLookupStrategy; import org.springframework.data.repository.query.QueryLookupStrategy.Key; import org.springframework.data.repository.query.QueryMethod; import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.data.repository.query.QueryMethodValueEvaluationContextAccessor; import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.ValueExpressionDelegate; import org.springframework.data.repository.util.QueryExecutionConverters; +import org.springframework.data.spel.EvaluationContextProvider; import org.springframework.data.util.Lazy; import org.springframework.data.util.ReflectionUtils; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.lang.Nullable; import org.springframework.transaction.interceptor.TransactionalProxy; import org.springframework.util.Assert; @@ -80,9 +91,13 @@ * @author John Blum * @author Johannes Englmeier */ -public abstract class RepositoryFactorySupport implements BeanClassLoaderAware, BeanFactoryAware { +public abstract class RepositoryFactorySupport + implements BeanClassLoaderAware, BeanFactoryAware, EnvironmentAware, EnvironmentCapable { static final GenericConversionService CONVERSION_SERVICE = new DefaultConversionService(); + private static final ExpressionParser EXPRESSION_PARSER = new SpelExpressionParser(); + private static final ValueExpressionParser VALUE_PARSER = ValueExpressionParser.create(() -> EXPRESSION_PARSER); + private static final Log logger = LogFactory.getLog(RepositoryFactorySupport.class); static { @@ -93,15 +108,16 @@ public abstract class RepositoryFactorySupport implements BeanClassLoaderAware, private final Map repositoryInformationCache; private final List postProcessors; - private Optional> repositoryBaseClass; + private @Nullable Class repositoryBaseClass; private boolean exposeMetadata; private @Nullable QueryLookupStrategy.Key queryLookupStrategyKey; - private List> queryPostProcessors; - private List methodInvocationListeners; + private final List> queryPostProcessors; + private final List methodInvocationListeners; private NamedQueries namedQueries; private ClassLoader classLoader; - private QueryMethodEvaluationContextProvider evaluationContextProvider; + private EvaluationContextProvider evaluationContextProvider; private BeanFactory beanFactory; + private Environment environment; private Lazy projectionFactory; private final QueryCollectingQueryCreationListener collectingListener = new QueryCollectingQueryCreationListener(); @@ -112,16 +128,19 @@ public RepositoryFactorySupport() { this.repositoryInformationCache = new HashMap<>(16); this.postProcessors = new ArrayList<>(); - this.repositoryBaseClass = Optional.empty(); this.namedQueries = PropertiesBasedNamedQueries.EMPTY; this.classLoader = org.springframework.util.ClassUtils.getDefaultClassLoader(); - this.evaluationContextProvider = QueryMethodEvaluationContextProvider.DEFAULT; + this.evaluationContextProvider = QueryMethodValueEvaluationContextAccessor.DEFAULT_CONTEXT_PROVIDER; this.queryPostProcessors = new ArrayList<>(); this.queryPostProcessors.add(collectingListener); this.methodInvocationListeners = new ArrayList<>(); this.projectionFactory = createProjectionFactory(); } + EvaluationContextProvider getEvaluationContextProvider() { + return evaluationContextProvider; + } + /** * Set whether the repository method metadata should be exposed by the repository factory as a ThreadLocal for * retrieval via the {@code RepositoryMethodContext} class. This is useful if an advised object needs to obtain @@ -143,7 +162,7 @@ public void setExposeMetadata(boolean exposeMetadata) { } /** - * Sets the strategy of how to lookup a query to execute finders. + * Sets the strategy of how to look up a query to execute finders. * * @param key */ @@ -156,12 +175,12 @@ public void setQueryLookupStrategyKey(Key key) { * * @param namedQueries the namedQueries to set */ - public void setNamedQueries(NamedQueries namedQueries) { + public void setNamedQueries(@Nullable NamedQueries namedQueries) { this.namedQueries = namedQueries == null ? PropertiesBasedNamedQueries.EMPTY : namedQueries; } @Override - public void setBeanClassLoader(ClassLoader classLoader) { + public void setBeanClassLoader(@Nullable ClassLoader classLoader) { this.classLoader = classLoader == null ? org.springframework.util.ClassUtils.getDefaultClassLoader() : classLoader; this.projectionFactory = createProjectionFactory(); } @@ -172,15 +191,42 @@ public void setBeanFactory(BeanFactory beanFactory) throws BeansException { this.projectionFactory = createProjectionFactory(); } + @Override + public void setEnvironment(Environment environment) { + this.environment = environment; + } + + @Override + public Environment getEnvironment() { + + if (this.environment == null) { + this.environment = new StandardEnvironment(); + } + + return this.environment; + } + /** * Sets the {@link QueryMethodEvaluationContextProvider} to be used to evaluate SpEL expressions in manually defined * queries. * * @param evaluationContextProvider can be {@literal null}, defaults to * {@link QueryMethodEvaluationContextProvider#DEFAULT}. + * @deprecated since 3.4, use {@link #setEvaluationContextProvider(EvaluationContextProvider)} instead. */ - public void setEvaluationContextProvider(QueryMethodEvaluationContextProvider evaluationContextProvider) { - this.evaluationContextProvider = evaluationContextProvider == null ? QueryMethodEvaluationContextProvider.DEFAULT + @Deprecated(since = "3.4") + public void setEvaluationContextProvider(@Nullable QueryMethodEvaluationContextProvider evaluationContextProvider) { + setEvaluationContextProvider(evaluationContextProvider == null ? EvaluationContextProvider.DEFAULT + : evaluationContextProvider.getEvaluationContextProvider()); + } + + /** + * Sets the {@link EvaluationContextProvider} to be used to evaluate SpEL expressions in manually defined queries. + * + * @param evaluationContextProvider can be {@literal null}, defaults to {@link EvaluationContextProvider#DEFAULT}. + */ + public void setEvaluationContextProvider(@Nullable EvaluationContextProvider evaluationContextProvider) { + this.evaluationContextProvider = evaluationContextProvider == null ? EvaluationContextProvider.DEFAULT : evaluationContextProvider; } @@ -191,15 +237,15 @@ public void setEvaluationContextProvider(QueryMethodEvaluationContextProvider ev * @param repositoryBaseClass the repository base class to back the repository proxy, can be {@literal null}. * @since 1.11 */ - public void setRepositoryBaseClass(Class repositoryBaseClass) { - this.repositoryBaseClass = Optional.ofNullable(repositoryBaseClass); + public void setRepositoryBaseClass(@Nullable Class repositoryBaseClass) { + this.repositoryBaseClass = repositoryBaseClass; } /** * Adds a {@link QueryCreationListener} to the factory to plug in functionality triggered right after creation of * {@link RepositoryQuery} instances. * - * @param listener + * @param listener the listener to add. */ public void addQueryCreationListener(QueryCreationListener listener) { @@ -211,7 +257,7 @@ public void addQueryCreationListener(QueryCreationListener listener) { * Adds a {@link RepositoryMethodInvocationListener} to the factory to plug in functionality triggered right after * running {@link RepositoryQuery query methods} and {@link Method fragment methods}. * - * @param listener + * @param listener the listener to add. * @since 2.4 */ public void addInvocationListener(RepositoryMethodInvocationListener listener) { @@ -225,7 +271,7 @@ public void addInvocationListener(RepositoryMethodInvocationListener listener) { * the proxy gets created. Note that the {@link QueryExecutorMethodInterceptor} will be added to the proxy * after the {@link RepositoryProxyPostProcessor}s are considered. * - * @param processor + * @param processor the post-processor to add. */ public void addRepositoryProxyPostProcessor(RepositoryProxyPostProcessor processor) { @@ -236,8 +282,8 @@ public void addRepositoryProxyPostProcessor(RepositoryProxyPostProcessor process /** * Creates {@link RepositoryFragments} based on {@link RepositoryMetadata} to add repository-specific extensions. * - * @param metadata - * @return + * @param metadata the repository metadata to use. + * @return fragment composition. */ protected RepositoryFragments getRepositoryFragments(RepositoryMetadata metadata) { return RepositoryFragments.empty(); @@ -246,8 +292,8 @@ protected RepositoryFragments getRepositoryFragments(RepositoryMetadata metadata /** * Creates {@link RepositoryComposition} based on {@link RepositoryMetadata} for repository-specific method handling. * - * @param metadata - * @return + * @param metadata the repository metadata to use. + * @return repository composition. */ private RepositoryComposition getRepositoryComposition(RepositoryMetadata metadata) { return RepositoryComposition.fromMetadata(metadata); @@ -257,7 +303,7 @@ private RepositoryComposition getRepositoryComposition(RepositoryMetadata metada * Returns a repository instance for the given interface. * * @param repositoryInterface must not be {@literal null}. - * @return + * @return the implemented repository interface. */ public T getRepository(Class repositoryInterface) { return getRepository(repositoryInterface, RepositoryFragments.empty()); @@ -269,7 +315,7 @@ public T getRepository(Class repositoryInterface) { * * @param repositoryInterface must not be {@literal null}. * @param customImplementation must not be {@literal null}. - * @return + * @return the implemented repository interface. */ public T getRepository(Class repositoryInterface, Object customImplementation) { return getRepository(repositoryInterface, RepositoryFragments.just(customImplementation)); @@ -281,7 +327,7 @@ public T getRepository(Class repositoryInterface, Object customImplementa * * @param repositoryInterface must not be {@literal null}. * @param fragments must not be {@literal null}. - * @return + * @return the implemented repository interface. * @since 2.0 */ @SuppressWarnings({ "unchecked" }) @@ -298,7 +344,9 @@ public T getRepository(Class repositoryInterface, RepositoryFragments fra StartupStep repositoryInit = onEvent(applicationStartup, "spring.data.repository.init", repositoryInterface); - repositoryBaseClass.ifPresent(it -> repositoryInit.tag("baseClass", it.getName())); + if (repositoryBaseClass != null) { + repositoryInit.tag("baseClass", repositoryBaseClass.getName()); + } StartupStep repositoryMetadataStep = onEvent(applicationStartup, "spring.data.repository.metadata", repositoryInterface); @@ -384,7 +432,9 @@ public T getRepository(Class repositoryInterface, RepositoryFragments fra } Optional queryLookupStrategy = getQueryLookupStrategy(queryLookupStrategyKey, - evaluationContextProvider); + new ValueExpressionDelegate( + new QueryMethodValueEvaluationContextAccessor(getEnvironment(), evaluationContextProvider), + VALUE_PARSER)); result.addAdvice(new QueryExecutorMethodInterceptor(information, getProjectionFactory(), queryLookupStrategy, namedQueries, queryPostProcessors, methodInvocationListeners)); @@ -412,7 +462,7 @@ public T getRepository(Class repositoryInterface, RepositoryFragments fra */ protected ProjectionFactory getProjectionFactory(ClassLoader classLoader, BeanFactory beanFactory) { - SpelAwareProxyProjectionFactory factory = new SpelAwareProxyProjectionFactory(); + SpelAwareProxyProjectionFactory factory = new SpelAwareProxyProjectionFactory(EXPRESSION_PARSER); factory.setBeanClassLoader(classLoader); factory.setBeanFactory(beanFactory); @@ -476,7 +526,7 @@ private RepositoryInformation getRepositoryInformation(RepositoryMetadata metada return repositoryInformationCache.computeIfAbsent(cacheKey, key -> { - Class baseClass = repositoryBaseClass.orElse(getRepositoryBaseClass(metadata)); + Class baseClass = repositoryBaseClass != null ? repositoryBaseClass : getRepositoryBaseClass(metadata); return new DefaultRepositoryInformation(metadata, baseClass, composition); }); @@ -531,12 +581,34 @@ protected ProjectionFactory getProjectionFactory() { * @param evaluationContextProvider will never be {@literal null}. * @return the {@link QueryLookupStrategy} to use or {@literal null} if no queries should be looked up. * @since 1.9 + * @deprecated since 3.4, use {@link #getQueryLookupStrategy(Key, ValueExpressionDelegate)} instead to support + * {@link org.springframework.data.expression.ValueExpression} in query methods. */ + @Deprecated(since = "3.4") protected Optional getQueryLookupStrategy(@Nullable Key key, QueryMethodEvaluationContextProvider evaluationContextProvider) { return Optional.empty(); } + /** + * Returns the {@link QueryLookupStrategy} for the given {@link Key} and {@link ValueExpressionDelegate}. Favor + * implementing this method over {@link #getQueryLookupStrategy(Key, QueryMethodEvaluationContextProvider)} for + * extended {@link org.springframework.data.expression.ValueExpression} support. + *

+ * This method delegates to {@link #getQueryLookupStrategy(Key, QueryMethodEvaluationContextProvider)} unless + * overridden. + * + * @param key can be {@literal null}. + * @param valueExpressionDelegate will never be {@literal null}. + * @return the {@link QueryLookupStrategy} to use or {@literal null} if no queries should be looked up. + * @since 3.4 + */ + protected Optional getQueryLookupStrategy(@Nullable Key key, + ValueExpressionDelegate valueExpressionDelegate) { + return getQueryLookupStrategy(key, + new ExtensionAwareQueryMethodEvaluationContextProvider(evaluationContextProvider)); + } + /** * Validates the given repository interface as well as the given custom implementation. * diff --git a/src/main/java/org/springframework/data/repository/core/support/TransactionalRepositoryFactoryBeanSupport.java b/src/main/java/org/springframework/data/repository/core/support/TransactionalRepositoryFactoryBeanSupport.java index 2bd5163353..3b45a45157 100644 --- a/src/main/java/org/springframework/data/repository/core/support/TransactionalRepositoryFactoryBeanSupport.java +++ b/src/main/java/org/springframework/data/repository/core/support/TransactionalRepositoryFactoryBeanSupport.java @@ -101,6 +101,7 @@ protected final RepositoryFactorySupport createRepositoryFactory() { */ protected abstract RepositoryFactorySupport doCreateRepositoryFactory(); + @Override public void setBeanFactory(BeanFactory beanFactory) { Assert.isInstanceOf(ListableBeanFactory.class, beanFactory); diff --git a/src/main/java/org/springframework/data/repository/query/CachingValueExpressionDelegate.java b/src/main/java/org/springframework/data/repository/query/CachingValueExpressionDelegate.java new file mode 100644 index 0000000000..e961d133ed --- /dev/null +++ b/src/main/java/org/springframework/data/repository/query/CachingValueExpressionDelegate.java @@ -0,0 +1,66 @@ +/* + * 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.repository.query; + +import org.springframework.data.expression.ValueExpression; +import org.springframework.data.expression.ValueExpressionParser; +import org.springframework.expression.ParseException; +import org.springframework.util.ConcurrentLruCache; + +/** + * Caching variant of {@link ValueExpressionDelegate}. + * + * @author Mark Paluch + * @since 3.4 + */ +public class CachingValueExpressionDelegate extends ValueExpressionDelegate { + + private final ConcurrentLruCache expressionCache; + + /** + * Creates a new {@link CachingValueExpressionDelegate} given {@link ValueExpressionDelegate}. + * + * @param delegate must not be {@literal null}. + */ + public CachingValueExpressionDelegate(ValueExpressionDelegate delegate) { + super(delegate); + this.expressionCache = new ConcurrentLruCache<>(256, delegate.getValueExpressionParser()::parse); + } + + /** + * Creates a new {@link CachingValueExpressionDelegate} given {@link QueryMethodValueEvaluationContextAccessor} and + * {@link ValueExpressionParser}. + * + * @param providerFactory the factory to create value evaluation context providers, must not be {@code null}. + * @param valueExpressionParser the parser to parse expression strings into value expressions, must not be + * {@code null}. + */ + public CachingValueExpressionDelegate(QueryMethodValueEvaluationContextAccessor providerFactory, + ValueExpressionParser valueExpressionParser) { + super(providerFactory, valueExpressionParser); + this.expressionCache = new ConcurrentLruCache<>(256, valueExpressionParser::parse); + } + + @Override + public ValueExpressionParser getValueExpressionParser() { + return this; + } + + @Override + public ValueExpression parse(String expressionString) throws ParseException { + return expressionCache.get(expressionString); + } +} diff --git a/src/main/java/org/springframework/data/repository/query/ExtensionAwareQueryMethodEvaluationContextProvider.java b/src/main/java/org/springframework/data/repository/query/ExtensionAwareQueryMethodEvaluationContextProvider.java index 2897872fe0..bc30d81564 100644 --- a/src/main/java/org/springframework/data/repository/query/ExtensionAwareQueryMethodEvaluationContextProvider.java +++ b/src/main/java/org/springframework/data/repository/query/ExtensionAwareQueryMethodEvaluationContextProvider.java @@ -15,18 +15,16 @@ */ package org.springframework.data.repository.query; -import java.util.HashMap; import java.util.List; -import java.util.Map; import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.data.expression.ValueEvaluationContext; +import org.springframework.data.spel.EvaluationContextProvider; import org.springframework.data.spel.ExpressionDependencies; -import org.springframework.data.spel.ExtensionAwareEvaluationContextProvider; import org.springframework.data.spel.spi.EvaluationContextExtension; import org.springframework.expression.EvaluationContext; -import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.util.Assert; -import org.springframework.util.StringUtils; /** * An {@link QueryMethodEvaluationContextProvider} that assembles an {@link EvaluationContext} from a list of @@ -38,10 +36,26 @@ * @author Jens Schauder * @author Johannes Englmeier * @since 1.9 + * @deprecated since 3.4 in favor of {@link QueryMethodValueEvaluationContextAccessor}. */ +@Deprecated(since = "3.4") public class ExtensionAwareQueryMethodEvaluationContextProvider implements QueryMethodEvaluationContextProvider { - private final ExtensionAwareEvaluationContextProvider delegate; + private final QueryMethodValueEvaluationContextAccessor delegate; + + /** + * Creates a new {@link ExtensionAwareQueryMethodEvaluationContextProvider}. + * + * @param evaluationContextProvider to lookup the {@link EvaluationContextExtension}s from, must not be + * {@literal null}. + */ + public ExtensionAwareQueryMethodEvaluationContextProvider(EvaluationContextProvider evaluationContextProvider) { + + Assert.notNull(evaluationContextProvider, "EvaluationContextProvider must not be null"); + + this.delegate = new QueryMethodValueEvaluationContextAccessor(QueryMethodValueEvaluationContextAccessor.ENVIRONMENT, + evaluationContextProvider); + } /** * Creates a new {@link ExtensionAwareQueryMethodEvaluationContextProvider}. @@ -53,7 +67,9 @@ public ExtensionAwareQueryMethodEvaluationContextProvider(ListableBeanFactory be Assert.notNull(beanFactory, "ListableBeanFactory must not be null"); - this.delegate = new ExtensionAwareEvaluationContextProvider(beanFactory); + this.delegate = beanFactory instanceof ApplicationContext ctx ? new QueryMethodValueEvaluationContextAccessor(ctx) + : new QueryMethodValueEvaluationContextAccessor(QueryMethodValueEvaluationContextAccessor.ENVIRONMENT, + beanFactory); } /** @@ -66,55 +82,39 @@ public ExtensionAwareQueryMethodEvaluationContextProvider(List> EvaluationContext getEvaluationContext(T parameters, Object[] parameterValues) { - - StandardEvaluationContext evaluationContext = delegate.getEvaluationContext(parameterValues); + ExtensionAwareQueryMethodEvaluationContextProvider(QueryMethodValueEvaluationContextAccessor delegate) { + this.delegate = delegate; + } - evaluationContext.setVariables(collectVariables(parameters, parameterValues)); + @Override + public EvaluationContextProvider getEvaluationContextProvider() { + return getDelegate().getEvaluationContextProvider(); + } - return evaluationContext; + public QueryMethodValueEvaluationContextAccessor getDelegate() { + return delegate; } @Override - public > EvaluationContext getEvaluationContext(T parameters, Object[] parameterValues, - ExpressionDependencies dependencies) { - - StandardEvaluationContext evaluationContext = delegate.getEvaluationContext(parameterValues, dependencies); + public > EvaluationContext getEvaluationContext(T parameters, Object[] parameterValues) { - evaluationContext.setVariables(collectVariables(parameters, parameterValues)); + ValueEvaluationContext evaluationContext = delegate.create(parameters).getEvaluationContext(parameterValues); - return evaluationContext; + return evaluationContext.getRequiredEvaluationContext(); } - /** - * Exposes variables for all named parameters for the given arguments. Also exposes non-bindable parameters under the - * names of their types. - * - * @param parameters must not be {@literal null}. - * @param arguments must not be {@literal null}. - * @return - */ - static Map collectVariables(Parameters parameters, Object[] arguments) { - - Map variables = new HashMap<>(parameters.getNumberOfParameters(), 1.0f); - - parameters.stream()// - .filter(Parameter::isSpecialParameter)// - .forEach(it -> variables.put(// - StringUtils.uncapitalize(it.getType().getSimpleName()), // - arguments[it.getIndex()])); + @Override + public > EvaluationContext getEvaluationContext(T parameters, Object[] parameterValues, + ExpressionDependencies dependencies) { - parameters.stream()// - .filter(Parameter::isNamedParameter)// - .forEach(it -> variables.put(// - it.getName().orElseThrow(() -> new IllegalStateException("Should never occur")), // - arguments[it.getIndex()])); + ValueEvaluationContext evaluationContext = delegate.create(parameters).getEvaluationContext(parameterValues, + dependencies); - return variables; + return evaluationContext.getRequiredEvaluationContext(); } } diff --git a/src/main/java/org/springframework/data/repository/query/QueryMethodEvaluationContextProvider.java b/src/main/java/org/springframework/data/repository/query/QueryMethodEvaluationContextProvider.java index 3d4e165be8..76dcd38f2b 100644 --- a/src/main/java/org/springframework/data/repository/query/QueryMethodEvaluationContextProvider.java +++ b/src/main/java/org/springframework/data/repository/query/QueryMethodEvaluationContextProvider.java @@ -17,6 +17,7 @@ import java.util.Collections; +import org.springframework.data.spel.EvaluationContextProvider; import org.springframework.data.spel.ExpressionDependencies; import org.springframework.expression.EvaluationContext; @@ -27,7 +28,9 @@ * @author Oliver Gierke * @author Christoph Strobl * @since 1.9 + * @deprecated since 3.4 in favor of {@link QueryMethodValueEvaluationContextAccessor}. */ +@Deprecated(since = "3.4") public interface QueryMethodEvaluationContextProvider { QueryMethodEvaluationContextProvider DEFAULT = new ExtensionAwareQueryMethodEvaluationContextProvider( @@ -51,4 +54,9 @@ public interface QueryMethodEvaluationContextProvider { */ > EvaluationContext getEvaluationContext(T parameters, Object[] parameterValues, ExpressionDependencies dependencies); + + /** + * @return the underlying {@link EvaluationContextProvider}. + */ + EvaluationContextProvider getEvaluationContextProvider(); } diff --git a/src/main/java/org/springframework/data/repository/query/QueryMethodValueEvaluationContextAccessor.java b/src/main/java/org/springframework/data/repository/query/QueryMethodValueEvaluationContextAccessor.java new file mode 100644 index 0000000000..d4773afaf9 --- /dev/null +++ b/src/main/java/org/springframework/data/repository/query/QueryMethodValueEvaluationContextAccessor.java @@ -0,0 +1,269 @@ +/* + * 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.repository.query; + +import reactor.core.publisher.Mono; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.core.env.Environment; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.data.expression.ReactiveValueEvaluationContextProvider; +import org.springframework.data.expression.ValueEvaluationContext; +import org.springframework.data.expression.ValueEvaluationContextProvider; +import org.springframework.data.spel.EvaluationContextProvider; +import org.springframework.data.spel.ExpressionDependencies; +import org.springframework.data.spel.ExtensionAwareEvaluationContextProvider; +import org.springframework.data.spel.ReactiveEvaluationContextProvider; +import org.springframework.data.spel.ReactiveExtensionAwareEvaluationContextProvider; +import org.springframework.data.spel.spi.EvaluationContextExtension; +import org.springframework.data.spel.spi.ExtensionIdAware; +import org.springframework.data.util.ReactiveWrappers; +import org.springframework.expression.EvaluationContext; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Factory to create {@link ValueEvaluationContextProvider} instances. Supports its reactive variant + * {@link ReactiveValueEvaluationContextProvider} if the underlying {@link EvaluationContextProvider} is a reactive one. + * + * @author Mark Paluch + * @since 3.4 + */ +public class QueryMethodValueEvaluationContextAccessor { + + public static final EvaluationContextProvider DEFAULT_CONTEXT_PROVIDER = createEvaluationContextProvider( + Collections.emptyList()); + + static final StandardEnvironment ENVIRONMENT = new StandardEnvironment(); + + private final @Nullable Environment environment; + private final EvaluationContextProvider evaluationContextProvider; + + /** + * Creates a new {@link QueryMethodValueEvaluationContextAccessor} from {@link ApplicationContext}. + * + * @param context the application context to use, must not be {@literal null}. + */ + public QueryMethodValueEvaluationContextAccessor(ApplicationContext context) { + + Assert.notNull(context, "ApplicationContext must not be null"); + + this.environment = context.getEnvironment(); + this.evaluationContextProvider = createEvaluationContextProvider(context); + } + + /** + * Creates a new {@link QueryMethodValueEvaluationContextAccessor} from {@link Environment} and + * {@link ListableBeanFactory}. + * + * @param environment + * @param beanFactory the bean factory to use, must not be {@literal null}. + */ + public QueryMethodValueEvaluationContextAccessor(@Nullable Environment environment, ListableBeanFactory beanFactory) { + this(environment, createEvaluationContextProvider(beanFactory)); + } + + /** + * Creates a new {@link QueryMethodValueEvaluationContextAccessor} from {@link Environment} and + * {@link EvaluationContextProvider}. + * + * @param environment + * @param evaluationContextProvider the underlying {@link EvaluationContextProvider} to use, must not be + * {@literal null}. + */ + public QueryMethodValueEvaluationContextAccessor(@Nullable Environment environment, + EvaluationContextProvider evaluationContextProvider) { + + Assert.notNull(evaluationContextProvider, "EvaluationContextProvider must not be null"); + + this.environment = environment; + this.evaluationContextProvider = evaluationContextProvider; + } + + /** + * Creates a new {@link QueryMethodValueEvaluationContextAccessor} for the given {@link EvaluationContextExtension}s. + * + * @param environment + * @param extensions must not be {@literal null}. + */ + public QueryMethodValueEvaluationContextAccessor(@Nullable Environment environment, + Collection extensions) { + + Assert.notNull(extensions, "EvaluationContextExtensions must not be null"); + + this.environment = environment; + this.evaluationContextProvider = createEvaluationContextProvider(extensions); + } + + private static EvaluationContextProvider createEvaluationContextProvider(ListableBeanFactory factory) { + + return ReactiveWrappers.isAvailable(ReactiveWrappers.ReactiveLibrary.PROJECT_REACTOR) + ? new ReactiveExtensionAwareEvaluationContextProvider(factory) + : new ExtensionAwareEvaluationContextProvider(factory); + + } + + private static EvaluationContextProvider createEvaluationContextProvider( + Collection extensions) { + + return ReactiveWrappers.isAvailable(ReactiveWrappers.ReactiveLibrary.PROJECT_REACTOR) + ? new ReactiveExtensionAwareEvaluationContextProvider(extensions) + : new ExtensionAwareEvaluationContextProvider(extensions); + } + + /** + * Creates a default {@link QueryMethodValueEvaluationContextAccessor} using the + * {@link org.springframework.core.env.StandardEnvironment} and extension-less + * {@link org.springframework.data.spel.EvaluationContextProvider}. + * + * @return a default {@link ValueExpressionDelegate}. + */ + public static QueryMethodValueEvaluationContextAccessor create() { + return new QueryMethodValueEvaluationContextAccessor(ENVIRONMENT, DEFAULT_CONTEXT_PROVIDER); + } + + EvaluationContextProvider getEvaluationContextProvider() { + return evaluationContextProvider; + } + + /** + * Creates a new {@link ValueEvaluationContextProvider} for the given {@link Parameters}. + * + * @param parameters must not be {@literal null}. + * @return a new {@link ValueEvaluationContextProvider} for the given {@link Parameters}. + */ + public ValueEvaluationContextProvider create(Parameters parameters) { + + Assert.notNull(parameters, "Parameters must not be null"); + + if (ReactiveWrappers.isAvailable(ReactiveWrappers.ReactiveLibrary.PROJECT_REACTOR)) { + if (evaluationContextProvider instanceof ReactiveEvaluationContextProvider reactive) { + return new DefaultReactiveQueryMethodValueEvaluationContextProvider(environment, parameters, reactive); + } + } + + return new DefaultQueryMethodValueEvaluationContextProvider(environment, parameters, evaluationContextProvider); + } + + /** + * Exposes variables for all named parameters for the given arguments. Also exposes non-bindable parameters under the + * names of their types. + * + * @param parameters must not be {@literal null}. + * @param arguments must not be {@literal null}. + * @return + */ + static Map collectVariables(Parameters parameters, Object[] arguments) { + + if (parameters.getNumberOfParameters() != arguments.length) { + + throw new IllegalArgumentException( + "Number of method parameters (%d) must match the number of method invocation arguments (%d)" + .formatted(parameters.getNumberOfParameters(), arguments.length)); + } + + Map variables = new HashMap<>(parameters.getNumberOfParameters(), 1.0f); + + for (Parameter parameter : parameters) { + + if (parameter.isSpecialParameter()) { + variables.put(// + StringUtils.uncapitalize(parameter.getType().getSimpleName()), // + arguments[parameter.getIndex()]); + } + + if (parameter.isNamedParameter()) { + variables.put(parameter.getRequiredName(), // + arguments[parameter.getIndex()]); + } + } + + return variables; + } + + /** + * Imperative {@link ValueEvaluationContextProvider} variant. + */ + static class DefaultQueryMethodValueEvaluationContextProvider implements ValueEvaluationContextProvider { + + final @Nullable Environment environment; + final Parameters parameters; + final EvaluationContextProvider delegate; + + DefaultQueryMethodValueEvaluationContextProvider(@Nullable Environment environment, Parameters parameters, + EvaluationContextProvider delegate) { + this.environment = environment; + this.parameters = parameters; + this.delegate = delegate; + } + + @Override + public ValueEvaluationContext getEvaluationContext(@Nullable Object rootObject) { + return doGetEvaluationContext(delegate.getEvaluationContext(rootObject), rootObject); + } + + @Override + public ValueEvaluationContext getEvaluationContext(@Nullable Object rootObject, + ExpressionDependencies dependencies) { + return doGetEvaluationContext(delegate.getEvaluationContext(rootObject, dependencies), rootObject); + } + + ValueEvaluationContext doGetEvaluationContext(EvaluationContext evaluationContext, @Nullable Object rootObject) { + + if (rootObject instanceof Object[] parameterValues) { + collectVariables(parameters, parameterValues).forEach(evaluationContext::setVariable); + } + + return ValueEvaluationContext.of(environment, evaluationContext); + } + + } + + /** + * Reactive {@link ValueEvaluationContextProvider} extension to + * {@link DefaultQueryMethodValueEvaluationContextProvider}. + */ + static class DefaultReactiveQueryMethodValueEvaluationContextProvider + extends DefaultQueryMethodValueEvaluationContextProvider implements ReactiveValueEvaluationContextProvider { + + private final ReactiveEvaluationContextProvider delegate; + + DefaultReactiveQueryMethodValueEvaluationContextProvider(@Nullable Environment environment, + Parameters parameters, ReactiveEvaluationContextProvider delegate) { + super(environment, parameters, delegate); + this.delegate = delegate; + } + + @Override + public Mono getEvaluationContextLater(@Nullable Object rootObject) { + return delegate.getEvaluationContextLater(rootObject).map(it -> doGetEvaluationContext(it, rootObject)); + } + + @Override + public Mono getEvaluationContextLater(@Nullable Object rootObject, + ExpressionDependencies dependencies) { + return delegate.getEvaluationContextLater(rootObject, dependencies) + .map(it -> doGetEvaluationContext(it, rootObject)); + } + } +} diff --git a/src/main/java/org/springframework/data/repository/query/ReactiveExtensionAwareQueryMethodEvaluationContextProvider.java b/src/main/java/org/springframework/data/repository/query/ReactiveExtensionAwareQueryMethodEvaluationContextProvider.java index 1a1d2a8b07..283c091882 100644 --- a/src/main/java/org/springframework/data/repository/query/ReactiveExtensionAwareQueryMethodEvaluationContextProvider.java +++ b/src/main/java/org/springframework/data/repository/query/ReactiveExtensionAwareQueryMethodEvaluationContextProvider.java @@ -20,13 +20,13 @@ import java.util.List; import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.data.expression.ReactiveValueEvaluationContextProvider; +import org.springframework.data.expression.ValueEvaluationContext; +import org.springframework.data.spel.EvaluationContextProvider; import org.springframework.data.spel.ExpressionDependencies; -import org.springframework.data.spel.ReactiveExtensionAwareEvaluationContextProvider; import org.springframework.data.spel.spi.EvaluationContextExtension; import org.springframework.data.spel.spi.ExtensionIdAware; import org.springframework.expression.EvaluationContext; -import org.springframework.expression.spel.support.StandardEvaluationContext; -import org.springframework.util.Assert; /** * An reactive {@link QueryMethodEvaluationContextProvider} that assembles an {@link EvaluationContext} from a list of @@ -35,12 +35,13 @@ * * @author Mark Paluch * @since 2.4 + * @deprecated since 3.4 in favor of {@link QueryMethodValueEvaluationContextAccessor}. */ +@Deprecated(since = "3.4") public class ReactiveExtensionAwareQueryMethodEvaluationContextProvider + extends ExtensionAwareQueryMethodEvaluationContextProvider implements ReactiveQueryMethodEvaluationContextProvider { - private final ReactiveExtensionAwareEvaluationContextProvider delegate; - /** * Create a new {@link ReactiveExtensionAwareQueryMethodEvaluationContextProvider}. * @@ -48,10 +49,7 @@ public class ReactiveExtensionAwareQueryMethodEvaluationContextProvider * be {@literal null}. */ public ReactiveExtensionAwareQueryMethodEvaluationContextProvider(ListableBeanFactory beanFactory) { - - Assert.notNull(beanFactory, "ListableBeanFactory must not be null"); - - this.delegate = new ReactiveExtensionAwareEvaluationContextProvider(beanFactory); + super(beanFactory); } /** @@ -62,61 +60,39 @@ public ReactiveExtensionAwareQueryMethodEvaluationContextProvider(ListableBeanFa * @param extensions must not be {@literal null}. */ public ReactiveExtensionAwareQueryMethodEvaluationContextProvider(List extensions) { - - Assert.notNull(extensions, "EvaluationContextExtensions must not be null"); - - this.delegate = new ReactiveExtensionAwareEvaluationContextProvider(extensions); - } - - @Override - public > EvaluationContext getEvaluationContext(T parameters, Object[] parameterValues) { - - EvaluationContext evaluationContext = delegate.getEvaluationContext(parameterValues); - - if (evaluationContext instanceof StandardEvaluationContext) { - ((StandardEvaluationContext) evaluationContext).setVariables( - ExtensionAwareQueryMethodEvaluationContextProvider.collectVariables(parameters, parameterValues)); - } - - return evaluationContext; + super(new QueryMethodValueEvaluationContextAccessor(QueryMethodValueEvaluationContextAccessor.ENVIRONMENT, + extensions)); } - @Override - public > EvaluationContext getEvaluationContext(T parameters, Object[] parameterValues, - ExpressionDependencies dependencies) { - - EvaluationContext evaluationContext = delegate.getEvaluationContext(parameterValues, dependencies); - - if (evaluationContext instanceof StandardEvaluationContext) { - ((StandardEvaluationContext) evaluationContext).setVariables( - ExtensionAwareQueryMethodEvaluationContextProvider.collectVariables(parameters, parameterValues)); - } - - return evaluationContext; + /** + * Creates a new {@link ReactiveExtensionAwareQueryMethodEvaluationContextProvider}. + * + * @param evaluationContextProvider to lookup the {@link EvaluationContextExtension}s from, must not be + * {@literal null}. + */ + public ReactiveExtensionAwareQueryMethodEvaluationContextProvider( + EvaluationContextProvider evaluationContextProvider) { + super(new QueryMethodValueEvaluationContextAccessor(QueryMethodValueEvaluationContextAccessor.ENVIRONMENT, + evaluationContextProvider)); } @Override public > Mono getEvaluationContextLater(T parameters, Object[] parameterValues) { - Mono evaluationContext = delegate.getEvaluationContextLater(parameterValues); - - return evaluationContext - .doOnNext(it -> it.setVariables( - ExtensionAwareQueryMethodEvaluationContextProvider.collectVariables(parameters, parameterValues))) - .cast(EvaluationContext.class); + return createProvider(parameters).getEvaluationContextLater(parameterValues) + .map(ValueEvaluationContext::getRequiredEvaluationContext); } @Override public > Mono getEvaluationContextLater(T parameters, Object[] parameterValues, ExpressionDependencies dependencies) { - Mono evaluationContext = delegate.getEvaluationContextLater(parameterValues, - dependencies); + return createProvider(parameters).getEvaluationContextLater(parameterValues, dependencies) + .map(ValueEvaluationContext::getRequiredEvaluationContext); + } - return evaluationContext - .doOnNext(it -> it.setVariables( - ExtensionAwareQueryMethodEvaluationContextProvider.collectVariables(parameters, parameterValues))) - .cast(EvaluationContext.class); + private ReactiveValueEvaluationContextProvider createProvider(Parameters parameters) { + return (ReactiveValueEvaluationContextProvider) getDelegate().create(parameters); } } diff --git a/src/main/java/org/springframework/data/repository/query/ReactiveQueryMethodEvaluationContextProvider.java b/src/main/java/org/springframework/data/repository/query/ReactiveQueryMethodEvaluationContextProvider.java index 02bba3d022..9e10bc0d82 100644 --- a/src/main/java/org/springframework/data/repository/query/ReactiveQueryMethodEvaluationContextProvider.java +++ b/src/main/java/org/springframework/data/repository/query/ReactiveQueryMethodEvaluationContextProvider.java @@ -28,7 +28,9 @@ * * @author Mark Paluch * @since 2.4 + * @deprecated since 4.0 in favor of {@link QueryMethodValueEvaluationContextAccessor}. */ +@Deprecated(since = "4.0") public interface ReactiveQueryMethodEvaluationContextProvider extends QueryMethodEvaluationContextProvider { ReactiveQueryMethodEvaluationContextProvider DEFAULT = new ReactiveExtensionAwareQueryMethodEvaluationContextProvider( diff --git a/src/main/java/org/springframework/data/repository/query/SpelEvaluator.java b/src/main/java/org/springframework/data/repository/query/SpelEvaluator.java index 8142187910..7d1a5f5eef 100644 --- a/src/main/java/org/springframework/data/repository/query/SpelEvaluator.java +++ b/src/main/java/org/springframework/data/repository/query/SpelEvaluator.java @@ -35,7 +35,9 @@ * @author Oliver Gierke * @since 2.1 * @see SpelQueryContext#parse(String) + * @deprecated since 3.3, use {@link ValueExpressionQueryRewriter} instead. */ +@Deprecated(since = "3.3") public class SpelEvaluator { private static final SpelExpressionParser PARSER = new SpelExpressionParser(); diff --git a/src/main/java/org/springframework/data/repository/query/SpelQueryContext.java b/src/main/java/org/springframework/data/repository/query/SpelQueryContext.java index ac35839ebb..ebdc8ad0cd 100644 --- a/src/main/java/org/springframework/data/repository/query/SpelQueryContext.java +++ b/src/main/java/org/springframework/data/repository/query/SpelQueryContext.java @@ -58,7 +58,9 @@ * @author Gerrit Meier * @author Mark Paluch * @since 2.1 + * @deprecated since 3.3, use {@link ValueExpressionQueryRewriter.QueryExpressionEvaluator} instead. */ +@Deprecated(since = "3.3") public class SpelQueryContext { private static final String SPEL_PATTERN_STRING = "([:?])#\\{([^}]+)}"; diff --git a/src/main/java/org/springframework/data/repository/query/ValueExpressionDelegate.java b/src/main/java/org/springframework/data/repository/query/ValueExpressionDelegate.java new file mode 100644 index 0000000000..bc50cf87c5 --- /dev/null +++ b/src/main/java/org/springframework/data/repository/query/ValueExpressionDelegate.java @@ -0,0 +1,87 @@ +/* + * 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.repository.query; + +import org.springframework.data.expression.ValueEvaluationContext; +import org.springframework.data.expression.ValueEvaluationContextProvider; +import org.springframework.data.expression.ValueExpression; +import org.springframework.data.expression.ValueExpressionParser; +import org.springframework.expression.ParseException; + +/** + * Delegate to provide a {@link ValueExpressionParser} along with a context factory. + *

+ * Subclasses can customize parsing behavior. + * + * @author Mark Paluch + */ +public class ValueExpressionDelegate implements ValueExpressionParser { + + private final QueryMethodValueEvaluationContextAccessor contextAccessor; + private final ValueExpressionParser valueExpressionParser; + + /** + * Creates a new {@link ValueExpressionDelegate} given {@link QueryMethodValueEvaluationContextAccessor} and + * {@link ValueExpressionParser}. + * + * @param contextAccessor the factory to create value evaluation context providers, must not be {@code null}. + * @param valueExpressionParser the parser to parse expression strings into value expressions, must not be + * {@code null}. + */ + public ValueExpressionDelegate(QueryMethodValueEvaluationContextAccessor contextAccessor, + ValueExpressionParser valueExpressionParser) { + this.contextAccessor = contextAccessor; + this.valueExpressionParser = valueExpressionParser; + } + + ValueExpressionDelegate(ValueExpressionDelegate original) { + this.contextAccessor = original.contextAccessor; + this.valueExpressionParser = original.valueExpressionParser; + } + + /** + * Creates a default {@link ValueExpressionDelegate} using the + * {@link org.springframework.core.env.StandardEnvironment}, a default {@link ValueExpression} and extension-less + * {@link org.springframework.data.spel.EvaluationContextProvider}. + * + * @return a default {@link ValueExpressionDelegate}. + */ + public static ValueExpressionDelegate create() { + return new ValueExpressionDelegate(QueryMethodValueEvaluationContextAccessor.create(), + ValueExpressionParser.create()); + } + + /** + * Creates a {@link ValueEvaluationContextProvider} for query method {@link Parameters} for later creation of a + * {@link ValueEvaluationContext} based on the actual method parameter values. The resulting + * {@link ValueEvaluationContextProvider} is only valid for the given parameters + * + * @param parameters the query method parameters to use. + * @return + */ + public ValueEvaluationContextProvider createValueContextProvider(Parameters parameters) { + return contextAccessor.create(parameters); + } + + public ValueExpressionParser getValueExpressionParser() { + return valueExpressionParser; + } + + @Override + public ValueExpression parse(String expressionString) throws ParseException { + return valueExpressionParser.parse(expressionString); + } +} diff --git a/src/main/java/org/springframework/data/repository/query/ValueExpressionQueryRewriter.java b/src/main/java/org/springframework/data/repository/query/ValueExpressionQueryRewriter.java new file mode 100644 index 0000000000..e4b1e48fc6 --- /dev/null +++ b/src/main/java/org/springframework/data/repository/query/ValueExpressionQueryRewriter.java @@ -0,0 +1,423 @@ +/* + * Copyright 2018-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.repository.query; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.data.domain.Range; +import org.springframework.data.domain.Range.Bound; +import org.springframework.data.expression.ValueEvaluationContext; +import org.springframework.data.expression.ValueEvaluationContextProvider; +import org.springframework.data.expression.ValueExpression; +import org.springframework.data.expression.ValueExpressionParser; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * A {@literal ValueExpressionQueryRewriter} is able to detect Value expressions in a query string and to replace them + * with bind variables. + *

+ * Result of the parse process is a {@link ParsedQuery} which provides the transformed query string. Alternatively and + * preferred one may provide a {@link QueryMethodEvaluationContextProvider} via + * {@link #withEvaluationContextAccessor(QueryMethodValueEvaluationContextAccessor)} which will yield the more powerful + * {@link EvaluatingValueExpressionQueryRewriter}. + *

+ * Typical usage looks like + * + *

+ * 
+     ExpressionQueryRewriter.ParsedQuery parsed = ExpressionQueryRewriter
+         .of(valueExpressionParser, (counter, expression) -> String.format("__$synthetic$__%d", counter), String::concat)
+         .withEvaluationContextProvider(evaluationContextProviderFactory);
+
+     ExpressionQueryRewriter.QueryExpressionEvaluator evaluator = queryContext.parse(query, queryMethod.getParameters());
+
+     evaluator.evaluate(objects).forEach(parameterMap::addValue);
+ * 
+ * 
+ * + * @author Jens Schauder + * @author Gerrit Meier + * @author Mark Paluch + * @since 3.3 + * @see ValueExpression + */ +public class ValueExpressionQueryRewriter { + + private static final Pattern EXPRESSION_PATTERN = Pattern.compile("([:?])([#$]\\{[^}]+})"); + + private final ValueExpressionParser expressionParser; + + /** + * A function from the index of a Value expression in a query and the actual SpEL expression to the parameter name to + * be used in place of the SpEL expression. A typical implementation is expected to look like + * (index, spel) -> "__some_placeholder_" + index + */ + private final BiFunction parameterNameSource; + + /** + * A function from a prefix used to demarcate a SpEL expression in a query and a parameter name as returned from + * {@link #parameterNameSource} to a {@literal String} to be used as a replacement of the SpEL in the query. The + * returned value should normally be interpretable as a bind parameter by the underlying persistence mechanism. A + * typical implementation is expected to look like (prefix, name) -> prefix + name or + * (prefix, name) -> "{" + name + "}" + */ + private final BiFunction replacementSource; + + private ValueExpressionQueryRewriter(ValueExpressionParser expressionParser, + BiFunction parameterNameSource, BiFunction replacementSource) { + + Assert.notNull(expressionParser, "ValueExpressionParser must not be null"); + Assert.notNull(parameterNameSource, "Parameter name source must not be null"); + Assert.notNull(replacementSource, "Replacement source must not be null"); + + this.parameterNameSource = parameterNameSource; + this.replacementSource = replacementSource; + this.expressionParser = expressionParser; + } + + public static ValueExpressionQueryRewriter of(ValueExpressionParser expressionParser, + BiFunction parameterNameSource, BiFunction replacementSource) { + return new ValueExpressionQueryRewriter(expressionParser, parameterNameSource, replacementSource); + } + + /** + * Parses the query for {@link org.springframework.data.expression.ValueExpression value expressions} using the + * pattern: + * + *
+	 * <prefix>#{<spel>}
+	 * <prefix>${<property placeholder>}
+	 * 
+ *

+ * with prefix being the character ':' or '?'. Parsing honors quoted {@literal String}s enclosed in single or double + * quotation marks. + * + * @param query a query containing SpEL expressions in the format described above. Must not be {@literal null}. + * @return A {@link ParsedQuery} which makes the query with SpEL expressions replaced by bind parameters and a map + * from bind parameter to SpEL expression available. Guaranteed to be not {@literal null}. + */ + public ParsedQuery parse(String query) { + return new ParsedQuery(expressionParser, query); + } + + /** + * Creates a {@link EvaluatingValueExpressionQueryRewriter} from the current one and the given + * {@link QueryMethodValueEvaluationContextAccessor}. + * + * @param factory must not be {@literal null}. + * @return + */ + public EvaluatingValueExpressionQueryRewriter withEvaluationContextAccessor( + QueryMethodValueEvaluationContextAccessor factory) { + + Assert.notNull(factory, "QueryMethodValueEvaluationContextAccessor must not be null"); + + return new EvaluatingValueExpressionQueryRewriter(expressionParser, factory, parameterNameSource, + replacementSource); + } + + /** + * An extension of {@link ValueExpressionQueryRewriter} that can create {@link QueryExpressionEvaluator} instances as + * it also knows about a {@link QueryMethodValueEvaluationContextAccessor}. + * + * @author Oliver Gierke + */ + public static class EvaluatingValueExpressionQueryRewriter extends ValueExpressionQueryRewriter { + + private final QueryMethodValueEvaluationContextAccessor contextProviderFactory; + + /** + * Creates a new {@link EvaluatingValueExpressionQueryRewriter} for the given + * {@link QueryMethodValueEvaluationContextAccessor}, parameter name source and replacement source. + * + * @param factory must not be {@literal null}. + * @param parameterNameSource must not be {@literal null}. + * @param replacementSource must not be {@literal null}. + */ + private EvaluatingValueExpressionQueryRewriter(ValueExpressionParser expressionParser, + QueryMethodValueEvaluationContextAccessor factory, + BiFunction parameterNameSource, BiFunction replacementSource) { + + super(expressionParser, parameterNameSource, replacementSource); + + this.contextProviderFactory = factory; + } + + /** + * Parses the query for SpEL expressions using the pattern: + * + *

+		 * <prefix>#{<spel>}
+		 * <prefix>${<property placeholder>}
+		 * 
+ *

+ * with prefix being the character ':' or '?'. Parsing honors quoted {@literal String}s enclosed in single or double + * quotation marks. + * + * @param query a query containing Value Expressions in the format described above. Must not be {@literal null}. + * @param parameters a {@link Parameters} instance describing query method parameters + * @return A {@link QueryExpressionEvaluator} which allows to evaluate the Value Expressions. + */ + public QueryExpressionEvaluator parse(String query, Parameters parameters) { + return new QueryExpressionEvaluator(contextProviderFactory.create(parameters), parse(query)); + } + } + + /** + * Parses a query string, identifies the contained Value expressions, replaces them with bind parameters and offers a + * {@link Map} from those bind parameters to the value expression. + *

+ * The parser detects quoted parts of the query string and does not detect value expressions inside such quoted parts + * of the query. + * + * @author Jens Schauder + * @author Oliver Gierke + * @author Mark Paluch + */ + public class ParsedQuery { + + private static final int PREFIX_GROUP_INDEX = 1; + private static final int EXPRESSION_GROUP_INDEX = 2; + + private final String query; + private final Map expressions; + private final QuotationMap quotations; + + /** + * Creates a ExpressionDetector from a query String. + * + * @param query must not be {@literal null}. + */ + ParsedQuery(ValueExpressionParser parser, String query) { + + Assert.notNull(query, "Query must not be null"); + + Map expressions = new HashMap<>(); + Matcher matcher = EXPRESSION_PATTERN.matcher(query); + StringBuilder resultQuery = new StringBuilder(); + QuotationMap quotedAreas = new QuotationMap(query); + + int expressionCounter = 0; + int matchedUntil = 0; + + while (matcher.find()) { + + if (quotedAreas.isQuoted(matcher.start())) { + resultQuery.append(query, matchedUntil, matcher.end()); + + } else { + + String expressionString = matcher.group(EXPRESSION_GROUP_INDEX); + String prefix = matcher.group(PREFIX_GROUP_INDEX); + + String parameterName = parameterNameSource.apply(expressionCounter, expressionString); + String replacement = replacementSource.apply(prefix, parameterName); + + resultQuery.append(query, matchedUntil, matcher.start()); + resultQuery.append(replacement); + + expressions.put(parameterName, parser.parse(expressionString)); + expressionCounter++; + } + + matchedUntil = matcher.end(); + } + + resultQuery.append(query.substring(matchedUntil)); + + this.expressions = Collections.unmodifiableMap(expressions); + this.query = resultQuery.toString(); + + // recreate quotation map based on rewritten query. + this.quotations = new QuotationMap(this.query); + } + + /** + * The query with all the SpEL expressions replaced with bind parameters. + * + * @return Guaranteed to be not {@literal null}. + */ + public String getQueryString() { + return query; + } + + /** + * Return whether the {@link #getQueryString() query} at {@code index} is quoted. + * + * @param index + * @return {@literal true} if quoted; {@literal false} otherwise. + */ + public boolean isQuoted(int index) { + return quotations.isQuoted(index); + } + + public ValueExpression getParameter(String name) { + return expressions.get(name); + } + + /** + * Returns the number of expressions in this extractor. + * + * @return the number of expressions in this extractor. + */ + public int size() { + return expressions.size(); + } + + /** + * A {@literal Map} from parameter name to SpEL expression. + * + * @return Guaranteed to be not {@literal null}. + */ + Map getParameterMap() { + return expressions; + } + + } + + /** + * Value object to analyze a {@link String} to determine the parts of the {@link String} that are quoted and offers an + * API to query that information. + * + * @author Jens Schauder + * @author Oliver Gierke + * @since 2.1 + */ + static class QuotationMap { + + private static final Collection QUOTING_CHARACTERS = Arrays.asList('"', '\''); + + private final List> quotedRanges = new ArrayList<>(); + + /** + * Creates a new {@link QuotationMap} for the query. + * + * @param query can be {@literal null}. + */ + public QuotationMap(@Nullable String query) { + + if (query == null) { + return; + } + + Character inQuotation = null; + int start = 0; + + for (int i = 0; i < query.length(); i++) { + + char currentChar = query.charAt(i); + + if (QUOTING_CHARACTERS.contains(currentChar)) { + + if (inQuotation == null) { + + inQuotation = currentChar; + start = i; + + } else if (currentChar == inQuotation) { + + inQuotation = null; + + quotedRanges.add(Range.from(Bound.inclusive(start)).to(Bound.inclusive(i))); + } + } + } + + if (inQuotation != null) { + throw new IllegalArgumentException( + String.format("The string <%s> starts a quoted range at %d, but never ends it.", query, start)); + } + } + + /** + * Checks if a given index is within a quoted range. + * + * @param index to check if it is part of a quoted range. + * @return whether the query contains a quoted range at {@literal index}. + */ + public boolean isQuoted(int index) { + return quotedRanges.stream().anyMatch(r -> r.contains(index)); + } + } + + /** + * Evaluates Value expressions as detected by {@link ParsedQuery} based on parameter information from a method and + * parameter values from a method call. + * + * @author Jens Schauder + * @author Gerrit Meier + * @author Oliver Gierke + * @see ValueExpressionQueryRewriter#parse(String) + */ + public class QueryExpressionEvaluator { + + private final ValueEvaluationContextProvider evaluationContextProvider; + private final ParsedQuery detector; + + public QueryExpressionEvaluator(ValueEvaluationContextProvider evaluationContextProvider, + ParsedQuery detector) { + this.evaluationContextProvider = evaluationContextProvider; + this.detector = detector; + } + + /** + * Evaluate all value expressions in {@link ParsedQuery} based on values provided as an argument. + * + * @param values Parameter values. Must not be {@literal null}. + * @return a map from parameter name to evaluated value as of {@link ParsedQuery#getParameterMap()}. + */ + public Map evaluate(Object[] values) { + + Assert.notNull(values, "Values must not be null."); + + Map parameterMap = detector.getParameterMap(); + Map results = new LinkedHashMap<>(parameterMap.size()); + + parameterMap.forEach((parameter, expression) -> results.put(parameter, evaluate(expression, values))); + + return results; + } + + /** + * Returns the query string produced by the intermediate SpEL expression collection step. + * + * @return + */ + public String getQueryString() { + return detector.getQueryString(); + } + + @Nullable + private Object evaluate(ValueExpression expression, Object[] values) { + + ValueEvaluationContext evaluationContext = evaluationContextProvider.getEvaluationContext(values, + expression.getExpressionDependencies()); + + return expression.evaluate(evaluationContext); + } + } +} diff --git a/src/main/java/org/springframework/data/spel/EvaluationContextProvider.java b/src/main/java/org/springframework/data/spel/EvaluationContextProvider.java index fe8e17a4ce..ff9905b4e4 100644 --- a/src/main/java/org/springframework/data/spel/EvaluationContextProvider.java +++ b/src/main/java/org/springframework/data/spel/EvaluationContextProvider.java @@ -17,6 +17,7 @@ import org.springframework.expression.EvaluationContext; import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.lang.Nullable; /** * Provides a way to access a centrally defined potentially shared {@link StandardEvaluationContext}. @@ -44,7 +45,7 @@ public interface EvaluationContextProvider { * @param rootObject the root object to set in the {@link EvaluationContext}. * @return */ - EvaluationContext getEvaluationContext(Object rootObject); + EvaluationContext getEvaluationContext(@Nullable Object rootObject); /** * Return a tailored {@link EvaluationContext} built using the given parameter values and considering @@ -57,7 +58,7 @@ public interface EvaluationContextProvider { * @return * @since 2.4 */ - default EvaluationContext getEvaluationContext(Object rootObject, ExpressionDependencies dependencies) { + default EvaluationContext getEvaluationContext(@Nullable Object rootObject, ExpressionDependencies dependencies) { return getEvaluationContext(rootObject); } } diff --git a/src/main/java/org/springframework/data/spel/ExtensionAwareEvaluationContextProvider.java b/src/main/java/org/springframework/data/spel/ExtensionAwareEvaluationContextProvider.java index 67afc5e0ff..4ffb06adcd 100644 --- a/src/main/java/org/springframework/data/spel/ExtensionAwareEvaluationContextProvider.java +++ b/src/main/java/org/springframework/data/spel/ExtensionAwareEvaluationContextProvider.java @@ -101,16 +101,17 @@ public ExtensionAwareEvaluationContextProvider( } @Override - public StandardEvaluationContext getEvaluationContext(Object rootObject) { + public StandardEvaluationContext getEvaluationContext(@Nullable Object rootObject) { return doGetEvaluationContext(rootObject, getExtensions(Predicates.isTrue())); } @Override - public StandardEvaluationContext getEvaluationContext(Object rootObject, ExpressionDependencies dependencies) { + public StandardEvaluationContext getEvaluationContext(@Nullable Object rootObject, + ExpressionDependencies dependencies) { return doGetEvaluationContext(rootObject, getExtensions(it -> dependencies.stream().anyMatch(it::provides))); } - StandardEvaluationContext doGetEvaluationContext(Object rootObject, + StandardEvaluationContext doGetEvaluationContext(@Nullable Object rootObject, Collection extensions) { StandardEvaluationContext context = new StandardEvaluationContext(); @@ -180,7 +181,6 @@ EvaluationContextExtensionInformation getOrCreateInformation(Class toAdapters( diff --git a/src/main/java/org/springframework/data/spel/ReactiveEvaluationContextProvider.java b/src/main/java/org/springframework/data/spel/ReactiveEvaluationContextProvider.java index 062781a826..a1fd3a1c24 100644 --- a/src/main/java/org/springframework/data/spel/ReactiveEvaluationContextProvider.java +++ b/src/main/java/org/springframework/data/spel/ReactiveEvaluationContextProvider.java @@ -18,6 +18,7 @@ import reactor.core.publisher.Mono; import org.springframework.expression.EvaluationContext; +import org.springframework.lang.Nullable; /** * Provides a way to access a centrally defined potentially shared {@link EvaluationContext}. @@ -33,7 +34,7 @@ public interface ReactiveEvaluationContextProvider extends EvaluationContextProv * @param rootObject the root object to set in the {@link EvaluationContext}. * @return a mono that emits exactly one {@link EvaluationContext}. */ - Mono getEvaluationContextLater(Object rootObject); + Mono getEvaluationContextLater(@Nullable Object rootObject); /** * Return a tailored {@link EvaluationContext} built using the given parameter values and considering @@ -46,7 +47,7 @@ public interface ReactiveEvaluationContextProvider extends EvaluationContextProv * @return a mono that emits exactly one {@link EvaluationContext}. * @since 2.4 */ - default Mono getEvaluationContextLater(Object rootObject, + default Mono getEvaluationContextLater(@Nullable Object rootObject, ExpressionDependencies dependencies) { return getEvaluationContextLater(rootObject); } diff --git a/src/main/java/org/springframework/data/spel/ReactiveExtensionAwareEvaluationContextProvider.java b/src/main/java/org/springframework/data/spel/ReactiveExtensionAwareEvaluationContextProvider.java index 935678f766..bdc68bfb59 100644 --- a/src/main/java/org/springframework/data/spel/ReactiveExtensionAwareEvaluationContextProvider.java +++ b/src/main/java/org/springframework/data/spel/ReactiveExtensionAwareEvaluationContextProvider.java @@ -31,6 +31,7 @@ import org.springframework.data.util.ReflectionUtils; import org.springframework.expression.EvaluationContext; import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.lang.Nullable; /** * A reactive {@link EvaluationContextProvider} that assembles an {@link EvaluationContext} from a list of @@ -81,13 +82,13 @@ public EvaluationContext getEvaluationContext(Object rootObject, ExpressionDepen } @Override - public Mono getEvaluationContextLater(Object rootObject) { + public Mono getEvaluationContextLater(@Nullable Object rootObject) { return getExtensions(Predicates.isTrue()) // .map(it -> evaluationContextProvider.doGetEvaluationContext(rootObject, it)); } @Override - public Mono getEvaluationContextLater(Object rootObject, + public Mono getEvaluationContextLater(@Nullable Object rootObject, ExpressionDependencies dependencies) { return getExtensions(it -> dependencies.stream().anyMatch(it::provides)) // diff --git a/src/test/java/org/springframework/data/expression/ValueEvaluationUnitTests.java b/src/test/java/org/springframework/data/expression/ValueEvaluationUnitTests.java index 59269b2b4a..1859aeed68 100644 --- a/src/test/java/org/springframework/data/expression/ValueEvaluationUnitTests.java +++ b/src/test/java/org/springframework/data/expression/ValueEvaluationUnitTests.java @@ -28,7 +28,6 @@ import org.springframework.expression.EvaluationContext; import org.springframework.expression.EvaluationException; import org.springframework.expression.ParseException; -import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.expression.spel.support.StandardEvaluationContext; /** @@ -86,8 +85,7 @@ void shouldParseAndEvaluateExpressions() { @Test // GH-2369 void shouldParseLiteral() { - ValueParserConfiguration parserContext = () -> new SpelExpressionParser(); - ValueExpressionParser parser = ValueExpressionParser.create(parserContext); + ValueExpressionParser parser = ValueExpressionParser.create(); assertThat(parser.parse("#{'foo'}-${key.one}").isLiteral()).isFalse(); assertThat(parser.parse("foo").isLiteral()).isTrue(); @@ -132,9 +130,7 @@ void shouldParseQuoted() { private String eval(String expressionString) { - ValueParserConfiguration parserContext = SpelExpressionParser::new; - - ValueExpressionParser parser = ValueExpressionParser.create(parserContext); + ValueExpressionParser parser = ValueExpressionParser.create(); return (String) parser.parse(expressionString).evaluate(evaluationContext); } diff --git a/src/test/java/org/springframework/data/repository/query/ValueExpressionQueryRewriterUnitTests.java b/src/test/java/org/springframework/data/repository/query/ValueExpressionQueryRewriterUnitTests.java new file mode 100644 index 0000000000..bf686c1da7 --- /dev/null +++ b/src/test/java/org/springframework/data/repository/query/ValueExpressionQueryRewriterUnitTests.java @@ -0,0 +1,158 @@ +/* + * 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.repository.query; + +import static org.assertj.core.api.Assertions.*; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Map; +import java.util.function.BiFunction; + +import org.assertj.core.groups.Tuple; +import org.junit.jupiter.api.Test; + +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.data.expression.ValueExpressionParser; +import org.springframework.data.spel.EvaluationContextProvider; + +/** + * Unit tests for {@link ValueExpressionQueryRewriter}. + * + * @author Mark Paluch + */ +class ValueExpressionQueryRewriterUnitTests { + + static final BiFunction PARAMETER_NAME_SOURCE = (index, spel) -> "EPP" + index; + static final BiFunction REPLACEMENT_SOURCE = (prefix, name) -> prefix + name; + static final ValueExpressionParser PARSER = ValueExpressionParser.create(); + + @Test // GH-3049 + void nullQueryThrowsException() { + + var context = ValueExpressionQueryRewriter.of(PARSER, PARAMETER_NAME_SOURCE, REPLACEMENT_SOURCE); + + assertThatIllegalArgumentException().isThrownBy(() -> context.parse(null)); + } + + @Test // GH-3049 + void emptyStringGetsParsedCorrectly() { + + var context = ValueExpressionQueryRewriter.of(PARSER, PARAMETER_NAME_SOURCE, REPLACEMENT_SOURCE); + var extractor = context.parse(""); + + assertThat(extractor.getQueryString()).isEqualTo(""); + assertThat(extractor.getParameterMap()).isEmpty(); + } + + @Test // GH-3049 + void findsAndReplacesExpressions() { + + var context = ValueExpressionQueryRewriter.of(PARSER, PARAMETER_NAME_SOURCE, REPLACEMENT_SOURCE); + var extractor = context.parse(":#{one} ?#{two} :${three} ?${four}"); + + assertThat(extractor.getQueryString()).isEqualTo(":EPP0 ?EPP1 :EPP2 ?EPP3"); + assertThat(extractor.getParameterMap().entrySet()) // + .extracting(Map.Entry::getKey, it -> it.getValue().getExpressionString()) // + .containsExactlyInAnyOrder( // + Tuple.tuple("EPP0", "one"), // + Tuple.tuple("EPP1", "two"), // + Tuple.tuple("EPP2", "${three}"), // + Tuple.tuple("EPP3", "${four}") // + ); + + } + + @Test // GH-3049 + void keepsStringWhenNoMatchIsFound() { + + var context = ValueExpressionQueryRewriter.of(PARSER, PARAMETER_NAME_SOURCE, REPLACEMENT_SOURCE); + var extractor = context.parse("abcdef"); + + assertThat(extractor.getQueryString()).isEqualTo("abcdef"); + assertThat(extractor.getParameterMap()).isEmpty(); + } + + @Test // GH-3049 + void spelsInQuotesGetIgnored() { + + var queries = Arrays.asList(// + "a'b:#{one}cd'ef", // + "a'b:#{o'ne}cdef", // + "ab':#{one}'cdef", // + "ab:'#{one}cd'ef", // + "ab:#'{one}cd'ef", // + "a'b:#{o'ne}cdef"); + + queries.forEach(this::checkNoExpressionIsFound); + } + + private void checkNoExpressionIsFound(String query) { + + var context = ValueExpressionQueryRewriter.of(PARSER, PARAMETER_NAME_SOURCE, REPLACEMENT_SOURCE); + var extractor = context.parse(query); + + assertThat(extractor.getQueryString()).describedAs(query).isEqualTo(query); + assertThat(extractor.getParameterMap()).describedAs(query).isEmpty(); + } + + @Test // GH-3049 + void shouldEvaluateExpression() throws Exception { + + ValueExpressionQueryRewriter rewriter = ValueExpressionQueryRewriter.of(PARSER, PARAMETER_NAME_SOURCE, + REPLACEMENT_SOURCE); + StandardEnvironment environment = new StandardEnvironment(); + environment.getPropertySources().addFirst(new MapPropertySource("synthetic", Map.of("foo", "world"))); + + QueryMethodValueEvaluationContextAccessor factory = new QueryMethodValueEvaluationContextAccessor(environment, + EvaluationContextProvider.DEFAULT); + + Method method = ValueExpressionQueryRewriterUnitTests.MyRepository.class.getDeclaredMethod("simpleExpression", + String.class); + var extractor = rewriter.withEvaluationContextAccessor(factory).parse("SELECT :#{#value}, :${foo}", + new DefaultParameters(ParametersSource.of(method))); + + assertThat(extractor.getQueryString()).isEqualTo("SELECT :EPP0, :EPP1"); + assertThat(extractor.evaluate(new Object[] { "hello" })).containsEntry("EPP0", "hello").containsEntry("EPP1", + "world"); + } + + @Test // GH-3049 + void shouldAllowNullValues() throws Exception { + + ValueExpressionQueryRewriter rewriter = ValueExpressionQueryRewriter.of(PARSER, PARAMETER_NAME_SOURCE, + REPLACEMENT_SOURCE); + StandardEnvironment environment = new StandardEnvironment(); + + QueryMethodValueEvaluationContextAccessor factory = new QueryMethodValueEvaluationContextAccessor(environment, + EvaluationContextProvider.DEFAULT); + + Method method = ValueExpressionQueryRewriterUnitTests.MyRepository.class.getDeclaredMethod("simpleExpression", + String.class); + var extractor = rewriter.withEvaluationContextAccessor(factory).parse("SELECT :#{#value}", + new DefaultParameters(ParametersSource.of(method))); + + assertThat(extractor.getQueryString()).isEqualTo("SELECT :EPP0"); + assertThat(extractor.evaluate(new Object[] { null })).containsEntry("EPP0", null); + } + + interface MyRepository { + + void simpleExpression(String value); + + } +} From ae2e66f18d22626fb33deeb7d6e0a504cf10a507 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Wed, 2 Oct 2024 11:22:09 +0200 Subject: [PATCH 3/6] Introduce `getValueType(ValueEvaluationContext)` to `ValueExpression`. Closes #3169 --- .../expression/CompositeValueExpression.java | 6 ++++ .../data/expression/ExpressionExpression.java | 13 +++++--- .../expression/LiteralValueExpression.java | 5 ++++ .../expression/PlaceholderExpression.java | 7 ++++- .../data/expression/ValueExpression.java | 11 +++++++ .../expression/ValueEvaluationUnitTests.java | 30 +++++++++++++++++-- 6 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/springframework/data/expression/CompositeValueExpression.java b/src/main/java/org/springframework/data/expression/CompositeValueExpression.java index d2dc64a48a..e2224d5f99 100644 --- a/src/main/java/org/springframework/data/expression/CompositeValueExpression.java +++ b/src/main/java/org/springframework/data/expression/CompositeValueExpression.java @@ -72,4 +72,10 @@ public String evaluate(ValueEvaluationContext context) { return builder.toString(); } + + @Override + public Class getValueType(ValueEvaluationContext context) { + return String.class; + } + } diff --git a/src/main/java/org/springframework/data/expression/ExpressionExpression.java b/src/main/java/org/springframework/data/expression/ExpressionExpression.java index 83da26614e..1702f4eb1e 100644 --- a/src/main/java/org/springframework/data/expression/ExpressionExpression.java +++ b/src/main/java/org/springframework/data/expression/ExpressionExpression.java @@ -47,9 +47,14 @@ public boolean isLiteral() { public Object evaluate(ValueEvaluationContext context) { EvaluationContext evaluationContext = context.getEvaluationContext(); - if (evaluationContext != null) { - return expression.getValue(evaluationContext); - } - return expression.getValue(); + return evaluationContext != null ? expression.getValue(evaluationContext) : expression.getValue(); } + + @Override + public Class getValueType(ValueEvaluationContext context) { + + EvaluationContext evaluationContext = context.getEvaluationContext(); + return evaluationContext != null ? expression.getValueType(evaluationContext) : expression.getValueType(); + } + } diff --git a/src/main/java/org/springframework/data/expression/LiteralValueExpression.java b/src/main/java/org/springframework/data/expression/LiteralValueExpression.java index 764220bfc6..2b50f91f77 100644 --- a/src/main/java/org/springframework/data/expression/LiteralValueExpression.java +++ b/src/main/java/org/springframework/data/expression/LiteralValueExpression.java @@ -39,4 +39,9 @@ public String evaluate(ValueEvaluationContext context) { return expression; } + @Override + public Class getValueType(ValueEvaluationContext context) { + return String.class; + } + } diff --git a/src/main/java/org/springframework/data/expression/PlaceholderExpression.java b/src/main/java/org/springframework/data/expression/PlaceholderExpression.java index 1473f1ed28..f58abfc0e4 100644 --- a/src/main/java/org/springframework/data/expression/PlaceholderExpression.java +++ b/src/main/java/org/springframework/data/expression/PlaceholderExpression.java @@ -38,7 +38,7 @@ public boolean isLiteral() { } @Override - public Object evaluate(ValueEvaluationContext context) { + public String evaluate(ValueEvaluationContext context) { Environment environment = context.getEnvironment(); if (environment != null) { @@ -51,4 +51,9 @@ public Object evaluate(ValueEvaluationContext context) { return expression; } + @Override + public Class getValueType(ValueEvaluationContext context) { + return String.class; + } + } diff --git a/src/main/java/org/springframework/data/expression/ValueExpression.java b/src/main/java/org/springframework/data/expression/ValueExpression.java index 8f4545bcd8..7ad6ad4d26 100644 --- a/src/main/java/org/springframework/data/expression/ValueExpression.java +++ b/src/main/java/org/springframework/data/expression/ValueExpression.java @@ -62,4 +62,15 @@ default ExpressionDependencies getExpressionDependencies() { @Nullable Object evaluate(ValueEvaluationContext context) throws EvaluationException; + /** + * Return the most general type that the expression would use as return type for the given context. + * + * @param context the context in which to evaluate the expression. + * @return the most general type of value. + * @throws EvaluationException if there is a problem determining the type + * @since 3.4 + */ + @Nullable + Class getValueType(ValueEvaluationContext context) throws EvaluationException; + } diff --git a/src/test/java/org/springframework/data/expression/ValueEvaluationUnitTests.java b/src/test/java/org/springframework/data/expression/ValueEvaluationUnitTests.java index 1859aeed68..22b65073b7 100644 --- a/src/test/java/org/springframework/data/expression/ValueEvaluationUnitTests.java +++ b/src/test/java/org/springframework/data/expression/ValueEvaluationUnitTests.java @@ -19,6 +19,7 @@ import java.util.Map; +import org.jetbrains.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -38,6 +39,7 @@ */ public class ValueEvaluationUnitTests { + private ValueExpressionParser parser = ValueExpressionParser.create(); private ValueEvaluationContext evaluationContext; @BeforeEach @@ -47,6 +49,10 @@ void setUp() { StandardEnvironment environment = new StandardEnvironment(); environment.getPropertySources().addFirst(propertySource); + record MyRecord(String foo, @org.springframework.lang.Nullable String bar) { + + } + this.evaluationContext = new ValueEvaluationContext() { @Override public Environment getEnvironment() { @@ -57,6 +63,8 @@ public Environment getEnvironment() { public EvaluationContext getEvaluationContext() { StandardEvaluationContext context = new StandardEvaluationContext(); context.setVariable("contextVar", "contextVal"); + context.setVariable("nullVar", null); + context.setVariable("someRecord", new MyRecord("foo", null)); return context; } @@ -82,6 +90,20 @@ void shouldParseAndEvaluateExpressions() { .withMessageContaining("Could not resolve placeholder 'env.does.not.exist'"); } + @Test // GH-3169 + void shouldReturnValueType() { + + assertThat(getValueType("foo")).isEqualTo(String.class); + assertThat(getValueType("${env.key.one}")).isEqualTo(String.class); + assertThat(getValueType("#{'foo'}")).isEqualTo(String.class); + assertThat(getValueType("#{1+1}")).isEqualTo(Integer.class); + assertThat(getValueType("#{null}")).isNull(); + assertThat(getValueType("#{#contextVar}")).isEqualTo(String.class); + assertThat(getValueType("#{#nullVar}")).isNull(); + assertThat(getValueType("#{#someRecord.foo}")).isEqualTo(String.class); + assertThat(getValueType("#{#someRecord.bar}")).isEqualTo(String.class); + } + @Test // GH-2369 void shouldParseLiteral() { @@ -128,10 +150,14 @@ void shouldParseQuoted() { assertThat(eval("#{(1+1) + \"-foo'}\" + '-bar}'}")).isEqualTo("2-foo'}-bar}"); } + @SuppressWarnings("DataFlowIssue") private String eval(String expressionString) { - - ValueExpressionParser parser = ValueExpressionParser.create(); return (String) parser.parse(expressionString).evaluate(evaluationContext); } + @Nullable + private Class getValueType(String expressionString) { + return parser.parse(expressionString).getValueType(evaluationContext); + } + } From ad38fd47518e81e0ccdd0c9bccb1bc12a5e5797f Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 8 Oct 2024 09:13:55 +0200 Subject: [PATCH 4/6] Expose ParsedQuery parameter name mapping. --- .../query/ValueExpressionQueryRewriter.java | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/src/main/java/org/springframework/data/repository/query/ValueExpressionQueryRewriter.java b/src/main/java/org/springframework/data/repository/query/ValueExpressionQueryRewriter.java index e4b1e48fc6..ecacb826e1 100644 --- a/src/main/java/org/springframework/data/repository/query/ValueExpressionQueryRewriter.java +++ b/src/main/java/org/springframework/data/repository/query/ValueExpressionQueryRewriter.java @@ -72,17 +72,17 @@ public class ValueExpressionQueryRewriter { private final ValueExpressionParser expressionParser; /** - * A function from the index of a Value expression in a query and the actual SpEL expression to the parameter name to - * be used in place of the SpEL expression. A typical implementation is expected to look like - * (index, spel) -> "__some_placeholder_" + index + * A function from the index of a Value expression in a query and the actual Value Expression to the parameter name to + * be used in place of the Value Expression. A typical implementation is expected to look like + * (index, expression) -> "__some_placeholder_" + index */ private final BiFunction parameterNameSource; /** - * A function from a prefix used to demarcate a SpEL expression in a query and a parameter name as returned from - * {@link #parameterNameSource} to a {@literal String} to be used as a replacement of the SpEL in the query. The - * returned value should normally be interpretable as a bind parameter by the underlying persistence mechanism. A - * typical implementation is expected to look like (prefix, name) -> prefix + name or + * A function from a prefix used to demarcate a Value Expression in a query and a parameter name as returned from + * {@link #parameterNameSource} to a {@literal String} to be used as a replacement of the Value Expressions in the + * query. The returned value should normally be interpretable as a bind parameter by the underlying persistence + * mechanism. A typical implementation is expected to look like (prefix, name) -> prefix + name or * (prefix, name) -> "{" + name + "}" */ private final BiFunction replacementSource; @@ -116,9 +116,9 @@ public static ValueExpressionQueryRewriter of(ValueExpressionParser expressionPa * with prefix being the character ':' or '?'. Parsing honors quoted {@literal String}s enclosed in single or double * quotation marks. * - * @param query a query containing SpEL expressions in the format described above. Must not be {@literal null}. - * @return A {@link ParsedQuery} which makes the query with SpEL expressions replaced by bind parameters and a map - * from bind parameter to SpEL expression available. Guaranteed to be not {@literal null}. + * @param query a query containing Value Expressions in the format described above. Must not be {@literal null}. + * @return A {@link ParsedQuery} which makes the query with Value Expressions replaced by bind parameters and a map + * from bind parameter to Value Expression available. Guaranteed to be not {@literal null}. */ public ParsedQuery parse(String query) { return new ParsedQuery(expressionParser, query); @@ -168,7 +168,7 @@ private EvaluatingValueExpressionQueryRewriter(ValueExpressionParser expressionP } /** - * Parses the query for SpEL expressions using the pattern: + * Parses the query for Value Expressions using the pattern: * *

 		 * <prefix>#{<spel>}
@@ -257,7 +257,7 @@ public class ParsedQuery {
 		}
 
 		/**
-		 * The query with all the SpEL expressions replaced with bind parameters.
+		 * The query with all the Value Expressions replaced with bind parameters.
 		 *
 		 * @return Guaranteed to be not {@literal null}.
 		 */
@@ -289,11 +289,20 @@ public int size() {
 		}
 
 		/**
-		 * A {@literal Map} from parameter name to SpEL expression.
+		 * Returns whether the query contains Value Expressions.
+		 *
+		 * @return {@literal true} if the query contains Value Expressions.
+		 */
+		public boolean hasParameterBindings() {
+			return !expressions.isEmpty();
+		}
+
+		/**
+		 * A {@literal Map} from parameter name to Value Expression.
 		 *
 		 * @return Guaranteed to be not {@literal null}.
 		 */
-		Map getParameterMap() {
+		public Map getParameterMap() {
 			return expressions;
 		}
 
@@ -403,7 +412,7 @@ public Map evaluate(Object[] values) {
 		}
 
 		/**
-		 * Returns the query string produced by the intermediate SpEL expression collection step.
+		 * Returns the query string produced by the intermediate Value Expression collection step.
 		 *
 		 * @return
 		 */

From 5c290bb2a0f147d7958d36d274323e7259a76420 Mon Sep 17 00:00:00 2001
From: Mark Paluch 
Date: Tue, 8 Oct 2024 15:22:59 +0200
Subject: [PATCH 5/6] Polishing.

---
 .../repository/query/SpelQueryContext.java    |  2 +-
 .../query/ValueExpressionQueryRewriter.java   | 37 ++++++++++++-------
 2 files changed, 24 insertions(+), 15 deletions(-)

diff --git a/src/main/java/org/springframework/data/repository/query/SpelQueryContext.java b/src/main/java/org/springframework/data/repository/query/SpelQueryContext.java
index ebdc8ad0cd..43a67809fc 100644
--- a/src/main/java/org/springframework/data/repository/query/SpelQueryContext.java
+++ b/src/main/java/org/springframework/data/repository/query/SpelQueryContext.java
@@ -58,7 +58,7 @@
  * @author Gerrit Meier
  * @author Mark Paluch
  * @since 2.1
- * @deprecated since 3.3, use {@link ValueExpressionQueryRewriter.QueryExpressionEvaluator} instead.
+ * @deprecated since 3.3, use {@link ValueExpressionQueryRewriter} instead.
  */
 @Deprecated(since = "3.3")
 public class SpelQueryContext {
diff --git a/src/main/java/org/springframework/data/repository/query/ValueExpressionQueryRewriter.java b/src/main/java/org/springframework/data/repository/query/ValueExpressionQueryRewriter.java
index ecacb826e1..14637ca2a7 100644
--- a/src/main/java/org/springframework/data/repository/query/ValueExpressionQueryRewriter.java
+++ b/src/main/java/org/springframework/data/repository/query/ValueExpressionQueryRewriter.java
@@ -41,22 +41,20 @@
  * with bind variables.
  * 

* Result of the parse process is a {@link ParsedQuery} which provides the transformed query string. Alternatively and - * preferred one may provide a {@link QueryMethodEvaluationContextProvider} via + * preferred one may provide a {@link QueryMethodValueEvaluationContextAccessor} via * {@link #withEvaluationContextAccessor(QueryMethodValueEvaluationContextAccessor)} which will yield the more powerful * {@link EvaluatingValueExpressionQueryRewriter}. *

* Typical usage looks like * - *

- * 
-     ExpressionQueryRewriter.ParsedQuery parsed = ExpressionQueryRewriter
-         .of(valueExpressionParser, (counter, expression) -> String.format("__$synthetic$__%d", counter), String::concat)
-         .withEvaluationContextProvider(evaluationContextProviderFactory);
-
-     ExpressionQueryRewriter.QueryExpressionEvaluator evaluator = queryContext.parse(query, queryMethod.getParameters());
-
-     evaluator.evaluate(objects).forEach(parameterMap::addValue);
- * 
+ * 
+ * ValueExpressionQueryRewriter.EvaluatingValueExpressionQueryRewriter rewriter = ValueExpressionQueryRewriter
+ * 		.of(valueExpressionParser, (counter, expression) -> String.format("__$synthetic$__%d", counter), String::concat)
+ * 		.withEvaluationContextAccessor(evaluationContextProviderFactory);
+ *
+ * ValueExpressionQueryRewriter.QueryExpressionEvaluator evaluator = rewriter.parse(query, queryMethod.getParameters());
+ *
+ * evaluator.evaluate(objects).forEach(parameterMap::addValue);
  * 
* * @author Jens Schauder @@ -74,7 +72,7 @@ public class ValueExpressionQueryRewriter { /** * A function from the index of a Value expression in a query and the actual Value Expression to the parameter name to * be used in place of the Value Expression. A typical implementation is expected to look like - * (index, expression) -> "__some_placeholder_" + index + * {@code (index, expression) -> "__some_placeholder_" + index}. */ private final BiFunction parameterNameSource; @@ -82,8 +80,8 @@ public class ValueExpressionQueryRewriter { * A function from a prefix used to demarcate a Value Expression in a query and a parameter name as returned from * {@link #parameterNameSource} to a {@literal String} to be used as a replacement of the Value Expressions in the * query. The returned value should normally be interpretable as a bind parameter by the underlying persistence - * mechanism. A typical implementation is expected to look like (prefix, name) -> prefix + name or - * (prefix, name) -> "{" + name + "}" + * mechanism. A typical implementation is expected to look like {@code (prefix, name) -> prefix + name} or + * {@code (prefix, name) -> "{" + name + "}"}. */ private final BiFunction replacementSource; @@ -99,6 +97,17 @@ private ValueExpressionQueryRewriter(ValueExpressionParser expressionParser, this.expressionParser = expressionParser; } + /** + * Creates a new {@link ValueExpressionQueryRewriter} using the given {@link ValueExpressionParser} and rewrite + * functions. + * + * @param expressionParser the expression parser to use. + * @param parameterNameSource function to generate parameter names. Typically, a function of the form + * {@code (index, expression) -> "__some_placeholder_" + index}. + * @param replacementSource function to generate replacements. Typically, a concatenation of the prefix and the + * parameter name such as {@code String::concat}. + * @return + */ public static ValueExpressionQueryRewriter of(ValueExpressionParser expressionParser, BiFunction parameterNameSource, BiFunction replacementSource) { return new ValueExpressionQueryRewriter(expressionParser, parameterNameSource, replacementSource); From 43df1e0aa9cd91a007a97ec9765c23545a7a1245 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 8 Oct 2024 16:13:58 +0200 Subject: [PATCH 6/6] Add factory method overload to ValueExpressionQueryRewriter to return a EvaluatingValueExpressionQueryRewriter directly. --- .../query/ValueExpressionDelegate.java | 12 ++++--- .../query/ValueExpressionQueryRewriter.java | 34 ++++++++++++++----- ...ValueExpressionQueryRewriterUnitTests.java | 12 ++++--- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/springframework/data/repository/query/ValueExpressionDelegate.java b/src/main/java/org/springframework/data/repository/query/ValueExpressionDelegate.java index bc50cf87c5..17dbdcaf8e 100644 --- a/src/main/java/org/springframework/data/repository/query/ValueExpressionDelegate.java +++ b/src/main/java/org/springframework/data/repository/query/ValueExpressionDelegate.java @@ -64,6 +64,14 @@ public static ValueExpressionDelegate create() { ValueExpressionParser.create()); } + public ValueExpressionParser getValueExpressionParser() { + return valueExpressionParser; + } + + public QueryMethodValueEvaluationContextAccessor getEvaluationContextAccessor() { + return contextAccessor; + } + /** * Creates a {@link ValueEvaluationContextProvider} for query method {@link Parameters} for later creation of a * {@link ValueEvaluationContext} based on the actual method parameter values. The resulting @@ -76,10 +84,6 @@ public ValueEvaluationContextProvider createValueContextProvider(Parameters "__some_placeholder_" + index}. * @param replacementSource function to generate replacements. Typically, a concatenation of the prefix and the * parameter name such as {@code String::concat}. - * @return + * @return a ValueExpressionQueryRewriter instance to rewrite queries and extract parsed {@link ValueExpression}s. */ public static ValueExpressionQueryRewriter of(ValueExpressionParser expressionParser, BiFunction parameterNameSource, BiFunction replacementSource) { return new ValueExpressionQueryRewriter(expressionParser, parameterNameSource, replacementSource); } + /** + * Creates a new EvaluatingValueExpressionQueryRewriter using the given {@link ValueExpressionDelegate} and rewrite + * functions. + * + * @param delegate the ValueExpressionDelegate to use for parsing and to obtain EvaluationContextAccessor from. + * @param parameterNameSource function to generate parameter names. Typically, a function of the form + * {@code (index, expression) -> "__some_placeholder_" + index}. + * @param replacementSource function to generate replacements. Typically, a concatenation of the prefix and the + * parameter name such as {@code String::concat}. + * @return a EvaluatingValueExpressionQueryRewriter instance to rewrite queries and extract parsed + * {@link ValueExpression}s. + * @since 3.4 + */ + public static EvaluatingValueExpressionQueryRewriter of(ValueExpressionDelegate delegate, + BiFunction parameterNameSource, BiFunction replacementSource) { + return of((ValueExpressionParser) delegate, parameterNameSource, replacementSource) + .withEvaluationContextAccessor(delegate.getEvaluationContextAccessor()); + } + /** * Parses the query for {@link org.springframework.data.expression.ValueExpression value expressions} using the * pattern: @@ -137,15 +155,15 @@ public ParsedQuery parse(String query) { * Creates a {@link EvaluatingValueExpressionQueryRewriter} from the current one and the given * {@link QueryMethodValueEvaluationContextAccessor}. * - * @param factory must not be {@literal null}. - * @return + * @param accessor must not be {@literal null}. + * @return EvaluatingValueExpressionQueryRewriter instance to rewrite and evaluate Value Expressions. */ public EvaluatingValueExpressionQueryRewriter withEvaluationContextAccessor( - QueryMethodValueEvaluationContextAccessor factory) { + QueryMethodValueEvaluationContextAccessor accessor) { - Assert.notNull(factory, "QueryMethodValueEvaluationContextAccessor must not be null"); + Assert.notNull(accessor, "QueryMethodValueEvaluationContextAccessor must not be null"); - return new EvaluatingValueExpressionQueryRewriter(expressionParser, factory, parameterNameSource, + return new EvaluatingValueExpressionQueryRewriter(expressionParser, accessor, parameterNameSource, replacementSource); } diff --git a/src/test/java/org/springframework/data/repository/query/ValueExpressionQueryRewriterUnitTests.java b/src/test/java/org/springframework/data/repository/query/ValueExpressionQueryRewriterUnitTests.java index bf686c1da7..68f0b35579 100644 --- a/src/test/java/org/springframework/data/repository/query/ValueExpressionQueryRewriterUnitTests.java +++ b/src/test/java/org/springframework/data/repository/query/ValueExpressionQueryRewriterUnitTests.java @@ -74,7 +74,6 @@ void findsAndReplacesExpressions() { Tuple.tuple("EPP2", "${three}"), // Tuple.tuple("EPP3", "${four}") // ); - } @Test // GH-3049 @@ -113,17 +112,20 @@ private void checkNoExpressionIsFound(String query) { @Test // GH-3049 void shouldEvaluateExpression() throws Exception { - ValueExpressionQueryRewriter rewriter = ValueExpressionQueryRewriter.of(PARSER, PARAMETER_NAME_SOURCE, - REPLACEMENT_SOURCE); StandardEnvironment environment = new StandardEnvironment(); environment.getPropertySources().addFirst(new MapPropertySource("synthetic", Map.of("foo", "world"))); - QueryMethodValueEvaluationContextAccessor factory = new QueryMethodValueEvaluationContextAccessor(environment, + QueryMethodValueEvaluationContextAccessor contextAccessor = new QueryMethodValueEvaluationContextAccessor( + environment, EvaluationContextProvider.DEFAULT); + ValueExpressionDelegate delegate = new ValueExpressionDelegate(contextAccessor, PARSER); + ValueExpressionQueryRewriter.EvaluatingValueExpressionQueryRewriter rewriter = ValueExpressionQueryRewriter + .of(delegate, PARAMETER_NAME_SOURCE, REPLACEMENT_SOURCE); + Method method = ValueExpressionQueryRewriterUnitTests.MyRepository.class.getDeclaredMethod("simpleExpression", String.class); - var extractor = rewriter.withEvaluationContextAccessor(factory).parse("SELECT :#{#value}, :${foo}", + var extractor = rewriter.parse("SELECT :#{#value}, :${foo}", new DefaultParameters(ParametersSource.of(method))); assertThat(extractor.getQueryString()).isEqualTo("SELECT :EPP0, :EPP1");