Some features of a framework are so expected that you end up being surprised when they don’t actually exist. Just recently, it happened again: our team thought it would be better to write a Spring Data construct using a query method. But at first, it didn’t work.
The following JpaRepository is part of our project:
public interface FamilyTreeRepository extends JpaRepository<FamilyTree, UUID> {
@Query("select t.name from FamilyTree t")
List<String> findAllNames();
}
Of course, not exactly this one. Unfortunately, we don’t deal with genealogy, but any topic can be explained using an example from genealogy. The example JpaRepository has a method to return the names of all saved family trees. It is annotated with @Query, which defines how the entity’s name property is queried.
At this point, all experienced Spring Boot developers agreed that a normal query method could be used here. In fact, however, there is no mechanism in the meta-language of query methods to narrow down from the entity to a specific property. You either get entities or a projection back. Of course, you could retrieve the entities and then map them to the desired property. But what a waste that would be.
For developers, learning about a missing feature isn’t the end—it’s the beginning of a story. So what can we do when we’re fed up with a tangle of @Query-annotated methods?
The best solution, of course, would be an extension to the Spring Data query mechanism that allows for a syntax to narrow down to properties.
public interface FamilyTreeRepository extends JpaRepository<FamilyTree, UUID> {
List<String> findAllWithOnlyName();
}
Here, the WithOnly keyword is used to specify which property is returned instead of the entity. In our example, that would be name. Unfortunately, this solution requires digging very deep into the Spring Data libraries. Therefore, a simpler solution must be found.
Of course, such a solution exists. Spring Data allows us to create our own implementations for the repositories. That sounds more complicated than it actually is, because we’re not starting from scratch, we’re extending SimpleJpaRepository.
public class PropertySelectRepositoryImpl<T, ID>
extends SimpleJpaRepository<T, ID>
implements PropertySelectRepository<T, ID> {
private final EntityManager em;
private final Class<T> domainClass;
public PropertySelectRepositoryImpl(
JpaEntityInformation<T, ?> entityInfo,
EntityManager em) {
super(entityInfo, em);
this.em = em;
this.domainClass = entityInfo.getJavaType();
}
@Override
public <R> List<R> findAllByProperty(String property, Class<R> resultType) {
return findAllByProperty(property, resultType, null);
}
@Override
public <R> List<R> findAllByProperty(String property, Class<R> resultType, Sort.Direction direction) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<R> query = cb.createQuery(resultType);
Root<T> root = query.from(domainClass);
Path<R> selection = resolvePath(root, property);
query.select(selection);
if (direction != null) {
Order order = direction == Sort.Direction.ASC ? cb.asc(selection) : cb.desc(selection);
query.orderBy(List.of(order));
}
return em.createQuery(query).getResultList();
}
@SuppressWarnings("unchecked")
private <R> Path<R> resolvePath(Root<T> root, String property) {
String[] parts = property.split("\\.");
Path<?> path = root;
for (String part : parts) {
path = path.get(part);
}
return (Path<R>) path;
}
}
This implementation supports everything that SimpleJpaRepository offers and adds two additional methods. The first findAllByProperty method retrieves the values from the property with the name specified in the first parameter and returns it as a list of the type of the class passed as the second parameter. The second form of the method has an additional direction parameter, which is used to sort the results.
Internally, the method creates a Criteria query for the attribute. We have previously worked with the Criteria API and its specifications for our filtering solution.
However, this does not yet complete the solution, because we still need a way to apply this extension. To do this, we first define an interface that provides the new methods and have it inherit from JpaRepository.
/**
* Extension of {@link JpaRepository} that allows selecting a single property
* from all rows without writing a custom {@code @Query}.
*
* <p>Usage example:
* <pre>{@code
* List<String> names = customerRepository.findAllByProperty("name", String.class);
* List<String> cities = customerRepository.findAllByProperty("address.city", String.class, Direction.ASC);
* }</pre>
*
* <p>The {@code property} argument supports nested paths using dot notation
* (e.g. {@code "address.city"}). The runtime base class is
* {@link PropertySelectRepositoryImpl}.
*/
@NoRepositoryBean
public interface PropertySelectRepository<T, ID> extends JpaRepository<T, ID> {
/**
* Returns all values of {@code property} for the given entity type.
*
* @param property dot-separated JPA property path, e.g. {@code "name"} or {@code "address.city"}
* @param resultType the scalar type expected by the caller
*/
<R> List<R> findAllByProperty(String property, Class<R> resultType);
/**
* Same as {@link #findAllByProperty(String, Class)} but with explicit sorting.
*
* @param property dot-separated JPA property path
* @param resultType the scalar type expected by the caller
* @param sort Spring Data {@link Sort} descriptor
*/
<R> List<R> findAllByProperty(String property, Class<R> resultType, Sort.Direction sort);
}
This means that a new base interface is now available for our repository interfaces.
/**
* Registers {@link PropertySelectRepositoryImpl} as the base class for all
* Spring Data JPA repositories in this application.
*
* <p>Kept in a dedicated {@code @Configuration} class (and NOT on
* {@code @SpringBootApplication}) so that {@code @WebMvcTest} slices can
* exclude it without needing a full JPA context.
*/
@Configuration(proxyBeanMethods = false)
@EnableJpaRepositories(repositoryBaseClass = PropertySelectRepositoryImpl.class)
class JpaRepositoryConfig {
}
Once we’ve changed the configuration so that it no longer uses SimpleJpaRepository but instead uses our own implementation, the new findAllByProperty method will be available in all of our repositories.
Now we can not only retrieve the names of our family trees:
List<String> treeNames = treeRepository.findAllByProperty("name", String.class);
But also all the birthplaces of our ancestors:
List<String> placesOfBirth = ancestorRepository.findAllByProperty("birth.place", String.class);
Instead of changing all the repository implementations, we could have used fragments, or we could have specified the properties in a type-safe way. We’ll cover that in a later post.