Saturday, August 21, 2010

Building Flexible Query Api's with Hamcrest and Guava

If you've ever had to build an api for custom querying of data then you've probably had to address the issue of how to handle queries for ever changing criteria. We've all seen different varieties of approaches. One approach I've seen recently that I find particularly interesting is using a combination of Hamcrest matchers and the Google commons library (now Guava) to create an api where custom queries can be accepted without having to customize the api.

We'll start by creating a simple class to hold information about People.
import org.apache.commons.lang.builder.ToStringBuilder;

final class Person{
private final String firstName,lastName;
private final int age;
private final double salary;

Person(final String firstName, final String lastName, final int age, final double salary) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
this.salary = salary;
}
public String firstName() {return firstName;}
public String lastName() {return lastName;}
public int age() {return age;}
public double salary() {return salary;}

@Override
public String toString() { return ToStringBuilder.reflectionToString(this);}
}

Nothing special here. One thing to note is the implementation of equals uses ToStringBuilder from the Apache Commons api. This is a great little utility for building a string representation of an object. Be careful how you use it however because it does so by reflection so performance may suffer.

Next, lets build a class to hold a cache of data on people containing a method to query for those people.
package org.foo;
import com.google.common.base.Predicate;
import com.google.common.collect.Iterables;
import org.hamcrest.Matcher;
import java.util.*;

final class PeopleCache {
private static final PeopleCache instance = new PeopleCache();
private final erson> people = buildCache();

private PeopleCache() {}

public static PeopleCache instance() {return instance;}

public Iterable<Person> queryForPeople(Matcher<Person> matcher){
return (matcher == null
? people : Iterables.filter(people, new PredicateWrapper<Person>(matcher)));
}

private List<Person> buildCache() {
final List<Person> people = new ArrayList<Person>();
people.add(new Person("John", "Smith", 45, 150000));
people.add(new Person("Bob", "Smith", 35, 75000));
people.add(new Person("Bob", "Gordon", 37, 80000));
people.add(new Person("Tom", "Barry", 54, 100000));
return people;
}
}
Note that the queryForPeople method takes one argument, a matcher. The Matcher is a Hamcrest interface that provides methods to match an object and to describe a condition where a match has failed. Also note the use of the Iterables.filter method. This if from Google's Guava library. It is a method that takes an Iterable and and an Predicate. The Predicate is also from the Guava library and like the Matcher interface, it provides methods for checking equality on objects. Custom implementations of the Predicate interface work well but the real magic lies in adapting the rich set of Hamcrest matchers to the Predicate interface so that those matchers can be used in the filtering.

So, lets create the magic now. The following is a wrapper that adapts Matchers to a Predicate.
class PredicateWrapper<Person> implements Predicate<Person>{
private final Matcher<Person> matcher;
PredicateWrapper(final Matcher<Person> matcher) { this.matcher = matcher; }
public boolean apply(final Person input) { return matcher.matches(input); }
}
Now that we have an adapter for the matchers, we can create some custom matchers.
package org.foo;
import org.hamcrest.*;
import static org.hamcrest.CoreMatchers.allOf;
final class PeopleMatchers{
private PeopleMatchers(){}

public static Matcher<Person> hasLastName(final String lastName) {
return new TypeSafeMatcher<Person>() {

@Override
public boolean matchesSafely(final Person toMatch) {
return lastName.equalsIgnoreCase(toMatch.lastName());
}

@Override
public void describeTo(final Description desc) {
desc.appendText("last name is ").appendValue(lastName);
}
};
}

public static Matcher<Person> isOlderThan(final Integer age) {
return new TypeSafeMatcher<Person>() {
@Override
public boolean matchesSafely(final Person toMatch) {
return toMatch.age() > age;
}

@Override
public void describeTo(final Description desc) {
desc.appendText("age is than ").appendValue(age);
}
};
}
}
Ok, So now that we have some matchers, we can call build a query by initializing a matcher and passing it to the query method. If you were to print out the result of executing the following:
PeopleCache.instance().queryForPeople(PeopleMatchers.hasLastName("Smith"))
You would see something like the following in the output:
org.foo.Person@482923[firstName=John,lastName=Smith,age=45,salary=150000.0]
org.foo.Person@c832d2[firstName=Bob,lastName=Smith,age=35,salary=75000.0]

If you were to print out the result of executing the following:
PeopleCache.instance().queryForPeople(PeopleMatchers.isOlderThan(35)))

You would see something like the following in the output:
org.foo.Person@482923[firstName=John,lastName=Smith,age=45,salary=150000.0]
org.foo.Person@d19bc8[firstName=Bob,lastName=Gordon,age=37,salary=80000.0]
org.foo.Person@14a8cd1[firstName=Tom,lastName=Barry,age=54,salary=100000.0]

Ok, so thats kind of cool but most real life queries require more than one condition. This can easily be accomodated by using the Hamcrest allOf matcher
which takes an array of matchers as an argument. Lets add a new matcher into the PeopleMatchers class and try it out. The following is a matcher that matches for a salary being greater than the given argument.

public static Matcher<Person> salaryGreaterThan(final double salary)
{
return new TypeSafeMatcher<Person>() {
@Override
public boolean matchesSafely(final Person toMatch) { return toMatch.salary() > salary; }

@Override
public void describeTo(final Description desc) { desc.appendText("salary is").appendValue(salary); }
};
}

So now, if you were to to print out the results of calling the query :
PeopleCache.instance().queryForPeople(
allOf(PeopleMatchers.isOlderThan(35), PeopleMatchers.salaryGreaterThan(80000))))

You would see something like the following in the output:
org.foo.Person@184ec44[firstName=John,lastName=Smith,age=45,salary=150000.0]
org.foo.Person@1630ab9[firstName=Tom,lastName=Barry,age=54,salary=100000.0]
You can see that it filtered bot for age and salary in the results.
So there you have it, a nice flexible api that can accept multiple criteria nicely without having to change the signature of the query method. One thing to keep in mind here is that the Iterables.filter method does iterate through all the elements in the given iterable so performance should be considered when dealing with large cached data or when retrieving from a database and filtering the results afterwards. Source code and sample test cases for this article can be found at github.

No comments:

Post a Comment