DATAJPA-292 - Manually defined queries can contain like expressions now.

This commit introduces support for JPQL inline like expressions such as "… like ?1%". The implementation will detect those expressions and turn the query back into a valid JPQL one. On query execution the parameters will be bound according to the like expression defined in the query.
This commit is contained in:
Oliver Gierke
2013-03-13 13:26:21 +01:00
parent c2aa1215f8
commit 285a994e95
8 changed files with 701 additions and 12 deletions

View File

@@ -508,6 +508,31 @@ public class User {
}</programlisting>
</example>
<simplesect>
<title>Using advanced <code>LIKE</code> expressions</title>
<para>The query execution mechanism for manually defined queries using
<interfacename>@Query</interfacename> allow the definition of advanced
<code>LIKE</code> expressions inside the query definition.</para>
<example>
<title>Advanced <code>LIKE</code> expressions in
<interfacename>@Query</interfacename></title>
<programlisting language="java">public interface UserRepository extends JpaRepository&lt;User, Long&gt; {
@Query("select u from User u where u.firstname like %?1")
List&lt;User&gt; findByFirstnameEndsWith(String firstname);
}</programlisting>
</example>
<para>In the just shown sample <code>LIKE</code> delimiter character
<code>%</code> is recognized and the query transformed into a valid
JPQL query (removing the <code>%</code>). Upon query execution the
parameter handed into the method call gets augmented with the
previously recognized <code>LIKE</code> pattern.</para>
</simplesect>
<simplesect>
<title>Native queries</title>

View File

@@ -37,9 +37,8 @@ final class SimpleJpaQuery extends AbstractJpaQuery {
private static final Logger LOG = LoggerFactory.getLogger(SimpleJpaQuery.class);
private final String queryString;
private final String countQuery;
private final String alias;
private final StringQuery query;
private final StringQuery countQuery;
private final JpaQueryMethod method;
@@ -50,11 +49,10 @@ final class SimpleJpaQuery extends AbstractJpaQuery {
super(method, em);
this.queryString = queryString;
this.alias = QueryUtils.detectAlias(queryString);
this.countQuery = method.getCountQuery() == null ? QueryUtils.createCountQueryFor(queryString) : method
.getCountQuery();
this.method = method;
this.query = new StringQuery(queryString);
this.countQuery = new StringQuery(method.getCountQuery() == null ? QueryUtils.createCountQueryFor(queryString)
: method.getCountQuery());
Parameters parameters = method.getParameters();
boolean hasPagingOrSortingParameter = parameters.hasPageableParameter() || parameters.hasSortParameter();
@@ -66,7 +64,7 @@ final class SimpleJpaQuery extends AbstractJpaQuery {
// Try to create a Query object already to fail fast
if (!method.isNativeQuery()) {
try {
em.createQuery(queryString);
em.createQuery(query.getQuery());
} catch (RuntimeException e) {
// Needed as there's ambiguities in how an invalid query string shall be expressed by the persistence provider
// http://java.net/projects/jpa-spec/lists/jsr338-experts/archive/2012-07/message/17
@@ -82,6 +80,15 @@ final class SimpleJpaQuery extends AbstractJpaQuery {
this(method, em, method.getAnnotatedQuery());
}
/*
* (non-Javadoc)
* @see org.springframework.data.jpa.repository.query.AbstractJpaQuery#createBinder(java.lang.Object[])
*/
@Override
protected ParameterBinder createBinder(Object[] values) {
return new StringQueryParameterBinder(getQueryMethod().getParameters(), values, query);
}
/*
* (non-Javadoc)
* @see org.springframework.data.jpa.repository.query.AbstractJpaQuery#createQuery(java.lang.Object[])
@@ -90,7 +97,7 @@ final class SimpleJpaQuery extends AbstractJpaQuery {
public Query doCreateQuery(Object[] values) {
ParameterAccessor accessor = new ParametersParameterAccessor(method.getParameters(), values);
String sortedQueryString = QueryUtils.applySorting(queryString, accessor.getSort(), alias);
String sortedQueryString = QueryUtils.applySorting(query.getQuery(), accessor.getSort(), query.getAlias());
EntityManager em = getEntityManager();
Query query = null;
@@ -111,8 +118,7 @@ final class SimpleJpaQuery extends AbstractJpaQuery {
*/
@Override
protected TypedQuery<Long> doCreateCountQuery(Object[] values) {
return createBinder(values).bind(getEntityManager().createQuery(countQuery, Long.class));
return createBinder(values).bind(getEntityManager().createQuery(countQuery.getQuery(), Long.class));
}
/**
@@ -131,4 +137,4 @@ final class SimpleJpaQuery extends AbstractJpaQuery {
return query == null ? null : new SimpleJpaQuery(queryMethod, em, query);
}
}
}

View File

@@ -0,0 +1,353 @@
/*
* Copyright 2013 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.jpa.repository.query;
import static java.util.regex.Pattern.*;
import static org.springframework.util.ObjectUtils.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.data.repository.query.parser.Part.Type;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* Encapsulation of a String JPA query.
*
* @author Oliver Gierke
*/
class StringQuery {
private static final Pattern LIKE_PATTERN;
private static final String MESSAGE = "Already found like binding with same index / parameter name but differing binding type! Already have: %s, found %s! If you bind a parameter multiple times make sure they use the same like binding.";
static {
StringBuilder builder = new StringBuilder();
builder.append("(?<=like)"); // starts with like
builder.append("(?: )+"); // some whitespace
builder.append("(");
builder.append("%?(\\?(\\d+))%?"); // position parameter with likes
builder.append("|"); // or
builder.append("%?(:(\\w+))%?"); // named parameter with likes;
builder.append(")");
LIKE_PATTERN = Pattern.compile(builder.toString(), CASE_INSENSITIVE);
}
private final String query;
private final List<StringQuery.LikeBinding> bindings;
private final String alias;
/**
* Creates a new {@link StringQuery} from the given JPQL query.
*
* @param query must not be {@literal null} or empty.
*/
public StringQuery(String query) {
Assert.hasText(query, "Query must not be null or empty!");
this.bindings = new ArrayList<StringQuery.LikeBinding>();
this.query = parseLikeBindings(query);
this.alias = QueryUtils.detectAlias(query);
}
/**
* Returns whether we have found some like bindings.
*
* @return
*/
public boolean hasLikeBindings() {
return !bindings.isEmpty();
}
/**
* Returns the {@link LikeBinding}s registered.
*
* @return
*/
List<LikeBinding> getLikeBindings() {
return bindings;
}
/**
* Returns the JPQL query.
*
* @return
*/
public String getQuery() {
return query;
}
/**
* Returns the main alias used in the query.
*
* @return the alias
*/
public String getAlias() {
return alias;
}
/**
* Returns the {@link LikeBinding} for the given name.
*
* @param name
* @return
*/
public LikeBinding getBindingFor(String name) {
for (StringQuery.LikeBinding binding : bindings) {
if (binding.hasName(name)) {
return binding;
}
}
return null;
}
/**
* Returns the {@link LikeBinding} for the given position.
*
* @param position
* @return
*/
public LikeBinding getBindingFor(int position) {
for (LikeBinding binding : bindings) {
if (binding.hasPosition(position)) {
return binding;
}
}
return null;
}
/**
* Parses {@link LikeBinding} instances from the given query and adds them to the registered bindings. Returns the
* cleaned up query.
*
* @param query
* @return
*/
private final String parseLikeBindings(String query) {
Matcher matcher = LIKE_PATTERN.matcher(query);
String result = query;
while (matcher.find()) {
Type likeType = getLikeTypeFrom(matcher.group(1));
String index = matcher.group(3);
String replacement = matcher.group(2);
if (index != null) {
checkAndRegister(new LikeBinding(Integer.parseInt(index), likeType));
} else {
checkAndRegister(new LikeBinding(matcher.group(5), likeType));
replacement = matcher.group(4);
}
result = StringUtils.replace(result, matcher.group(1), replacement);
}
return result;
}
private final void checkAndRegister(LikeBinding binding) {
for (LikeBinding existing : bindings) {
if (existing.hasName(binding.name) || existing.hasPosition(binding.position)) {
Assert.isTrue(existing.equals(binding), String.format(MESSAGE, existing, binding));
}
}
this.bindings.add(binding);
}
/**
* Extracts the like {@link Type} from the given JPA like expression.
*
* @param expression must not be {@literal null} or empty.
* @return
*/
private static Type getLikeTypeFrom(String expression) {
Assert.hasText(expression);
if (expression.matches("%.*%")) {
return Type.CONTAINING;
}
if (expression.startsWith("%")) {
return Type.ENDING_WITH;
}
if (expression.endsWith("%")) {
return Type.STARTING_WITH;
}
throw new IllegalArgumentException(String.format("Illegal like pattern %s!", expression));
}
/**
* Represents a paramter binding in a JPQL query augmented with instructions of how to apply a parameter as LIKE
* parameter. This allows expressions like {@code …like %?1} in the JPQL query, which is not allowed by plain JPA.
*
* @author Oliver Gierke
*/
static class LikeBinding {
private static final List<Type> SUPPORTED_TYPES = Arrays.asList(Type.CONTAINING, Type.STARTING_WITH,
Type.ENDING_WITH);
private final String name;
private final Integer position;
private final Type type;
/**
* Creates a new {@link LikeBinding} for the parameter with the given name and {@link Type}.
*
* @param name must not be {@literal null} or empty.
* @param type must not be {@literal null}.
*/
public LikeBinding(String name, Type type) {
Assert.hasText(name, "Name must not be null or empty!");
Assert.notNull(type, "Type must not be null!");
Assert.isTrue(SUPPORTED_TYPES.contains(type),
String.format("Type must be one of %s!", StringUtils.collectionToCommaDelimitedString(SUPPORTED_TYPES)));
this.name = name;
this.type = type;
this.position = null;
}
/**
* Creates a new {@link LikeBinding} for the parameter with the given position and {@link Type}.
*
* @param position
* @param type must not be {@literal null}.
*/
public LikeBinding(int position, Type type) {
Assert.isTrue(position > 0, "Position must be greater than zero!");
Assert.notNull(type, "Type must not be null!");
this.position = position;
this.type = type;
this.name = null;
}
/**
* Returns whether the binding has the given name. Will always be {@literal false} in case the {@link LikeBinding}
* has been set up from a position.
*
* @param name
* @return
*/
public boolean hasName(String name) {
return this.position == null && this.name != null && this.name.equals(name);
}
/**
* Returns whether the binding has the given position. Will always be {@literal false} in case the
* {@link LikeBinding} has been set up from a name.
*
* @param position
* @return
*/
public boolean hasPosition(int position) {
return this.name == null && this.position == position;
}
/**
* Returns the type of the {@link LikeBinding}.
*
* @return
*/
public Type getType() {
return type;
}
/**
* Prepares the given raw value according to the like type.
*
* @param value
*/
public Object prepare(Object value) {
if (value == null) {
return value;
}
switch (type) {
case STARTING_WITH:
return String.format("%s%%", value.toString());
case ENDING_WITH:
return String.format("%%%s", value.toString());
case CONTAINING:
return String.format("%%%s%%", value.toString());
default:
return value;
}
}
/*
* (non-Javadoc)
* @see java.lang.Object#equals(java.lang.Object)
*/
@Override
public boolean equals(Object obj) {
if (!(obj instanceof LikeBinding)) {
return false;
}
LikeBinding that = (LikeBinding) obj;
return nullSafeEquals(this.name, that.name) && nullSafeEquals(this.position, that.position)
&& this.type.equals(that.type);
}
/*
* (non-Javadoc)
* @see java.lang.Object#hashCode()
*/
@Override
public int hashCode() {
int result = 17;
result += nullSafeHashCode(this.name);
result += nullSafeHashCode(this.position);
result += nullSafeHashCode(this.type);
return result;
}
/*
* (non-Javadoc)
* @see java.lang.Object#toString()
*/
@Override
public String toString() {
return String.format("LikeBinding [name: %s, position: %d, type: %s]", name, position, type);
}
}
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright 2013 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.jpa.repository.query;
import javax.persistence.Query;
import org.springframework.data.jpa.repository.query.StringQuery.LikeBinding;
import org.springframework.data.repository.query.Parameter;
import org.springframework.data.repository.query.Parameters;
import org.springframework.util.Assert;
/**
* {@link ParameterBinder} that takes {@link LikeBinding}s encapsulated in a {@link StringQuery} into account.
*
* @author Oliver Gierke
*/
public class StringQueryParameterBinder extends ParameterBinder {
private final StringQuery query;
/**
* Creates a new {@link StringQueryParameterBinder} from the given {@link Parameters}, method arguments and
* {@link StringQuery}.
*
* @param parameters must not be {@literal null}.
* @param values must not be {@literal null}.
* @param query must not be {@literal null}.
*/
public StringQueryParameterBinder(Parameters parameters, Object[] values, StringQuery query) {
super(parameters, values);
Assert.notNull(query, "StringQuery must not be null!");
this.query = query;
}
/*
* (non-Javadoc)
* @see org.springframework.data.jpa.repository.query.ParameterBinder#bind(javax.persistence.Query, org.springframework.data.repository.query.Parameter, java.lang.Object, int)
*/
@Override
protected void bind(Query jpaQuery, Parameter methodParameter, Object value, int position) {
Object valueToBind = value;
if (query.hasLikeBindings()) {
LikeBinding binding = getBindingFor(jpaQuery, position, methodParameter);
if (binding != null) {
valueToBind = binding.prepare(valueToBind);
}
}
super.bind(jpaQuery, methodParameter, valueToBind, position);
}
/**
* Finds the {@link LikeBinding} to be applied before binding a parameter value to the query.
*
* @param jpaQuery must not be {@literal null}.
* @param position
* @param methodParameter must not be {@literal null}.
* @return the {@link LikeBinding} for the given parameters or {@literal null} if none available.
*/
private LikeBinding getBindingFor(Query jpaQuery, int position, Parameter methodParameter) {
try {
jpaQuery.getParameter(position);
return query.getBindingFor(position);
} catch (IllegalArgumentException o_O) {
if (hasNamedParameter(jpaQuery)) {
return query.getBindingFor(methodParameter.getName());
}
}
return null;
}
}

View File

@@ -999,6 +999,34 @@ public class UserRepositoryTests {
assertThat(page, hasItem(firstUser));
}
/**
* @see DATAJPA-292
*/
@Test
public void executesManualQueryWithPositionLikeExpressionCorrectly() {
flushTestUsers();
List<User> result = repository.findByFirstnameLike("Da");
assertThat(result, hasSize(1));
assertThat(result, hasItem(thirdUser));
}
/**
* @see DATAJPA-292
*/
@Test
public void executesManualQueryWithNamedLikeExpressionCorrectly() {
flushTestUsers();
List<User> result = repository.findByFirstnameLikeNamed("Da");
assertThat(result, hasSize(1));
assertThat(result, hasItem(thirdUser));
}
private Page<User> executeSpecWithSort(Sort sort) {
flushTestUsers();

View File

@@ -0,0 +1,94 @@
/*
* Copyright 2013 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.jpa.repository.query;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import org.junit.Test;
import org.springframework.data.jpa.repository.query.StringQuery.LikeBinding;
import org.springframework.data.repository.query.parser.Part.Type;
/**
* @author Oliver Gierke
*/
public class LikeBindingUnitTests {
@Test(expected = IllegalArgumentException.class)
public void rejectsNullName() {
new LikeBinding(null, Type.CONTAINING);
}
@Test(expected = IllegalArgumentException.class)
public void rejectsEmptyName() {
new LikeBinding("", Type.CONTAINING);
}
@Test(expected = IllegalArgumentException.class)
public void rejectsNullType() {
new LikeBinding("foo", null);
}
@Test(expected = IllegalArgumentException.class)
public void rejectsInvalidType() {
new LikeBinding("foo", Type.SIMPLE_PROPERTY);
}
@Test(expected = IllegalArgumentException.class)
public void rejectsInvalidPosition() {
new LikeBinding(0, Type.CONTAINING);
}
@Test
public void setsUpInstanceForName() {
LikeBinding binding = new LikeBinding("foo", Type.CONTAINING);
assertThat(binding.hasName("foo"), is(true));
assertThat(binding.hasName("bar"), is(false));
assertThat(binding.hasName(null), is(false));
assertThat(binding.hasPosition(0), is(false));
assertThat(binding.getType(), is(Type.CONTAINING));
}
@Test
public void setsUpInstanceForIndex() {
LikeBinding binding = new LikeBinding(1, Type.CONTAINING);
assertThat(binding.hasName("foo"), is(false));
assertThat(binding.hasName(null), is(false));
assertThat(binding.hasPosition(0), is(false));
assertThat(binding.hasPosition(1), is(true));
assertThat(binding.getType(), is(Type.CONTAINING));
}
@Test
public void augmentsValueCorrectly() {
assertAugmentedValue(Type.CONTAINING, "%value%");
assertAugmentedValue(Type.ENDING_WITH, "%value");
assertAugmentedValue(Type.STARTING_WITH, "value%");
assertThat(new LikeBinding(1, Type.CONTAINING).prepare(null), is(nullValue()));
}
private static void assertAugmentedValue(Type type, Object value) {
LikeBinding binding = new LikeBinding("foo", type);
assertThat(binding.prepare("value"), is(value));
}
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright 2013 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.jpa.repository.query;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;
import java.util.List;
import org.junit.Test;
import org.springframework.data.jpa.repository.query.StringQuery.LikeBinding;
import org.springframework.data.repository.query.parser.Part.Type;
/**
* @author Oliver Gierke
*/
public class StringQueryUnitTests {
@Test
public void detectsPositionalLikeBindings() {
StringQuery query = new StringQuery("select u from User u where u.firstname like %?1% or u.lastname like %?2");
assertThat(query.hasLikeBindings(), is(true));
assertThat(query.getQuery(),
is("select u from User u where u.firstname like ?1 or u.lastname like ?2"));
List<LikeBinding> bindings = query.getLikeBindings();
assertThat(bindings, hasSize(2));
LikeBinding binding = bindings.get(0);
assertThat(binding, is(notNullValue()));
assertThat(binding.hasPosition(1), is(true));
assertThat(binding.getType(), is(Type.CONTAINING));
binding = bindings.get(1);
assertThat(binding, is(notNullValue()));
assertThat(binding.hasPosition(2), is(true));
assertThat(binding.getType(), is(Type.ENDING_WITH));
}
@Test
public void detectsNamedLikeBindings() {
StringQuery query = new StringQuery("select u from User u where u.firstname like %:firstname");
assertThat(query.hasLikeBindings(), is(true));
assertThat(query.getQuery(), is("select u from User u where u.firstname like :firstname"));
List<LikeBinding> bindings = query.getLikeBindings();
assertThat(bindings, hasSize(1));
LikeBinding binding = bindings.get(0);
assertThat(binding, is(notNullValue()));
assertThat(binding.hasName("firstname"), is(true));
assertThat(binding.getType(), is(Type.ENDING_WITH));
}
@Test(expected = IllegalArgumentException.class)
public void rejectsDifferentBindingsForRepeatedParameter() {
new StringQuery("select u from User u where u.firstname like %?1 and u.lastname like ?1%");
}
}

View File

@@ -124,6 +124,18 @@ public interface UserRepository extends JpaRepository<User, Integer>, JpaSpecifi
List<User> findByFirstnameNotIn(Collection<String> firstnames);
/**
* @see DATAJPA-292
*/
@Query("select u from User u where u.firstname like ?1%")
List<User> findByFirstnameLike(String firstname);
/**
* @see DATAJPA-292
*/
@Query("select u from User u where u.firstname like :firstname%")
List<User> findByFirstnameLikeNamed(@Param("firstname") String firstname);
/**
* Manipulating query to set all {@link User}'s names to the given one.
*