Written on
Grails - Adding MySQL Collation Support to GORM
Lately I've been working on the client-side developing a mult-international Grails application. As this application is being used from several different parts of the world, an important requirement has been to support MySQL's collations for the most important SQL queries.MySQL Collation Support
In MySQL there is a distinction between the character set and its collations. According to the documentation,a character set is a set of symbols and encodings. A collation is a set of rules for comparing characters in a character setTherefore collations can be used to implement language specific sorting, which has been exactly the requirement in my case. The MySQL documentation gives good examples for the impacts of different collations [0]. For example, the following data set
Muffler
Müller
MX Systems
MySQL
latin1_german1_ci: Muffler, Müller, MX Systems, MySQL (German DIN-1 Rule, ü sorts with u)
latin1_german2_ci: Müller, Muffler, MX Systems, MySQL (German DIN-2 Rule, ü sorts with ue)
Collation Support for GORM
Before adding collation support to HQL we have to be aware of a huge drawback: collations are a MySQL specific feature. By introducing HQL support for collations, we introduce a large binding to MySQL, including collation-aware integration tests. AFAIK there is no built-in query-based support for collations in H2, so we have to stick to MySQL-backed integration tests. Let's start our journey to adding collations to HQL. The first step is to find the right place to add this kind of functionality. My first thought, (I'll spoilt it, not proven to be a good one) was to implement a custom org.hibernate.Interceptor that modifies the SQL query right before its execution in the onPrepareStatement method. I very quickly reached the point where I was missing too much meta-data about the SQL query that needed collation support. In a nutshell, it turned out to be too complicated to add automatic collation support in a custom Hibernate interceptor, so I decided to find a way to somehow introduce a "collation function" to HQL. Whenever adding custom functions to HQL the org.hibernate.dialect.Dialect class comes to rescue. There are various dialect implementations MySQL5InnoDBDialect, H2Dialect, DB2Dialect are only some among them. When having a look at the MySQLDialect implementation we can see the Dialect class is being used for registering MySQL specific built-in database functions and types. In addition, language specific features such as paging support, registering var char types for various lengths etc. are made by the dialect implementation. In order to support the manual specification of a collation in HQL I added a custom MySQL5InnoDBDialect descendant class: CollationAwareMySQL5InnoDBDialect. The collation aware dialect does only a handful of things, but the most important one is: it registers a collate function object of type org.hibernate.dialect.function.SQLFunction:
interface CollationAwareDialect {
static final String KEYWORD_COLLATE = 'collate'
static class CollationSQLFunction implements SQLFunction {
Dialect dialect
String collation
CollationMappingConfiguration collationMappingConfiguration
@Override boolean hasArguments() { true }
@Override boolean hasParenthesesIfNoArguments() { false }
@Override Type getReturnType(Type firstArgumentType, Mapping mapping) throws QueryException { firstArgumentType }
@Override
String render(Type firstArgumentType, List arguments, SessionFactoryImplementor factory) throws QueryException {
if (arguments.size() != 1)
throw new QueryException("'collate' has to be called with a single argument, that is the column collate should refer to")
if (supportsCollation())
return "${arguments[0]} $KEYWORD_COLLATE ${collation}"
else
return arguments[0]
}
boolean supportsCollation() {
return dialect instanceof CollationAwareMySQL5InnoDBDialect
}
}
}
dataSource {
// ...
// custom Hibernate configuration (enables collation support)
configClass = 'org.ast.hibernate.collation.CustomConfiguration'
// Language.id to MySQL collation mapping
languageIdCollationMapping = [
cs: 'utf8_czech_ci',
de: 'utf8_general_ci',
es: 'utf8_spanish_ci',
fr: 'utf8_german1_ci',
hr: 'utf8_croatian_ci',
hu: 'utf8_hungarian_ci',
pl: 'utf8_polish_ci',
ro: 'utf8_romanian_ci',
sk: 'utf8_slovak_ci',
sl: 'utf8_slovenian_ci'
]
}
@Log4j
class CustomConfiguration extends GrailsAnnotationConfiguration {
GrailsApplication grailsApplication
/**
* If the current dialect is collation-aware, inject the collation mapping
* configuration and register the collation HQL functions.
*
* @return the newly created session factory
*
* @throws HibernateException
*/
@Override
SessionFactory buildSessionFactory() throws HibernateException {
SessionFactory sessionFactory = super.buildSessionFactory()
if (sessionFactory instanceof SessionFactoryImpl) {
SessionFactoryImpl sessionFactoryImpl = sessionFactory as SessionFactoryImpl
if (isCollationAwareDialectEnabled(sessionFactoryImpl.settings)) {
def collationMappingConfiguration = grailsApplication.mainContext.getBean('collationMappingConfiguration')
def dialect = sessionFactoryImpl.settings.dialect
dialect.collationMappingConfiguration = collationMappingConfiguration
dialect.registerCollationFunctions()
log.info 'Collation support enabled, injected collation mapping configuration into Dialect.'
}
}
return sessionFactory
}
/**
* @return do we have a collation-aware dialect in the given <tt>settings</tt>?
*/
boolean isCollationAwareDialectEnabled(Settings settings) {
isCollationAwareDialectName(settings.dialect.class.name)
}
/**
* @return is the dialect class name in the list of collection-aware dialect class names?
*/
boolean isCollationAwareDialectName(String dialectName) {
dialectName in [
CollationAwareH2Dialect.class.name,
CollationAwareHSQLDialect.class.name,
CollationAwareMySQL5InnoDBDialect.class.name
]
}
@Override
void setGrailsApplication(GrailsApplication application) {
super.setGrailsApplication(application)
grailsApplication = application
}
}
class CollationSupportEnhancer implements InitializingBean {
GrailsApplication grailsApplication
CollationMappingConfiguration collationMappingConfiguration
/**
* Enhances the given <tt>grailsDomainClass</tt> with collation queries.
*
* @param grailsDomainClass the domain class to enhance with
*/
void enhance(GrailsDomainClass grailsDomainClass) {
def metaClass = GrailsMetaClassUtils.getExpandoMetaClass(grailsDomainClass.clazz)
metaClass.static.executeQueryWithCollate = { String languageId, String query ->
delegate.executeQuery(prepareQuery(query, languageId))
}
metaClass.static.executeQueryWithCollate = { String languageId, String query, List params ->
delegate.executeQuery(prepareQuery(query, languageId), params)
}
metaClass.static.executeQueryWithCollate = { String languageId, String query, List params, Map args ->
delegate.executeQuery(prepareQuery(query, languageId), params, args)
}
metaClass.static.findWithCollate = { String languageId, String query ->
delegate.find(prepareQuery(query, languageId))
}
metaClass.static.findWithCollate = { String languageId, String query, List params ->
delegate.find(prepareQuery(query, languageId), params)
}
metaClass.static.findWithCollate = { String languageId, String query, List params, Map args ->
delegate.find(prepareQuery(query, languageId), params, args)
}
metaClass.static.findAllWithCollate = { String languageId, String query ->
delegate.findAll(prepareQuery(query, languageId))
}
metaClass.static.findAllWithCollate = { String languageId, String query, List params ->
delegate.findAll(prepareQuery(query, languageId), params)
}
metaClass.static.findAllWithCollate = { String languageId, String query, List params, Map args ->
delegate.findAll(prepareQuery(query, languageId), params, args)
}
}
/**
* Prepares the HQL query before it is delegated to the Hibernate query resolution chain. Replaces
* all generic "collate" function calls with concrete "collate" function calls. This is necessary
* to have separate HQL query plans for the same query with different collations.
*
* @param hqlQuery the HQL query holding "collate" function calls
* @param languageId the current language id
*
* @return the modified HQL query, ready to be used by Hibernate
*/
String prepareQuery(String hqlQuery, String languageId) {
def collationQuery = new HQLQuery(hqlQuery)
def collation = collationMappingConfiguration.collationForLanguageId(languageId)
if (collation) {
collationQuery.replaceFunctionCall(CollationAwareDialect.FUNCTION_COLLATE, collation).hqlQuery
} else {
collationQuery.hqlQuery
}
}
@Override
void afterPropertiesSet() throws Exception {
grailsApplication.domainClasses.each { GrailsDomainClass grailsDomainClass ->
enhance(grailsDomainClass)
}
}
}
Person.findAllWithCollate('en', 'from Person p order by collate(p.name) asc')
class CollationAwareMySQL5InnoDBDialect extends MySQL5InnoDBDialect implements CollationAwareDialect {
CollationMappingConfiguration collationMappingConfiguration
void registerCollationFunctions() {
registerFunction(FUNCTION_COLLATE, new CollationAwareDialect.CollationSQLFunction(dialect: dialect, collation: '', collationMappingConfiguration: collationMappingConfiguration))
collationMappingConfiguration.languageIdCollationMapping().each { String language, String collation ->
registerFunction(collation, new CollationAwareDialect.CollationSQLFunction(dialect: this, collation: collation, collationMappingConfiguration: collationMappingConfiguration))
}
}
}