DATACOUCH-166 - Improve SpEL support, prefix is now #{

This commit is contained in:
Simon Baslé
2015-11-16 13:54:43 +01:00
parent 2f68740247
commit 2378e7145b
6 changed files with 40 additions and 109 deletions

View File

@@ -136,8 +136,8 @@ public class SimpleCouchbaseRepositoryTests {
User user = repository.findByUsernameBadSelect("uname-1");
fail("shouldFailFindByUsernameWithNoIdOrCas");
} catch (CouchbaseQueryExecutionException e) {
assertTrue(e.getMessage().contains("_ID"));
assertTrue(e.getMessage().contains("_CAS"));
assertTrue("_ID expected in exception " + e, e.getMessage().contains("_ID"));
assertTrue("_CAS expected in exception " + e, e.getMessage().contains("_CAS"));
} catch (Exception e) {
fail("CouchbaseQueryExecutionException expected");
}

View File

@@ -34,13 +34,13 @@ public interface UserRepository extends CouchbaseRepository<User, String> {
@View(designDocument = "user", viewName = "all")
Iterable<User> customViewQuery(ViewQuery query);
@Query("${#n1ql.selectEntity} WHERE username = $1")
@Query("#{#n1ql.selectEntity} WHERE username = $1")
User findByUsername(String username);
@Query("SELECT * FROM ${#n1ql.bucket} WHERE username = $1")
@Query("SELECT * FROM #{#n1ql.bucket} WHERE username = $1")
User findByUsernameBadSelect(String username);
@Query("${#n1ql.selectEntity} WHERE username LIKE '%-${3 + 1}'")
@Query("#{#n1ql.selectEntity} WHERE username LIKE '%-#{3 + 1}'")
User findByUsernameWithSpelAndPlaceholder();
@Query

View File

@@ -122,7 +122,7 @@ Here is an example:
----
public interface UserRepository extends CrudRepository<UserInfo, String> {
@Query("${#n1ql.selectEntity} WHERE role = 'admin' AND ${#n1ql.filter}")
@Query("#{#n1ql.selectEntity} WHERE role = 'admin' AND #{#n1ql.filter}")
List<UserInfo> findAllAdmins();
List<UserInfo> findByFirstname(String fname);
@@ -132,7 +132,7 @@ public interface UserRepository extends CrudRepository<UserInfo, String> {
Here we see two N1QL-backed ways of querying.
The first method uses the `Query` annotation to provide a N1QL statement inline. SpEL (Spring Expression Language) is supported by surrounding SpEL expressions between `${` and `}`. You can also include N1QL positional placeholders (eg. `$1`, `$2`).
The first method uses the `Query` annotation to provide a N1QL statement inline. SpEL (Spring Expression Language) is supported by surrounding SpEL expression blocks between `#{` and `}`. You can also include N1QL positional placeholders (eg. `$1`, `$2`).
A few N1QL-specific values are provided through SpEL:
- `#n1ql.selectEntity` allows to easily make sure the statement will select all the fields necessary to build the full entity (including document ID and CAS value).
@@ -140,8 +140,8 @@ A few N1QL-specific values are provided through SpEL:
- `#n1ql.bucket` will be replaced by the name of the bucket the entity is stored in, escaped in backticks.
- `#n1ql.fields` will be replaced by the list of fields (eg. for a SELECT clause) necessary to reconstruct the entity.
Another example: "`${#n1ql.selectEntity} WHERE ${#n1ql.filter} AND test = $1`", which is equivalent to
`SELECT ${#n1ql.fields} FROM ${#n1ql.bucket} WHERE ${#n1ql.filter} AND test = $1`".
Another example: "`#{#n1ql.selectEntity} WHERE #{#n1ql.filter} AND test = $1`", which is equivalent to
`SELECT #{#n1ql.fields} FROM #{#n1ql.bucket} WHERE #{#n1ql.filter} AND test = $1`".
The second method uses Spring-Data's query derivation mechanism to build a N1QL query from the method name and parameters. This will produce a query looking like this: `SELECT ... FROM ... WHERE firstName = "valueOfFnameAtRuntime"`. You can combine these criteria, even do a count with a name like `countByFirstname` or a limit with a name like `findFirst3ByLastname`...

View File

@@ -34,7 +34,7 @@ import org.springframework.data.couchbase.repository.query.StringN1qlBasedQuery;
* <p/>
* In this case, one can use a placeholder notation of {@code ?0}, {@code ?1} and so on.
* <p/>
* Also, SpEL in the form <code>${spelExpression}</code> is supported, including the
* Also, SpEL in the form <code>#{spelExpression}</code> is supported, including the
* following N1QL variables that will be replaced by the underlying {@link CouchbaseTemplate}
* associated information:
* <ul>

View File

@@ -28,6 +28,7 @@ import org.springframework.data.repository.query.ParameterAccessor;
import org.springframework.data.repository.query.RepositoryQuery;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.common.TemplateParserContext;
import org.springframework.expression.spel.standard.SpelExpressionParser;
/**
@@ -36,8 +37,9 @@ import org.springframework.expression.spel.standard.SpelExpressionParser;
* The statement can contain positional placeholders (eg. <code>name = $1</code>) that will map to the
* method's parameters, in the same order.
* <p/>
* The statement can also contain SpEL expressions enclosed in <code>${}</code>.
* <p/> There are couchbase-provided variables included for the {@link #SPEL_BUCKET bucket namespace},
* The statement can also contain SpEL expressions enclosed in <code>#{</code> and <code>}</code>.
* <p/>
* There are couchbase-provided variables included for the {@link #SPEL_BUCKET bucket namespace},
* the {@link #SPEL_ENTITY ID and CAS fields} necessary for entity reconstruction
* or a shortcut that covers {@link #SPEL_SELECT_FROM_CLAUSE SELECT AND FROM clauses},
* along with a variable for {@link #SPEL_FILTER WHERE clause filtering} of the correct entity.
@@ -54,7 +56,7 @@ public class StringN1qlBasedQuery extends AbstractN1qlBasedQuery {
/**
* Use this variable in a SpEL expression in a {@link org.springframework.data.couchbase.core.query.Query @Query}
* annotation's inline statement. This will be replaced by the correct <code>SELECT x FROM y</code> clause needed
* for entity mapping. Eg. <code>"${{@value StringN1qlBasedQuery#SPEL_SELECT_FROM_CLAUSE}} WHERE test = true"</code>.
* for entity mapping. Eg. <code>"#{{@value StringN1qlBasedQuery#SPEL_SELECT_FROM_CLAUSE}} WHERE test = true"</code>.
* Note this only makes sense once, as the beginning of the statement.
*/
public static final String SPEL_SELECT_FROM_CLAUSE = "#" + SPEL_PREFIX + ".selectEntity";
@@ -62,21 +64,21 @@ public class StringN1qlBasedQuery extends AbstractN1qlBasedQuery {
/**
* Use this variable in a SpEL expression in a {@link org.springframework.data.couchbase.core.query.Query @Query}
* annotation's inline statement. This will be replaced by the (escaped) bucket name corresponding to the repository's
* entity. Eg. <code>"SELECT * FROM ${{@value StringN1qlBasedQuery#SPEL_BUCKET}} LIMIT 3"</code>.
* entity. Eg. <code>"SELECT * FROM #{{@value StringN1qlBasedQuery#SPEL_BUCKET}} LIMIT 3"</code>.
*/
public static final String SPEL_BUCKET = "#" + SPEL_PREFIX + ".bucket";
/**
* Use this variable in a SpEL expression in a {@link org.springframework.data.couchbase.core.query.Query @Query}
* annotation's inline statement. This will be replaced by the fields allowing to construct the repository's entity
* (SELECT clause). Eg. <code>"SELECT ${{@value StringN1qlBasedQuery#SPEL_ENTITY}} FROM test"</code>.
* (SELECT clause). Eg. <code>"SELECT #{{@value StringN1qlBasedQuery#SPEL_ENTITY}} FROM test"</code>.
*/
public static final String SPEL_ENTITY = "#" + SPEL_PREFIX + ".fields";
/**
* Use this variable in a SpEL expression in a {@link org.springframework.data.couchbase.core.query.Query @Query}
* annotation's inline statement WHERE clause. This will be replaced by the expression allowing to only select
* documents matching the entity's class. Eg. <code>"SELECT * FROM test WHERE test = true AND ${{@value StringN1qlBasedQuery#SPEL_FILTER}}"</code>.
* documents matching the entity's class. Eg. <code>"SELECT * FROM test WHERE test = true AND #{{@value StringN1qlBasedQuery#SPEL_FILTER}}"</code>.
*/
public static final String SPEL_FILTER = "#" + SPEL_PREFIX + ".filter";
@@ -123,81 +125,28 @@ public class StringN1qlBasedQuery extends AbstractN1qlBasedQuery {
}
/**
* Parse the statement to detect SPEL expressions (delimited by <code>${</code> and <code>}</code>)
* and replace said expressions with their corresponding values (see {@link #evaluateSpelExpression(String, boolean, Object[])}).
* Parse the statement to detect SPEL blocks (delimited by <code>#{</code> and <code>}</code>)
* and replace said expression blocks with their corresponding values.
*
* @param statement the full statement into which SpEL expressions should be parsed and replaced.
* @param runtimeParameters the parameters passed into the method at runtime.
* @return the statement with the SpEL interpreted and replaced by its values.
*/
protected String parseSpel(String statement, boolean isCount, Object[] runtimeParameters) {
StringBuilder parsed = new StringBuilder(statement);
int currentIndex = 0;
while(currentIndex > -1 && currentIndex < parsed.length()) {
if (LOGGER.isTraceEnabled()) {
LOGGER.trace("Evaluating from index {}, {}", currentIndex, parsed.substring(currentIndex));
}
int opening = parsed.indexOf("${", currentIndex);
if (opening == -1) {
break;
} else {
//find the end of the ${ } expression, eat inner braces
int closing = opening + 2;
int curlyBraceOpenCnt = 1;
while (curlyBraceOpenCnt > 0) {
switch (parsed.charAt(closing++)) {
case '{':
curlyBraceOpenCnt++;
break;
case '}':
curlyBraceOpenCnt--;
break;
default:
}
}
//we're now at the end of the expression
//evaluate and replace
String expression = parsed.substring(opening + 2, closing - 1);
String replacement = evaluateSpelExpression(expression, isCount, runtimeParameters);
if (LOGGER.isTraceEnabled()) {
LOGGER.trace("Replacing SPEL \"{}\" with value \"{}\"", expression, replacement);
}
parsed.replace(opening, closing, replacement);
//move on
currentIndex = opening + replacement.length();
}
}
return parsed.toString();
}
/**
* This method is in charge of evaluating a single SPEL expression (as delimited by <code>${</code> and <code>}</code>)
* and returning the corresponding computed value.
*
* @param expression the SPEL expression.
* @param runtimeParameters the parameters using during method call.
* @return the corresponding value.
*/
protected String evaluateSpelExpression(String expression, boolean isCount, Object[] runtimeParameters) {
EvaluationContext evaluationContext = evaluationContextProvider.getEvaluationContext(getQueryMethod().getParameters(), runtimeParameters);
Expression parsedExpression = parser.parseExpression(expression);
N1qlSpelValues n1qlSpelValues = this.statementContext;
if (isCount) {
n1qlSpelValues = this.countContext;
}
return doEvaluate(evaluationContext, parsedExpression, n1qlSpelValues);
return doParse(statement, parser, evaluationContext, n1qlSpelValues);
}
protected static String doEvaluate(EvaluationContext context, Expression parsedExpression, N1qlSpelValues spelValues) {
context.setVariable(SPEL_PREFIX, spelValues);
return parsedExpression.getValue(context, String.class);
//this static method can be used to test the parsing behavior for Couchbase specific spel variables
//in isolation from the rest of the spel parser initialization chain.
protected static String doParse(String statement, SpelExpressionParser parser, EvaluationContext evaluationContext, N1qlSpelValues n1qlSpelValues) {
Expression parsedExpression = parser.parseExpression(statement, new TemplateParserContext());
evaluationContext.setVariable(SPEL_PREFIX, n1qlSpelValues);
return parsedExpression.getValue(evaluationContext, String.class);
}
@Override
@@ -228,25 +177,25 @@ public class StringN1qlBasedQuery extends AbstractN1qlBasedQuery {
public static final class N1qlSpelValues {
/**
* <code>${{@value org.springframework.data.couchbase.repository.query.StringN1qlBasedQuery#SPEL_PREFIX}.
* <code>#{{@value org.springframework.data.couchbase.repository.query.StringN1qlBasedQuery#SPEL_PREFIX}.
* selectEntity</code> will be replaced by the SELECT-FROM clause corresponding to the entity. Use once at the beginning.
*/
public final String selectEntity;
/**
* <code>${{@value org.springframework.data.couchbase.repository.query.StringN1qlBasedQuery#SPEL_PREFIX}.
* <code>#{{@value org.springframework.data.couchbase.repository.query.StringN1qlBasedQuery#SPEL_PREFIX}.
* fields</code> will be replaced by the list of N1QL fields allowing to reconstruct the entity.
*/
public final String fields;
/**
* <code>${{@value org.springframework.data.couchbase.repository.query.StringN1qlBasedQuery#SPEL_PREFIX}.
* <code>#{{@value org.springframework.data.couchbase.repository.query.StringN1qlBasedQuery#SPEL_PREFIX}.
* bucket</code> will be replaced by (escaped) bucket name in which the entity is stored.
*/
public final String bucket;
/**
* <code>${{@value org.springframework.data.couchbase.repository.query.StringN1qlBasedQuery#SPEL_PREFIX}.
* <code>#{{@value org.springframework.data.couchbase.repository.query.StringN1qlBasedQuery#SPEL_PREFIX}.
* filter</code> will be replaced by an expression allowing to select only entries matching the entity in a WHERE clause.
*/
public final String filter;

View File

@@ -12,13 +12,14 @@ import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
public class StringN1QlBasedQueryTest {
private static final SpelExpressionParser SPEL_PARSER = new SpelExpressionParser();
private static final EvaluationContext SPEL_EVALUATION_CONTEXT = new StandardEvaluationContext();
private StringN1qlBasedQuery mockStringUnderscoreClass;
private StringN1qlBasedQuery mockStringAtClass;
@@ -30,33 +31,30 @@ public class StringN1QlBasedQueryTest {
N1qlSpelValues contextCountStringAtClass = StringN1qlBasedQuery.createN1qlSpelValues("B", "@class", String.class, true);
mockStringUnderscoreClass = mock(StringN1qlBasedQuery.class);
when(mockStringUnderscoreClass.parseSpel(anyString(), anyBoolean(), any(Object[].class))).thenCallRealMethod();
when(mockStringUnderscoreClass.evaluateSpelExpression(anyString(), anyBoolean(), any(Object[].class)))
when(mockStringUnderscoreClass.parseSpel(anyString(), anyBoolean(), any(Object[].class)))
.thenAnswer(mockSpelEvaluation(contextStringUnderscoreClass, contextCountStringUnderscoreClass));
mockStringAtClass = mock(StringN1qlBasedQuery.class);
when(mockStringAtClass.parseSpel(anyString(),anyBoolean(), any(Object[].class))).thenCallRealMethod();
when(mockStringAtClass.evaluateSpelExpression(anyString(), anyBoolean(), any(Object[].class)))
.thenAnswer(mockSpelEvaluation(contextStringAtClass, contextCountStringAtClass));
when(mockStringAtClass.parseSpel(anyString(),anyBoolean(), any(Object[].class)))
.thenAnswer(mockSpelEvaluation(contextStringAtClass, contextCountStringAtClass));
}
private static Answer<?> mockSpelEvaluation(final N1qlSpelValues spelValues, final N1qlSpelValues countSpelValues) {
return new Answer<Object>() {
@Override
public Object answer(InvocationOnMock invocation) throws Throwable {
EvaluationContext context = new StandardEvaluationContext();
Expression expression = SPEL_PARSER.parseExpression((String) invocation.getArguments()[0]);
String statement = (String) invocation.getArguments()[0];
boolean isCount = (Boolean) invocation.getArguments()[1];
if (isCount)
return doEvaluate(context, expression, countSpelValues);
return doParse(statement, SPEL_PARSER, SPEL_EVALUATION_CONTEXT, countSpelValues);
else
return doEvaluate(context, expression, spelValues);
return doParse(statement, SPEL_PARSER, SPEL_EVALUATION_CONTEXT, spelValues);
}
};
}
private static String spel(String expression) {
return "${" + expression + "}";
return "#{" + expression + "}";
}
@Test
@@ -101,20 +99,4 @@ public class StringN1QlBasedQueryTest {
assertEquals("SELECT COUNT(*) AS " + CountFragment.COUNT_ALIAS + " FROM `B` WHERE true", parsed);
}
@Test
public void testParsingOfSpel() {
StringN1qlBasedQuery mock = mock(StringN1qlBasedQuery.class);
when(mock.parseSpel(anyString(), anyBoolean(), any(Object[].class))).thenCallRealMethod();
when(mock.evaluateSpelExpression(anyString(), anyBoolean(), any(Object[].class)))
.thenReturn("EXPRESSION1", "EXPRESSION2isMuchMoreLongerThanTheTextItReplaces", "EXPRESSION3", "LASTEXPRESSION"
);
String spelStatement = "some statement with ${ad{\"toto\"}.lib} spel ${expression.parsed}${{several{close}}expressions}";
String expected = "some statement with EXPRESSION1 spel EXPRESSION2isMuchMoreLongerThanTheTextItReplacesEXPRESSION3";
String parsed = mock.parseSpel(spelStatement, false, new Object[0]);
assertEquals(expected, parsed);
}
}