Wednesday, June 28, 2006

R.J. Lorimer who frequently posts very well written and useful Java tips on JavaLobby (and has authored the cool new site DZone. Update: RJ reports he had no active development role in DZone, sorry for the mistake) recently posted a few tips on using Ibatis. I'm not that big of a fan of Ibatis since IMHO mainly transforms writing tedious Java code to propagate ResultSets into Beans, into writing tedious but XML configuration files.

Using external XML mapping files, as is common in many frameworks for XML or Database persistence, if you make a change to the underlying objects, you have to make sure you keep the XML map files up to date. Sometimes IDEs can do this for you if you are lucky.

However, the point was made in the discussion that although annotations save some of the tedium of creating mappings, and provide some type-safe refactoring for the mappings, they have the downside of requiring the mapping information to be placed inside the POJO bean itself. In some scenarios, this could get ugly, especially if you've got a POJO that must be bound to both an ORM and an XML binding like JAXB.

So, is it possible to use Java to create refactorable type-safe bindings in external classes? The answer turns out to be yes. To understand how this can be done, one must recall a trick that Alex Winston posted a few months ago on Strongly Typed Java Delegates.

Alex's technique uses Generics in Tiger and CGLib generated method interceptors to perform its magic. But this trick is far more powerful than Alex is given credit for. It opens a new vista on metaclass programming in Java, a subset of what one gets in a language like Groovy or Ruby, but with strong type information preserved for the IDE to and compiler.

I started out not sure if it was possible, but after an hour of so of hacking, and I had a rudimentary Hibernate configuration version done. I won't fully explain how the following works, you can read Alex's original article for the gist, but here is a sample of what one can do.

Given the following POJO

public class POJO
{
private Long id;
private String firstname;

public String getFirstname() {
return firstname;
}

public void setFirstname(String firstname) {
this.firstname = firstname;
}

public String getLastName() {
return lastName;
}

public void setLastName(String lastName) {
this.lastName = lastName;
}

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}
}


one map create a Hibernate mapping using my new Config class as so

public ConfigTest
{
public static void config(Configuration cfg)
{
MapOp mo=Config.map(POJO.class);
mo.table("mytable").id().bind().getId();
mo.column("first").bind().getFirstname();
cfg.addDocument(Config.xml(POJO.class));
}
}


If you refactor any of the getter methods in POJO, the ConfigTest class will automagically be updated in most IDEs. Moreover, errors in mapping due to property name changes will be caught at compile time, unlike the XML mapping approach.

Looking at the above code, one might want to write

Config.map(POJO.class).id().bind().getId();


but javac complains, and there is a BugParade bug claiming that one must assign the return value of the map() function above to a temporary variable first. (ugh!)

Anyway, here is the hacked up code demonstrating the technique. Thanks for Alex for his original, of which I borrowed the code skeleton to produce this.



import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import net.sf.cglib.proxy.NoOp;
import org.w3c.dom.Document;
import org.w3c.dom.Element;

import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

/**
* Create type-safe refactorable hibernate configurations without using annotations in POJOs.
*/
public class Config {

private static Map<Class, MapData> mapping = new HashMap<Class, MapData>();
private static Set<String> mapOpMethods = new HashSet<String>();

static {
for (Method m : MapOps.class.getDeclaredMethods())
if (!m.getName().equals("bind")) mapOpMethods.add(m.getName());
}

/**
* @param t - a class that you want to map in hibernate
* @return a MapOp class which builds up a map before binding it to a method
* @throws Exception
*/
public static <T, S extends MapOps<T>> S map(Class<T> t) throws Exception {
return (S) Enhancer.create(t, new Class[]{MapOps.class}, new ProxyInterceptor<S, T>(t));
}

public static Document xml(Class t) {
MapData md = mapping.get(t);
return md.getXmlDocument();
}

public static class ProxyInterceptor<S,T> implements MethodInterceptor {
private T proxy;
private Class<T> clazz;
final private HashMap<String, Object> bindingData = new HashMap<String, Object>();

public ProxyInterceptor(Class<T> target) {
this.proxy = (T) Enhancer.create(target, NoOp.INSTANCE);
this.clazz = target;
}

public Object intercept(final Object o, final Method m,
final Object[] args, MethodProxy mp) throws Throwable {
final MapData md[] = new MapData[1];
md[0] = mapping.get(clazz.getClass());

if (md[0] == null) {
md[0] = new MapData();
mapping.put(clazz, md[0]);
}

md[0].setClassName(clazz.getCanonicalName());

if (m.getName().equals("bind")) {
return Enhancer.create(clazz,
new MethodInterceptor() {
public Object intercept(Object o1, Method m1,
Object[] args1, MethodProxy mp1) throws Throwable {
if (m1.getName().startsWith("get")) {
md[0].bindToProperty(bindingData, getPropertyName(m1.getName()));

bindingData.clear();
}
return null;

}


});
}
if (mapOpMethods.contains(m.getName())) {
bindingData.put(m.getName(), args.length == 0 ? "" : args.length < 2 ? args[0] : args);
return o;

} else
return Enhancer.create(m.getReturnType(),
new MethodInterceptor() {
public Object intercept(Object o1, Method m1,
Object[] args1, MethodProxy mp1) throws Throwable {
return proxy.getClass().getMethod(
m.getName(), classes(args1)).invoke(proxy, args1);
}
});
}
}

private static String getPropertyName(String methodName) {
return methodName.substring(3, 4).toLowerCase() + (methodName.length() > 4 ? methodName.substring(4) : "");

}

private static Class[] classes(Object[] objects) {
Class[] classes = new Class[objects.length];
for (int i = 0; i < objects.length; i++)
classes[i] = objects[i].getClass();
return classes;
}

public static class MapData<T> {
private Document hbm;
private Element hibernate_mapping;
private Element clazz;
private Element id;
private Map<String, Element> properties = new HashMap<String, Element>();

public MapData() {
try {
hbm = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument();
hibernate_mapping = hbm.createElement("hibernate-mapping");
hbm.appendChild(hibernate_mapping);
clazz = hbm.createElement("class");
hibernate_mapping.appendChild(clazz);
id = hbm.createElement("id");
clazz.appendChild(id);
} catch (ParserConfigurationException e) {
e.printStackTrace(); //To change body of catch statement use File | Settings | File Templates.
}
}

public void setId(String name) {
id.setAttribute("name", name);

}

public void setColumn(String dbColName, String propName) {
getProperty(propName).setAttribute("column", dbColName);
getProperty(propName).setAttribute("name", propName);

System.out.println("Mapping " + dbColName + " to " + propName);
}

private Element getProperty(String propName) {
Element e = properties.get(propName);
if (e == null) {
e = hbm.createElement("property");
clazz.appendChild(e);
properties.put(propName, e);
}
return e;
}

public void setTable(String tableName) {
clazz.setAttribute("table", tableName);
}

public void bindToProperty(HashMap<String, Object> bindingData, String propertyName) {
for (Map.Entry<String, Object> e : bindingData.entrySet()) {
if ("id".equals(e.getKey())) setId(propertyName);
else if ("column".equals(e.getKey())) setColumn(e.getValue().toString(), propertyName);
else if ("table".equals(e.getKey())) setTable(e.getValue().toString());

}
}

public Document getXmlDocument() {
return hbm;
}

public void setClassName(String canonicalName) {
clazz.setAttribute("name", canonicalName);
}
}

public interface MapOps<T> {
/**
* Sets the database table for this class. Equivalent to @Table annotation
*
* @param tableName
* @return
*/
public MapOps<T> table(String tableName);

/**
* identifies the db column of the property to be bound, for example
* <code> Config.map(Foo.class).column("first").bind().getFirstName(); </code>
*
* @param dbColumn
* @return
*/
public MapOps<T> column(String dbColumn);

/**
* identifies that the property to be bound will be be an ID column, equivalent to @Id annotation
* <code> Config.map(Foo.class).id().bind().getFirstName(); </code>
*
* @return
*/
public MapOps<T> id();

/**
* ends the current context for property mapping. The very next method call on type T if it is a call to a getter, like getFoo(), will bind
* all of the previously configured information in the expression to the property 'foo'
* <code> Config.map(Foo.class).table("footable").column("foofoo").bind().getMyFoo() </code>
*
* @return
*/
public T bind();
}


}

Labels: