5.4. Mapeo#

El mapeo consiste en definir cómo se hará la traducción entre las clases del modelo de objetos (de Java, en este caso) y las entidades del modelo relacional. En esta traducción las clases se corresponden con las tablas, las instancias con los registros y los atributos con los campos.

Las anotaciones se encuentran definidas en la especificación JPA e Hibernate (o cualquier otro ORM compatible) se encarga de darles soporte. Fundamentalmente sirven para:

  • Indicar qué atributos incorporan a los campos de la tabla y con qué nombre.

  • Establecer restricciones sobre los valores.

  • Definir relaciones entre tablas.

5.4.1. Anotaciones básicas#

Ya introdujimos las anotaciones más básicas al presentar el ejemplo de anotación de la clase Centro:

@Entity
@Table(name = "Centro") // Sólo útil si la table se llama de modo diferente.
public class Centro {

    public static enum Titularidad {
        PUBLICA, PRIVADA
    }

    @Id
    //@GeneratedValue(strategy = GenerationType.IDENTITY)  // GENERATED ALWAYS BY IDENTITY
    private Long id;

    @Column(name = "nombre", nullable = false, length = 255)
    private String nombre;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Titularidad titularidad;

    public Centro() {
        super();
    }

    public Centro cargarDatos(long id, String nombre, Titularidad titularidad) {
        setId(id);
        setNombre(nombre);
        setTitularidad(titularidad);
        return this;
    }

    public Centro(long id, String nombre, Titularidad titularidad) {
        cargarDatos(id, nombre, titularidad);
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getNombre() {
        return nombre;
    }

    public void setNombre(String nombre) {
        this.nombre = nombre;
    }

    public Titularidad getTitularidad() {
        return titularidad;
    }

    public void setTitularidad(Titularidad titularidad) {
        this.titularidad = titularidad;
    }

    
    @Override
    public String toString() {
        return String.format("%s (%d)", getNombre(), getId());
    }
}

Y convendría leer lo explicado allí para no repetirlo. Ahora bien, podemos ampliar nuestro conocimientos sobre las anotaciones válidas:

@Column/@Transient

Por lo general, todos los atributos de la clase, aunque no estén anotados en absoluto, se consideran campos de la tabla correspondiente. Las excepciones son:

  • Los campos estáticos (static), ya que no forman parte del objeto.

  • Los campos con el modificador transient.

  • Los atributos sin getter o al menos con un getter que no sea accesible (private)[1].

  • Los atributos cuyo tipo no se sepa traducir.

  • Los atributos que se heredan de una clase base que no se anotó con @Entity.

La anotación @Column fuerza a que un atributo se traduzca a campo y además permite añadir información sobre la traducción mediante parámetros:

  • name permite indicar un nombre para el campo distinto al nombre del atributo.

  • nullable, si puede contener valores nulos.

  • unique, si su valor debe ser único.

  • length, la longitud de la cadena.

  • precision y scale para tipos como BigDecimal permiten indicar el número total de dígitos y el de dígitos decimales respectivamente.

  • insertable, indica si el campo debe incluirse en las sentencias INSERT.

  • updatable, indica si el campo debe incluirse en las sentencias UPDATE.

  • columnDefinition sirve para indicar directamente la definición SQL del campo.

La anotación @Transient evita que el atributo se mapee como campo.

@Id/@GeneratedValue

Como ya hemos visto, la anotación @Id sirve para marcar un atributo como identificador; y @GeneratedValue para indicar cómo se generan automáticamente sus valores. Lo más socorrido es:

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id

que equivale a indicar que el campo identificador es autoincremental (GENERATED ALWAYS AS IDENTITY). Si no usamos la segunda anotación el campo requerirá siempre que el usuario establezca el valor.

@Lob

Se utiliza para campos que contienen cantidades apreciables de datos (BLOB o CLOB) en el estándar:

@Lob
private byte[] foto;
@Temporal

Es útil cuando se usa el tipo antiguo Date para indicar si se quiere almacenar en la base de datos, sólo la hora (TemporalType.TIME) , sólo la fecha (TemporalType.DATE) o tanto la fecha como la hora (TemporalType.TIMESTAMP):

@Temporal(TemporalType.DATE)  // Sólo almacena la fecha.
private Date nacimiento;

Para tipos modernos como LocalDate, LocalTime o LocalDateTime es absolutamente innecesaria la anotación.

@Version

Sirve para identificar el atributo como un campo que controla la versión del registro, de modo que se actualiza el valor cada vez que se actualiza. Por ejemplo:

@Version
private int version;

El atributo debe ser numérico, o si se desea almacenar la fecha en vez de un número de versión, un tipo de fecha. Por ejemplo:

@Version
@Column(nullable = false)
private LocalDateTime actualizacion;
@Enumerated

Permite indicar cómo almacenar un campo enum:

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Titularidad titularidad

En este caso, el tipo se guardará como una cadena en la base de datos. Otra opción habría sido EnumType.ORDINAL.

@Embeddable/@Embedded

Ambas trabajan en comandita y permiten empotrar un objeto completo como campo de una tabla. Por ejemplo, si añadiéramos un campo dirección a la definición de Centro, podríamos hacer lo siguiente:

@Embeddable
public class Direccion {
   private String calle;
   private String numero;
   private String localidad;
   private Integer codigoPostal;

   // ... getters y setters ...
}

// ...

public class Centro {
   // ...

   @Embedded
   private Direccion direccion;

   // ...
}

Al traducirse, no se crea una tabla Direccion, sino que los atributos de Direccion pasarán a añadirse como atributos de la tabla Centro. Para controlar con qué nombre de columna se traducen es necesario:

@Embedded
@AttributeOverrides({
   @AttributeOverride(name = "calle", column = @Column(name = "d_calle")),
   @AttributeOverride(name = "numero", column = @Column(name = "d_numero"))
})
private Direccion direccion;
@Convert/@Converter

Permite definir exactamente la traducción de un atributo a campo y viceversa. Para llevar a cabo esta traducción debe definirse una clase que implemente la interfaz AttibuteConverter. Por ejemplo, imaginemos que el tipo Titularidad lo hubiéramos definido así:

public enum Titularidad {
   PUBLICA("Pública"),
   PRIVADA("Privada");

   private String nombre;

   Titularidad(String nombre) {
      this.nombre = nombre;
   }

   public String getNombre() {
      return nombre;
   }

   public static Titularidad fromNombre(String nombre) {
      return Arrays.stream(Titularidad.values())
         .filter(t -> t.getNombre().toLowerCase().equals(nombre.toLowerCase()))
         .findFirst.orElse(null);
   }
}

y que pretendiéramos almacenar en la base de datos el nombre («Pública» o «Privada»). En ese caso tendríamos que definir un convesor así:

public class TitularidadConverter implements AttributeConverter<Titularidad, String> {

     @Override
     public Titularidad convertToEntityAttribute(String nombre) {
         return Titularidad.fromNombre(nombre);
     }

     @Override
     public String convertToDatabaseColumn(Titularidad titularidad) {
         return titularidad == null?null:titularidad.getNombre();
     }

 }

De esto modo podríamos anotar el atributo así:

@Convert(converter = TitularidadConverter.class)
private Titularidad titularidad;

También es posible anotar la clase conversora:

@Converter(autoPlay = true)
public class TitularidadConverter implements AttributeConverter<Titularidad, String> {
   // ... Implementación ...
}

Y el conversor se aplicará automáticamente a todos los atributos de tipo Titularidad sin necesidad de anotar los atributos individualmente.

5.4.2. Otras anotaciones#

Existen otras anotaciones relacionadas con la validación de valores (el equivalente al CHECK de SQL), que requieren importar la librería validation-api. Son variadas. Por ejemplo:

@PositiveOrZero(message = "La cantidad no puede ser negativa")
private int cantidad

@Positive
private BigDecimal precio;

@Min(value = 18, message = "La entrada está vetada a menores de edad")
private int edad;

@Pattern(regexp = "[0-9]{7,8}[A-Z]")
private String nif;

Nota

"message" permite personalizar el mensaje de error.

5.4.3. Relaciones#

Las relaciones entre las entidades también se significan mediante anotaciones. Hay tres tipos de relaciones binarias en el modelo relacional:

Relación 1:N: @OneToMany/@ManyToOne

Se produce cuando una registro de la primera tabla se relaciona con muchos registros de la segunda, pero al contrario los registros de la segunda sólo se relacionan con uno de la primera.

JPA establece dos modos de resolver esta relación (y las otras dos que se explicarán a continuación): de modo unidireccional y de modo bidireccional.

Unidireccional

El modo unidireccional es el que más fielmente refleja el modelo relacional, ya que la relación sólo se refleja en uno de los extremos: en aquel en que se encuentra la clave foránea. Por ejemplo, la relación la relación 1:N entre Centro y Estudiante provoca que definamos así la clase Estudiante:

@Entity
public class Estudiante {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String nombre;

    private LocalDate nacimiento;

    @ManyToOne
    @JoinColumn(name = "id_centro", nullable = false) // El campo se llama id_centro en la BD.
    private Centro centro;

    public Estudiante() {
        super();
    }

    public Estudiante cargarDatos(String nombre, LocalDate nacimiento, Centro centro) {
        setNombre(nombre);
        setNacimiento(nacimiento);
        setCentro(centro);
        return this;
    }

    public Estudiante(String nombre, LocalDate nacimiento, Centro centro) {
        cargarDatos(nombre, nacimiento, centro);
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getNombre() {
        return nombre;
    }

    public void setNombre(String nombre) {
        this.nombre = nombre;
    }

    public LocalDate getNacimiento() {
        return nacimiento;
    }

    public void setNacimiento(LocalDate nacimiento) {
        this.nacimiento = nacimiento;
    }

    public Centro getCentro() {
        return centro;
    }

    public void setCentro(Centro centro) {
        this.centro = centro;
    }

    @Override
    public String toString() {
        return String.format("%s (%s, % d años)", getNombre(), getCentro().getNombre(), ChronoUnit.YEARS.between(getNacimiento(), LocalDate.now()));
    }
    
}

Prudencia

Recuerde registrar en la configuración esta nueva clase:

<mapping class="edu.acceso.test_hibernate.modelo.Estudiante"/>

Como se ve se ha significado:

  • Con la anotación @ManyToOne para un atributo que representa el objeto (registro) completo, no sólo el identificador.

  • Con un getter y un setter asociados.

  • Haciendo que el constructor y .cargarDatos manejen el objeto referenciado, no la clave foránea en sí.

En definitiva, la traducción de la relación es casi literal con la única salvedad de que el atributo no almacena la clave foránea (un identificador), sino el registro completo (objeto, en realidad).

Bidireccional

En este modo, la relación se refleja en ambos extremos, por lo que debe añadirse también un atributo en el otro extremo (Centro), que estará anotado con @OneToMany:

@OneToMany(mappedBy = "centro")  //Ya está mapeado.
private List<Estudiante> estudiantes;

Como se ve, expresamos que este campo ya está mapeado por el atributo centro de la clase Estudiante. Además, deberemos definirle un getter para poder acceder a su valor. El setter, en cambio, no es indispensable.

Esta alternativa nos permite conocer todos los estudiantes matriculados en un centro de modo sencillo, pero introduce problemas de sincronización con los que deberíamos lidiar. Por ejemplo, establecer como nulo el atributo centro de un estudiante, no actualizará automáticamente la lista de estudiantes matriculados del centro correspondiente. Para que la actualización fuera automática deberíamos manipular el setter de tal atributo.

Prudencia

Tenga presente esto último. Una relación bidireccional introduce un problema de sincronización entre los extremos de la relación, ya que un extremo puede que informe de una cosa distinta a la que informa el otro. En el caso anterior, el estudiante informa de que no está matriculado, mientras la lista de estudiantes en el centro informa de que sí lo está. En casos de discrepancia, Hibernate hará prevalecer la información del extremo de la relación que realmente existe en la base de datos, esto es, la información que ofrece estudiante.

Relación 1:1: @OneToOne

Expresa que cada registro de una tabla se relaciona exclusivamente con uno (o ninguno) registro de la otra tabla. En SQL se resuelve colocando el campo en cualquiera de las dos tablas. En Hibernate para significar este tipo relaciones se usa la notación @OneToOne y, como en el caso anterior, podemos reflejarla de modo unidireccional o bidireccional.

Para ilustrarlo, supongamos que añadimos un campo direccion a la clase Centro de tipo Direccion y queremos que en la base de datos los campos que componen la dirección se mantengan en una tabla independiente (a diferencia de lo que propusimos al explicar la anotación @Embedded).

Unidireccional

En este caso, la relación 1:1 se expresaría añadiendo a Centro:

@OneToOne
@JoinColumn(name = "direccion_id", nullable = false)
private Direccion direccion;

porque es en su tabla correspondiente donde decididimos añadir el campo. Además, deberíamos definir el getter y el setter correspondientes.

Bidireccional

Además de lo hecho antes, debemos reflejar la relación en el otro extremo (la clase Direccion), por lo que debemos añadir:

@OneToOne(mappedBy = "direccion")
private Centro centro;

más al menos el *getter* correspondiente.
Relación N:M:

La relación supone que un registro de una tabla se relaciona con muchos de la otra, y viceversa; y supone en el modelo relacional la aparición de una tercera tabla. En nuestro ejemplo, podríamos introducir este tipo de relación, si ampliáramos la base de datos para que incluyera profesores y los quisiéramos relacionar con estudiantes, ya que los estudiantes tienen varios profesores; y cada profesor imparte sus enseñanzas a varios estudiantes. Por tanto, la relación entre las tablas Profesor y Estudiante es una relación N:M. Para implementar en Hibernate estas relaciones, han de distinguirse dos casos:

  1. La relación no tiene atributos propios: @ManyToMany.

    En este caso, puede expresarse la relación N:M directamente con la anotación @ManyToMany.

    Unidireccional

    Creamos el atributo anotado en uno de los dos extremos (el que nos resulte más natural). Así podríamos añadir a Estudiante:

    @ManyToMany
    @JoinTable(
       name="Estudiante_Profesor", // Nombre de la tabla adicional
       joinColums = @JoinColumn(name = "id_estudiante"),
       inverseJoinColums = @JoinColumn(name = "id_profesor"),
    )
    private List<Profesor> profesores;
    

    También debemos añadir un getter; el setter no es necesario.

    Bidireccional

    Además de lo anterior, debemos reflejar la relación en el otro extremo, por lo que en Profesor debemos añadir:

    @ManyToMany(mappedBy = "profesores")
    private List<Estudiante> estudiantes;
    

    Como en el otro extremo, debemos añadir un getter y el setter no es necesario.

  2. La relación tiene atributos propios: @OneToMany/@ManyToOne.

    En tal caso, el único modo de poder añadir los atributos es crear una clase adicional que añada los atributos, y descomponer la relación N:M en dos relaciones 1:N, de ahí que las anotaciones apropiadas sean @OneToMany y @ManyToOne. No entraremos en detalle, porque ya hemos resuelto cómo expresar una relación 1:N.

Notas al pie