Refactoring-safe referencing of bean properties

Currently starting to overhaul an old idea for a rather handy (sort of DSL’ish) query API in Java that exposes some properties I very much miss in APIs like Query DSL.

A typical problem when designing data access APIs or any other API that binds some data structure to Java Beans is that you cannot directly refer to bean properties in a refactoring-safe way when constructing expressions. To do so you make use of string constants, thereby denoting property names redundantly. The advantage of bean properties over string constants however is that refactoring tools recognize usage throughout a complete codebase, so that changing internal data naming is a straightforward and low-risk task.

The approach taken by tools such as the (dreadful) JPA criteria API or Query DSL is to offer generation of Companion Types for bean types. The companion types expose access to property names and more. As code generation – in particular code generation  involving the IDE – that generates code that is referenced by name from hand-typed code, extends the compiler food chain to an even more intrusive beast – even introducing IDE dependencies – this approach is not only ugly, it asks to trouble mid-way and cannot be a sane choice long term.

Here is another approach:

Based on the Java Bean Specification we have a one-to-one relationship between bean properties and its read methods (the getters). In Java, method meta-data is not as directly accessible as class meta-data via the reflection API. That is, unlike

MyBean.class.getName()

to access a class name in code, there is nothing like

MyBean.getOrder.method.getName()

for methods. In order to retrieve the property association via a getter method, we can however make some careful use of byte-code trickery. Using the Javassist library, we can generate a support extension – a meta bean – of the original bean type, that, when invoking its getters, provides us with the associated property name.

In essence this works as follows (see the code below): After retrieving a meta bean (that may be held on to), invoking a getter leaves the corresponding property name in a thread local. A helper method reads the thread local and resets it. So, continuing the example below,

MyBean mb = MetaBeans.make(MyBean.class);
System.err.println(MetaBeans.p(mb.getOrder()));

would output

order

As a neat extension, the artificially created getters return (whenever sensibly possible) meta beans itself, and when finding a non-empty property name held by the thread local storage, instead of setting it to the property name, the property name will be appended, so that

//...
import static MetaBeans.*;
//...

MyBean mb = make(MyBean.class);
System.err.println(p(mb.getOrder().getId));

would output

order.id

Using this approach requires no further tooling whatsoever, can easily be extended to other use-cases, is completely refactor-safe, and comes at diminishing costs.

Note that the implementation below is not made to run with module-system-type class loader setups, is somewhat crude, and is really just meant to illustrate the idea. Consult the Java Assist API for more information on managing class pools.

Here is the MetaBeans class:

 public class MetaBeans {
    private static ThreadLocal<String> properties = new ThreadLocal<String>();

    /**
     * Create a meta bean instance. If not eligible, this method throws an IllegalArgumentException.
     * 
     * @param beanClass the bean class to create a meta bean instance for
     * @return instance of meta bean
     */
    public static <T> T make(Class<T> beanClass) {
        return make(beanClass,true);
    }

    /**
     * Create a meta bean instance. If not eligible, return null.
     * 
     * @param beanClass the bean class to create a meta bean instance for or null, if the class is found to be not eligible.
     * @return instance of meta bean
     */
    public static <T> T makeOrNull(Class<T> beanClass) {
        return make(beanClass,false);
    }

    /**
     * Track meta bean invocations and return property path.
     */
    public static String p(Object any) {
        try {
            return properties.get();
        } finally {
            properties.set(null);
        }
    }

    /**
     * Internal.
     */
    public static void note(String name) {
        String n = properties.get();
        if (n==null) {
            n = name;
        } else {
            n += "."+name;
        }
        properties.set(n);
    }

    //
    // private 
    //

    // actually provide an instance
    private static <T> T make(Class<T> beanClass, boolean nullIfNotEligible) {
        try {
            Class<?> c = provideMetaBeanClass(beanClass, nullIfNotEligible);
            if (c==null) {
                return null;
            }
            return beanClass.cast(c.newInstance());
        } catch (Exception e) {
            throw new RuntimeException("Failed create meta bean for type "+beanClass,e);
        }
    }

    // try to provide a meta bean class or return null if note eligible
    private static Class<?> provideMetaBeanClass(Class<?> beanClass, boolean nullIfNotEligible) throws Exception {
        // check eligibility
        StringBuilder b = checkEligible(beanClass);
        if (b.length()>0) {
            if (nullIfNotEligible) {
                throw new IllegalArgumentException("Cannot construct meta bean for "+beanClass+" because: n"+b.toString());
            }
            return null;
        }
        String newName = metaBeanName(beanClass);
        // check if the class can be found normally or has been defined previously
        ClassPool pool = ClassPool.getDefault();
        CtClass cc = pool.getOrNull(newName);
        if (cc==null) {
            // ok, need to construct it.
            // start constructing
            cc = pool.makeClass(newName);
            // as derivation of the bean class
            CtClass sc = pool.get(beanClass.getName());
            cc.setSuperclass(sc);

            // override getters
            for (PropertyDescriptor pd : Introspector.getBeanInfo(beanClass).getPropertyDescriptors()) {
                String pn = pd.getName();
                Method g = pd.getReadMethod();
                if ( (g.getModifiers() & (Modifier.FINAL | Modifier.NATIVE | Modifier.PRIVATE)) ==0) {

                    // fetch return type (pool will retrieve or throw exception, if it cannot be found)
                    CtClass rc = pool.get(g.getReturnType().getName());
                    // create the new getter
                    String body = "{"+
                        MetaBeans.class.getName()+".note(""+pn+"");"+
                        // add a cast as Java Assist is not great with generics it seems
                        "return ("+g.getReturnType().getCanonicalName()+") "+MetaBeans.class.getName()+".makeOrNull("+g.getReturnType().getCanonicalName()+".class);"+
                    "}";

                    CtMethod m = CtNewMethod.make(
                        rc,
                        g.getName(),
                        new CtClass[0],
                        new CtClass[0],
                        body,
                        cc
                    );
                    cc.addMethod(m);
                }
            }
            return cc.toClass();
        } else {
            return Class.forName(newName);
        }
    }

    private static String metaBeanName(Class<?> beanClass) {
        String newName = beanClass.getCanonicalName()+"__meta";
        return newName;
    }

    private static StringBuilder checkEligible(Class<?> beanClass) {
        StringBuilder b = new StringBuilder();
        if (beanClass.getPackage().getName().startsWith("java.lang")) {
            b.append("No meta beans for standard typesn");
        }
        try{
            beanClass.getConstructor();
        } catch (NoSuchMethodException nsme) {
            b.append(beanClass.toString()).append(" has no default constructorn");
        }
        return b;
    }
}

 

 

Advertisements