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));
+ }
+}