diff --git a/pom.xml b/pom.xml index 4f26a47983..052c354733 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-commons - 2.1.0.BUILD-SNAPSHOT + 2.1.0.DATACMNS-1258-SNAPSHOT Spring Data Core diff --git a/src/main/java/org/springframework/data/repository/query/parser/QuotationMap.java b/src/main/java/org/springframework/data/repository/query/parser/QuotationMap.java new file mode 100644 index 0000000000..d306ee389b --- /dev/null +++ b/src/main/java/org/springframework/data/repository/query/parser/QuotationMap.java @@ -0,0 +1,82 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.repository.query.parser; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import org.springframework.data.domain.Range; +import org.springframework.lang.Nullable; + +/** + * Value object to analyze a String to determine the parts of the String that are quoted and offers an API to query that + * information. + * + * @author Jens Schauder + * @since 2.0.3 + */ +class QuotationMap { + + private static final Collection QUOTING_CHARACTERS = Arrays.asList('"', '\''); + + private final List> quotedRanges = new ArrayList<>(); + + 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.of(Range.Bound.inclusive(start), Range.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)); + } +} diff --git a/src/main/java/org/springframework/data/repository/query/parser/SpelEvaluator.java b/src/main/java/org/springframework/data/repository/query/parser/SpelEvaluator.java new file mode 100644 index 0000000000..4784e633ba --- /dev/null +++ b/src/main/java/org/springframework/data/repository/query/parser/SpelEvaluator.java @@ -0,0 +1,75 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.repository.query.parser; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.data.repository.query.Parameters; +import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.data.repository.query.parser.SpelQueryContext.SpelExtractor; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Evaluates SpEL expressions as extracted by the SpelExtractor based on parameter information from a method and + * parameter values from a method call. + * + * @author Jens Schauder + * @author Gerrit Meier + */ +@RequiredArgsConstructor +public class SpelEvaluator { + + private final static SpelExpressionParser PARSER = new SpelExpressionParser(); + + private final @NonNull QueryMethodEvaluationContextProvider evaluationContextProvider; + private final @NonNull Parameters parameters; + + /** + * A map from parameter name to SpEL expression as returned by + * {@link SpelQueryContext.SpelExtractor#parameterNameToSpelMap()}. + */ + private final @NonNull SpelExtractor extractor; + + /** + * Evaluate all the SpEL expressions in {@link #parameterNameToSpelMap} 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. Guaranteed to be not {@literal null}. + */ + public Map evaluate(Object[] values) { + + Assert.notNull(values, "Values must not be null."); + + EvaluationContext evaluationContext = evaluationContextProvider.getEvaluationContext(parameters, values); + + return extractor.parameters().collect(Collectors.toMap(// + it -> it.getKey(), // + it -> getSpElValue(evaluationContext, it.getValue()) // + )); + } + + @Nullable + private static Object getSpElValue(EvaluationContext evaluationContext, String expression) { + return PARSER.parseExpression(expression).getValue(evaluationContext, Object.class); + } +} diff --git a/src/main/java/org/springframework/data/repository/query/parser/SpelQueryContext.java b/src/main/java/org/springframework/data/repository/query/parser/SpelQueryContext.java new file mode 100644 index 0000000000..e2069d0dd5 --- /dev/null +++ b/src/main/java/org/springframework/data/repository/query/parser/SpelQueryContext.java @@ -0,0 +1,177 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.repository.query.parser; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.function.BiFunction; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.springframework.data.repository.query.Parameters; +import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.util.Assert; + +/** + * Source of {@link SpelExtractor} encapsulating configuration often common for all queries. + * + * @author Jens Schauder + * @author Gerrit Meier + */ +@RequiredArgsConstructor(staticName = "of") +public class SpelQueryContext { + + private final static String SPEL_PATTERN_STRING = "([:?])#\\{([^}]+)}"; + private final static Pattern SPEL_PATTERN = Pattern.compile(SPEL_PATTERN_STRING); + + private final QueryMethodEvaluationContextProvider evaluationContextProvider; + + /** + * A function from the index of a SpEL 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 @NonNull 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 @NonNull BiFunction replacementSource; + + /** + * Parses the query for SpEL expressions using the pattern + * + *
+	 * <prefix>#{<spel>}
+	 * 
+ * + * 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 SpelExtractor} 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}. + */ + SpelExtractor parse(String query) { + return new SpelExtractor(query); + } + + public SpelEvaluator parse(String query, Parameters parameters) { + + SpelExtractor extractor = parse(query); + + return new SpelEvaluator(evaluationContextProvider, parameters, extractor); + } + + /** + * Parses a query string, identifies the contained SpEL expressions, replaces them with bind parameters and offers a + * {@link Map} from those bind parameters to the spel expression. + *

+ * The parser detects quoted parts of the query string and does not detect SpEL expressions inside such quoted parts + * of the query. + * + * @author Jens Schauder + * @author Oliver Gierke + * @since 2.1 + */ + class SpelExtractor { + + private static final int PREFIX_GROUP_INDEX = 1; + private static final int EXPRESSION_GROUP_INDEX = 2; + + private final String query; + private final Map expressions; + + /** + * Creates a SpelExtractor from a query String. + * + * @param query must not be {@literal null}. + */ + SpelExtractor(String query) { + + Assert.notNull(query, "Query must not be null"); + + Map expressions = new HashMap<>(); + Matcher matcher = SPEL_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.substring(matchedUntil, matcher.end())); + + } else { + + String spelExpression = matcher.group(EXPRESSION_GROUP_INDEX); + String prefix = matcher.group(PREFIX_GROUP_INDEX); + + String parameterName = parameterNameSource.apply(expressionCounter, spelExpression); + String replacement = replacementSource.apply(prefix, parameterName); + + resultQuery.append(query.substring(matchedUntil, matcher.start())); + resultQuery.append(replacement); + + expressions.put(parameterName, spelExpression); + expressionCounter++; + } + + matchedUntil = matcher.end(); + } + + resultQuery.append(query.substring(matchedUntil)); + + this.expressions = Collections.unmodifiableMap(expressions); + this.query = resultQuery.toString(); + } + + /** + * The query with all the SpEL expressions replaced with bind parameters. + * + * @return Guaranteed to be not {@literal null}. + */ + String query() { + return query; + } + + /** + * A {@literal Map} from parameter name to SpEL expression. + * + * @return Guaranteed to be not {@literal null}. + */ + Map parameterNameToSpelMap() { + return expressions; + } + + Stream> parameters() { + return expressions.entrySet().stream(); + } + } +} diff --git a/src/test/java/org/springframework/data/repository/query/parser/QuotationMapUnitTests.java b/src/test/java/org/springframework/data/repository/query/parser/QuotationMapUnitTests.java new file mode 100644 index 0000000000..f5e18f0454 --- /dev/null +++ b/src/test/java/org/springframework/data/repository/query/parser/QuotationMapUnitTests.java @@ -0,0 +1,131 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.repository.query.parser; + +import static org.assertj.core.api.Assertions.*; + +import org.assertj.core.api.SoftAssertions; +import org.junit.Test; + +/** + * Unit tests for {@link QuotationMap}. + * + * @author Jens Schauder + */ +public class QuotationMapUnitTests { + + SoftAssertions softly = new SoftAssertions(); + + @Test // DATAJPA-1235 + public void emptyStringDoesNotContainQuotes() { + isNotQuoted("", "empty String", -1, 0, 1); + } + + @Test // DATAJPA-1235 + public void nullStringDoesNotContainQuotes() { + isNotQuoted(null, "null String", -1, 0, 1); + } + + @Test // DATAJPA-1235 + public void simpleStringDoesNotContainQuotes() { + + String query = "something"; + + isNotQuoted(query, "simple String", -1, 0, query.length() - 1, query.length(), query.length() + 1); + } + + @Test // DATAJPA-1235 + public void fullySingleQuotedStringDoesContainQuotes() { + + String query = "'something'"; + + isNotQuoted(query, "quoted String", -1, query.length()); + isQuoted(query, "quoted String", 0, 1, 5, query.length() - 1); + } + + @Test // DATAJPA-1235 + public void fullyDoubleQuotedStringDoesContainQuotes() { + + String query = "\"something\""; + + isNotQuoted(query, "double quoted String", -1, query.length()); + isQuoted(query, "double quoted String", 0, 1, 5, query.length() - 1); + } + + @Test // DATAJPA-1235 + public void stringWithEmptyQuotes() { + + String query = "abc''def"; + + isNotQuoted(query, "zero length quote", -1, 0, 1, 2, 5, 6, 7); + isQuoted(query, "zero length quote", 3, 4); + } + + @Test // DATAJPA-1235 + public void doubleInSingleQuotes() { + + String query = "abc'\"'def"; + + isNotQuoted(query, "double inside single quote", -1, 0, 1, 2, 6, 7, 8); + isQuoted(query, "double inside single quote", 3, 4, 5); + } + + @Test // DATAJPA-1235 + public void singleQuotesInDoubleQuotes() { + + String query = "abc\"'\"def"; + + isNotQuoted(query, "single inside double quote", -1, 0, 1, 2, 6, 7, 8); + isQuoted(query, "single inside double quote", 3, 4, 5); + } + + @Test // DATAJPA-1235 + public void escapedQuotes() { + + String query = "a'b''cd''e'f"; + isNotQuoted(query, "escaped quote", -1, 0, 11, 12); + isQuoted(query, "escaped quote", 1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + } + + @Test // DATAJPA-1235 + public void openEndedQuoteThrowsException() { + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> new QuotationMap("a'b")); + } + + private static void isNotQuoted(String query, Object label, int... indexes) { + + QuotationMap quotationMap = new QuotationMap(query); + + for (int index : indexes) { + + assertThat(quotationMap.isQuoted(index)) + .describedAs(String.format("(%s) %s does not contain a quote at %s", label, query, index)) // + .isFalse(); + } + } + + private static void isQuoted(String query, Object label, int... indexes) { + + QuotationMap quotationMap = new QuotationMap(query); + + for (int index : indexes) { + + assertThat(quotationMap.isQuoted(index)) + .describedAs(String.format("(%s) %s does contain a quote at %s", label, query, index)) // + .isTrue(); + } + } +} diff --git a/src/test/java/org/springframework/data/repository/query/parser/SpelExtractorUnitTests.java b/src/test/java/org/springframework/data/repository/query/parser/SpelExtractorUnitTests.java new file mode 100644 index 0000000000..ab0d571545 --- /dev/null +++ b/src/test/java/org/springframework/data/repository/query/parser/SpelExtractorUnitTests.java @@ -0,0 +1,118 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.repository.query.parser; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; + +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.groups.Tuple; +import org.junit.Test; +import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.data.repository.query.parser.SpelQueryContext.SpelExtractor; + +/** + * Unit tests for {@link SpelExtractor}. + * + * @author Jens Schauder + * @author Oliver Gierke + */ +public class SpelExtractorUnitTests { + + static final QueryMethodEvaluationContextProvider PROVIDER = QueryMethodEvaluationContextProvider.DEFAULT; + static final BiFunction PARAMETER_NAME_SOURCE = (index, spel) -> "EPP" + index; + static final BiFunction REPLACEMENT_SOURCE = (prefix, name) -> prefix + name; + + final SoftAssertions softly = new SoftAssertions(); + + @Test // DATACMNS-1258 + public void nullQueryThrowsException() { + + SpelQueryContext context = SpelQueryContext.of(PROVIDER, PARAMETER_NAME_SOURCE, REPLACEMENT_SOURCE); + + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> context.parse(null)); + } + + @Test // DATACMNS-1258 + public void emptyStringGetsParsedCorrectly() { + + SpelQueryContext context = SpelQueryContext.of(PROVIDER, PARAMETER_NAME_SOURCE, REPLACEMENT_SOURCE); + SpelExtractor extractor = context.parse(""); + + softly.assertThat(extractor.query()).isEqualTo(""); + softly.assertThat(extractor.parameterNameToSpelMap()).isEmpty(); + + softly.assertAll(); + } + + @Test // DATACMNS-1258 + public void findsAndReplacesExpressions() { + + SpelQueryContext context = SpelQueryContext.of(PROVIDER, PARAMETER_NAME_SOURCE, REPLACEMENT_SOURCE); + SpelExtractor extractor = context.parse(":#{one} ?#{two}"); + + softly.assertThat(extractor.query()).isEqualTo(":EPP0 ?EPP1"); + softly.assertThat(extractor.parameterNameToSpelMap().entrySet()) // + .extracting(Map.Entry::getKey, Map.Entry::getValue) // + .containsExactlyInAnyOrder( // + Tuple.tuple("EPP0", "one"), // + Tuple.tuple("EPP1", "two") // + ); + + softly.assertAll(); + } + + @Test // DATACMNS-1258 + public void keepsStringWhenNoMatchIsFound() { + + SpelQueryContext context = SpelQueryContext.of(PROVIDER, PARAMETER_NAME_SOURCE, REPLACEMENT_SOURCE); + SpelExtractor extractor = context.parse("abcdef"); + + softly.assertThat(extractor.query()).isEqualTo("abcdef"); + softly.assertThat(extractor.parameterNameToSpelMap()).isEmpty(); + + softly.assertAll(); + } + + @Test // DATACMNS-1258 + public void spelsInQuotesGetIgnored() { + + List 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::checkNoSpelIsFound); + + softly.assertAll(); + } + + private void checkNoSpelIsFound(String query) { + + SpelQueryContext context = SpelQueryContext.of(PROVIDER, PARAMETER_NAME_SOURCE, REPLACEMENT_SOURCE); + SpelExtractor extractor = context.parse(query); + + softly.assertThat(extractor.query()).describedAs(query).isEqualTo(query); + softly.assertThat(extractor.parameterNameToSpelMap()).describedAs(query).isEmpty(); + } +} diff --git a/src/test/java/org/springframework/data/repository/query/parser/SpelQueryContextUnitTests.java b/src/test/java/org/springframework/data/repository/query/parser/SpelQueryContextUnitTests.java new file mode 100644 index 0000000000..4f5a3f69e6 --- /dev/null +++ b/src/test/java/org/springframework/data/repository/query/parser/SpelQueryContextUnitTests.java @@ -0,0 +1,52 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.repository.query.parser; + +import static org.assertj.core.api.Assertions.*; + +import java.util.function.BiFunction; + +import org.junit.Test; +import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; + +/** + * Unit tests for {@link SpelQueryContext}. + * + * @author Oliver Gierke + * @author Jens Schauder + */ +public class SpelQueryContextUnitTests { + + static final QueryMethodEvaluationContextProvider EVALUATION_CONTEXT_PROVIDER = QueryMethodEvaluationContextProvider.DEFAULT; + static final String EXPRESSION_PARAMETER_PREFIX = "EPP"; + static final BiFunction PARAMETER_NAME_SOURCE = (index, spel) -> EXPRESSION_PARAMETER_PREFIX + + index; + static final BiFunction REPLACEMENT_SOURCE = (prefix, name) -> prefix + name; + + @Test // DATACMNS-1258 + public void nullParameterNameSourceThrowsException() { + + assertThatExceptionOfType(IllegalArgumentException.class) // + .isThrownBy(() -> SpelQueryContext.of(EVALUATION_CONTEXT_PROVIDER, null, REPLACEMENT_SOURCE)); + } + + @Test // DATACMNS-1258 + public void nullReplacementSourceThrowsException() { + + assertThatExceptionOfType(IllegalArgumentException.class) // + .isThrownBy(() -> SpelQueryContext.of(EVALUATION_CONTEXT_PROVIDER, PARAMETER_NAME_SOURCE, null)); + } +}