JPA con Spring Boot

Índice

En este apartado vamos a ver el funcionamiento de la parte ORM del framework llamado Spring Boot

Un ORM facilita el trabajo de creación de aplicaciones de base de datos ya que prácticamente automatiza la persistencia y consulta de datos basándose para ello en el mapeo de las entidades en objetos y viceversa.

Esqueleto

El primer paso es descargar el esqueleto de proyecto desde aquí. En el archivo pom.xml están definidas las siguientes dependencias:

  • spring-boot-starter-data-jpa define que utilizamos la capa JPA de Spring (Java Persistent API)
  • sqlite-jdbc define la base de datos SQLite
  • hibernate-community-dialects se define que vamos a usar dialectos JDBC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
		 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>3.0.5</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>org.ieselcaminas</groupId>
	<artifactId>jpa</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>jpa</name>
	<description>Proyecto demo para JPA</description>
	<properties>
		<java.version>17</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>
		<!-- https://mvnrepository.com/artifact/org.xerial/sqlite-jdbc -->
        <dependency>
            <groupId>org.xerial</groupId>
            <artifactId>sqlite-jdbc</artifactId>
            <version>3.45.1.0</version>
        </dependency>
		<!-- https://mvnrepository.com/artifact/org.hibernate.orm/hibernate-community-dialects -->
		<dependency>
			<groupId>org.hibernate.orm</groupId>
			<artifactId>hibernate-community-dialects</artifactId>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>

La clase java que tiene el método main es JpaApplication.java

image-20230419094909718

Que contiene el siguiente código. Es común para todas las aplicaciones

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package org.ieselcaminas.jpa;

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class JpaApplication implements CommandLineRunner {

	public static void main(String[] args) {
		SpringApplication.run(JpaApplication.class, args);
	}
	//En este método definimos nuestro propio código
	@Override
	public void run(String... args) {
		
	}
}

SpringApplication.run(JpaApplication.class, args); es quien inicia la aplicación SpringBoot

Propiedades

Debemos definir un archivo llamado application.properties dentro de la carpeta resources con el siguiente contenido:

1
2
3
spring.datasource.url=jdbc:sqlite:customers.sqlite // Importante. Se guarda en raíz del proyecto
spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect
spring.jpa.hibernate.ddl-auto=validate
  • spring.datasource.url=jdbc:sqlite:jdbc:sqlite:customers.sqlite define como base de datos customers.sqlite dentro del directorio raíz.
  • spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect y aquí se define que, de todos los dialectos SQL propietarios o no, se va a usar org.hibernate.community.dialect.SQLiteDialect
  • spring.jpa.hibernate.ddl-auto=validate Cuando la aplicación arranca, se comprueba si las entidades definidas coinciden con la las tablas de la base de datos. Si no es así, la aplicación no arranca hasta que se arregle.

Estructura de una aplicación de base de datos

Una aplicación web bien estructurada se divide en capas, cada una con responsabilidades claras:

1
Controller  →  Service  →  Repository  →  Base de datos
  • Controller (Controlador)

    • Recibe peticiones HTTP (GET, POST, etc.) del usuario o del frontend.

    • No contiene lógica de negocio complicada.

    • Llama al Service para realizar la acción.

  • Service (Servicio)

    • Contiene la lógica de negocio (reglas, validaciones, transacciones).

    • Decide cómo se usan los datos, qué crear, qué actualizar, etc.

    • Llama a los Repository para acceder a la base de datos.

  • Repository (Repositorio)

    • Interactúa con la base de datos usando JPA/Hibernate.

    • Solo hace consultas, inserciones, borrados.

  • Entity (Entidad)

    • Clase que mapea los registros de las tablas de la base de datos en objetos java y viceversa

Base de datos

Creamos la base de datos SQLite y creamos la siguiente tabla:

1
2
3
4
5
CREATE TABLE customer (
	id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
	first_name TEXT NOT NULL,
	last_name TEXT NOT NULL
);

Entidad

Primero hemos de definir las entidades como clases POJO (son colo las vistas en el modelo entidad-relación).

Es necesario que las columnas de la base de datos tengan el mismo nombre que los atributos de la clase. Antes de cada camelCase se introduce un _ y se pone en minúscula camel_case

Podéis instalar el plugin JPA Buddy, que nos facilita la creación de las clases POJO image-20260213082810591

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package org.ieselcaminas.jpa.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Customer {

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

  protected Customer() {}
  public Customer(String firstName, String lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }
  public Long getId() {
    return id;
  }
  public String getFirstName() {
    return firstName;
  }
  public void setFirstName(String firstName) {
    this.firstName = firstName;
  }
  public String getLastName() {
    return lastName;
  }

  public void setLastName(String lastName) {
    this.lastName = lastName;
  }

  @Override	
  public String toString() {
      return id + " - " + firstName + " - " + lastName;
  }

}

Vemos que esta clase se anota con @Entity para indicarle al framework que esta clase la debe tratar como una entidad que tiene persistencia en base de datos.

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

Estas anotaciones le indican a JPA que el atributo id es la clave primaria y que el valor se genera automáticamente. Después cada atributo de la entidad se mapea en un campo en la base de datos.

Repositorio

El siguiente paso es crear CustomerRepository:

1
2
3
4
5
6
7
8
package org.ieselcaminas.jpa.repository;

import org.ieselcaminas.jpa.entity.Customer;
import org.springframework.data.repository.CrudRepository;

public interface CustomerRepository extends CrudRepository<Customer, Long> {

}

Extiende CrudRepository para poder realizar las operaciones CRUD <Customer, Long>. Long es el tipo de la clave primaria de la entidad Customer.

De momento no nos hace falta definir métodos de acceso a datos porque CrudRepository ya provee findById que selecciona un elemento a partir su id y findAll que devuelve una lista de todos los elementos.

Vamos a probar el repositorio creando algunos Customer. Primero hemos de crear un constructor para que Spring cree la clase customerRepository por nosotros. Esto se denomina Dependency Injection

1
2
3
4
5
private final CustomerRepository customerRepository;

public JpaApplication(CustomerRepository customerRepository){
    this.customerRepository = customerRepository;
}

Este repositorio permite realizar las operaciones CRUD, así como muchas operaciones de consulta predefinidas.

image-20260213083554271

Y ahora ya podemos crear algún Customer y mostrar todos:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@Override
@Transactional // IMPORTANTE - Para no dar errores de sesiones.
public void run(String... args) {
    Customer c = new Customer("Pepe", "García");
    //El repositorio es donde están todos los métodos que tratan con la base de datos.
    //En este caso está haciendo un INSERT ya que el objeto es nuevo
    this.customerRepository.save(c);
    
    //Pero también puedo modificar un registro
    c.setFirstName("Juan");
	this.customerRepository.save(c);
    
    //Vamos a seleccionar el Customer con id 1
    //Se escribe 1L porque es un dato escrito a mano de tipo long
    Optional<Customer> clienteOp = this.customerRepository.findById(1L);
    clienteOp.ifPresent(System.out::println);
    
    //Si queremos acceder al objeto Customer
    if (clienteOp.isPresent()){
        c = clienteOp.get();
        System.out.println(c);
    }

    //El método findAll devuelve todos los registros de la entidad asociada
    this.customerRepository.findAll().forEach(System.out::println);

    //En este código estamos guardando los datos en un Iterable que es lo que devuelven los métodos findAll
    Iterable<Customer> l = this.customerRepository.findAll();
    for (Customer customer : l) {
        System.out.println(customer);
    }
    
    //Cuidado que si no existe el registro con ID 5, saltará una excepción
    try {
        c = this.customerRepository.findById(5L).get();
        this.customerRepository.delete(c);
        System.out.println(c);
    } catch (NoSuchElementException e) {
        System.out.println(" No existe el elemento con ID 5");
    }
    
    // Vamos a hacer una búsqueda por FirstName que  nos devuelva un lista de clientes que tengan ese apellido
    clientes = this.customerRepository.findCustomerByFirstName("Pepe");
    clientes.forEach(System.out::println);
    // Que nos devuelve sólo uno
	c = this.customerRepository.findFirstCustomerByFirstName("Pepe");
	/**
		También se puede usar findOneCustomerByFirstName, pero si hay más de un usuario, saltará una excepción
	*/
}

Tal vez os aparezca findCustomerByFirstName en rojo. Si es así, con el botón secundario del ratón, seleccionad la opciónShow Context Actions

image-20260213085733671

Ahora seleccionad la opción Create repository method 'findCustomerByFirstName' in 'CustomerRepository'

image-20260213085516707

Esto os debe haber creado el método en el repositorio:

1
2
3
4
5
6
7
8
9
10
11
package org.ieselcaminas.jpa.repository;

import org.ieselcaminas.jpa.entity.Customer;
import org.springframework.data.repository.CrudRepository;

import java.util.List;

public interface CustomerRepository extends CrudRepository<Customer, Long> {
	// Como es una interface, sólo se especifica la signatura
    List<Customer> findCustomerByFirstName(String firstName);
}

Construcción de Consultas

En el repositorio se definen todas aquellas consultas que sean necesarias. Hay algunas consultas que ya están hechas. Las puedes ver en org.springframework.data.repository.CrudRepository.

En general, no hace falta escribir el cuerpo de las consultas SQL ya que lo hace Spring Boot automáticamente por nosotros a partir del nombre del método y los parámetros. Por ejemplo:

1
2
3
// En CustomerRepository
public List<Customer> findCustomersByFirstName(String firstName);
public Customer findCustomerByFirstNameAndLastName(String firstName, String lastName)

Si creamos el método findCustomersByFirstName(String firstName) devolvería aquellos con dicho firstName y findCustomerByFirstNameAndLastName que coincida el firstName y el lastName.

Fijaos en la diferencia:

  • Si empieza por findCustomerBy devuelve un único Customer
  • Si empieza por findCustomersBy (con s) devuelve una lista de Customer

Tienes el listado completo de Querys aquí.

Si la consulta que queremos hacer no se puede crear con el asistente, siempre se puede escribir a mano el SQL de la misma.

1
2
@Query("SELECT COUNT(c) FROM Customer c")
public int countAllRecords();

Notes

Vamos a implementar la funcionalidad de poder crear notas que se adjuntan a un cliente. Es decir, vamos a crear una relación 1:N

Primero creamos la tabla note:

1
2
3
4
5
6
CREATE TABLE note (
	id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
	"text" TEXT NOT NULL,
	id_customer INTEGER,
	CONSTRAINT note_customer_FK FOREIGN KEY (id_customer) REFERENCES customer(id)
);

Ahora creamos la entidad Note:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package org.ieselcaminas.jpa;

import jakarta.persistence.*;

@Entity
public class Note {

    @Id
    @GeneratedValue(strategy=GenerationType.IDENTITY)
    private Long id;
    private String text;
    
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name="id_customer")
    private Customer customer;

    protected Note() {}
    
    public Note(String text, Customer customer) {
        this.text = text;
        this.customer = customer;
    }
    
    public Long getId() {
        return id;
    }
    public String getText() {
        return text;
    }

    public Customer getCustomer() {
        return customer;
    }

    @Override
    public String toString() {
        return text;
    }

}

Mediante …

1
2
3
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name="customer_id")
private Customer customer;

.. estamos creando un relación N:1, la columna se llamará customer_id y la entidad relacionada es Customer

Y ahora vamos a ver la parte 1, es decir, Customer

1
2
@OneToMany(mappedBy = "customer",  fetch=FetchType.EAGER)
private List<Note> notes = new ArrayList<>;

En este caso le indicamos mappedBy = "customer" donde customer es el nombre del campo en la relación N

Ahora creamos los setters.

1
2
3
4
5
6
7
public List<Note> getNotes(){
    return this.notes;
}

public void addNote(Note note){
    this.notes.add(note);
}

Y generamos el repositorio para Note

1
2
3
4
5
6
7
8
package org.ieselcaminas.jpa.repository;

import org.ieselcaminas.jpa.entity.Note;
import org.springframework.data.repository.CrudRepository;

public interface NoteRepository extends CrudRepository<Note, Long> {

}

Y lo inyectamos en el constructor:

1
2
3
4
5
private final NoteRepository noteRepository;
public JpaApplication(CustomerRepository customerRepository, NoteRepository noteRepository){
	this.customerRepository = customerRepository;
	this.noteRepository = noteRepository;
}

Y ya podemos crear y consultar notas:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
// Nos evitamos conflictos al trabajar en una única transacción
@Transactional
public void run(String... args) {
    //Creamos una nota
    // 1º el cliente
    Customer c = customerRepository.findById(1L).get();
    // Y ahora la nota
    Note n = new Note("Primera nota", c);
    noteRepository.save(n);

    Customer c2 = customerRepository.findById(2L).get();
    n = new Note("Primera nota María", c2);
    noteRepository.save(n);

    customerRepository.findCustomersByFirstName("Pepe").forEach(System.out::println);
}

Servicios

Un Service es una clase que:

  • Recibe peticiones del Controller

  • Aplica lógica (validaciones, reglas, cálculos…)

  • Usa los Repository para acceder a la base de datos

  • Devuelve el resultado

👉 Es el punto intermedio entre la web (o interfaz de comandos, o interfaz gráfica) y la base de datos.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package org.ieselcaminas.jpa.service;

import org.ieselcaminas.jpa.entity.Customer;
import org.ieselcaminas.jpa.entity.Note;
import org.ieselcaminas.jpa.repository.CustomerRepository;
import org.ieselcaminas.jpa.repository.NoteRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;

@Service
@Transactional
public class CustomerService {

    private final CustomerRepository customerRepository;
    private final NoteRepository noteRepository;

    public CustomerService(CustomerRepository customerRepository,
                           NoteRepository noteRepository) {
        this.customerRepository = customerRepository;
        this.noteRepository = noteRepository;
    }

    // Crear cliente
    public Customer createCustomer(String firstName, String lastName) {
        return customerRepository.save(new Customer(firstName, lastName));
    }

    // Añadir nota a un cliente
    public Note addNote(Long customerId, String content) {
        Optional<Customer> customer = customerRepository.findById(customerId); // clave
        Customer c = customer.orElseThrow();
        return new Note(content, c);

    }

    // Obtener notas de un cliente
    public List<Note> getNotes(Long customerId) {
        return noteRepository.getNotesByCustomerId(customerId);
    }


    // Obtener cliente
    public Customer getCustomer(Long id) {
        return customerRepository.findById(id).orElseThrow();
    }
}

Controladores

Además de los repositorios, es común crearse un controlador que haga de intermediario entre la base de datos y la entidad.

En el siguiente ejemplo tenemos un método en el controlador de Customer que permite la creación de un Customer desde la entrada estándar.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package org.ieselcaminas.jpa.controller;

import org.ieselcaminas.jpa.entity.Customer;
import org.ieselcaminas.jpa.service.CustomerService;
import org.springframework.stereotype.Controller;
import java.util.Scanner;

@Controller
public class CustomerController {
    
    private final CustomerService customerService;
    public CustomerController(CustomerService customerService){
        this.customerService = customerService;ç
    }
    
    public void createCustomer(){
        String firstName, lastName;
        Scanner sc = new Scanner(System.in);

        System.out.println("Enter customer's first name:");
        firstName = sc.nextLine();
        
        System.out.println("Enter customer's last name:");
        lastName = sc.nextLine();
        this.customerService.createCustomer(firstName, lastName);
    }
}

Y lo usamos en el programa principal JpaApplication:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
......
private final CustomerController customerController;
public JpaApplication(CustomerRepository customerRepository,
                      NoteRepository noteRepository, CustomerController customerController){
    this.customerRepository = customerRepository;
    this.noteRepository = noteRepository;
    this.customerController = customerController;
}
.....
@Override
@Transactional
public void run(String... args) {
    this.customerController.createCustomer();
    this.customerRepository.findAll().forEach(System.out::println);
}

Banco

Vamos a crear una relación n:m , como la que vimos en un apartado anterior, entre cliente y cuenta corriente

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CREATE TABLE clientes (
                          id INTEGER PRIMARY KEY AUTOINCREMENT,
                          nombre TEXT NOT NULL
);

CREATE TABLE cuentas_corrientes (
                                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                                    nombre TEXT NOT NULL,
    								 cantidad REAL
);

CREATE TABLE cliente_cuenta (
                                cliente_id INTEGER NOT NULL,
                                cuenta_id INTEGER NOT NULL,
                                PRIMARY KEY (cliente_id, cuenta_id),
                                FOREIGN KEY (cliente_id) REFERENCES clientes(id) ON DELETE CASCADE,
                                FOREIGN KEY (cuenta_id) REFERENCES cuentas_corrientes(id) ON DELETE CASCADE
);

Y application.properties

1
2
3
spring.datasource.url=jdbc:sqlite:banco.sqlite
spring.datasource.driver-class-name=
spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect

La entidad Cliente

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package org.ieselcaminas.jpa.entity;

import jakarta.persistence.*;

@Entity
// Se le puede indicar que el nombre de la tabla no es "cliente", nombre por defecto que es // igual al nombre la entidad (Cliente), sino "clientes"
@Table(name = "clientes")
public class Cliente {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String nombre;

    // Siempre un constructor por defecto
    public Cliente() {
    }
    public Cliente(String nombre) {
        this.nombre = nombre;
    }

    // Getters y Setters
    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;
    }

    @Override
    public String toString(){
        return this.nombre;
    }
}

Y la entidad CuentaCorriente

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
package org.ieselcaminas.jpa.entity;

import jakarta.persistence.*;
import org.ieselcaminas.jpa.Cliente;
import java.util.HashSet;
import java.util.Set;

@Entity
// Se le puede indicar que el nombre de la tabla no es "cuenta_corriente", nombre por 
// defecto que es igual al nombre la entidad (CuentaCorriente), sino "cuentas_corrientes"
@Table(name = "cuentas_corrientes")
public class CuentaCorriente {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String nombre;

    @Column(nullable = false)
    private double cantidad;

    //Siempre debe tener un constructor vacío
    public CuentaCorriente() {
    }

    //Se pueden hacer tantos constructores como deseemos
    public CuentaCorriente(String nombre) {
        this.nombre = nombre;
    }

    // Getters y Setters
    public Long getId() {
        return id;
    }

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

    //Le decimos que, en la entidad Cliente, las cuentas se almacenan en el atributo con nombre cuentas
    @ManyToMany(mappedBy = "cuentas", fetch = FetchType.EAGER)
    //Hay que inicializar la lista siempre
    private Set<Cliente> clientes = new HashSet<>();

    public Set<Cliente> getClientes() {
        return this.clientes;
    }

    public void setClientes(Set<Cliente> clientes) {
        this.clientes = clientes;
    }

    // Se puede crear un método para añadir la entidad relacionada a la lista.
    // En otro caso, habría que usar cuenta.getClientes().add(cliente)
    public void addCliente(Cliente cliente){
        this.clientes.add(cliente);
    }
    public String getNombre() {
        return nombre;
    }

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

    public double getCantidad() {
        return cantidad;
    }

    public void setCantidad(double cantidad) {
        this.cantidad = cantidad;
    }

    // Otros setters
    public void ingresar(double cantidad){
        if (cantidad > 0 )
            this.cantidad += cantidad;
    }

    public void retirar(double cantidad){
        if (cantidad > 0 )
            this.cantidad -= cantidad;
    }

    @Override
    public String toString(){
        return this.nombre + " - " + this.cantidad;
    }
}

Y ahora ya podemos crear la relación n:m:

En la entidad Cliente, añadimos la relación :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
    /**
     * IMPORTANTE fetch = FetchType.EAGER. En caso contrario es FetchType.LAZY y da 
     * error porque no se inicializa la lista de cuentas
     */
    @ManyToMany(fetch = FetchType.EAGER)
    /*
       En este caso le decimos:
       - que la tabla se llama cliente_cuenta
       - que mi columna id se llama en esa tabla cliente_id
       - que la columna de la cuenta se llama cuenta_id en dicha tabla
     */
    @JoinTable(
            name = "cliente_cuenta",
            joinColumns = @JoinColumn(name = "cliente_id"),
            inverseJoinColumns = @JoinColumn(name = "cuenta_id")

    )
    // `cuentas` es el nombre que tiene el atributo `mappedBy` de
    // la relación `ManyToMany` en `CuentaCorriente`
    // Inicializar siempre el Set
    private Set<CuentaCorriente> cuentas = new HashSet<>();

    public Set<CuentaCorriente> getCuentas() {
        return this.cuentas;
    }

    public void setCuentas(Set<CuentaCorriente> cuentas) {
        this.cuentas = cuentas;
    }

    // Se puede crear un método para añadir la entidad relacionada a la lista.
    // En otro caso, habría que usar cliente.getCuentas().add(cuenta)
    public void addCuenta(CuentaCorriente cuenta) {
        this.cuentas.add(cuenta);
    }

Y en la entidad CuentaCorriente

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    // Le decimos que, en la entidad Cliente, las cuentas se almacenan en el atributo con nombre cuentas
    @ManyToMany(mappedBy = "cuentas", fetch = FetchType.EAGER)
    // Hay que inicializar la lista siempre
    private Set<Cliente> clientes = new HashSet<>();

    public Set<Cliente> getClientes() {
        return this.clientes;
    }

    public void setClientes(Set<Cliente> clientes) {
        this.clientes = clientes;
    }

    // Se puede crear un método para añadir la entidad relacionada a la lista.
    // En otro caso, habría que usar cuenta.getClientes().add(cliente)
    public void addCliente(Cliente cliente){
        this.clientes.add(cliente);
    }

Los repositorios

1
2
3
4
5
6
package org.ieselcaminas.jpa.repository;

import org.ieselcaminas.jpa.entity.Cliente;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ClienteRepository extends JpaRepository<Cliente, Long> {}

y

1
2
3
4
5
6
package org.ieselcaminas.jpa.repository;

import org.ieselcaminas.jpa.entity.CuentaCorriente;
import org.springframework.data.jpa.repository.JpaRepository;

public interface CuentaCorrienteRepository extends JpaRepository<CuentaCorriente, Long> {}

Y ahora ya podemos probarlo.

Vamos a hacerlo mediante un Servicio. En un servicio vamos creando los métodos relacionados con una entidad y que no deban estar en el repositorio.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package org.ieselcaminas.jpa.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

/**
 * Se pueden hacer tantos servicios como deseemos, pero siempre hemos de crear métodos relacionados
 * en ellos.
 */
@Service
public class ClienteService {
    /*
    La anotación @Autowired indica a Spring que nos "auto inyecte" esta clase, en este caso ClienteRepository y
    CuentaCorrienteRepository.
    De esta forma ya no es necesario inyectarlas en el constructor
     */
    @Autowired
    private ClienteRepository clienteRepository;
    
    @Autowired
    private CuentaCorrienteRepository cuentaCorrienteRepository;

    public void crearClienteConCuenta(String nombreCliente, String nombreCuenta) {
        Cliente cliente = new Cliente(nombreCliente);
        CuentaCorriente cuenta = new CuentaCorriente();
        cuenta.setNombre(nombreCuenta);
        cuenta.addCliente(cliente);
        cliente.addCuenta(cuenta);
        this.clienteRepository.save(cliente);
        this.cuentaCorrienteRepository.save(cuenta);
    }
}

Y ahora en main lo probamos

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package org.ieselcaminas.jpa;

import org.ieselcaminas.jpa.service.ClienteService;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class JpaApplication implements CommandLineRunner {
    private final ClienteService clienteService;
    public JpaApplication(ClienteService clienteService) {
        this.clienteService = clienteService;
    }

    public static void main(String[] args) {
        SpringApplication.run(JpaApplication.class, args);
    }

    //En este método definimos nuestro propio código
    @Override
	@Transactional
    public void run(String... args) {
        this.clienteService.crearClienteConCuenta("Mengano", "Cmengano");
    }
}

Vamos a crear otro método que permita asociar una cuenta con varios clientes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void crearCuentaConClientes(String nombreCuenta) {
    CuentaCorriente cuenta = new CuentaCorriente();
    cuenta.setNombre(nombreCuenta);
    cuenta.setCantidad(100);

    Cliente cliente1 = new Cliente("Cliente 1");
    Cliente cliente2 = new Cliente("Cliente 2");

    cliente1.getCuentas().add(cuenta); // o cliente1.addCuenta(cuenta);
    cuenta.getClientes().add(cliente1); // o cuenta.addCliente(cliente);

    cliente2.getCuentas().add(cuenta);
    cuenta.getClientes().add(cliente2);

    this.cuentaCorrienteRepository.save(cuenta);
    this.clienteRepository.save(cliente1);
    this.clienteRepository.save(cliente2);
}

y en main

1
2
3
4
5
@Override
@Transactional
public void run(String... args) { 
    this.clienteService.crearCuentaConClientes("CuentaConMásDeUnCliente");
}

Vamos a usar el asistente para creación de relaciones que trae incorporado JPA Buddy, suponiendo que ya habéis creado las tablas.

  1. Abre la entidad Cliente En la parte superior de la ventana aparece una barra de botones: image-20260225085504526 Haz clic en el tercero por la izquierda y elige la opción Association

    Ahora elige las opciones que se muestran a continuación image-20260225085653912 Pulsa el botón OK . Aparecerá otra ventana para las opciones del atributo inverso: image-20260225085837719 Vuelve a pulsar OK

Como resultado te debe haber generado el código para las dos relaciones.

Tal vez quieras, hacer addCuenta() en Cliente y addCliente en Cuenta

Resumen

Los pasos para crear una aplicación con Spring boot son:

  • definir las dependencias en el archivo pom.xml
  • crear las entidades donde se mapean los campos de la tabla con los atributos de la clase POJO
  • crear los repositorios. Uno para cada entidad. Se puede ir definiendo los métodos conforme nos vayan haciendo falta.
  • crear los controladores, donde crearemos métodos que usarán las entidades y los repositorios.
  • crear la aplicación, según el ejemplo.

Creación de una mini web.

Vamos a crear una web a partir de la bd de Customer. Va a ser muy sencilla: mostrará los nombres de los clientes y, a continuación, la lista de las notas.

Primero, vamos a añadir las dependencias para desarrollar webs con Spring:

1
2
3
4
5
6
7
8
9
10
<!-- Añade la funcionalidad de servidor de aplicaciones -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Añade el motor de plantillas html `thymeleaf` -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

Para ejecutar el servidor Web hay que lanzar el siguiente comando en la terminal ./mvnw spring-boot:run o hacer clic en el botón verde de flecha.

image-20260225124141730

Y arrancará el el puerto 8080, por defecto

image-20260225124340610

Cada vez que modifiquemos algo del proyecto, hay que parar y volver a arrancar

Aparecerá un mensaje de error, pero es normal, ya que no hemos creado todavía ninguna ruta en un controlador. Para crear un nuevo controlador para web:

image-20260225130954399

Y luego, elegimos Controller

image-20260225131306282

Cuidado que hay que hacer pública la clase

En CustomerController creamos la siguiente ruta:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package org.ieselcaminas.jpa.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/")
public class CustomerWebController {
    @GetMapping
    public String inicio(Model model){
        return "index";
    }
}
  • @GetMapping("/") le indica que este método se encarga de la ruta /, es decir, la raíz de la web
  • index es el nombre de la plantilla que va a mostrar. Usamos el motor de plantillas Thymeleaf

Ahora, creamos la plantilla resources/templates/index.html (el nombre de la plantilla debe coincidir con la cadena devuelta por el controlador, en este caso index) con el siguiente código:

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>Web de clientes</h1>
</body>
</html>

Reinicia el servidor y debe aparecer la siguiente ventana:

image-20260225125650432

Si es así, es que todo ha ido bien.

Listar clientes

Vamos a mostrar los clientes en la web. El proceso consta de las siguientes partes:

  • Obtener los datos de todos los clientes

  • Pasárselos a la plantilla index
  • Hacer un foreach en la plantilla para recorrer todos los clientes.

Para obtener todos los clientes, usaremos el método findAll que ya está implementado en el repositorio

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package org.ieselcaminas.jpa.controller;

import org.ieselcaminas.jpa.repository.CustomerRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/") // La ruta a la página de inicio
public class CustomerController {
    
    // Dependencias
    private final CustomerRepository customerRepository;
    CustomerController(CustomerRepository customerRepository) {
        this.customerRepository = customerRepository;
    }

    @GetMapping // Ruta por defecto del Controlador, es decir, `/`
    public String inicio(Model model){
        model.addAttribute("customers", customerRepository.findAll());
        return "index";
    }
}

El Model es la forma que tenemos para pasar datos a la plantilla html.

model.addAttribute("customers", customerRepository.findAll()); indica que la variable customers va a estar disponible en la plantilla index

Ahora vamos a pintarlos en la plantilla.

🧾 ¿Qué es una plantilla?

Es un archivo HTML que usa Thymeleaf, un motor de plantillas muy usado con Spring Boot.

👉 Sirve para mezclar HTML + datos Java que vienen del servidor.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>Web de clientes</h1>
<ul>
    <li th:each="customer : ${customers}"
        th:text="${customer.firstName + ' ' + customer.lastName}">
        Cliente de ejemplo
    </li>
</ul>
</body>
</html> 	

Estructura básica

1
2
<!DOCTYPE html>
<html lang="en">

Esto es HTML normal. Define el documento.


Título de la página

1
<h1>Web de clientes</h1>

Texto fijo. No depende de Java.


Bucle con Thymeleaf

1
2
<ul>
    <li th:each="customer : ${customers}"

Aquí empieza lo interesante:

🔹 th:each

Es como un for en Java.

👉 Significa:

“Para cada customer dentro de la lista customers


🔹 ${customers}

Es una variable que viene del controlador Java:

1
model.addAttribute("customers", lista);

👉 Es decir:

  • customers = lista de objetos Customer

Mostrar datos

1
th:text="${customer.firstName + ' ' + customer.lastName}"

Esto hace:

👉 Sustituye el contenido del <li> por:

1
Nombre Apellido

Ejemplo real:

1
2
Juan Pérez
Ana García

🔹 ¿Qué significa exactamente?

  • customer.firstName → nombre
  • customer.lastName → apellido
  • + ' ' → añade un espacio

👉 Es como en Java:

1
customer.getFirstName() + " " + customer.getLastName()

Resultado final en el navegador

Si tienes 3 clientes, el HTML generado será:

1
2
3
4
5
<ul>
    <li>Juan Pérez</li>
    <li>Ana García</li>
    <li>Luis López</li>
</ul>

Texto por defecto

1
Cliente de ejemplo

Esto sirve para:

👉 Ver algo en el HTML si Thymeleaf no se ejecuta 👉 Es reemplazado automáticamente cuando la app corre

Notas

Ahora vamos a mostrar las notas de los clientes.

Objetivo

Mostrar:

  • Cliente (nombre + apellido)
  • Debajo, su lista de notas

Plantilla modificada

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Clientes</title>
</head>
<body>
<h1>Web de clientes</h1>
<ul>
    <li th:each="customer : ${customers}">
        <!-- Nombre del cliente -->
        <span th:text="${customer.firstName + ' ' + customer.lastName}"></span>
        <!-- Lista de notas. Otro for por cada nota del cliente -->
        <ul>
            <li th:each="note : ${customer.notes}"
                th:text="${note.text}">
                Nota de ejemplo
            </li>
        </ul>
    </li>
</ul>
</body>
</html>

Explicación

🔁 Bucle exterior
1
<li th:each="customer : ${customers}">

👉 Recorre todos los clientes


🔁 Bucle interior
1
<li th:each="note : ${customer.notes}">

👉 Para cada cliente, recorre sus notas

Esto es como en Java:

1
2
3
4
5
for (Customer c : customers) {
    for (Note n : c.getNoteList()) {
        // mostrar nota
    }
}
Ejemplo de resultado
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<ul>
    <li>
        <!-- Nombre del cliente -->
        <span>Pepe Navarro</span>
        <!-- Lista de notas -->
        <ul>
            <li>Llamar mañana</li>
            <li>Enviar correo</li>
        </ul>
    </li>
    <li>
        <!-- Nombre del cliente -->
        <span>María García</span>
        <!-- Lista de notas -->
        <ul>
            <li>Pedir cita</li>
        </ul>
    </li>
</ul>

Y este es el resultado:

image-20260226085347990

Cómo servir archivos státicos

Es habitual en las páginas web mostrar archivos estáticos tal como imágenes, hojas de estilo, scripts …

En desarrollo, lo habitual es tener configurado un directorio para estas imágenes. Vamos a suponer que este directorio es /home-tu-nombre-de-usuario/spring/uploads/img.

El primer paso es añadir una propiedad en el archivo application.properties

1
upload.img.path=/home/tu-nombre-de-usuario/spring/uploads/img/

Y ahora configurar el spring para que sirva los archivos que residan en ese directorio. Este clase se puede poner al mismo nivel que JpaApplication

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package org.ieselcaminas.jpa;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {
	// Este valor es el mismo que hemos configurado en application.properties
    @Value("${upload.img.path}")
    private String uploadImgPath;

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/uploads/img/**") // Lo servimos en la ruta /uploads/img/
                .addResourceLocations("file:" + uploadImgPath);
    }
}

Y ya podemos tener imágenes guardadas en dicho directorio y servirlas como recurso web en /uploads/img/

Para usarlo con ThymeLeaf, suponiendo que el ` Customer tiene un campo image`

1
<img th:src="@{/uploads/img/{img}(img=${customer.image})}" />