Hibernate is great, but often one has to specify queries as HQL in Strings, or as criteria which allow building of invalid queries.
It would be great to use the java type system to help enforce correct queries. I am aware of some tools to do this, but all the ones I have seen require either a code generation step, or preclude the use of standard getters/setters.
Here is a proof of concept of one approach that I think could work. It allows things like:
public List getPeopleByName(final String firstName, final String lastName) {
return new Query() {
public CompletedQuery spec(Person person) { // <- Only Person can be passed to here.
return where(person.getFirstname())
.equalTo(firstName) // <- Only comparisons are valid at this step, a .and(... would be a compilefailure.
.and(person.getLastname())
.equalTo(lastName)
.select(); // <- Omitting this would be a compile failure due to return type.
}
}.list(HibernateUtil.getSessionFactory().getCurrentSession()); // <- Method returns a List
}
A toString on the above Query<Person> when passed firstname benji and lastname weber would yield:
FROM Person WHERE firstname = 'benji' AND lastname = 'weber'
If we were to try to supply a non-string value for first name we would get a compile failure.
public List getPeopleByName(final String firstName, final String lastName) {
return new Query() {
public CompletedQuery spec(Person person) {
return where(person.getFirstname())
.equalTo(firstName)
.and(person.getLastname())
.equalTo(5) // <- Compile failure
.select();
}
}.list(HibernateUtil.getSessionFactory().getCurrentSession());
}
This approach can even work querying through relationships
public List getPeopleByAddress(final Address address) {
return new Query() {
public CompletedQuery spec(Person person) {
return where(person.getAddress().getId())
.equalTo(address.getId()) // < - Only Integers are allowed here.
.select();
}
}.list(HibernateUtil.getSessionFactory().getCurrentSession());
}
A toString on the above Query<Person> when passed an Address with id 2 would yield:
FROM Person WHERE address.id = 2
To implement this I used a few tricks:
- Query<T> is an abstract class that uses Gafter's Gadget to obtain a Class<T> this is used to get the entity name that we are querying, and later to create an instance.
- A Dynamic Proxy class is created for this Class and passed to the spec(T value) method.
- This dynamic proxy records all calls made to it. If the call is to a method that returns another mockable object (e.g. getAddress() in the above example) then another recording-proxy is returned.
- The query builder then uses the recorded method calls to determine which properties are being queried
- The need to return a CompletedQuery interface forces the implementor to type a complete query or they will get a compile failure.
- Interfaces represent the valid progressions at each step of the query building. e.g. after a where() you need to supply a comparison equalTo(), or like() etc. After a comparison you can complete the query or add another restriction with and();
Here is the example implementation of Query that enables this. Full demo code here
package uk.co.benjiweber.typesafehibernate.typesafe;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.List;
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import org.hibernate.Session;
import org.springframework.util.StringUtils;
public abstract class Query {
private final Type type;
private final Recorder recorder;
private final StringBuilder queryBuilder = new StringBuilder();
public Query() {
Type superclass = getClass().getGenericSuperclass();
this.type = ((ParameterizedType) superclass).getActualTypeArguments()[0];
this.queryBuilder.append("FROM " + getParamClass().getSimpleName());
recorder = createMockInstance();
spec(recorder.getObject());
}
public abstract CompletedQuery spec(T entity);
public interface CompletedQuery {
}
public Comparison where(U ignore) {
queryBuilder.append(" WHERE ");
return comparison(ignore);
}
private Comparison comparison(U ignore) {
return new Comparison() {
public QueryOptions equalTo(U value) {
return comparison(value, "=");
}
public QueryOptions notEqualTo(U value) {
return comparison(value, "!=");
}
public QueryOptions like(U value) {
return comparison(value, "LIKE");
}
private QueryOptions comparison(U value, String operator) {
boolean quote = value != null && String.class.equals(value.getClass());
queryBuilder.append(recorder.getCurrentPropertyName()).append(" ").append(operator).append(" ").append(quote ? "'" : "").append(value).append(quote ? "'" : "");
return new AbstractQueryOptions() {
public Comparison and(V o) {
return Query.this.and(o);
}
};
}
};
}
private Comparison and(U ignore) {
queryBuilder.append(" AND ");
return comparison(ignore);
}
public interface Comparison {
QueryOptions equalTo(U value);
QueryOptions notEqualTo(U value);
QueryOptions like(U value);
}
abstract static class AbstractQueryOptions implements QueryOptions {
public CompletedQuery select() {
return new CompletedQuery() {
};
}
}
public interface QueryOptions {
Comparison and(V o);
CompletedQuery select();
}
private Recorder createMockInstance() {
Class cls = getParamClass();
return RecordingObject.create(cls);
}
private Class getParamClass() {
return type instanceof Class>
? (Class) type
: (Class) ((ParameterizedType) type).getRawType();
}
@Override
public String toString() {
return queryBuilder.toString();
}
public List list(Session session) {
return session.createQuery(toString()).list();
}
}
class Recorder {
private T t;
private RecordingObject recorder;
public Recorder(T t, RecordingObject recorder) {
this.t = t;
this.recorder = recorder;
}
public String getCurrentPropertyName() {
return recorder.getCurrentPropertyName();
}
public T getObject() {
return t;
}
}
class RecordingObject implements MethodInterceptor {
private String currentPropertyName = "";
private Recorder> currentMock = null;
@SuppressWarnings("unchecked")
public static Recorder create(Class cls) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(cls);
final RecordingObject recordingObject = new RecordingObject();
enhancer.setCallback(recordingObject);
return new Recorder((T) enhancer.create(), recordingObject);
}
public Object intercept(Object o, Method method, Object[] os, MethodProxy mp) throws Throwable {
if (method.getName().equals("getCurrentPropertyName")) {
return getCurrentPropertyName();
}
currentPropertyName = StringUtils.uncapitalize(method.getName().replaceAll("^get", ""));
try {
currentMock = create(method.getReturnType()); //method.invoke(o, os);
return currentMock.getObject();
} catch (IllegalArgumentException e) {
//non-mockable
return null;
}
}
public String getCurrentPropertyName() {
return currentPropertyName + (currentMock == null ? "" : ("." + currentMock.getCurrentPropertyName()));
}
}