We have a large number of domain entities that we want to expose to front end. Front end would like the users to be able to filter on those entities using almost all attributes that those entities have.
Also, when the entities form one to one or one to many relations, the users should be able to filter on the attributes of the related entities as well.
If we use Spring Data JpaRepository interface, we can filter entities using Query Methods:
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
List<User> findUserByLastname(String lastname); | |
List<User> findUserByLastnameAndFirstname(String lastname, String firstname); |
Solution:
So instead we have to use JpaSpecificationExecutor for our repositories and pass a Specification instance to the findAll() method of entity repositories. We create a BaseSpecification class to transform url filter parameters into predicats.
First we try to make every attribute of the entities filterable. To do that, we have to read the attribute name from the url query string and test whether the query key is one of the entity's attributes. We achieve this by using the Reflection API:
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Specification<T> filterWithOptions(final Map<String, String> params, final Class<T> tClass) { | |
return (root, query, cb) -> { | |
List<Predicate> predicates = new ArrayList<>(); | |
for (String field : params.keySet()) { | |
if (tClass.getDeclaredField(field) != null) { | |
predicates.add(cb.equal(root.get(field), params.get(field))); | |
} | |
} | |
return cb.and(predicates.toArray(new Predicate[predicates.size()])); | |
}; | |
} |
But we also want to be able to filter on attributes from the entities that are associated with the entity to be filterd. We design the api as such: http://localhost:8080/demo/api/user?address.street=5th will find all users whose address has the attribute Street and has the value 5th.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Specification<T> filterWithOptions(final Map<String, String> params, final Class<T> tClass) { | |
return (root, query, cb) -> { | |
try { | |
List<Predicate> predicates = new ArrayList<>(); | |
for (String field : params.keySet()) { | |
if (field.contains(".")) { | |
String[] compositeField = field.split("\\."); | |
if (compositeField.length == 2 | |
&& tClass.getDeclaredField(compositeField[0]).getType().getDeclaredField(compositeField[1]) != null) { | |
predicates.add(cb.equal(root.get(compositeField[0]).get(compositeField[1]), params.get(field))); | |
} | |
} else { | |
if (tClass.getDeclaredField(field) != null) { | |
predicates.add(cb.equal(root.get(field), params.get(field))); | |
} | |
} | |
} | |
return cb.and(predicates.toArray(new Predicate[predicates.size()])); | |
} catch (NoSuchFieldException e) { | |
e.printStackTrace(); | |
} | |
return null; | |
}; | |
} |
At this point the front end rises the requirement that the legacy front end code couldn't provide the entity name that is associated with the entity to be filtered. So the to find the users whose address has attribute street and value 5th, the query string looks like this: http://localhost:8080/demo/api/user?street=5th
In this case we have to search if the url query key is one of the attributes from the filtered entity or from entities associate with it. We can achieve this search using either Breadth First Search or Depth Firsts Search.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Pair<List<Predicate>, List<Order>> generatePredicates(final Root<T> root, final CriteriaQuery<?> query, final CriteriaBuilder cb) { | |
//... | |
Pair<Class, Path> searchResult = new Object() { | |
// Class is the class having the attribute | |
// path is the path pointing to the class via criteria | |
Pair<Class, Path> depthFirstNameMatcher(Class currentQueryClass, Path currentPath) { | |
for (Field attribute : currentQueryClass.getDeclaredFields()) { | |
logger.debug("Attribute name: " + attribute.getName()); | |
// if queryField is a match, stop searching | |
if (queryField.equals(attribute.getName())) { | |
logger.debug("Match: " + attribute.getName()); | |
return Pair.of(currentQueryClass, currentPath); | |
} | |
// if attribute is one of the predefined type, go one level deeper for searching | |
else if (MODEL_CLASS_NAMES.contains(attribute.getType().getSimpleName())) { | |
Pair<Class, Path> res = depthFirstNameMatcher(attribute.getType(), currentPath.get(attribute.getName())); | |
if (res != null) | |
return res; | |
} | |
} | |
return null; | |
} | |
//class is the type containing the querying attribute | |
//path is the path pointing to the class via criteria | |
Pair<Class, Path> breadthFirstNameMatcher(Class rootQueryClass, Path rootPath) { | |
//node to store field and path info | |
class CustomNode { | |
public Field cfield; | |
public Class cclazz; | |
public Path cpath; | |
public CustomNode(Field f, Class c, Path p) { | |
this.cfield = f; | |
this.cclazz = c; | |
this.cpath = p; | |
} | |
} | |
LinkedList<CustomNode> fifoQueue = new LinkedList<>(); | |
for (Field attribute : rootQueryClass.getDeclaredFields()) { | |
logger.debug("Attribute name: " + attribute.getName()); | |
fifoQueue.offer(new CustomNode(attribute, rootQueryClass, rootPath)); | |
} | |
while (!fifoQueue.isEmpty()) { | |
logger.debug("While: "); | |
CustomNode head = fifoQueue.poll(); | |
// if queryField is a match, stop searching | |
if (queryField.equals(head.cfield.getName())) { | |
logger.debug("Match: " + head.cfield.getName()); | |
return Pair.of(head.cclazz, head.cpath); | |
} | |
// if attribute is one of the predefined type, enqueue | |
else if (MODEL_CLASS_NAMES.contains(head.cfield.getType().getSimpleName())) { | |
for (Field subAttribute : head.cfield.getType().getDeclaredFields()) { | |
logger.debug("Attribute name: " + subAttribute.getName()); | |
fifoQueue.offer(new CustomNode(subAttribute, head.cfield.getType(), head.cpath.get(head.cfield.getName()))); | |
} | |
} | |
} | |
return null; | |
} | |
}.breadthFirstNameMatcher(tClass, root); | |
//... | |
} |
Here the search output a Pair. The Path object is later to be used to build the Criteria Predicate. And the Class object tells us the type of the attribute used for filtering. If the type is Date for example, we can generate Date specific Criteria Predicates.