Dynamic GORM Domain Classes
A recent discussion
on the Grails Dev mailing list about creating a dynamic form builder involved needing to compile new domain classes at runtime. The consensus seemed to be that it's not possible and/or not advisable, but I've thought a lot about this topic and had done similar work when creating the Dynamic Controller
plugin, so I started playing with it.
The solution I came up with isn't pretty but seems to work. There were a few issues to tackle. One is that Grails does quite a bit of work to convert your relatively simple domain classes into full GORM classes, registered with Hibernate and wired up with validation, convenience MetaClass methods, etc. There's also the issue of automatically compiling in an id and version field, a default toString() method, and collections corresponding to hasMany declarations. In addition there are four Spring beans created for each domain class. There's a lot being done under the hood that we tend to take for granted.
But the big hurdle is registering the new entity with Hibernate. It's expected that this is done at startup and never changed, so the data fields in SessionFactoryImpl are mostly private and in two cases final. So the solution is rather hackish and involves brute force reflection. It just so happens that when using reflection, final fields are only mostly final. So I create a whole new SessionFactoryImpl (it'd be convenient to create a small one with just the new domain class, but then you couldn't reference other domain classes) and replace the real SessionFactoryImpl's data with the data from the new one. I can't replace the SessionFactoryImpl since other classes will have a reference to the previous one.
The primary class is DynamicDomainService, although this could certainly be in a helper class in src/groovy:
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.grails.commons.ApplicationHolder as AH
import org.codehaus.groovy.grails.commons.DomainClassArtefactHandler
import org.codehaus.groovy.grails.commons.GrailsDomainClass
import org.codehaus.groovy.grails.compiler.injection.ClassInjector
import org.codehaus.groovy.grails.compiler.injection.DefaultGrailsDomainClassInjector
import org.codehaus.groovy.grails.compiler.injection.GrailsAwareClassLoader
import org.codehaus.groovy.grails.orm.hibernate.ConfigurableLocalSessionFactoryBean
import org.codehaus.groovy.grails.plugins.DomainClassGrailsPlugin
import org.codehaus.groovy.grails.plugins.orm.hibernate.HibernatePluginSupport
import org.codehaus.groovy.grails.validation.GrailsDomainClassValidator
import org.springframework.beans.factory.config.MethodInvokingFactoryBean
import org.springframework.beans.factory.config.RuntimeBeanReference
import org.springframework.beans.factory.support.AbstractBeanDefinition
import org.springframework.beans.factory.support.GenericBeanDefinition
import org.springframework.util.ReflectionUtils
/**
* Compiles and registers domain classes at runtime.
*
* @author <a href='mailto:[email protected]'>Burt Beckwith</a>
*/
class DynamicDomainService {
static transactional = false
void registerNewDomainClass(String code) {
def application = AH.application
def clazz = compile(code, application)
// register it as if it was a class under grails-app/domain
GrailsDomainClass dc = application.addArtefact(
DomainClassArtefactHandler.TYPE, clazz)
def ctx = application.mainContext
registerBeans ctx, dc
wireMetaclass ctx, dc
updateSessionFactory ctx
}
private Class compile(String code, application) {
Class clazz = new DynamicClassLoader().parseClass(code)
application.classLoader.setClassCacheEntry clazz // TODO hack
clazz
}
// this is typically done in DomainClassGrailsPlugin.doWithSpring
private void registerBeans(ctx, GrailsDomainClass dc) {
ctx.registerBeanDefinition dc.fullName,
new GenericBeanDefinition(
beanClass: dc.clazz,
scope: AbstractBeanDefinition.SCOPE_PROTOTYPE,
autowireMode:AbstractBeanDefinition.AUTOWIRE_BY_NAME)
GenericBeanDefinition beanDef = new GenericBeanDefinition(
beanClass: MethodInvokingFactoryBean,
lazyInit: true)
setBeanProperty beanDef, 'targetObject',
new RuntimeBeanReference('grailsApplication', true)
setBeanProperty beanDef, 'targetMethod', 'getArtefact'
setBeanProperty beanDef, 'arguments',
[DomainClassArtefactHandler.TYPE, dc.fullName]
ctx.registerBeanDefinition "${dc.fullName}DomainClass", beanDef
beanDef = new GenericBeanDefinition(
beanClass: MethodInvokingFactoryBean,
lazyInit: true)
setBeanProperty beanDef, 'targetObject',
new RuntimeBeanReference("${dc.fullName}DomainClass")
setBeanProperty beanDef, 'targetMethod', 'getClazz'
ctx.registerBeanDefinition "${dc.fullName}PersistentClass", beanDef
beanDef = new GenericBeanDefinition(
beanClass: GrailsDomainClassValidator,
lazyInit: true)
setBeanProperty beanDef, 'messageSource',
new RuntimeBeanReference('messageSource')
setBeanProperty beanDef, 'domainClass',
new RuntimeBeanReference("${dc.fullName}DomainClass")
setBeanProperty beanDef, 'grailsApplication',
new RuntimeBeanReference('grailsApplication', true)
ctx.registerBeanDefinition "${dc.fullName}Validator", beanDef
}
private void setBeanProperty(GenericBeanDefinition bean,
String name, value) {
bean.propertyValues.addPropertyValue name, value
}
private void wireMetaclass(ctx, GrailsDomainClass dc) {
def fakeApplication = new FakeApplication(dc)
DomainClassGrailsPlugin.enhanceDomainClasses(
fakeApplication, ctx)
HibernatePluginSupport.enhanceSessionFactory(
ctx.sessionFactory, fakeApplication, ctx)
}
// creates a new session factory so new classes can
// reference existing, and then replaces the data in
// the original session factory with the new combined data
private void updateSessionFactory(ctx) {
def sessionFactoryBean = ctx.getBean('&sessionFactory')
def newSessionFactoryFactory = new ConfigurableLocalSessionFactoryBean(
dataSource: ctx.dataSource,
configLocations: getFieldValue(
sessionFactoryBean, 'configLocations'),
configClass: getFieldValue(sessionFactoryBean, 'configClass'),
hibernateProperties: getFieldValue(
sessionFactoryBean, 'hibernateProperties'),
grailsApplication: ctx.grailsApplication,
lobHandler: getFieldValue(sessionFactoryBean, 'lobHandler'),
entityInterceptor: getFieldValue(
sessionFactoryBean, 'entityInterceptor'))
newSessionFactoryFactory.afterPropertiesSet()
def newSessionFactory = newSessionFactoryFactory.object
['entityPersisters', 'collectionPersisters', 'identifierGenerators',
'namedQueries', 'namedSqlQueries', 'sqlResultSetMappings',
'imports', 'collectionRolesByEntityParticipant',
'classMetadata', 'collectionMetadata'].each { fieldName ->
def field = ReflectionUtils.findField(
ctx.sessionFactory.getClass(), fieldName)
field.accessible = true
field.set ctx.sessionFactory, new HashMap(
field.get(newSessionFactory))
}
}
private getFieldValue(sessionFactoryBean, String fieldName) {
def field = ReflectionUtils.findField(
sessionFactoryBean.getClass(), fieldName)
field.accessible = true
field.get sessionFactoryBean
}
}
This depends on a helper class that extends DefaultGrailsApplication and is used to ensure that only the new class gets MetaClass methods wired up:
import org.codehaus.groovy.grails.commons.ApplicationHolder as AH
import org.codehaus.groovy.grails.commons.DefaultGrailsApplication
import org.codehaus.groovy.grails.commons.GrailsDomainClass
/**
* This is needed when calling DomainClassGrailsPlugin.enhanceDomainClasses()
* and HibernatePluginSupport.enhanceSessionFactory() so they only act
* on the new class.
*
* @author Burt
*/
class FakeApplication extends DefaultGrailsApplication {
final domainClasses
FakeApplication(GrailsDomainClass dc) {
super([dc.clazz] as Class[], AH.application.classLoader)
domainClasses = [dc]
}
}
We also need a custom GrailsDomainClassInjector since the standard mechanism doesn't recognize the new classes as domain classes:
import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.classgen.GeneratorContext
import org.codehaus.groovy.control.SourceUnit
import org.codehaus.groovy.grails.compiler.injection.DefaultGrailsDomainClassInjector
/**
* Works around the fact that the class is dynamically
* compiled and not from a file.
*
* @author <a href='mailto:[email protected]'>Burt Beckwith</a>
*/
class DynamicDomainClassInjector extends DefaultGrailsDomainClassInjector {
// always true since we're only compiling dynamic domain classes
@Override
boolean shouldInject(URL url) { true }
// always true since we're only compiling dynamic domain classes
@Override
protected boolean isDomainClass(ClassNode cn, SourceUnit su) { true }
}
Finally we need a custom classloader that uses the custom injector:
import java.security.CodeSource
import org.codehaus.groovy.control.CompilationUnit
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.Phases
import org.codehaus.groovy.grails.commons.ApplicationHolder
import org.codehaus.groovy.grails.compiler.injection.ClassInjector
import org.codehaus.groovy.grails.compiler.injection.GrailsAwareClassLoader
import org.codehaus.groovy.grails.compiler.injection.GrailsAwareInjectionOperation
/**
* Uses a custom injector.
*
* @author <a href='mailto:[email protected]'>Burt Beckwith</a>
*/
class DynamicClassLoader extends GrailsAwareClassLoader {
private final ClassInjector[] _classInjectors = [
new DynamicDomainClassInjector()]
DynamicClassLoader() {
super(ApplicationHolder.application.classLoader,
CompilerConfiguration.DEFAULT)
classInjectors = _classInjectors
}
@Override
protected CompilationUnit createCompilationUnit(
CompilerConfiguration config, CodeSource source) {
CompilationUnit cu = super.createCompilationUnit(config, source)
cu.addPhaseOperation(new GrailsAwareInjectionOperation(
getResourceLoader(), _classInjectors), Phases.CANONICALIZATION)
cu
}
}
Here's an example demonstrating usage. First compile the Book class:
package com.foo.testapp.book
class Book {
String title
}
"
dynamicDomainService.registerNewDomainClass bookCode
and then the Author class:
package com.foo.testapp.author
import com.foo.testapp.book.Book
class Author {
static hasMany = [books: Book]
String name
}
"
dynamicDomainService.registerNewDomainClass authorCode
Then you can load the classes dynamically and persist new instances:
def Author = grailsApplication.getClassForName('com.foo.testapp.author.Author')
def author = Author.newInstance(name: 'Stephen King')
author.addToBooks(Book.newInstance(title: 'The Shining'))
author.addToBooks(Book.newInstance(title: 'Rose Madder'))
author.save(failOnError: true)
Note that you can't use new since the classes are compiled dynamically, so use newInstance instead.
The reason I've been interested in this idea is that I'd like to make domain class reloading during development more friendly. Currently if you edit a domain class while running in dev mode with run-app, the application restarts. This is frustrating if you only made a change in a helper method that has no impact on persistence though. So I'm hoping to be able to diff the previous persistence metadata with the data after recompiling, and if there's no difference, just reload the class like a service or controller.
On the other end of the spectrum, if you do make a change affecting persistence but you're running in create-drop mode, it should be straightforward to rebuild the session factory and reload the class, plus re-export the schema, all without restarting the application. I'm not sure yet what to do with the other cases, e.g. when running in update mode or with no dbCreate set at all. Hopefully this will make it into Grails 1.4 or 2.0.
You can download the referenced code here
.
About Burt Beckwith
Burt Beckwith is a Java and Groovy developer with over ten years of experience in a variety of industries including biotech, travel, e-learning, social networking, and financial services. For the past three years he's been working with Grails and Groovy full-time. Along the way he's created over fifteen Grails plugins and made significant contributions to several others. He was the technical editor for Grails in Action.
More About Burt »NFJS, the Magazine
2011-08-01 00:00:00.0 Issue Now AvailableProgramming with Scala Traits – Part One
by Venkat SubramaniamOn Eloquent Conversations – Part Two
by Raju GandhiNoXML: Spring for XML Haters
by Craig WallsHandling Big Data with HBase
by Scott Leberknight