Filtering Java Collections via Annotation-Driven Introspection
Since Java 8, the programming workload of iterating over collections and selecting a subcollection (filtering) based on defined constraints (predicates) has been considerably simplified using the new Stream API. Despite this new feature, some scenarios can still overwhelm developers by requiring them to implement a significant amount of very specific code to meet the filtering conditions. A common example in enterprise systems is the need to filter:
". . . a collection where each object is an element of a large graph and the attributes to be considered in the filters belong to distinct objects."
To illustrate the scenario, let's consider the small class diagram below. It is required to filter a collection of Post
objects taking into account different attributes of the object graph. At this point, disregard the @Filterable
annotation on some attributes; this will be covered later.
To present a traditional Java code for filtering Post
collections, let's make some assumptions:
- A
List<Post>
has been instantiated and loaded. - A primitive type
textFilter
variable has been set as the value to be searched. - The Apache Commons Lang
StringUtils
class is used to remove diacritics from strings.
A way to filter a postsCollection
by the text
attribute of the Publication
class is:
postsCollection
.stream()
.filter(p -> Objects.isNull(p.getText()) || StringUtils.stripAccents(p.getText().trim().toLowerCase())
.contains(textFilter.trim().toLowerCase()))
.toList();
If the need is to filter the collection by the review
attribute of the Comment
class, the code could be like this:
postsCollection
.stream()
.filter(p -> Objects.isNull(p.getComments()) || p.getComments().isEmpty() ||
p.getComments().stream().anyMatch(c -> StringUtils.stripAccents(c.getReview().trim().toLowerCase())
.contains(textFilter.trim().toLowerCase())))
.toList();
Note that for each change in filter requirements, the code must be adjusted accordingly. And what's worse: the need to combine multiple attributes from different graph classes can lead to code hard to maintain.
Addressing such scenarios, this article presents a generic approach to filtering collections by combining annotations and the Java Reflection API.
Introspector Filter Algorithm
The proposed approach is called Introspector Filter and its main algorithm relies on three fundaments:
- Traverse all relationships of each object using a Breadth-First Search strategy.
- Traverse the hierarchy of each object.
- Check if any attribute annotated with
@Filterable
of each object contains the given pattern.
It's worth explaining that all traversing operations are supported by the Reflection API. Another relevant point is that the attributes to be considered in the filtering operation must be annotated with @Filterable
.
The code below presents the implementation of the Introspector Filter algorithm. Its full source code is available in the GitHub repository.
public IntrospectorFilter() {
public Boolean filter(Object value, Object filter) {
if (Objects.isNull(filter)) {
return true;
}
String textFilter = StringUtils.stripAccents(filter.toString().trim().toLowerCase());
var nodesList = new ArrayList<Node>();
nodesList.add(new Node(0, 0, value));
while (!nodesList.isEmpty()) { // BFS for relationships
var node = nodesList.removeFirst();
if (node.height() > this.heightBound || node.breadth() > this.breadthBound) {
continue;
}
var fieldValue = node.value();
var fieldValueClass = fieldValue.getClass();
int heightHop = node.height();
do { // Hierarchical traversing
if (Objects.nonNull(
this.searchInRelationships(node, fieldValueClass, heightHop, textFilter, nodesList))) {
return true;
}
fieldValueClass = fieldValueClass.getSuperclass();
heightHop++;
} while (isValidParentClass(fieldValueClass) && heightHop <= this.heightBound);
if (isStringOrWrapper(fieldValue) && containsTextFilter(fieldValue.toString(), textFilter)) {
return true;
}
}
return false;
}
}
Three methods have been abstracted from this code to keep the focus on the main algorithm:
isValidParentClass
: Verify if the parent class is valid, considering any additional annotations that have been provided to identify the class. If no additional annotation have been provided, all parent classes are considered valid.isStringOrWrapper
: Check if the value of the attribute annotated with@Filterable
is aString
or a primitive type wrapper. When this is true, such a relationship traversing is interrupted, as there is no further way forward.containsTextFilter
: Check if the attribute annotated with@Filterable
contains the supplied pattern.
Let's go back to the previous small class diagram. The two codes presented for filtering a postsCollection
may be replaced with the following code using the Introspector Filter Algorithm.
postsCollection.stream().filter(p -> filter.filter(p, textFilter)).toList();
Introspector Filter Project
The implementation of the Introspector Filter Algorithm has been encapsulated in a library (jar). It can be incorporated into a Maven project by simply adding the following dependency to the pom.xml
file. This implementation requires at least Java version 21 to work. But there is a Java 8 compatible version — change the version dependency to 0.1.0.
<dependency>
<groupId>io.github.tnas</groupId>
<artifactId>introspectorfilter</artifactId>
<version>1.0.0</version>
</dependency>
The project also has an example module detailing how to use the Introspector Filter as a global filter in JSF datatable components.
Conclusion
Selecting a subset of objects from a Java collection is a common task in software development. Having a flexible strategy (Introspector Filter Algorithm) or tool (Introspector Filter Library) for creating filters to be used in these selections can help developers write more concise and maintainable code. This is the proposal of Instrospector Filter Project, which is fully available in a GitHub repository.