Grazie alla diffusione dei framework ORM (Object-Relational Mapping) la vita di uno sviluppatore che aveva intenzione di interfacciarsi con un database si è semplificata notevolmente, o per meglio dire è diventata più vivace! Vivace perché è più interessante capire come funziona un framework e come risolve i problemi per noi piuttosto che ripetere sempre la canonica struttura per impostare l’accesso ai dati: crea il DAO, gestisci l’apertura e la chiusura della connessione o della transazione, committa, gestisci eccezioni e rollback, crea un builder per costruire gli oggetti delle entità dal result set delle query, scrivi TUTTE le query… “accidenti abbiamo cambiato DB, quella query non funziona più!!” (e chi più ne ha più ne metta…).
E l’ordine fu…
Con l’arrivo delle specifiche EJB3 e JPA1, si è dato un po’ di ordine ai framework ORM esistenti (anche se, a mio avviso, Hibernate regnava incontrastato): adesso è possibile lavorare quasi indistintamente con prodotti come Hibernate, EclispeLink od OpenJPA, utilizzando annotazioni o XML. Dico quasi indistintamente perché la specifica JPA1 non copre tutti gli aspetti dell’ORM, per cui alcuni problemi rimangono demandati alle soluzioni proprietarie dei vendors. In particolare, la specifica JPA1 (e a quanto pare anche la 2) non propone una soluzione standard quando si ha la necessità mappare tra loro valori che, nel mondo ad oggetti ed nel mondo relazionale, sono rappresentati con tipi differenti o non direttamente comparabili.
Fuori specifica: i tipi personalizzati
Cosa si intende per tipo personalizzato? Facciamo un esempio. Ricordo che con Hibernate inizialmente non era possibile salvare un enum su database come stringa in modo semplice: andava implementato uno UserType
per poter effettuare la conversione. Fortunatamente in JPA (e quindi adesso anche in Hibernate) è possibile mappare tranquillamente un enum tramite l’annotazione @Enumerated.
Per tipi personalizzati quindi possiamo intendere tutti quei casi in cui il mapping tra i tipi di dati del modello a oggetti e quello relazionale non coincidono o non sono compatibili, per cui è richiesta una sorta di conversione.
Mapping per valore
Come fare per esempio a mappare una variabile booleana di una classe su una colonna varchar(1)
con valori 'S'
o 'N'
? Oppure come mappare un enumeratore {MASCHIO, FEMMINA}
sui valori 'M'
o 'F'
? Il JPA non propone una soluzione standard al problema: proprio da qui nasce l’esigenza di avere dei tipi personalizzati (o convertiti) per valore. Vediamo per esempio come è possibile risolvere il problema in EclipseLink e in Hibernate.
EclipseLink Converter
Questa implementazione di JPA mette a disposizione una serie di convertitori che permettono di manipolare tipi e dati, configurabili tramite codice Java, annotazioni o XML. Ogni convertitore implementa l’interfaccia Converter
: fortunatamente il framework fornisce già alcuni convertitori che permettono di risolvere i casi di conversione più comuni.
Per risolvere il problema del mapping dei booleani, per esempio, possiamo usare un ObjectTypeConverter
: con esso è possibile mappare coppie di valori in modo da associare al valore di un oggetto (che chiameremo object value) il corrispettivo valore su database (data value).
Un convertitore può essere dichiarato tramite annotazioni (in una delle entità del nostro dominio per esempio). Ammettiamo di avere l’entità Account
con un attributo booleano enabled
@Entity
public class Account implements Serializable {
...
@ObjectTypeConverter (
name="booleanConverter",
dataType=java.lang.Boolean.class,
objectType=java.lang.String.class,
conversionValues={
@ConversionValue(dataValue=Boolean.TRUE, objectValue="S"),
@ConversionValue(dataValue=Boolean.FALSE, objectValue="N")}
)
@Convert("booleanConverter")
private boolean enabled;
...
}
@ObjectTypeConverter
-
definisce il mapping tra i valori
true/false
e'S'/'N'
@Convert
- associa il convertitore alla variabile
Con EclipseLink quindi, riusciamo in un attimo a configurare un convertitore e ad associarlo alla variabile interessata con poche annotazioni.
Se volessimo usare il converter appena definito su un attributo booleano di un’altra entità, basterebbe annotarlo con @Convert("booleanConverter")
, in quanto il tipo di convertitore è già stato definito dentro la classe Account
.
Dichiarare un convertitore in una classe che poi verrà riusato anche in un’altra risulta poco manutenibile (per usare un eufemismo): in questi casi, a mio avviso, è consigliabile usare un file XML per definire il converter, in modo da centralizzarne le dichiarazioni. EclipseLink dispone di un file di configurazione (solitamente chiamato eclipselink-orm.xml
), nel quale, tra le altre cose, possiamo dichiarare un convertitore come segue:
In questo modo abbiamo un file unico in cui verranno elencati tutti i convertitori del nostro dominio: per utilizzare un convertitore basta quindi annotare un attributo di una classe con @Convert("converterName")
.
Hibernate UserType
Con il passaggio a JPA, Hibernate ha mantenuto la propria gerarchia di UserType per personalizzare i tipi da utilizzare nel mapping ORM tramite le annotazioni TypeDef
e Type
. Diversamente da quanto visto in precedenza, in questo caso non è possibile definire un mapping object-data value in modo dichiarativo: ogni nuovo tipo dovrà implementare l’interfaccia UserType
o una delle sue sottoclassi.
La classe dell’esempio precedente verrà annotata in modo differente:
@Entity
@TypeDef(typeClass = BooleanType.class)
public class Account implements Serializable {
...
@Type(type = "booleanType")
private boolean enabled;
...
}
@TypeDef
-
dichiara l’esistenza di uno
UserType
la cui classe è definita dall’attributotypeClass
@Type
- associa il tipo alla variabile
Se intendiamo mappare in valore di un oggetto della classe K
su un varchar
, possiamo generalizzare gran parte del codice richiesto dall’interfaccia UserType
, creando una classe parametrizzata chiamata StringBasedUserType
:
public abstract class StringBasedUserType implements UserType {
@Override
public Object assemble(Serializable cached, Object owner)
throws HibernateException {
return cached;
}
@Override
public Object deepCopy(Object value) throws HibernateException {
return value;
}
@Override
public Serializable disassemble(Object value) throws HibernateException {
return (Serializable) value;
}
@Override
public boolean equals(Object x, Object y) throws HibernateException {
return x.equals(y);
}
@Override
public int hashCode(Object x) throws HibernateException {
return x.hashCode();
}
@Override
public boolean isMutable() {
return false;
}
@Override
public Object nullSafeGet(ResultSet rs, String[] names, Object owner)
throws HibernateException, SQLException {
assert names.length == 1;
Map conversionMap = getConversionMap();
String dbValue = (String) StringType.INSTANCE.get(rs, names[0]);
if (conversionMap.containsValue(dbValue) && !"".equals(dbValue)) {
Set> entrySet = conversionMap.entrySet();
for (Entry entry : entrySet) {
if (entry.getValue().equals(dbValue))
return entry.getKey();
}
}
throw new HibernateException("Cannot convert DB value " + dbValue);
}
@Override
public void nullSafeSet(PreparedStatement rs, Object value, int index)
throws HibernateException, SQLException {
Map conversionMap = getConversionMap();
if (conversionMap.containsKey(value)) {
StringType.INSTANCE.set(rs, conversionMap.get(value), index);
} else {
throw new HibernateException("Cannot convert object value " + value);
}
}
@Override
public Object replace(Object original, Object target, Object owner)
throws HibernateException {
return original;
}
@Override
public Class< ?> returnedClass() {
return this.getClass().getTypeParameters()[0].getClass();
}
@Override
public int[] sqlTypes() {
return new int[] {StringType.INSTANCE.sqlType()};
}
public abstract Map getConversionMap();
}
Questa classe implementa tutti i metodi dell’interfaccia UserType
e dichiara un metodo astratto nel quale verrà definita la mappa tra il valore di un oggetto e il valore su database.
I metodi più interessanti sono ovviamente nullSafeGet
e nullSafeSet
che si occupano rispettivamente di convertire i valori provenienti dal database in un oggetto e viceversa. Da notare che non vengono accettati valori nulli in conversione, di conseguenza la colonna interessata sul database è consigliabile che sia not nullable con un valore di default per evitare problemi in runtime.
La classe BooleanType
è quindi semplicemente definita come:
public class BooleanType extends StringBasedUserType {
@Override
public Map getConversionMap() {
Map map = new HashMap();
map.put(true, "S");
map.put(false, "N");
return map;
}
}
Per gli stessi motivi menzionati in precedenza, eviterei di usare @TypeDef
su una entità se poi il tipo viene riusato in altre classi. Seguendo sempre il principio della centralizzazione, sfruttiamo la configurazione del framework tramite XML. Possiamo creare un file ad hoc, chiamato per esempio typedefs.hbm.xml
, che raccoglierà la definizione di tutti i tipi personalizzati che intendiamo creare:
< ?xml version="1.0"?>
< !DOCTYPE hibernate-mapping PUBLIC "-//Hibernate/Hibernate Mapping DTD 3.0//EN"
"http://www.hibernate.org/dtd/hibernate-mapping-3.0.dtd">
Basta poi referenziarlo all’interno del canonico file di configurazione di Hibernate (hibernate.cfg.xml
):
< ?xml version="1.0" encoding="UTF-8"?>
< !DOCTYPE hibernate-configuration PUBLIC
"-//Hibernate/Hibernate Configuration DTD 3.0//EN"
"http://www.hibernate.org/dtd/hibernate-configuration-3.0.dtd">
ed il gioco è fatto!
Ovviamente quello illustrato è un esempio banale che però può tornare utile in caso si abbia a che fare con un database legacy (in italiano!!). Dal canto suo Hibernate dispone già di convertitori booleani codificati sulla lingua inglese: @Type("yes_no")
e @Type("true_false")
rispettivamente scrivono su database 'Y'/'N'
e 'T'/'F'
.
Tipi personalizzati ed Enumeratori
Apparte la conversione dei booleani, a chi non è capitato di dover persistere il valore di un enumeratore? Come abbiamo già accennato, JPA permette facilmente questa operazione tramite l’annotazione @Enumerated
. Ad esempio ammettiamo che la nostra entità Account
disponga di un attributo type
:
public class Account {
...
@Enumerated
private AccountType type;
...
}
del tipo:
public enum AccountType {
BASIC, PRO, GOLD;
}
Come verrà persistito il suo valore? Secondo la specifica JPA:
- se l’annotazione
@Enumerated
non ha attributi, di default il valore viene convertito nel numero corrispondente alla posizione in cui è dichiarato nell’enum: quindi1 -> MALE
e2 -> FEMALE
; - l’alternativa è specificare
@Enumerated(EnumType.STRING)
: il valore verrà salvato come stringa rappresentata dall’istanza dell’enumeratore.
Se però abbiamo bisogno di salvare il valore dell’enumeratore in un modo diverso da quello predisposto dalla specifica JPA, entrano in gioco le soluzioni vendor mostrate in precedenza. Vediamo brevemente nel dettaglio come possono essere applicate.
Enum ed ElipseLink
In questo caso, usando l’ObjectTypeConverter
, basta dichiarare:
nel file eclipselink-orm.xml
in modo da mappare ogni valore dell’enum su una stringa con le sue iniziali.
A questo punto, annotare l’attributo della classe come segue:
...
@Enumerated(EnumType.STRING)
@Convert("accountTypeConverter")
private AccountType type;
...
e il converter penserà al resto.
Enum e Hibernate
In questo caso la classe StringBasedUserType
creata precedentemente può tornarci utile. Basta estenderla come segue:
public class AccountTypeUserType extends StringBasedUserType {
@Override
public Map getConversionMap() {
Map map = new HashMap();
map.put(AccountType.BASIC, "B");
map.put(AccountType.PRO, "P");
map.put(AccountType.GOLD, "G");
return map;
}
}
e inserirne la dichiarazione
...
...
nel file typedefs.hbm.xml
creato in precedenza. Infine, annotare l’attributo della classe:
...
@Enumerated(EnumType.STRING)
@Type("accountTypeUserType")
private AccountType type;
...
Conclusioni
Quando si ha a che fare con progetti che riguardano l’aggiornamento di applicazioni preesistenti, spesso il database non viene modificato per non incorrere in possibili problemi di perdite di dati, soprattutto in quei casi in cui vengono interessati dati sensibili. La progettazione di un nuovo applicativo sopra questi dati però non può legare e condizionare la modellazione, che ruota intorno a concetti e oggetti parlanti, ben chiari, che si autodocumentano: va da sé che lo stile degli acronimi che spesso si trova nei database legacy va contro questi principi. Fortunatamente, anche se la specifica non lo contempla, le implementazioni JPA sono talmente potenti da venire incontro alla maggior parte delle esigenze lavorative.
Abbiamo visto quindi come un problema semplice, come mappare una stringa su un database a partire da un booleano o un enumeratore, non sia così immediato. Dal confronto delle due soluzioni, a mio avviso EclipseLink permette di risolvere in modo molto elegante e sbrigativo la questione, senza scrivere praticamente una riga di codice tramite l’ObjectTypeConverter
già “preconfezionato”. La soluzione proposta da Hibernate invece rimane più generica, fornendo tutti gli strumenti necessari agli sviluppatori per arrivare alla stessa soluzione del concorrente, richiedendo però un po’ più di sforzo.