DATACMNS-1258 - Added infrastructure for SpEL handling in queries.

The goal is to extract the parts of query parsing that are generic into commons in order to avoid slightly different behavior in modules. 

Extracted the QuotationMap from Spring Data JPA as well as the part of the parser that deals with extracting SpEL expressions.
Added the SpelEvaluator for evaluating the SpEL expressions.

Added enough configurability to satisfy the needs of Neo4j and JPA.
Mainly to support the different formats for bind parameter :name vs {name}.
This commit is contained in:
Jens Schauder
2018-02-16 18:26:35 +01:00
committed by Oliver Gierke
parent a6215fbe0f
commit 1fdf77aed7
5 changed files with 589 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
/*
* 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.HashSet;
import java.util.List;
import java.util.Set;
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
*/
public class QuotationMap {
private static final Set<Character> QUOTING_CHARACTERS = new HashSet<>(Arrays.asList('"', '\''));
private final List<Range<Integer>> 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));
}
}

View File

@@ -0,0 +1,76 @@
/*
* 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.HashMap;
import java.util.Map;
import org.springframework.data.repository.query.EvaluationContextProvider;
import org.springframework.data.repository.query.Parameters;
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();
@NonNull private final EvaluationContextProvider evaluationContextProvider;
@NonNull private final Parameters<?, ?> parameters;
/**
* A map from parameter name to SpEL expression as returned by {@link SpelQueryContext.SpelExtractor#parameterNameToSpelMap()}.
*/
@NonNull private final Map<String, String> parameterNameToSpelMap;
/**
* 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<String, Object> evaluate(Object[] values) {
Assert.notNull(values, "Values must not be null.");
HashMap<String, Object> spelExpressionResults = new HashMap<>();
EvaluationContext evaluationContext = evaluationContextProvider.getEvaluationContext(parameters, values);
for (Map.Entry<String, String> parameterNameToSpel : parameterNameToSpelMap.entrySet()) {
Object spElValue = getSpElValue(evaluationContext, parameterNameToSpel.getValue());
spelExpressionResults.put(parameterNameToSpel.getKey(), spElValue);
}
return spelExpressionResults;
}
@Nullable
private Object getSpElValue(EvaluationContext evaluationContext, String expression) {
return PARSER.parseExpression(expression).getValue(evaluationContext, Object.class);
}
}

View File

@@ -0,0 +1,161 @@
/*
* 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.function.BiFunction;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.util.Assert;
/**
* Source of {@link SpelExtractor} encapsulating configuration often common for all queries.
*
* @author Jens Schauder
* @author Gerrit Meier
*/
@RequiredArgsConstructor
public class SpelQueryContext {
private final static String SPEL_PATTERN_STRING = "([:?])#\\{([^}]+)}";
private final static Pattern SPEL_PATTERN = Pattern.compile(SPEL_PATTERN_STRING);
/**
* 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
* <code>(index, spel) -> "__some_placeholder_" + index</code>
*/
@NonNull private final BiFunction<Integer, String, String> 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 <code>(prefix, name) -> prefix + name</code> or
* <code>(prefix, name) -> "{" + name + "}"</code>
*/
@NonNull private final BiFunction<String, String, String> replacementSource;
/**
* Parses the query for SpEL expressions using the pattern
*
* <pre>
* &lt;prefix&gt;#{&lt;spel&gt;}
* </pre>
*
* 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}.
*/
public SpelExtractor parse(String query) {
return new SpelExtractor(query);
}
/**
* 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.
* <p>
* 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
*/
public 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<String, String> expressions;
/**
* Creates a SpelExtractor from a query String.
*
* @param query Must not be {@literal null}.
*/
private SpelExtractor(String query) {
Assert.notNull(query, "Query must not be null");
HashMap<String, String> 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}.
*/
public String query() {
return query;
}
/**
* A {@literal Map} from parameter name to SpEL expression.
*
* @return Guaranteed to be not {@literal null}.
*/
public Map<String, String> parameterNameToSpelMap() {
return expressions;
}
}
}

View File

@@ -0,0 +1,124 @@
/*
* 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 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 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();
}
}
}

View File

@@ -0,0 +1,145 @@
/*
* 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;
/**
* Unit tests for {@link SpelQueryContext} and
* {@link org.springframework.data.repository.query.parser.SpelQueryContext.SpelExtractor}
*
* @author Jens Schauder
*/
public class SpelExtractorUnitTests {
static final String EXPRESSION_PARAMETER_PREFIX = "EPP";
static final BiFunction<Integer, String, String> PARAMETER_NAME_SOURCE = (index, spel) -> EXPRESSION_PARAMETER_PREFIX + index;
static final BiFunction<String, String, String> REPLACEMENT_SOURCE = (prefix, name) -> prefix + name;
final SoftAssertions softly = new SoftAssertions();
@Test // DATACMNS-1258
public void nullQueryThrowsException() {
assertThatExceptionOfType(IllegalArgumentException.class) //
.isThrownBy(() -> new SpelQueryContext( //
PARAMETER_NAME_SOURCE, //
REPLACEMENT_SOURCE) //
.parse(null) //
);
}
@Test // DATACMNS-1258
public void nullParameterNameSourceThrowsException() {
assertThatExceptionOfType(IllegalArgumentException.class) //
.isThrownBy(() -> new SpelQueryContext( //
null, //
REPLACEMENT_SOURCE) //
);
}
@Test // DATACMNS-1258
public void nullReplacementSourceThrowsException() {
assertThatExceptionOfType(IllegalArgumentException.class) //
.isThrownBy( //
() -> new SpelQueryContext( //
PARAMETER_NAME_SOURCE, //
null) //
);
}
@Test // DATACMNS-1258
public void emptyStringGetsParsedCorrectly() {
SpelQueryContext.SpelExtractor extractor = new SpelQueryContext( //
PARAMETER_NAME_SOURCE, //
REPLACEMENT_SOURCE //
).parse("");
softly.assertThat(extractor.query()).isEqualTo("");
softly.assertThat(extractor.parameterNameToSpelMap()).isEmpty();
softly.assertAll();
}
@Test // DATACMNS-1258
public void findsAndReplacesExpressions() {
SpelQueryContext.SpelExtractor extractor = new SpelQueryContext( //
PARAMETER_NAME_SOURCE, //
REPLACEMENT_SOURCE //
).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.SpelExtractor extractor = new SpelQueryContext( //
PARAMETER_NAME_SOURCE, //
REPLACEMENT_SOURCE //
).parse("abcdef");
softly.assertThat(extractor.query()).isEqualTo("abcdef");
softly.assertThat(extractor.parameterNameToSpelMap()).isEmpty();
softly.assertAll();
}
@Test // DATACMNS-1258
public void spelsInQuotesGetIgnored() {
List<String> 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");
for (String query : queries) {
checkNoSpelIsFound(query);
}
softly.assertAll();
}
private void checkNoSpelIsFound(String query) {
SpelQueryContext.SpelExtractor extractor = new SpelQueryContext( //
PARAMETER_NAME_SOURCE, //
REPLACEMENT_SOURCE //
).parse(query);
softly.assertThat(extractor.query()).describedAs(query).isEqualTo(query);
softly.assertThat(extractor.parameterNameToSpelMap()).describedAs(query).isEmpty();
}
}