Introducción a JPA 2.0 (II)

En este segundo artículo del tutorial de introducción a JPA vamos a ver dos conceptos relacionados con ORM que merecen un artículo propio: asociaciones y herencia.

2.1 Asociaciones

En el artículo anterior, vimos como realizar ORM básico, y una de las opciones que JPA nos permite es la de mapear colecciones de objetos simples. Sin embargo, cuando queremos mapear colecciones de entidades, debemos usar asociaciones. Estas asociaciones pueden ser de dos tipos:

  • Unidireccionales
  • Bidireccionales

Las asociaciones unidireccionales reflejan un objeto que tiene una referencia a otro objeto (la información puede viajar en una dirección). Por el contrario, las asociaciones bidireccionales representan dos objetos que mantienen referencias al objeto contrario (la información puede viajar en dos direcciones). Además del concepto de dirección, existe otro concepto llamado cardinalidad, que determina cuantos objetos pueden haber en cada extremo de la asociación.

2.2 Asociaciones unidireccionales

Para entender el concepto de unidireccionalidad nada mejor que mostrar ejemplo de asociación unidireccional:

@Entity 
public class Cliente { 
    @Id 
    @GeneratedValue 
    private Long id; 
    @OneToOne 
    private Direccion direccion; 
     
    // Getters y setters 
} 

@Entity 
public class Direccion { 
    @Id 
    GeneratedValue 
    private Long id; 
    private String calle; 
    private String ciudad; 
    private String pais; 
    private Integer codigoPostal; 
     
    // Getters y setters 
}
	

Como puedes ver en el ejemplo anterior, cada entidad Cliente mantiene una referencia a una entidad Direccion. Esta relación es de tipo uno-a-uno (one-to-one) unidireccional (puesto que una entidad Cliente contiene una referencia a una entidad Direccion, relación que ha sido declarada mediante la anotación @OneToOne. Cliente es el dueño de la relación de manera implícita, y por tanto cada entidad de este tipo contendrá por defecto una columna adicional en su tabla correspondiente de la base de datos (mediante la que podremos acceder al objeto Direccion). Esta columna es una clave foránea (foreign key), un concepto muy importante en bases de datos relaciones que abordaremos a lo largo de todo este artículo.

Cuando JPA realice el mapeo de esta relación, cada entidad será almacenada en su propia tabla, añadiendo a la tabla donde se almacenan los clientes (la dueña de la relación) una columna con las claves foráneas necesarias para asociar cada fila con la fila correspondiente en la tabla donde se almacenan las direcciones. Recuerda que JPA utiliza configuración por defecto para realizar el mapeo, pero podemos customizar este proceso definiendo el nombre de la columna que contendrá la clave foránea mediante la anotación @JoinColumn:

@OneToOne 
@JoinColumn(name = "DIRECCION_FK") 
private Direccion direccion; 
	

Otro tipo de asociación muy común es la de tipo uno-a-muchos (one-to-many) unidireccional:

@Entity 
public class Cliente { 
    @Id 
    @GeneratedValue 
    private Long id; 
    @OneToMany 
    private List<Direccion> direcciones; 
     
    // Getters y setters 
}
	

En el ejemplo anterior, cada entidad de tipo Cliente mantiene una lista de direcciones a través de la propiedad direcciones. Puesto que un objeto List puede albergar múltiples objetos en su interior, debemos anotarlo con @OneToMany. En este caso, en vez de utilizar una clave foránea en Cliente, JPA utilizará por defecto una tabla de unión (join table). Cuando ocurre esto, las tablas donde se almacenan ambas entidades contienen una clave foránea a una tercera tabla con dos columnas; esta tercera tabla es llamada tabla de unión, y es donde se estata la asociación entre las entidades relacionadas. Como siempre, podemos configurar el mapeo mediante metadatos (anotaciones/XML) para que se ajuste a nuestras necesidades:

@OneToMany 
@JoinTable(name = ..., 
        joinColumn = @JoinColumn(name = ...), 
        inverseJoinColumn = @JoinColumn(name = ...)) 
private List direcciones; 
	

2.3 Asociaciones bidireccionales

En las asociaciones bidireccionales, ambos extremos de la relación mantienen una referencia al extremo contrario. En este caso, el dueño de la relación debe ser especificado explícitamente, de manera que JPA pueda realizar el mapeo correctamente. Veamos un ejemplo de bidireccionalidad en una relación de tipo uno-a-uno:

@Entity 
public class Mujer { 
    @Id 
    @GeneratedValue 
    private Long id; 
    @OneToOne 
    private Marido marido; 
     
    // Getters y setters 
} 

@Entity 
public class Marido { 
    @Id 
    @GeneratedValue 
    private Long id; 
    @OneToOne(mappedBy = "marido") 
    private Mujer mujer; 
} 
	

En el ejemplo anterior (y como suele ocurrir en el mundo real...), Mujer es la dueña de la relación; puesto que la relación es bidireccional, ambos lados de la relación deben estar anotados con @OneToOne, pero ahora uno de ellos debe indicar de manera explícita que la parte contraria es dueña de la relación. Esto lo hacemos añadiendo el atributo mappedBy sobre la anotación de asociación dentro de la entidad no-dueña. El valor de este atributo es el nombre de la propiedad asociada en la entidad que es dueña de la relación. El atributo mappedBy puede ser usado en relaciones de tipo @OneToOne, @OneToMany y @ManyToMany. Únicamente el cuarto tipo de relación, @ManyToOne, no permite el uso de este atributo.

2.4 Lectura temprana y lectura demorada de asociaciones

En sección 1.5 del artículo anterior vimos el significado de los conceptos lectura temprana y lectura demorada. Las asociaciones son parte del mapeo relacional, y por tanto también son afectadas por este concepto. El tipo de lectura por defecto para las relaciones uno-a-uno y muchos-a-uno es temprana (eager). Por contra, el tipo de lectura para los dos tipos de relaciones restantes (uno-a-muchos y muchos-a-muchos), es demorada (lazy). Por supuesto, ambos comportamientos pueden ser modificados:

@OneToMany(fetch = FetchType.EAGER)
private List<Pedido> pedidos;
	

Al igual que se indicó en el artículo anterior, debes ser consciente del impacto en el rendimiento de la aplicación que puede causar una configuración errónea en el tipo de lectura. En el caso de las asociaciones, donde se pueden ver involucrados muchos objetos, leerlos de manera temprana puede ser, además de innecesario, inadecuado. En otros casos una lectura temprana puede ser necesaria: si la entidad que es dueña de la relación se desconecta de gestor de persistencia (como veremos en el próximo artículo) sin haber inicializado aún una asociación con lectura demorada, al intentar acceder a dicha asociación se producirá una excepción de tipo LazyInitializationException (podemos encontrar una analogía a este comportamiento cuando tenemos un objeto sin inicializar (con valor null), que al no apuntar a ninguna instancia en memoria produce un error si es usado). Sea como sea, configura siempre los tipos de lectura de la forma más adecuada para tu aplicación.

2.5 Ordenación de asociaciones

Puedes ordenar los resultados devueltos por una asociación mediante la anotación @OrderBy:

@OneToMany
@OrderBy("nombrePropiedad asc")
private List<Pedido> pedidos;
	

El atributo de tipo String que hemos proporcionado a @OrderBy se compone de dos partes: el nombre de la propiedad sobre la que queremos que se realice la ordenación, y ,opcionalmente, el sentido en que se realizara, que puede ser:

  • Ascendente (añadiendo asc al final del atributo)
  • Descendente (añadiendo desc al final del atributo)

Así mismo, podemos mantener ordenada la colección a la que hace referencia una asociación en la propia tabla de la base de datos; para ello, debemos usar la anotación @OrderColumn. Sin embargo, el impacto en el rendimiento de la base de datos que puede producir este comportamiento es algo que debes tener muy presente, ya que las tablas afectadas tendrán que reordenarse cada vez que se hayan cambios en ellas (en tablas de cierto tamaño, o en aquellas donde se inserten o modifiquen registros con cierta frecuencia, es totalmente desaconsejable forzar una ordenación automática).

2.6 Herencia

En las aplicaciones Java, el concepto de herencia es usado de forma intensiva. Por supuesto, JPA nos permite gestionar la forma en que nuestros objetos son mapeados cuando en ellos interviene el concepto de herencia. Esto puede hacerse de maneras distintas:

  • Una tabla por familia (comportamiento por defecto)
  • Unión de subclases
  • Una tabla por clase concreta

El mapeo por defecto es una tabla por familia. Una familia no es, ni más ni menos, que todas las subclases que están relacionadas por herencia con una clase madre, inclusive. Todas las clases que forman parte de una misma familia son almacenadas en una única tabla. En esta tabla existe una columna por cada atributo de cada clase y subclase de la familia, además de una columna adicional donde se almacena el tipo de clase al que hace referencia cada fila. Imaginemos el ejemplo siguiente:

@Entity 
public class SuperClase { 
    @Id 
    @GeneratedValue 
    private Long id; 
    private int propiedadUno; 
    private String propiedadDos; 
     
    // Getters y Setters 
} 

@Entity 
public class SubClase extends SuperClase { 
    @Id 
    @GeneratedValue 
    private Long id; 
    private float propiedadTres; 
    private float propiedadCuatro; 
     
    // Getters y setters 
}
	

En el ejemplo anterior, tanto las instancias de las entidades SuperClase y SubClase seran almancenadas en una única tabla que tendrá el nombre por defecto de la clase raíz (SuperClase). Dentro de esta tabla habrá seis columnas que se corresponderán con:

  • Una columna para la propiedad id (válida para ambas entidades, pues en ambas se mapearía a una columna con el mismo nombre)
  • Cuatro columnas para las propiedades propiedadUno, propiedadDos, propiedadTres y propiedadCuatro
  • Una última columna discriminatoria, donde se almacenará el tipo de clase al que hace referencia cada fila

La columna discriminatoria suele contener el nombre de la clase. Esta columna es necesaria para que al recuperar un objeto desde la base de datos, JPA sepa que clase concreta debe instanciar, y que propiedades leer. Las propiedades que son propias de cada clase no deben ser configuradas como not null, ya que al intentar persistir un objeto de la misma familia pero de otra clase (y que tal vez no dispone de las mismas propiedades) obtendríamos un error. Aunque una tabla por familia es el comportamiento por defecto cuando mapeamos entidades en las que interviene la herencia entre clases, podemos declararlo explícitamente mediante el atributo SINGLE_TABLE (tabla única) de la anotación @Inheritance:

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
public class SuperClass { ... }
	

Ten presente que los valores definidos en @Inheritance son heredados por todas las subclases de la entidad anotada, aunque estas pueden sobreescribir dichos valores si desean adoptar una política de mapeo diferente. En lo que refiere al nombre de la columna discriminatoria y a su tipo, estos son por defecto DTYPE y String (este último es un tipo SQL, evidentemente). Podemos cambiar ambos mediante las anotaciones @DiscriminatorColumn y @DiscriminatorValue:

@Entity
@Inheritance
@DiscriminatorColumn(name = "...", discriminatorType = CHAR)
@DiscriminatorValue("C")
public class SuperClase { ... }
	

La anotación @DiscriminatorColumn solo debe ser usada en la clase raiz de la herencia (a no ser que una subclase desee cambiar los parámetros del mapeo, en cuyo caso ella se convertiría en clase raiz por derecho). El atributo discriminatorType de la citada anotación nos permite cambiar el tipo de valor que almacenará la columna discriminatoria. Los tipos soportados son STRING (por defecto), CHAR e INTEGER. Si cambiamos en la clase raíz el tipo de valor que almacenará la columna discriminatoria a otro distinto de STRING, cada subclase tendrá que indicar de forma explicita el valor que lo representará en la columna discriminatoria. Esto lo hacemos mediante la anotación DiscrimitadorValue:

@Entity
@DiscriminatorValue("S")
public class SubClase extends SuperClase { ... }
	

En el ejemplo anterior, la columna discriminatoria (que es de tipo CHAR) contendrá un valor C si la instancia correspondiente es de tipo SuperClase, y una S si es de tipo SubClase. Por supuesto, no estaría permitido usar @DiscriminatorValue("C") si se ha definido una columna discriminatoria de tipo INTEGER, etc.

El segundo tipo de mapeo cuando existe herencia es unión de subclases, en el que cada clase y subclase (sea abstracta o concreta) será almacenada en su propia tabla:

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public class SuperClase { ... }
	

La tabla raíz contiene una columna con una clave primaria usada por todas las tablas, así como la columna discriminatoria. Cada subclase almacenará en su propia tabla únicamente sus atributos propios (nunca los heredados), así como una clave foránea que hace referencia a la clave primaria de la tabla raíz. La mayor ventaja de este sistema es que es intuitivo. Por contra, su mayor inconveniente es que, para construir un objeto de una subclase, hay que hacer una (o varias) operaciones JOIN en la base de datos, de manera que se puedan unir los atributos de la subclase con los de sus superclases. Por tanto, una subclase que esté varios niveles por debajo de la superclase en la herencia, necesitará realizar múltiples operaciones JOIN (una por nivel), lo cual puede producir un impacto en el rendimiento que tal vez no deseemos.

El tercer y último tipo de mapeo cuando existe herencia es una tabla por clase concreta. Mediante este comportamiento, cada entidad será mapeada a su propia tabla (incluyendo todos los atributos propios y heredados). Con este sistema no hay tablas compartidas, columnas compartidas, ni columna discriminatoria:

@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public class SuperClase { ... }
	

El único requerimiento al utilizar una tabla por clase concreta es que todas las tablas dentro de una misma familia compartan el valor de la clave primaria. De esta manera, cuando almacenemos una subclase, tanto su tabla como las de sus superclases contendrán los mismos valores para las propiedades comunes, gracias a que todas ellas comparten la misma ID. Este sistema puede provocar en determinadas circunstancias problemas de rendimiento, ya que al igual que con la unión de subclases, la base de datos tendrá que realizar múltiples operaciones JOIN ante determinadas solicitudes.

2.7 Mapped Superclasses, clases abstractas y no-entidades

Para terminar este artículo veamos de forma muy superficial la forma en la que gestiona JPA tres tipos de clases no vistas hasta ahora: Mapped Superclases, clases abstractas, y no-entidades.

Mapped Superclasses son clases que no son manejadas por el proveedor de persistencia, pero comparten sus propiedades con cualquier entidad que extienda de ellas:

@MappedSuperclass 
@Inheritance(strategy = InheritanceType.XXX) 
public class MapedSuperClase { 
    private String propiedadUno; 
    private int propiedadDos; 
     
    // Getters y setters 
} 

@Entity 
public class SuperClase extends MappedSuperClase { ... } 
	

En el ejemplo anterior, únicamente SuperClase será manejada por el proveedor de persistencia. Sin embargo, durante el mapeo se incluirán todas las propiedades heredadas de MappedSuperClase.

Por otro lado, todas las clases abstractas son tratadas exactamente igual que si fueran clases concretas, y por tanto son entidades 100% usables siempre que sean declaradas como tal (mediante @Entity). Precisamente por esto último, toda clase que no sea declarada como entidad, será ignorada a la hora de realizar el mapeo relacional. A este tipo de clases se las conoce como no-entidades.

Resumen

En este artículo hemos visto los conceptos de asociaciones y herencia en JPA, dos conceptos totalmente necesarios a la hora de diseñar tus mapeos.

En el próximo artículo veremos como persistir, leer, actualizar, y borrar de una base de datos entidades como las que hemos visto en los dos primeros artículos, así como tres conceptos que afectan a nuestras entidades cuando se encuentran gestionadas el servidor de aplicaciones donde se encuentra el proveedor de JPA: transacciones, métodos callback, y clases listener.