Friday, November 17, 2006

Thursday, November 16, 2006

Auto Discovering Hibernate Entities

We're a good portion of the way through a rewrite of an Order Entry system using spring & hibernate and all and all love the technology. There a few places, in particular with configuration, that have forced us to have repetitive code.

One of these places is the annotated class list that we provide to spring's AnnotationSessionFactoryBean in order to let Hibernate know which classes it should persist. This seems repetitive since (almost, more on that later) every class we'd like to have on this list is marked by the @Entity annotation. The session factory bean just needs a list of classes so I thought why not make a factory bean to generate this list for me. This seemed a good place to start a little cleanup so I wrote the following test:


/**
* Test that we find annotated classes given a package
*/
public void testLoadWithPackageName() throws Exception {
AnnotatedClassDiscoveryBean discoveryBean = new AnnotatedClassDiscoveryBean();
discoveryBean.setAnnotation("com.checkernet.util.testpackage.Test");
discoveryBean.setPkg("com.checkernet.util.testpackage");
Object object = discoveryBean.getObject();
assertTrue("Returned object incorrect type", object instanceof List );
List classes = (List) object;
assertEquals("Incorrect number of classes found", 3, classes.size());
}



Then I created a test package, @Test annotation, and popped in a few classes. Two of these classes used the @Test annotation. Now it was time to create an AnnotatedClassDiscoveryBean.

For starters it implemented FactoryBean. After that I knew I needed to find a way to walk through a package and get all of the classes to see if they had the annotation or not. A quick search on google lead me to a discussion on topic. Between the code on page two and spring's included AnnotationClassFilter I had everything I needed to make my test green.

With that out of the way I decided to add some more functionality. If you're in the DomainDrivenDesign camp or just don't like to organize your packages by architectural function, it would be nice to give the discovery bean a top level package and recurse/iterate through to find entity classes in sub-packages. So I added a second test:


/**
* Test that we can find annotations in packages which are descendants of the
* supplied package
*/
public void testParentPackage() throws Exception {
AnnotatedClassDiscoveryBean discoveryBean = new AnnotatedClassDiscoveryBean();
discoveryBean.setAnnotation("com.checkernet.util.testpackage.Test");
discoveryBean.setPkg("com.checkernet");
Object object = discoveryBean.getObject();
assertTrue("Returned object incorrect type", object instanceof List );
List classes = (List) object;
assertEquals("Incorrect number of classes found", 2, classes.size());
}


As you could have guessed it didn't work. Looking at the code from that thread, it was a good starting point but:

1. It wasn't that clean
2. It didn't help me pass my second test

It looked like I needed to pull the code that generated a list of class apart from the code which checked for the annotation. I did this and tried to give the code a quick pass (although it is still pretty rough) but now it passes both tests. In my session factory spring configuration the annotatedClasses property referred to a list (bean) with an id of annotated classes. It used to look like this:

<util:list id="annotatedClassList" list-class="java.util.ArrayList">
com.checkernet.model.ActiveShipmentHold
com.checkernet.model.Carrier
com.checkernet.model.CarrierMethod
com.checkernet.model.CatalogPageEntry
... (and many many more)
</util:list id="annotatedClassList" list-class="java.util.ArrayList">


so I removed that bean and replaced it with:


<bean id="annotatedClassList" class="com.checkernet.util.AnnotatedClassDiscoveryBean">
<property name="pkg" value="com.checkernet.model" />
<property name="annotation" value="javax.persistence.Entity" />
</bean>





re-ran my tests and viola no more duplication.

Here is the current (notice I didn't say final) code for the AnnotatedClassDiscoveryBean:


This code could be used for things other than hiberate.


package com.checkernet.util;

import java.io.File;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.lang.annotation.Annotation;
import java.net.URL;
import static java.net.URLDecoder.decode;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.aop.support.annotation.AnnotationClassFilter;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.beans.factory.annotation.Required;
import org.springframework.util.Assert;


/**
* Notes: A factory bean which given a package name and fully qualified annotation class name will
* Return a list of classes. Developed for use with Spring for dynamic configuration of annotated classes
* properties (like the one in AbstractSessionFactoryBean)
*
* @author Dan Thiffault
* @version 0.0.1
*/
public class AnnotatedClassDiscoveryBean implements FactoryBean {

private final Log logger = LogFactory.getLog(AnnotatedClassDiscoveryBean.class);

private Class annotation;

private String packageName;

private static final int CLASS_LIST_SIZE = 50;

private static final char PACKAGE_SEPARATOR = '.';

/**
* this is specified by ClassLoader.getResources()
*/
@SuppressWarnings({"HardcodedFileSeparator"})
private static final char PATH_SEPARATOR = '/';

/**
* Set the annotation to search for
*
* @param annotationName of the Annotation class
* @throws ClassNotFoundException if that annotation is not on the classpath
*/
@Required
public void setAnnotation(final String annotationName) throws ClassNotFoundException {
//noinspection unchecked
annotation = (Class) Thread.currentThread().getContextClassLoader()
.loadClass(annotationName);
}

/**
* Set the base package name to search through. Sub-packages will be searched
*
* @param packageName to search through
*/
@Required
public void setPkg(final String packageName) {
this.packageName = packageName;
}

/**
* Returns a list of classes which are in the specified package, or
* one of its descedant packages, that have the specified annotation.
*
* @return List<Class> of classes found
*/
public final Object getObject() {
final List classes;

List allClasses = getClassesForPackage(packageName);
classes = new ArrayList(allClasses.size());
logger.debug("Found ".concat(Integer.toString(allClasses.size())).concat(" classes in package"));
AnnotationClassFilter filter = new AnnotationClassFilter(annotation);
for (Class clazz : allClasses) {
if (filter.matches(clazz)) {
logger.debug("Discovered class: ".concat(clazz.toString()));
classes.add(clazz);
}
}


return classes;
}

/**
* Returns a list class. This is the type the calls to getObject are
* guaranteed to return.
*
* @return Class of type List
*/
public Class getObjectType() {
return List.class;
}

/**
* {@inheritDoc}
*/
public boolean isSingleton() {
return false;
}

/**
* Attempts to list all the classes in the specified package as determined
* by the context class loader
*
* @param pckgname the package name to search
* @return a list of classes that exist within that package
*/
@SuppressWarnings({"MethodWithMultipleLoops"})
private static List getClassesForPackage(String pckgname) {
String path = pckgname.replace(PACKAGE_SEPARATOR, PATH_SEPARATOR);
// This will hold a list of directories matching the pckgname. There may be more than one if a package is split over multiple jars/paths

List directories = new ArrayList(CLASS_LIST_SIZE);
List jars = new ArrayList(CLASS_LIST_SIZE);

List classFiles = new ArrayList(CLASS_LIST_SIZE);

//noinspection ProhibitedExceptionCaught
try {
ClassLoader cld = Thread.currentThread().getContextClassLoader();
if (cld == null) {
//noinspection ProhibitedExceptionThrown
throw new RuntimeException("Can't get class loader.");
}

// Ask for all resources for the path
Enumeration resources = cld.getResources(path);
//noinspection MethodCallInLoopCondition
while (resources.hasMoreElements()) {
//noinspection ObjectAllocationInLoop
String decodedPath = decode(resources.nextElement().getPath(), "UTF-8");
if (decodedPath.contains(".jar!")) {
String jarPathName = decodedPath.substring(5, decodedPath.indexOf("!"));
classFiles.addAll(getClassesInJar(new JarFile(jarPathName)));
} else {
if (!decodedPath.contains(".zip!")) {
directories.add(new File(decodedPath));
}
}
}
} catch (NullPointerException x) {
throw new IllegalArgumentException(
pckgname + " does not appear to be a valid package", x);
} catch (UnsupportedEncodingException encex) {
throw new IllegalArgumentException(
pckgname + " does not appear to be a valid package (Unsupported encoding)", encex);
} catch (IOException ioex) {
//noinspection ProhibitedExceptionThrown
throw new RuntimeException("IOException was thrown when trying to get all resources for " + pckgname, ioex);

} catch (Exception e) {
throw new RuntimeException("Something terrible happened", e);
}

// For every directory identified capture all the .class files
for (File directory : directories) {
if (directory.exists()) {
classFiles.addAll(getFilesInDirectory(directory, path));
} else {
throw new IllegalArgumentException(
pckgname + " (" + directory.getPath() + ") does not appear to be a valid package");
}
}


List classes = new ArrayList(classFiles.size());

for (String fileName : classFiles) {
try {
classes.add(Class.forName(
fileName.substring(0, fileName.length() - 6).replace(File.separatorChar, PACKAGE_SEPARATOR).replace(PATH_SEPARATOR, PACKAGE_SEPARATOR)));

} catch (ClassNotFoundException e) {
//noinspection ProhibitedExceptionThrown
throw new RuntimeException("Couldn't load class", e);
}
}

return classes;

}

private static List getFilesInDirectory(File directory, final String currentDirectory) {
Assert.isTrue(directory.isDirectory(), "File passed in was not a directory: ".concat(directory.getName()));
List files = new ArrayList(CLASS_LIST_SIZE);

for (File file : directory.listFiles()) {
String fileName = file.getName();
if (file.isFile() && fileName.endsWith(".class")) {
files.add(currentDirectory.concat(File.separator).concat(fileName));
} else if (file.isDirectory()) {
files.addAll(getFilesInDirectory(file, currentDirectory.concat(File.separator).concat(fileName)));
}
}

return files;
}

private static List getClassesInJar(JarFile jf) {
List classNames = new ArrayList(CLASS_LIST_SIZE);
Enumeration e = jf.entries();
while (e.hasMoreElements()) {
JarEntry jarEntry = e.nextElement();
if (jarEntry.getName().endsWith(".class")) {
classNames.add(jarEntry.getName());
}

}

return classNames;
}

}




Updated: Fixed to work with jars.
Update 2: Based on Joe's comment, updated the code to work on non-BSD based systems (like windows)

Trac Wiki Macro API Fun

Here is a small snippet of Python code:


import datetime
import time

def execute(hdf, txt, env):
event, when = txt.split(',', 1)
today = datetime.date.today()
future = datetime.date(*time.strptime(when, '%m/%d/%Y')[0:3])
difference = future - today
days = difference.days
if days <= 0:
return event + " has already happened! (" + str(abs(days)) + " days ago.)"
proper = "days"
if days == "1":
proper = "day"
return event + " in " + str(days) + " " + proper + "!"


Small and simple. Dan asked me to write a macro for our Trac wiki that would take in a date (in a string format) and an event name and create a small warning on the page. I've never written any sort of macro for any sort of wiki before, but the macros are written in Python, so I suspected it would be fun and easy. It was both.

The first thing I did was edit the front page of our wiki, adding a call to my soon-to-be macro:

[[CountDown()]]

I saved the changes to the page, and got an error when the page reloaded, which was exactly what I wanted... the api says that you need to restart the webserver, which seemed sort of suspect since you don't need to restart after installing plugins.

I read just enough of the docs to figure out where the macros directory was. Turns out that the macros reside in the rather obvious directory: "/usr/local/share/trac/wiki-macros". I created CountDown.py there and loaded it into vi.

Back to the docs to check out the api. Only function needed in a macro is execute, which takes in an hdf, some text, and an environment. A bare-bones macro looks like this:

def execute(hdf, txt, env):
return "Testing..."

I saved the above and reloaded the page. It worked. Now, for input. Input is passed into execute via the argument txt. Changed the macro call to:

[[CountDown(Tomorrow,11/17/2006)]]

and then changed the macro to:

def execute(hdf, txt, env):
return txt

It returned "Tomorrow,11/17/2006" on the page. I poked around the module docs for datetime and finished the macro as it appears at the top of this post. It was just so painless. Trac has impressed me again.