Entidades
Una entidad va a ser una clase Java que se va a comportar como un modelo de datos conectado con una base de datos. Lo podemos ver como una clase normal con sus campos, sus getters, sus setters y otros métodos de utilidad, como las que hemos estado usando hasta ahora. Sin embargo, las entidades van a tener una serie de anotaciones especiales que vienen proporcionadas por JPA, la API para la Persistencia de Datos de Java, con el objetivo de marcar sus campos como algo que llevar a columnas de una base de datos.
JPA, el sistema de Acceso a Persistencia de Java, es un ORM. Es una tecnología que es capaz de traducir entre clases Java y sentencias SQL, y viceversa. Por ejemplo, supongamos que tenemos la siguiente instancia de clase:
Author a = new Author();
a.setName("Miguel");
a.setSurname("De Cervantes");
a.setCountryOfBirth("España");
Suponiendo que pongamos las anotaciones de JPA, un ORM, como Hibernate, será capaz de traducir esta clase al código SQL correspondiente:
database.persist(a);
// Genera el siguiente código SQL:
// INSERT INTO Author(name, surname, countryOfBirth)
//. VALUES('Miguel', 'De Cervantes', 'España');
De una manera similar, supongamos la siguiente query:
SELECT name, surname, countryOfBirth FROM Author WHERE id = 5;
-- Devuelve un record
-- name: Garcilaso
-- surname: De la Vega
-- countryOfBirth: España
Será convertida automáticamente en una instancia de la clase Author que verifique lo siguiente:
Author a = database.findById(5);
assertEquals(a.getName(), "Garcilaso");
assertEquals(a.getSurname(), "De la Vega");
assertEquals(a.getCountryOfBirth(), "España")
Dicho de otro modo, nosotros con JPA nos ocupamos únicamente de trabajar con objetos, y dejamos que el ORM se ocupe de la traducción a base de datos. Conveniente. Tenemos un montón de anotaciones también, ya no solo para pedirle que trate una clase como una entidad, sino para especificar si nos hace falta el nombre de la columna, de la tabla, las relaciones, los índices y otras restricciones... Podríamos trabajar con una base de datos ya existente desde hace años y si especificamos bien las clases y le ponemos los atributos JPA correspondientes, podríamos empezar a recorrer esa base de datos usando orientación a objetos.
Crear una entidad
Para crear una entidad, tomaremos una clase que ya existe. La podemos crear sobre la marcha. Por ejemplo, aquí defino una clase con los siguientes campos:
public class Book {
private String title;
private int numPages;
private LocalDate pubDate;
private String description;
public Book() {
// Es obligatorio que tenga un constructor vacío.
// No tienes por qué crearlo, por supuesto, excepto que decidas
// poner un constructor que sí tenga campos, porque JPA necesita
// poder crear instancias con un constructor de cero parámetros.
}
public Book(String title) {
// Somos libres de definir más constructores además del vacío.
this.title = title;
}
// Getters, setters...
// equals y hashCode()
}
Como ves, es importante que la clase tenga un constructor vacío. Normalmente no tendrás que especificarlo, pero si decidieses crear un constructor con parámetros sí es necesario tener uno sin parámetros explícitamente declarado. Para poder funcionar, JPA necesitará poder fabricar instancias de nuestra clase, y necesita tener acceso a un constructor sin parámetros para poder fabricar esas instancias sin tener que pensar en parámetros, ya que no hay garantía de que el compilador conserve los nombres de los campos en tiempo de ejecución.
Ahora, para marcar esta clase como una entidad persistente, tendremos que ponerle a la clase la anotación @Entity, que podemos traernos desde jakarta.persistence.Entity. Además, es obligatorio que si la clase es una Entity tenga un campo identificador. Típicamente le daremos como nombre id (aunque le podemos llamar como nos interese), y normalmente será de tipo Long (wrapper, para que acepte valores nulos), para poder tener miles de millones de identificadores, algo que con un int de 4 bytes no podríamos conseguir con seguridad. Se corresponde con el identificador de base de datos, necesario para poder hacer búsquedas de un registro en función de su ID, o para poder usar un registro en relaciones de bases de datos de las que participe, como conectar un libro con el género literario al que pertenece. Este identificador lo declaramos con las anotaciones @Id y @GeneratedValue, también pertenecientes al paquete jakarta.persistence:
@Entity
public class Book {
@Id
@GeneratedValue
private Long id;
private String title;
private int numPages;
private LocalDate pubDate;
private String description;
}
JPA trabajará con identificadores, y además se compromete a que cuando inserte un registro en base de datos transformando una instancia nueva de una clase en el código SQL necesario para hacer la inserción, recuperará también el ID que le ha sido asignado al registro y lo establecerá en el campo que tengamos etiquetado como @Id, para así poder usarlo luego en la aplicación si lo necesitamos.
@GeneratedValue también lo tenemos que especificar porque en algunos motores de bases de datos es necesario señalizarle de forma especial que se use un identificador secuencial. Por ejemplo, en MySQL sería un AUTO_INCREMENT y en PostgreSQL sería una secuencia serial. Además, imaginemos que como parte de una transacción insertamos varios registros de forma simultánea en los cuales hace falta una relación foránea. El ORM necesitará ese GeneratedValue para saber el orden en el que hacerlo a fin de siempre tener los identificadores. Por ejemplo, si inserto a la vez un libro y el género al que pertenece, no puedo insertar el libro hasta que no tenga el valor generado para el identificador del género al que va a pertenecer.
Creación automática de las tablas
Ten en cuenta que dependiendo de la manera en la que hayas configurado el ORM en tu application.properties, puede que las tablas se creen, modifiquen o destruyan automáticamente. Este proceso ocurre cuando se inicia la aplicación Quarkus, durante el proceso de arranque. Hasta que la base de datos no está refrescada de acuerdo con las reglas que se hayan especificado, no servirá tráfico HTTP.
En el ejemplo del capítulo anterior, configurábamos una conexión con una base de datos de tipo H2 y establecíamos el tipo de migración de datos a update. Eso significa que la próxima vez que ejecutemos quarkus dev, Hibernate detectará que tenemos una clase anotada como Entity pero que no existe ninguna tabla llamada Book en la base de datos, y proporcionará el comando SQL correspondiente para hacer la creación de la tabla (CREATE TABLE Book(...)).
Si Hibernate detecta que el esquema de datos está actualizado y que existe una tabla para cada clase anotada como Entity y una columna para cada campo perteneciente a la misma, no hará ninguna modificación.
Si estás usando update, como mostraba en el ejemplo anterior, si se inserta un campo más a la entidad, la próxima vez que se arranque la aplicación y Quarkus haga ese escaneo, detectará que la tabla Book carece de uno de los campos y ejecutará un comando SQL para crear la columna correspondiente, típicamente un ALTER TABLE Book.
En caso de haber configurado la base de datos como drop-and-create, Hibernate hará un DROP TABLE de todas las tablas, seguido de un CREATE TABLE para recrear todo el esquema de datos con cada inicio de la aplicación, provocando que la aplicación siempre inicie con una base de datos limpia, algo que puede ser útil en entornos de testing, aunque peligroso en entornos de producción.
Repositorio
Después, un repositorio será una clase que se comporta como un servicio para interactuar con la base de datos. Si alguna vez has trabajado con Spring, es posible que te suenen los repositorios. Un repositorio es la clase que tiene los métodos para insertar registros en la base de datos, recuperar registros, modificar registros, borrar registros... es decir, es la clase que va a interactuar con la base de datos.
Los repositorios siempre van a estar vinculados a una entidad con la que trabajan. Por ejemplo, si tengo una entidad llamada Book, necesitaré un repositorio preparado para trabajar con Book. En la práctica, esto significará que para cualquier entidad que forme parte de nuestra aplicación tendrá que existir un repositorio del tipo correspondiente.
Si has trabajado con Hibernate o con JPA de forma manual, puede que te suenen los EntityManager. Los EntityManager son mucho más genéricos, porque tienen una conexión global con una fuente de datos y cualquier entidad, sea del tipo que sea, pasa por ellas. Un repositorio está especializado en una clase particular, como ocurre en Spring. Además, dado que los repositorios son clases completas que nosotros definimos, se comportan como servicio y podrán tener lógica de negocio específica si nos viene bien.
La forma más simple de crear un repositorio es agregar una clase que implemente PanacheRepository<E>, donde E es el genérico que representa el tipo de entidad al que queremos que vaya asociado. Por ejemplo:
@ApplicationScoped
public class BookRepository implements PanacheRepository<Book> {
}
Ahora tenemos un repositorio que sabe interactuar con libros. PanacheRepository es una interfaz que define los métodos típicos de un repositorio. Lo interesante de esto es que si lo inyectamos sobre un controlador o sobre otro servicio, recibiremos una instancia de una clase interna de Panache con la implementación de todos esos métodos. Observa que para tal fin, le he puesto la anotación @ApplicationScoped a la clase.
Además, la clase está vacía y no tiene ningún método, porque no vamos a extender la clase. Si en el futuro queremos crear métodos de acceso rápido al modelo de datos, podríamos agregar métodos a esta clase, porque se comporta como un servicio. Eso sí, la recomendación es solamente incorporar métodos que realmente tengan que ver con el repositorio, por ejemplo incorporar selectores con filtros predefinidos, pero no tendría cabida incorporar métodos que tengan otras responsabilidades no relacionadas con el acceso a datos.
Usar un repositorio en un recurso HTTP
Como los repositorios se pueden inyectar, podemos ahora crear un controlador que dependa de este repositorio para usar sus métodos. En el siguiente ejemplo, hacemos un recurso denominado BookResource conectado a la ruta /books, donde dependemos de un BookService:
@Path("/books")
public Path BookResource {
private BookRepository repository;
@Inject
public BookResource(BookRepository repository) {
this.repository = repository;
}
}
listAll: Implementar un endpoint GET
Si quisiésemos listar todos los elementos de un repositorio, podemos usar el método .listAll(), que forma parte de la interfaz PanacheRepository, así que todos los repositorios siempre tendrán este método para listar todos los elementos del recurso. El siguiente código recupera la lista de todos los libros que haya en la base de datos y los asignaría a la variable books:
@Path("/books")
public Path BookResource {
private BookRepository repository;
@Inject
public BookResource(BookRepository repository) {
this.repository = repository;
}
@GET
public List<Book> listBooks() {
List<Book> books = repository.listAll();
return books;
}
}
Podemos verificar que si la base de datos está vacía, visitar ahora /books devolverá un array vacío si pedimos la respuesta en formato JSON. En cambio, si la base de datos tuviese registros, estos vendrían dentro del array.
persist: insertar registros en la base de datos
Para darle vida a este recurso, vamos a agregar un endpoint POST que se ocupe de insertar en base de datos un libro que recibimos como payload. En base a lo que ya sabemos sobre definición de objetos JSON, podemos crear un endpoint que tenga como anotación @POST y configurarlo para que reciba como parámetro una instancia de ese libro:
@POST
public Book createBook(Book newBook) {
// TODO: Insertar el libro `newBook` en la base de datos.
return newBook;
}
Para insertar elementos en una base de datos, tendremos que usar el método persist del repositorio. Este método tiene un parámetro correspondiente al objeto que queremos insertar en base de datos. El ORM detectará que el objeto que le hemos pasado como atributo es nuevo y lo traducirá a un INSERT para guardarlo en base de datos:
@POST
public Book createBook(Book newBook) {
this.repository.persist(newBook);
return newBook;
}
En la práctica, probablemente querramos validar el objeto antes de insertarlo, algo que veremos más tarde. Además, el código que he puesto antes no va a funcionar hasta que no le ponga la anotación @Transactional. Esta anotación es obligatoria de poner en endpoints que hagan modificaciones a base de datos, como una inserción, una modificación o un borrado. Con eso, se le pide a Quarkus que inicie una transacción SQL en el momento de empezar a tratar la petición HTTP. De este modo, si llegasen varias peticiones de forma simultánea, habría cierta consistencia eventual al impedir que se inserten, modifiquen o recuperen más datos si no es de uno en uno. Hibernate rechazará por defecto insertar registros si no está dentro de una transacción.
Ten en cuenta que la anotación @Transactional se puede poner sobre un método, o bien si vamos a tener varios, se puede poner directamente a nivel de clase para que afecte a todos los métodos declarados en la misma. El código final tiene el siguiente aspecto:
@Path("/books")
public Path BookResource {
private BookRepository repository;
@Inject
public BookResource(BookRepository repository) {
this.repository = repository;
}
@GET
public List<Book> listBooks() {
List<Book> books = repository.listAll();
return books;
}
@Transactional
@POST
public Book createBook(Book newBook) {
repository.persist(newBook);
return newBook;
}
}
Un último detalle que puedes apreciar si pruebas este endpoint es que el libro que se devuelve en la API cuando se inserta bien ya lleva un ID. Dicho de otro modo, si hiciese ahora una petición HTTP como ésta:
# POST /books
{
"title": "Java para principiantes",
"pubDate": "2022-12-21",
"numPages": 450,
"description": "Aprende todo sobre Java con este libro"
}
En la respuesta que me hace el endpoint me vendría un campo extra llamado id que encima tendrá valor:
{
"id": 1,
"title": "Java para principiantes",
"pubDate": "2022-12-21",
"numPages": 450,
"description": "Aprende todo sobre Java con este libro"
}
La razón por la que esto ocurre es porque persist(), a la que inserta un nuevo registro, recupera el ID que le ha asignado el motor de base de datos al registro que ha insertado y le asigna al campo declarado como @Id ese identificador, tal como contaba antes. Eso significa que antes de insertar una entidad, podemos verificar que el identificador es nulo, pero después, no:
Book newBook = createNewBook();
assertNull(newBook.getId()); // no debe tener ID antes.
repository.persist(newBook);
assertNotNull(newBook.getId()); // si se ha insertado bien, debe tener ID
Es importante tener en cuenta dos cosas:
- Una entidad después de persistirse siempre debería tener un ID. Si no la tiene, es que ha habido un error durante la inserción. De esto nos daríamos cuenta porque, por otra parte, es de esperar que se produzca una excepción si no se pudo hacer la inserción.
- Si pretendemos insertar un nuevo registro, es importante que el identificador valga nulo antes de llamar a persist(). De igual modo, esto es una simplificación. En realidad, Hibernate usa una serie de metadatos internos que va a vincular a cada una de las instancias de Book con las que trabaje nuestra aplicación, para saber si una instancia de una clase está administrada (managed) por Hibernate o si está desenganchada (detached). Si Hibernate detecta que lo que le intentamos persistir ya lleva un ID, pensará que se trata de una actualización, pero como esa entidad probablemente no esté administrada, lo tratará como un error y tampoco hará la inserción.