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<Person> getPeopleByName(final String firstName, final String lastName) { return new Query<Person>() { 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<Person> } |
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<Person> getPeopleByName(final String firstName, final String lastName) { return new Query<Person>() { 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<Person> getPeopleByAddress(final Address address) { return new Query<Person>() { 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
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<T> { private final Type type; private final Recorder<T> 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 <U> Comparison<U> where(U ignore) { queryBuilder.append(" WHERE "); return comparison(ignore); } private <U> Comparison<U> comparison(U ignore) { return new Comparison<U>() { 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 <V> Comparison<V> and(V o) { return Query.this.and(o); } }; } }; } private <U> Comparison<U> and(U ignore) { queryBuilder.append(" AND "); return comparison(ignore); } public interface Comparison<U> { 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 { <V> Comparison<V> and(V o); CompletedQuery select(); } private Recorder<T> createMockInstance() { Class<T> cls = getParamClass(); return RecordingObject.create(cls); } private Class<T> getParamClass() { return type instanceof Class<?> ? (Class<T>) type : (Class<T>) ((ParameterizedType) type).getRawType(); } @Override public String toString() { return queryBuilder.toString(); } public List<T> list(Session session) { return session.createQuery(toString()).list(); } } class Recorder<T> { 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 <T> Recorder<T> create(Class<T> 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())); } } |