Benji's Blog

Typesafe Hibernate POJO Queries without code generation

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:

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()));
    }
}