diff --git a/pom.xml b/pom.xml index 8576363d34..ce9bcb2231 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-commons - 3.3.0-SNAPSHOT + 3.3.x-2369-SNAPSHOT Spring Data Core Core Spring concepts underpinning every Spring Data module. diff --git a/src/main/antora/modules/ROOT/nav.adoc b/src/main/antora/modules/ROOT/nav.adoc index d5cd181f8e..2f483a6549 100644 --- a/src/main/antora/modules/ROOT/nav.adoc +++ b/src/main/antora/modules/ROOT/nav.adoc @@ -15,6 +15,7 @@ ** xref:repositories/null-handling.adoc[] ** xref:repositories/projections.adoc[] * xref:query-by-example.adoc[] +* xref:value-expressions.adoc[] * xref:auditing.adoc[] * xref:custom-conversions.adoc[] * xref:entity-callbacks.adoc[] diff --git a/src/main/antora/modules/ROOT/pages/value-expressions.adoc b/src/main/antora/modules/ROOT/pages/value-expressions.adoc new file mode 100644 index 0000000000..752d3d69ec --- /dev/null +++ b/src/main/antora/modules/ROOT/pages/value-expressions.adoc @@ -0,0 +1,246 @@ +[[valueexpressions.fundamentals]] += Value Expressions Fundamentals + +Value Expressions are a combination of {spring-framework-docs}/core/expressions.html[Spring Expression Language (SpEL)] and {spring-framework-docs}/core/beans/environment.html#beans-placeholder-resolution-in-statements[Property Placeholder Resolution]. +They combine powerful evaluation of programmatic expressions with the simplicity to resort to property-placeholder resolution to obtain values from the `Environment` such as configuration properties. + +Expressions are expected to be defined by a trusted input such as an annotation value and not to be determined from user input. + +The following code demonstrates how to use expressions in the context of annotations. + +.Annotation Usage +==== +[source,java] +---- +@Document("orders-#{tenantService.getOrderCollection()}-${tenant-config.suffix}") +class Order { + // … +} +---- +==== + +Value Expressions can be defined from a sole SpEL Expression, a Property Placeholder or a composite expression mixing various expressions including literals. + +.Expression Examples +==== +[source] +---- +#{tenantService.getOrderCollection()} <1> +#{(1+1) + '-hello-world'} <2> +${tenant-config.suffix} <3> +orders-${tenant-config.suffix} <4> +#{tenantService.getOrderCollection()}-${tenant-config.suffix} <5> +---- + +<1> Value Expression using a single SpEL Expression. +<2> Value Expression using a static SpEL Expression evaluating to `2-hello-world`. +<3> Value Expression using a single Property Placeholder. +<4> Composite expression comprised of the literal `orders-` and the Property Placeholder `${tenant-config.suffix}`. +<5> Composite expression using SpEL, Property Placeholders and literals. +==== + +NOTE: Using value expressions introduces a lot of flexibility to your code. +Doing so requires evaluation of the expression on each usage and, therefore, value expression evaluation has an impact on the performance profile. + +[[valueexpressions.api]] +== Parsing and Evaluation + +Value Expressions are parsed by the `ValueExpressionParser` API. +Instances of `ValueExpression` are thread-safe and can be cached for later use to avoid repeated parsing. + +The following example shows the Value Expression API usage: + +.Parsing and Evaluation +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +ValueParserConfiguration configuration = SpelExpressionParser::new; +ValueEvaluationContext context = ValueEvaluationContext.of(environment, evaluationContext); + +ValueExpressionParser parser = ValueExpressionParser.create(configuration); +ValueExpression expression = parser.parse("Hello, World"); +Object result = expression.evaluate(context); +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +val configuration = ValueParserConfiguration { SpelExpressionParser() } +val context = ValueEvaluationContext.of(environment, evaluationContext) + +val parser = ValueExpressionParser.create(configuration) +val expression: ValueExpression = parser.parse("Hello, World") +val result: Any = expression.evaluate(context) +---- +====== + +[[valueexpressions.spel]] +== SpEL Expressions + +{spring-framework-docs}/core/expressions.html[SpEL Expressions] follow the Template style where the expression is expected to be enclosed within the `#{…}` format. +Expressions are evaluated using an `EvaluationContext` that is provided by `EvaluationContextProvider`. +The context itself is a powerful `StandardEvaluationContext` allowing a wide range of operations, access to static types and context extensions. + +NOTE: Make sure to parse and evaluate only expressions from trusted sources such as annotations. +Accepting user-provided expressions can create an entry path to exploit the application context and your system resulting in a potential security vulnerability. + +=== Extending the Evaluation Context + +`EvaluationContextProvider` and its reactive variant `ReactiveEvaluationContextProvider` provide access to an `EvaluationContext`. +`ExtensionAwareEvaluationContextProvider` and its reactive variant `ReactiveExtensionAwareEvaluationContextProvider` are default implementations that determine context extensions from an application context, specifically `ListableBeanFactory`. + +Extensions implement either `EvaluationContextExtension` or `ReactiveEvaluationContextExtension` to provide extension support to hydrate `EvaluationContext`. +That are a root object, properties and functions (top-level methods). + +The following example shows a context extension that provides a root object, properties, functions and an aliased function. + +.Implementing a `EvaluationContextExtension` +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Component +public class MyExtension implements EvaluationContextExtension { + + @Override + public String getExtensionId() { + return "my-extension"; + } + + @Override + public Object getRootObject() { + return new CustomExtensionRootObject(); + } + + @Override + public Map getProperties() { + + Map properties = new HashMap<>(); + + properties.put("key", "Hello"); + + return properties; + } + + @Override + public Map getFunctions() { + + Map functions = new HashMap<>(); + + try { + functions.put("aliasedMethod", new Function(getClass().getMethod("extensionMethod"))); + return functions; + } catch (Exception o_O) { + throw new RuntimeException(o_O); + } + } + + public static String extensionMethod() { + return "Hello World"; + } + + public static int add(int i1, int i2) { + return i1 + i2; + } + +} + +public class CustomExtensionRootObject { + + public boolean rootObjectInstanceMethod() { + return true; + } + +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Component +class MyExtension : EvaluationContextExtension { + + override fun getExtensionId(): String { + return "my-extension" + } + + override fun getRootObject(): Any? { + return CustomExtensionRootObject() + } + + override fun getProperties(): Map { + val properties: MutableMap = HashMap() + + properties["key"] = "Hello" + + return properties + } + + override fun getFunctions(): Map { + val functions: MutableMap = HashMap() + + try { + functions["aliasedMethod"] = Function(javaClass.getMethod("extensionMethod")) + return functions + } catch (o_O: Exception) { + throw RuntimeException(o_O) + } + } + + companion object { + fun extensionMethod(): String { + return "Hello World" + } + + fun add(i1: Int, i2: Int): Int { + return i1 + i2 + } + } +} + +class CustomExtensionRootObject { + fun rootObjectInstanceMethod(): Boolean { + return true + } +} +---- +====== + +Once the above shown extension is registered, you can use its exported methods, properties and root object to evaluate SpEL expressions: + +.Expression Evaluation Examples +==== +[source] +---- +#{add(1, 2)} <1> +#{extensionMethod()} <2> +#{aliasedMethod()} <3> +#{key} <4> +#{rootObjectInstanceMethod()} <5> +---- + +<1> Invoke the method `add` declared by `MyExtension` resulting in `3` as the method adds both numeric parameters and returns the sum. +<2> Invoke the method `extensionMethod` declared by `MyExtension` resulting in `Hello World`. +<3> Invoke the method `aliasedMethod`. +The method is exposed as function and redirects into the method `extensionMethod` declared by `MyExtension` resulting in `Hello World`. +<4> Evaluate the `key` property resulting in `Hello`. +<5> Invoke the method `rootObjectInstanceMethod` on the root object instance `CustomExtensionRootObject`. +==== + +You can find real-life context extensions at https://github.com/spring-projects/spring-security/blob/main/data/src/main/java/org/springframework/security/data/repository/query/SecurityEvaluationContextExtension.java[`SecurityEvaluationContextExtension`]. + +[[valueexpressions.property-placeholders]] +== Property Placeholders + +Property placeholders following the form `${…}` refer to properties provided typically by a `PropertySource` through `Environment`. +Properties are useful to resolve against system properties, application configuration files, environment configuration or property sources contributed by secret management systems. +You can find more details on the property placeholders in {spring-framework-docs}/core/beans/annotation-config/value-annotations.html#page-title[Spring Framework's documentation on `@Value` usage]. + + diff --git a/src/main/java/org/springframework/data/expression/CompositeValueExpression.java b/src/main/java/org/springframework/data/expression/CompositeValueExpression.java new file mode 100644 index 0000000000..d2dc64a48a --- /dev/null +++ b/src/main/java/org/springframework/data/expression/CompositeValueExpression.java @@ -0,0 +1,75 @@ +/* + * 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 java.util.List; + +import org.springframework.data.spel.ExpressionDependencies; + +/** + * Composite {@link ValueExpression} consisting of multiple placeholder, SpEL, and literal expressions. + * + * @param raw + * @param expressions + * @author Mark Paluch + * @since 3.3 + */ +record CompositeValueExpression(String raw, List expressions) implements ValueExpression { + + @Override + public String getExpressionString() { + return raw; + } + + @Override + public ExpressionDependencies getExpressionDependencies() { + + ExpressionDependencies dependencies = ExpressionDependencies.none(); + + for (ValueExpression expression : expressions) { + ExpressionDependencies dependency = expression.getExpressionDependencies(); + if (!dependency.equals(ExpressionDependencies.none())) { + dependencies = dependencies.mergeWith(dependency); + } + } + + return dependencies; + } + + @Override + public boolean isLiteral() { + + for (ValueExpression expression : expressions) { + if (!expression.isLiteral()) { + return false; + } + } + + return true; + } + + @Override + public String evaluate(ValueEvaluationContext context) { + + StringBuilder builder = new StringBuilder(); + + for (ValueExpression expression : expressions) { + builder.append((String) expression.evaluate(context)); + } + + return builder.toString(); + } +} diff --git a/src/main/java/org/springframework/data/expression/DefaultValueEvaluationContext.java b/src/main/java/org/springframework/data/expression/DefaultValueEvaluationContext.java new file mode 100644 index 0000000000..d498560135 --- /dev/null +++ b/src/main/java/org/springframework/data/expression/DefaultValueEvaluationContext.java @@ -0,0 +1,39 @@ +/* + * 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 org.springframework.core.env.Environment; +import org.springframework.expression.EvaluationContext; + +/** + * Default {@link ValueEvaluationContext}. + * + * @author Mark Paluch + * @since 3.3 + */ +record DefaultValueEvaluationContext(Environment environment, + EvaluationContext evaluationContext) implements ValueEvaluationContext { + + @Override + public Environment getEnvironment() { + return environment(); + } + + @Override + public EvaluationContext getEvaluationContext() { + return evaluationContext(); + } +} diff --git a/src/main/java/org/springframework/data/expression/DefaultValueExpressionParser.java b/src/main/java/org/springframework/data/expression/DefaultValueExpressionParser.java new file mode 100644 index 0000000000..ceea642641 --- /dev/null +++ b/src/main/java/org/springframework/data/expression/DefaultValueExpressionParser.java @@ -0,0 +1,180 @@ +/* + * 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 java.util.ArrayList; +import java.util.List; + +import org.springframework.data.spel.ExpressionDependencies; +import org.springframework.expression.Expression; +import org.springframework.expression.ParseException; +import org.springframework.expression.ParserContext; +import org.springframework.util.Assert; +import org.springframework.util.SystemPropertyUtils; + +/** + * Default {@link ValueExpressionParser} implementation. Instances are thread-safe. + * + * @author Mark Paluch + * @since 3.3 + */ +class DefaultValueExpressionParser implements ValueExpressionParser { + + public static final String PLACEHOLDER_PREFIX = SystemPropertyUtils.PLACEHOLDER_PREFIX; + public static final String EXPRESSION_PREFIX = ParserContext.TEMPLATE_EXPRESSION.getExpressionPrefix(); + public static final char SUFFIX = '}'; + public static final int PLACEHOLDER_PREFIX_LENGTH = PLACEHOLDER_PREFIX.length(); + public static final char[] QUOTE_CHARS = { '\'', '"' }; + + private final ValueParserConfiguration configuration; + + public DefaultValueExpressionParser(ValueParserConfiguration configuration) { + + Assert.notNull(configuration, "ValueParserConfiguration must not be null"); + + this.configuration = configuration; + } + + @Override + public ValueExpression parse(String expressionString) { + + int placerholderIndex = expressionString.indexOf(PLACEHOLDER_PREFIX); + int expressionIndex = expressionString.indexOf(EXPRESSION_PREFIX); + + if (placerholderIndex == -1 && expressionIndex == -1) { + return new LiteralValueExpression(expressionString); + } + + if (placerholderIndex != -1 && expressionIndex == -1 + && findPlaceholderEndIndex(expressionString, placerholderIndex) != expressionString.length()) { + return createPlaceholder(expressionString); + } + + if (placerholderIndex == -1 + && findPlaceholderEndIndex(expressionString, expressionIndex) != expressionString.length()) { + return createExpression(expressionString); + } + + return parseComposite(expressionString, placerholderIndex, expressionIndex); + } + + private CompositeValueExpression parseComposite(String expressionString, int placerholderIndex, int expressionIndex) { + + List expressions = new ArrayList<>(PLACEHOLDER_PREFIX_LENGTH); + int startIndex = getStartIndex(placerholderIndex, expressionIndex); + + if (startIndex != 0) { + expressions.add(new LiteralValueExpression(expressionString.substring(0, startIndex))); + } + + while (startIndex != -1) { + + int endIndex = findPlaceholderEndIndex(expressionString, startIndex); + + if (endIndex == -1) { + throw new ParseException(expressionString, startIndex, + "No ending suffix '}' for expression starting at character %d: %s".formatted(startIndex, + expressionString.substring(startIndex))); + } + + int afterClosingParenthesisIndex = endIndex + 1; + String part = expressionString.substring(startIndex, afterClosingParenthesisIndex); + + if (part.startsWith(PLACEHOLDER_PREFIX)) { + expressions.add(createPlaceholder(part)); + } else { + expressions.add(createExpression(part)); + } + + placerholderIndex = expressionString.indexOf(PLACEHOLDER_PREFIX, endIndex); + expressionIndex = expressionString.indexOf(EXPRESSION_PREFIX, endIndex); + + startIndex = getStartIndex(placerholderIndex, expressionIndex); + + if (startIndex == -1) { + // no next expression but we're capturing everything after the expression as literal. + expressions.add(new LiteralValueExpression(expressionString.substring(afterClosingParenthesisIndex))); + } else { + // capture literal after the expression ends and before the next starts. + expressions + .add(new LiteralValueExpression(expressionString.substring(afterClosingParenthesisIndex, startIndex))); + } + } + + return new CompositeValueExpression(expressionString, expressions); + } + + private static int getStartIndex(int placerholderIndex, int expressionIndex) { + return placerholderIndex != -1 && expressionIndex != -1 ? Math.min(placerholderIndex, expressionIndex) + : placerholderIndex != -1 ? placerholderIndex : expressionIndex; + } + + private PlaceholderExpression createPlaceholder(String part) { + return new PlaceholderExpression(part); + } + + private ExpressionExpression createExpression(String expression) { + + Expression expr = configuration.getExpressionParser().parseExpression(expression, + ParserContext.TEMPLATE_EXPRESSION); + ExpressionDependencies dependencies = ExpressionDependencies.discover(expr); + return new ExpressionExpression(expr, dependencies); + } + + private static int findPlaceholderEndIndex(CharSequence buf, int startIndex) { + + int index = startIndex + PLACEHOLDER_PREFIX_LENGTH; + char quotationChar = 0; + char nestingLevel = 0; + boolean skipEscape = false; + + while (index < buf.length()) { + + char c = buf.charAt(index); + + if (!skipEscape && c == '\\') { + skipEscape = true; + } else if (skipEscape) { + skipEscape = false; + } else if (quotationChar == 0) { + + for (char quoteChar : QUOTE_CHARS) { + if (quoteChar == c) { + quotationChar = c; + break; + } + } + } else if (quotationChar == c) { + quotationChar = 0; + } + + if (!skipEscape && quotationChar == 0) { + + if (nestingLevel != 0 && c == SUFFIX) { + nestingLevel--; + } else if (c == '{') { + nestingLevel++; + } else if (nestingLevel == 0 && c == SUFFIX) { + return index; + } + } + + index++; + } + + return -1; + } +} diff --git a/src/main/java/org/springframework/data/expression/ExpressionExpression.java b/src/main/java/org/springframework/data/expression/ExpressionExpression.java new file mode 100644 index 0000000000..83da26614e --- /dev/null +++ b/src/main/java/org/springframework/data/expression/ExpressionExpression.java @@ -0,0 +1,55 @@ +/* + * 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 org.springframework.data.spel.ExpressionDependencies; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; + +/** + * SpEL expression. + * + * @param expression + * @author Mark Paluch + * @since 3.3 + */ +record ExpressionExpression(Expression expression, ExpressionDependencies dependencies) implements ValueExpression { + + @Override + public String getExpressionString() { + return expression.getExpressionString(); + } + + @Override + public ExpressionDependencies getExpressionDependencies() { + return dependencies(); + } + + @Override + public boolean isLiteral() { + return false; + } + + @Override + public Object evaluate(ValueEvaluationContext context) { + + EvaluationContext evaluationContext = context.getEvaluationContext(); + if (evaluationContext != null) { + return expression.getValue(evaluationContext); + } + return expression.getValue(); + } +} diff --git a/src/main/java/org/springframework/data/expression/LiteralValueExpression.java b/src/main/java/org/springframework/data/expression/LiteralValueExpression.java new file mode 100644 index 0000000000..764220bfc6 --- /dev/null +++ b/src/main/java/org/springframework/data/expression/LiteralValueExpression.java @@ -0,0 +1,42 @@ +/* + * 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; + +/** + * Literal expression returning the underlying expression string upon evaluation. + * + * @param expression + * @author Mark Paluch + * @since 3.3 + */ +record LiteralValueExpression(String expression) implements ValueExpression { + + @Override + public String getExpressionString() { + return expression; + } + + @Override + public boolean isLiteral() { + return true; + } + + @Override + public String evaluate(ValueEvaluationContext context) { + return expression; + } + +} diff --git a/src/main/java/org/springframework/data/expression/PlaceholderExpression.java b/src/main/java/org/springframework/data/expression/PlaceholderExpression.java new file mode 100644 index 0000000000..1473f1ed28 --- /dev/null +++ b/src/main/java/org/springframework/data/expression/PlaceholderExpression.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 org.springframework.core.env.Environment; +import org.springframework.expression.EvaluationException; + +/** + * Property placeholder expression evaluated against a {@link Environment}. + * + * @param expression + * @author Mark Paluch + * @since 3.3 + */ +record PlaceholderExpression(String expression) implements ValueExpression { + + @Override + public String getExpressionString() { + return expression; + } + + @Override + public boolean isLiteral() { + return false; + } + + @Override + public Object evaluate(ValueEvaluationContext context) { + + Environment environment = context.getEnvironment(); + if (environment != null) { + try { + return environment.resolveRequiredPlaceholders(expression); + } catch (IllegalArgumentException e) { + throw new EvaluationException(e.getMessage(), e); + } + } + return expression; + } + +} diff --git a/src/main/java/org/springframework/data/expression/ValueEvaluationContext.java b/src/main/java/org/springframework/data/expression/ValueEvaluationContext.java new file mode 100644 index 0000000000..5be091d8ca --- /dev/null +++ b/src/main/java/org/springframework/data/expression/ValueEvaluationContext.java @@ -0,0 +1,58 @@ +/* + * 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 org.springframework.core.env.Environment; +import org.springframework.expression.EvaluationContext; +import org.springframework.lang.Nullable; + +/** + * Expressions are executed in an evaluation context. It is in this context that references are resolved when + * encountered during expression evaluation. + * + * @author Christoph Strobl + * @author Mark Paluch + * @since 3.3 + */ +public interface ValueEvaluationContext { + + /** + * Returns a new {@link ValueEvaluationContext}. + * + * @param environment + * @param evaluationContext + * @return a new {@link ValueEvaluationContext} for the given environment and evaluation context. + */ + static ValueEvaluationContext of(Environment environment, EvaluationContext evaluationContext) { + return new DefaultValueEvaluationContext(environment, evaluationContext); + } + + /** + * Returns the {@link Environment} if provided. + * + * @return the {@link Environment} or {@literal null}. + */ + @Nullable + Environment getEnvironment(); + + /** + * Returns the {@link EvaluationContext} if provided. + * + * @return the {@link EvaluationContext} or {@literal null}. + */ + @Nullable + EvaluationContext getEvaluationContext(); +} diff --git a/src/main/java/org/springframework/data/expression/ValueEvaluationContextProvider.java b/src/main/java/org/springframework/data/expression/ValueEvaluationContextProvider.java new file mode 100644 index 0000000000..05d7d4de2f --- /dev/null +++ b/src/main/java/org/springframework/data/expression/ValueEvaluationContextProvider.java @@ -0,0 +1,50 @@ +/* + * 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 org.springframework.data.spel.ExpressionDependencies; +import org.springframework.expression.EvaluationContext; + +/** + * SPI to provide to access a centrally defined potentially shared {@link ValueEvaluationContext}. + * + * @author Mark Paluch + * @since 3.3 + */ +public interface ValueEvaluationContextProvider { + + /** + * Return a {@link EvaluationContext} built using the given parameter values. + * + * @param rootObject the root object to set in the {@link EvaluationContext}. + * @return + */ + ValueEvaluationContext getEvaluationContext(Object rootObject); + + /** + * Return a tailored {@link EvaluationContext} built using the given parameter values and considering + * {@link ExpressionDependencies expression dependencies}. The returned {@link EvaluationContext} 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 EvaluationContext}. + * @param dependencies the requested expression dependencies to be available. + * @return + */ + default ValueEvaluationContext getEvaluationContext(Object rootObject, ExpressionDependencies dependencies) { + return getEvaluationContext(rootObject); + } +} diff --git a/src/main/java/org/springframework/data/expression/ValueExpression.java b/src/main/java/org/springframework/data/expression/ValueExpression.java new file mode 100644 index 0000000000..8f4545bcd8 --- /dev/null +++ b/src/main/java/org/springframework/data/expression/ValueExpression.java @@ -0,0 +1,65 @@ +/* + * 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 org.springframework.data.spel.ExpressionDependencies; +import org.springframework.expression.EvaluationException; +import org.springframework.lang.Nullable; + +/** + * An expression capable of evaluating itself against context objects. Encapsulates the details of a previously parsed + * expression string. Provides a common abstraction for expression evaluation. + * + * @author Christoph Strobl + * @author Mark Paluch + * @since 3.3 + */ +public interface ValueExpression { + + /** + * Returns the original string used to create this expression (unmodified). + * + * @return the original expression string. + */ + String getExpressionString(); + + /** + * Returns the expression dependencies. + * + * @return the dependencies the underlying expression requires. Can be {@link ExpressionDependencies#none()}. + */ + default ExpressionDependencies getExpressionDependencies() { + return ExpressionDependencies.none(); + } + + /** + * Returns whether the expression is a literal expression (that doesn't actually require evaluation). + * + * @return {@code true} if the expression is a literal expression; {@code false} if the expression can yield a + * different result upon {@link #evaluate(ValueEvaluationContext) evaluation}. + */ + boolean isLiteral(); + + /** + * Evaluates this expression using the given evaluation context. + * + * @return the evaluation result. + * @throws EvaluationException if there is a problem during evaluation + */ + @Nullable + Object evaluate(ValueEvaluationContext context) throws EvaluationException; + +} diff --git a/src/main/java/org/springframework/data/expression/ValueExpressionParser.java b/src/main/java/org/springframework/data/expression/ValueExpressionParser.java new file mode 100644 index 0000000000..89f9f65cdc --- /dev/null +++ b/src/main/java/org/springframework/data/expression/ValueExpressionParser.java @@ -0,0 +1,58 @@ +/* + * 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 org.springframework.expression.ParseException; + +/** + * Parses expression strings into expressions that can be evaluated. Supports parsing expression, configuration + * templates as well as literal strings. + * + * @author Christoph Strobl + * @author Mark Paluch + * @since 3.3 + */ +public interface ValueExpressionParser { + + /** + * Creates a new parser to parse expression strings. + * + * @param configuration the parser context configuration. + * @return the parser instance. + */ + static ValueExpressionParser create(ValueParserConfiguration configuration) { + return new DefaultValueExpressionParser(configuration); + } + + /** + * Parses the expression string and return an Expression object you can use for repeated evaluation. + *

+ * Some examples: + * + *

+	 *     #{3 + 4}
+	 *     #{name.firstName}
+	 *     ${key.one}
+	 *     #{name.lastName}-${key.one}
+	 * 
+ * + * @param expressionString the raw expression string to parse. + * @return an evaluator for the parsed expression. + * @throws ParseException an exception occurred during parsing. + */ + ValueExpression parse(String expressionString) throws ParseException; + +} diff --git a/src/main/java/org/springframework/data/expression/ValueParserConfiguration.java b/src/main/java/org/springframework/data/expression/ValueParserConfiguration.java new file mode 100644 index 0000000000..424ece5856 --- /dev/null +++ b/src/main/java/org/springframework/data/expression/ValueParserConfiguration.java @@ -0,0 +1,36 @@ +/* + * 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 org.springframework.expression.ExpressionParser; + +/** + * Configuration for {@link ValueExpressionParser}. + * + * @author Christoph Strobl + * @author Mark Paluch + * @since 3.3 + */ +public interface ValueParserConfiguration { + + /** + * Parser for {@link org.springframework.expression.Expression SpEL expressions}. + * + * @return for {@link org.springframework.expression.Expression SpEL expressions}. + */ + ExpressionParser getExpressionParser(); + +} diff --git a/src/main/java/org/springframework/data/expression/package-info.java b/src/main/java/org/springframework/data/expression/package-info.java new file mode 100644 index 0000000000..35ebb9ed7b --- /dev/null +++ b/src/main/java/org/springframework/data/expression/package-info.java @@ -0,0 +1,5 @@ +/** + * Value Expression implementation. + */ +@org.springframework.lang.NonNullApi +package org.springframework.data.expression; diff --git a/src/main/java/org/springframework/data/mapping/Parameter.java b/src/main/java/org/springframework/data/mapping/Parameter.java index 9738f93fc8..1254c2a37d 100644 --- a/src/main/java/org/springframework/data/mapping/Parameter.java +++ b/src/main/java/org/springframework/data/mapping/Parameter.java @@ -39,11 +39,11 @@ public class Parameter> { private final @Nullable String name; private final TypeInformation type; private final MergedAnnotations annotations; - private final String key; + private final @Nullable String expression; private final @Nullable PersistentEntity entity; private final Lazy enclosingClassCache; - private final Lazy hasSpelExpression; + private final Lazy hasExpression; /** * Creates a new {@link Parameter} with the given name, {@link TypeInformation} as well as an array of @@ -64,7 +64,7 @@ public Parameter(@Nullable String name, TypeInformation type, Annotation[] an this.name = name; this.type = type; this.annotations = MergedAnnotations.from(annotations); - this.key = getValue(this.annotations); + this.expression = getValue(this.annotations); this.entity = entity; this.enclosingClassCache = Lazy.of(() -> { @@ -77,7 +77,7 @@ public Parameter(@Nullable String name, TypeInformation type, Annotation[] an return ClassUtils.isInnerClass(owningType) && type.getType().equals(owningType.getEnclosingClass()); }); - this.hasSpelExpression = Lazy.of(() -> StringUtils.hasText(getSpelExpression())); + this.hasExpression = Lazy.of(() -> StringUtils.hasText(getValueExpression())); } @Nullable @@ -128,21 +128,62 @@ public Class getRawType() { } /** - * Returns the key to be used when looking up a source data structure to populate the actual parameter value. + * Returns the expression to be used when looking up a source data structure to populate the actual parameter value. * - * @return + * @return the expression to be used when looking up a source data structure. + * @deprecated since 3.3, use {@link #getValueExpression()} instead. */ + @Nullable public String getSpelExpression() { - return key; + return getValueExpression(); + } + + /** + * Returns the expression to be used when looking up a source data structure to populate the actual parameter value. + * + * @return the expression to be used when looking up a source data structure. + * @since 3.3 + */ + @Nullable + public String getValueExpression() { + return expression; + } + + /** + * Returns the required expression to be used when looking up a source data structure to populate the actual parameter + * value or throws {@link IllegalStateException} if there's no expression. + * + * @return the expression to be used when looking up a source data structure. + * @since 3.3 + */ + public String getRequiredValueExpression() { + + if (!hasValueExpression()) { + throw new IllegalStateException("No expression associated with this parameter"); + } + + return getValueExpression(); } /** * Returns whether the constructor parameter is equipped with a SpEL expression. * - * @return + * @return {@literal true}} if the parameter is equipped with a SpEL expression. + * @deprecated since 3.3, use {@link #hasValueExpression()} instead. */ + @Deprecated(since = "3.3") public boolean hasSpelExpression() { - return this.hasSpelExpression.get(); + return hasValueExpression(); + } + + /** + * Returns whether the constructor parameter is equipped with a value expression. + * + * @return {@literal true}} if the parameter is equipped with a value expression. + * @since 3.3 + */ + public boolean hasValueExpression() { + return this.hasExpression.get(); } @Override @@ -157,12 +198,12 @@ public boolean equals(@Nullable Object o) { } return Objects.equals(this.name, that.name) && Objects.equals(this.type, that.type) - && Objects.equals(this.key, that.key) && Objects.equals(this.entity, that.entity); + && Objects.equals(this.expression, that.expression) && Objects.equals(this.entity, that.entity); } @Override public int hashCode() { - return Objects.hash(name, type, key, entity); + return Objects.hash(name, type, expression, entity); } /** diff --git a/src/main/java/org/springframework/data/mapping/context/AbstractMappingContext.java b/src/main/java/org/springframework/data/mapping/context/AbstractMappingContext.java index 4c2ecc88f2..145ce20c80 100644 --- a/src/main/java/org/springframework/data/mapping/context/AbstractMappingContext.java +++ b/src/main/java/org/springframework/data/mapping/context/AbstractMappingContext.java @@ -35,13 +35,18 @@ import org.apache.commons.logging.LogFactory; import org.springframework.beans.BeanUtils; import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.ListableBeanFactory; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisherAware; +import org.springframework.context.EnvironmentAware; import org.springframework.core.KotlinDetector; import org.springframework.core.NativeDetector; +import org.springframework.core.env.Environment; import org.springframework.data.domain.ManagedTypes; import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.PersistentEntity; @@ -89,7 +94,8 @@ * @author Christoph Strobl */ public abstract class AbstractMappingContext, P extends PersistentProperty

> - implements MappingContext, ApplicationEventPublisherAware, ApplicationContextAware, InitializingBean { + implements MappingContext, ApplicationEventPublisherAware, ApplicationContextAware, BeanFactoryAware, + EnvironmentAware, InitializingBean { private static final Log LOGGER = LogFactory.getLog(MappingContext.class); @@ -100,6 +106,7 @@ public abstract class AbstractMappingContext> initialEntitySet) { setManagedTypes(ManagedTypes.fromIterable(initialEntitySet)); @@ -200,6 +239,7 @@ public Collection getPersistentEntities() { } } + @Override @Nullable public E getPersistentEntity(Class type) { return getPersistentEntity(TypeInformation.of(type)); @@ -406,6 +446,9 @@ private E doAddPersistentEntity(TypeInformation typeInformation) { E entity = createPersistentEntity(userTypeInformation); entity.setEvaluationContextProvider(evaluationContextProvider); + if (environment != null) { + entity.setEnvironment(environment); + } // Eagerly cache the entity as we might have to find it during recursive lookups. persistentEntities.put(userTypeInformation, Optional.of(entity)); @@ -776,4 +819,5 @@ public boolean matches(String name, Class type) { } } } + } diff --git a/src/main/java/org/springframework/data/mapping/model/AnnotationBasedPersistentProperty.java b/src/main/java/org/springframework/data/mapping/model/AnnotationBasedPersistentProperty.java index 46548a9dbb..4bd60cc391 100644 --- a/src/main/java/org/springframework/data/mapping/model/AnnotationBasedPersistentProperty.java +++ b/src/main/java/org/springframework/data/mapping/model/AnnotationBasedPersistentProperty.java @@ -37,7 +37,6 @@ import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.PersistentEntity; import org.springframework.data.mapping.PersistentProperty; -import org.springframework.data.util.ClassTypeInformation; import org.springframework.data.util.Lazy; import org.springframework.data.util.Optionals; import org.springframework.data.util.ReflectionUtils; @@ -197,10 +196,12 @@ public boolean isTransient() { return isTransient.get(); } + @Override public boolean isIdProperty() { return isId.get(); } + @Override public boolean isVersionProperty() { return isVersion.get(); } @@ -226,6 +227,7 @@ public boolean isWritable() { * @param annotationType must not be {@literal null}. * @return {@literal null} if annotation type not found on property. */ + @Override @Nullable public A findAnnotation(Class annotationType) { @@ -267,6 +269,7 @@ public A findPropertyOrOwnerAnnotation(Class annotatio * @param annotationType the annotation type to look up. * @return */ + @Override public boolean isAnnotationPresent(Class annotationType) { return doFindAnnotation(annotationType).isPresent(); } diff --git a/src/main/java/org/springframework/data/mapping/model/BasicPersistentEntity.java b/src/main/java/org/springframework/data/mapping/model/BasicPersistentEntity.java index 1f9c15d637..edf0917225 100644 --- a/src/main/java/org/springframework/data/mapping/model/BasicPersistentEntity.java +++ b/src/main/java/org/springframework/data/mapping/model/BasicPersistentEntity.java @@ -30,9 +30,11 @@ import java.util.stream.Collectors; import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.env.Environment; import org.springframework.data.annotation.Immutable; import org.springframework.data.annotation.TypeAlias; import org.springframework.data.domain.Persistable; +import org.springframework.data.expression.ValueEvaluationContext; import org.springframework.data.mapping.*; import org.springframework.data.spel.EvaluationContextProvider; import org.springframework.data.spel.ExpressionDependencies; @@ -79,6 +81,7 @@ public class BasicPersistentEntity> implement private @Nullable P versionProperty; private PersistentPropertyAccessorFactory propertyAccessorFactory; private EvaluationContextProvider evaluationContextProvider = EvaluationContextProvider.DEFAULT; + private @Nullable Environment environment = null; private final Lazy typeAlias; private final Lazy isNewStrategy; @@ -146,36 +149,44 @@ public boolean isCreatorArgument(PersistentProperty property) { return creator != null && creator.isCreatorParameter(property); } + @Override public boolean isIdProperty(PersistentProperty property) { return idProperty != null && idProperty.equals(property); } + @Override public boolean isVersionProperty(PersistentProperty property) { return versionProperty != null && versionProperty.equals(property); } + @Override public String getName() { return getType().getName(); } + @Override @Nullable public P getIdProperty() { return idProperty; } + @Override @Nullable public P getVersionProperty() { return versionProperty; } + @Override public boolean hasIdProperty() { return idProperty != null; } + @Override public boolean hasVersionProperty() { return versionProperty != null; } + @Override public void addPersistentProperty(P property) { Assert.notNull(property, "Property must not be null"); @@ -205,10 +216,8 @@ public void addPersistentProperty(P property) { if (versionProperty != null) { throw new MappingException( - String.format( - "Attempt to add version property %s but already have property %s registered " - + "as version; Check your mapping configuration", - property.getField(), versionProperty.getField())); + String.format("Attempt to add version property %s but already have property %s registered " + + "as version; Check your mapping configuration", property.getField(), versionProperty.getField())); } this.versionProperty = property; @@ -220,6 +229,15 @@ public void setEvaluationContextProvider(EvaluationContextProvider provider) { this.evaluationContextProvider = provider; } + /** + * @param environment the {@code Environment} that this component runs in. + * @since 3.3 + */ + @Override + public void setEnvironment(Environment environment) { + this.environment = environment; + } + /** * Returns the given property if it is a better candidate for the id property than the current id property. * @@ -243,6 +261,7 @@ protected P returnPropertyIfBetterIdPropertyCandidateOrNull(P property) { return property; } + @Override public void addAssociation(Association

association) { Assert.notNull(association, "Association must not be null"); @@ -278,18 +297,22 @@ private List

doFindPersistentProperty(Class annotationT .filter(it -> it.isAnnotationPresent(annotationType)).collect(Collectors.toList()); } + @Override public Class getType() { return information.getType(); } + @Override public Alias getTypeAlias() { return typeAlias.get(); } + @Override public TypeInformation getTypeInformation() { return information; } + @Override public void doWithProperties(PropertyHandler

handler) { Assert.notNull(handler, "PropertyHandler must not be null"); @@ -309,6 +332,7 @@ public void doWithProperties(SimplePropertyHandler handler) { } } + @Override public void doWithAssociations(AssociationHandler

handler) { Assert.notNull(handler, "Handler must not be null"); @@ -318,6 +342,7 @@ public void doWithAssociations(AssociationHandler

handler) { } } + @Override public void doWithAssociations(SimpleAssociationHandler handler) { Assert.notNull(handler, "Handler must not be null"); @@ -345,6 +370,7 @@ private Optional doFindAnnotation(Class annotationT it -> Optional.ofNullable(AnnotatedElementUtils.findMergedAnnotation(getType(), it))); } + @Override public void verify() { if (comparator != null) { @@ -443,6 +469,29 @@ protected EvaluationContext getEvaluationContext(Object rootObject, ExpressionDe return evaluationContextProvider.getEvaluationContext(rootObject, dependencies); } + /** + * Obtain a {@link ValueEvaluationContext} for a {@code rootObject}. + * + * @param rootObject must not be {@literal null}. + * @return the evaluation context including all potential extensions. + * @since 3.3 + */ + protected ValueEvaluationContext getValueEvaluationContext(Object rootObject) { + return ValueEvaluationContext.of(this.environment, getEvaluationContext(rootObject)); + } + + /** + * Obtain a {@link ValueEvaluationContext} for a {@code rootObject} given {@link ExpressionDependencies}. + * + * @param rootObject must not be {@literal null}. + * @param dependencies must not be {@literal null}. + * @return the evaluation context with extensions loaded that satisfy {@link ExpressionDependencies}. + * @since 3.3 + */ + protected ValueEvaluationContext getValueEvaluationContext(Object rootObject, ExpressionDependencies dependencies) { + return ValueEvaluationContext.of(this.environment, getEvaluationContext(rootObject, dependencies)); + } + /** * Returns the default {@link IsNewStrategy} to be used. Will be a {@link PersistentEntityIsNewStrategy} by default. * Note, that this strategy only gets used if the entity doesn't implement {@link Persistable} as this indicates the @@ -518,6 +567,7 @@ private static final class AssociationComparator

this.delegate = delegate; } + @Override public int compare(@Nullable Association

left, @Nullable Association

right) { if (left == null) { diff --git a/src/main/java/org/springframework/data/mapping/model/CachingValueExpressionEvaluatorFactory.java b/src/main/java/org/springframework/data/mapping/model/CachingValueExpressionEvaluatorFactory.java new file mode 100644 index 0000000000..91be052dc2 --- /dev/null +++ b/src/main/java/org/springframework/data/mapping/model/CachingValueExpressionEvaluatorFactory.java @@ -0,0 +1,88 @@ +/* + * 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.mapping.model; + +import org.springframework.core.env.EnvironmentCapable; +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.data.spel.EvaluationContextProvider; +import org.springframework.data.spel.ExpressionDependencies; +import org.springframework.expression.ExpressionParser; +import org.springframework.util.Assert; +import org.springframework.util.ConcurrentLruCache; + +/** + * Factory to create a ValueExpressionEvaluator + * + * @author Mark Paluch + * @since 3.3 + */ +public class CachingValueExpressionEvaluatorFactory implements ValueEvaluationContextProvider { + + private final ConcurrentLruCache expressionCache; + private final EnvironmentCapable environmentProvider; + private final EvaluationContextProvider evaluationContextProvider; + + public CachingValueExpressionEvaluatorFactory(ExpressionParser expressionParser, + EnvironmentCapable environmentProvider, EvaluationContextProvider evaluationContextProvider) { + this(expressionParser, environmentProvider, evaluationContextProvider, 256); + } + + public CachingValueExpressionEvaluatorFactory(ExpressionParser expressionParser, + EnvironmentCapable environmentProvider, EvaluationContextProvider evaluationContextProvider, int cacheSize) { + + Assert.notNull(expressionParser, "ExpressionParser must not be null"); + + ValueExpressionParser parser = ValueExpressionParser.create(() -> expressionParser); + this.expressionCache = new ConcurrentLruCache<>(cacheSize, parser::parse); + this.environmentProvider = environmentProvider; + this.evaluationContextProvider = evaluationContextProvider; + } + + @Override + public ValueEvaluationContext getEvaluationContext(Object rootObject) { + return ValueEvaluationContext.of(environmentProvider.getEnvironment(), + evaluationContextProvider.getEvaluationContext(rootObject)); + } + + @Override + public ValueEvaluationContext getEvaluationContext(Object rootObject, ExpressionDependencies dependencies) { + return ValueEvaluationContext.of(environmentProvider.getEnvironment(), + evaluationContextProvider.getEvaluationContext(rootObject, dependencies)); + } + + /** + * Creates a new {@link ValueExpressionEvaluator} using the given {@code source} as root object. + * + * @param source the root object for evaluating the expression. + * @return a new {@link ValueExpressionEvaluator} to evaluate the expression in the context of the given + * {@code source} object. + */ + public ValueExpressionEvaluator create(Object source) { + + return new ValueExpressionEvaluator() { + @SuppressWarnings("unchecked") + @Override + public T evaluate(String expression) { + ValueExpression valueExpression = expressionCache.get(expression); + return (T) valueExpression.evaluate(getEvaluationContext(source, valueExpression.getExpressionDependencies())); + } + }; + } + +} diff --git a/src/main/java/org/springframework/data/mapping/model/DefaultSpELExpressionEvaluator.java b/src/main/java/org/springframework/data/mapping/model/DefaultSpELExpressionEvaluator.java index 693056b590..9b88632a43 100644 --- a/src/main/java/org/springframework/data/mapping/model/DefaultSpELExpressionEvaluator.java +++ b/src/main/java/org/springframework/data/mapping/model/DefaultSpELExpressionEvaluator.java @@ -28,7 +28,9 @@ * {@link SpelExpressionParser} and {@link EvaluationContext}. * * @author Oliver Gierke + * @deprecated since 3.3, use {@link CachingValueExpressionEvaluatorFactory} instead. */ +@Deprecated(since = "3.3") public class DefaultSpELExpressionEvaluator implements SpELExpressionEvaluator { private final Object source; diff --git a/src/main/java/org/springframework/data/mapping/model/MutablePersistentEntity.java b/src/main/java/org/springframework/data/mapping/model/MutablePersistentEntity.java index d66aa33517..d5c493b5a5 100644 --- a/src/main/java/org/springframework/data/mapping/model/MutablePersistentEntity.java +++ b/src/main/java/org/springframework/data/mapping/model/MutablePersistentEntity.java @@ -15,6 +15,7 @@ */ package org.springframework.data.mapping.model; +import org.springframework.context.EnvironmentAware; import org.springframework.data.mapping.Association; import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.PersistentEntity; @@ -28,7 +29,8 @@ * @author Oliver Gierke * @author Mark Paluch */ -public interface MutablePersistentEntity> extends PersistentEntity { +public interface MutablePersistentEntity> + extends PersistentEntity, EnvironmentAware { /** * Adds a {@link PersistentProperty} to the entity. @@ -66,4 +68,5 @@ public interface MutablePersistentEntity> ext * @param provider must not be {@literal null}. */ void setEvaluationContextProvider(EvaluationContextProvider provider); + } diff --git a/src/main/java/org/springframework/data/mapping/model/PersistentEntityParameterValueProvider.java b/src/main/java/org/springframework/data/mapping/model/PersistentEntityParameterValueProvider.java index bfc4762c00..708c91dd6d 100644 --- a/src/main/java/org/springframework/data/mapping/model/PersistentEntityParameterValueProvider.java +++ b/src/main/java/org/springframework/data/mapping/model/PersistentEntityParameterValueProvider.java @@ -45,6 +45,7 @@ public PersistentEntityParameterValueProvider(PersistentEntity entity, Pro this.parent = parent; } + @Override @Nullable @SuppressWarnings("unchecked") public T getParameterValue(Parameter parameter) { diff --git a/src/main/java/org/springframework/data/mapping/model/SpELContext.java b/src/main/java/org/springframework/data/mapping/model/SpELContext.java index 113e643ca0..005333c73a 100644 --- a/src/main/java/org/springframework/data/mapping/model/SpELContext.java +++ b/src/main/java/org/springframework/data/mapping/model/SpELContext.java @@ -17,6 +17,7 @@ import org.springframework.beans.factory.BeanFactory; import org.springframework.context.expression.BeanFactoryResolver; +import org.springframework.data.spel.EvaluationContextProvider; import org.springframework.expression.EvaluationContext; import org.springframework.expression.ExpressionParser; import org.springframework.expression.PropertyAccessor; @@ -30,7 +31,7 @@ * * @author Oliver Gierke */ -public class SpELContext { +public class SpELContext implements EvaluationContextProvider { private final SpelExpressionParser parser; private final PropertyAccessor accessor; @@ -90,6 +91,7 @@ public ExpressionParser getParser() { return this.parser; } + @Override public EvaluationContext getEvaluationContext(Object source) { StandardEvaluationContext evaluationContext = new StandardEvaluationContext(source); diff --git a/src/main/java/org/springframework/data/mapping/model/SpELExpressionEvaluator.java b/src/main/java/org/springframework/data/mapping/model/SpELExpressionEvaluator.java index 8e559c53e0..92aa4a2c76 100644 --- a/src/main/java/org/springframework/data/mapping/model/SpELExpressionEvaluator.java +++ b/src/main/java/org/springframework/data/mapping/model/SpELExpressionEvaluator.java @@ -15,21 +15,12 @@ */ package org.springframework.data.mapping.model; -import org.springframework.lang.Nullable; - /** * SPI for components that can evaluate Spring EL expressions. * * @author Oliver Gierke */ -public interface SpELExpressionEvaluator { +@Deprecated(since = "3.3") +public interface SpELExpressionEvaluator extends ValueExpressionEvaluator { - /** - * Evaluates the given expression. - * - * @param expression - * @return - */ - @Nullable - T evaluate(String expression); } diff --git a/src/main/java/org/springframework/data/mapping/model/SpELExpressionParameterValueProvider.java b/src/main/java/org/springframework/data/mapping/model/SpELExpressionParameterValueProvider.java index 71df544f39..117f0a5cea 100644 --- a/src/main/java/org/springframework/data/mapping/model/SpELExpressionParameterValueProvider.java +++ b/src/main/java/org/springframework/data/mapping/model/SpELExpressionParameterValueProvider.java @@ -16,9 +16,7 @@ package org.springframework.data.mapping.model; import org.springframework.core.convert.ConversionService; -import org.springframework.data.mapping.Parameter; import org.springframework.data.mapping.PersistentProperty; -import org.springframework.lang.Nullable; /** * {@link ParameterValueProvider} that can be used to front a {@link ParameterValueProvider} delegate to prefer a SpEL @@ -26,43 +24,16 @@ * * @author Oliver Gierke * @author Mark Paluch + * @deprecated since 3.3, use {@link ValueExpressionParameterValueProvider} instead. */ +@Deprecated(since = "3.3") public class SpELExpressionParameterValueProvider

> - implements ParameterValueProvider

{ - - private final SpELExpressionEvaluator evaluator; - private final ConversionService conversionService; - private final ParameterValueProvider

delegate; + extends ValueExpressionParameterValueProvider

implements ParameterValueProvider

{ public SpELExpressionParameterValueProvider(SpELExpressionEvaluator evaluator, ConversionService conversionService, ParameterValueProvider

delegate) { - this.evaluator = evaluator; - this.conversionService = conversionService; - this.delegate = delegate; - } - - @Nullable - public T getParameterValue(Parameter parameter) { - - if (!parameter.hasSpelExpression()) { - return delegate == null ? null : delegate.getParameterValue(parameter); - } - - Object object = evaluator.evaluate(parameter.getSpelExpression()); - return object == null ? null : potentiallyConvertSpelValue(object, parameter); + super(evaluator, conversionService, delegate); } - /** - * Hook to allow to massage the value resulting from the Spel expression evaluation. Default implementation will - * leverage the configured {@link ConversionService} to massage the value into the parameter type. - * - * @param object the value to massage, will never be {@literal null}. - * @param parameter the {@link Parameter} we create the value for - * @return - */ - @Nullable - protected T potentiallyConvertSpelValue(Object object, Parameter parameter) { - return conversionService.convert(object, parameter.getRawType()); - } } diff --git a/src/main/java/org/springframework/data/mapping/model/ValueExpressionEvaluator.java b/src/main/java/org/springframework/data/mapping/model/ValueExpressionEvaluator.java new file mode 100644 index 0000000000..bac22cd7ec --- /dev/null +++ b/src/main/java/org/springframework/data/mapping/model/ValueExpressionEvaluator.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-2023 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.mapping.model; + +import org.springframework.lang.Nullable; + +/** + * SPI for components that can evaluate Value expressions. + * + * @author Mark Paluch + * @since 3.2 + */ +public interface ValueExpressionEvaluator { + + /** + * Evaluates the given expression. + * + * @param expression + * @return + */ + @Nullable + T evaluate(String expression); +} diff --git a/src/main/java/org/springframework/data/mapping/model/ValueExpressionParameterValueProvider.java b/src/main/java/org/springframework/data/mapping/model/ValueExpressionParameterValueProvider.java new file mode 100644 index 0000000000..96ba2f11a8 --- /dev/null +++ b/src/main/java/org/springframework/data/mapping/model/ValueExpressionParameterValueProvider.java @@ -0,0 +1,97 @@ +/* + * Copyright 2012-2023 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.mapping.model; + +import org.springframework.core.convert.ConversionService; +import org.springframework.data.mapping.Parameter; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * {@link ParameterValueProvider} that can be used to front a {@link ParameterValueProvider} delegate to prefer a SpEL + * expression evaluation over directly resolving the parameter value with the delegate. + * + * @author Oliver Gierke + * @author Mark Paluch + * @since 3.3 + */ +public class ValueExpressionParameterValueProvider

> + implements ParameterValueProvider

{ + + private final ValueExpressionEvaluator evaluator; + private final ConversionService conversionService; + private final ParameterValueProvider

delegate; + + public ValueExpressionParameterValueProvider(ValueExpressionEvaluator evaluator, ConversionService conversionService, + ParameterValueProvider

delegate) { + + Assert.notNull(evaluator, "ValueExpressionEvaluator must not be null"); + Assert.notNull(conversionService, "ConversionService must not be null"); + Assert.notNull(delegate, "Delegate must not be null"); + + this.evaluator = evaluator; + this.conversionService = conversionService; + this.delegate = delegate; + } + + @Override + @Nullable + public T getParameterValue(Parameter parameter) { + + if (!parameter.hasValueExpression()) { + return delegate.getParameterValue(parameter); + } + + // retain compatibility where we accepted bare expressions in @Value + String rawExpressionString = parameter.getRequiredValueExpression(); + String expressionString = rawExpressionString.contains("#{") || rawExpressionString.contains("${") + ? rawExpressionString + : "#{" + rawExpressionString + "}"; + + Object object = evaluator.evaluate(expressionString); + return object == null ? null : potentiallyConvertExpressionValue(object, parameter); + } + + /** + * Hook to allow to massage the value resulting from the Spel expression evaluation. Default implementation will + * leverage the configured {@link ConversionService} to massage the value into the parameter type. + * + * @param object the value to massage, will never be {@literal null}. + * @param parameter the {@link Parameter} we create the value for + * @return the converted parameter value. + * @deprecated since 3.3, use {@link #potentiallyConvertExpressionValue(Object, Parameter)} instead. + */ + @Nullable + @Deprecated(since = "3.3") + protected T potentiallyConvertSpelValue(Object object, Parameter parameter) { + return conversionService.convert(object, parameter.getRawType()); + } + + /** + * Hook to allow to massage the value resulting from the Spel expression evaluation. Default implementation will + * leverage the configured {@link ConversionService} to massage the value into the parameter type. + * + * @param object the value to massage, will never be {@literal null}. + * @param parameter the {@link Parameter} we create the value for + * @return the converted parameter value. + * @since 3.3 + */ + @Nullable + protected T potentiallyConvertExpressionValue(Object object, Parameter parameter) { + return potentiallyConvertSpelValue(object, parameter); + } +} diff --git a/src/main/java/org/springframework/data/projection/SpelAwareProxyProjectionFactory.java b/src/main/java/org/springframework/data/projection/SpelAwareProxyProjectionFactory.java index bc63daffde..edf3150ece 100644 --- a/src/main/java/org/springframework/data/projection/SpelAwareProxyProjectionFactory.java +++ b/src/main/java/org/springframework/data/projection/SpelAwareProxyProjectionFactory.java @@ -28,6 +28,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.data.util.AnnotationDetectionMethodCallback; +import org.springframework.expression.ExpressionParser; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -46,10 +47,29 @@ public class SpelAwareProxyProjectionFactory extends ProxyProjectionFactory implements BeanFactoryAware { private final Map, Boolean> typeCache = new ConcurrentHashMap<>(); - private final SpelExpressionParser parser = new SpelExpressionParser(); + private final ExpressionParser parser; private @Nullable BeanFactory beanFactory; + /** + * Create a new {@link SpelAwareProxyProjectionFactory}. + */ + public SpelAwareProxyProjectionFactory() { + this(new SpelExpressionParser()); + } + + /** + * Create a new {@link SpelAwareProxyProjectionFactory} for a given {@link ExpressionParser}. + * + * @param parser the parser to use. + * @since 3.3 + */ + public SpelAwareProxyProjectionFactory(ExpressionParser parser) { + + Assert.notNull(parser, "ExpressionParser must not be null"); + this.parser = parser; + } + @Override public void setBeanFactory(BeanFactory beanFactory) throws BeansException { this.beanFactory = beanFactory; diff --git a/src/main/java/org/springframework/data/projection/SpelEvaluatingMethodInterceptor.java b/src/main/java/org/springframework/data/projection/SpelEvaluatingMethodInterceptor.java index a2039da1eb..c5d5fc3641 100644 --- a/src/main/java/org/springframework/data/projection/SpelEvaluatingMethodInterceptor.java +++ b/src/main/java/org/springframework/data/projection/SpelEvaluatingMethodInterceptor.java @@ -30,9 +30,9 @@ import org.springframework.core.annotation.AnnotationUtils; import org.springframework.expression.EvaluationContext; import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; import org.springframework.expression.ParserContext; import org.springframework.expression.common.TemplateParserContext; -import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -71,7 +71,7 @@ class SpelEvaluatingMethodInterceptor implements MethodInterceptor { * @param targetInterface must not be {@literal null}. */ public SpelEvaluatingMethodInterceptor(MethodInterceptor delegate, Object target, @Nullable BeanFactory beanFactory, - SpelExpressionParser parser, Class targetInterface) { + ExpressionParser parser, Class targetInterface) { Assert.notNull(delegate, "Delegate MethodInterceptor must not be null"); Assert.notNull(target, "Target object must not be null"); @@ -105,7 +105,7 @@ public SpelEvaluatingMethodInterceptor(MethodInterceptor delegate, Object target * @return */ private static Map potentiallyCreateExpressionsForMethodsOnTargetInterface( - SpelExpressionParser parser, Class targetInterface) { + ExpressionParser parser, Class targetInterface) { Map expressions = new HashMap<>(); diff --git a/src/main/java/org/springframework/data/spel/package-info.java b/src/main/java/org/springframework/data/spel/package-info.java new file mode 100644 index 0000000000..d45330a59c --- /dev/null +++ b/src/main/java/org/springframework/data/spel/package-info.java @@ -0,0 +1,5 @@ +/** + * SpEL support. + */ +@org.springframework.lang.NonNullApi +package org.springframework.data.spel; diff --git a/src/main/java/org/springframework/data/support/PlaceholderResolver.java b/src/main/java/org/springframework/data/support/PlaceholderResolver.java new file mode 100644 index 0000000000..5fbdd7fa4e --- /dev/null +++ b/src/main/java/org/springframework/data/support/PlaceholderResolver.java @@ -0,0 +1,25 @@ +/* + * Copyright 2023 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.support; + +/** + * @author Christoph Strobl + * @since 3.3 + */ +public interface PlaceholderResolver { + + String resolvePlaceholders(String text); +} diff --git a/src/test/java/org/springframework/data/expression/ValueEvaluationUnitTests.java b/src/test/java/org/springframework/data/expression/ValueEvaluationUnitTests.java new file mode 100644 index 0000000000..59269b2b4a --- /dev/null +++ b/src/test/java/org/springframework/data/expression/ValueEvaluationUnitTests.java @@ -0,0 +1,141 @@ +/* + * 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 static org.assertj.core.api.Assertions.*; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.core.env.Environment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.StandardEnvironment; +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; + +/** + * Unit tests for {@link ValueExpression} and its parsing. + * + * @author Christoph Strobl + * @author Mark Paluch + */ +public class ValueEvaluationUnitTests { + + private ValueEvaluationContext evaluationContext; + + @BeforeEach + void setUp() { + + MapPropertySource propertySource = new MapPropertySource("map", Map.of("env.key.one", "value", "env.key.two", 42L)); + StandardEnvironment environment = new StandardEnvironment(); + environment.getPropertySources().addFirst(propertySource); + + this.evaluationContext = new ValueEvaluationContext() { + @Override + public Environment getEnvironment() { + return environment; + } + + @Override + public EvaluationContext getEvaluationContext() { + StandardEvaluationContext context = new StandardEvaluationContext(); + context.setVariable("contextVar", "contextVal"); + + return context; + } + }; + } + + @Test // GH-2369 + void shouldParseAndEvaluateExpressions() { + + assertThat(eval("foo")).isEqualTo("foo"); + assertThat(eval("${env.key.one}")).isEqualTo("value"); + assertThat(eval("${env.key.two}")).isEqualTo("42"); + assertThat(eval("${env.key.one")).isEqualTo("${env.key.one"); + assertThat(eval("#{'foo'}-")).isEqualTo("foo-"); + assertThat(eval("#{'fo\"o'}-")).isEqualTo("fo\"o-"); + assertThat(eval("#{\"foo\"}-")).isEqualTo("foo-"); + assertThat(eval("#{\"fo'o\"}-")).isEqualTo("fo'o-"); + assertThat(eval("${env.key.one}-")).isEqualTo("value-"); + assertThat(eval("#{#contextVar}")).isEqualTo("contextVal"); + assertThat(eval("${env.does.not.exist:some-default}-")).isEqualTo("some-default-"); + + assertThatExceptionOfType(EvaluationException.class).isThrownBy(() -> eval("${env.does.not.exist}-")) + .withMessageContaining("Could not resolve placeholder 'env.does.not.exist'"); + } + + @Test // GH-2369 + void shouldParseLiteral() { + + ValueParserConfiguration parserContext = () -> new SpelExpressionParser(); + ValueExpressionParser parser = ValueExpressionParser.create(parserContext); + + assertThat(parser.parse("#{'foo'}-${key.one}").isLiteral()).isFalse(); + assertThat(parser.parse("foo").isLiteral()).isTrue(); + assertThat(parser.parse("#{'foo'}").isLiteral()).isFalse(); + assertThat(parser.parse("${key.one}").isLiteral()).isFalse(); + } + + @Test // GH-2369 + void shouldParseCompoundExpression() { + + assertThat(eval("#{'foo'}-")).isEqualTo("foo-"); + assertThat(eval("#{'fo\"o'}-")).isEqualTo("fo\"o-"); + assertThat(eval("#{\"foo\"}-")).isEqualTo("foo-"); + assertThat(eval("#{\"fo'o\"}-")).isEqualTo("fo'o-"); + assertThat(eval("${env.key.one}-")).isEqualTo("value-"); + assertThat(eval("${env.key.one}-and-some-#{'foo'}-#{#contextVar}")).isEqualTo("value-and-some-foo-contextVal"); + } + + @Test // GH-2369 + void shouldRaiseParseException() { + + assertThatExceptionOfType(ParseException.class).isThrownBy(() -> eval("#{'foo'}-${key.one")).withMessageContaining( + "Expression [#{'foo'}-${key.one] @9: No ending suffix '}' for expression starting at character 9: ${key.one"); + + assertThatExceptionOfType(ParseException.class).isThrownBy(() -> eval("#{'foo'}-${env.key.one")); + assertThatExceptionOfType(ParseException.class).isThrownBy(() -> eval("#{'foo'")); + + assertThatExceptionOfType(ParseException.class).isThrownBy(() -> eval("#{#foo - ${numeric.key}}")) + .withMessageContaining("[#foo - ${numeric.key}]"); + + } + + @Test // GH-2369 + void shouldParseQuoted() { + + assertThat(eval("#{(1+1) + '-foo'+\"-bar\"}")).isEqualTo("2-foo-bar"); + assertThat(eval("#{(1+1) + '-foo'+\"-bar}\"}")).isEqualTo("2-foo-bar}"); + assertThat(eval("#{(1+1) + '-foo}'}")).isEqualTo("2-foo}"); + assertThat(eval("#{(1+1) + '-foo\\}'}")).isEqualTo("2-foo\\}"); + assertThat(eval("#{(1+1) + \"-foo'}\" + '-bar}'}")).isEqualTo("2-foo'}-bar}"); + } + + private String eval(String expressionString) { + + ValueParserConfiguration parserContext = SpelExpressionParser::new; + + ValueExpressionParser parser = ValueExpressionParser.create(parserContext); + return (String) parser.parse(expressionString).evaluate(evaluationContext); + } + +} diff --git a/src/test/java/org/springframework/data/mapping/PreferredConstructorDiscovererUnitTests.java b/src/test/java/org/springframework/data/mapping/PreferredConstructorDiscovererUnitTests.java index 2a4ac1028e..08853a7e67 100755 --- a/src/test/java/org/springframework/data/mapping/PreferredConstructorDiscovererUnitTests.java +++ b/src/test/java/org/springframework/data/mapping/PreferredConstructorDiscovererUnitTests.java @@ -147,7 +147,7 @@ void detectsMetaAnnotatedValueAnnotation() { var constructor = PreferredConstructorDiscoverer.discover(ClassWithMetaAnnotatedParameter.class); assertThat(constructor).isNotNull(); - assertThat(constructor.getParameters().get(0).getSpelExpression()).isEqualTo("${hello-world}"); + assertThat(constructor.getParameters().get(0).getValueExpression()).isEqualTo("${hello-world}"); assertThat(constructor.getParameters().get(0).getAnnotations()).isNotNull(); } diff --git a/src/test/java/org/springframework/data/mapping/model/CachingValueExpressionEvaluatorFactoryUnitTests.java b/src/test/java/org/springframework/data/mapping/model/CachingValueExpressionEvaluatorFactoryUnitTests.java new file mode 100644 index 0000000000..41670d46c0 --- /dev/null +++ b/src/test/java/org/springframework/data/mapping/model/CachingValueExpressionEvaluatorFactoryUnitTests.java @@ -0,0 +1,64 @@ +/* + * 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.mapping.model; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.core.env.StandardEnvironment; +import org.springframework.data.spel.EvaluationContextProvider; +import org.springframework.data.spel.ExpressionDependencies; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +/** + * Unit tests for {@link CachingValueExpressionEvaluatorFactory}. + * + * @author Mark Paluch + */ +class CachingValueExpressionEvaluatorFactoryUnitTests { + + @Test // GH-2369 + void shouldEvaluateWithDependencies() { + + EvaluationContextProvider contextProviderMock = mock(EvaluationContextProvider.class); + CachingValueExpressionEvaluatorFactory factory = new CachingValueExpressionEvaluatorFactory( + new SpelExpressionParser(), StandardEnvironment::new, contextProviderMock); + ValueExpressionEvaluator evaluator = factory.create(new MyRoot("foo")); + + when(contextProviderMock.getEvaluationContext(any(), any(ExpressionDependencies.class))) + .then(invocation -> new StandardEvaluationContext(invocation.getArgument(0))); + + Object result = evaluator.evaluate("#{root}"); + + assertThat(result).isEqualTo("foo"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ExpressionDependencies.class); + + verify(contextProviderMock).getEvaluationContext(any(), captor.capture()); + + ExpressionDependencies value = captor.getValue(); + assertThat(value).hasSize(1).isNotEqualTo(ExpressionDependencies.none()); + } + + record MyRoot(String root) { + + } + +} diff --git a/src/test/java/org/springframework/data/mapping/model/SpelExpressionParameterProviderUnitTests.java b/src/test/java/org/springframework/data/mapping/model/SpelExpressionParameterProviderUnitTests.java index 04425f395e..929515e2de 100755 --- a/src/test/java/org/springframework/data/mapping/model/SpelExpressionParameterProviderUnitTests.java +++ b/src/test/java/org/springframework/data/mapping/model/SpelExpressionParameterProviderUnitTests.java @@ -53,7 +53,7 @@ void setUp() { provider = new SpELExpressionParameterValueProvider<>(evaluator, conversionService, delegate); parameter = mock(Parameter.class); - when(parameter.hasSpelExpression()).thenReturn(true); + when(parameter.hasValueExpression()).thenReturn(true); when(parameter.getRawType()).thenReturn(Object.class); } @@ -62,7 +62,7 @@ void setUp() { void delegatesIfParameterDoesNotHaveASpELExpression() { Parameter parameter = mock(Parameter.class); - when(parameter.hasSpelExpression()).thenReturn(false); + when(parameter.hasValueExpression()).thenReturn(false); provider.getParameterValue(parameter); verify(delegate, times(1)).getParameterValue(parameter); @@ -72,17 +72,17 @@ void delegatesIfParameterDoesNotHaveASpELExpression() { @Test void evaluatesSpELExpression() { - when(parameter.getSpelExpression()).thenReturn("expression"); + when(parameter.getRequiredValueExpression()).thenReturn("expression"); provider.getParameterValue(parameter); verify(delegate, times(0)).getParameterValue(parameter); - verify(evaluator, times(1)).evaluate("expression"); + verify(evaluator, times(1)).evaluate("#{expression}"); } @Test void handsSpELValueToConversionService() { - doReturn("source").when(parameter).getSpelExpression(); + doReturn("source").when(parameter).getRequiredValueExpression(); doReturn("value").when(evaluator).evaluate(any()); provider.getParameterValue(parameter); @@ -94,7 +94,7 @@ void handsSpELValueToConversionService() { @Test void doesNotConvertNullValue() { - doReturn("source").when(parameter).getSpelExpression(); + doReturn("source").when(parameter).getRequiredValueExpression(); doReturn(null).when(evaluator).evaluate(any()); provider.getParameterValue(parameter); @@ -116,7 +116,7 @@ protected T potentiallyConvertSpelValue(Object object, Parameter { + + SpELContext spELContext = new SpELContext(new MapAccessor()); + return spELContext.getEvaluationContext(rootObject); + }); + + ValueExpressionParameterValueProvider provider = new ValueExpressionParameterValueProvider<>( + factory.create(Map.of("name", "Walter")), DefaultConversionService.getSharedInstance(), + new ParameterValueProvider() { + @Override + public T getParameterValue(Parameter parameter) { + return null; + } + }); + + @Test // GH-2369 + void considersValueCompatibilityFormat() { + + Annotation[] annotations = CompatibilityConstructor.class.getConstructors()[0].getParameterAnnotations()[0]; + String name = provider + .getParameterValue(new Parameter<>("name", TypeInformation.of(String.class), annotations, null)); + + assertThat(name).isEqualTo("Walter"); + } + + @Test // GH-2369 + void considersValueTemplateFormat() { + + Annotation[] annotations = TemplateConstructor.class.getConstructors()[0].getParameterAnnotations()[0]; + String name = provider + .getParameterValue(new Parameter<>("name", TypeInformation.of(String.class), annotations, null)); + + assertThat(name).isEqualTo("Walter"); + } + + static class CompatibilityConstructor { + + public CompatibilityConstructor(@Value("#root.name") String name) {} + + } + + static class TemplateConstructor { + + public TemplateConstructor(@Value("#{#root.name}") String name) {} + + } + +} diff --git a/src/test/resources/persistent-entity.properties b/src/test/resources/persistent-entity.properties new file mode 100644 index 0000000000..89c335b615 --- /dev/null +++ b/src/test/resources/persistent-entity.properties @@ -0,0 +1 @@ +my-type.name=foo