Skip to content

11. Hibernate

Prerequisites: Version 7 of the [Tax] application, presented at the end of this chapter, reuses the [tax-dao], [tax-business], and [tax-ui] modules from Version 5 (Chapter 6). Before building or opening your projects, you must have installed version 5 in the local Maven repository using the command [mvn install] entered in the [06-architectures/TaxV5] folder (see Chapter 6, section on [mvn install]).

The previous chapter demonstrated how a Java application communicates with a database through a JDBC driver: you open a connection, issue SQL commands, read the result rows column by column, and copy them into objects. This process of copying—from rows to objects and from objects to the [INSERT], [UPDATE], and [DELETE] commands—is repetitive. It can be automated using a tool: an “Object-Relational Mapper” (ORM). This chapter introduces the most widely used ORM in the Java world, Hibernate, and the standard it implements, Jakarta Persistence.

The chapter revisits the sample database from the articles in Chapter 10 and concludes with version 7 of the tax calculation application, whose ORM layer uses Hibernate.

11.1. What is a ORM?

11.1.1. Objects and Tables

A Java application works with objects: an item has a name, a price, and a stock level; an order has a customer and a list of line items, each of which refers to an item. A relational database stores this same information in tables: one row in the [articles] table per item, one row in the [orders] table per order, with the relationships between rows expressed by foreign keys. The two worlds do not quite align:

  • an object directly references other objects ([order.getClient()]); a row contains the primary key value of another row ([client_id]);
  • an object can contain a collection ([order.getLines()]); a table contains only single values; the collection is represented by the rows of another table that point to the order;
  • the Java types ([String], [BigDecimal], [LocalDate], [boolean]) and the SQL types ([varchar(30)], [decimal(10,2)], [date], [bit]) are not the same;
  • objects are queried using Java (and the API stream), while tables are queried using SQL.

A ORM bridges the two worlds. We define the correspondence between classes and tables (the model, or “mapping”) once and for all; it then takes care of:

  • translating queries written in a language that refers to objects (HQL) or constructed by Java code (the API Criteria);
  • to create objects from the read lines (this is called materialization);
  • to store the read objects and detect those that have been modified, added, or deleted;
  • generate the [INSERT], [UPDATE], and [DELETE] commands that reflect these changes in the database;
  • and, if necessary, to create and update the database schema (tables, columns, indexes) based on the classes.

The architecture of an application that uses Hibernate with MySQL is as follows:

Image

  • [1]: the application. Its [dao] layer no longer communicates directly with the JDBC driver but with Hibernate, through a [Session] object and HQL queries;
  • [2]: Hibernate, independent of DBMS: model, query translation, change tracking, schema generation;
  • [3]: the dialect specific to DBMS (here, [MySQLDialect]): it knows the SQL syntax of DBMS, its column types, and its functions. The dialects of the main DBMSs are part of Hibernate itself: nothing needs to be added to the project;
  • [4]: the JDBC driver for DBMS, the one from Chapter 10 (MySQL Connector/J): Hibernate does not replace JDBC; it uses it;
  • [5]: the DBMS.

11.1.2. Jakarta Persistence and Hibernate

Hibernate was created in 2001 by Gavin King in response to the cumbersomeness of the “entity beans” in the Java EE platform of the time. Its success was such that its concepts were incorporated into a standard, JPA (Java Persistence API), released in 2006 with Java EE 5. When Java EE was transferred to the Eclipse Foundation and renamed Jakarta EE, JPA became Jakarta Persistence, and its packages changed from [javax.persistence] to [jakarta.persistence] (version 3.0, 2020). The current version is Jakarta Persistence 3.2 (2024).

Jakarta Persistence is a specification: a set of interfaces ([EntityManagerFactory], [EntityManager]...), annotations ([@Entity], [@Id], [@Column]...), and a query language, JPQL. It is implemented by several products: Hibernate ORM (the most widely used, used in Spring Boot and Quarkus), EclipseLink (the reference implementation)... This course uses Hibernate ORM 7.4.5, which implements Jakarta Persistence 3.2.

Hibernate offers two ways to use it:

  • exclusively through the standard interfaces: in this case, the code depends only on [jakarta.persistence], and it would, in principle, be possible to switch implementations;
  • through its own, more feature-rich “native” API, whose interfaces extend those of the standard: a [SessionFactory] is a [EntityManagerFactory], and a [Session] is a [EntityManager].

We use Hibernate's own API, as recommended in its documentation: It provides all the standard methods ([persist], [find], [merge], [remove]...) as well as Hibernate’s extensions. The annotations, however, are always those defined by the standard. The following table shows the correspondences:

Jakarta Persistence
Hibernate
Role
[EntityManagerFactory]
[SessionFactory]
Hibernate configuration for a database: a heavy object, created once, shared by the entire application
[EntityManager]
[Session]
A database session: a lightweight, short-lived object
[EntityTransaction]
[Transaction]
a transaction
JPQL
HQL
the query language; HQL is a superset of JPQL
[CriteriaBuilder]
[HibernateCriteriaBuilder]
Query construction using Java code

11.1.3. Libraries

The libraries used in this chapter are as follows (they are summarized in the appendix on Maven dependencies):

Maven Dependency
Role
[org.hibernate.orm:hibernate-core] 7.4.5.Final
Hibernate itself; it includes the API from Jakarta Persistence 3.2 ([jakarta.persistence-api]) and its own dependencies (Byte Buddy for class generation, ANTLR for query analysis, JBoss Logging for logging, etc.)
[com.mysql:mysql-connector-j] 9.7.0
the JDBC driver for MySQL, the one from Chapter 10
[ch.qos.logback:logback-classic] 1.5.38
Logback, which will receive the Hibernate log

Hibernate writes its log (executed SQL commands, warnings, etc.) through JBoss Logging, a small library that simply forwards messages to the logging system it finds in the classpath: Log4j 2, or Logback (via SLF4J), or, failing that, the one from JDK or [java.util.logging]. Without configuration, the latter displays each message on two lines, in red in most consoles, which makes the program output difficult to read. We therefore add Logback to our projects and configure it using a [logback.xml] file.

11.1.4. Code First or Database First

The model (the mapping between classes and tables) can be built in two ways:

  • code-first: we write the Java classes, and Hibernate derives the database schema from them, which it can create on its own. This is the approach taken in this chapter;
  • database-first: the database already exists, and you generate the classes from its schema using Hibernate’s reverse-engineering tools (Hibernate Tools) or those of development environments (IntelliJ IDEA can generate entities from database tables). This is the approach for applications that need to work with an existing database. The generated code is similar to what we would write by hand.

11.2. A First Example

11.2.1. The project

The examples in this chapter are modules of a multi-module Maven project (see Chapter 6), located in the [11-hibernate] folder:

11-hibernate/
  pom.xml                 the parent project
  tools/                  library: the compact SQL log
  articles/               library: the Article entity and the [hbarticles] database
  first/                  a first example
  model/                  the model: annotations, XML file, creation script
  crud/                   persist, find, merge, remove
  hql/                    HQL queries
  criteria/               Criteria API queries
  relations/              relationships between entities
  clients/                the evolution of the database schema
  async/                  Hibernate and virtual threads
  transactions/           transactions
  concurrency/            optimistic concurrency
  rawsql/                 native SQL
  bulkupdate/             bulk updates
  injection/              Hibernate and Spring
  TaxV7/                  the tax calculation application, version 7 (separate project)

The parent project sets the versions of the libraries and Maven extensions for all modules:

<?xml version="1.0" encoding="UTF-8"?>
<!-- the examples of chapter 11: Hibernate; one Maven module per example -->
<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>

    <groupId>examples.hibernate</groupId>
    <artifactId>hibernate</artifactId>
    <version>1.0</version>
    <packaging>pom</packaging>

    <!-- the modules, in the order of their dependencies -->
    <modules>
        <module>tools</module>
        <module>articles</module>
        <module>first</module>
        <module>model</module>
        <module>crud</module>
        <module>hql</module>
        <module>criteria</module>
        <module>relations</module>
        <module>clients</module>
        <module>async</module>
        <module>transactions</module>
        <module>concurrency</module>
        <module>rawsql</module>
        <module>bulkupdate</module>
        <module>injection</module>
    </modules>

    <properties>
        <maven.compiler.release>25</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <!-- the versions of the libraries, common to all the modules -->
    <dependencyManagement>
        <dependencies>
            <!-- the two common libraries of the chapter -->
            <dependency>
                <groupId>examples.hibernate</groupId>
                <artifactId>tools</artifactId>
                <version>1.0</version>
            </dependency>
            <dependency>
                <groupId>examples.hibernate</groupId>
                <artifactId>articles</artifactId>
                <version>1.0</version>
            </dependency>
            <!-- Hibernate ORM (Jakarta Persistence 3.2) -->
            <dependency>
                <groupId>org.hibernate.orm</groupId>
                <artifactId>hibernate-core</artifactId>
                <version>7.4.5.Final</version>
            </dependency>
            <!-- the MySQL JDBC driver -->
            <dependency>
                <groupId>com.mysql</groupId>
                <artifactId>mysql-connector-j</artifactId>
                <version>9.7.0</version>
            </dependency>
            <!-- logging: Logback (through SLF4J) -->
            <dependency>
                <groupId>ch.qos.logback</groupId>
                <artifactId>logback-classic</artifactId>
                <version>1.5.38</version>
            </dependency>
            <!-- Spring Framework: dependency injection and transactions -->
            <dependency>
                <groupId>org.springframework</groupId>
                <artifactId>spring-context</artifactId>
                <version>7.0.9</version>
            </dependency>
            <dependency>
                <groupId>org.springframework</groupId>
                <artifactId>spring-orm</artifactId>
                <version>7.0.9</version>
            </dependency>
            <!-- the HikariCP connection pool -->
            <dependency>
                <groupId>com.zaxxer</groupId>
                <artifactId>HikariCP</artifactId>
                <version>7.0.2</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.15.0</version>
                    <configuration>
                        <parameters>true</parameters>
                        <compilerArgs>
                            <arg>-Xlint:all</arg>
                        </compilerArgs>
                    </configuration>
                </plugin>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-surefire-plugin</artifactId>
                    <version>3.5.6</version>
                </plugin>
                <!-- mvn exec:java executes the class ${exec.mainClass}, defined by each module -->
                <plugin>
                    <groupId>org.codehaus.mojo</groupId>
                    <artifactId>exec-maven-plugin</artifactId>
                    <version>3.5.1</version>
                    <configuration>
                        <mainClass>${exec.mainClass}</mainClass>
                    </configuration>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>
</project>
  • lines 14–30: the modules, in the order in which Maven should build them: the two shared libraries first;
  • lines 38–87: the [dependencyManagement] section specifies the version of each library. A module that requires it references it without a version;
  • lines 108–116: the [exec-maven-plugin] extension will execute the class specified by the [exec.mainClass] property, which each module defines.

The first example is the module [first]. It is independent of the others:

<?xml version="1.0" encoding="UTF-8"?>
<!-- a first example: an entity, a database, a few SQL statements generated by Hibernate -->
<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>examples.hibernate</groupId>
        <artifactId>hibernate</artifactId>
        <version>1.0</version>
    </parent>

    <artifactId>first</artifactId>

    <properties>
        <!-- the program executed by mvn exec:java -->
        <exec.mainClass>first.Main</exec.mainClass>
    </properties>

    <dependencies>
        <!-- Hibernate and the MySQL JDBC driver -->
        <dependency>
            <groupId>org.hibernate.orm</groupId>
            <artifactId>hibernate-core</artifactId>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
        </dependency>
        <!-- Logback: it receives the Hibernate log -->
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
  • lines 8–12: the parent project;
  • line 18: the module’s main class;
  • lines 22–35: Hibernate, the JDBC driver for MySQL, and Logback, without a version number (it is provided by the parent project).

To run an example, build all the modules once and for all in the [11-hibernate] folder ([mvn install] installs the shared libraries in the local Maven repository), then navigate to the module’s folder:

1
2
3
4
> cd 11-hibernate
> mvn install
> cd first
> mvn compile exec:java

The database used is the [hbfirst] database on the local MySQL server, which is the Laragon server (see appendices). It does not need to exist yet: MySQL will create it.

11.2.2. The entity [Article]

A class whose objects are stored in a table is called an entity. Our first entity is a store item, as shown in table [articles] in Chapter 10:

package first;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.math.BigDecimal;

// an article of the shop: an ordinary Java class, plus a few annotations
@Entity
public class Article {

    // the primary key, generated by the DBMS
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    // the other columns of the table
    private String name;
    private BigDecimal price;
    private int currentStock;
    private int minimumStock;

    // the parameterless constructor required by Hibernate
    protected Article() {
    }

    // the constructor used by the program
    public Article(String name, BigDecimal price, int currentStock, int minimumStock) {
        this.name = name;
        this.price = price;
        this.currentStock = currentStock;
        this.minimumStock = minimumStock;
    }

    // display
    @Override
    public String toString() {
        return "[%d, %s, %.2f, %d, %d]".formatted(id, name, price, currentStock, minimumStock);
    }
}
  • Line 10: The annotation [@Entity] (package [jakarta.persistence]) designates the class as an entity. It is almost a regular Java class, deriving from no Hibernate class: it is referred to as POJO (“Plain Old Java Object”);
  • lines 14–16: the primary key is designated by the [@Id] annotation. There is no naming convention: the annotation is required. [@GeneratedValue(strategy = GenerationType.IDENTITY)] indicates that its values will be generated by DBMS (column [AUTO_INCREMENT] of MySQL). Its type is [Integer], not [int]: the value [null] means “not yet recorded in the database”;
  • lines 18–21: Each field in the class becomes a column with the same name. Since the annotation [@Id] is applied to a field, Hibernate accesses the fields directly (it reads and writes them via reflection, even if they are private): the class does not need accessors;
  • lines 24–25: Jakarta Persistence requires a parameterless constructor, which Hibernate uses to create objects read from the database. It can be protected ([protected]): the application code will not use it;
  • lines 28–33: the constructor used by the program. No identifier is provided; it will be generated by DBMS;
  • lines 36–39: a convenient display of the item. The [%.2f] format displays the price with two decimal places, in accordance with local conventions (a comma in France).

An entity cannot be a record (Chapter 4): Hibernate must be able to create the empty object, fill in its fields, and modify those fields when the application changes them—something that the immutability of a record prevents. Records remain very useful with Hibernate for query results that are not entities (projections), as we will see.

11.2.3. [SessionFactory] and [Session]

The program uses three Hibernate objects:

  • [SessionFactory] represents Hibernate configured for a database: the list of entities, the database, and the options. Its creation is resource-intensive (Hibernate analyzes the entities, connects to the server to determine its version, and prepares the SQL for common operations): only one is created per database and per application, at startup. It is thread-safe: all application threads can use it;
  • the [Session] represents a working session with the database. It obtains a connection when needed, stores the entities it has read or saved, and knows how to commit their changes to the database. It is a lightweight, short-lived object that is not thread-safe: one is opened for each unit of work and then closed;
  • [Transaction]: With Hibernate, all writes to the database occur within an explicit transaction, opened by [session.beginTransaction()] and closed by [commit] (commit) or [rollback] (rollback).

[SessionFactory] and [Session] implement the [AutoCloseable] interface: they are created in a [try] statement with resources, which closes them at the end of the block.

11.2.4. The program

package first;

import java.math.BigDecimal;
import java.util.List;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.jpa.HibernatePersistenceConfiguration;

public class Main {

    public static void main(String[] args) {
        // the SessionFactory: Hibernate configured for the [hbfirst] database
        try (SessionFactory sessionFactory = new HibernatePersistenceConfiguration("first")
                // the entities
                .managedClass(Article.class)
                // the database; MySQL creates it if it does not exist
                .jdbcUrl("jdbc:mysql://localhost:3306/hbfirst?createDatabaseIfNotExist=true")
                .jdbcCredentials("root", "")
                // display of the SQL statements, formatted, without colors
                .showSql(true, true, false)
                .createEntityManagerFactory()) {

            // the tables are dropped then recreated from the entities
            sessionFactory.getSchemaManager().drop(false);
            sessionFactory.getSchemaManager().create(false);

            // adding three articles, in a transaction
            try (Session session = sessionFactory.openSession()) {
                Transaction transaction = session.beginTransaction();
                IO.println("--- persist");
                session.persist(new Article("bike", BigDecimal.valueOf(500), 10, 5));
                session.persist(new Article("pump", BigDecimal.valueOf(10), 10, 2));
                session.persist(new Article("bow", BigDecimal.valueOf(600), 4, 1));
                IO.println("--- commit");
                transaction.commit();
            }

            // reading the table, with a new session
            IO.println("--- reading the articles");
            try (Session session = sessionFactory.openSession()) {
                List<Article> articles = session
                        .createSelectionQuery("from Article order by name", Article.class)
                        .getResultList();
                for (Article article : articles) {
                    IO.println(article);
                }
            }
        }
    }
}
  • lines 14–22: creation of [SessionFactory]. There are several ways to configure Hibernate (we’ll discuss them later); we use the [HibernatePersistenceConfiguration] class from Hibernate 7, an enhanced version of the [PersistenceConfiguration] class introduced with Jakarta Persistence 3.2, which allows everything to be configured via code. Its parameter is the name of the persistence unit—a freely chosen name that appears in Hibernate messages. Each method returns the configuration itself, allowing calls to be chained together;
  • line 16: the list of entities;
  • lines 18–19: the URL and JDBC from the [hbfirst] database, in the same format as in Chapter 10, and the user ([root], with no password in Laragon). The parameter [createDatabaseIfNotExist=true] of URL is an option of the driver MySQL: it creates the database if it does not exist;
  • Line 21: [showSql(true, true, false)] instructs Hibernate to print each SQL command it executes to the console, formatted across multiple lines (second parameter), without colors (third parameter). Looking at the SQL sent to the server is the best way to understand what Hibernate does: we’ll do this in all the examples;
  • Line 22: [createEntityManagerFactory] constructs [SessionFactory] (the method name is that of the standard; the returned type is that of Hibernate);
  • Lines 25–26: The [SchemaManager] object from [SessionFactory] (Jakarta Persistence 3.2) manages the entity tables: [drop(false)] deletes them if they exist, [create(false)] creates them (the [false] parameter specifies that the database itself should not be deleted or created). This is convenient for examples and tests; a real-world application manages its database differently, as we will see;
  • line 29: [openSession()] opens a session;
  • line 30: the start of a transaction;
  • lines 32–34: [persist] adds three new items to the session: they will be saved to the database. No value is specified for the identifier; it will be generated by DBMS;
  • line 36: [commit] commits the transaction. Prior to this, the session sends the SQL commands to the server, which are still necessary to save the changes it has stored: this is referred to as “flushing” the session to the database;
  • lines 42–44: a query written in HQL, Hibernate’s query language: the items in alphabetical order by name. It refers to the entity [Article] and its field [name], not to the table. The second parameter of [createSelectionQuery] is the result type: [getResultList] returns a [List<Article>].

The [src/main/resources/logback.xml] file in the module configures Logback. It displays Hibernate messages of level [INFO] or higher, one per line (level, log name, message):

<?xml version="1.0" encoding="UTF-8"?>
<!-- Logback configuration: Hibernate information messages, on one line each -->
<configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%-5level %logger - %msg%n</pattern>
        </encoder>
    </appender>
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>

Results:

> mvn compile exec:java
INFO  org.hibernate.orm.jpa - HHH008540: Processing PersistenceUnitInfo [name: first]
INFO  org.hibernate.orm.core - HHH000001: Hibernate ORM core version 7.4.5.Final
WARN  org.hibernate.orm.connections.pooling - HHH10001002: Using built-in connection pool (not intended for production use)
INFO  org.hibernate.orm.connections.pooling - HHH10001005: Database info:
    Database JDBC URL [jdbc:mysql://localhost:3306/hbfirst?createDatabaseIfNotExist=true]
    Database driver: MySQL Connector/J
    Database dialect: MySQLDialect
    Database version: 8.0.46
    Default catalog/schema: hbfirst/undefined
    Autocommit mode: false
    Isolation level: REPEATABLE_READ
    JDBC fetch size: none
    Pool: DriverManagerConnectionProvider
    Minimum pool size: 1
    Maximum pool size: 20
INFO  org.hibernate.orm.core - HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
Hibernate:
    drop table if exists Article
Hibernate:
    create table Article (
        id integer not null auto_increment,
        currentStock integer not null,
        minimumStock integer not null,
        name varchar(255),
        price decimal(38,2),
        primary key (id)
    ) engine=InnoDB
--- persist
Hibernate:
    insert
    into
        Article
        (currentStock, minimumStock, name, price)
    values
        (?, ?, ?, ?)
Hibernate:
    insert
    into
        Article
        (currentStock, minimumStock, name, price)
    values
        (?, ?, ?, ?)
Hibernate:
    insert
    into
        Article
        (currentStock, minimumStock, name, price)
    values
        (?, ?, ?, ?)
--- commit
--- reading the articles
Hibernate:
    select
        a1_0.id,
        a1_0.currentStock,
        a1_0.minimumStock,
        a1_0.name,
        a1_0.price
    from
        Article a1_0
    order by
        a1_0.name
[1, bike, 500.00, 10, 5]
[3, bow, 600.00, 4, 1]
[2, pump, 10.00, 10, 2]

Comments:

  • Lines 2–17: Hibernate informational messages: the name of the persistence unit, the Hibernate version, and database characteristics (URL, driver, selected dialect, server version, transaction isolation level, connection pool size). Hibernate queried the server to select the [MySQLDialect] dialect appropriate for its version;
  • line 4: a warning. Hibernate manages its own small connection pool, which is not intended for a production application. We’ll see, with Spring, how to provide it with the HikariCP pool from Chapter 10; for our examples, the built-in pool is sufficient;
  • Lines 18–28: Deleting and then creating the table [Article] based on the entity: the table name is the same as the entity’s name, the columns have the same names as the fields, and the column [id] is the primary key, with a generated value ([auto_increment]). The columns for primitive-type fields ([int]) are [not null]: a [int] cannot equal [null]. Those for reference-type fields ([String], [BigDecimal]) accept the value [NULL];
  • note the default types: [varchar(255)] for a string, and [decimal(38,2)] for a [BigDecimal] (38 digits, including 2 decimal places). These are rarely the ones you want: the following paragraph shows how to specify them;
  • Lines 29–51: The three configured [INSERT] commands were executed as soon as [persist] was called, before [commit]. This is a consequence of the key being generated by DBMS ([IDENTITY]): the session must know an entity’s identifier as soon as it takes over that entity, and to obtain it, it must execute the insert operation (the JDBC driver returns the generated key; see Chapter 10). With other generation methods, the insert operations are deferred until the session is synchronized. The parameter values ([?]) are not displayed: we will see how to make them appear;
  • lines 53–63: the query HQL [from Article order by name] was translated into a statement [SELECT ... ORDER BY]. Hibernate assigned the alias [a1_0] to the table and created a [Article] object for each row read (lines 64–66).

We haven't written any SQL commands or column read codes: that's the whole point of a ORM. We can also see that this doesn’t mean you don’t need to know SQL: to determine whether the generated SQL is correct and effective, you need to know how to read it.

11.3. The Model

Hibernate builds its model from entities in three steps:

  • First, it applies its default rules: anything that isn’t specified is assigned a reasonable value (this is known as “configuration by exception”);
  • it then supplements or corrects these values using the annotations placed on the entities;
  • finally, it applies the mapping files XML ([orm.xml]), which can fully describe a class without annotations or modify what its annotations specify.

11.3.1. Default Rules

We saw the basics of this in the first example:

  • an entity carries the annotation [@Entity] and has an identifier designated by [@Id]; both of these annotations are required;
  • the placement of the annotation [@Id] determines the access mode: when placed on a field, Hibernate reads and writes the fields directly; when placed on an accessor ([getId]), it uses the accessors ([getX]/[setX]). Access via fields is the most common: it allows the class to have whatever methods it wants;
  • any non-static field is stored in a column, unless it bears the annotation [@Transient] or the keyword [transient];
  • the table name is the entity name (the class’s simple name), and the column name is the field name. These names then undergo a naming strategy: Hibernate’s strategy keeps the names as-is, while another strategy—which we’ll try below—converts them to “snake case” ([currentStock] becomes [current_stock]). Spring Boot uses the latter by default;
  • a column of a primitive type ([int], [double], [boolean]) becomes [NOT NULL]; a column of a reference type ([Integer], [String], [BigDecimal], [LocalDate]) accepts the value [NULL];
  • the SQL types are selected by the dialect. With MySQL: [integer] for [int], [bigint] for [long], [bit] for [boolean], [varchar(255)] for [String], [decimal(38,2)] for [BigDecimal], [date] for [LocalDate], [datetime(6)] (to the microsecond) for [LocalDateTime]. By default, an enumeration is recorded by its sequence number (see Chapter 3); the annotation [@Enumerated(EnumType.STRING)] records it by its name, which is more reliable;
  • a field that refers to another entity, or to a collection of entities, defines a relationship (we will discuss this later).

11.3.2. The annotations and the mapping file XML

The module [model] defines two entities configured in two different ways. The first is configured using annotations:

package model;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import java.math.BigDecimal;
import java.time.LocalDate;

// an entity configured by annotations
@Entity
@Table(name = "products")
public class Product {

    // the primary key: @Id, whatever its name
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer code;

    // required string of 50 characters at most
    @Column(nullable = false, length = 50)
    private String name;

    // optional string (NULL column) of 200 characters at most
    @Column(length = 200)
    private String description;

    // name and SQL type of the column imposed
    @Column(name = "price_excl_tax", nullable = false, precision = 10, scale = 2)
    private BigDecimal priceExclTax;

    // an optional date: an object can be null
    private LocalDate withdrawalDate;

    // a field without a column
    @Transient
    private boolean selected;

    protected Product() {
    }

    // a method is never saved: here Hibernate accesses the fields, not the methods
    public BigDecimal getPriceInclTax() {
        return priceExclTax.multiply(new BigDecimal("1.2"));
    }

    public boolean isSelected() {
        return selected;
    }
}
  • lines 3–9: Jakarta Persistence annotations;
  • line 15: [@Table] specifies the table name;
  • lines 19–21: the primary key, designated as [@Id] regardless of its name;
  • Lines 24–25: [@Column] specifies the column; [nullable = false] makes it mandatory ([NOT NULL]); [length = 50] sets its length ([varchar(50)]);
  • lines 28–29: an optional description of up to 200 characters; the column will accept the value [NULL];
  • lines 32–33: [@Column] also specifies the column name ([price_excl_tax]) and, for a decimal number, its precision (the total number of digits) and its scale (the number of digits after the decimal point): [decimal(10,2)];
  • line 36: an optional date. The type [LocalDate] (Chapter 5) represents a date without a time: it is associated with the types SQL and [date];
  • lines 39–40: [@Transient] excludes a field from the model: in this case, information useful to the user interface that does not belong in the database;
  • lines 46–48: a method, even if named like a getter, is not registered, since Hibernate accesses the fields directly. If accessed via getters, the annotation [@Transient] would need to be added to it.

The second entity has no annotations:

package model;

import java.time.LocalDateTime;

// a class without any annotation: its mapping is described by the [supplier.orm.xml] file
public class Supplier {

    private Integer number;
    private String companyName;
    private String phone;
    private LocalDateTime creationDate;

    protected Supplier() {
    }

    public Supplier(String companyName, String phone) {
        this.companyName = companyName;
        this.phone = phone;
    }
}

Its mapping to the table is described by a file named XML, located in the [src/main/resources/META-INF] folder of the module:

<?xml version="1.0" encoding="UTF-8"?>
<!-- the mapping of the [Supplier] class to its table, described in XML (Jakarta Persistence 3.2) -->
<entity-mappings xmlns="https://jakarta.ee/xml/ns/persistence/orm"
                 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                 xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence/orm
                                     https://jakarta.ee/xml/ns/persistence/orm/orm_3_2.xsd"
                 version="3.2">
    <entity class="model.Supplier" access="FIELD">
        <table name="suppliers">
            <!-- a unique index: two suppliers cannot have the same company name -->
            <index name="ix_suppliers_company_name" column-list="companyName" unique="true"/>
        </table>
        <attributes>
            <id name="number">
                <generated-value strategy="IDENTITY"/>
            </id>
            <basic name="companyName">
                <column nullable="false" length="60"/>
            </basic>
            <basic name="phone">
                <column name="tel" length="20"/>
            </basic>
            <!-- a default value computed by the DBMS: Hibernate never writes this column -->
            <basic name="creationDate">
                <column nullable="false" insertable="false" updatable="false"
                        column-definition="datetime(6) default current_timestamp(6)"/>
            </basic>
        </attributes>
    </entity>
</entity-mappings>
  • lines 3–7: the XML schema for Jakarta Persistence 3.2 mapping files;
  • line 8: the described class, with access via fields;
  • lines 9–12: the table, and a unique index on the company name: no two suppliers can have the same one. The equivalent annotation is [@Table(name = "suppliers", indexes = @Index(...))];
  • lines 14–16: the identifier, generated by DBMS;
  • lines 17–19: a required column of no more than 60 characters;
  • lines 20–22: the field [phone] is stored in the column [tel];
  • lines 24–27: the creation date has a default value, calculated by DBMS. The attribute [column-definition] provides the text SQL from the column definition, copied verbatim in the order [CREATE TABLE]; [insertable="false"] and [updatable="false"] indicate that Hibernate must never write to this column, which allows DBMS to use [current_timestamp(6)].

Everything that annotations do can be done in XML. Annotations have the advantage of placing the configuration next to the field, and this is the most common approach; the XML file, on the other hand, keeps the classes independent of Jakarta Persistence and consolidates the configuration in a single location. The two approaches can be combined: the XML file takes precedence in case of a conflict. Jakarta Persistence does not support configuring the model via Java code, as other frameworks (such as Entity Framework in C#) do.

The program displays the model built by Hibernate, followed by the SQL script that would create the tables:

package model;

import jakarta.persistence.metamodel.Attribute;
import jakarta.persistence.metamodel.EntityType;
import jakarta.persistence.metamodel.SingularAttribute;
import java.io.StringWriter;
import org.hibernate.SessionFactory;
import org.hibernate.boot.model.naming.PhysicalNamingStrategySnakeCaseImpl;
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.jpa.HibernatePersistenceConfiguration;

public class Main {

    public static void main(String[] args) {
        // the table creation script will be written into this object
        StringWriter script = new StringWriter();
        // a SessionFactory that never connects to the server
        try (SessionFactory sessionFactory = new HibernatePersistenceConfiguration("model")
                // an annotated entity and a mapping described in XML
                .managedClass(Product.class)
                .mappingFile("META-INF/supplier.orm.xml")
                // no database: the DBMS and its version are given, for the SQL dialect
                .property(AvailableSettings.JAKARTA_HBM2DDL_DB_NAME, "MySQL")
                .property(AvailableSettings.JAKARTA_HBM2DDL_DB_MAJOR_VERSION, "8")
                .property(AvailableSettings.ALLOW_METADATA_ON_BOOT, false)
                // the column names in "snake case": companyName -> company_name
                .property(AvailableSettings.PHYSICAL_NAMING_STRATEGY,
                        PhysicalNamingStrategySnakeCaseImpl.class.getName())
                // the table creation script, without executing it
                .property(AvailableSettings.JAKARTA_HBM2DDL_SCRIPTS_ACTION, "create")
                .property(AvailableSettings.JAKARTA_HBM2DDL_SCRIPTS_CREATE_TARGET, script)
                .property(AvailableSettings.HBM2DDL_DELIMITER, ";")
                .property(AvailableSettings.FORMAT_SQL, true)
                .createEntityManagerFactory()) {

            // the model built by Hibernate: entities and attributes (Jakarta Persistence API)
            for (EntityType<?> entity : sessionFactory.getMetamodel().getEntities()) {
                IO.println("entity %s (%s)".formatted(entity.getName(), entity.getJavaType().getName()));
                for (Attribute<?, ?> attribute : entity.getAttributes()) {
                    boolean id = attribute instanceof SingularAttribute<?, ?> s && s.isId();
                    IO.println("  %-14s %s%s".formatted(attribute.getName(),
                            attribute.getJavaType().getSimpleName(), id ? " (identifier)" : ""));
                }
            }
        }
        // the table creation script, without the 4-space indentation of its lines
        IO.println("--- creation script");
        script.toString().strip().lines()
                .map(line -> line.startsWith("    ") ? line.substring(4) : line)
                .forEach(line -> IO.println(line.stripTrailing()));
    }
}
  • line 16: a [StringWriter] (package [java.io]), a write stream to a string in memory, will receive the SQL script;
  • lines 20–21: the annotated entity is declared by [managedClass], and the file XML by [mappingFile];
  • lines 23–25: The database [hbmodel] will never exist; this [SessionFactory] is used solely to examine the model. Therefore, there is no URL or JDBC. To select the SQL dialect without querying the server, Hibernate needs the name of the DBMS and its version; the [ALLOW_METADATA_ON_BOOT] property ([hibernate.boot.allow_jdbc_metadata_access]) prevents it from connecting at startup. The names of the configuration properties are constants in the [AvailableSettings] interface;
  • lines 27–28: the “snake case” naming convention;
  • lines 30–33: Hibernate will write the table creation script (["create"]) to the [script] object without executing it; the commands are terminated by a semicolon and formatted across multiple lines;
  • line 37: the [getMetamodel] method (Jakarta Persistence) provides access to the model: its entities ([EntityType]) and, for each one, its attributes ([Attribute]);
  • Lines 38–43: For each entity, its name and class; for each attribute, its name, Java type, and whether it is the identifier. The pattern filtering in line 40 ([instanceof SingularAttribute<?, ?> s && s.isId()], Chapter 4) checks the attribute’s type and, if appropriate, uses it immediately;
  • lines 48–50: the script as displayed, without the four-space indentation that Hibernate adds to its lines.

Results:

> mvn compile exec:java
WARN  connection - HHH102003: No appropriate connection provider encountered; client must supply connections
entity Supplier (model.Supplier)
  number         Integer (identifier)
  companyName    String
  creationDate   LocalDateTime
  phone          String
entity Product (model.Product)
  code           Integer (identifier)
  description    String
  name           String
  priceExclTax   BigDecimal
  withdrawalDate LocalDate
--- creation script
create table products (
    code integer not null auto_increment,
    price_excl_tax decimal(10,2) not null,
    withdrawal_date date,
    name varchar(50) not null,
    description varchar(200),
    primary key (code)
) engine=InnoDB;

create table suppliers (
    number integer not null auto_increment,
    creation_date datetime(6) default current_timestamp(6) not null,
    tel varchar(20),
    company_name varchar(60) not null,
    primary key (number)
) engine=InnoDB;

alter table suppliers
   add constraint ix_suppliers_company_name unique (company_name);
  • line 2: The Hibernate warning confirms that it has no way to obtain a connection;
  • lines 3–13: the two entities and their attributes (the identifier first, then the others in alphabetical order). The field [selected] and the method [getPriceInclTax] do not appear in the model;
  • lines 15–21: the table [suppliers], described by the file XML. The naming strategy has changed [companyName] and [creationDate] to [company_name] and [creation_date]; the column [tel], whose name is fixed, remains unchanged. The definition of the column [creation_date] is that of the file XML, supplemented by [not null];
  • Lines 23–30: the table [products]. The types SQL come from annotations ([varchar(50)], [decimal(10,2)]) or from dialect choices for a Java type ([integer], [date]);
  • lines 32–33: the unique index, created under MySQL by a uniqueness constraint ([unique]).

11.3.3. Generated identifiers

The annotation [@GeneratedValue] indicates that the identifier value is generated. Its parameter [strategy] specifies how:

Strategy
Identifier Generation
[IDENTITY]
by DBMS, when inserting the row (column [AUTO_INCREMENT] of MySQL, [IDENTITY] of SQL Server, and PostgreSQL)
[SEQUENCE]
by a sequence from DBMS (PostgreSQL, Oracle), which Hibernate queries before insertion
[TABLE]
by a counter table managed by Hibernate
[UUID]
by Hibernate: a random universal identifier, such as [java.util.UUID]
[AUTO] (default)
Hibernate chooses; with MySQL, which has no sequences, it simulates them using a table named [<table>_SEQ]

With MySQL, we still use [IDENTITY], which corresponds to the natural behavior of the [AUTO_INCREMENT] column. We’ve seen the result of this: the [INSERT] command is executed as soon as [persist] is called, and Hibernate cannot group multiple inserts into a batch (we’ll cover batches later). The [SEQUENCE] strategy, which allows you to know the ID before the insertion, is preferred for DBMS statements that use sequences.

11.3.4. Optional Values and [NULL] Columns

Whether a column is optional is therefore determined in Java by the field type (primitive or reference) and by the [nullable] attribute of [@Column]. This choice must be made carefully: the rule expresses both a constraint in the Java code (“this value may be missing”) and a constraint in the database. A few notes:

  • [@Column(nullable = false)] defines the [NOT NULL] constraint in the schema created by Hibernate, and Hibernate also uses it before sending a [INSERT] or [UPDATE] command: if the field is [null], it does not send the command and triggers a [PropertyValueException] (“not-null property references a null or transient value”). This Hibernate check is disabled by default when Jakarta Validation (below) is present: validation then falls back to its annotations, or, failing that, to DBMS, which rejects the command;
  • For more comprehensive validation performed by Java before any database access, we use Jakarta Validation (library [hibernate-validator]) and its annotations [@NotNull], [@Size(max = 30)], [@Positive].... When present in the classpath, Hibernate validates entities before saving them and takes these annotations into account when creating columns;
  • a field cannot be of type [Optional] (Chapter 4), which is not intended for this purpose; however, an accessor can return a [Optional] constructed from an optional field: [return Optional.ofNullable(description);].

11.4. The following examples: two common libraries

The examples in the following paragraphs almost all use the [hbarticles] database and its [articles] table, populated with the six items from Chapter 10. Rather than repeating the Hibernate entity and configuration in each module, we place them in a library—the [articles] module—on which the other modules depend. We also replace the SQL log from [showSql]—which is too verbose for our examples—with a more compact log, placed in a second library, the [tools] module.

11.4.1. The compact SQL log

Rather than writing directly to the console ([showSql]), Hibernate can forward its activity to the logging system (in this case, Logback), with each message category written to a different logger:

Log
Level
Messages
[org.hibernate.SQL]
[DEBUG]
SQL orders executed
[org.hibernate.orm.jdbc.bind]
[TRACE]
the value of each parameter in an order SQL
[org.hibernate.orm.resource.jdbc]
[TRACE]
including, among other things, the initiation, confirmation, and cancellation of transactions
[org.hibernate.orm.jdbc.batch]
[TRACE]
execution of order batches SQL
[org.hibernate.orm.jdbc.error]
[WARN]
Errors reported by DBMS

A log only records messages of a level equal to or higher than its own. Therefore, you simply need to set the desired level for these logs in the [logback.xml] file to capture these messages. We process them through a layout class that makes them more compact:

package tools;

import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.LayoutBase;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.slf4j.LoggerFactory;

// a compact log of the SQL statements executed by Hibernate: a Logback layout
public class SqlLog extends LayoutBase<ILoggingEvent> {

    // the Hibernate loggers used
    public static final String SQL = "org.hibernate.SQL";
    public static final String PARAMETERS = "org.hibernate.orm.jdbc.bind";
    public static final String TRANSACTIONS = "org.hibernate.orm.resource.jdbc";
    public static final String ERRORS = "org.hibernate.orm.jdbc.error";
    public static final String BATCHES = "org.hibernate.orm.jdbc.batch";

    // the maximum length of a clause written on a single line
    private static final int WIDTH = 100;

    // the execution of a batch of SQL statements: "Executing JDBC batch (3 / 3) - [...]"
    private static final Pattern BATCH = Pattern.compile("Executing JDBC batch \\((\\d+) / \\d+\\).*");

    // a parameter bound to an SQL statement: "binding parameter (1:VARCHAR) <- [bike]"
    private static final Pattern PARAMETER =
            Pattern.compile("binding parameter \\((\\d+):\\w+\\) <- \\[(.*)]");

    // layout of a message: the text to write is returned ("": nothing is written)
    @Override
    public String doLayout(ILoggingEvent event) {
        String message = event.getFormattedMessage();
        return switch (event.getLoggerName()) {
            case SQL -> sqlStatement(message);
            case PARAMETERS -> parameter(message);
            case TRANSACTIONS -> transaction(message);
            case ERRORS -> error(message);
            case BATCHES -> batch(message);
            default -> "";
        };
    }

    // the SQL statement formatted by Hibernate, made more compact: one line per clause (select, from...)
    private static String sqlStatement(String sql) {
        // the non-empty lines, without the trailing spaces
        List<String> lines = sql.lines().map(String::stripTrailing).filter(l -> !l.isBlank()).toList();
        // the clauses: a top-level line (the least indented) and the lines that follow it
        int indent = lines.stream().mapToInt(SqlLog::indent).min().orElse(0);
        List<List<String>> clauses = new ArrayList<>();
        for (String line : lines) {
            if (clauses.isEmpty() || indent(line) == indent) {
                clauses.add(new ArrayList<>());
            }
            clauses.getLast().add(line.substring(indent));
        }
        // each clause is written on one line, if it is not too long
        StringBuilder text = new StringBuilder();
        String start = "";
        for (List<String> clause : clauses) {
            String line = start + clause.stream().map(String::strip).collect(Collectors.joining(" "));
            if (line.equals("insert") || line.equals("delete")) {
                // [insert into ...], [delete from ...]: a single line
                start = line + " ";
                continue;
            }
            if (line.length() <= WIDTH) {
                text.append("    SQL> ").append(line).append('\n');
            } else {
                // clause too long: the Hibernate lines are kept
                for (int i = 0; i < clause.size(); i++) {
                    text.append("    SQL> ").append(i == 0 ? start : "").append(clause.get(i)).append('\n');
                }
            }
            start = "";
        }
        return text.toString();
    }

    // the number of spaces at the start of the line
    private static int indent(String line) {
        return line.length() - line.stripLeading().length();
    }

    // the value of a parameter of the SQL statement
    private static String parameter(String message) {
        Matcher m = PARAMETER.matcher(message);
        return m.matches() ? "    SQL> -- parameter " + m.group(1) + " = " + m.group(2) + "\n" : "";
    }

    // start, commit and rollback of the transactions (the other messages are ignored)
    private static String transaction(String message) {
        if (message.startsWith("HHH10003002")) {
            return "    SQL> -- start of transaction\n";
        }
        if (message.startsWith("HHH10003004")) {
            return "    SQL> -- transaction committed (COMMIT)\n";
        }
        if (message.startsWith("HHH10003006")) {
            return "    SQL> -- transaction rolled back (ROLLBACK)\n";
        }
        return "";
    }

    // the error reported by the DBMS (the line that gives its error codes is ignored)
    private static String error(String message) {
        return message.startsWith("HHH000247") ? "" : "    SQL> -- failure: " + message + "\n";
    }

    // the execution of a batch of SQL statements (the other messages are ignored)
    private static String batch(String message) {
        Matcher m = BATCH.matcher(message);
        return m.matches() ? "    SQL> -- execution of a batch of " + m.group(1) + " statement(s)\n" : "";
    }

    // executes an action without an SQL log
    public static void withoutLog(Runnable action) {
        List<Logger> loggers = Stream.of(SQL, PARAMETERS, TRANSACTIONS, ERRORS, BATCHES)
                .map(SqlLog::logger)
                .toList();
        // the current levels, restored at the end
        List<Level> levels = loggers.stream().map(Logger::getLevel).toList();
        loggers.forEach(l -> l.setLevel(Level.OFF));
        try {
            action.run();
        } finally {
            for (int i = 0; i < loggers.size(); i++) {
                loggers.get(i).setLevel(levels.get(i));
            }
        }
    }

    // the Logback logger with the given name
    private static Logger logger(String name) {
        return (Logger) LoggerFactory.getLogger(name);
    }
}
  • line 16: a Logback layout class derives from the [LayoutBase<ILoggingEvent>] class; its [doLayout] method (lines 36–47) receives each message (a [ILoggingEvent]) and returns the text to be written;
  • lines 19–23: the names of the Hibernate logs used;
  • lines 39–46: the processing depends on the log that sent the message ([switch] on a string, using the Java 14 “arrow” syntax);
  • lines 50–83: the order SQL, as formatted by Hibernate ([format_sql]), spread across multiple lines, with one column per line. We make it more compact. Non-empty lines are divided into clauses (lines 54–61): a clause begins with a top-level line ([select], [from], [where]...) and includes the indented lines that follow it. Each clause is then written on a single line, preceded by [SQL>], if it does not exceed 100 characters (lines 72–73); otherwise, its lines are retained (lines 76–78). The clauses [insert] and [delete] are combined with the following one (lines 67–71);
  • lines 91–94: The Hibernate message that provides a parameter’s value looks like [binding parameter (1:VARCHAR) <- [bike]]. The regular expression in lines 32–33 (Chapter 5) extracts the parameter number and its value;
  • lines 97–108: Among the many messages in the [org.hibernate.orm.resource.jdbc] log, only those indicating the start, commit, and rollback of a transaction are retained. Each Hibernate message begins with a code ([HHH10003002]...) that uniquely identifies it;
  • lines 111–113: an error in DBMS. Hibernate writes this as two messages: one containing the error codes (which we ignore) and the other containing the error text;
  • lines 116–119: execution of a batch of SQL commands, which we will encounter at the end of the chapter;
  • Lines 122–136: The [withoutLog] method executes an action without logging (SQL). It uses Logback’s API: each log ([Logger]) has a level that can be modified during execution. We set the five logs to level [OFF], execute the action, and then restore the original levels in a [finally] clause, so that they remain at those levels even if the action throws an exception;
  • lines 139–141: [LoggerFactory] is the class of SLF4J that returns a log; Since Logback is the current implementation of SLF4J, the returned object is a [ch.qos.logback.classic.Logger].

The [tools] module also contains the [logback.xml] file used in the examples. Placed at the root of the classpath ([src/main/resources]), it is found by Logback when the program starts:

<?xml version="1.0" encoding="UTF-8"?>
<!-- Logback configuration for the examples of the chapter -->
<configuration>

    <!-- the compact SQL log: layout by the [tools.SqlLog] class -->
    <appender name="SQL" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
            <layout class="tools.SqlLog"/>
        </encoder>
    </appender>

    <!-- the other messages: level, short name of the logger, text -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%-5level %logger{0} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- the SQL statements (DEBUG), their parameters, the transactions, the batches (TRACE) and the errors -->
    <logger name="org.hibernate.SQL" level="DEBUG" additivity="false">
        <appender-ref ref="SQL"/>
    </logger>
    <logger name="org.hibernate.orm.jdbc.bind" level="TRACE" additivity="false">
        <appender-ref ref="SQL"/>
    </logger>
    <logger name="org.hibernate.orm.resource.jdbc" level="TRACE" additivity="false">
        <appender-ref ref="SQL"/>
    </logger>
    <logger name="org.hibernate.orm.jdbc.batch" level="TRACE" additivity="false">
        <appender-ref ref="SQL"/>
    </logger>
    <logger name="org.hibernate.orm.jdbc.error" level="WARN" additivity="false">
        <appender-ref ref="SQL"/>
    </logger>

    <!-- the built-in Hibernate connection pool is enough for our examples: its warning is silenced -->
    <logger name="org.hibernate.orm.connections.pooling" level="ERROR"/>

    <!-- dropping the tables of an empty database causes harmless errors: they are silenced -->
    <logger name="org.hibernate.tool.schema" level="OFF"/>

    <!-- all the other loggers: warnings and errors only -->
    <root level="WARN">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>
  • lines 6–10: An “appender” writes the messages, in this case to the console. It uses our formatting;
  • lines 13–17: a second “appender” for other messages: level, log short name, text;
  • lines 20–34: the Hibernate logs from the previous table, with their levels. Their messages go to the “appender” [SQL] and only to it ([additivity="false"]: they are not additionally forwarded to the root log);
  • line 37: the warning about Hibernate’s built-in connection pool is suppressed;
  • line 40: Hibernate deletes tables starting with their foreign keys; on an empty database, these deletions fail without consequence, but Hibernate logs this at length. These messages are suppressed;
  • lines 43–45: The other logs record only warnings and errors.

The [tools] module has only one dependency: Logback. A library does not typically impose its logging configuration on the applications that use it; we do so here to avoid repeating this file in every example.

11.4.2. The entity and the article database

The [Article] entity in the [articles] module uses the annotations from the previous paragraph. Here is the beginning of the code; the rest contains the two constructors for the entity from the first example, [getX]/[setX] getter and setter methods for each field (except [setId]: the identifier is managed by Hibernate) and the same method [toString]:

package articles;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.math.BigDecimal;

// an article of the shop, with the annotations that specify its table
@Entity
@Table(name = "articles",
        uniqueConstraints = @UniqueConstraint(name = "uk_articles_name", columnNames = "name"))
public class Article {

    // primary key, generated by the DBMS (AUTO_INCREMENT)
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    // name of the article: mandatory, at most 30 characters, unique (see @Table)
    @Column(nullable = false, length = 30)
    private String name;

    // price: 10 digits of which 2 after the decimal point
    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;

    // current stock and minimum stock below which restocking is needed
    private int currentStock;
    private int minimumStock;

    // the parameterless constructor required by Hibernate (it can be protected)
    protected Article() {
    }
  • lines 14–15: the table is named [articles], as in Chapter 10. The parameter [uniqueConstraints] declares a uniqueness constraint, named [uk_articles_name], on the column [name]: two items cannot have the same name. Without an explicit name, Hibernate would generate an unreadable one ([UK3f8k...]), which would appear in error messages;
  • lines 24–25: a required name of no more than 30 characters;
  • lines 28–29: a price in [decimal(10,2)].

The [BaseArticles] class contains the Hibernate configuration for the [hbarticles] database:

package articles;

import java.math.BigDecimal;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.jpa.HibernatePersistenceConfiguration;
import tools.SqlLog;

// the [hbarticles] database: Hibernate configuration and database initialization
public final class BaseArticles {

    // the database; MySQL creates it if it does not exist (createDatabaseIfNotExist)
    public static final String URL = "jdbc:mysql://localhost:3306/hbarticles?createDatabaseIfNotExist=true";
    public static final String USER = "root";
    public static final String PASSWORD = "";

    // utility class: no instance
    private BaseArticles() {
    }

    // the Hibernate configuration: entities, database, formatting of the logged SQL
    public static HibernatePersistenceConfiguration configuration() {
        return new HibernatePersistenceConfiguration("articles")
                .managedClass(Article.class)
                .jdbcUrl(URL)
                .jdbcCredentials(USER, PASSWORD)
                .property(AvailableSettings.FORMAT_SQL, true);
    }

    // a SessionFactory for the [hbarticles] database, with the previous configuration
    public static SessionFactory createSessionFactory() {
        return configuration().createEntityManagerFactory();
    }

    // (re)creation of the [articles] table with its six articles, without an SQL log
    public static void initialize(SessionFactory sessionFactory) {
        SqlLog.withoutLog(() -> {
            // dropping then creating the tables of the entities
            sessionFactory.getSchemaManager().drop(false);
            sessionFactory.getSchemaManager().create(false);
            // the six articles, added in a transaction
            sessionFactory.inTransaction(session -> {
                session.persist(new Article("bike", BigDecimal.valueOf(500), 10, 5));
                session.persist(new Article("pump", BigDecimal.valueOf(10), 10, 2));
                session.persist(new Article("bow", BigDecimal.valueOf(600), 4, 1));
                session.persist(new Article("arrows - set of 6", BigDecimal.valueOf(100), 12, 20));
                session.persist(new Article("diving suit", BigDecimal.valueOf(300), 8, 2));
                session.persist(new Article("diver's oxygen tanks", BigDecimal.valueOf(120), 10, 5));
            });
        });
    }
}
  • lines 13–15: the database’s URL and the user, both public so that other modules can use them;
  • lines 22–28: the Hibernate configuration. It is returned by a public method, which will allow an instance to complete it before creating the [SessionFactory]. The [FORMAT_SQL] property ([hibernate.format_sql]) instructs Hibernate to format the SQL, which it then passes to the [org.hibernate.SQL] log: it is this formatted data that the [SqlLog] class then condenses;
  • lines 31–33: the [SessionFactory] from the [hbarticles] database;
  • lines 36–51: a method that recreates the table [articles] with its six items, without the log SQL: we do not want to see the SQL from this initialization in the example results;
  • line 42: [sessionFactory.inTransaction(session -> ...)] opens a session, starts a transaction, executes the lambda (Chapter 7) by passing it the session, commits the transaction (or rolls it back if the lambda throws an exception), and closes the session. This is the shortest way to write a work unit; we’ll use it often, along with its variants [inSession] (a session without a transaction), [fromTransaction], and [fromSession] (which return the result of the lambda).

The [articles] module depends on the [tools] module, Hibernate, and the JDBC driver:

    <dependencies>
        <!-- the compact SQL log (and Logback) -->
        <dependency>
            <groupId>examples.hibernate</groupId>
            <artifactId>tools</artifactId>
        </dependency>
        <!-- Hibernate and the MySQL JDBC driver -->
        <dependency>
            <groupId>org.hibernate.orm</groupId>
            <artifactId>hibernate-core</artifactId>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
        </dependency>
    </dependencies>

The example modules therefore require only one dependency, that of the [articles] module; Maven adds its dependencies (these are called transitive dependencies). Here are the dependencies for the [crud] module:

1
2
3
4
5
6
7
    <dependencies>
        <!-- the [Article] entity and the [hbarticles] database (Hibernate, MySQL and the SQL log included) -->
        <dependency>
            <groupId>examples.hibernate</groupId>
            <artifactId>articles</artifactId>
        </dependency>
    </dependencies>

11.4.3. Other Ways to Configure Hibernate

We configure Hibernate programmatically using [HibernatePersistenceConfiguration]. There are other ways to do this, which can be found in applications and documentation:

  • a [META-INF/persistence.xml] file, the traditional approach used in Jakarta Persistence: it describes one or more persistence units (entity classes, connection properties, options), and the factory is created using [Persistence.createEntityManagerFactory("articles")];
  • a [hibernate.properties] (or [hibernate.cfg.xml]) file, specific to Hibernate, read by the [org.hibernate.cfg.Configuration] class: [new Configuration().addAnnotatedClass(Article.class).buildSessionFactory()]. This is the approach used in older versions of Hibernate;
  • in a Spring Boot application, the properties [spring.datasource.*] and [spring.jpa.*] from the file [application.properties]: Spring Boot creates the factory itself.

The properties are the same everywhere ([hibernate.format_sql], [jakarta.persistence.jdbc.url]...): only the way they are specified changes.

11.5. Basic operations: add, read, update, delete

11.5.1. The [crud] program

The [crud] module demonstrates the four basic operations (CRUD: Create, Read, Update, Delete), as well as entity tracking via the session. To make this tracking visible, it uses a few internal Hibernate methods (its “SPI,” Service Provider Interface), intended for Hibernate extensions rather than applications: they are grouped in the utility methods at the end of the program.

package crud;

import articles.Article;
import articles.BaseArticles;
import java.math.BigDecimal;
import java.util.Map;
import java.util.Objects;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.engine.spi.EntityEntry;
import org.hibernate.engine.spi.PersistenceContext;
import org.hibernate.engine.spi.SessionImplementor;
import org.hibernate.persister.entity.EntityPersister;

public class Main {

    public static void main(String[] args) {
        try (SessionFactory sessionFactory = BaseArticles.createSessionFactory()) {
            // the [articles] table is recreated with its six articles
            BaseArticles.initialize(sessionFactory);
            try (Session session = sessionFactory.openSession()) {
                // 1 - search by the primary key
                IO.println("--- find(1)");
                Article bike = session.find(Article.class, 1);
                IO.println(bike);
                IO.println("--- find(1) a second time");
                Article bike2 = session.find(Article.class, 1);
                IO.println("same object: " + (bike == bike2));

                // 2 - adding an article
                IO.println("--- persist");
                Transaction transaction = session.beginTransaction();
                Article x = new Article("x", BigDecimal.valueOf(100), 10, 1);
                IO.println("before persist: state=" + state(session, x) + ", id=" + x.getId());
                session.persist(x);
                IO.println("after persist: state=" + state(session, x) + ", id=" + x.getId());
                transaction.commit();

                // 3 - modification: it is enough to modify the object
                IO.println("--- modification of the price");
                transaction = session.beginTransaction();
                x.setPrice(x.getPrice().multiply(new BigDecimal("1.1")));
                IO.println("session modified: %s, original price=%s, current price=%s"
                        .formatted(session.isDirty(), originalValue(session, x, "price"), x.getPrice()));
                IO.println("--- commit");
                transaction.commit();
                IO.println("after commit: session modified: " + session.isDirty());

                // 4 - deletion
                IO.println("--- remove");
                transaction = session.beginTransaction();
                session.remove(x);
                IO.println("before commit: state=" + state(session, x));
                transaction.commit();
                IO.println("after commit: state=" + state(session, x));

                // 5 - a detached object, modified then merged with the session
                IO.println("--- detach and merge");
                Article bow = session.find(Article.class, 3);
                session.detach(bow);
                IO.println("after detach: state=" + state(session, bow));
                bow.setPrice(BigDecimal.valueOf(550));
                transaction = session.beginTransaction();
                Article managedBow = session.merge(bow);
                IO.println("merge: same object=%s, state of the returned object=%s"
                        .formatted(bow == managedBow, state(session, managedBow)));
                transaction.commit();

                // 6 - the entities tracked by the session
                IO.println("--- the tracked entities");
                bike.setCurrentStock(bike.getCurrentStock() - 1);
                displayTrackedEntities(session);
            }
        }
    }

    // the state of an article with respect to the session (Hibernate internal API)
    private static String state(Session session, Article article) {
        EntityEntry entry = context(session).getEntry(article);
        if (entry != null) {
            // MANAGED, DELETED...
            return entry.getStatus().name();
        }
        // an object unknown to the session: never saved (without an identifier) or detached
        return article.getId() == null ? "TRANSIENT" : "DETACHED";
    }

    // the value of a property as it was read from the database (Hibernate internal API)
    private static Object originalValue(Session session, Object entity, String property) {
        return context(session).getEntry(entity).getLoadedValue(property);
    }

    // the entities of the persistence context of the session (Hibernate internal API)
    private static void displayTrackedEntities(Session session) {
        for (Map.Entry<Object, EntityEntry> pair : context(session).reentrantSafeEntityEntries()) {
            EntityEntry entry = pair.getValue();
            EntityPersister persister = entry.getPersister();
            IO.println("%s #%s %s".formatted(persister.getEntityName(), entry.getId(), entry.getStatus()));
            // current values of the properties, compared with the values read from the database
            String[] names = persister.getPropertyNames();
            Object[] current = persister.getValues(pair.getKey());
            Object[] originals = entry.getLoadedState();
            for (int i = 0; i < names.length; i++) {
                String modification = Objects.equals(current[i], originals[i]) ? ""
                        : " (modified, original value: " + originals[i] + ")";
                IO.println("    %s : %s%s".formatted(names[i], current[i], modification));
            }
        }
        IO.println("session modified: " + session.isDirty());
    }

    // the persistence context of the session (Hibernate internal API)
    private static PersistenceContext context(Session session) {
        return session.unwrap(SessionImplementor.class).getPersistenceContext();
    }
}

Results:

> mvn compile exec:java
--- find(1)
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> where a1_0.id=?
    SQL> -- parameter 1 = 1
[1, bike, 500.00, 10, 5]
--- find(1) a second time
same object: true
--- persist
    SQL> -- start of transaction
before persist: state=TRANSIENT, id=null
    SQL> insert into articles (currentStock, minimumStock, name, price)
    SQL> values (?, ?, ?, ?)
    SQL> -- parameter 1 = 10
    SQL> -- parameter 2 = 1
    SQL> -- parameter 3 = x
    SQL> -- parameter 4 = 100
after persist: state=MANAGED, id=7
    SQL> -- transaction committed (COMMIT)
--- modification of the price
    SQL> -- start of transaction
session modified: true, original price=100, current price=110.0
--- commit
    SQL> update articles
    SQL> set currentStock=?, minimumStock=?, name=?, price=?
    SQL> where id=?
    SQL> -- parameter 1 = 10
    SQL> -- parameter 2 = 1
    SQL> -- parameter 3 = x
    SQL> -- parameter 4 = 110.0
    SQL> -- parameter 5 = 7
    SQL> -- transaction committed (COMMIT)
after commit: session modified: false
--- remove
    SQL> -- start of transaction
before commit: state=DELETED
    SQL> delete from articles
    SQL> where id=?
    SQL> -- parameter 1 = 7
    SQL> -- transaction committed (COMMIT)
after commit: state=DETACHED
--- detach and merge
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> where a1_0.id=?
    SQL> -- parameter 1 = 3
after detach: state=DETACHED
    SQL> -- start of transaction
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> where a1_0.id=?
    SQL> -- parameter 1 = 3
merge: same object=false, state of the returned object=MANAGED
    SQL> update articles
    SQL> set currentStock=?, minimumStock=?, name=?, price=?
    SQL> where id=?
    SQL> -- parameter 1 = 4
    SQL> -- parameter 2 = 1
    SQL> -- parameter 3 = bow
    SQL> -- parameter 4 = 550
    SQL> -- parameter 5 = 3
    SQL> -- transaction committed (COMMIT)
--- the tracked entities
articles.Article #1 MANAGED
    currentStock : 9 (modified, original value: 10)
    minimumStock : 5
    name : bike
    price : 500.00
articles.Article #3 MANAGED
    currentStock : 4
    minimumStock : 1
    name : bow
    price : 550
session modified: true

Comments:

  • Line 25: [session.find(Article.class, 1)] searches for the primary key record 1. It returns [null] if the key does not exist. The generated SQL (lines 3–6 of the results) is a parameterized [SELECT], where the parameter is set to 1: the compact log now displays the parameter values;
  • line 28: the second call to [find(Article.class, 1)] does not send any SQL commands and returns the same object (line 9). The session stores the entities it has read or saved in its persistence context—also known as the first-level cache: before querying the database, [find] checks to see if the requested entity is already there. A session never contains two objects for the same row in the database;
  • Lines 34–37: adding an item. Before [persist], the object is [TRANSIENT] (“transient”): the session does not recognize it, and its identifier is [null] (line 12). [persist] brings it into the session, which immediately executes the command [INSERT] (lines 13–18) to obtain the generated key and places it in the field [id]. The item is then [MANAGED] (“managed” or “persistent”) and its identifier is 7 (line 19). The transaction is opened on line 33 and committed on line 38 (lines 11 and 20 of the results);
  • line 43: To modify a row in the database, simply modify the object. No [update] method is required: the session has retained a copy of the values read or saved (the original state) and compares the current values to those. This is known as “dirty checking”;
  • lines 44–45: [session.isDirty()] indicates whether the session contains changes that have not yet been committed to the database: this is the case (line 23). The method [originalValue] (lines 90–92) reads the original price value, 100, from the persistence context, whereas the current value is 110.0;
  • Line 47: [commit] synchronizes the session with the database (“flush”): the [UPDATE] command is executed (lines 25–32), and then the transaction is committed. After that, the session has no pending changes (line 34);
  • note that the [UPDATE] command writes all columns in the table, not just the one that has changed. This is Hibernate’s default behavior: a single SQL command, prepared once and for all, is used for all article updates. In contrast, the [@DynamicUpdate] annotation placed on the entity instructs it to generate a query limited to the modified columns each time;
  • Lines 53–56: deletion. [remove] changes the article’s state to [DELETED] (line 37); [commit] sends the command [DELETE] (lines 38–40). The object is then no longer recognized by the session: it becomes [DETACHED] (“detached,” line 42);
  • lines 60–63: the bow is read (lines 44–47), then detached from the session by [detach]: the session forgets it (line 48). Any change to its price is then ignored. A detached object is a common occurrence in an application: it is an object read by a session that is now closed—for example, during a previous HTTP query—and subsequently modified by the user;
  • line 65: [session.merge(bow)] merges the detached object with the session. The session reads the record from the database (lines 50–53), copies the state of the detached object into the entity it manages, and returns the latter: this is not the same object (line 54). The object passed to [merge] remains detached; the returned object is the one that must be used next. [commit] saves the new price (lines 55–62);
  • line 72: the bike’s inventory is updated, without saving;
  • lines 95–111: display of the persistence context. For each tracked entity ([EntityEntry]), its name, ID, and status are displayed, followed by the current value of each property and its original value if different. The [EntityPersister] object is the one in Hibernate that knows the mapping between an entity and its table: it provides the names of the properties and their values;
  • Lines 65–75 of the results: the session tracks the bike, whose inventory has changed from 10 to 9, and the bow. The change to the bike is not saved: the session is closed without synchronization. There is no [commit], and a session does not synchronize automatically upon closure.

11.5.2. The States of an Entity

An entity is, with respect to a session, in one of the following states (Jakarta Persistence refers to them as “new,” “managed,” “detached,” and “removed”):

State
Meaning
Transient ([TRANSIENT])
The object was created by [new] and has never been saved: it has no identifier and no session is aware of it
Managed ([MANAGED])
The object has been read or saved by the session, which is tracking it: changes to it will be reflected in the database during the next synchronization
Deleted ([DELETED])
The object has been marked for deletion ([remove]): the next synchronization will send the command [DELETE]
detached ([DETACHED])
The object corresponds to a row in the database, but the session that read it is closed or has forgotten it ([detach], [clear]): its changes are ignored unless it is merged with a session ([merge])

The session synchronization (“flush”) sends the SQL commands to DBMS, which propagate the changes to the managed entities. This occurs:

  • during the [commit] phase of the transaction;
  • before executing a query whose result might depend on changes that have not yet been committed ([FlushMode.AUTO] mode, the default mode): the query thus sees the session data;
  • during an explicit call to [session.flush()]. The commands are then executed, but they will not be permanently committed until the transaction’s [commit].

Here’s an overview of the workflow with Hibernate: we open a session and a transaction, read entities, modify them as ordinary objects, add them ([persist]), and delete them ([remove]), then commit the transaction, which sends all necessary commands to the database in a single batch. The session acts as a “unit of work.” It is a lightweight object designed for a short lifespan: a unit of work, such as a HTTP query in a web application. You do not use the same session throughout the entire life of an application; otherwise, it would accumulate the tracked entities in its persistence context.

11.6. Queries HQL

11.6.1. HQL and JPQL

To retrieve entities other than by their primary key, queries are written in a language similar to SQL, but one that refers to entities and attributes rather than tables and columns: HQL (Hibernate Query Language). Jakarta Persistence defines a similar language, JPQL (Jakarta Persistence Query Language), of which HQL is a superset: a JPQL query is a valid HQL query. Hibernate translates the query into SQL, in the DBMS dialect, and DBMS executes it.

Some differences from SQL:

  • We use the entity name ([Article]), not the table name ([articles]), and the attribute names ([currentStock]), not the column names. Entity and attribute names are case-sensitive, just like in Java;
  • the clause [select] is optional in HQL: [from Article] returns entities [Article]. JPQL requires writing [select a from Article a];
  • relationships between entities are traversed using a dot ([p.category.name]), with Hibernate writing the joins itself, as we will see.

A query is created by the session’s [createSelectionQuery] method (Hibernate 6), which takes the text HQL and the result type as arguments, and returns a [SelectionQuery<T>] object. You can then set its parameters and execute it with [getResultList] (a list), [getSingleResult] (a single result; an exception if there are zero or multiple results), [getSingleResultOrNull] ([null] if there are none), or [getResultStream] (a stream). The Jakarta Persistence method [createQuery] also exists; it is used for both read queries and updates.

11.6.2. The program [hql]

The module [hql] provides several examples of queries:

package hql;

import articles.Article;
import articles.BaseArticles;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.query.SemanticException;

public class Main {

    // a projection: the name of an article and the value of its stock
    record StockValue(String name, BigDecimal value) {
    }

    public static void main(String[] args) {
        try (SessionFactory sessionFactory = BaseArticles.createSessionFactory()) {
            // the [articles] table is recreated with its six articles
            BaseArticles.initialize(sessionFactory);
            try (Session session = sessionFactory.openSession()) {
                // 1 - filtering and sorting
                IO.println("--- the articles to restock, in alphabetical order");
                String hql = "from Article where currentStock < minimumStock order by name";
                List<Article> toOrder = session.createSelectionQuery(hql, Article.class).getResultList();
                toOrder.forEach(IO::println);

                // 2 - projection: only the useful columns are requested
                IO.println("--- value of the stock of each article");
                List<StockValue> values = session
                        .createSelectionQuery("""
                                select name, price * currentStock from Article
                                order by price * currentStock desc""", StockValue.class)
                        .getResultList();
                for (StockValue v : values) {
                    IO.println("%s : %.2f euros".formatted(v.name(), v.value()));
                }

                // 3 - a single result; the value [minPrice] is passed by a named parameter
                IO.println("--- the first article over 200 euros");
                BigDecimal minPrice = BigDecimal.valueOf(200);
                Article article = session
                        .createSelectionQuery("""
                                select a from Article a
                                where a.price > :minPrice order by a.price""", Article.class)
                        .setParameter("minPrice", minPrice)
                        .setMaxResults(1)
                        .getSingleResultOrNull();
                IO.println(article == null ? "none" : article);

                // 4 - aggregates: the computation is done by the DBMS
                IO.println("--- aggregates");
                long nbArticles = session.createSelectionQuery("select count(*) from Article", Long.class)
                        .getSingleResult();
                BigDecimal stockValue = session
                        .createSelectionQuery("select sum(price * currentStock) from Article", BigDecimal.class)
                        .getSingleResult();
                IO.println("%d articles, value of the stock: %.2f euros".formatted(nbArticles, stockValue));

                // 5 - like and in; a Java list as the value of a parameter
                IO.println("--- like and in");
                List<String> names = List.of("bow", "pump", "racket");
                List<String> results = session
                        .createSelectionQuery("""
                                select name from Article
                                where name like 'b%' or name in :names""", String.class)
                        .setParameter("names", names)
                        .getResultList();
                IO.println(String.join(", ", results));

                // 6 - pagination: the third page of two articles, by ascending price
                IO.println("--- pagination");
                int pageSize = 2, pageNumber = 3;
                List<Article> page = session.createSelectionQuery("from Article order by price", Article.class)
                        .setFirstResult((pageNumber - 1) * pageSize)
                        .setMaxResults(pageSize)
                        .getResultList();
                IO.println("page %d : %s".formatted(pageNumber, page.stream()
                        .map(a -> "%s (%.2f euros)".formatted(a.getName(), a.getPrice()))
                        .collect(Collectors.joining(", "))));

                // 7 - an incorrect query is rejected as soon as it is created, before any execution
                IO.println("--- incorrect query");
                try {
                    session.createSelectionQuery("from Article where stock < minimumStock", Article.class);
                } catch (SemanticException e) {
                    IO.println(e.getClass().getSimpleName() + " : " + e.getMessage());
                }

                // 8 - a rule written in Java cannot be translated into SQL: in-memory filtering
                IO.println("--- in-memory filtering");
                try (Stream<Article> stream = session
                        .createSelectionQuery("from Article", Article.class)
                        .getResultStream()) {
                    stream.filter(Rules::isOutOfStock).forEach(IO::println);
                }
            }
        }
    }

    // a business rule written in Java
    static final class Rules {

        private Rules() {
        }

        // an article is out of stock if its stock is below the minimum stock
        static boolean isOutOfStock(Article a) {
            return a.getCurrentStock() < a.getMinimumStock();
        }
    }
}

Results:

> mvn compile exec:java
--- the articles to restock, in alphabetical order
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> where a1_0.currentStock<a1_0.minimumStock
    SQL> order by a1_0.name
[4, arrows - set of 6, 100.00, 12, 20]
--- value of the stock of each article
    SQL> select a1_0.name, (a1_0.price*a1_0.currentStock)
    SQL> from articles a1_0
    SQL> order by (a1_0.price*a1_0.currentStock) desc
bike : 5000.00 euros
bow : 2400.00 euros
diving suit : 2400.00 euros
arrows - set of 6 : 1200.00 euros
diver's oxygen tanks : 1200.00 euros
pump : 100.00 euros
--- the first article over 200 euros
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> where a1_0.price>?
    SQL> order by a1_0.price
    SQL> limit ?
    SQL> -- parameter 1 = 200
    SQL> -- parameter 2 = 1
[5, diving suit, 300.00, 8, 2]
--- aggregates
    SQL> select count(*)
    SQL> from articles a1_0
    SQL> select sum((a1_0.price*a1_0.currentStock))
    SQL> from articles a1_0
6 articles, value of the stock: 12300.00 euros
--- like and in
    SQL> select a1_0.name
    SQL> from articles a1_0
    SQL> where a1_0.name like 'b%' escape '' or a1_0.name in (?, ?, ?)
    SQL> -- parameter 1 = bow
    SQL> -- parameter 2 = pump
    SQL> -- parameter 3 = racket
bike, bow, pump
--- pagination
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> order by a1_0.price
    SQL> limit ?, ?
    SQL> -- parameter 1 = 4
    SQL> -- parameter 2 = 2
page 3 : bike (500.00 euros), bow (600.00 euros)
--- incorrect query
SemanticException : Could not interpret path expression 'stock'
--- in-memory filtering
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
[4, arrows - set of 6, 100.00, 12, 20]

Comments:

  • Lines 26–27: The clause [where] compares two attributes of the entity; the [order by] clause sorts by name: they become the [WHERE] and [ORDER BY] clauses in the SQL sequence (lines 5–6). The query is executed by [getResultList];
  • lines 32–36: a projection. The query does not return entities, but rather two values per item: the name and the stock value. The generated SQL (line 9) requests only the necessary columns and performs the calculation itself. The result type is the [StockValue] record in lines 16–17: Hibernate creates one record per line, passing the values from the [select] clause to its constructor in order. These objects are not entities: the session does not track them. The query is written in a text block (Java 15, Chapter 3), which is more readable than a long string;
  • lines 44–50: a query written as in JPQL, with an alias ([a]). [:minPrice] is a named parameter, whose value is given by [setParameter]. It becomes a parameter of the order SQL (line 21): therefore, we do not need to worry about SQL injections, since the value is transmitted separately from the order text. This is the rule that Chapter 10 applied to prepared queries. [setMaxResults(1)] limits the result to one row: the clause [LIMIT] of MySQL (line 23). A literal value written in the query text (such as ['b%'] on line 68: line 36 of the results) is, on the other hand, copied into the order text SQL: values coming from outside the program must always be passed as parameters;
  • lines 55–59: the aggregates [count] and [sum] are calculated by DBMS (lines 28–31): only the result is transferred, not the rows. HQL also references [avg], [min], [max], the clauses [group by], and [having];
  • Lines 65–70: the operator [like] of SQL, and the operator [in], whose operand is a parameter that accepts a Java list: Hibernate expands it into as many SQL parameters as there are elements in the list (line 36). HQL supports numerous functions on strings ([upper], [length], [substring]...), dates, and numbers, which it translates into the DBMS dialect;
  • lines 76–79: pagination, to display a long list page by page. [setFirstResult] skips the entries from the previous pages, while [setMaxResults] keeps those from the requested page: the [LIMIT ?, ?] clause of MySQL (lines 45–47). Pagination only makes sense on a sorted list ([order by]). Lines 80–82 construct the page display using a stream and [Collectors.joining] (Chapter 5);
  • lines 86–90: a query that uses an attribute [stock] that does not exist. The error is detected as soon as the query is created, before any execution, because Hibernate parses the text HQL and compares it to the model: it throws a [SemanticException] (line 50; Hibernate messages are in English). A syntax error would result in a [SyntaxException];
  • lines 94–98: a query cannot use a Java method, such as [Rules.isOutOfStock] (lines 110–112): Hibernate does not know the code, and even if it did, it would not necessarily know how to write it as SQL. You can read all entities and filter them in memory using the API Stream: [getResultStream] returns a [Stream<Article>] (which must be closed after use, hence the [try] statement with resources). The result is correct (line 54), but the entire table was transferred (lines 52–53): with a large table, this should be avoided. It is better to include the condition in the query, as shown on line 26.

11.6.3. Named Queries and Compile-Time Validation

A query written in a string is only parsed at runtime. There are two ways to detect errors earlier:

  • named queries: the annotation ***[@NamedQuery(name = "Article.outOfStock", query = "from Article where currentStock < minimumStock")]***, placed on the entity, declares a query that Hibernate parses at startup, when creating the *[SessionFactory]*. It is then executed by its name: [session.createNamedSelectionQuery("Article.outOfStock", Article.class)];
  • the Hibernate annotation processor (“Hibernate Processor,” library [org.hibernate.orm:hibernate-processor]), declared in the compiler configuration ([annotationProcessorPaths] from [maven-compiler-plugin]). Executed by [javac] during compilation, it checks annotation requests and generates, for each entity, a “metamodel” class " class ([Article_] for [Article]), which we will encounter again with the API Criteria. We do not use it in this course.

11.7. The API Criteria

11.7.1. Queries built using code

A HQL query is a string that can be constructed by concatenation: this is unreliable and hard to read as soon as the query depends on many optional criteria (a search form, for example). Jakarta Persistence offers another way to write queries: API Criteria, where the query is a tree of objects constructed through method calls. Hibernate translates this tree into SQL, just as it does with a HQL query (it also translates HQL into such a tree). The [criteria] module reuses several queries from the previous program:

package criteria;

import articles.Article;
import articles.BaseArticles;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Expression;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.query.criteria.HibernateCriteriaBuilder;

public class Main {

    // a projection: the name of an article and the value of its stock
    record StockValue(String name, BigDecimal value) {
    }

    public static void main(String[] args) {
        try (SessionFactory sessionFactory = BaseArticles.createSessionFactory()) {
            // the [articles] table is recreated with its six articles
            BaseArticles.initialize(sessionFactory);
            try (Session session = sessionFactory.openSession()) {
                // the factory of the query elements
                HibernateCriteriaBuilder cb = session.getCriteriaBuilder();

                // 1 - filtering and sorting
                IO.println("--- the articles to restock, in alphabetical order");
                CriteriaQuery<Article> query = cb.createQuery(Article.class);
                Root<Article> a = query.from(Article.class);
                query.select(a)
                        .where(cb.lt(a.<Integer>get("currentStock"), a.<Integer>get("minimumStock")))
                        .orderBy(cb.asc(a.get("name")));
                session.createSelectionQuery(query).getResultList().forEach(IO::println);

                // 2 - projection into a record
                IO.println("--- value of the stock of each article");
                CriteriaQuery<StockValue> projection = cb.createQuery(StockValue.class);
                Root<Article> p = projection.from(Article.class);
                Expression<BigDecimal> value = cb
                        .prod(p.<BigDecimal>get("price"), p.<Integer>get("currentStock"))
                        .as(BigDecimal.class);
                projection.select(cb.construct(StockValue.class, p.get("name"), value))
                        .orderBy(cb.desc(value));
                for (StockValue v : session.createSelectionQuery(projection).getResultList()) {
                    IO.println("%s : %.2f euros".formatted(v.name(), v.value()));
                }

                // 3 - a query built according to the provided criteria
                IO.println("--- search: maximum price 150 euros");
                search(session, BigDecimal.valueOf(150), null).forEach(IO::println);
                IO.println("--- search: maximum price 150 euros and name starting with [d]");
                search(session, BigDecimal.valueOf(150), "d").forEach(IO::println);

                // 4 - aggregates
                IO.println("--- aggregates");
                CriteriaQuery<Long> count = cb.createQuery(Long.class);
                count.select(cb.count(count.from(Article.class)));
                long nbArticles = session.createSelectionQuery(count).getSingleResult();
                CriteriaQuery<BigDecimal> sum = cb.createQuery(BigDecimal.class);
                Root<Article> s = sum.from(Article.class);
                sum.select(cb.sum(cb
                        .prod(s.<BigDecimal>get("price"), s.<Integer>get("currentStock"))
                        .as(BigDecimal.class)));
                BigDecimal stockValue = session.createSelectionQuery(sum).getSingleResult();
                IO.println("%d articles, value of the stock: %.2f euros".formatted(nbArticles, stockValue));
            }
        }
    }

    // the articles whose price does not exceed [maxPrice] and whose name starts with [nameStart],
    // each criterion being used only if it is not null
    static List<Article> search(Session session, BigDecimal maxPrice, String nameStart) {
        HibernateCriteriaBuilder cb = session.getCriteriaBuilder();
        CriteriaQuery<Article> query = cb.createQuery(Article.class);
        Root<Article> a = query.from(Article.class);
        // the conditions, added one by one
        List<Predicate> conditions = new ArrayList<>();
        if (maxPrice != null) {
            conditions.add(cb.le(a.get("price"), maxPrice));
        }
        if (nameStart != null) {
            conditions.add(cb.like(a.get("name"), nameStart + "%"));
        }
        query.select(a).where(conditions.toArray(Predicate[]::new)).orderBy(cb.asc(a.get("price")));
        return session.createSelectionQuery(query).getResultList();
    }
}

Results:

> mvn compile exec:java
--- the articles to restock, in alphabetical order
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> where a1_0.currentStock<a1_0.minimumStock
    SQL> order by a1_0.name
[4, arrows - set of 6, 100.00, 12, 20]
--- value of the stock of each article
    SQL> select a1_0.name, (a1_0.price*a1_0.currentStock)
    SQL> from articles a1_0
    SQL> order by (a1_0.price*a1_0.currentStock) desc
bike : 5000.00 euros
bow : 2400.00 euros
diving suit : 2400.00 euros
arrows - set of 6 : 1200.00 euros
diver's oxygen tanks : 1200.00 euros
pump : 100.00 euros
--- search: maximum price 150 euros
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> where a1_0.price<=?
    SQL> order by a1_0.price
    SQL> -- parameter 1 = 150
[2, pump, 10.00, 10, 2]
[4, arrows - set of 6, 100.00, 12, 20]
[6, diver's oxygen tanks, 120.00, 10, 5]
--- search: maximum price 150 euros and name starting with [d]
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> where a1_0.price<=? and a1_0.name like ? escape ''
    SQL> order by a1_0.price
    SQL> -- parameter 1 = 150
    SQL> -- parameter 2 = d%
[6, diver's oxygen tanks, 120.00, 10, 5]
--- aggregates
    SQL> select count(a1_0.id)
    SQL> from articles a1_0
    SQL> select sum((a1_0.price*a1_0.currentStock))
    SQL> from articles a1_0
6 articles, value of the stock: 12300.00 euros

Comments:

  • line 28: the session returns a [HibernateCriteriaBuilder], the Hibernate-enhanced version of Jakarta Persistence’s [CriteriaBuilder]: this is the factory for all elements of a query (conditions, expressions, sorts);
  • Lines 32–36: The query from the first example, HQL. [createQuery(Article.class)] creates a query that returns articles; [from(Article.class)] declares the queried entity and returns its root ([Root<Article>]), which is the equivalent of the alias [a] for [from Article a]; [a.get("currentStock")] designates an attribute. The condition is constructed by [cb.lt(x, y)] (“less than,” [x < y]), and the sort by [cb.asc(...)]. The notation [a.<Integer>get(...)] provides the compiler with the attribute’s type, which the string ["currentStock"] does not allow it to determine;
  • line 37: the query is executed by the session as a HQL query. The generated SQL (lines 3–6) is the same as that of the HQL query;
  • Lines 41–47: a projection onto the record [StockValue]. [cb.prod(x, y)] is the product of two expressions; [as(BigDecimal.class)] specifies the result type; [cb.construct(StockValue.class, ...)] indicates that each row of the result must be passed to the record constructor;
  • Lines 76–90: This is where the API Criteria is most useful. The [search] method constructs a query based on the criteria provided to it: each condition is added to the list of conditions only if the corresponding criterion is not [null], and the [where] clause receives all the conditions from the list, combined by [and]. The first call (line 54) produces a query with one condition (line 21), while the second (line 56) produces a query with two conditions (line 30). Note that with the API criteria, Hibernate passes the values ([150], [d%]) as parameters in the order SQL;
  • lines 60–68: the aggregates [count] and [sum].

11.7.2. The static metamodel

The weakness of these queries is that attributes are designated by strings ([a.get("currentStock")]): a typo will not be detected until runtime, and the compiler ignores the attribute’s type. To address this, Jakarta Persistence provides a static metamodel: for each entity [Article], there is a class [Article_] that contains one field per attribute, describing its name and type. You then write ***[cb.lt(a.get(Article\_.currentStock), a.get(Article\_.minimumStock))]***, which the compiler fully validates. These classes are not written by hand: they are generated during compilation by the Hibernate annotation processor mentioned above. This is the recommended approach in a real-world project that uses API Criteria.

For a fixed query, HQL is more readable than the API Criteria; for a query that is constructed based on the circumstances, the API Criteria is safer. The two can be combined: Hibernate allows you to create a Criteria query from a HQL ([cb.createQuery(hql, Article.class)]) string and then supplement it with code.

11.8. Relationships

11.8.1. The Store Model

The tables in a database are linked to one another. Hibernate represents these links as associations: fields that reference another entity or a collection of entities. The [relations] module uses a [hbshop] database that describes a small store with six entities. A category groups products together:

// a category groups several products (one-to-many relationship)
@Entity
@Table(name = "categories")
public class Category {

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

    @Column(nullable = false, length = 30)
    private String name;

    // the products of the category: the inverse side of the relationship, described by Product.category
    @OneToMany(mappedBy = "category", cascade = CascadeType.PERSIST)
    private List<Product> products = new ArrayList<>();

    protected Category() {
    }

    public Category(String name) {
        this.name = name;
    }

    // adding a product: the two sides of the relationship are updated
    public void add(Product product) {
        products.add(product);
        product.setCategory(this);
    }

    public Integer getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public List<Product> getProducts() {
        return products;
    }
}

A product belongs to a category and has tags:

// a product belongs to a category and carries several tags
@Entity
@Table(name = "products")
public class Product {

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

    @Column(nullable = false, length = 30)
    private String name;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;

    // many-to-one relationship: the foreign key [category_id] of the [products] table
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "category_id", foreignKey = @ForeignKey(name = "fk_products_category"))
    private Category category;

    // many-to-many relationship, stored in an association table
    @ManyToMany(cascade = CascadeType.PERSIST)
    @JoinTable(name = "products_tags",
            joinColumns = @JoinColumn(name = "product_id"),
            inverseJoinColumns = @JoinColumn(name = "tag_id"),
            foreignKey = @ForeignKey(name = "fk_products_tags_product"),
            inverseForeignKey = @ForeignKey(name = "fk_products_tags_tag"))
    private Set<Tag> tags = new HashSet<>();

    protected Product() {
    }

    public Product(String name, BigDecimal price, Tag... tags) {
        this.name = name;
        this.price = price;
        // only the owning side of the relationship is filled in: it is the one Hibernate saves
        this.tags.addAll(List.of(tags));
    }

A tag is assigned to multiple products:

// a tag (promo, new...) is carried by several products
@Entity
@Table(name = "tags")
public class Tag {

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

    @Column(nullable = false, length = 20)
    private String label;

    // the inverse side of the relationship described by Product.tags
    @ManyToMany(mappedBy = "tags")
    private Set<Product> products = new HashSet<>();

    protected Tag() {
    }

    public Tag(String label) {
        this.label = label;
    }

An order belongs to a customer and contains line items:

// an order of a client, made of lines
@Entity
@Table(name = "orders")
public class CustomerOrder {

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

    private LocalDate date;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "client_id")
    private Client client;

    @OneToMany(mappedBy = "order", cascade = CascadeType.PERSIST)
    private List<OrderLine> lines = new ArrayList<>();

    protected CustomerOrder() {
    }

    public CustomerOrder(LocalDate date) {
        this.date = date;
    }

    // adding a line: the two sides of the relationship are updated
    public void add(OrderLine line) {
        lines.add(line);
        line.setOrder(this);
    }

The entities [Client] and [OrderLine] are built on the same model. A customer has an ID, a name, and a list of orders ([@OneToMany(mappedBy = "client", cascade = CascadeType.PERSIST)]), with a method [add(CustomerOrder)]; an order line has an ID, a quantity, and two many-to-one associations—one to its order and one to its product:

// an order line: a quantity of a product
@Entity
@Table(name = "order_lines")
public class OrderLine {

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

    private int quantity;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "order_id")
    private CustomerOrder order;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "product_id")
    private Product product;

    protected OrderLine() {
    }

    public OrderLine(Product product, int quantity) {
        this.product = product;
        this.quantity = quantity;
    }

This example includes all three types of associations:

  • a many-to-one relationship ([@ManyToOne]): a product belongs to a category (Product, lines 17–19). In the database, this is represented by a foreign key, the column [category_id] in table [products], where [@JoinColumn] specifies the name and the constraint name ([fk_products_category]). [optional = false] enforces the relationship: the column will be [NOT NULL];
  • the inverse, one-to-many relationship ([@OneToMany]): a category contains multiple products (Category, lines 14–15). It does not create anything in the database: [mappedBy = "category"] indicates that it is described by the field [category] of the entity [Product]. [Product.category] is said to be the owner side of the relationship, and [Category.products] its inverse side: Hibernate saves the value of the owner side. However, both sides must be kept consistent in memory: this is the role of the [add] method (Category, lines 25–28), which adds the product to the list and sets its category;
  • a many-to-many relationship ([@ManyToMany]): a product has multiple labels, and a label is associated with multiple products. It is stored in an association table, [products_tags], whose name, the names of the two columns, and the names of the two foreign keys are provided by [@JoinTable] (Product, lines 22–28). The reverse side is [Tag.products] ([mappedBy = "tags"]). For a many-to-many relationship, a [Set] is preferred over a [List]: with a list, Hibernate deletes and re-inserts all rows in the association table every time the collection is modified;
  • [fetch = FetchType.LAZY] requires lazy loading of a product’s category: it will only be read if it is used. This is the default behavior for collections ([@OneToMany], [@ManyToMany]), but not for relationships to a single entity ([@ManyToOne]), which the standard loads immediately by default ([EAGER]): [fetch = FetchType.LAZY] is systematically added to them, as recommended by the Hibernate documentation;
  • [cascade = CascadeType.PERSIST] propagates the operation [persist]: persisting a category persists its products, and persisting a product persists its tags. The other values ([MERGE], [REMOVE], [ALL]...) propagate the other operations;
  • collections are initialized with an empty collection, and the field of a relationship to a single entity remains [null] until it has been populated. The methods [setCategory], [setClient], and [setOrder] are not public: only the [add] methods in the same package call them.

The [Shop] class configures Hibernate for the [hbshop] database and creates an initial dataset there:

package relations;

import java.math.BigDecimal;
import java.time.LocalDate;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.jpa.HibernatePersistenceConfiguration;
import tools.SqlLog;

// the [hbshop] database: Hibernate configuration and first data set
public final class Shop {

    private Shop() {
    }

    // a SessionFactory for the [hbshop] database and its six entities
    public static SessionFactory createSessionFactory() {
        return new HibernatePersistenceConfiguration("shop")
                .managedClasses(Category.class, Product.class, Tag.class,
                        Client.class, CustomerOrder.class, OrderLine.class)
                .jdbcUrl("jdbc:mysql://localhost:3306/hbshop?createDatabaseIfNotExist=true")
                .jdbcCredentials("root", "")
                .property(AvailableSettings.FORMAT_SQL, true)
                .createEntityManagerFactory();
    }

    // (re)creation of the tables and of a first data set, without an SQL log
    public static void initialize(SessionFactory sessionFactory) {
        SqlLog.withoutLog(() -> {
            sessionFactory.getSchemaManager().drop(false);
            sessionFactory.getSchemaManager().create(false);
            sessionFactory.inTransaction(session -> {
                // two tags
                Tag promo = new Tag("promo");
                Tag novelty = new Tag("new");
                // three categories and their products: a graph of objects
                Category cycles = new Category("cycles");
                cycles.add(new Product("bike", BigDecimal.valueOf(500), promo));
                Product pump = new Product("pump", BigDecimal.valueOf(10));
                cycles.add(pump);
                Category archery = new Category("archery");
                Product bow = new Product("bow", BigDecimal.valueOf(600), novelty);
                archery.add(bow);
                archery.add(new Product("arrows - set of 6", BigDecimal.valueOf(100),
                        promo, novelty));
                Category diving = new Category("diving");
                // the persistence is propagated to the products and to the tags (cascade)
                session.persist(cycles);
                session.persist(archery);
                session.persist(diving);
                // a client and their order of two lines
                CustomerOrder order = new CustomerOrder(LocalDate.of(2026, 9, 28));
                order.add(new OrderLine(pump, 2));
                order.add(new OrderLine(bow, 1));
                Client martin = new Client("Martin");
                martin.add(order);
                session.persist(martin);
            });
        });
    }
}
  • lines 19–20: the six entities, declared by [managedClasses];
  • lines 34–46: an object graph: three categories, their products, and the products’ labels;
  • lines 48–50: it is sufficient to persist the categories: thanks to cascading, Hibernate traverses the associations and persists all the entities it reaches, in the correct order (the categories before the products that reference them), populating the foreign keys and the association table;
  • lines 52–57: a customer, their order, and its two line items, persisted in a single operation.

The tables created by Hibernate are as follows:

> mysql -uroot hbshop -e "SHOW TABLES; SHOW CREATE TABLE products\G SHOW CREATE TABLE products_tags\G"
Tables_in_hbshop
categories
clients
order_lines
orders
products
products_tags
tags
*************************** 1. row ***************************
       Table: products
Create Table: CREATE TABLE `products` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(30) NOT NULL,
  `price` decimal(10,2) NOT NULL,
  `category_id` int NOT NULL,
  PRIMARY KEY (`id`),
  KEY `fk_products_category` (`category_id`),
  CONSTRAINT `fk_products_category` FOREIGN KEY (`category_id`) REFERENCES `categories` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
*************************** 1. row ***************************
       Table: products_tags
Create Table: CREATE TABLE `products_tags` (
  `product_id` int NOT NULL,
  `tag_id` int NOT NULL,
  PRIMARY KEY (`product_id`,`tag_id`),
  KEY `fk_products_tags_tag` (`tag_id`),
  CONSTRAINT `fk_products_tags_product` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`),
  CONSTRAINT `fk_products_tags_tag` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
  • Lines 3–9: one table per entity, and the association table [products_tags];
  • Lines 16–19: The foreign key [category_id] in table [products] references table [categories]. It is [NOT NULL] ([optional = false]). MySQL creates an index on each foreign key (line 18). Unlike other ORM statements, Hibernate does not require cascade deletion in the database ([ON DELETE CASCADE]): this can be achieved using the [@OnDelete(action = OnDeleteAction.CASCADE)] annotation;
  • lines 23–30: The association table has two columns—foreign keys to the two related tables—which together form its primary key.

11.8.2. The program

package relations;

import jakarta.persistence.EntityGraph;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.stream.Collectors;
import org.hibernate.LazyInitializationException;
import org.hibernate.Session;
import org.hibernate.SessionFactory;

public class Main {

    // the results of the projection queries
    record ProductCategory(String product, String category) {
    }

    record OrderAmount(Integer id, String client, BigDecimal amount) {
    }

    record CategoryStatistics(String category, Long count, Double averagePrice) {
    }

    public static void main(String[] args) {
        try (SessionFactory sessionFactory = Shop.createSessionFactory()) {
            // 1 - creation of the tables and of a first data set, without a log
            Shop.initialize(sessionFactory);

            // 2 - a client, their order and its lines, added in one go
            IO.println("--- addition of a client, an order and its two lines");
            sessionFactory.inTransaction(session -> {
                Product bike = product(session, "bike");
                Product arrows = product(session, "arrows - set of 6");
                CustomerOrder order = new CustomerOrder(LocalDate.of(2026, 10, 1));
                order.add(new OrderLine(bike, 1));
                order.add(new OrderLine(arrows, 3));
                Client client = new Client("Dupont");
                client.add(order);
                session.persist(client);
                IO.println("client no. %d, order no. %d".formatted(client.getId(), order.getId()));
            });

            // 3 - lazy loading: the relationships are read on demand
            IO.println("--- lazy loading");
            sessionFactory.inSession(session -> {
                List<CustomerOrder> orders = session
                        .createSelectionQuery("from CustomerOrder order by id", CustomerOrder.class)
                        .getResultList();
                for (CustomerOrder order : orders) {
                    IO.println("order no. %d : client=%s, %d line(s)".formatted(order.getId(),
                            order.getClient().getName(), order.getLines().size()));
                }
            });

            // 4 - a relationship that is not loaded is no longer accessible once the session is closed
            IO.println("--- after the session is closed");
            CustomerOrder detachedOrder = sessionFactory.fromSession(s -> s.find(CustomerOrder.class, 1));
            try {
                IO.println(detachedOrder.getLines().size() + " line(s)");
            } catch (LazyInitializationException e) {
                IO.println(e.getClass().getSimpleName() + " : " + e.getMessage());
            }

            // 5 - join fetch: the orders, their client, their lines and their products in one query
            IO.println("--- join fetch");
            sessionFactory.inSession(session -> {
                List<CustomerOrder> orders = session.createSelectionQuery("""
                        from CustomerOrder c
                        join fetch c.client
                        join fetch c.lines l
                        join fetch l.product
                        order by c.id""", CustomerOrder.class).getResultList();
                for (CustomerOrder order : orders) {
                    IO.println("order no. %d of %s for %s".formatted(order.getId(), order.getDate(),
                            order.getClient().getName()));
                    for (OrderLine line : order.getLines()) {
                        Product product = line.getProduct();
                        IO.println("  %d x %s at %.2f euros"
                                .formatted(line.getQuantity(), product.getName(), product.getPrice()));
                    }
                }
            });

            // 6 - an entity graph: what must be loaded with the entity, described outside the query
            IO.println("--- entity graph");
            Category category = sessionFactory.fromSession(session -> {
                EntityGraph<Category> graph = session.createEntityGraph(Category.class);
                graph.addAttributeNode("products");
                return session.find(graph, 2);
            });
            // the session is closed, but the products have been loaded
            IO.println("%s : %s".formatted(category.getName(),
                    category.getProducts().stream().map(Product::getName).collect(Collectors.joining(", "))));

            // 7 - the many-to-many relationship in a query
            IO.println("--- the products on promotion");
            sessionFactory.inSession(session -> session.createSelectionQuery("""
                            select p.name, p.category.name from Product p join p.tags e
                            where e.label = 'promo' order by p.name""", ProductCategory.class)
                    .getResultList()
                    .forEach(p -> IO.println("%s (%s)".formatted(p.product(), p.category()))));

            // 8 - a computation across the relationships, done by the DBMS
            IO.println("--- amount of the orders");
            sessionFactory.inSession(session -> session.createSelectionQuery("""
                            select c.id, c.client.name, sum(l.quantity * l.product.price)
                            from CustomerOrder c join c.lines l
                            group by c.id, c.client.name order by c.id""", OrderAmount.class)
                    .getResultList()
                    .forEach(m -> IO.println("order no. %d (%s) : %.2f euros"
                            .formatted(m.id(), m.client(), m.amount()))));

            // 9 - a grouping: number of products and average price per category
            IO.println("--- products per category");
            sessionFactory.inSession(session -> session.createSelectionQuery("""
                            select p.category.name, count(*), avg(p.price) from Product p
                            group by p.category.name order by p.category.name""", CategoryStatistics.class)
                    .getResultList()
                    .forEach(s -> IO.println("%s : %d product(s), average price %.2f euros".formatted(
                            s.category(), s.count(), s.averagePrice()))));
        }
    }

    // the product with the given name
    private static Product product(Session session, String name) {
        return session.createSelectionQuery("from Product where name = :name", Product.class)
                .setParameter("name", name)
                .getSingleResult();
    }
}

Results:

> mvn compile exec:java
--- addition of a client, an order and its two lines
    SQL> -- start of transaction
    SQL> select p1_0.id, p1_0.category_id, p1_0.name, p1_0.price
    SQL> from products p1_0
    SQL> where p1_0.name=?
    SQL> -- parameter 1 = bike
    SQL> select p1_0.id, p1_0.category_id, p1_0.name, p1_0.price
    SQL> from products p1_0
    SQL> where p1_0.name=?
    SQL> -- parameter 1 = arrows - set of 6
    SQL> insert into clients (name)
    SQL> values (?)
    SQL> -- parameter 1 = Dupont
    SQL> insert into orders (client_id, date)
    SQL> values (?, ?)
    SQL> -- parameter 1 = 2
    SQL> -- parameter 2 = 2026-10-01
    SQL> insert into order_lines (order_id, product_id, quantity)
    SQL> values (?, ?, ?)
    SQL> -- parameter 1 = 2
    SQL> -- parameter 2 = 1
    SQL> -- parameter 3 = 1
    SQL> insert into order_lines (order_id, product_id, quantity)
    SQL> values (?, ?, ?)
    SQL> -- parameter 1 = 2
    SQL> -- parameter 2 = 4
    SQL> -- parameter 3 = 3
client no. 2, order no. 2
    SQL> -- transaction committed (COMMIT)
--- lazy loading
    SQL> select co1_0.id, co1_0.client_id, co1_0.date
    SQL> from orders co1_0
    SQL> order by co1_0.id
    SQL> select c1_0.id, c1_0.name
    SQL> from clients c1_0
    SQL> where c1_0.id=?
    SQL> -- parameter 1 = 1
    SQL> select l1_0.order_id, l1_0.id, l1_0.product_id, l1_0.quantity
    SQL> from order_lines l1_0
    SQL> where l1_0.order_id=?
    SQL> -- parameter 1 = 1
order no. 1 : client=Martin, 2 line(s)
    SQL> select c1_0.id, c1_0.name
    SQL> from clients c1_0
    SQL> where c1_0.id=?
    SQL> -- parameter 1 = 2
    SQL> select l1_0.order_id, l1_0.id, l1_0.product_id, l1_0.quantity
    SQL> from order_lines l1_0
    SQL> where l1_0.order_id=?
    SQL> -- parameter 1 = 2
order no. 2 : client=Dupont, 2 line(s)
--- after the session is closed
    SQL> select co1_0.id, co1_0.client_id, co1_0.date
    SQL> from orders co1_0
    SQL> where co1_0.id=?
    SQL> -- parameter 1 = 1
LazyInitializationException : Cannot lazily initialize collection of role 'relations.CustomerOrder.lines' with key '1' (no session)
--- join fetch
    SQL> select
    SQL>     co1_0.id,
    SQL>     co1_0.client_id,
    SQL>     c1_0.id,
    SQL>     c1_0.name,
    SQL>     co1_0.date,
    SQL>     l1_0.order_id,
    SQL>     l1_0.id,
    SQL>     l1_0.product_id,
    SQL>     p1_0.id,
    SQL>     p1_0.category_id,
    SQL>     p1_0.name,
    SQL>     p1_0.price,
    SQL>     l1_0.quantity
    SQL> from orders co1_0
    SQL> join clients c1_0 on c1_0.id=co1_0.client_id
    SQL> join order_lines l1_0 on co1_0.id=l1_0.order_id
    SQL> join products p1_0 on p1_0.id=l1_0.product_id
    SQL> order by co1_0.id
order no. 1 of 2026-09-28 for Martin
  2 x pump at 10.00 euros
  1 x bow at 600.00 euros
order no. 2 of 2026-10-01 for Dupont
  1 x bike at 500.00 euros
  3 x arrows - set of 6 at 100.00 euros
--- entity graph
    SQL> select c1_0.id, c1_0.name, p1_0.category_id, p1_0.id, p1_0.name, p1_0.price
    SQL> from categories c1_0
    SQL> left join products p1_0 on c1_0.id=p1_0.category_id
    SQL> where c1_0.id=?
    SQL> -- parameter 1 = 2
archery : bow, arrows - set of 6
--- the products on promotion
    SQL> select p1_0.name, c1_0.name
    SQL> from products p1_0
    SQL> join products_tags t1_0 on p1_0.id=t1_0.product_id
    SQL> join tags t1_1 on t1_1.id=t1_0.tag_id
    SQL> join categories c1_0 on c1_0.id=p1_0.category_id
    SQL> where t1_1.label='promo'
    SQL> order by p1_0.name
arrows - set of 6 (archery)
bike (cycles)
--- amount of the orders
    SQL> select co1_0.id, c1_0.name, sum((l1_0.quantity*p1_0.price))
    SQL> from orders co1_0
    SQL> join order_lines l1_0 on co1_0.id=l1_0.order_id
    SQL> join products p1_0 on p1_0.id=l1_0.product_id
    SQL> join clients c1_0 on c1_0.id=co1_0.client_id
    SQL> group by co1_0.id, c1_0.name
    SQL> order by co1_0.id
order no. 1 (Martin) : 620.00 euros
order no. 2 (Dupont) : 800.00 euros
--- products per category
    SQL> select c1_0.name, count(*), avg(p1_0.price)
    SQL> from products p1_0
    SQL> join categories c1_0 on c1_0.id=p1_0.category_id
    SQL> group by c1_0.name
    SQL> order by c1_0.name
archery : 2 product(s), average price 350.00 euros
cycles : 2 product(s), average price 255.00 euros

Comments:

  • lines 15–22: the records that will receive the projection results;
  • lines 32–39: the output is read by the [product] method (lines 125–129); a customer, their order, and its two lines are persisted with a single call to [persist]. SQL (lines 12–28 of the results) shows the order of insertions: the customer, then the order, whose foreign key [client_id] receives the key generated for the customer (2), then the rows, whose foreign key [order_id] receives the key from the order;
  • Lines 46–52: lazy loading. The query reads only the orders (lines 32–34): the customer and the lines for each order are read only when the program uses them, via additional queries (lines 35–42 for the first order, 44–51 for the second). For the client, Hibernate has placed a proxy in the [client] field—an object of a subclass of [Client] generated by Hibernate (using Byte Buddy)—which contains only the identifier and loads the client upon the first call to a method ([getName()]). This is why an entity must not be a [final] class. For the rows, the list is a Hibernate collection, loaded upon its first use ([size()]);
  • lines 57–62: The command is read by a session that is immediately closed ([fromSession]). These rows were not loaded, and once the session is closed, they can no longer be loaded: accessing the collection triggers a [LazyInitializationException] (line 58), one of the most common exceptions in applications that use Hibernate. You must load what you need while the session is open;
  • Lines 67–72: preloading via a query. [join fetch] instructs Hibernate to load, along with the orders, their customers, their lines, and the product of each line: a single query with joins (lines 60–78). The display shows the two orders and their line items (lines 79–84). The join with the line items produces one SQL row per order line; Hibernate derives a single order for each identifier: the rendered list does indeed contain two orders;
  • lines 86–90: an “entity graph” (Jakarta Persistence) describes, independently of any query, the associations to be loaded with an entity: in this case, the products in the category. [session.find(graph, 2)] (Jakarta Persistence 3.2) retrieves category 2 along with its products in a single query (lines 86–90 of the results). Once the session is closed, the products remain accessible (lines 92–93). To load an association later, while the session is open, we write [Hibernate.initialize(category.getProducts())];
  • lines 97–101: associations are used in queries: [join p.tags e] traverses the many-to-many relationship (two joins, with the association table and the label table, lines 95–96), and [p.category.name] is translated into an implicit join with the category table (line 97);
  • lines 105–111: a calculation across two relations—the total amount of each order—entirely performed by DBMS: joins, [sum] and [group by] (lines 103–109);
  • lines 115–120: a grouping. [group by p.category.name] groups products by category name; for each group, the number of products ([count]) and the average price ([avg], of type [Double]) are calculated by DBMS (lines 113–117). The category [diving], which has no products, does not appear: the join with the category table is an inner join ([join]).

11.8.3. The N+1 query problem

The lazy loading in the example illustrates a problem that plagues any user of a ORM, known as the “N+1 queries” problem: one query to read the N orders, then one query per order to read its customer (and another for its lines). With a thousand orders, this results in a thousand and one round trips to the server, when a single one would have sufficed. The problem is all the more insidious because it’s invisible in the code: [order.getClient().getName()] doesn’t look like a query. The solutions are those shown in the example: [join fetch], an entity graph, or better yet, a projection that retrieves only the relevant data. Hibernate also offers batch loading ([@BatchSize(size = 20)] applied to a collection, or to the referenced entity class—in this case, [Client]), which, for example, retrieves the customers for twenty orders in a single query. The log SQL helps identify the problem: the same query being repeated numerous times.

11.9. Changes to the database schema

11.9.1. The Problem

Until now, tables have been created by Hibernate after being deleted. This is impossible for a live application: its database contains data that must be preserved when the model evolves (a new column, a new table). It must be possible to migrate the database from one schema version to the next, in a safe and reproducible manner, both on the developer’s machine and on the production server. This is referred to as schema migration.

Hibernate does not support migrations: it can create a schema, validate it, and make limited updates to it. In the Java world, the standard practice is to handle migrations using versioned SQL scripts, applied by a dedicated tool—most often Flyway or Liquibase—and to have Hibernate verify that the resulting schema matches the entities.

11.9.2. Schema Management by Hibernate

The [hibernate.hbm2ddl.auto] property (or the [schemaToolingAction] method in the configuration, which the program below uses) specifies what Hibernate should do with the schema when creating the [SessionFactory]:

Value
Constant [Action]
Effect
[none]
[NONE]
none (default value)
[create-only]
[CREATE_ONLY]
table creation
[create]
[CREATE]
Deleting and then creating tables
[create-drop]
[CREATE_DROP]
such as [create], and deletion of tables upon closing [SessionFactory]: for testing
[drop]
[DROP]
Delete tables
[validate]
[VALIDATE]
Verify that the tables and columns of the entities exist and have compatible types; throw an exception otherwise
[update]
[UPDATE]
Create any missing tables and columns

Jakarta Persistence defines a related property, [jakarta.persistence.schema-generation.database.action], with the values [none], [create], [drop-and-create], and [drop]. The same actions are available programmatically, as we did with [SchemaManager] from [SessionFactory] ([create], [drop], [validate], [truncate]).

The value [update] is tempting: Hibernate compares the entities to the database schema and adds what’s missing. It has limitations that make it unsuitable for a production application:

  • it only adds: it never deletes a column or table that has become unnecessary, does not modify the type or length of an existing column, and cannot rename (renaming a field creates a new empty column, while the old one remains with its data);
  • it cannot transform data (fill a new required column, split a column into two);
  • what it does depends on the state of the database it encounters: it is impossible to know in advance which commands will be executed, or in what order, and no record of this is kept.

We therefore reserve [create], [create-drop], and [update] for development and testing, and we use [validate] (or [none]) in production, with the schema managed by scripts.

11.9.3. Versioned SQL scripts

The principle behind script-based migrations is simple:

  • each schema change is described by a SQL script, numbered: [V1__create_clients.sql], [V2__add_phone.sql]... Once a script has been applied to a database, it is never modified again: any correction is the subject of a new script;
  • the database contains a history table that lists the versions that have been applied to it;
  • When the application starts (or during deployment), a tool compares the available scripts with the history and applies, in order, those that have not yet been applied.

The scripts are part of the application’s source code: they are stored and managed along with the rest of the code (in a Git repository, for example). To understand how this works, the [clients] module implements a small versioned script executor, modeled after Flyway; we will then introduce Flyway itself.

11.9.4. The [clients] module

The [clients] module builds on the [hbclients] base. The entity is a client:

// a client
@Entity
@Table(name = "clients")
public class Client {

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

    @Column(nullable = false, length = 50)
    private String name;

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

    @Column(length = 20)
    private String phone;

    protected Client() {
    }

    public Client(String name, String email, String phone) {
        this.name = name;
        this.email = email;
        this.phone = phone;
    }

    @Override
    public String toString() {
        String tel = phone == null ? "no phone" : phone;
        return "[%d, %s, %s, %s]".formatted(id, name, email, tel);
    }
}

In the first version of the application, the customer does not have a phone number: lines 16–17 do not yet exist. We will add them in the second version. The scripts are located in the module’s [src/main/resources/db/migration] folder, which will be copied to the classpath. The first script creates the table:

1
2
3
4
5
6
7
-- version 1: the clients table
create table clients (
    id integer not null auto_increment,
    name varchar(50) not null,
    email varchar(100) not null,
    primary key (id)
) engine=InnoDB;

The second adds the phone number column:

1
2
3
-- version 2: the (optional) phone of the clients
alter table clients
    add column phone varchar(20);

The [Migrations] class executes the scripts using JDBC (Chapter 10), independently of Hibernate:

package clients;

import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;

// a small runner of versioned SQL scripts, modeled on Flyway: the scripts
// [db/migration/V<version>__<description>.sql] are applied only once, in the order of the versions,
// and the [migration_history] table of the database records those that have been
public class Migrations {

    // a migration script
    public record Script(int version, String description, Path path) {
        @Override
        public String toString() {
            return "V" + version + " (" + description + ")";
        }
    }

    // the name of a script: V1__create_clients.sql
    private static final Pattern NAME = Pattern.compile("V(\\d+)__(\\w+)\\.sql");

    // the history table
    private static final String CREATE_HISTORY = """
            create table if not exists migration_history (
                version integer not null primary key,
                description varchar(100) not null,
                applied_on datetime not null default current_timestamp
            ) engine=InnoDB""";

    // the database
    private final String url;
    private final String user;
    private final String password;

    public Migrations(String url, String user, String password) {
        this.url = url;
        this.user = user;
        this.password = password;
    }

    // the scripts of the [db/migration] folder of the classpath, in version order
    public List<Script> scripts() throws IOException, URISyntaxException {
        URL folder = Migrations.class.getResource("/db/migration");
        if (folder == null) {
            return List.of();
        }
        List<Script> scripts = new ArrayList<>();
        try (Stream<Path> files = Files.list(Path.of(folder.toURI()))) {
            for (Path file : files.toList()) {
                Matcher m = NAME.matcher(file.getFileName().toString());
                if (m.matches()) {
                    int version = Integer.parseInt(m.group(1));
                    scripts.add(new Script(version, m.group(2).replace('_', ' '), file));
                }
            }
        }
        scripts.sort(Comparator.comparingInt(Script::version));
        return scripts;
    }

    // the versions already applied to the database (the history table is created if needed)
    public Set<Integer> appliedVersions() throws SQLException {
        try (Connection connection = DriverManager.getConnection(url, user, password);
             Statement statement = connection.createStatement()) {
            statement.execute(CREATE_HISTORY);
            Set<Integer> versions = new TreeSet<>();
            try (ResultSet rows = statement.executeQuery("select version from migration_history")) {
                while (rows.next()) {
                    versions.add(rows.getInt(1));
                }
            }
            return versions;
        }
    }

    // the scripts that remain to be applied
    public List<Script> pending() throws SQLException, IOException, URISyntaxException {
        Set<Integer> applied = appliedVersions();
        return scripts().stream().filter(s -> !applied.contains(s.version())).toList();
    }

    // application of the pending scripts, up to version [target] included; the applied scripts are returned
    public List<Script> apply(int target) throws SQLException, IOException, URISyntaxException {
        List<Script> applied = new ArrayList<>();
        for (Script script : pending()) {
            if (script.version() > target) {
                break;
            }
            apply(script);
            applied.add(script);
        }
        return applied;
    }

    // application of a script and addition of its version to the history, in a transaction
    private void apply(Script script) throws SQLException, IOException {
        // the statements of the script, separated by semicolons, without the comment lines
        String text = Files.readString(script.path()).lines()
                .filter(line -> !line.strip().startsWith("--"))
                .reduce("", (a, b) -> a + "\n" + b);
        try (Connection connection = DriverManager.getConnection(url, user, password)) {
            connection.setAutoCommit(false);
            try (Statement statement = connection.createStatement();
                 PreparedStatement history = connection.prepareStatement(
                         "insert into migration_history(version, description) values (?, ?)")) {
                for (String sql : text.split(";")) {
                    if (!sql.isBlank()) {
                        statement.execute(sql);
                    }
                }
                history.setInt(1, script.version());
                history.setString(2, script.description());
                history.executeUpdate();
                connection.commit();
            } catch (SQLException e) {
                // beware: with MySQL, a schema modification statement cannot be rolled back
                connection.rollback();
                throw e;
            }
        }
    }
}
  • Lines 29–34: A script is described by a record: its version, description, and path;
  • line 37: a script’s name follows the Flyway convention: [V], the version number, two underscores, and a description;
  • lines 40–45: the history table: one row per applied version, with its description and the date it was applied;
  • lines 59–76: the list of scripts. [getResource("/db/migration")] adds URL from the folder to the classpath, which is listed along with [Files.list] (Chapter 5). This code assumes that the folder is a real folder (which is the case when the program is run by Maven from the [target/classes] folder); Flyway can also read scripts from a [.jar] archive. The scripts are sorted by version number: alphabetical sorting would place [V10] before [V2];
  • lines 79–91: versions already applied, read from the history table, which is created as needed ([create table if not exists]);
  • lines 94–97: the scripts that remain to be applied;
  • lines 100–110: the application of pending scripts, up to a given version;
  • lines 113–138: the application of a script: its commands (separated by semicolons, with comment lines ignored), followed by the addition of its version to the history table, within a transaction. One caveat, noted in the comment on line 133: under MySQL, a schema modification command ([CREATE TABLE], [ALTER TABLE]) implicitly commits the current transaction and cannot be rolled back. If an error occurs in the middle of a script, the database may remain in an intermediate state. This is why it’s best to use short scripts, each containing only a single change.

The main program applies the migrations, then asks Hibernate to validate the schema:

package clients;

import jakarta.persistence.PersistenceException;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.jpa.HibernatePersistenceConfiguration;
import org.hibernate.tool.schema.Action;

public class Main {

    // the clients database; MySQL creates it if it does not exist
    public static final String URL = "jdbc:mysql://localhost:3306/hbclients?createDatabaseIfNotExist=true";

    public static void main(String[] args) throws Exception {
        // the targeted version: the last one by default, or the one passed as an argument
        int target = args.length > 0 ? Integer.parseInt(args[0]) : Integer.MAX_VALUE;

        // the state of the migrations
        Migrations migrations = new Migrations(URL, "root", "");
        IO.println("applied versions: " + migrations.appliedVersions());
        IO.println("pending migrations: " + migrations.pending());

        // application of the pending migrations
        for (Migrations.Script script : migrations.apply(target)) {
            IO.println("migration applied: " + script);
        }

        // Hibernate checks that the database schema matches the entities (validate)
        try (SessionFactory sessionFactory = new HibernatePersistenceConfiguration("clients")
                .managedClass(Client.class)
                .jdbcUrl(URL)
                .jdbcCredentials("root", "")
                .property(AvailableSettings.FORMAT_SQL, true)
                .schemaToolingAction(Action.VALIDATE)
                .createEntityManagerFactory()) {
            IO.println("the database schema matches the entities");
            // the new column is usable
            sessionFactory.inTransaction(session ->
                    session.persist(new Client("Dupont", "dupont@example.com", "01 23 45 67 89")));
            sessionFactory.inSession(session -> session
                    .createSelectionQuery("from Client", Client.class)
                    .getResultList()
                    .forEach(IO::println));
        } catch (PersistenceException e) {
            // the validation error is the cause of the exception
            Throwable cause = e.getCause() == null ? e : e.getCause();
            IO.println(cause.getClass().getSimpleName() + " : " + cause.getMessage());
        }
    }
}
  • line 16: You can specify, as an argument, the version up to which to apply the migrations; by default, all are applied;
  • lines 19–26: the status of the migrations, followed by their application;
  • lines 29–35: [SessionFactory] is created with the action [VALIDATE]: Hibernate verifies that the database schema matches the entity [Client]. If this is not the case, the creation of [SessionFactory] fails, resulting in [PersistenceException], caused by the validation error (lines 44–48);
  • lines 38–43: the new column is used.

11.9.5. Generate the scripts with Hibernate

The SQL scripts are written by hand. However, Hibernate can help write them: it can generate—without executing it—the script to create the schema, or, by comparing the entities to an existing database, the script to update it. This is what the [GenerateScript] class does:

package clients;

import java.io.StringWriter;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.jpa.HibernatePersistenceConfiguration;

// generation by Hibernate of the SQL script that creates (create) or updates (update) the database schema
public class GenerateScript {

    public static void main(String[] args) {
        // the action: create (by default) or update
        String action = args.length > 0 ? args[0] : "create";
        StringWriter script = new StringWriter();
        // a SessionFactory that writes the script without executing it
        try (SessionFactory _ = new HibernatePersistenceConfiguration("clients")
                .managedClass(Client.class)
                .jdbcUrl(Main.URL)
                .jdbcCredentials("root", "")
                .property(AvailableSettings.JAKARTA_HBM2DDL_SCRIPTS_ACTION, action)
                .property(AvailableSettings.JAKARTA_HBM2DDL_SCRIPTS_CREATE_TARGET, script)
                .property(AvailableSettings.HBM2DDL_DELIMITER, ";")
                .property(AvailableSettings.FORMAT_SQL, true)
                .createEntityManagerFactory()) {
            // the script, without the 4-space indentation of its lines
            script.toString().strip().lines()
                    .map(line -> line.startsWith("    ") ? line.substring(4) : line)
                    .forEach(line -> IO.println(line.stripTrailing()));
        }
    }
}
  • line 13: the action, passed as an argument: [create] or [update];
  • line 16: [SessionFactory] is created solely to generate the script; the variable that holds it is never used. Java 22 allows it to be called [_] (anonymous variable);
  • Lines 20–23: The script generation properties, which we’ve already seen in the [model] module. This time, Hibernate connects to the database: for the [update] action, it needs to know the database schema.

The creation script, generated from the current version of the entity, is used to create a brand-new database all at once (for testing, for example):

1
2
3
4
5
6
7
8
> mvn compile exec:java -Dexec.mainClass=clients.GenerateScript
create table clients (
    id integer not null auto_increment,
    phone varchar(20),
    name varchar(50) not null,
    email varchar(100) not null,
    primary key (id)
) engine=InnoDB;

With the first version of the entity (without a phone number), it would have generated—with the same column order—the script [V1__create_clients.sql].

11.9.6. Apply the migrations

Suppose the database does not yet exist. We apply only the first migration, passing version 1 as an argument:

1
2
3
4
5
> mvn compile exec:java -Dexec.args="1"
applied versions: []
pending migrations: [V1 (create clients), V2 (add phone)]
migration applied: V1 (create clients)
SchemaManagementException : Schema validation: missing column [phone] in table [clients]
  • Lines 2–3: No version is applied; two scripts are pending. The history table was created by the [appliedVersions] method, and the [hbclients] database by MySQL ([createDatabaseIfNotExist=true]);
  • line 4: the script [V1] is applied;
  • Line 5: Hibernate detects that the [phone] column of the entity does not exist in the table: this is normal, since the database is at version 1 and the entity is at version 2. With the version 1 entity, the validation would have succeeded.

The application is evolving: we’re adding the phone number to the entity. To write the script for version 2, we ask Hibernate to compare the entity to the database, which is still at version 1:

1
2
3
> mvn compile exec:java -Dexec.mainClass=clients.GenerateScript -Dexec.args="update"
alter table clients
   add column phone varchar(20);

This is, apart from the formatting, the same as the script [V2__add_phone.sql]. We now apply all pending migrations:

> mvn compile exec:java
applied versions: [1]
pending migrations: [V2 (add phone)]
migration applied: V2 (add phone)
the database schema matches the entities
    SQL> -- start of transaction
    SQL> insert into clients (email, name, phone)
    SQL> values (?, ?, ?)
    SQL> -- parameter 1 = dupont@example.com
    SQL> -- parameter 2 = Dupont
    SQL> -- parameter 3 = 01 23 45 67 89
    SQL> -- transaction committed (COMMIT)
    SQL> select c1_0.id, c1_0.email, c1_0.name, c1_0.phone
    SQL> from clients c1_0
[1, Dupont, dupont@example.com, 01 23 45 67 89]
  • Lines 2–4: Version 1 is applied, Version 2 is pending, and it is applied;
  • line 5: validation succeeds;
  • lines 6–15: the new column is ready for use.

The history table now contains both versions:

1
2
3
4
5
6
7
> mysql -uroot -t hbclients -e "SELECT * FROM migration_history"
+---------+----------------+---------------------+
| version | description    | applied_on          |
+---------+----------------+---------------------+
|       1 | create clients | 2026-10-03 17:27:22 |
|       2 | add phone      | 2026-10-03 17:27:27 |
+---------+----------------+---------------------+

Running the program again will not apply any changes: both versions are listed in the history.

11.9.7. Flyway

Flyway is the most widely used migration tool in the Java world (Spring Boot runs it automatically when an application starts if it is present in the classpath). It works exactly according to the same principle as our executor, but is more comprehensive: [V<version>__<description>.sql] scripts are searched for by default in the [db/migration] folder of the classpath, the [flyway_schema_history] history table, and the application of pending scripts in version order. In particular, it adds a checksum for each script, which detects changes to a script that has already been applied; “repeatable” scripts ([R__description.sql], reapplied with each change, for views for example); migrations written in Java, a lock that prevents two instances of the application from migrating the database at the same time, and a Maven extension ([mvn flyway:migrate], [mvn flyway:info]).

To use it in the [clients] module, simply add the dependencies [org.flywaydb:flyway-core] and [org.flywaydb:flyway-mysql] (in their latest versions), keep the two scripts as-is—since they already follow its convention—and replace the use of the [Migrations] class with a single line: [Flyway.configure().dataSource(URL, "root", "").load().migrate();]

Liquibase, the other commonly used tool, describes schema changes in files XML, YAML, or SQL (the “changelogs”), and can generate the SQL specific to each DBMS. In both cases, Hibernate is configured with [validate] or [none], and no longer modifies the schema.

11.10. Hibernate and Virtual Threads

A database query is an I/O operation: while the server is processing the request, the waiting thread does nothing. Chapter 9 presented two ways to avoid wasting threads during these waits: asynchronous programming ([CompletableFuture]) and virtual threads (Java 21). Hibernate’s API, like the JDBC it uses, is blocking: [getResultList] does not yield control until the rows have been read. With virtual threads, this is no longer a drawback: a virtual thread blocked on I/O releases the system thread that was executing it, and thousands of them can be created. Therefore, we write ordinary blocking code, executed by virtual threads.

One rule must be followed: a session is not thread-safe. It must never be shared among multiple threads: each thread (each task) opens its own session with [SessionFactory], which is itself shared. Hibernate does not always detect simultaneous use of the same session; the resulting errors are unpredictable.

The [async] module enforces this rule:

package async;

import articles.Article;
import articles.BaseArticles;
import jakarta.persistence.QueryTimeoutException;
import java.math.BigDecimal;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.stream.Stream;
import org.hibernate.SessionFactory;

public class Main {

    public static void main(String[] args) throws InterruptedException, ExecutionException {
        try (SessionFactory sessionFactory = BaseArticles.createSessionFactory()) {
            // the [articles] table is recreated with its six articles
            BaseArticles.initialize(sessionFactory);

            // 1 - two queries executed at the same time by two virtual threads, each with its own session
            IO.println("--- two queries in parallel");
            try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
                Future<List<Article>> lowPrices = executor.submit(() -> sessionFactory.fromSession(s -> s
                        .createSelectionQuery("from Article where price < 200 order by price", Article.class)
                        .getResultList()));
                String hql = "select count(*) from Article where currentStock < minimumStock";
                Future<Long> outOfStock = executor.submit(() -> sessionFactory.fromSession(s -> s
                        .createSelectionQuery(hql, Long.class)
                        .getSingleResult()));
                // get() waits for the end of each task
                lowPrices.get().forEach(IO::println);
                IO.println(outOfStock.get() + " article(s) out of stock");
            }

            // 2 - a transaction executed by a virtual thread, followed by a processing of its result
            IO.println("--- CompletableFuture");
            try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
                CompletableFuture<Void> processing = CompletableFuture
                        .supplyAsync(() -> sessionFactory.fromTransaction(session -> {
                            Article bow = session
                                    .createSelectionQuery("from Article where name = 'bow'", Article.class)
                                    .getSingleResult();
                            bow.setPrice(BigDecimal.valueOf(550));
                            return bow;
                        }), executor)
                        .thenAccept(bow -> IO.println("new price of the bow: " + bow.getPrice()));
                processing.join();
            }

            // 3 - a stream: the entities are processed as they are read
            IO.println("--- getResultStream");
            sessionFactory.inSession(session -> {
                try (Stream<Article> articles = session
                        .createSelectionQuery("from Article where price >= 300", Article.class)
                        .getResultStream()) {
                    articles.forEach(article -> IO.println("read: " + article.getName()));
                }
            });

            // 4 - a query that is too long is interrupted after the set timeout (in seconds)
            IO.println("--- timeout exceeded");
            try {
                sessionFactory.inSession(session -> session
                        .createNativeQuery("select sleep(5)", Integer.class)
                        .setTimeout(1)
                        .getSingleResult());
            } catch (QueryTimeoutException e) {
                IO.println(e.getClass().getSimpleName() + " : " + e.getMessage());
            }
        }
    }
}

Results:

> mvn compile exec:java
--- two queries in parallel
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> where a1_0.price<200
    SQL> order by a1_0.price
    SQL> select count(*)
    SQL> from articles a1_0
    SQL> where a1_0.currentStock<a1_0.minimumStock
[2, pump, 10.00, 10, 2]
[4, arrows - set of 6, 100.00, 12, 20]
[6, diver's oxygen tanks, 120.00, 10, 5]
1 article(s) out of stock
--- CompletableFuture
    SQL> -- start of transaction
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> where a1_0.name='bow'
    SQL> update articles
    SQL> set currentStock=?, minimumStock=?, name=?, price=?
    SQL> where id=?
    SQL> -- parameter 1 = 4
    SQL> -- parameter 2 = 1
    SQL> -- parameter 3 = bow
    SQL> -- parameter 4 = 550
    SQL> -- parameter 5 = 3
    SQL> -- transaction committed (COMMIT)
new price of the bow: 550
--- getResultStream
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> where a1_0.price>=300
read: bike
read: bow
read: diving suit
--- timeout exceeded
    SQL> select sleep(5)
    SQL> -- failure: Statement cancelled due to timeout or client request
QueryTimeoutException : JDBC exception executing SQL [Statement cancelled due to timeout or client request] [select sleep(5)]

Comments:

  • Line 25: an executor that creates a virtual thread for each task (Chapter 9). The [try] instruction with resources waits for all tasks to finish before closing it;
  • Lines 26–32: Two tasks submitted to the executor. Each opens its own session ([fromSession]) and executes a query; both queries run simultaneously, using two connections from the pool. Their order in the log (lines 3–9) may therefore vary from one execution to another;
  • lines 34–35: [get()] waits for each task to complete and returns its result (lines 10–13);
  • lines 41–50: a [CompletableFuture] (Chapter 9) whose first step—a complete transaction—is executed by a virtual thread ([supplyAsync(..., executor)]), and the second step displays its result. The record returned by the transaction is detached (its session is closed): its fields can be read, but its associations cannot be loaded;
  • Lines 56–60: [getResultStream] returns a stream: entities are processed as they are read, without forming a list (lines 33–35). This is useful for processing a large number of rows. With MySQL, note that by default the driver reads all rows of the result before returning the first one; for truly progressive reading, you must instruct it to use a cursor (parameter [useCursorFetch=true] of URL and batch size set by [setFetchSize]);
  • Lines 66–69: a maximum execution time, in seconds, set by [setTimeout]. The query (a native SQL query, which we’ll discuss later) takes five seconds; after one second, the JDBC driver interrupts it, and Hibernate launches a [QueryTimeoutException] (lines 37–39). This is the equivalent, for a query, of canceling a task ([Future.cancel], Chapter 9), which would have no effect here: interrupting a thread blocked in the JDBC driver does not interrupt the query currently running on the server.

For applications that require a truly non-blocking API, there is Hibernate Reactive, a variant of Hibernate that relies on reactive drivers (those from Vert.x) rather than JDBC, and whose methods return [CompletionStage] or objects from the Mutiny library. Virtual threads eliminate this complexity in most cases: that is the approach taken in this course.

11.11. Transactions and Concurrency

11.11.1. Transactions

Chapter 10 introduced transactions: a set of operations that either all succeed or all fail. With Hibernate:

  • every write occurs within a transaction, opened by [session.beginTransaction()] and closed by [commit] or [rollback]. The methods [inTransaction] and [fromTransaction] of [SessionFactory] handle this for us: they commit the transaction if the lambda completes normally, and roll it back if it throws an exception, which is then rethrown;
  • all commands sent during the transaction (immediate commands via [INSERT], explicit synchronizations via [flush], and synchronization via [commit]) are either committed or rolled back together;
  • an error from DBMS is propagated as a Hibernate exception, derived from [JDBCException], which encapsulates the driver’s [SQLException]: [ConstraintViolationException] for a constraint violation (primary key, uniqueness, foreign key, [NOT NULL]), [SQLGrammarException] for an error in the order SQL, etc.

The [transactions] module illustrates the two ways to handle a transaction:

package transactions;

import articles.Article;
import articles.BaseArticles;
import java.math.BigDecimal;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.exception.ConstraintViolationException;

public class Main {

    public static void main(String[] args) {
        try (SessionFactory sessionFactory = BaseArticles.createSessionFactory()) {
            // the [articles] table is recreated with its six articles
            BaseArticles.initialize(sessionFactory);

            // 1 - inTransaction: commit if everything succeeds, rollback in case of exception
            IO.println("--- two additions, the second of which violates the uniqueness of the name");
            try {
                sessionFactory.inTransaction(session -> {
                    session.persist(new Article("racket", BigDecimal.valueOf(80), 5, 2));
                    session.persist(new Article("bike", BigDecimal.valueOf(450), 3, 1));
                });
            } catch (ConstraintViolationException e) {
                IO.println("%s : constraint [%s] violated"
                        .formatted(e.getClass().getSimpleName(), e.getConstraintName()));
            }
            display(sessionFactory, "racket");

            // 2 - a transaction managed by the code: several writes, committed or rolled back together
            IO.println("--- transaction rolled back");
            try (Session session = sessionFactory.openSession()) {
                Transaction transaction = session.beginTransaction();
                try {
                    // sale of two bikes: the UPDATE is sent by flush
                    Article bike = session
                            .createSelectionQuery("from Article where name = 'bike'", Article.class)
                            .getSingleResult();
                    bike.setCurrentStock(bike.getCurrentStock() - 2);
                    session.flush();
                    // adding an article whose name already exists: error
                    session.persist(new Article("pump", BigDecimal.valueOf(12), 5, 1));
                    // commit of the whole (it is not reached here)
                    transaction.commit();
                } catch (ConstraintViolationException e) {
                    IO.println(e.getClass().getSimpleName() + " : " + e.getSQLException().getMessage());
                    // rollback of all the operations of the transaction
                    transaction.rollback();
                }
            }
            // the sale of the bikes has been rolled back
            display(sessionFactory, "bike");
        }
    }

    // display of an article read from the database, with a new session
    private static void display(SessionFactory sessionFactory, String name) {
        Article article = sessionFactory.fromSession(session -> session
                .createSelectionQuery("from Article where name = :name", Article.class)
                .setParameter("name", name)
                .getSingleResultOrNull());
        IO.println("article [%s] in the database: %s".formatted(name, article == null ? "missing" : article));
    }
}

Results:

> mvn compile exec:java
--- two additions, the second of which violates the uniqueness of the name
    SQL> -- start of transaction
    SQL> insert into articles (currentStock, minimumStock, name, price)
    SQL> values (?, ?, ?, ?)
    SQL> -- parameter 1 = 5
    SQL> -- parameter 2 = 2
    SQL> -- parameter 3 = racket
    SQL> -- parameter 4 = 80
    SQL> insert into articles (currentStock, minimumStock, name, price)
    SQL> values (?, ?, ?, ?)
    SQL> -- parameter 1 = 3
    SQL> -- parameter 2 = 1
    SQL> -- parameter 3 = bike
    SQL> -- parameter 4 = 450
    SQL> -- failure: Duplicate entry 'bike' for key 'articles.uk_articles_name'
    SQL> -- transaction rolled back (ROLLBACK)
ConstraintViolationException : constraint [articles.uk_articles_name] violated
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> where a1_0.name=?
    SQL> -- parameter 1 = racket
article [racket] in the database: missing
--- transaction rolled back
    SQL> -- start of transaction
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> where a1_0.name='bike'
    SQL> update articles
    SQL> set currentStock=?, minimumStock=?, name=?, price=?
    SQL> where id=?
    SQL> -- parameter 1 = 8
    SQL> -- parameter 2 = 5
    SQL> -- parameter 3 = bike
    SQL> -- parameter 4 = 500.00
    SQL> -- parameter 5 = 1
    SQL> insert into articles (currentStock, minimumStock, name, price)
    SQL> values (?, ?, ?, ?)
    SQL> -- parameter 1 = 5
    SQL> -- parameter 2 = 1
    SQL> -- parameter 3 = pump
    SQL> -- parameter 4 = 12
    SQL> -- failure: Duplicate entry 'pump' for key 'articles.uk_articles_name'
ConstraintViolationException : Duplicate entry 'pump' for key 'articles.uk_articles_name'
    SQL> -- transaction rolled back (ROLLBACK)
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> where a1_0.name=?
    SQL> -- parameter 1 = bike
article [bike] in the database: [1, bike, 500.00, 10, 5]

Comments:

  • Lines 21–24: Two items are added to a transaction managed by [inTransaction]; the second item has the same name as an existing item. Both [INSERT] processes are executed immediately (lines 4–15); the second violates the uniqueness constraint on the [name] column: MySQL rejects it (line 16), [persist] launches a [ConstraintViolationException]; [inTransaction] rolls back the transaction (line 17) and rethrows the exception;
  • lines 25–28: the exception is caught; [getConstraintName] provides the name of the violated constraint (line 18). This demonstrates the value of having named the constraint;
  • line 29: the racket, which had been successfully inserted, is not in the database (line 23): rolling back the transaction deleted it;
  • lines 33–51: a transaction managed by the code. Two bicycles are sold, and the update is sent to DBMS via an explicit [flush] (lines 29–36); then an item with an existing name is added: the insertion fails (line 43);
  • line 45: validation is never reached;
  • Lines 46–50: In [catch], the transaction is rolled back (line 45 of the results). [getSQLException] reports the original exception from the JDBC driver, and its message is that of MySQL (line 44);
  • line 53: the sale of bicycles, although sent to DBMS by [flush], was canceled: the bicycle inventory is still 10 (line 50);
  • Lines 58–64: A method that re-reads a record with a new session to view the actual state of the database rather than the entities from the current session.

After an exception, a session should no longer be used: its persistence context may contain entities in a state that is inconsistent with the database. We roll back the transaction, close the session, and start over as needed with a new session. This is what [inTransaction] does, and what our code does when exiting the [try] block, which closes the session.

The transaction isolation level is that of the JDBC connection: [REPEATABLE_READ], the default value for MySQL, as shown in the first example. You can change this for the entire [SessionFactory] using the [jdbcTransactionIsolation] method in the configuration.

11.11.2. Optimistic Concurrency

Two users are reading the same item, which has a stock of 10. The first user sells one item and updates the stock to 9. The second user, who read the stock value before this update, sells three items and updates the stock to 7: the first user’s sale is lost. This is the problem of lost updates.

One solution would be to lock the row for the entire duration of each user’s operation (“pessimistic” concurrency): this blocks other users, sometimes for a long time. With Hibernate, optimistic concurrency is generally preferred: nothing is locked, but a check is performed at the time of saving to ensure that the row has not been modified since it was read. The standard way to do this is to add a version number to the entity—a field annotated as [@Version]. Hibernate increments this number each time the row is modified, and adds the condition “the version still has the value read” to the [WHERE] clause, along with the commands [UPDATE] and [DELETE]. If the command does not modify any rows, it means another user has already accessed the data: Hibernate executes a [OptimisticLockException].

The [concurrency] module uses its own database, [hbconcurrency], and its own entity, [Article], stripped down to the essentials:

// an article whose modifications are controlled by a version number
@Entity
@Table(name = "articles")
public class Article {

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

    @Column(nullable = false, length = 30)
    private String name;

    private int currentStock;

    // the version number: incremented by Hibernate on each modification of the row
    @Version
    private int version;

    protected Article() {
    }

    public Article(String name, int currentStock) {
        this.name = name;
        this.currentStock = currentStock;
    }

    public int getCurrentStock() {
        return currentStock;
    }

    public void setCurrentStock(int currentStock) {
        this.currentStock = currentStock;
    }

    public int getVersion() {
        return version;
    }
}
  • lines 16–17: the version number, of the form [int] (you can also use [long], or a date in the form [Instant]). The application never modifies this itself.

The program simulates the two users with two sessions:

package concurrency;

import jakarta.persistence.OptimisticLockException;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.jpa.HibernatePersistenceConfiguration;
import tools.SqlLog;

public class Main {

    public static void main(String[] args) {
        try (SessionFactory sessionFactory = new HibernatePersistenceConfiguration("concurrency")
                .managedClass(Article.class)
                .jdbcUrl("jdbc:mysql://localhost:3306/hbconcurrency?createDatabaseIfNotExist=true")
                .jdbcCredentials("root", "")
                .property(AvailableSettings.FORMAT_SQL, true)
                .createEntityManagerFactory()) {
            // the [articles] table is recreated with a single article, without a log
            SqlLog.withoutLog(() -> {
                sessionFactory.getSchemaManager().drop(false);
                sessionFactory.getSchemaManager().create(false);
                sessionFactory.inTransaction(session -> session.persist(new Article("bike", 10)));
            });

            // two users, two sessions: each one reads the same article
            try (Session session1 = sessionFactory.openSession();
                 Session session2 = sessionFactory.openSession()) {
                IO.println("--- the two users read the bike");
                Article bike1 = session1.find(Article.class, 1);
                Article bike2 = session2.find(Article.class, 1);

                // user 1 sells 1 bike
                IO.println("--- user 1 sells 1 bike");
                Transaction transaction1 = session1.beginTransaction();
                bike1.setCurrentStock(bike1.getCurrentStock() - 1);
                transaction1.commit();
                IO.println("stock saved by user 1: %d (version %d)"
                        .formatted(bike1.getCurrentStock(), bike1.getVersion()));

                // user 2 sells 3 bikes based on outdated data (stock = 10, version 0)
                IO.println("--- user 2 sells 3 bikes");
                Transaction transaction2 = session2.beginTransaction();
                bike2.setCurrentStock(bike2.getCurrentStock() - 3);
                try {
                    session2.flush();
                    transaction2.commit();
                } catch (OptimisticLockException e) {
                    IO.println(e.getClass().getSimpleName() + " : " + e.getMessage());
                    transaction2.rollback();
                }
            }

            // resolution: user 2 rereads the article, with a new session, and starts again
            IO.println("--- user 2 rereads the bike and starts again");
            sessionFactory.inTransaction(session -> {
                Article bike = session.find(Article.class, 1);
                IO.println("reread stock: %d (version %d)"
                        .formatted(bike.getCurrentStock(), bike.getVersion()));
                bike.setCurrentStock(bike.getCurrentStock() - 3);
                session.flush();
                IO.println("stock saved by user 2: %d (version %d)"
                        .formatted(bike.getCurrentStock(), bike.getVersion()));
            });
        }
    }
}

Results:

> mvn compile exec:java
--- the two users read the bike
    SQL> select a1_0.id, a1_0.currentStock, a1_0.name, a1_0.version
    SQL> from articles a1_0
    SQL> where a1_0.id=?
    SQL> -- parameter 1 = 1
    SQL> select a1_0.id, a1_0.currentStock, a1_0.name, a1_0.version
    SQL> from articles a1_0
    SQL> where a1_0.id=?
    SQL> -- parameter 1 = 1
--- user 1 sells 1 bike
    SQL> -- start of transaction
    SQL> update articles
    SQL> set currentStock=?, name=?, version=?
    SQL> where id=? and version=?
    SQL> -- parameter 1 = 9
    SQL> -- parameter 2 = bike
    SQL> -- parameter 3 = 1
    SQL> -- parameter 4 = 1
    SQL> -- parameter 5 = 0
    SQL> -- transaction committed (COMMIT)
stock saved by user 1: 9 (version 1)
--- user 2 sells 3 bikes
    SQL> -- start of transaction
    SQL> update articles
    SQL> set currentStock=?, name=?, version=?
    SQL> where id=? and version=?
    SQL> -- parameter 1 = 7
    SQL> -- parameter 2 = bike
    SQL> -- parameter 3 = 1
    SQL> -- parameter 4 = 1
    SQL> -- parameter 5 = 0
OptimisticLockException : Unexpected row count (expected row count 1 but was 0) [update articles set currentStock=?,name=?,version=? where id=? and version=?] for entity [concurrency.Article with id '1']
    SQL> -- transaction rolled back (ROLLBACK)
--- user 2 rereads the bike and starts again
    SQL> -- start of transaction
    SQL> select a1_0.id, a1_0.currentStock, a1_0.name, a1_0.version
    SQL> from articles a1_0
    SQL> where a1_0.id=?
    SQL> -- parameter 1 = 1
reread stock: 9 (version 1)
    SQL> update articles
    SQL> set currentStock=?, name=?, version=?
    SQL> where id=? and version=?
    SQL> -- parameter 1 = 6
    SQL> -- parameter 2 = bike
    SQL> -- parameter 3 = 2
    SQL> -- parameter 4 = 1
    SQL> -- parameter 5 = 1
stock saved by user 2: 6 (version 2)
    SQL> -- transaction committed (COMMIT)

Comments:

  • lines 21–25: The table is recreated with a single item, the bicycle, version 0;
  • lines 31–32: Each user reads the bicycle in their own session. The [version] column is read along with the others (lines 3–10);
  • Lines 36–38: The order [UPDATE] from the first user (lines 13–20) writes the new version (1) and contains the condition [version=?] with the read version (0). The row is modified; the entity’s [version] field is set to 1 (line 22);
  • lines 44–48: the second user’s request contains the same condition, with the version they read, 0, whereas the version of the row in the database is now 1. No rows are modified, and Hibernate triggers a [OptimisticLockException] (line 33). We explicitly synchronize the session ([flush]) before the [commit] to obtain this exception as-is; if it were thrown during the [commit], it would be encapsulated within a [RollbackException];
  • Lines 57–65: The resolution depends on the application: abort, notify the user, or, as here, re-read the item using a new session (the session that received the exception must no longer be used), and repeat the operation. The restocked inventory is 9 in version 1 (line 41), and the sale of the three bicycles results in an inventory of 6 in version 2 (line 50).

Two variations:

  • Without a version column, Hibernate can check the old values of the columns themselves: with the annotations [@OptimisticLocking(type = OptimisticLockType.DIRTY)] and [@DynamicUpdate] on the entity, the [WHERE] clause of the [UPDATE] contains the value read from each modified column ([where id=? and currentStock=?]). This is useful for an existing database that cannot be modified;
  • Pessimistic concurrency remains possible when conflicts are frequent: [session.find(Article.class, 1, LockModeType.PESSIMISTIC_WRITE)] reads the row with a [select ... for update] order, which locks it until the end of the transaction.

11.12. The native SQL

Hibernate generates the SQL from HQL or the API Criteria, but it is still possible to write the SQL for a query yourself, to use a feature specific to the DBMS or to optimize a complex query: this is referred to as a native query. The [rawsql] module lists the available methods:

package rawsql;

import articles.Article;
import articles.BaseArticles;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.List;
import org.hibernate.Session;
import org.hibernate.SessionFactory;

public class Main {

    public static void main(String[] args) {
        try (SessionFactory sessionFactory = BaseArticles.createSessionFactory()) {
            // the [articles] table is recreated with its six articles
            BaseArticles.initialize(sessionFactory);
            try (Session session = sessionFactory.openSession()) {
                // 1 - a hand-written SQL query that returns entities
                IO.println("--- createNativeQuery: entities");
                BigDecimal minPrice = BigDecimal.valueOf(200);
                List<Article> expensive = session
                        .createNativeQuery("""
                                select * from articles
                                where price > :minPrice order by name""", Article.class)
                        .setParameter("minPrice", minPrice)
                        .getResultList();
                expensive.forEach(IO::println);

                // 2 - a value passed as a parameter: no SQL injection possible
                IO.println("--- SQL injection attempt with a parameter");
                String name = "x' or '1'='1";
                long nb = session
                        .createNativeQuery("select count(*) from articles where name = :name", Long.class)
                        .setParameter("name", name)
                        .getSingleResult();
                IO.println(nb + " article(s) found");
                // the same query built by concatenation: never do this
                IO.println("--- the same attempt with a concatenation");
                String sql = "select count(*) from articles where name = '" + name + "'";
                nb = session.createNativeQuery(sql, Long.class).getSingleResult();
                IO.println(nb + " article(s) found");

                // 3 - results that are not entities
                IO.println("--- createNativeQuery: values");
                List<String> names = session
                        .createNativeQuery("""
                                select name from articles
                                where currentStock < minimumStock""", String.class)
                        .getResultList();
                IO.println("out of stock: " + String.join(", ", names));
                BigDecimal value = session
                        .createNativeQuery("select sum(price * currentStock) from articles", BigDecimal.class)
                        .getSingleResult();
                IO.println("stock value: %.2f euros".formatted(value));

                // 4 - an update statement
                IO.println("--- createNativeMutationQuery");
                int nbRows = session.fromTransaction(transaction -> session
                        .createNativeMutationQuery("""
                                update articles set price = price * :coefficient
                                where name = :name""")
                        .setParameter("coefficient", new BigDecimal("0.9"))
                        .setParameter("name", "bow")
                        .executeUpdate());
                IO.println(nbRows + " row(s) modified");

                // 5 - the JDBC connection of the session, for ordinary JDBC code
                IO.println("--- doWork: the JDBC connection");
                session.doWork(connection -> {
                    String query = "select name, price from articles where name = 'bow'";
                    try (Statement statement = connection.createStatement();
                         ResultSet rows = statement.executeQuery(query)) {
                        while (rows.next()) {
                            IO.println("%s : %.2f euros"
                                    .formatted(rows.getString("name"), rows.getBigDecimal("price")));
                        }
                    }
                });
            }
        }
    }
}

Results:

> mvn compile exec:java
--- createNativeQuery: entities
    SQL> select *
    SQL> from articles
    SQL> where price > ?
    SQL> order by name
    SQL> -- parameter 1 = 200
[1, bike, 500.00, 10, 5]
[3, bow, 600.00, 4, 1]
[5, diving suit, 300.00, 8, 2]
--- SQL injection attempt with a parameter
    SQL> select count(*)
    SQL> from articles
    SQL> where name = ?
    SQL> -- parameter 1 = x' or '1'='1
0 article(s) found
--- the same attempt with a concatenation
    SQL> select count(*)
    SQL> from articles
    SQL> where name = 'x' or '1'='1'
6 article(s) found
--- createNativeQuery: values
    SQL> select name
    SQL> from articles
    SQL> where currentStock < minimumStock
out of stock: arrows - set of 6
    SQL> select sum(price * currentStock)
    SQL> from articles
stock value: 12300.00 euros
--- createNativeMutationQuery
    SQL> -- start of transaction
    SQL> update articles
    SQL> set price = price * ?
    SQL> where name = ?
    SQL> -- parameter 1 = 0.9
    SQL> -- parameter 2 = bow
    SQL> -- transaction committed (COMMIT)
1 row(s) modified
--- doWork: the JDBC connection
bow : 540.00 euros

Comments:

  • Lines 22–27: [createNativeQuery] executes a query (SQL) that returns rows from an entity's table, and the second parameter ([Article.class]) instructs Hibernate to materialize them as entities managed by the session. The column names in the result must match those in the model: [select *] is appropriate here. The query is passed as-is to DBMS (lines 3–6), except that the parameter named [:minPrice] becomes a parameter JDBC ([?]);
  • lines 32–36: an attempt at a SQL injection. The value of [name] is passed as a parameter: it is compared as-is to the [name] column, and no record matches (line 16);
  • lines 40–41: the same query constructed by concatenation. The value of [name] becomes the code SQL, the condition remains true (line 20), and the query counts the six items (line 21): this is a successful SQL injection. As in Chapter 10, a native query must contain only constant text, with values passed as parameters;
  • lines 46–54: queries that return single values ([String], [BigDecimal]): the second parameter of [createNativeQuery] is then the type of the value. For multiple columns, you can specify a record (its components receive the columns in order) or use [Object[].class];
  • lines 59–65: [createNativeMutationQuery] prepares an update statement, executed by [executeUpdate], which returns the number of rows modified. It runs within a transaction: the session’s [fromTransaction] method opens it, commits it, and returns the result of the lambda;
  • lines 70–79: [doWork] provides access to the session’s JDBC connection ([java.sql.Connection]) for standard JDBC code (Chapter 10): calling a stored procedure, using a driver feature... The cost of the bow has indeed been reduced by 10% (line 40).

A native query is not portable from one DBMS to another, and Hibernate cannot verify it before executing it: it is reserved for cases where HQL is not sufficient. HQL also covers a large part of SQL (joins of all kinds, subqueries, windowing functions, [union]...), and allows you to call a function from DBMS that it does not recognize.

11.13. Bulk updates, read-only mode, and batches

Tracking changes comes at a cost: to increase the price of 100 items, you must read all 100 items, modify them one by one, and the session synchronization will send 100 [UPDATE] commands. Hibernate offers several ways to efficiently process large numbers of rows. The [bulkupdate] module introduces them:

package bulkupdate;

import articles.Article;
import articles.BaseArticles;
import java.math.BigDecimal;
import java.util.List;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;

public class Main {

    public static void main(String[] args) {
        try (SessionFactory sessionFactory = BaseArticles.createSessionFactory()) {
            // the [articles] table is recreated with its six articles
            BaseArticles.initialize(sessionFactory);
            try (Session session = sessionFactory.openSession()) {
                Transaction transaction = session.beginTransaction();

                // 1 - HQL update: a single UPDATE statement, no entity loaded into memory
                IO.println("--- restocking of the out-of-stock articles");
                int nbRows = session
                        .createMutationQuery("""
                                update Article set currentStock = minimumStock * 2
                                where currentStock < minimumStock""")
                        .executeUpdate();
                IO.println(nbRows + " article(s) modified");

                IO.println("--- 10 % increase of the prices below 150 euros");
                nbRows = session
                        .createMutationQuery("update Article set price = price * 1.1 where price < 150")
                        .executeUpdate();
                IO.println(nbRows + " article(s) modified");

                // 2 - HQL delete: a single DELETE statement
                IO.println("--- deletion of the articles over 550 euros");
                nbRows = session
                        .createMutationQuery("delete from Article where price > 550")
                        .executeUpdate();
                IO.println(nbRows + " article(s) deleted");
                transaction.commit();

                // 3 - read-only: the entities read are not watched
                IO.println("--- read-only");
                transaction = session.beginTransaction();
                List<Article> articles = session
                        .createSelectionQuery("from Article order by id", Article.class)
                        .setReadOnly(true)
                        .getResultList();
                articles.forEach(IO::println);
                // the modification of a read-only entity is ignored
                articles.getFirst().setPrice(BigDecimal.ZERO);
                IO.println("session modified: " + session.isDirty());
                transaction.commit();

                // 4 - an HQL update statement does not modify the entities already loaded by the session
                IO.println("--- HQL update and loaded entities");
                transaction = session.beginTransaction();
                Article pump = session.find(Article.class, 2);
                session.createMutationQuery("update Article set price = 9.5 where id = :id")
                        .setParameter("id", pump.getId())
                        .executeUpdate();
                IO.println("price in memory: " + pump.getPrice());
                session.refresh(pump);
                IO.println("price after refresh: " + pump.getPrice());
                transaction.commit();
            }

            // 5 - a stateless session (StatelessSession) and statements sent in batches
            IO.println("--- StatelessSession and batches of 3 statements");
            sessionFactory.inStatelessTransaction(session -> {
                session.setJdbcBatchSize(3);
                List<Article> articles = session
                        .createSelectionQuery("from Article order by id", Article.class)
                        .getResultList();
                for (Article article : articles) {
                    article.setCurrentStock(article.getCurrentStock() + 1);
                    // each modification is requested explicitly: the entities are not watched
                    session.update(article);
                }
            });
        }
    }
}

Results (the repetitive ending has been abbreviated):

> mvn compile exec:java
    SQL> -- start of transaction
--- restocking of the out-of-stock articles
    SQL> update articles a1_0
    SQL> set currentStock=(a1_0.minimumStock*2)
    SQL> where a1_0.currentStock<a1_0.minimumStock
1 article(s) modified
--- 10 % increase of the prices below 150 euros
    SQL> update articles a1_0
    SQL> set price=(a1_0.price*1.1)
    SQL> where a1_0.price<150
3 article(s) modified
--- deletion of the articles over 550 euros
    SQL> delete a1_0
    SQL> from articles a1_0
    SQL> where a1_0.price>550
1 article(s) deleted
    SQL> -- transaction committed (COMMIT)
--- read-only
    SQL> -- start of transaction
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> order by a1_0.id
[1, bike, 500.00, 10, 5]
[2, pump, 11.00, 10, 2]
[4, arrows - set of 6, 110.00, 40, 20]
[5, diving suit, 300.00, 8, 2]
[6, diver's oxygen tanks, 132.00, 10, 5]
session modified: false
    SQL> -- transaction committed (COMMIT)
--- HQL update and loaded entities
    SQL> -- start of transaction
    SQL> update articles a1_0
    SQL> set price=9.5
    SQL> where a1_0.id=?
    SQL> -- parameter 1 = 2
price in memory: 11.00
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> where a1_0.id=?
    SQL> -- parameter 1 = 2
price after refresh: 9.50
    SQL> -- transaction committed (COMMIT)
--- StatelessSession and batches of 3 statements
    SQL> -- start of transaction
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> order by a1_0.id
    SQL> update articles
    SQL> set currentStock=?, minimumStock=?, name=?, price=?
    SQL> where id=?
    SQL> -- parameter 1 = 11
    SQL> -- parameter 2 = 5
    SQL> -- parameter 3 = bike
    SQL> -- parameter 4 = 500.00
    SQL> -- parameter 5 = 1
...
    SQL> -- execution of a batch of 3 statement(s)
    SQL> update articles
    SQL> set currentStock=?, minimumStock=?, name=?, price=?
    SQL> where id=?
...
    SQL> -- execution of a batch of 2 statement(s)
    SQL> -- transaction committed (COMMIT)

Comments:

  • Lines 22–26: A [update] command written as HQL, prepared by [createMutationQuery], and executed by [executeUpdate]. It becomes a single command SQL [UPDATE] (lines 4–6), executed by DBMS on all affected lines, without any entity being loaded into memory. The new value may depend on the line itself ([minimumStock * 2]). Such a command is executed within a transaction (line 18);
  • lines 30–32: a 10% increase in low prices. The constants for the query HQL ([1.1], [150]) are written directly into the text of the command SQL (lines 10–11);
  • lines 37–39: a [delete] HQL order removes the bow, the only item priced over 550 euros (lines 14–16);
  • Lines 46–49: a read-only read ([setReadOnly(true)]). The entities being read are managed by the session, but the session does not retain their original state or monitor them: this is faster and more memory-efficient for data that is read without being modified (display, calculations). The trade-off is that any modification to such an entity is ignored (line 52): the session has nothing to synchronize (line 29), and the [commit] command sends no instructions. It is also possible to set an entire session to read-only ([session.setDefaultReadOnly(true)]);
  • lines 59–65: a pitfall. A [update] HQL command operates directly on the database: it does not modify entities already loaded by the session. The pump managed by the session retains its old price (line 37) until it is read again with [session.refresh(pump)] (lines 38–42);
  • lines 71–81: a stateless session ([StatelessSession]), opened here with a transaction via [inStatelessTransaction]. It has no persistence context: no first-level cache, no change tracking, no lazy loading, no cascades. Each operation is explicitly requested ([insert], [update], [delete], [get]) and executed as is. This is the tool for bulk processing: importing a large file, recalculating all rows in a table;
  • line 72: the SQL commands will be sent to DBMS in batches (see Chapter 10) of three: the JDBC driver transmits three commands in a single exchange with the server. The log shows the first three [UPDATE] entries, followed by the execution of the batch (line 58), and concludes with the final batch of two commands (line 63). An application sets the batch size for all its sessions using the [hibernate.jdbc.batch_size] property (typically between 10 and 50). Note that inserts into a table whose key is generated by [IDENTITY] cannot be grouped into batches.

For bulk processing with a regular session, you must prevent its persistence context from accumulating entities: synchronize and flush the session regularly, for example every fifty records ([session.flush(); session.clear();]).

11.14. Hibernate and Dependency Injection: Spring

11.14.1. The [SessionFactory] in the container

The previous examples created their own sessions. In a layered application (Chapter 6), and in all Spring applications, the [SessionFactory] is an object in the dependency injection container, just like the layers that use it. To achieve this, the Spring Framework provides the following in its [spring-orm] library:

  • the [LocalSessionFactoryBean] class, which constructs the [SessionFactory] from a data source ([DataSource], Chapter 10) and a list of entities;
  • the [HibernateTransactionManager] class, a transaction manager that opens a Hibernate session for each transaction, associates it with the current thread, and closes it at the end of the transaction;
  • the [@Transactional] annotation (library [spring-tx]): when applied to a method, or to a class for all its public methods, it instructs Spring to execute the method within a transaction, which is committed if the method completes normally and rolled back if it throws an uncaught exception. These are known as declarative transactions.

Inside a transactional method, [sessionFactory.getCurrentSession()] returns the current transaction session. A [dao] layer can then be a singleton that receives the [SessionFactory] (thread-safe) and requests the current session from it in each method: the rule “one session per unit of work, never shared between threads” is effortlessly enforced.

11.14.2. The [injection] module

The [injection] module depends, in addition to the [articles] module, on Spring and HikariCP:

    <dependencies>
        <!-- the [Article] entity and the [hbarticles] database (Hibernate, MySQL and the SQL log included) -->
        <dependency>
            <groupId>examples.hibernate</groupId>
            <artifactId>articles</artifactId>
        </dependency>
        <!-- Spring: container, declarative transactions, Hibernate integration -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-orm</artifactId>
        </dependency>
        <!-- the HikariCP connection pool -->
        <dependency>
            <groupId>com.zaxxer</groupId>
            <artifactId>HikariCP</artifactId>
        </dependency>
    </dependencies>

The Spring configuration is written in Java, using the annotations [@Configuration] and [@Bean] from Chapter 6:

package injection;

import articles.Article;
import articles.BaseArticles;
import com.zaxxer.hikari.HikariDataSource;
import java.util.Properties;
import javax.sql.DataSource;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.AvailableSettings;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.orm.jpa.hibernate.HibernateTransactionManager;
import org.springframework.orm.jpa.hibernate.LocalSessionFactoryBean;
import org.springframework.transaction.annotation.EnableTransactionManagement;

// the Spring configuration: connection pool, SessionFactory, transaction manager, [dao] layer
@Configuration
@EnableTransactionManagement
public class Config {

    // the HikariCP connection pool (chapter 10)
    @Bean(destroyMethod = "close")
    public HikariDataSource dataSource() {
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl(BaseArticles.URL);
        dataSource.setUsername(BaseArticles.USER);
        dataSource.setPassword(BaseArticles.PASSWORD);
        return dataSource;
    }

    // the SessionFactory, built by Spring with the connection pool
    @Bean
    public LocalSessionFactoryBean sessionFactory(DataSource dataSource) {
        LocalSessionFactoryBean factory = new LocalSessionFactoryBean();
        factory.setDataSource(dataSource);
        factory.setAnnotatedClasses(Article.class);
        Properties properties = new Properties();
        properties.put(AvailableSettings.FORMAT_SQL, "true");
        factory.setHibernateProperties(properties);
        return factory;
    }

    // the transaction manager: one Hibernate session per transaction
    @Bean
    public HibernateTransactionManager transactionManager(SessionFactory sessionFactory) {
        return new HibernateTransactionManager(sessionFactory);
    }

    // the [dao] layer: a singleton that receives the SessionFactory
    @Bean
    public ArticlesDao articlesDao(SessionFactory sessionFactory) {
        return new ArticlesDao(sessionFactory);
    }
}
  • Line 18: [@EnableTransactionManagement] enables the [@Transactional] annotation: Spring will replace every object with a method bearing this annotation with a proxy that opens and commits the transaction around the call;
  • lines 22–29: the HikariCP connection pool from Chapter 10. The [destroyMethod] attribute specifies the method to be called when the container is closed;
  • Lines 32–41: the [SessionFactory]. A [LocalSessionFactoryBean] is a factory for [SessionFactory]: Spring registers the object it creates in the container, and requesting this bean returns a [SessionFactory]. It receives the connection pool (which replaces Hibernate’s built-in pool), the entities, and the Hibernate properties;
  • lines 44–47: the transaction manager;
  • lines 50–53: the [dao] layer, which receives the [SessionFactory].

The [dao] layer:

package injection;

import articles.Article;
import java.util.List;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.springframework.transaction.annotation.Transactional;

// the [dao] layer: each public method runs in a transaction opened by Spring
@Transactional
public class ArticlesDao {

    // the SessionFactory, shared by all the threads
    private final SessionFactory sessionFactory;

    public ArticlesDao(SessionFactory sessionFactory) {
        this.sessionFactory = sessionFactory;
    }

    // do two requests for the current session, in the same transaction, return the same session?
    public boolean sameSession() {
        return sessionFactory.getCurrentSession() == sessionFactory.getCurrentSession();
    }

    // the session of the transaction
    public Session session() {
        return sessionFactory.getCurrentSession();
    }

    // the articles to restock; a read-only transaction
    @Transactional(readOnly = true)
    public List<String> articlesOutOfStock() {
        String hql = "select name from Article where currentStock < minimumStock";
        return sessionFactory.getCurrentSession().createSelectionQuery(hql, String.class).getResultList();
    }

    // the article with the given name
    @Transactional(readOnly = true)
    public Article article(String name) {
        return sessionFactory.getCurrentSession()
                .createSelectionQuery("from Article where name = :name", Article.class)
                .setParameter("name", name)
                .getSingleResult();
    }

    // the sale of a quantity of an article: an exception rolls back the transaction
    public void sell(String name, int quantity) {
        Article article = article(name);
        article.setCurrentStock(article.getCurrentStock() - quantity);
        if (article.getCurrentStock() < 0) {
            throw new IllegalStateException("insufficient stock for the article [" + name + "]");
        }
    }
}
  • line 10: all public methods of the class are transactional;
  • lines 21–23: During the same transaction, do two requests from the current session return the same session?
  • lines 26–28: the transaction’s session is returned to the caller (for the following experiment—this is not done in a real application);
  • lines 31–35: a read-only transaction ([readOnly = true]): Spring sets the session to read-only and does not synchronize it;
  • Lines 47–53: The sale of a quantity of an item. If the inventory becomes negative, the method throws a [IllegalStateException], an unhandled exception: Spring rolls back the transaction. The call to the [article] method (line 48) is an internal call within the object that does not go through the proxy: it executes within the [sell] transaction.

The main program:

package injection;

import articles.BaseArticles;
import org.hibernate.HibernateException;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class Main {

    public static void main(String[] args) {
        // the Spring container, configured by the [Config] class
        try (var context = new AnnotationConfigApplicationContext(Config.class)) {
            // the [articles] table is recreated with its six articles
            SessionFactory sessionFactory = context.getBean(SessionFactory.class);
            BaseArticles.initialize(sessionFactory);
            ArticlesDao dao = context.getBean(ArticlesDao.class);

            // 1 - during a transaction, the current session is always the same
            IO.println("--- sessions and transactions");
            IO.println("one transaction: same session = " + dao.sameSession());
            // 2 - two transactions: two sessions, closed at the end of their transaction
            Session session1 = dao.session();
            Session session2 = dao.session();
            IO.println("two transactions: same session = %s, first session open = %s"
                    .formatted(session1 == session2, session1.isOpen()));
            // 3 - outside a transaction, there is no current session
            try {
                sessionFactory.getCurrentSession();
            } catch (HibernateException e) {
                IO.println(e.getClass().getSimpleName() + " : " + e.getMessage());
            }

            // 4 - transactional methods of the [dao] layer
            IO.println("--- articles out of stock");
            IO.println("out of stock: " + dao.articlesOutOfStock());
            IO.println("--- sale of 2 bikes");
            dao.sell("bike", 2);
            IO.println("--- sale of 50 pumps");
            try {
                dao.sell("pump", 50);
            } catch (IllegalStateException e) {
                IO.println(e.getClass().getSimpleName() + " : " + e.getMessage());
            }
            IO.println("--- state of the database");
            IO.println(dao.article("bike"));
            IO.println(dao.article("pump"));
        }
    }
}

Results:

> mvn compile exec:java
--- sessions and transactions
    SQL> -- start of transaction
    SQL> -- transaction committed (COMMIT)
one transaction: same session = true
    SQL> -- start of transaction
    SQL> -- transaction committed (COMMIT)
    SQL> -- start of transaction
    SQL> -- transaction committed (COMMIT)
two transactions: same session = false, first session open = false
HibernateException : Could not obtain transaction-synchronized Session for current thread
--- articles out of stock
    SQL> -- start of transaction
    SQL> select a1_0.name
    SQL> from articles a1_0
    SQL> where a1_0.currentStock<a1_0.minimumStock
    SQL> -- transaction committed (COMMIT)
out of stock: [arrows - set of 6]
--- sale of 2 bikes
    SQL> -- start of transaction
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> where a1_0.name=?
    SQL> -- parameter 1 = bike
    SQL> update articles
    SQL> set currentStock=?, minimumStock=?, name=?, price=?
    SQL> where id=?
    SQL> -- parameter 1 = 8
    SQL> -- parameter 2 = 5
    SQL> -- parameter 3 = bike
    SQL> -- parameter 4 = 500.00
    SQL> -- parameter 5 = 1
    SQL> -- transaction committed (COMMIT)
--- sale of 50 pumps
    SQL> -- start of transaction
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> where a1_0.name=?
    SQL> -- parameter 1 = pump
    SQL> -- transaction rolled back (ROLLBACK)
IllegalStateException : insufficient stock for the article [pump]
--- state of the database
    SQL> -- start of transaction
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> where a1_0.name=?
    SQL> -- parameter 1 = bike
    SQL> -- transaction committed (COMMIT)
[1, bike, 500.00, 8, 5]
    SQL> -- start of transaction
    SQL> select a1_0.id, a1_0.currentStock, a1_0.minimumStock, a1_0.name, a1_0.price
    SQL> from articles a1_0
    SQL> where a1_0.name=?
    SQL> -- parameter 1 = pump
    SQL> -- transaction committed (COMMIT)
[2, pump, 10.00, 10, 2]

Comments:

  • Line 13: the Spring container, configured by the [Config] class. It is closed at the end of the [try] block, which closes the [SessionFactory] and the connection pool;
  • line 15: the [SessionFactory] created by Spring; it is used to recreate the product table;
  • line 21: a transaction (lines 3–4): within the same transaction, the current session remains the same (line 5);
  • lines 23–26: two calls, two transactions (lines 6–9), two different sessions; the first is closed, since its transaction is complete (line 10);
  • lines 28–32: outside a transaction, there is no current session: [getCurrentSession] launches a [HibernateException] (line 11). This is the classic error caused by a method that omitted the annotation [@Transactional];
  • line 36: a read-only transaction (lines 13–17);
  • line 38: the sale of two bicycles. The transaction is committed, and session synchronization sends the [UPDATE] (lines 20–33);
  • lines 40–44: the sale of fifty pumps triggers an exception: Spring rolls back the transaction (line 40), and [UPDATE] is never sent, since the session was not synchronized;
  • lines 46–47: database state: the bicycle inventory has changed to 8, while the pump inventory remains at 10 (lines 49 and 56).

In a Spring Boot application, all of this happens automatically: Spring Boot creates the connection pool, the factory (in its Jakarta Persistence form, a [EntityManagerFactory]), and the transaction manager from the [application.properties] file. Spring Data JPA is often used on top of Hibernate; it generates the [dao] layer from a simple interface: [interface ArticleRepository extends JpaRepository<Article, Integer>] provides [findAll], [findById], [save], [delete]..., and a declared method [List<Article> findByPriceLessThan(BigDecimal price)] is automatically translated into a query.

11.15. Sample Application - Version 7

11.15.1. The Architecture

Version 6 of the tax calculation application (Chapter 10) read the tax brackets from a MySQL database using JDBC. Version 7 does the same using Hibernate. As with version 6, only the [dao] layer changes: the [business] and [ui] layers from version 5 (Chapter 6) are reused as-is via Maven dependencies.

Image

The Maven project [TaxV7], located in the [11-hibernate] folder, consists of two modules:

TaxV7/
  pom.xml                           the parent project
  tax-dao-hibernate/                library: the Hibernate [dao] layer
    pom.xml
    src/main/java/tax/dao/
      Bracket.java                  the entity
      HibernateTax.java             the implementation of ITaxDao
      CreateDatabase.java           the creation of the database [hbtaxes]
    src/main/resources/
      logback.xml
  tax-ui-hibernate/                 executable: integration of the layers
    pom.xml
    src/main/java/tax/ui/
      MainHibernate.java
    tax-hibernate.properties
    tax-hibernate-error.properties

The parent project ([examples.tax:tax-v7:7.0]) is structured like the one for version 5: it declares both modules and specifies the library versions, including those for the version 5 modules ([tax-dao], [tax-business], [tax-ui], version 5.0), which are installed in the local Maven repository using the command [mvn install] entered in the [06-architectures/TaxV5] folder.

11.15.2. The [dao] layer

The [tax-dao-hibernate] module depends on the [dao] layer from version 5, which contains the application entities ([TaxBracket], [TaxException]) and the [ITaxDao] interface:

    <dependencies>
        <!-- the [dao] layer of version 5: entities, ITaxDao interface, HardwiredTax -->
        <dependency>
            <groupId>examples.tax</groupId>
            <artifactId>tax-dao</artifactId>
        </dependency>
        <!-- Hibernate, the MySQL JDBC driver and Logback -->
        <dependency>
            <groupId>org.hibernate.orm</groupId>
            <artifactId>hibernate-core</artifactId>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!-- mvn exec:java creates the [hbtaxes] database -->
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <configuration>
                    <mainClass>tax.dao.CreateDatabase</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
  • lines 3–6: the [dao] layer of version 5;
  • lines 8–19: Hibernate, the JDBC driver for MySQL, and Logback;
  • lines 25–31: the class executed by [mvn exec:java] in the module’s directory: it creates the database, as we will see.

The [TaxBracket] record in version 5 cannot be a Hibernate entity: it is a record, read-only, and has no identifier. It is not modified because it is part of the contract between the layers, shared by all versions. We create an entity specific to the [dao] layer, whose role is to represent a row in the table:

// a tax bracket as it is stored in the database: a Hibernate entity
@Entity
@Table(name = "brackets")
public class Bracket {

    // the primary key, which does not exist in the TaxBracket record
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column(name = "limits", nullable = false, precision = 10, scale = 2)
    private BigDecimal limit;

    @Column(name = "coeffr", nullable = false, precision = 6, scale = 4)
    private BigDecimal coeffR;

    @Column(name = "coeffn", nullable = false, precision = 10, scale = 2)
    private BigDecimal coeffN;

    protected Bracket() {
    }

    public Bracket(BigDecimal limit, BigDecimal coeffR, BigDecimal coeffN) {
        this.limit = limit;
        this.coeffR = coeffR;
        this.coeffN = coeffN;
    }
}
  • row 3: the table [brackets];
  • rows 7–9: the primary key, which is also used to sort the segments;
  • rows 11–18: the three columns, with appropriate [decimal] data types: the limit and the coefficient ([coeffN]) are amounts with two decimal places, and [coeffR] is a rate with four decimal places. The column names are in lowercase;
  • Lines 23–27: a constructor, which will be used to populate the table. The entity has no accessors: the rest of the application never sees it.

The class [HibernateTax] implements the interface [ITaxDao]:

package tax.dao;

import tax.entities.TaxException;
import tax.entities.TaxBracket;
import jakarta.persistence.PersistenceException;
import java.util.List;
import org.hibernate.SessionFactory;
import org.hibernate.jpa.HibernatePersistenceConfiguration;

// a [dao] layer that reads the tax brackets from a database with Hibernate
public class HibernateTax implements ITaxDao {

    // error codes specific to this implementation
    public static final int ACCESS_ERROR = 1;
    public static final int EMPTY_TABLE = 2;

    // tax brackets
    private final List<TaxBracket> taxBrackets;

    // constructor: the database is designated by its JDBC URL
    public HibernateTax(String url, String user, String password) {
        // a short-lived SessionFactory, just long enough to read the table
        try (SessionFactory sessionFactory = new HibernatePersistenceConfiguration("tax")
                .managedClass(Bracket.class)
                .jdbcUrl(url)
                .jdbcCredentials(user, password)
                .createEntityManagerFactory()) {
            // the rows of the table are projected to the TaxBracket record of the application
            String hql = "select b.limit, b.coeffR, b.coeffN from Bracket b order by b.id";
            taxBrackets = sessionFactory.fromSession(session -> session
                    .createSelectionQuery(hql, TaxBracket.class)
                    .getResultList());
        } catch (PersistenceException e) {
            // Hibernate or DBMS error: it is wrapped in a TaxException
            throw new TaxException("Error while reading the tax brackets from the database",
                    e, ACCESS_ERROR);
        }
        // the [business] layer needs at least one bracket
        if (taxBrackets.isEmpty()) {
            throw new TaxException("The tax brackets table is empty", EMPTY_TABLE);
        }
    }

    @Override
    public List<TaxBracket> getTaxBrackets() {
        return taxBrackets;
    }
}
  • lines 14–15: the error codes for this implementation, as with [FileTax] and [JsonTax];
  • line 21: the [dao] layer receives the URL from the database, along with the user and their password. It reads the segments once and for all in its constructor; it will be a singleton;
  • lines 23–27: a short-lived [SessionFactory], lasting only long enough to read the table, which is closed immediately. Since the layer accesses the database only once, there is no need to keep it; a [dao] layer that accessed the database on every call would instead receive a [SessionFactory] created once and for all, as in the previous example;
  • lines 29–32: reading the table in primary key order. The query is a projection onto the record [TaxBracket]: Hibernate requests only the three relevant columns from the DBMS and creates the application objects directly, without going through [Bracket] entities;
  • Lines 33–37: An error from Hibernate or the DBMS (database inaccessible, table missing, etc.) manifests as a [PersistenceException] (the base class for Jakarta Persistence exceptions, from which Hibernate’s exceptions are derived). As in other implementations, it is encapsulated within a [TaxException]. The exception can occur as early as the creation of the [SessionFactory] (line 27), which connects to the server: the [catch] clause of a [try] statement with resources also intercepts exceptions thrown during resource creation;
  • lines 39–41: an empty table is also an error: the [business] layer requires at least one segment.

11.15.3. The database

The database [hbtaxes] is created by the class [CreateDatabase]. To avoid rewriting the scale segments, it reuses those from the class [HardwiredTax] in version 5:

package tax.dao;

import tax.entities.TaxBracket;
import org.hibernate.SessionFactory;
import org.hibernate.jpa.HibernatePersistenceConfiguration;
import org.hibernate.tool.schema.Action;

// creation of the [hbtaxes] database: the [brackets] table, filled with the brackets of the HardwiredTax class
public class CreateDatabase {

    public static void main(String[] args) {
        try (SessionFactory sessionFactory = new HibernatePersistenceConfiguration("tax")
                .managedClass(Bracket.class)
                .jdbcUrl("jdbc:mysql://localhost:3306/hbtaxes?createDatabaseIfNotExist=true")
                .jdbcCredentials("root", "")
                // display of the SQL statements, without formatting
                .showSql(true, false, false)
                // the table is dropped then recreated when the SessionFactory is created
                .schemaToolingAction(Action.CREATE)
                .createEntityManagerFactory()) {
            // the brackets of the tax scale, those of version 5, numbered from 1 by the DBMS
            sessionFactory.inTransaction(session -> {
                for (TaxBracket bracket : new HardwiredTax().getTaxBrackets()) {
                    session.persist(new Bracket(bracket.limit(), bracket.coeffR(), bracket.coeffN()));
                }
            });
        }
    }
}
  • Line 14: The database [hbtaxes], created by MySQL if it does not exist;
  • Line 17: Displays the SQL orders, without formatting;
  • line 19: the table is deleted and then recreated when [SessionFactory] is created;
  • lines 22–26: one [Bracket] entity per bracket of the [HardwiredTax] class, persisted within a transaction.

The module also contains a file named [logback.xml], used by [CreateDatabase] and by the application, which only allows errors to pass through:

<?xml version="1.0" encoding="UTF-8"?>
<!-- Logback configuration: only the Hibernate errors are displayed -->
<configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%-5level %logger{0} - %msg%n</pattern>
        </encoder>
    </appender>
    <!-- the DBMS errors are passed to the application by exceptions: they are not logged -->
    <logger name="org.hibernate.orm.jdbc.error" level="OFF"/>
    <root level="ERROR">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>
  • line 10: Errors from the DBMS, which Hibernate logs before throwing an exception, are not displayed: the application reports them itself;
  • Line 11: The other logs record only errors.

Execution:

> mvn compile exec:java
Hibernate: drop table if exists brackets
Hibernate: create table brackets (coeffn decimal(10,2) not null, coeffr decimal(6,4) not null, id integer not null auto_increment, limits decimal(10,2) not null, primary key (id)) engine=InnoDB
Hibernate: insert into brackets (coeffn,coeffr,limits) values (?,?,?)
Hibernate: insert into brackets (coeffn,coeffr,limits) values (?,?,?)
Hibernate: insert into brackets (coeffn,coeffr,limits) values (?,?,?)
Hibernate: insert into brackets (coeffn,coeffr,limits) values (?,?,?)
Hibernate: insert into brackets (coeffn,coeffr,limits) values (?,?,?)
Hibernate: insert into brackets (coeffn,coeffr,limits) values (?,?,?)
Hibernate: insert into brackets (coeffn,coeffr,limits) values (?,?,?)

We check the table contents:

> mysql -uroot -t hbtaxes -e "SELECT id, limits, coeffr, coeffn FROM brackets"
+----+----------+--------+---------+
| id | limits   | coeffr | coeffn  |
+----+----------+--------+---------+
|  1 |  4962.00 | 0.0000 |    0.00 |
|  2 |  8382.00 | 0.0680 |  291.09 |
|  3 | 14753.00 | 0.1910 | 1322.92 |
|  4 | 23888.00 | 0.2830 | 2668.39 |
|  5 | 38868.00 | 0.3740 | 4846.98 |
|  6 | 47932.00 | 0.4260 | 6883.66 |
|  7 |     0.00 | 0.4810 | 9505.54 |
+----+----------+--------+---------+

We find the pay scale brackets, with the last one having a zero limit (it is not used by layer [business], which takes the last bracket when no other applies).

To create the database on your own system:

  • start MySQL (in Laragon, see the appendix; user [root] with no password);
  • if you haven’t already, install the version 5 modules in the local Maven repository: [mvn install] in the [06-architectures/TaxV5] folder;
  • Build version 7: [mvn install] in the [11-hibernate/TaxV7] folder;
  • Navigate to the [11-hibernate/TaxV7/tax-dao-hibernate] folder and enter the command [mvn exec:java].

The database [hbtaxes] is then created, with its table [brackets] populated. If the command fails, verify that MySQL is running and that the URL in the [CreateDatabase] class corresponds to your server (port 3306, username [root], empty password). Alternatively, if you prefer, you can create the table and its rows using a SQL script, as described in Chapter 10.

11.15.4. Integrating the Layers

The [tax-ui-hibernate] module combines the following layers:

    <dependencies>
        <!-- the [business] and [ui] layers of version 5, unchanged -->
        <dependency>
            <groupId>examples.tax</groupId>
            <artifactId>tax-business</artifactId>
        </dependency>
        <dependency>
            <groupId>examples.tax</groupId>
            <artifactId>tax-ui</artifactId>
        </dependency>
        <!-- the new [dao] layer -->
        <dependency>
            <groupId>examples.tax</groupId>
            <artifactId>tax-dao-hibernate</artifactId>
        </dependency>
        <!-- the Spring dependency injection container -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!-- mvn exec:java executes the main class -->
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <configuration>
                    <mainClass>tax.ui.MainHibernate</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
  • lines 3–10: the layers [business] and [ui] from version 5. The [tax-ui] module contains the main program from version 5; it is used here as a library, specifically for its [Dialog] class;
  • lines 12–15: the new layer [dao], which includes Hibernate, the JDBC driver, and Logback;
  • lines 17–20: the Spring container;
  • line 30: the main class.

The configuration file [tax-hibernate.properties], located in the module folder, specifies the database:

1
2
3
4
# configuration of the [Tax] application, version 7: the database of the tax brackets
jdbc.url=jdbc:mysql://localhost:3306/hbtaxes
jdbc.user=root
jdbc.password=

The file [tax-hibernate-error.properties] specifies a database named [hbtaxesmissing], which does not exist:

1
2
3
4
# a database that does not exist
jdbc.url=jdbc:mysql://localhost:3306/hbtaxesmissing
jdbc.user=root
jdbc.password=

The main program is the one from version 5, in which only the composition of the [dao] layer changes:

package tax.ui;

import tax.dao.HibernateTax;
import tax.dao.ITaxDao;
import tax.entities.TaxException;
import tax.business.ITaxBusiness;
import tax.business.TaxBusiness;
import java.io.IOException;
import java.io.Reader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
import org.springframework.context.support.GenericApplicationContext;

public class MainHibernate {

    public static void main(String[] args) {
        // the configuration file: tax-hibernate.properties by default, or the one passed as an argument
        String configFile = args.length > 0 ? args[0] : "tax-hibernate.properties";

        // integration of the layers
        ITaxBusiness business;
        try {
            // reading the configuration, in the current folder
            Properties config = new Properties();
            try (Reader reader = Files.newBufferedReader(Path.of(configFile))) {
                config.load(reader);
            }
            String url = config.getProperty("jdbc.url");
            if (url == null) {
                throw new TaxException("Database URL [jdbc.url] missing from the file " + configFile);
            }
            String user = config.getProperty("jdbc.user", "root");
            String password = config.getProperty("jdbc.password", "");

            // the Spring container: the layers are registered in it
            GenericApplicationContext context = new GenericApplicationContext();
            // the [dao] layer: HibernateTax, with the database of the configuration
            context.registerBean(ITaxDao.class, () -> new HibernateTax(url, user, password));
            // the [business] layer of version 5: the container will pass it the [dao] layer
            context.registerBean(TaxBusiness.class);

            // starting the container: it creates the [dao] layer, then the [business] layer
            context.refresh();
            business = context.getBean(ITaxBusiness.class);
        } catch (IOException | RuntimeException e) {
            // display of the error and end of the program
            Dialog.displayError(e);
            System.exit(1);
            return;
        }

        // the dialog with the user, that of version 5
        Dialog.run(business);
    }
}
  • line 15: the class is not named [Main]: the [tax-ui] module from version 5, on which we depend, already contains a class named [tax.ui.Main];
  • Lines 29–34: the database URL, required, the username and password, read from the configuration;
  • line 39: the [dao] layer is a [HibernateTax], created by a lambda that passes it the database parameters;
  • Line 41: The [business] layer of version 5: The container will pass the [dao] layer to its constructor;
  • Line 54: the dialog box for version 5.

11.15.5. Executions

1
2
3
4
5
6
7
8
> mvn compile exec:java
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 2 60000
Tax=4282 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 3 60000
Tax=2959 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: n 0 60000
Tax=11519 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop:

The threshold values from previous versions are retained: 4,282 euros for a married couple with two children and a combined income of 60,000 euros; 2,959 euros for a family with three children; and 11,519 euros for a single person without children.

With the configuration file [tax-hibernate-error.properties], which refers to a nonexistent database:

> mvn compile exec:java -Dexec.args="tax-hibernate-error.properties"
Exception chain:
----------------------------------------
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'tax.dao.ITaxDao': Instantiation of supplied bean failed
----------------------------------------
tax.entities.TaxException: Error while reading the tax brackets from the database
----------------------------------------
org.hibernate.service.spi.ServiceException: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Error calling Driver.connect() [Unknown database 'hbtaxesmissing']
----------------------------------------
org.hibernate.exception.SQLGrammarException: Error calling Driver.connect() [Unknown database 'hbtaxesmissing']
----------------------------------------
java.sql.SQLSyntaxErrorException: Unknown database 'hbtaxesmissing'
----------------------------------------
  • line 12: the original exception, thrown by the JDBC driver;
  • lines 8 and 10: the Hibernate exceptions that encapsulate it: the error occurred during the creation of [SessionFactory], when Hibernate connected to the server to determine its version;
  • line 6: the [dao] layer wrapped the error in a [TaxException];
  • Line 4: The Spring container, which was creating the [dao] layer, itself encapsulated the exception within a [BeanCreationException], which was displayed by the main program.

Changing the DBMS is equivalent to changing the base URL and the dependency on the JDBC driver: Hibernate automatically selects the SQL dialect from the DBMS encountered, and the code for the [HibernateTax] class, written in HQL, remains unchanged. Only the database creation—which depends on the column types—needs to be redone, which the [CreateDatabase] class does without any changes other than to its URL.

11.16. Conclusion: JDBC, JdbcClient, jOOQ, or Hibernate?

We now have two ways to access a database: JDBC (Chapter 10) and Hibernate (this chapter). There are other, intermediate approaches that are widely used:

  • JdbcClient, from the Spring Framework (library [spring-jdbc]), adds a flexible API to JDBC that executes a hand-written SQL query and automatically transforms the result rows into objects: [jdbcClient.sql("select * from articles where price > :price").param("price", 200).query(Article.class).list()]. You write the SQL; Spring handles the parameters and copies the columns into the objects (including records). It is a “micro-ORM”;
  • jOOQ generates, based on the database schema, classes that allow you to write a SQL in Java that is verified by the compiler: [dsl.selectFrom(ARTICLES).where(ARTICLES.PRICE.gt(price)).fetch()]. You retain full control over the SQL, with the safety of type checking.
 
Strengths
Weaknesses
JDBC
full control over SQL; performance; no dependencies
a lot of repetitive code (parameters, reading columns); SQL in strings, not checked by the compiler
JdbcClient
SQL hand-written, without copy-and-paste code; performance similar to JDBC
No change tracking or generation of [INSERT]/[UPDATE]; SQL not verified
jOOQ
SQL verified by the compiler; all features of the DBMS
Code generation with every schema change; no change tracking; paid license required for certain commercial DBMS versions
Hibernate
object-oriented model; change tracking; relationships; HQL and API Criteria; Jakarta Persistence standard; independent of the DBMS
an additional layer to understand (sessions, states, lazy loading); generated SQL to monitor; less suitable for complex bulk processing

The following guidelines can be suggested:

  • Hibernate (often via Spring Data JPA) is suitable for most business applications: a model of interconnected entities, operations for reading and updating entities, and a schema that evolves with the application. It is the default choice for Spring Boot applications;
  • JDBC, JdbcClient or jOOQ are suitable when you want precise control over the SQL code being executed: complex statistical queries, stored procedures, processing very large volumes, or an existing database with a schema that is poorly suited to an object-oriented model;
  • These approaches can be combined: an application can use Hibernate for the most part and hand-written SQL code, in native queries, with [JdbcClient] or with JDBC ([doWork]), for a few critical queries.

In any case, ORM does not eliminate the need to understand SQL and how the DBMS works: the log of executed SQL commands, which we have displayed throughout this chapter, is the primary tool for verifying what Hibernate is actually doing.

To learn more, the Hibernate documentation is comprehensive, and its introductory guide is the best place to start: Hibernate ORM - documentation. The Jakarta Persistence specification is published by the Eclipse Foundation: Jakarta Persistence.